deepseek-harness/scripts/ts-project.ts

129 lines
4.6 KiB
TypeScript
Raw Normal View History

/**
* Shared TypeScript Program construction for repository gates that need real
* cross-file symbols and types instead of isolated syntax trees.
*/
import { relative, resolve } from 'node:path'
import ts from 'typescript'
interface ProjectGraph {
rootNames: string[]
options: ts.CompilerOptions
}
feat(release): reject a module-scope load of an optional dependency A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta.<name>.optional, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. Nothing checked it, and nothing here could: the failure needs an installed tree missing that package, and a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. verify-optional-dependency-imports reads each package's own manifest for what it allows to be absent, then scans the files that ship across both compiler faces. Value-versus-type is decided against a bound Program rather than the import syntax, because verbatimModuleSyntax is off: the compiler already erases an import whose bindings resolve to types, so a syntactic rule would report four forms that emit nothing. Only the type phase erases an import — `import defer` still resolves and links its module, deferring evaluation alone — which is what phaseModifier expresses and the deprecated isTypeOnly cannot. A violation names the package, the declaration that made it optional, and the way out in order: import it as a type, or restructure so module scope does not need it. A dynamic import() only moves the failure to first use, so the gate does not offer it as the remedy. The gate runs in ci-static and ci-primary through ciSharedStaticGates and locally in hygiene; it needs no build. TypeScriptProject gained a face parameter so a repository-wide gate can seed the client aggregate, which was previously unreachable; the constraint it was built with is unchanged, a face config and never the root solution. The tree has no violation today, so this guards the rule rather than fixing a defect. The spec pins all seven import forms against what tsc emits, including the four a syntactic rule would misreport.
2026-08-14 15:08:01 +08:00
/**
* A compiler face: the two aggregates a repository-wide program may seed from.
* The root solution is never one of them.
*/
export type CompilerFace = 'host' | 'client'
/** TypeScript config host shared by repository scripts. */
export const repositoryConfigHost: ts.ParseConfigFileHost = {
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
2026-07-14 23:44:48 +08:00
readDirectory: (...args) => ts.sys.readDirectory(...args),
fileExists: fileName => ts.sys.fileExists(fileName),
readFile: fileName => ts.sys.readFile(fileName),
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
}
/**
feat(release): reject a module-scope load of an optional dependency A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta.<name>.optional, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. Nothing checked it, and nothing here could: the failure needs an installed tree missing that package, and a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. verify-optional-dependency-imports reads each package's own manifest for what it allows to be absent, then scans the files that ship across both compiler faces. Value-versus-type is decided against a bound Program rather than the import syntax, because verbatimModuleSyntax is off: the compiler already erases an import whose bindings resolve to types, so a syntactic rule would report four forms that emit nothing. Only the type phase erases an import — `import defer` still resolves and links its module, deferring evaluation alone — which is what phaseModifier expresses and the deprecated isTypeOnly cannot. A violation names the package, the declaration that made it optional, and the way out in order: import it as a type, or restructure so module scope does not need it. A dynamic import() only moves the failure to first use, so the gate does not offer it as the remedy. The gate runs in ci-static and ci-primary through ciSharedStaticGates and locally in hygiene; it needs no build. TypeScriptProject gained a face parameter so a repository-wide gate can seed the client aggregate, which was previously unreachable; the constraint it was built with is unchanged, a face config and never the root solution. The tree has no violation today, so this guards the rule rather than fixing a defect. The spec pins all seven import forms against what tsc emits, including the four a syntactic rule would misreport.
2026-08-14 15:08:01 +08:00
* Parse one face aggregate tsconfig and flatten all referenced projects into one
* semantic graph. Never seed the root solution: flattening host+client into one
* program collides the cordis Context merges.
*/
feat(release): reject a module-scope load of an optional dependency A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta.<name>.optional, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. Nothing checked it, and nothing here could: the failure needs an installed tree missing that package, and a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. verify-optional-dependency-imports reads each package's own manifest for what it allows to be absent, then scans the files that ship across both compiler faces. Value-versus-type is decided against a bound Program rather than the import syntax, because verbatimModuleSyntax is off: the compiler already erases an import whose bindings resolve to types, so a syntactic rule would report four forms that emit nothing. Only the type phase erases an import — `import defer` still resolves and links its module, deferring evaluation alone — which is what phaseModifier expresses and the deprecated isTypeOnly cannot. A violation names the package, the declaration that made it optional, and the way out in order: import it as a type, or restructure so module scope does not need it. A dynamic import() only moves the failure to first use, so the gate does not offer it as the remedy. The gate runs in ci-static and ci-primary through ciSharedStaticGates and locally in hygiene; it needs no build. TypeScriptProject gained a face parameter so a repository-wide gate can seed the client aggregate, which was previously unreachable; the constraint it was built with is unchanged, a face config and never the root solution. The tree has no violation today, so this guards the rule rather than fixing a defect. The spec pins all seven import forms against what tsc emits, including the four a syntactic rule would misreport.
2026-08-14 15:08:01 +08:00
function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph {
const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`)
const rootConfig = parseConfig(rootConfigPath)
const rootNames = new Set<string>()
const visited = new Set<string>()
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
if (visited.has(configPath)) return
visited.add(configPath)
for (const fileName of parsed.fileNames) rootNames.add(fileName)
for (const reference of parsed.projectReferences ?? []) {
const referencePath = ts.resolveProjectReferencePath(reference)
collect(referencePath, parseConfig(referencePath))
}
}
collect(rootConfigPath, rootConfig)
return {
rootNames: [...rootNames],
options: rootConfig.options,
}
}
/** Parse one config file and fail loud on any config diagnostic. */
function parseConfig(configPath: string): ts.ParsedCommandLine {
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost)
if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
if (parsed.errors.length > 0) {
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
}
return parsed
}
/** Disable emit-only options after loading the root solution config. */
function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
return {
...options,
noEmit: true,
composite: false,
declaration: false,
declarationMap: false,
sourceMap: false,
incremental: false,
}
}
/** A repository-scoped TypeScript Program and its shared TypeChecker. */
export class TypeScriptProject {
/** The bound cross-file TypeScript program. */
readonly program: ts.Program
/** The checker shared by every semantic query in this project. */
readonly checker: ts.TypeChecker
feat(release): reject a module-scope load of an optional dependency A dependency in optionalDependencies, or a peer carrying peerDependenciesMeta.<name>.optional, may be absent from an installed tree — that absence is the whole promise of "optional". A static import is evaluated when the importing module loads, so one absent package stops being "this capability is unavailable" and becomes a load failure for everything that reaches the importing module. Nothing checked it, and nothing here could: the failure needs an installed tree missing that package, and a workspace install always has every package, so the unit tests, the snapshots, and the packed-install probe all pass while the published package is broken for the consumer who declined the optional peer. verify-optional-dependency-imports reads each package's own manifest for what it allows to be absent, then scans the files that ship across both compiler faces. Value-versus-type is decided against a bound Program rather than the import syntax, because verbatimModuleSyntax is off: the compiler already erases an import whose bindings resolve to types, so a syntactic rule would report four forms that emit nothing. Only the type phase erases an import — `import defer` still resolves and links its module, deferring evaluation alone — which is what phaseModifier expresses and the deprecated isTypeOnly cannot. A violation names the package, the declaration that made it optional, and the way out in order: import it as a type, or restructure so module scope does not need it. A dynamic import() only moves the failure to first use, so the gate does not offer it as the remedy. The gate runs in ci-static and ci-primary through ciSharedStaticGates and locally in hygiene; it needs no build. TypeScriptProject gained a face parameter so a repository-wide gate can seed the client aggregate, which was previously unreachable; the constraint it was built with is unchanged, a face config and never the root solution. The tree has no violation today, so this guards the rule rather than fixing a defect. The spec pins all seven import forms against what tsc emits, including the four a syntactic rule would misreport.
2026-08-14 15:08:01 +08:00
/**
* @param projectRoot - repository root the program is seeded and reported from.
* @param face - which compiler face aggregate to flatten.
*/
constructor(readonly projectRoot: string, face: CompilerFace = 'host') {
const graph = loadProjectGraph(projectRoot, face)
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
this.checker = this.program.getTypeChecker()
}
/**
* Return every source file loaded into the flattened root project graph.
* @returns program source files, including libraries and external dependencies.
*/
sourceFiles(): readonly ts.SourceFile[] {
return this.program.getSourceFiles()
}
/**
* Render a loaded source file relative to the project root.
* @param sourceFile - a source file from this project.
* @returns a slash-separated repository-relative path.
*/
relativePath(sourceFile: ts.SourceFile): string {
return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
}
/**
* Return one program source file by repository-relative path.
* @param relativePath - path relative to the project root.
* @returns the source file bound into this project.
* @throws if a requested root or imported source was not loaded.
*/
sourceFile(relativePath: string): ts.SourceFile {
const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
return sourceFile
}
}