@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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. 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
+ }