Skip to content

Commit a647b8e

Browse files
committed
feat(index): f00016 S2 — ProjectIndex cache layer
Delivers the index layer for the ProjectSession / ProjectIndex proposal (f00016 S2): file + AST + manifest + workspace cache, incremental invalidator, and the framework registry hook that lets the future scanner refactor opt into the index. packages/core/index/ - project-index.service.ts IProjectIndex facade - file-cache.service.ts SHA-256 + language + size - ast-cache.service.ts Babel AST cache (TS/JS/TSX/JSX) - manifest-reader.service.ts JSON/YAML/TOML manifest cache - workspace-resolver.service.ts npm/yarn/pnpm/bun/cargo/go/composer/poetry - incremental-invalidator.service.ts inverse dependency graph packages/frameworks/framework.registry.ts - PROJECT_INDEX_SLOT symbol - defaultOrchestratorWithIndex(index) factory - projectIndexOf(orchestrator) accessor No existing scanner changes — the slot is opt-in. tests/core/project-index.spec.ts 13 unit tests, all green tests/core/ast-cache-perf.bench.ts hash stability + 100% AST hit rate gate DoD slice (scoped): - bun run typecheck:frameworks ✓ - bun run test:core ✓ (84 files / 1255 tests) - bunx vitest run --project core tests/core/project-index.spec.ts ✓ 13/13 - bun run tests/core/ast-cache-perf.bench.ts --check ✓ green Notes / known incompatibilities (out of slice scope): - astFor() returns unknown not ts.Program — the runtime lacks the 'typescript' dependency; only @babel/parser is installed. Adding 'typescript' to deps is a follow-up. - types colocated with services (IProjectIndex, IIndexedFile, IManifest, etc.) — same pattern as the S1 session layer. Moving them to packages/contracts/ would touch the lint gate and is out of scope for the slice's file list. - lint:naming rejects .bench.ts (the slice explicitly mandates that filename) and rejects the session/* colocated types already shipped in S1 — pre-existing baseline issues. Refs: f00016 S2-ProjectIndex-cache
1 parent ab5d96b commit a647b8e

9 files changed

Lines changed: 2187 additions & 0 deletions
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* AST cache — single source of truth for parsed source files
3+
* (f00016 S2).
4+
*
5+
* The proposal acceptance text says the cache stores
6+
* `ts.Program`s. The runtime does not have `typescript` as a
7+
* dependency (only `@babel/parser`, which the scanners already
8+
* use), so the cache stores **Babel ASTs** for JS/TS files and
9+
* returns them as `unknown`. Callers that need a `ts.Program` for
10+
* cross-file type-aware analysis would still need to add
11+
* `typescript` to the dependency list — that work is out of scope
12+
* for S2 and tracked separately.
13+
*
14+
* ## Why Babel is acceptable here
15+
*
16+
* - The Babel AST is what `language-frontends/typescript-frontend.ts`
17+
* already produces; reusing the same representation means the
18+
* scanners that today re-parse the file do not pay a second
19+
* conversion cost.
20+
* - The cache's job is **to avoid re-parsing**, not to pick a
21+
* dialect. The dialect question lives in the consumer.
22+
* - The cache's invalidation key is the SHA-256 of the source.
23+
* Same hash on the second scan ⇒ same AST, no re-parse.
24+
*
25+
* ## Hooks for the future TS upgrade
26+
*
27+
* - `astFor(relPath)` returns `unknown | undefined` — exactly the
28+
* shape `ts.Program` would take if/when the dependency lands.
29+
* - `parseAst(source, language, relPath)` is the seam: swap its
30+
* body for `ts.createProgram` and the cache contract is
31+
* unchanged.
32+
*/
33+
34+
import { readFile } from "node:fs/promises";
35+
import { join } from "node:path";
36+
37+
import { parse as babelParse } from "@babel/parser";
38+
39+
import { sha256Of } from "./file-cache.service.js";
40+
import type { IndexedLanguage, IIndexedFile } from "./file-cache.service.js";
41+
42+
/** Languages the AST cache can parse. */
43+
export type AstCapableLanguage = Extract<
44+
IndexedLanguage,
45+
"typescript" | "javascript" | "tsx" | "jsx"
46+
>;
47+
48+
/** A cached parse result, with the hash that produced it. */
49+
export interface ICachedAst {
50+
/** SHA-256 of the source text the parser saw. */
51+
readonly hashSha256: string;
52+
/** The Babel AST (or, in a future TS-aware version, a `ts.Program`). */
53+
readonly ast: unknown;
54+
/** Language the parser ran in. */
55+
readonly language: AstCapableLanguage;
56+
}
57+
58+
/** Parses source text with the parser that matches `language`. */
59+
export function parseAst(args: {
60+
readonly source: string;
61+
readonly language: AstCapableLanguage;
62+
readonly relPath: string;
63+
}): unknown {
64+
const plugins: string[] = [];
65+
if (args.language === "typescript" || args.language === "tsx") {
66+
plugins.push("typescript");
67+
}
68+
if (args.language === "tsx" || args.language === "jsx") {
69+
plugins.push("jsx");
70+
}
71+
try {
72+
// The Babel plugin-list types in `@babel/parser` are wider than
73+
// what this slice needs; cast through `unknown` so the local
74+
// ambient declaration's `string[]` shape survives.
75+
const opts = {
76+
sourceType: "module" as const,
77+
allowImportExportEverywhere: true,
78+
allowReturnOutsideFunction: true,
79+
errorRecovery: true,
80+
plugins: plugins as unknown as Array<unknown>,
81+
};
82+
return babelParse(args.source, opts as unknown as Parameters<typeof babelParse>[1]);
83+
} catch {
84+
// Same shape as the TS frontend's `parseModule`: errors degrade
85+
// to `null` so the cache stays truthful ("we tried, it did not
86+
// parse") without throwing into the caller.
87+
return null;
88+
}
89+
}
90+
91+
/**
92+
* Reads a file from disk and parses it into the cache, replacing any
93+
* previous entry. Returns the new record or `null` when the file is
94+
* gone. The cache is mutated by side-effect so the caller does not
95+
* need to reassign.
96+
*/
97+
export async function loadAst(args: {
98+
readonly cache: Map<string, ICachedAst>;
99+
readonly projectRoot: string;
100+
readonly file: IIndexedFile;
101+
}): Promise<ICachedAst | null> {
102+
if (!isAstCapable(args.file.language)) return null;
103+
let raw: string;
104+
try {
105+
raw = await readFile(join(args.projectRoot, ...args.file.relPath.split("/")), "utf8");
106+
} catch {
107+
return null;
108+
}
109+
const hash = sha256Of(raw);
110+
if (args.cache.get(args.file.relPath)?.hashSha256 === hash) {
111+
return args.cache.get(args.file.relPath) ?? null;
112+
}
113+
const ast = parseAst({ source: raw, language: args.file.language, relPath: args.file.relPath });
114+
const entry: ICachedAst = {
115+
hashSha256: hash,
116+
ast,
117+
language: args.file.language,
118+
};
119+
args.cache.set(args.file.relPath, entry);
120+
return entry;
121+
}
122+
123+
/**
124+
* Cache lookup that respects the hash: if the file on disk has
125+
* changed since the cache was filled, the entry is dropped and the
126+
* cache reports a miss. Callers that want the new value call
127+
* `loadAst` afterwards.
128+
*/
129+
export function cachedAst(
130+
cache: ReadonlyMap<string, ICachedAst>,
131+
file: IIndexedFile,
132+
): ICachedAst | undefined {
133+
if (!isAstCapable(file.language)) return undefined;
134+
const entry = cache.get(file.relPath);
135+
if (!entry) return undefined;
136+
if (entry.hashSha256 !== file.hashSha256) return undefined;
137+
return entry;
138+
}
139+
140+
/**
141+
* Eagerly fills the cache for every file the index knows about.
142+
* Uses a bounded concurrency (`READ_CONCURRENCY`) so the bench
143+
* reproduces the same shape as `readAllFiles`.
144+
*/
145+
export async function loadAsts(args: {
146+
readonly cache: Map<string, ICachedAst>;
147+
readonly projectRoot: string;
148+
readonly files: ReadonlyArray<IIndexedFile>;
149+
readonly concurrency?: number;
150+
}): Promise<void> {
151+
const width = Math.max(1, args.concurrency ?? 16);
152+
const work = args.files.filter((f) => isAstCapable(f.language));
153+
let next = 0;
154+
const worker = async (): Promise<void> => {
155+
while (next < work.length) {
156+
const idx = next++;
157+
const file = work[idx];
158+
if (!file) return;
159+
await loadAst({ cache: args.cache, projectRoot: args.projectRoot, file });
160+
}
161+
};
162+
const lanes: Array<Promise<void>> = [];
163+
for (let i = 0; i < Math.min(width, work.length); i++) lanes.push(worker());
164+
await Promise.all(lanes);
165+
}
166+
167+
/**
168+
* Drops every cached AST that depends on `relPath`. The inverse
169+
* graph the invalidator carries is the source of truth — this
170+
* helper exists so callers do not have to spell out the closure.
171+
*/
172+
export function invalidateAstsFor(
173+
cache: Map<string, ICachedAst>,
174+
relPaths: ReadonlyArray<string>,
175+
): void {
176+
for (const p of relPaths) cache.delete(p);
177+
}
178+
179+
/** Type guard: can this language be AST-cached at all? */
180+
export function isAstCapable(language: IndexedLanguage): language is AstCapableLanguage {
181+
return (
182+
language === "typescript" ||
183+
language === "javascript" ||
184+
language === "tsx" ||
185+
language === "jsx"
186+
);
187+
}
188+
189+
/** Re-export the helper for callers that want to mirror the same shape. */
190+
export { sha256Of };

0 commit comments

Comments
 (0)