@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,366 @@
1
+ /**
2
+ * Proof runnability classification (spec: proof-runnability-and-env-profiles, Slice R-1).
3
+ *
4
+ * A loop-level helper for autoProve: when the dynamic-proof oracle reports a RED baseline
5
+ * (baseline_green === false), classify WHY into a specific, actionable reason instead of a
6
+ * generic "needs setup (DB/env)". This mints NO proof and changes NO proof semantics — it
7
+ * only reads the oracle's already-redacted, single-line `baseline.failureSummary` (plus the
8
+ * target package's declared `engines.node` and the runner's Node version) and returns a
9
+ * sanitized { category, reason }. Raw stderr / the full failureSummary is never persisted.
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { dirname, join, posix, resolve, sep } from "node:path";
13
+ import { redactSecrets } from "./util/redact.js";
14
+ /**
15
+ * Categories whose failure is a deterministic property of the TARGET PACKAGE + runner Node,
16
+ * independent of which test runs it — so a same-file sibling can reuse the result WITHOUT
17
+ * re-running. Two qualify — `engine_mismatch` (a package-level fact: engines.node vs the runner
18
+ * Node) and `tsconfig_missing` (the package's tsconfig extends chain; see below). `module_not_found`
19
+ * frequently originates in the failing TEST file's OWN imports
20
+ * (fixtures/helpers), so it is NOT a reliable target-file property and must never drop a clean
21
+ * provable sibling. `experimental_builtin` is likewise test-originated unless the TARGET source
22
+ * is confirmed to reference the builtin — so dedup stays off here (R-2 instead auto-injects the
23
+ * env only on a verified target-source reference). `tsconfig_missing` IS a reliable package-level
24
+ * property: the failure is the PACKAGE's tsconfig `extends`ing a parent outside the isolated sandbox,
25
+ * which breaks transform for EVERY test in the package uniformly (Medplum's "25 attempts, one root
26
+ * cause") — so it dedups safely across same-package siblings.
27
+ */
28
+ export const IMPORT_TIME_CATEGORIES = new Set([
29
+ "engine_mismatch",
30
+ "tsconfig_missing"
31
+ ]);
32
+ /**
33
+ * Only the CONFIDENT env categories are needs_setup. A `logic_failure` is a genuine failing test
34
+ * on the real code; an `unknown` baseline-red is an ambiguous non-proof — neither may be
35
+ * mislabelled needs_setup (that would hide a real unproven behind a "just needs setup" excuse).
36
+ */
37
+ export function isNeedsSetupCategory(category) {
38
+ return category !== "logic_failure" && category !== "unknown";
39
+ }
40
+ const MAX_REASON_DETAIL = 200;
41
+ function shortLine(text) {
42
+ const first = String(text).split("\n", 1)[0] ?? "";
43
+ return first.length > MAX_REASON_DETAIL ? `${first.slice(0, MAX_REASON_DETAIL)}…` : first;
44
+ }
45
+ /**
46
+ * Did the baseline reach an ASSERTION? An assertion-shaped first line means the test ran far
47
+ * enough to evaluate an expectation — so the failure is a genuine logic failure on the real
48
+ * code, NEVER an engine/env block. This wins over every env category (even when the assertion
49
+ * message happens to contain "cannot find module"/"DatabaseSync", or the runner Node is out of
50
+ * the declared engines range): a test that reached an assertion demonstrably RAN.
51
+ */
52
+ function isAssertionShaped(line) {
53
+ return /assertionerror|jestassertionerror|\bexpect\(|\btoBe\b|\btoEqual\b|\btoMatch\b|\btoContain\b|\btoThrow\b|\btoBeInstanceOf\b|to (?:be|equal|match|contain|deep) /i.test(line);
54
+ }
55
+ /**
56
+ * Classify a baseline-red result. Precedence: a genuine ASSERTION (logic) failure FIRST — the
57
+ * test ran and reached an expectation, so it is never env/engine-blocked; then an out-of-range
58
+ * runner Node (engine_mismatch), then import-time builtin/module errors, then external/DB
59
+ * connectivity, else unknown. Only the confident env categories are needs_setup; logic_failure
60
+ * and unknown are honest non-proofs (see isNeedsSetupCategory).
61
+ */
62
+ export function classifyBaselineFailure(input) {
63
+ const line = (input.failureSummary ?? "").trim();
64
+ // 1. logic_failure FIRST — an assertion-shaped line means the test RAN and reached an
65
+ // expectation, so this is a genuine failure on the UNMODIFIED code, not env/engine.
66
+ // Wins over any env substring in the message AND over an out-of-range runner Node.
67
+ if (isAssertionShaped(line)) {
68
+ return {
69
+ category: "logic_failure",
70
+ reason: "The test fails on the unmodified target — a genuine test failure, not an environment problem."
71
+ };
72
+ }
73
+ // 2. engine_mismatch — the runner Node is outside the declared engines.node range (either
74
+ // bound). Reached only for a NON-assertion failure, and only when the range parses AND the
75
+ // version is definitely out (an assertion failure above already proves the Node ran it).
76
+ if (input.enginesNode && input.runnerNode) {
77
+ const ok = satisfiesNodeRange(input.runnerNode, input.enginesNode);
78
+ if (ok === false) {
79
+ return {
80
+ category: "engine_mismatch",
81
+ reason: `Runner Node ${cleanVersion(input.runnerNode)} is outside the target package's engines.node range "${input.enginesNode}"; run on a supported Node version.`
82
+ };
83
+ }
84
+ }
85
+ // 3. experimental_builtin — an unflagged experimental Node builtin (node:sqlite/DatabaseSync).
86
+ // Checked before module_not_found so "Cannot find module 'node:sqlite'" lands here.
87
+ if (/node:sqlite|databasesync|experimental[ -]sqlite|no such built-?in module/i.test(line)) {
88
+ return {
89
+ category: "experimental_builtin",
90
+ reason: "Target imports an experimental Node builtin (node:sqlite/DatabaseSync); run on Node >=24.2 or set NODE_OPTIONS=--experimental-sqlite."
91
+ };
92
+ }
93
+ // 3b. tsconfig_missing (M-4) — the package's tsconfig `extends` a parent config that isn't in the
94
+ // isolated sandbox (a monorepo root tsconfig), so the package fails to TRANSFORM before any
95
+ // test body runs. Package-level + dedupable. Catch both the esbuild warning ("Cannot find base
96
+ // config file") and the vite/oxc fatal ("Tsconfig not found" / "[TSCONFIG_ERROR]"), so it is
97
+ // recognized whichever line the oracle surfaces (Medplum grabbed the warning → fell to unknown).
98
+ if (/cannot find base config|tsconfig not found|failed to load tsconfig|\[tsconfig_error\]/i.test(line)) {
99
+ return {
100
+ category: "tsconfig_missing",
101
+ reason: "The package's tsconfig 'extends' a parent config outside the isolated proof sandbox (a monorepo root tsconfig), so the package can't be compiled to run the baseline."
102
+ };
103
+ }
104
+ // 4. module_not_found — a local/dep import did not resolve (tool-side or a genuinely missing dep).
105
+ if (/cannot find module|module not found|failed to resolve (?:import|entry)|cannot find package|err_module_not_found|cannot resolve|failed to load url/i.test(line)) {
106
+ return {
107
+ category: "module_not_found",
108
+ reason: "A required import did not resolve; install dependencies or fix the import path (not a database problem)."
109
+ };
110
+ }
111
+ // 5. db_or_external — a connection/adapter error to a service the sandbox lacks.
112
+ if (/econnrefused|enotfound|etimedout|econnreset|connection refused|could not connect|connect(?:ion)? tim\w*|getaddrinfo|sequelize|typeorm|postgres|mysql|mongo(?:db)?|redis|prisma|database (?:connection|is not|error)|no database/i.test(line)) {
113
+ return {
114
+ category: "db_or_external",
115
+ reason: "Baseline needs an external service or database that is unavailable in the proof sandbox."
116
+ };
117
+ }
118
+ // 6. unknown — an ambiguous baseline-red (NOT needs_setup; an honest non-killing unproven).
119
+ // Surface the redacted first line so it is still actionable.
120
+ return {
121
+ category: "unknown",
122
+ reason: line ? `Baseline did not pass: ${redactSecrets(shortLine(line))}` : "Baseline did not pass (no failure detail captured)."
123
+ };
124
+ }
125
+ /** Strip a leading `v`/`=` from a version for display. */
126
+ function cleanVersion(v) {
127
+ return v.trim().replace(/^[v=]+/, "");
128
+ }
129
+ /** Parse a (possibly partial/prefixed) version into [major, minor, patch]; null if unparseable. */
130
+ function parseVersion(raw) {
131
+ const cleaned = cleanVersion(raw).split("+")[0].split("-")[0];
132
+ const segs = cleaned.split(".");
133
+ const nums = [];
134
+ for (const s of segs) {
135
+ if (s === "" || s === "x" || s === "X" || s === "*")
136
+ break;
137
+ const n = Number.parseInt(s, 10);
138
+ if (!Number.isInteger(n) || n < 0)
139
+ return null;
140
+ nums.push(n);
141
+ }
142
+ if (nums.length === 0)
143
+ return null;
144
+ return { tuple: [nums[0], nums[1] ?? 0, nums[2] ?? 0], parts: Math.min(nums.length, 3) };
145
+ }
146
+ function cmp(a, b) {
147
+ for (let i = 0; i < 3; i += 1) {
148
+ if (a[i] !== b[i])
149
+ return a[i] < b[i] ? -1 : 1;
150
+ }
151
+ return 0;
152
+ }
153
+ /**
154
+ * Does `version` satisfy the npm-style range `range`?
155
+ * Returns true (in range) / false (definitely out of range) / null (could not confidently
156
+ * parse — callers must NOT treat null as a mismatch). Deliberately conservative: any token
157
+ * it cannot parse makes the whole check return null so it never fabricates a mismatch.
158
+ *
159
+ * Supports the forms `engines.node` realistically uses: `||` (OR), space-separated AND,
160
+ * `>= > <= < =`, caret `^`, tilde `~`, partial versions, and `* x`. Hyphen ranges bail to null.
161
+ */
162
+ export function satisfiesNodeRange(version, range) {
163
+ const v = parseVersion(version);
164
+ if (!v)
165
+ return null;
166
+ const groups = range.split("||");
167
+ let sawParsableGroup = false;
168
+ for (const group of groups) {
169
+ const comparators = group.trim().split(/\s+/).filter(Boolean);
170
+ if (comparators.includes("-"))
171
+ return null; // hyphen range: don't guess
172
+ if (comparators.length === 0) {
173
+ // Empty group ("" / "*") means "any" → satisfied.
174
+ return true;
175
+ }
176
+ let groupOk = true;
177
+ let groupParsable = true;
178
+ for (const c of comparators) {
179
+ const r = satisfiesComparator(v.tuple, c);
180
+ if (r === null) {
181
+ groupParsable = false;
182
+ break;
183
+ }
184
+ if (!r) {
185
+ groupOk = false;
186
+ break;
187
+ }
188
+ }
189
+ if (!groupParsable)
190
+ continue; // skip an unparseable OR-group, try the others
191
+ sawParsableGroup = true;
192
+ if (groupOk)
193
+ return true;
194
+ }
195
+ // Every parsable group failed → definitely out of range; if nothing parsed → unknown.
196
+ return sawParsableGroup ? false : null;
197
+ }
198
+ function satisfiesComparator(v, comparator) {
199
+ const c = comparator.trim();
200
+ if (c === "" || c === "*" || c.toLowerCase() === "x")
201
+ return true;
202
+ if (c.startsWith("^")) {
203
+ const p = parseVersion(c.slice(1));
204
+ if (!p)
205
+ return null;
206
+ const [maj, min, patch] = p.tuple;
207
+ const lower = p.tuple;
208
+ const upper = maj > 0 ? [maj + 1, 0, 0] : min > 0 ? [0, min + 1, 0] : [0, 0, patch + 1];
209
+ return cmp(v, lower) >= 0 && cmp(v, upper) < 0;
210
+ }
211
+ if (c.startsWith("~")) {
212
+ const p = parseVersion(c.slice(1));
213
+ if (!p)
214
+ return null;
215
+ const [maj, min] = p.tuple;
216
+ const lower = p.tuple;
217
+ const upper = p.parts >= 2 ? [maj, min + 1, 0] : [maj + 1, 0, 0];
218
+ return cmp(v, lower) >= 0 && cmp(v, upper) < 0;
219
+ }
220
+ const m = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(c);
221
+ if (!m)
222
+ return null;
223
+ const op = m[1] ?? "";
224
+ const p = parseVersion(m[2]);
225
+ if (!p)
226
+ return null;
227
+ if (op === "" || op === "=") {
228
+ // Bare full version = exact; bare partial (e.g. "24" / "24.2") = a range.
229
+ if (p.parts >= 3)
230
+ return cmp(v, p.tuple) === 0;
231
+ const lower = p.tuple;
232
+ const upper = p.parts === 1 ? [p.tuple[0] + 1, 0, 0] : [p.tuple[0], p.tuple[1] + 1, 0];
233
+ return cmp(v, lower) >= 0 && cmp(v, upper) < 0;
234
+ }
235
+ // A full version: compare the exact tuple.
236
+ if (p.parts >= 3) {
237
+ const d = cmp(v, p.tuple);
238
+ if (op === ">=")
239
+ return d >= 0;
240
+ if (op === "<=")
241
+ return d <= 0;
242
+ if (op === ">")
243
+ return d > 0;
244
+ if (op === "<")
245
+ return d < 0;
246
+ return null;
247
+ }
248
+ // A PARTIAL version with an operator MUST expand as a semver X-range, or an in-range Node is
249
+ // fabricated out (e.g. `<=24` means `<25.0.0`, `>24.2` means `>=24.3.0`). Comparing the raw
250
+ // partial tuple was the engine_mismatch-fabrication bug. Expansion matches node-semver:
251
+ // >=M[.m] → >= M.(m|0).0 <M[.m] → < M.(m|0).0
252
+ // >M → >= (M+1).0.0 >M.m → >= M.(m+1).0
253
+ // <=M → < (M+1).0.0 <=M.m → < M.(m+1).0
254
+ const [maj, min] = p.tuple;
255
+ if (op === ">=")
256
+ return cmp(v, [maj, min, 0]) >= 0;
257
+ if (op === "<")
258
+ return cmp(v, [maj, min, 0]) < 0;
259
+ if (op === ">")
260
+ return cmp(v, p.parts === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0]) >= 0;
261
+ if (op === "<=")
262
+ return cmp(v, p.parts === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0]) < 0;
263
+ return null;
264
+ }
265
+ // ── R-2: node:sqlite / experimental Node builtin env profile ────────────────────────────
266
+ /**
267
+ * The one env profile R-2 auto-applies: inject `--experimental-sqlite` via NODE_OPTIONS through
268
+ * the EXISTING --test-env path (the spike's parseTestEnv allowlists exactly this flag). It only
269
+ * makes a node:sqlite baseline RUNNABLE — it never asserts, mocks, or mints Proven.
270
+ */
271
+ export const EXPERIMENTAL_SQLITE_TEST_ENV = "NODE_OPTIONS=--experimental-sqlite";
272
+ /** A source that references the experimental `node:sqlite` builtin (import or DatabaseSync use). */
273
+ export function referencesExperimentalSqlite(source) {
274
+ return /node:sqlite/.test(source) || /\bDatabaseSync\b/.test(source);
275
+ }
276
+ const REL_IMPORT_SPEC_RE = /(?:\bfrom\s*|\bimport\s*|\brequire\s*\(\s*|\bimport\s*\(\s*)(['"])(\.\.?\/[^'"]*)\1/g;
277
+ const LOCAL_IMPORT_EXTS = ["", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
278
+ function relativeImportSpecs(source) {
279
+ const specs = [];
280
+ for (const m of source.matchAll(REL_IMPORT_SPEC_RE))
281
+ specs.push(m[2]);
282
+ return specs;
283
+ }
284
+ /** Resolve a relative import to a source-root-relative POSIX path the reader can read, else null. */
285
+ function resolveLocalImport(fromRel, spec, reader) {
286
+ const baseDir = posix.dirname(fromRel.split(sep).join("/"));
287
+ const joined = posix.normalize(posix.join(baseDir, spec));
288
+ for (const ext of LOCAL_IMPORT_EXTS) {
289
+ const candidate = `${joined}${ext}`;
290
+ if (reader(candidate) != null)
291
+ return candidate;
292
+ }
293
+ for (const ext of LOCAL_IMPORT_EXTS.slice(1)) {
294
+ const candidate = posix.join(joined, `index${ext}`);
295
+ if (reader(candidate) != null)
296
+ return candidate;
297
+ }
298
+ return null;
299
+ }
300
+ /**
301
+ * EXACT (confident) detection: does the target file — or a same-package local import within a
302
+ * bounded depth/file budget — reference `node:sqlite`/`DatabaseSync`? The `reader` is confined
303
+ * to the source root, so `../other-package` specifiers resolve to null and same-package scope is
304
+ * enforced for free. Only a direct reference in the reachable set counts (never a guess); if the
305
+ * builtin is used only deeper than the budget, R-1 still surfaces it as `experimental_builtin`
306
+ * needs_setup guidance rather than auto-injecting.
307
+ */
308
+ export function targetNeedsExperimentalSqlite(reader, targetFileRel, opts = {}) {
309
+ const maxFiles = opts.maxFiles ?? 60;
310
+ const maxDepth = opts.maxDepth ?? 2;
311
+ const start = targetFileRel.split(sep).join("/");
312
+ const seen = new Set();
313
+ const queue = [{ rel: start, depth: 0 }];
314
+ let scanned = 0;
315
+ while (queue.length > 0 && scanned < maxFiles) {
316
+ const { rel, depth } = queue.shift();
317
+ if (seen.has(rel))
318
+ continue;
319
+ seen.add(rel);
320
+ const src = reader(rel);
321
+ if (src == null)
322
+ continue;
323
+ scanned += 1;
324
+ if (referencesExperimentalSqlite(src))
325
+ return true;
326
+ if (depth >= maxDepth)
327
+ continue;
328
+ for (const spec of relativeImportSpecs(src)) {
329
+ const resolved = resolveLocalImport(rel, spec, reader);
330
+ if (resolved && !seen.has(resolved))
331
+ queue.push({ rel: resolved, depth: depth + 1 });
332
+ }
333
+ }
334
+ return false;
335
+ }
336
+ /**
337
+ * Nearest declared `engines.node` walking up from the target file to the source root
338
+ * (monorepo-aware: a package's own package.json wins over the repo root). Returns undefined
339
+ * when no package.json in range declares engines.node. Never escapes the source root.
340
+ */
341
+ export function readEnginesNode(sourceRoot, targetFileRel) {
342
+ const root = resolve(sourceRoot);
343
+ let dir = resolve(root, dirname(targetFileRel));
344
+ for (;;) {
345
+ if (dir !== root && !dir.startsWith(root + sep))
346
+ return undefined; // escaped the root
347
+ const pj = join(dir, "package.json");
348
+ if (existsSync(pj)) {
349
+ try {
350
+ const parsed = JSON.parse(readFileSync(pj, "utf8"));
351
+ const node = parsed?.engines?.node;
352
+ if (typeof node === "string" && node.trim())
353
+ return node.trim();
354
+ }
355
+ catch {
356
+ /* unreadable/garbage package.json → keep walking up */
357
+ }
358
+ }
359
+ if (dir === root)
360
+ return undefined;
361
+ const parent = dirname(dir);
362
+ if (parent === dir)
363
+ return undefined;
364
+ dir = parent;
365
+ }
366
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * DB-2 recipe: NestJS + TypeORM `sqljs` module-boundary integration spec generator.
3
+ *
4
+ * Productizes the validated sqljs proof recipe. Given an eligible service-method
5
+ * target and its entity, it writes a REAL Vitest integration spec under
6
+ * `orangepro_generated/` that boots the smallest Nest testing module against an
7
+ * in-memory `sqljs` (npm `sql.js` WASM) datasource, seeds rows through the REAL
8
+ * repository, and calls the target method for real — NEVER importing, mocking, or
9
+ * replacing the target service. It emits nothing that mints Proven: the generated
10
+ * spec is a local file, and Proven still flows ONLY from the unchanged dynamic
11
+ * targeted-proof oracle (`opProveLoop`/`opDynamicProof`) once this spec makes the
12
+ * baseline runnable. See docs/local-proof-kit.md and the db-backed-repo spec.
13
+ *
14
+ * Template-based for the Nest + TypeORM + sqljs shape (entity with a seedable text
15
+ * column, service method returning the seeded rows). Generalizing to arbitrary repo
16
+ * shapes is a follow-up.
17
+ */
18
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import { dirname, isAbsolute, posix, relative, resolve } from "node:path";
21
+ import { GENERATED_DIR } from "../generate/runHints.js";
22
+ import { isEligibleProvableTarget } from "../autoProve.js";
23
+ import { resolveTargetSymbol } from "../ledger.js";
24
+ import { loadGraph, workspacePaths } from "../workspace.js";
25
+ const GENERATED_HEADER = "// GENERATED BY OrangePro Local Proof Kit — recipe db-sqljs. Safe to edit or delete.";
26
+ /**
27
+ * Key-name secret filter mirroring the oracle's `isSecretEnvKey` (spike). Recipe-side
28
+ * defense-in-depth so a secret-looking `test_env` key never reaches setup/report/ledger;
29
+ * the oracle rejects it downstream too.
30
+ */
31
+ function isSecretEnvKey(key) {
32
+ return /TOKEN|SECRET|PASSWORD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET[_-]?KEY|PASSPHRASE|CREDENTIAL|PIN|AUTH|COOKIE|SESSION/i.test(key);
33
+ }
34
+ function assertNonSecretTestEnv(testEnv) {
35
+ for (const entry of testEnv) {
36
+ const key = entry.split("=", 1)[0] ?? "";
37
+ if (isSecretEnvKey(key)) {
38
+ throw new Error(`recipe db-sqljs: secret-looking test_env key is not allowed: ${key}`);
39
+ }
40
+ }
41
+ }
42
+ /** Split `sym:file#Class.member` / `file#Class` into file + dotted symbol name. */
43
+ function parseSymbolRef(ref) {
44
+ const stripped = ref.replace(/^sym:/, "");
45
+ const hash = stripped.indexOf("#");
46
+ if (hash <= 0 || hash === stripped.length - 1) {
47
+ throw new Error(`recipe db-sqljs: expected <file>#<Symbol>, got: ${ref}`);
48
+ }
49
+ return { file: stripped.slice(0, hash).split(/[\\/]+/).join("/"), symbol: stripped.slice(hash + 1) };
50
+ }
51
+ /** Extensionless posix import specifier from the spec dir to a source file. */
52
+ function importSpecifier(fromDir, toFileRel) {
53
+ const noExt = toFileRel.replace(/\.[cm]?[jt]sx?$/i, "");
54
+ let rel = posix.relative(fromDir, noExt);
55
+ if (!rel.startsWith("."))
56
+ rel = `./${rel}`;
57
+ return rel;
58
+ }
59
+ function isTsJsFile(file) {
60
+ return /\.[cm]?[jt]sx?$/i.test(file);
61
+ }
62
+ function sqljsResolvable(sourceRoot) {
63
+ try {
64
+ createRequire(resolve(sourceRoot, "package.json")).resolve("sql.js");
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /**
72
+ * Build the emitted setup profile. When sql.js is resolvable the profile is `exact`
73
+ * (auto-runnable) with a quote-free presence check; otherwise it is a `candidate`
74
+ * that surfaces the install instruction but is NOT auto-run (DB-1: no npm-install
75
+ * guessing on the user's checkout). Secret-looking test_env keys are rejected.
76
+ */
77
+ export function buildTypeormSqljsNestProfile(input) {
78
+ const testEnv = input.testEnv ?? [];
79
+ assertNonSecretTestEnv(testEnv);
80
+ if (input.sqljsResolvable) {
81
+ // Exact: sql.js is already resolvable and the sqljs driver runs fully in-memory,
82
+ // so there is nothing to prepare in the source checkout before the oracle copies it.
83
+ // (Presence was verified at generation time.) Empty setup ⇒ opProveLoop proceeds
84
+ // straight to the unchanged oracle.
85
+ return {
86
+ id: "typeorm-sqljs-nest",
87
+ label: "NestJS + TypeORM sqljs in-memory integration",
88
+ setup_commands: [],
89
+ test_env: testEnv,
90
+ confidence: "exact",
91
+ reason: `sql.js is resolvable; the sqljs driver runs fully in-memory (no external DB) — no setup needed. Runner: vitest via ${input.vitestConfig} (unplugin-swc emits decorator metadata for Nest DI + TypeORM).`
92
+ };
93
+ }
94
+ return {
95
+ id: "typeorm-sqljs-nest",
96
+ label: "NestJS + TypeORM sqljs in-memory integration",
97
+ setup_commands: [{ command: "npm", args: ["install", "--no-save", "sql.js"] }],
98
+ test_env: testEnv,
99
+ confidence: "candidate",
100
+ reason: "sql.js is not resolvable. Install it (`npm install sql.js`) then re-prove. Candidate: not auto-run on your checkout."
101
+ };
102
+ }
103
+ function renderSpec(input) {
104
+ const { entityImport, entityClass, serviceImport, serviceClass, method, seedField } = input;
105
+ return `${GENERATED_HEADER}
106
+ import "reflect-metadata";
107
+ import { Test, type TestingModule } from "@nestjs/testing";
108
+ import { TypeOrmModule, getRepositoryToken } from "@nestjs/typeorm";
109
+ import type { Repository } from "typeorm";
110
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
111
+ import { ${entityClass} } from "${entityImport}";
112
+ import { ${serviceClass} } from "${serviceImport}";
113
+
114
+ // Boots the smallest Nest testing module against an in-memory sqljs datasource and
115
+ // exercises ${serviceClass}.${method} through the REAL repository. The target service
116
+ // is imported and constructed by Nest DI — never mocked, replaced, or stubbed — so the
117
+ // dynamic targeted-proof oracle can credit Proven to the real method body.
118
+ describe("${serviceClass}.${method} (sqljs integration)", () => {
119
+ let moduleRef: TestingModule;
120
+ let service: ${serviceClass};
121
+ let repository: Repository<${entityClass}>;
122
+
123
+ beforeAll(async () => {
124
+ moduleRef = await Test.createTestingModule({
125
+ imports: [
126
+ TypeOrmModule.forRoot({
127
+ type: "sqljs",
128
+ autoSave: false,
129
+ synchronize: true,
130
+ entities: [${entityClass}]
131
+ }),
132
+ TypeOrmModule.forFeature([${entityClass}])
133
+ ],
134
+ providers: [${serviceClass}]
135
+ }).compile();
136
+
137
+ service = moduleRef.get(${serviceClass});
138
+ repository = moduleRef.get(getRepositoryToken(${entityClass}));
139
+ await repository.save([{ ${seedField}: "alpha" }, { ${seedField}: "beta" }]);
140
+ });
141
+
142
+ afterAll(async () => {
143
+ await moduleRef?.close();
144
+ });
145
+
146
+ it("returns seeded ${entityClass} rows through the real repository", async () => {
147
+ const rows = await service.${method}();
148
+ expect(rows.map((row) => row.${seedField})).toEqual(["alpha", "beta"]);
149
+ expect(rows).toHaveLength(2);
150
+ });
151
+ });
152
+ `;
153
+ }
154
+ /**
155
+ * Generate the sqljs integration spec + setup profile for an eligible service-method
156
+ * target. Writes ONLY under orangepro_generated/, never overwrites, never escapes root.
157
+ * Refuses targets that do not pass the shared auto-prove eligibility predicate.
158
+ */
159
+ export function opRecipeDbSqljs(root, opts) {
160
+ if (!opts.target_symbol)
161
+ throw new Error("recipe db-sqljs requires --target-symbol sym:<file>#<Class>.<method>.");
162
+ if (!opts.entity)
163
+ throw new Error("recipe db-sqljs requires --entity <file>#<Entity>.");
164
+ if (!opts.out)
165
+ throw new Error("recipe db-sqljs requires --out orangepro_generated/<name>.sqljs.spec.ts.");
166
+ const graph = loadGraph(workspacePaths(root).graphPath);
167
+ const resolved = resolveTargetSymbol(graph, opts.target_symbol);
168
+ if (!resolved) {
169
+ throw new Error(`recipe db-sqljs: --target-symbol did not resolve to exactly one CodeSymbol: ${opts.target_symbol}`);
170
+ }
171
+ const node = graph.nodes.find((n) => n.external_id === resolved);
172
+ // SAME trust barrier as auto-prove target selection: never build a recipe for an
173
+ // excluded/infra/non-entrypoint symbol (Selection Rules). Proof is gated downstream
174
+ // too, but refusing here keeps recipes off plumbing.
175
+ if (!isEligibleProvableTarget(node)) {
176
+ throw new Error(`recipe db-sqljs: ${resolved} is not an eligible provable target ` +
177
+ "(requires kind=CodeSymbol, denominator_eligible, behavior_surface=entrypoint_adjacent, no denominator_reason_code, TS/JS).");
178
+ }
179
+ const target = parseSymbolRef(resolved);
180
+ const targetParts = target.symbol.split(".").filter(Boolean);
181
+ const method = targetParts.pop();
182
+ const serviceClass = targetParts.join(".");
183
+ if (!method || !serviceClass) {
184
+ throw new Error(`recipe db-sqljs: could not derive <Class>.<method> from ${resolved}.`);
185
+ }
186
+ // Resolve --entity through the graph exactly like --target-symbol: it must be an in-root TS/JS
187
+ // CodeSymbol, never an unresolved / out-of-root path. (Graph nodes are in-root by construction, so
188
+ // resolution enforces containment; the explicit path guard gives a clear early error.)
189
+ const entityRef = parseSymbolRef(opts.entity);
190
+ const entityFileRel = entityRef.file.split(/[\\/]+/).join("/");
191
+ if (isAbsolute(entityRef.file) || entityFileRel.startsWith("../") || entityFileRel.includes("/../")) {
192
+ throw new Error(`recipe db-sqljs: --entity file must be a relative path inside the source root: ${entityRef.file}`);
193
+ }
194
+ const entityResolved = resolveTargetSymbol(graph, `sym:${entityRef.file}#${entityRef.symbol}`);
195
+ if (!entityResolved) {
196
+ throw new Error(`recipe db-sqljs: --entity did not resolve to an existing CodeSymbol in the graph: ${opts.entity}. ` +
197
+ "Analyze the repo first and pass an entity class inside the source root.");
198
+ }
199
+ const resolvedEntity = parseSymbolRef(entityResolved);
200
+ const entityClass = resolvedEntity.symbol.split(".").filter(Boolean).pop() ?? "";
201
+ if (!entityClass)
202
+ throw new Error(`recipe db-sqljs: could not derive entity class from ${entityResolved}.`);
203
+ if (!isTsJsFile(resolvedEntity.file))
204
+ throw new Error(`recipe db-sqljs: entity file must be TS/JS: ${resolvedEntity.file}`);
205
+ const seedField = opts.seed_field?.trim() || "name";
206
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(seedField)) {
207
+ throw new Error(`recipe db-sqljs: --seed-field must be a valid identifier: ${seedField}`);
208
+ }
209
+ const sourceRoot = resolve(opts.source ?? root);
210
+ const outRel = opts.out.split(/[\\/]+/).join("/");
211
+ if (isAbsolute(opts.out) || outRel.startsWith("../") || outRel.includes("/../")) {
212
+ throw new Error(`recipe db-sqljs: --out must be a relative path inside the source root: ${opts.out}`);
213
+ }
214
+ const specAbs = resolve(sourceRoot, outRel);
215
+ const generatedRoot = resolve(sourceRoot, GENERATED_DIR);
216
+ const withinGenerated = specAbs === generatedRoot || specAbs.startsWith(`${generatedRoot}/`);
217
+ if (!withinGenerated) {
218
+ throw new Error(`recipe db-sqljs: --out must live under ${GENERATED_DIR}/ (got ${outRel}).`);
219
+ }
220
+ if (!/\.(spec|test)\.[cm]?tsx?$/i.test(outRel)) {
221
+ throw new Error(`recipe db-sqljs: --out must be a .spec.ts / .test.ts file (got ${outRel}).`);
222
+ }
223
+ if (existsSync(specAbs)) {
224
+ throw new Error(`recipe db-sqljs: refusing to overwrite existing file ${outRel}. Delete it or choose another --out.`);
225
+ }
226
+ const specDir = posix.dirname(outRel);
227
+ const spec = renderSpec({
228
+ entityImport: importSpecifier(specDir, resolvedEntity.file),
229
+ entityClass,
230
+ serviceImport: importSpecifier(specDir, target.file),
231
+ serviceClass,
232
+ method,
233
+ seedField
234
+ });
235
+ mkdirSync(dirname(specAbs), { recursive: true });
236
+ writeFileSync(specAbs, spec, "utf8");
237
+ const vitestConfig = "vitest.config.ts";
238
+ const profile = buildTypeormSqljsNestProfile({ sqljsResolvable: sqljsResolvable(sourceRoot), vitestConfig });
239
+ const equivalentRows = JSON.stringify([{ [seedField]: "alpha" }, { [seedField]: "beta" }]);
240
+ return {
241
+ spec_path: specAbs,
242
+ spec_rel: relative(sourceRoot, specAbs).split(/[\\/]+/).join("/"),
243
+ target_symbol: resolved,
244
+ entity_id: entityResolved,
245
+ service_class: serviceClass,
246
+ entity_class: entityClass,
247
+ method,
248
+ seed_field: seedField,
249
+ runner: "vitest",
250
+ vitest_config: vitestConfig,
251
+ profile,
252
+ genuine_mutation: { method, replacement: "return [];", replacement_mode: "return-json" },
253
+ equivalent_mutation: { method, replacement: `return ${equivalentRows};`, replacement_mode: "return-json" }
254
+ };
255
+ }
@@ -0,0 +1,13 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ export function resolveContained(root, relOrAbs) {
3
+ const abs = resolve(root, relOrAbs);
4
+ if (abs !== root && !abs.startsWith(root + sep))
5
+ throw new Error("--test path must stay inside the workspace.");
6
+ return abs;
7
+ }
8
+ export function toWorkspaceRel(root, abs) {
9
+ const rel = relative(root, abs);
10
+ if (rel === "" || rel.startsWith("..") || rel.includes(`${sep}..${sep}`))
11
+ throw new Error("--test path must stay inside the workspace.");
12
+ return rel.split(sep).join("/");
13
+ }