Skip to content

Commit 18e08b2

Browse files
feat(frameworks): wire stage-3 config-AST evidence into detection (#264)
## Summary Wires framework-detection **stage 3 (config-AST)** into the production path. This was the documented-deferred near-miss flagged by the latent-bug sweep: `inspectConfigAst` (handles `next.config.*`, `astro.config.*`, `vite.config.*`, `META-INF/spring.factories`) shipped as a tested standalone module but had **zero production callers** — the dispatcher input couldn't carry config text, so its evidence never reached `frameworksDetected`. ## What changed - `FrameworkDetectorInput` gains optional `configText`. The dispatcher runs `inspectConfigAst` once, groups findings by framework name, and merges them as **stage-3 evidence** into a detection that already hit on a manifest/layout signal. - **Corroborates, never creates:** config text alone does not conjure a detection (a repo can vendor a config without using the framework). A stage-3 finding only attaches when stages 1/4 already fired for that framework. - `frameworks.ts` pre-reads `CONFIG_AST_FILES` alongside manifests/lockfiles and threads `configText` through both entrypoints, so the profile phase picks it up with **no phase-ordering change**. Legacy callers that omit `configText` are unaffected (verified by a no-op test). ## Verification - End-to-end: `analyze` on a Next.js repo now records the nextjs detection with evidence stages **[1, 3, 4]** — stage 3 contributing `next.config present` + `nextjs router: app-router` (previously only [1, 4]). - New tests: stage-3 evidence merges into nextjs; config-text-alone does **not** create a detection (using `spring.factories`, which is not a catalog file-marker); omitting `configText` is a no-op. - frameworks 92 + ingestion 638 tests green; full workspace 0 failures; `biome ci` clean. ## Deferred: stage 5 (imports / SCIP) Stage 5 consumes resolved `IMPORTS` edges, but the profile phase runs (`deps: [scan]`) **before** `crossFile` resolves them. Wiring it needs a phase-ordering change (run framework detection after `crossFile`, or add a later detection pass). Documented in the package header as the remaining step — out of scope here to avoid a DAG change in this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent dde590e commit 18e08b2

4 files changed

Lines changed: 158 additions & 12 deletions

File tree

packages/frameworks/src/detector.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@ function mkInput(
2929
files: readonly string[],
3030
manifests: ReadonlyArray<readonly [string, string]>,
3131
detectedLanguages: readonly string[],
32+
configText?: ReadonlyArray<readonly [string, string]>,
3233
): FrameworkDetectorInput {
3334
return {
3435
relPaths: new Set(files),
3536
manifestText: new Map(manifests),
3637
detectedLanguages,
38+
...(configText !== undefined ? { configText: new Map(configText) } : {}),
3739
};
3840
}
3941

@@ -799,3 +801,70 @@ describe("framework detection — version resolves from either dependency bucket
799801
assert.equal(vite?.version, "5.4.0", "version read from devDependencies bucket");
800802
});
801803
});
804+
805+
// ---------------------------------------------------------------------------
806+
// Stage 3 — config-AST evidence (wired via configText)
807+
// ---------------------------------------------------------------------------
808+
809+
describe("stage 3 — config-AST evidence", () => {
810+
it("merges next.config router evidence into the nextjs detection", () => {
811+
const input = mkInput(
812+
["package.json", "next.config.js", "app/page.tsx"],
813+
[["package.json", JSON.stringify({ dependencies: { next: "14.2.0" } })]],
814+
["typescript"],
815+
[["next.config.js", "module.exports = { experimental: { appDir: true } };\n"]],
816+
);
817+
const out = detectFrameworksStructured(input);
818+
const next = findByName(out, "nextjs");
819+
assert.ok(next, "nextjs detected");
820+
const stage3 = next?.evidence.filter((e) => e.stage === 3) ?? [];
821+
assert.ok(stage3.length > 0, "expected stage-3 config-AST evidence on the nextjs detection");
822+
assert.ok(
823+
stage3.some((e) => e.source === "next.config.js"),
824+
"stage-3 evidence should cite next.config.js",
825+
);
826+
});
827+
828+
it("does NOT create a detection from config text alone (corroborates only)", () => {
829+
// spring.factories is a stage-3 config signal but NOT a catalog file/layout
830+
// marker (spring-boot keys on pom.xml), so config text with no pom.xml and
831+
// no Java layout must not conjure a spring-boot detection.
832+
const input = mkInput(
833+
["META-INF/spring.factories"],
834+
[],
835+
["java"],
836+
[
837+
[
838+
"META-INF/spring.factories",
839+
"org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.MyAutoConfig\n",
840+
],
841+
],
842+
);
843+
const out = detectFrameworksStructured(input);
844+
assert.equal(
845+
findByName(out, "spring-boot"),
846+
undefined,
847+
"config text alone must not detect spring-boot",
848+
);
849+
});
850+
851+
it("is a no-op when configText is omitted (legacy callers unchanged)", () => {
852+
const withCfg = mkInput(
853+
["package.json", "next.config.js", "app/page.tsx"],
854+
[["package.json", JSON.stringify({ dependencies: { next: "14.2.0" } })]],
855+
["typescript"],
856+
[["next.config.js", "module.exports = {};\n"]],
857+
);
858+
const withoutCfg = mkInput(
859+
["package.json", "next.config.js", "app/page.tsx"],
860+
[["package.json", JSON.stringify({ dependencies: { next: "14.2.0" } })]],
861+
["typescript"],
862+
);
863+
const a = findByName(detectFrameworksStructured(withoutCfg), "nextjs");
864+
const b = findByName(detectFrameworksStructured(withCfg), "nextjs");
865+
assert.ok(a && b, "nextjs detected both ways");
866+
// Without configText: no stage-3 evidence. With it: stage-3 present.
867+
assert.equal((a?.evidence.filter((e) => e.stage === 3) ?? []).length, 0);
868+
assert.ok((b?.evidence.filter((e) => e.stage === 3) ?? []).length > 0);
869+
});
870+
});

packages/frameworks/src/detector.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type FrameworkRule,
3232
type ManifestKey,
3333
} from "./catalog.js";
34+
import { type ConfigAstFinding, inspectConfigAst } from "./stages/config-ast.js";
3435
import {
3536
VARIANT_RESOLVERS,
3637
type VariantResolveInput,
@@ -56,6 +57,15 @@ export interface FrameworkDetectorInput {
5657
* substitutes the lockfile's pinned version. Absent for legacy callers.
5758
*/
5859
readonly lockfileVersions?: ReadonlyMap<string, string>;
60+
/**
61+
* Stage 3 — raw text of framework config files (`next.config.*`,
62+
* `astro.config.*`, `vite.config.*`, `META-INF/spring.factories`), keyed by
63+
* relPath. When present, `inspectConfigAst` runs and its findings are merged
64+
* as stage-3 evidence into the matching framework's detection (corroborating
65+
* a manifest/layout hit; it never creates a detection on its own). Absent for
66+
* legacy callers — stage 3 simply contributes no evidence.
67+
*/
68+
readonly configText?: ReadonlyMap<string, string>;
5969
}
6070

6171
/** Mapping language → ecosystem. Covers the tree-sitter languages OpenCodeHub indexes. */
@@ -85,11 +95,16 @@ export function detectFrameworksStructured(
8595
manifestJson,
8696
manifestText: input.manifestText,
8797
};
98+
// Stage 3 — config-AST findings, grouped by the framework name they
99+
// implicate. Computed once; merged into a detection's evidence when that
100+
// framework already hit on a manifest/layout signal (stage 3 corroborates,
101+
// never creates).
102+
const configFindingsByFramework = groupConfigFindings(input.configText, input.relPaths);
88103

89104
const out: FrameworkDetection[] = [];
90105
for (const rule of FRAMEWORK_CATALOG) {
91106
if (rule.ecosystem !== "any" && !activeEcosystems.has(rule.ecosystem)) continue;
92-
const hit = evaluateRule(rule, input, manifestJson);
107+
const hit = evaluateRule(rule, input, manifestJson, configFindingsByFramework.get(rule.name));
93108
if (hit === null) continue;
94109
const detection = buildDetection(
95110
rule,
@@ -104,6 +119,26 @@ export function detectFrameworksStructured(
104119
return out;
105120
}
106121

122+
/**
123+
* Run stage 3 (config-AST) once and group its findings by the framework name
124+
* they implicate, so `evaluateRule` can look up a rule's corroborating
125+
* findings by `rule.name`. Returns an empty map when no config text was
126+
* supplied (legacy callers) — stage 3 then contributes nothing.
127+
*/
128+
function groupConfigFindings(
129+
configText: ReadonlyMap<string, string> | undefined,
130+
relPaths: ReadonlySet<string>,
131+
): ReadonlyMap<string, readonly ConfigAstFinding[]> {
132+
const grouped = new Map<string, ConfigAstFinding[]>();
133+
if (configText === undefined || configText.size === 0) return grouped;
134+
for (const finding of inspectConfigAst(configText, relPaths)) {
135+
const list = grouped.get(finding.framework) ?? [];
136+
list.push(finding);
137+
grouped.set(finding.framework, list);
138+
}
139+
return grouped;
140+
}
141+
107142
// ---------------------------------------------------------------------------
108143
// Evaluation helpers
109144
// ---------------------------------------------------------------------------
@@ -128,6 +163,7 @@ function evaluateRule(
128163
rule: FrameworkRule,
129164
input: FrameworkDetectorInput,
130165
manifestJson: ReadonlyMap<string, unknown>,
166+
configFindings: readonly ConfigAstFinding[] | undefined,
131167
): RuleHit | null {
132168
const evidenceSeen = new Map<string, Evidence>();
133169
let hasManifestHit = false;
@@ -173,6 +209,16 @@ function evaluateRule(
173209
}
174210
}
175211

212+
// Stage 3 — config-AST corroboration. Only merged when a manifest/layout
213+
// signal already fired: config text alone never creates a detection (a repo
214+
// can carry a vendored config without using the framework). Stage-3 evidence
215+
// sharpens an existing hit (e.g. Next.js App vs Pages router).
216+
if ((hasManifestHit || hasFileHit) && configFindings !== undefined) {
217+
for (const f of configFindings) {
218+
push({ stage: 3, source: f.source, detail: f.detail });
219+
}
220+
}
221+
176222
if (!hasManifestHit && !hasFileHit) return null;
177223
const sorted = [...evidenceSeen.values()].sort((a, b) => {
178224
if (a.stage !== b.stage) return a.stage - b.stage;

packages/frameworks/src/frameworks.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { promises as fs } from "node:fs";
1818
import path from "node:path";
1919
import { detectFrameworksStructured } from "./detector.js";
20+
import { CONFIG_AST_FILES } from "./stages/config-ast.js";
2021
import { indexResolutions, KNOWN_LOCKFILES, parseLockfile } from "./stages/lockfile.js";
2122

2223
/**
@@ -109,6 +110,29 @@ async function preReadLockfiles(
109110
return indexResolutions(all);
110111
}
111112

113+
/**
114+
* Stage 3 — pre-read every framework config file present at the repo root
115+
* (`next.config.*`, `astro.config.*`, `vite.config.*`,
116+
* `META-INF/spring.factories`). Returns a relPath → text map; unreadable /
117+
* missing files are simply absent (FRM-UN-002 log-and-continue).
118+
*/
119+
async function preReadConfigFiles(
120+
repoRoot: string,
121+
relPaths: ReadonlySet<string>,
122+
): Promise<ReadonlyMap<string, string>> {
123+
const out = new Map<string, string>();
124+
for (const name of CONFIG_AST_FILES) {
125+
if (!relPaths.has(name)) continue;
126+
try {
127+
const text = await fs.readFile(path.join(repoRoot, name), "utf8");
128+
out.set(name, text);
129+
} catch {
130+
// Malformed / unreadable — skip.
131+
}
132+
}
133+
return out;
134+
}
135+
112136
const ALL_ECOSYSTEM_LANGUAGES: readonly string[] = [
113137
"javascript",
114138
"typescript",
@@ -128,14 +152,16 @@ const ALL_ECOSYSTEM_LANGUAGES: readonly string[] = [
128152
*/
129153
export async function detectFrameworks(input: FrameworkDetectionInput): Promise<readonly string[]> {
130154
const relPaths = new Set(input.files.map((f) => f.relPath));
131-
const [manifestText, lockfileVersions] = await Promise.all([
155+
const [manifestText, lockfileVersions, configText] = await Promise.all([
132156
preReadManifests(input.repoRoot, relPaths),
133157
preReadLockfiles(input.repoRoot, relPaths),
158+
preReadConfigFiles(input.repoRoot, relPaths),
134159
]);
135160
const detections = detectFrameworksStructured({
136161
relPaths,
137162
manifestText,
138163
lockfileVersions,
164+
configText,
139165
detectedLanguages: input.detectedLanguages ?? ALL_ECOSYSTEM_LANGUAGES,
140166
});
141167
return detections.map((d) => d.name);
@@ -151,14 +177,16 @@ export async function detectFrameworksDetailed(
151177
input: FrameworkDetectionInput,
152178
): Promise<ReturnType<typeof detectFrameworksStructured>> {
153179
const relPaths = new Set(input.files.map((f) => f.relPath));
154-
const [manifestText, lockfileVersions] = await Promise.all([
180+
const [manifestText, lockfileVersions, configText] = await Promise.all([
155181
preReadManifests(input.repoRoot, relPaths),
156182
preReadLockfiles(input.repoRoot, relPaths),
183+
preReadConfigFiles(input.repoRoot, relPaths),
157184
]);
158185
return detectFrameworksStructured({
159186
relPaths,
160187
manifestText,
161188
lockfileVersions,
189+
configText,
162190
detectedLanguages: input.detectedLanguages ?? ALL_ECOSYSTEM_LANGUAGES,
163191
});
164192
}

packages/frameworks/src/index.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
/**
22
* `@opencodehub/frameworks` — framework detection over a curated catalog.
33
*
4-
* The dispatcher (`detector.ts`) merges three stages into each
4+
* The dispatcher (`detector.ts`) merges four stages into each
55
* `FrameworkDetection` (`{name, version?, confidence, evidence[]}`):
66
* 1. Manifest presence + declared deps (`package.json`, `pyproject.toml`,
77
* `pom.xml`, …)
88
* 2. Lockfile exact versions, overriding manifest semver ranges
99
* (`package-lock.json`, `pnpm-lock.yaml`, `Gemfile.lock`,
1010
* `poetry.lock`, `uv.lock`, `Cargo.lock`)
11+
* 3. Config AST (`config-ast.ts`) — `next.config.*`, `astro.config.*`,
12+
* `vite.config.*`, `spring.factories`. The wrapper pre-reads these and
13+
* passes `configText`; the dispatcher merges the findings as stage-3
14+
* evidence into a framework that already hit on a manifest/layout signal
15+
* (it corroborates, never creates a detection on its own).
1116
* 4. Folder / file-marker convention (`app/`, `pages/`, `vite.config.ts`,
1217
* `src/main/java/`, …)
1318
*
14-
* Two further stages ship as standalone, independently tested modules but
15-
* are not yet wired into the ingestion profile phase (their findings do not
16-
* reach `FrameworkDetection.evidence` until a caller passes the extra
17-
* inputs through):
18-
* 3. Config AST (`config-ast.ts`) — `next.config.*`, `astro.config.*`,
19-
* `vite.config.*`, `spring.factories`; needs the config-file text.
20-
* 5. Import / SCIP (`imports.ts`) — consumes the graph's `IMPORTS` edges;
21-
* needs the `KnowledgeGraph`.
19+
* One stage ships as a standalone, independently tested module but is not yet
20+
* wired into the ingestion profile phase:
21+
* 5. Import / SCIP (`imports.ts`) — consumes the graph's `IMPORTS` edges,
22+
* which the profile phase (deps: [scan]) runs before. Wiring it needs a
23+
* phase-ordering change (run framework detection after `crossFile`); a
24+
* caller that already holds the resolved graph can pass it through today.
2225
*
2326
* Every stage is pure-local file-system + string/regex inspection; no
2427
* network, no LLM, no subprocess.

0 commit comments

Comments
 (0)