@orangepro/orangepro-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/local/agentWorkflow.js +81 -0
- package/dist/local/aiGraph/links.js +635 -0
- package/dist/local/analyze/analyzer.js +2129 -0
- package/dist/local/analyze/behaviorContracts.js +169 -0
- package/dist/local/analyze/boilerplate.js +42 -0
- package/dist/local/analyze/callGraph.js +458 -0
- package/dist/local/analyze/classify.js +219 -0
- package/dist/local/analyze/clustering.js +357 -0
- package/dist/local/analyze/confirm.js +2422 -0
- package/dist/local/analyze/coverage.js +518 -0
- package/dist/local/analyze/coverageArtifacts.js +607 -0
- package/dist/local/analyze/frameworks.js +115 -0
- package/dist/local/analyze/linkage/conventions.js +160 -0
- package/dist/local/analyze/parseCache.js +164 -0
- package/dist/local/analyze/selfAssert.js +53 -0
- package/dist/local/analyze/symbols.js +430 -0
- package/dist/local/analyze/testLayer.js +135 -0
- package/dist/local/analyze/treeSitter/engine.js +1253 -0
- package/dist/local/analyze/treeSitter/languages.js +101 -0
- package/dist/local/autoProve.js +620 -0
- package/dist/local/cli.js +1468 -0
- package/dist/local/cliArgs.js +112 -0
- package/dist/local/corpusScope.js +162 -0
- package/dist/local/enrich/csv.js +348 -0
- package/dist/local/enrich/index.js +43 -0
- package/dist/local/enrich/markdown.js +193 -0
- package/dist/local/explain/explain.js +91 -0
- package/dist/local/exportCli.js +26 -0
- package/dist/local/flows/flowWalker.js +215 -0
- package/dist/local/flows/llmFlowDiscovery.js +567 -0
- package/dist/local/freshness/changed.js +280 -0
- package/dist/local/freshness/manifest.js +35 -0
- package/dist/local/freshness/status.js +30 -0
- package/dist/local/gaps/gaps.js +114 -0
- package/dist/local/generate/buckets.js +73 -0
- package/dist/local/generate/compareJudge.js +124 -0
- package/dist/local/generate/compareReport.js +538 -0
- package/dist/local/generate/compareScore.js +105 -0
- package/dist/local/generate/deriveImports.js +91 -0
- package/dist/local/generate/generator.js +2586 -0
- package/dist/local/generate/prompt.js +144 -0
- package/dist/local/generate/promptV5.js +438 -0
- package/dist/local/generate/providers.js +400 -0
- package/dist/local/generate/runHints.js +304 -0
- package/dist/local/graph/citations.js +73 -0
- package/dist/local/graph/confirmable.js +72 -0
- package/dist/local/graph/factories.js +210 -0
- package/dist/local/graph/ontology.js +18 -0
- package/dist/local/interactive.js +53 -0
- package/dist/local/jobs/jobStore.js +80 -0
- package/dist/local/jobs/notify.js +29 -0
- package/dist/local/jobs/runner.js +75 -0
- package/dist/local/ledger.js +117 -0
- package/dist/local/localConfig.js +112 -0
- package/dist/local/mcp.js +548 -0
- package/dist/local/operations.js +1749 -0
- package/dist/local/pack/coverageReport.js +192 -0
- package/dist/local/pack/exporter.js +195 -0
- package/dist/local/pack/schema.js +128 -0
- package/dist/local/pack/summary.js +127 -0
- package/dist/local/pack/validate.js +25 -0
- package/dist/local/proofRunnability.js +366 -0
- package/dist/local/recipe/dbSqljs.js +255 -0
- package/dist/local/reprove/paths.js +13 -0
- package/dist/local/reprove/scoped.js +136 -0
- package/dist/local/resolve/barrelWalker.js +178 -0
- package/dist/local/resolve/exportIndex.js +270 -0
- package/dist/local/resolve/importGraph.js +347 -0
- package/dist/local/resolve/resolver.js +122 -0
- package/dist/local/resolve/resolverCache.js +117 -0
- package/dist/local/rtm.js +413 -0
- package/dist/local/score/coverage.js +99 -0
- package/dist/local/score/doctor.js +67 -0
- package/dist/local/score/risk.js +362 -0
- package/dist/local/score/score.js +182 -0
- package/dist/local/types.js +1 -0
- package/dist/local/util/hash.js +16 -0
- package/dist/local/util/ids.js +16 -0
- package/dist/local/util/progress.js +8 -0
- package/dist/local/util/redact.js +39 -0
- package/dist/local/util/time.js +1 -0
- package/dist/local/util/walk.js +174 -0
- package/dist/local/viz/behaviorReportData.js +367 -0
- package/dist/local/viz/behaviorReportHtml.js +664 -0
- package/dist/local/viz/d3.bundle.js +3 -0
- package/dist/local/viz/html.js +1152 -0
- package/dist/local/viz/payload.js +525 -0
- package/dist/local/workspace.js +99 -0
- package/docs/agent-workflow.md +167 -0
- package/docs/agents/claude-code.md +43 -0
- package/docs/agents/codex.md +52 -0
- package/docs/agents/cursor.md +39 -0
- package/docs/agents/opencode.md +43 -0
- package/docs/agents/vscode.md +34 -0
- package/docs/local-proof-kit.md +269 -0
- package/package.json +92 -0
- package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
- package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
- package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
- package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
- package/scripts/spikes/failure-summary.mjs +29 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Import graph + per-category resolution metrics (Gate 1 / G-RESOLVE, Phase 1).
|
|
2
|
+
//
|
|
3
|
+
// Two layers, kept separate from the resolver (resolver.ts is the source of truth
|
|
4
|
+
// for resolution targets):
|
|
5
|
+
// 1. classifySpecifier — bucket each import specifier by INTENT (asset, relative,
|
|
6
|
+
// path-alias, baseUrl-internal, bare-external, node-builtin), using tsconfig
|
|
7
|
+
// context for the alias/baseUrl categories. Ported from the resolver spike.
|
|
8
|
+
// 2. buildImportGraph — extract imports per file via a `ts.createSourceFile`
|
|
9
|
+
// pass (type-only vs runtime aware, with imported binding names), resolve
|
|
10
|
+
// each via resolver.ts, and aggregate the per-category gate metrics.
|
|
11
|
+
//
|
|
12
|
+
// The `test_to_source` axis gates static association diagnostics
|
|
13
|
+
// (see private/spikes/gate-specs-digest.md §resolver B). The internal denominator
|
|
14
|
+
// EXCLUDES asset, node-builtin, and bare-external/external — those are not modules
|
|
15
|
+
// we expect to resolve inside the repo.
|
|
16
|
+
//
|
|
17
|
+
// Phase 1 PR-2 (this file): the `barrel_terminal` and `workspace_package` gate
|
|
18
|
+
// axes are computed here, the former by walking each test->source barrel import
|
|
19
|
+
// through the barrel walker (barrelWalker.ts) to a terminal defining file. The
|
|
20
|
+
// remaining `changed_scope` axis needs a diff (a later PR), and wiring these
|
|
21
|
+
// resolved terminals into the analyzer's TESTED_BY edges is Phase 2.
|
|
22
|
+
import ts from "typescript";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { loadTsConfigFor, resolveImport } from "./resolver.js";
|
|
26
|
+
import { computeResolverGate } from "./resolverCache.js";
|
|
27
|
+
import { buildExportIndex } from "./exportIndex.js";
|
|
28
|
+
import { walkBarrel } from "./barrelWalker.js";
|
|
29
|
+
import { isTestFile } from "../analyze/classify.js";
|
|
30
|
+
const EMPTY_CONTEXT = { pathAliasKeys: [], baseUrlTopLevel: new Set() };
|
|
31
|
+
const ASSET_RE = /\.(scss|sass|css|less|styl|png|jpe?g|gif|svg|webp|ico|bmp|woff2?|ttf|eot|otf|mp4|webm|mp3|wav|avif)$/i;
|
|
32
|
+
function isAssetSpecifier(spec) {
|
|
33
|
+
return ASSET_RE.test(spec);
|
|
34
|
+
}
|
|
35
|
+
function matchesPathAlias(spec, keys) {
|
|
36
|
+
return keys.some((k) => {
|
|
37
|
+
if (k.includes("*")) {
|
|
38
|
+
const base = k.slice(0, k.indexOf("*")); // TS wildcard = prefix match
|
|
39
|
+
return spec.startsWith(base);
|
|
40
|
+
}
|
|
41
|
+
return spec === k; // exact alias: exact match only
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function isBaseUrlInternal(spec, ctx) {
|
|
45
|
+
if (ctx.baseUrlTopLevel.size === 0)
|
|
46
|
+
return false;
|
|
47
|
+
const firstSeg = spec.split("/")[0];
|
|
48
|
+
return ctx.baseUrlTopLevel.has(firstSeg);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Classify an import specifier by intent. `ctx` supplies the tsconfig `paths`
|
|
52
|
+
* keys and `baseUrl` top-level names so alias/baseUrl-internal specifiers are
|
|
53
|
+
* recognized even when (webpack-only) aliases fail TS resolution.
|
|
54
|
+
*/
|
|
55
|
+
export function classifySpecifier(spec, ctx = EMPTY_CONTEXT) {
|
|
56
|
+
if (isAssetSpecifier(spec))
|
|
57
|
+
return "asset";
|
|
58
|
+
if (spec.startsWith(".") || path.isAbsolute(spec)) {
|
|
59
|
+
if (/\.(js|jsx|mjs|cjs)$/.test(spec))
|
|
60
|
+
return "rel-js-specifier"; // NodeNext .js -> .ts hazard
|
|
61
|
+
if (/\.(ts|tsx|mts|cts)$/.test(spec))
|
|
62
|
+
return "rel-ts-ext";
|
|
63
|
+
return "rel-extensionless";
|
|
64
|
+
}
|
|
65
|
+
if (/^node:/.test(spec))
|
|
66
|
+
return "node-builtin";
|
|
67
|
+
if (matchesPathAlias(spec, ctx.pathAliasKeys))
|
|
68
|
+
return "path-alias";
|
|
69
|
+
if (isBaseUrlInternal(spec, ctx))
|
|
70
|
+
return "baseurl-internal";
|
|
71
|
+
return "bare-external";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build the classify context (path-alias keys + baseUrl top-level names) for a
|
|
75
|
+
* file from its nearest tsconfig scope. Cached implicitly via loadTsConfigFor.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyContextFor(file) {
|
|
78
|
+
const scope = loadTsConfigFor(file);
|
|
79
|
+
const options = scope.options;
|
|
80
|
+
const pathAliasKeys = Object.keys(options.paths ?? {});
|
|
81
|
+
const baseUrlTopLevel = new Set();
|
|
82
|
+
if (options.baseUrl) {
|
|
83
|
+
const baseUrlDir = path.resolve(scope.configPath.startsWith("<") ? process.cwd() : path.dirname(scope.configPath), options.baseUrl);
|
|
84
|
+
try {
|
|
85
|
+
for (const entry of fs.readdirSync(baseUrlDir, { withFileTypes: true })) {
|
|
86
|
+
baseUrlTopLevel.add(entry.name.replace(/\.[mc]?[tj]sx?$/, ""));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* no baseUrl dir on disk */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { pathAliasKeys, baseUrlTopLevel };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Extract imports from a file via a lightweight `ts.createSourceFile` pass (no
|
|
97
|
+
* Program / no typecheck). Unlike `ts.preProcessFile`, this distinguishes
|
|
98
|
+
* `import type` / type-only re-exports from runtime imports — type-only edges
|
|
99
|
+
* must NOT count toward static assertion diagnostics — and carries each static import's
|
|
100
|
+
* binding names. Covers static imports/exports, dynamic `import("lit")`, and
|
|
101
|
+
* `require("lit")` with a string-literal argument.
|
|
102
|
+
*/
|
|
103
|
+
export function extractImports(file) {
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
text = fs.readFileSync(file, "utf8");
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, /*setParentNodes*/ false);
|
|
112
|
+
const imports = [];
|
|
113
|
+
const moduleText = (node) => node && ts.isStringLiteralLike(node) ? node.text : null;
|
|
114
|
+
const visit = (node) => {
|
|
115
|
+
if (ts.isImportDeclaration(node)) {
|
|
116
|
+
const specifier = moduleText(node.moduleSpecifier);
|
|
117
|
+
if (specifier !== null) {
|
|
118
|
+
imports.push({ specifier, kind: importDeclarationKind(node), bindings: importBindings(node) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
|
122
|
+
// Re-export: `export ... from "lit"`. Type-only when `export type ... from`.
|
|
123
|
+
const specifier = moduleText(node.moduleSpecifier);
|
|
124
|
+
if (specifier !== null) {
|
|
125
|
+
imports.push({ specifier, kind: node.isTypeOnly ? "type" : "runtime", bindings: [] });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (ts.isCallExpression(node)) {
|
|
129
|
+
// Dynamic `import("lit")` or `require("lit")` with a string-literal arg.
|
|
130
|
+
const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
|
|
131
|
+
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
|
|
132
|
+
// TODO(low): a concatenated dynamic-import arg yields a phantom specifier; ignored.
|
|
133
|
+
if (isDynamicImport || isRequire) {
|
|
134
|
+
const specifier = moduleText(node.arguments[0]);
|
|
135
|
+
if (specifier !== null)
|
|
136
|
+
imports.push({ specifier, kind: "runtime", bindings: [] });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
ts.forEachChild(node, visit);
|
|
140
|
+
};
|
|
141
|
+
visit(sourceFile);
|
|
142
|
+
return imports;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Imported binding names for a static import: a default binding, a namespace
|
|
146
|
+
* (`* as ns`, recorded as imported "*"), and/or named imports (carrying any
|
|
147
|
+
* `as` rename's SOURCE name). Side-effect imports bind nothing.
|
|
148
|
+
*/
|
|
149
|
+
function importBindings(node) {
|
|
150
|
+
const clause = node.importClause;
|
|
151
|
+
if (!clause)
|
|
152
|
+
return [];
|
|
153
|
+
const out = [];
|
|
154
|
+
if (clause.name)
|
|
155
|
+
out.push({ local: clause.name.text, imported: "default" });
|
|
156
|
+
const named = clause.namedBindings;
|
|
157
|
+
if (named && ts.isNamespaceImport(named)) {
|
|
158
|
+
out.push({ local: named.name.text, imported: "*" });
|
|
159
|
+
}
|
|
160
|
+
else if (named && ts.isNamedImports(named)) {
|
|
161
|
+
for (const el of named.elements) {
|
|
162
|
+
if (el.isTypeOnly)
|
|
163
|
+
continue; // `import { type X }`: a type binding, not a runtime use
|
|
164
|
+
out.push({ local: el.name.text, imported: (el.propertyName ?? el.name).text });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Type-only iff the clause is `import type ...`, OR there is no default binding
|
|
171
|
+
* AND no namespace import AND it has named bindings of which EVERY element is
|
|
172
|
+
* itself `isTypeOnly` (i.e. `import { type A, type B } from ...`). Otherwise runtime.
|
|
173
|
+
*/
|
|
174
|
+
function importDeclarationKind(node) {
|
|
175
|
+
const clause = node.importClause;
|
|
176
|
+
if (!clause)
|
|
177
|
+
return "runtime"; // side-effect import: `import "lit"` runs code
|
|
178
|
+
if (clause.isTypeOnly)
|
|
179
|
+
return "type";
|
|
180
|
+
const named = clause.namedBindings;
|
|
181
|
+
const hasNamedElements = named !== undefined && ts.isNamedImports(named);
|
|
182
|
+
if (clause.name === undefined && !(named && ts.isNamespaceImport(named)) && hasNamedElements) {
|
|
183
|
+
const elements = named.elements;
|
|
184
|
+
if (elements.length > 0 && elements.every((el) => el.isTypeOnly))
|
|
185
|
+
return "type";
|
|
186
|
+
}
|
|
187
|
+
return "runtime";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Thin specifier-only wrapper over `extractImports`, preserving the original
|
|
191
|
+
* `string[]` shape for callers that do not need the runtime/type distinction.
|
|
192
|
+
*/
|
|
193
|
+
export function extractSpecifiers(file) {
|
|
194
|
+
return extractImports(file).map((imp) => imp.specifier);
|
|
195
|
+
}
|
|
196
|
+
/** An internal-intent specifier is expected to resolve inside the repo. */
|
|
197
|
+
function isInternalCategory(category) {
|
|
198
|
+
return (category === "rel-js-specifier" ||
|
|
199
|
+
category === "rel-ts-ext" ||
|
|
200
|
+
category === "rel-extensionless" ||
|
|
201
|
+
category === "path-alias" ||
|
|
202
|
+
category === "baseurl-internal");
|
|
203
|
+
}
|
|
204
|
+
/** An edge counts toward the INTERNAL denominator: internal-intent AND not external. */
|
|
205
|
+
function isInternalEdge(edge) {
|
|
206
|
+
return isInternalCategory(edge.category) && !edge.external;
|
|
207
|
+
}
|
|
208
|
+
function axis(edges, predicate) {
|
|
209
|
+
const subset = edges.filter(predicate);
|
|
210
|
+
const resolved = subset.filter((e) => e.resolved).length;
|
|
211
|
+
const n = subset.length;
|
|
212
|
+
return { n, resolved, pct: n ? Number(((100 * resolved) / n).toFixed(1)) : null };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* workspace_package axis: of path-alias + baseUrl-internal imports (the
|
|
216
|
+
* workspace/project-ref style), how many resolve to an INTERNAL target. An
|
|
217
|
+
* alias that RESOLVES into node_modules is conclusively a vendor shim, not
|
|
218
|
+
* workspace code — it leaves the DENOMINATOR entirely (it is not a
|
|
219
|
+
* workspace-resolution failure). An UNRESOLVED alias stays in the denominator:
|
|
220
|
+
* it was internal-intent and genuinely failed.
|
|
221
|
+
*/
|
|
222
|
+
function workspacePackageAxis(edges) {
|
|
223
|
+
const subset = edges.filter((e) => (e.category === "path-alias" || e.category === "baseurl-internal") && !(e.resolved && e.external));
|
|
224
|
+
const n = subset.length;
|
|
225
|
+
const resolved = subset.filter((e) => e.resolved).length;
|
|
226
|
+
return { n, resolved, pct: n ? Number(((100 * resolved) / n).toFixed(1)) : null };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* barrel_terminal axis: among test->source RUNTIME imports that resolve to a
|
|
230
|
+
* BARREL file and carry at least one named/default binding, how many walk
|
|
231
|
+
* through the re-export chain so that EVERY such binding reaches a COVERS-eligible terminal
|
|
232
|
+
* (runtime; type-only terminals and type-only re-export hops do NOT count). Namespace-only imports are excluded (cannot be attributed to a
|
|
233
|
+
* single terminal without binding-use analysis). This is a health metric; the
|
|
234
|
+
* per-link COVERS upgrade lands in Phase 2.
|
|
235
|
+
*/
|
|
236
|
+
function barrelTerminalAxis(edges) {
|
|
237
|
+
let n = 0;
|
|
238
|
+
let resolved = 0;
|
|
239
|
+
for (const e of edges) {
|
|
240
|
+
if (e.fromRole !== "test")
|
|
241
|
+
continue;
|
|
242
|
+
if (!isInternalEdge(e))
|
|
243
|
+
continue;
|
|
244
|
+
if (e.importKind !== "runtime")
|
|
245
|
+
continue;
|
|
246
|
+
if (!e.resolved || !e.target)
|
|
247
|
+
continue;
|
|
248
|
+
if (e.targetRole === "test")
|
|
249
|
+
continue;
|
|
250
|
+
const namedOrDefault = e.bindings.filter((b) => b.imported !== "*");
|
|
251
|
+
if (namedOrDefault.length === 0)
|
|
252
|
+
continue; // namespace-only: cannot attribute to one terminal
|
|
253
|
+
if (!buildExportIndex(e.target).isBarrel)
|
|
254
|
+
continue; // only barrel targets are "barrel imports"
|
|
255
|
+
n += 1;
|
|
256
|
+
const target = e.target;
|
|
257
|
+
const allCovered = namedOrDefault.every((b) => walkBarrel(target, b.imported).covered);
|
|
258
|
+
if (allCovered)
|
|
259
|
+
resolved += 1;
|
|
260
|
+
}
|
|
261
|
+
return { n, resolved, pct: n ? Number(((100 * resolved) / n).toFixed(1)) : null };
|
|
262
|
+
}
|
|
263
|
+
const ALL_CATEGORIES = [
|
|
264
|
+
"rel-js-specifier",
|
|
265
|
+
"rel-ts-ext",
|
|
266
|
+
"rel-extensionless",
|
|
267
|
+
"path-alias",
|
|
268
|
+
"baseurl-internal",
|
|
269
|
+
"bare-external",
|
|
270
|
+
"node-builtin",
|
|
271
|
+
"asset"
|
|
272
|
+
];
|
|
273
|
+
/**
|
|
274
|
+
* Build the import graph for a set of role-tagged files: extract imports,
|
|
275
|
+
* classify and resolve each, then aggregate the per-category gate metrics.
|
|
276
|
+
* Returns the edges, the gate axes (`metrics`), and raw per-category counts.
|
|
277
|
+
*/
|
|
278
|
+
export function buildImportGraph(files, opts = {}) {
|
|
279
|
+
const { repoRoot } = opts;
|
|
280
|
+
const roleByPath = new Map();
|
|
281
|
+
for (const f of files)
|
|
282
|
+
roleByPath.set(path.resolve(f.path), f.role);
|
|
283
|
+
// Validate the resolution cache against the current filesystem shape + config (gate);
|
|
284
|
+
// a structural/config change discards all cached resolutions (no stale survival). The
|
|
285
|
+
// gate + known-target set come from the FULL walked file set (incl. tsconfig/package.json),
|
|
286
|
+
// so a config edit busts the cache and a cached internal target must be a real walked file.
|
|
287
|
+
const rcache = opts.resolverCache;
|
|
288
|
+
const gateFiles = opts.gateFiles ?? files.map((f) => f.path);
|
|
289
|
+
if (rcache)
|
|
290
|
+
rcache.useGate(computeResolverGate(gateFiles));
|
|
291
|
+
const targetRoleOf = (target) => {
|
|
292
|
+
if (!target)
|
|
293
|
+
return null;
|
|
294
|
+
const known = roleByPath.get(path.resolve(target));
|
|
295
|
+
if (known)
|
|
296
|
+
return known;
|
|
297
|
+
// Classify on a repo-relative path (or basename fallback) so `tests/`/`spec/`
|
|
298
|
+
// ANCESTOR dir segments in the absolute path don't misclassify a source target.
|
|
299
|
+
const rel = repoRoot ? path.relative(repoRoot, target) : path.basename(target);
|
|
300
|
+
return isTestFile(rel) ? "test" : "source";
|
|
301
|
+
};
|
|
302
|
+
const edges = [];
|
|
303
|
+
for (const file of files) {
|
|
304
|
+
const ctx = classifyContextFor(file.path);
|
|
305
|
+
for (const imp of extractImports(file.path)) {
|
|
306
|
+
const category = classifySpecifier(imp.specifier, ctx);
|
|
307
|
+
const r = rcache
|
|
308
|
+
? rcache.resolve(path.dirname(file.path), imp.specifier, () => resolveImport(imp.specifier, file.path))
|
|
309
|
+
: resolveImport(imp.specifier, file.path);
|
|
310
|
+
edges.push({
|
|
311
|
+
from: file.path,
|
|
312
|
+
fromRole: file.role,
|
|
313
|
+
specifier: imp.specifier,
|
|
314
|
+
category,
|
|
315
|
+
importKind: imp.kind,
|
|
316
|
+
bindings: imp.bindings,
|
|
317
|
+
resolved: r.resolved,
|
|
318
|
+
target: r.resolvedFileName,
|
|
319
|
+
targetRole: targetRoleOf(r.resolvedFileName),
|
|
320
|
+
external: r.isExternal
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const isTestInternal = (e) => e.fromRole === "test" && isInternalEdge(e);
|
|
325
|
+
const metrics = {
|
|
326
|
+
all_internal: axis(edges, isInternalEdge),
|
|
327
|
+
test_file: axis(edges, (e) => e.fromRole === "test"),
|
|
328
|
+
// All internal imports from a test file — the test-health denominator.
|
|
329
|
+
test_internal: axis(edges, isTestInternal),
|
|
330
|
+
// Internal test imports that resolve to another test file (test helpers etc.).
|
|
331
|
+
test_to_test: axis(edges, (e) => isTestInternal(e) && e.resolved && e.targetRole === "test"),
|
|
332
|
+
// THE confirmed-coverage gate: internal RUNTIME test imports that are not
|
|
333
|
+
// test->test. Type-only and test->test are excluded; an unresolved-internal
|
|
334
|
+
// still counts in the denom, so a real resolution failure lowers the gate.
|
|
335
|
+
test_to_source: axis(edges, (e) => isTestInternal(e) && e.importKind === "runtime" && !(e.resolved && e.targetRole === "test")),
|
|
336
|
+
// Diagnostic: internal test imports that failed to resolve at all.
|
|
337
|
+
test_unresolved_internal: axis(edges, (e) => isTestInternal(e) && !e.resolved),
|
|
338
|
+
source_to_source: axis(edges, (e) => e.fromRole === "source" && isInternalEdge(e)),
|
|
339
|
+
barrel_terminal: barrelTerminalAxis(edges),
|
|
340
|
+
workspace_package: workspacePackageAxis(edges)
|
|
341
|
+
};
|
|
342
|
+
const byCategory = {};
|
|
343
|
+
for (const cat of ALL_CATEGORIES) {
|
|
344
|
+
byCategory[cat] = axis(edges, (e) => e.category === cat);
|
|
345
|
+
}
|
|
346
|
+
return { edges, metrics, byCategory };
|
|
347
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// TypeScript import resolver (Gate 1 / G-RESOLVE, Phase 1 PR-1).
|
|
2
|
+
//
|
|
3
|
+
// Source of truth for "what does this import point to." Uses the TypeScript
|
|
4
|
+
// compiler API (`ts.resolveModuleName`) — the same resolver the rewrite adopts
|
|
5
|
+
// for "confirmed" coverage. This module is SEPARATE from any parser: the parser
|
|
6
|
+
// extracts specifiers (see importGraph.ts), this module turns a specifier +
|
|
7
|
+
// containing file into a resolved terminal file.
|
|
8
|
+
//
|
|
9
|
+
// Productionized from private/spikes/resolver/resolve-spike.mjs (spiked GREEN:
|
|
10
|
+
// test->source resolution ~100% on this NodeNext repo and the Mattermost bundler
|
|
11
|
+
// repo). It honors each file's NEAREST tsconfig so NodeNext (.js->.ts rewrite)
|
|
12
|
+
// and bundler (paths/baseUrl) repos each resolve under their own options.
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
/** Sane NodeNext defaults when no tsconfig is found walking up from a file. */
|
|
17
|
+
const FALLBACK_OPTIONS = {
|
|
18
|
+
module: ts.ModuleKind.NodeNext,
|
|
19
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
20
|
+
target: ts.ScriptTarget.ES2022,
|
|
21
|
+
esModuleInterop: true,
|
|
22
|
+
allowJs: true
|
|
23
|
+
};
|
|
24
|
+
const FALLBACK_KEY = "<nodenext-fallback>";
|
|
25
|
+
// Cache by `configPath + ":" + mtimeMs` (or the fallback key). Walking up the dir
|
|
26
|
+
// tree repeatedly is cheap, but parsing a tsconfig + building a resolution cache is
|
|
27
|
+
// not — so each distinct scope is parsed exactly once per (path, mtime) pair. Keying
|
|
28
|
+
// on mtime means an edited tsconfig busts its own cache entry, so a long-lived MCP
|
|
29
|
+
// process never resolves with a stale tsconfig after edits.
|
|
30
|
+
const scopeCache = new Map();
|
|
31
|
+
// Memoize the nearest-tsconfig lookup per containing directory.
|
|
32
|
+
const nearestConfigByDir = new Map();
|
|
33
|
+
/** Cache key for a tsconfig path: path + its current mtime (0 if unreadable). */
|
|
34
|
+
function scopeCacheKey(configPath) {
|
|
35
|
+
let mtimeMs = 0;
|
|
36
|
+
try {
|
|
37
|
+
mtimeMs = fs.statSync(configPath).mtimeMs;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* unreadable tsconfig — key on mtime 0 */
|
|
41
|
+
}
|
|
42
|
+
return `${configPath}:${mtimeMs}`;
|
|
43
|
+
}
|
|
44
|
+
function makeScope(configPath, options) {
|
|
45
|
+
const moduleResolutionCache = ts.createModuleResolutionCache(path.dirname(configPath === FALLBACK_KEY ? process.cwd() : configPath), (x) => x, options);
|
|
46
|
+
return { configPath, options, moduleResolutionCache };
|
|
47
|
+
}
|
|
48
|
+
function fallbackScope() {
|
|
49
|
+
const existing = scopeCache.get(FALLBACK_KEY);
|
|
50
|
+
if (existing)
|
|
51
|
+
return existing;
|
|
52
|
+
const scope = makeScope(FALLBACK_KEY, FALLBACK_OPTIONS);
|
|
53
|
+
scopeCache.set(FALLBACK_KEY, scope);
|
|
54
|
+
return scope;
|
|
55
|
+
}
|
|
56
|
+
/** Walk up from `dir` to find the nearest tsconfig.json. Memoized per dir. */
|
|
57
|
+
function findNearestTsConfig(dir) {
|
|
58
|
+
const memo = nearestConfigByDir.get(dir);
|
|
59
|
+
if (memo !== undefined)
|
|
60
|
+
return memo;
|
|
61
|
+
const found = ts.findConfigFile(dir, ts.sys.fileExists, "tsconfig.json") ?? null;
|
|
62
|
+
nearestConfigByDir.set(dir, found);
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
/** Parse a tsconfig into compiler options, building+caching its scope. */
|
|
66
|
+
function loadScopeForConfig(configPath) {
|
|
67
|
+
// Key on (path, mtime) so an edited tsconfig busts the cache (stale-scope fix).
|
|
68
|
+
const key = scopeCacheKey(configPath);
|
|
69
|
+
const cached = scopeCache.get(key);
|
|
70
|
+
if (cached)
|
|
71
|
+
return cached;
|
|
72
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
73
|
+
if (read.error) {
|
|
74
|
+
// Unparseable tsconfig — fall back rather than throw; resolution still works
|
|
75
|
+
// for plain relative/NodeNext imports under defaults.
|
|
76
|
+
return fallbackScope();
|
|
77
|
+
}
|
|
78
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
|
|
79
|
+
const scope = makeScope(configPath, parsed.options);
|
|
80
|
+
scopeCache.set(key, scope);
|
|
81
|
+
return scope;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Find the nearest tsconfig.json walking up from `file`'s directory, parse it,
|
|
85
|
+
* and return its scope (options + a shared module-resolution cache). The scope is
|
|
86
|
+
* cached by resolved tsconfig path. Falls back to NodeNext defaults when none is
|
|
87
|
+
* found.
|
|
88
|
+
*/
|
|
89
|
+
export function loadTsConfigFor(file) {
|
|
90
|
+
const dir = path.dirname(path.resolve(file));
|
|
91
|
+
const configPath = findNearestTsConfig(dir);
|
|
92
|
+
if (!configPath)
|
|
93
|
+
return fallbackScope();
|
|
94
|
+
return loadScopeForConfig(configPath);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a single import `specifier` as seen from `containingFile`, using
|
|
98
|
+
* `ts.resolveModuleName` under the containing file's nearest-tsconfig options.
|
|
99
|
+
* `isExternal` comes from TypeScript's own `isExternalLibraryImport` flag.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveImport(specifier, containingFile) {
|
|
102
|
+
const scope = loadTsConfigFor(containingFile);
|
|
103
|
+
const result = ts.resolveModuleName(specifier, containingFile, scope.options, ts.sys, scope.moduleResolutionCache);
|
|
104
|
+
const mod = result.resolvedModule;
|
|
105
|
+
if (!mod) {
|
|
106
|
+
return { resolvedFileName: null, isExternal: false, resolved: false };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
resolvedFileName: mod.resolvedFileName,
|
|
110
|
+
isExternal: !!mod.isExternalLibraryImport,
|
|
111
|
+
resolved: true
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Clear the process-level scope/config caches. Call at the START of each analyze
|
|
116
|
+
* run so a long-lived MCP process re-reads tsconfigs (and re-walks for new ones)
|
|
117
|
+
* from a clean slate, in addition to the per-entry mtime invalidation above.
|
|
118
|
+
*/
|
|
119
|
+
export function resetResolverCaches() {
|
|
120
|
+
scopeCache.clear();
|
|
121
|
+
nearestConfigByDir.clear();
|
|
122
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { hashString } from "../util/hash.js";
|
|
3
|
+
/**
|
|
4
|
+
* Phase 5.4.3 — persistent module-RESOLUTION cache for `ts.resolveModuleName`.
|
|
5
|
+
*
|
|
6
|
+
* `ts.resolveModuleName` walks the filesystem, so its answer can change with NO
|
|
7
|
+
* change to the importer, tsconfig, or package.json — e.g. a NEW file shadows an
|
|
8
|
+
* older extension (`./foo.js` -> `./foo.ts` once `foo.ts` is added), or the target
|
|
9
|
+
* is moved/deleted. A naive (importer, specifier, tsconfig) key would silently
|
|
10
|
+
* serve a stale resolution -> a wrong COVERS edge.
|
|
11
|
+
*
|
|
12
|
+
* The invalidation story is a GLOBAL GATE: the whole cache is trusted only while
|
|
13
|
+
* the filesystem SHAPE and resolution config are unchanged. The gate =
|
|
14
|
+
* RESOLVER_VERSION + the sorted file-path set + the content of every JSON config
|
|
15
|
+
* (and lockfile). Any of:
|
|
16
|
+
* - a file added / removed / renamed (path set changes) -> new-file shadow, target moved/deleted
|
|
17
|
+
* - a tsconfig/jsconfig paths/baseUrl edit -> path-alias change
|
|
18
|
+
* - an EXTENDED base config edit (tsconfig extends ./base.json) -> conservatively covered
|
|
19
|
+
* - a package.json exports / lockfile edit -> package-export change
|
|
20
|
+
* busts the ENTIRE cache. We hash ALL walked `.json` (plus lockfiles), not only the
|
|
21
|
+
* tsconfig/package files, because a tsconfig can `extends` an arbitrarily-named JSON
|
|
22
|
+
* file whose edit changes resolution (Codex 5.4.3) — conservative over-busting is
|
|
23
|
+
* the safe failure mode. Within a stable gate, resolution is path-level, so a
|
|
24
|
+
* content-only edit to a non-JSON source keeps its hits.
|
|
25
|
+
*
|
|
26
|
+
* TRUST BOUNDARY (deliberately narrow — see Codex 5.4.3 round-4, consistent with the
|
|
27
|
+
* parse cache): this file is TRUSTED LOCAL WORKSPACE STATE, the same trust level as
|
|
28
|
+
* `.orangepro/graph.json` (read back and trusted wholesale). The GATE handles real
|
|
29
|
+
* staleness (FS shape / config), and shape validation handles corruption — but we do
|
|
30
|
+
* NOT claim tamper-resistance. A "membership" check (target is in the walked set) was
|
|
31
|
+
* tried and removed: it only catches foreign/deleted targets the gate already busts on,
|
|
32
|
+
* and cannot prove a same-gate entry resolves to the RIGHT target — i.e. it was theater.
|
|
33
|
+
*/
|
|
34
|
+
// Bump when resolveImport's resolution behavior changes.
|
|
35
|
+
export const RESOLVER_VERSION = 1;
|
|
36
|
+
// Files whose content can change module resolution: ALL JSON configs (tsconfig +
|
|
37
|
+
// transitive `extends` bases, jsconfig, package.json, *.json referenced by config)
|
|
38
|
+
// plus the non-JSON lockfiles. Conservative by design.
|
|
39
|
+
const CONFIG_RE = /(\.json$)|((^|\/)(yarn\.lock|pnpm-lock\.yaml)$)/i;
|
|
40
|
+
/** The gate hash: resolver version + filesystem shape + resolution-config content. */
|
|
41
|
+
export function computeResolverGate(filePaths) {
|
|
42
|
+
const sorted = [...filePaths].sort();
|
|
43
|
+
const configDigest = sorted
|
|
44
|
+
.filter((p) => CONFIG_RE.test(p.replace(/\\/g, "/")))
|
|
45
|
+
.map((p) => {
|
|
46
|
+
try {
|
|
47
|
+
return `${p}:${hashString(readFileSync(p, "utf8"))}`;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return `${p}:?`;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return hashString(`v${RESOLVER_VERSION}\n${sorted.join("\n")}\n${configDigest.join("\n")}`);
|
|
54
|
+
}
|
|
55
|
+
/** Strict shape validation — the persisted file is untrusted input. */
|
|
56
|
+
function validResolvedImport(v) {
|
|
57
|
+
if (!v || typeof v !== "object")
|
|
58
|
+
return null;
|
|
59
|
+
const o = v;
|
|
60
|
+
if (typeof o.resolved !== "boolean" || typeof o.isExternal !== "boolean")
|
|
61
|
+
return null;
|
|
62
|
+
if (o.resolvedFileName !== null && typeof o.resolvedFileName !== "string")
|
|
63
|
+
return null;
|
|
64
|
+
// A "resolved" entry must name a target; an unresolved one must not.
|
|
65
|
+
if (o.resolved !== (typeof o.resolvedFileName === "string"))
|
|
66
|
+
return null;
|
|
67
|
+
return o;
|
|
68
|
+
}
|
|
69
|
+
export class ResolverCache {
|
|
70
|
+
entries;
|
|
71
|
+
gate;
|
|
72
|
+
hits = 0;
|
|
73
|
+
misses = 0;
|
|
74
|
+
constructor(data) {
|
|
75
|
+
this.gate = data?.gate ?? "";
|
|
76
|
+
this.entries = new Map();
|
|
77
|
+
// Persisted entries are untrusted FOR SHAPE: drop anything off-shape (miss + recompute).
|
|
78
|
+
if (data?.entries && typeof data.entries === "object") {
|
|
79
|
+
for (const [k, raw] of Object.entries(data.entries)) {
|
|
80
|
+
const valid = validResolvedImport(raw);
|
|
81
|
+
if (valid)
|
|
82
|
+
this.entries.set(k, valid);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Validate against the current run's gate; a mismatch discards ALL entries so no stale
|
|
87
|
+
* resolution can survive a structural or config change. Call once before resolving. */
|
|
88
|
+
useGate(currentGate) {
|
|
89
|
+
if (this.gate !== currentGate) {
|
|
90
|
+
this.entries = new Map();
|
|
91
|
+
this.gate = currentGate;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Resolution is determined by the containing DIRECTORY (nearest tsconfig + relative base)
|
|
95
|
+
* plus the specifier — files in the same dir share a result for the same specifier. Within a
|
|
96
|
+
* stable gate the entry is trusted (the gate already busts on any FS-shape/config change that
|
|
97
|
+
* could alter resolution); shape was validated on load. */
|
|
98
|
+
resolve(containingDir, specifier, compute) {
|
|
99
|
+
const key = `${containingDir} ${specifier}`;
|
|
100
|
+
const hit = this.entries.get(key);
|
|
101
|
+
if (hit) {
|
|
102
|
+
this.hits++;
|
|
103
|
+
return hit;
|
|
104
|
+
}
|
|
105
|
+
this.misses++;
|
|
106
|
+
const value = compute();
|
|
107
|
+
this.entries.set(key, value);
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
hitRate() {
|
|
111
|
+
const total = this.hits + this.misses;
|
|
112
|
+
return total === 0 ? 0 : Math.round((this.hits / total) * 1000) / 10;
|
|
113
|
+
}
|
|
114
|
+
toData() {
|
|
115
|
+
return { gate: this.gate, entries: Object.fromEntries(this.entries) };
|
|
116
|
+
}
|
|
117
|
+
}
|