@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,2586 @@
1
+ import ts from "typescript";
2
+ import path from "node:path";
3
+ import { isBuiltin } from "node:module";
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { extractImports } from "../resolve/importGraph.js";
8
+ import { deriveSubjectImport } from "./deriveImports.js";
9
+ import { resolveImport, loadTsConfigFor } from "../resolve/resolver.js";
10
+ import { GENERATED_DIR } from "./runHints.js";
11
+ import { behaviorNodes, findNode, nodesByKind, priorityRank } from "../graph/factories.js";
12
+ import { structurallyUnconfirmable } from "../graph/confirmable.js";
13
+ import { shortHash } from "../util/hash.js";
14
+ import { reportProgress } from "../util/progress.js";
15
+ import { redactSecrets, redactSecretsPreservingLineCount } from "../util/redact.js";
16
+ import { systemClock } from "../util/time.js";
17
+ import { languageOf } from "../analyze/classify.js";
18
+ import { buildGroundedUserPrompt, buildRawUserPrompt, buildSystemPrompt, PROMPT_VERSION } from "./prompt.js";
19
+ import { buildBatchGenerationSystemPromptV5, buildBatchGenerationUserPromptV5, buildPlanningRepairSystemPromptV5, buildPlanningRepairUserPromptV5, buildPlanningSystemPromptV5, buildPlanningUserPromptV5, hasRepairableScenarioStructure, parseBatchGeneratedTests, parsePlannedScenariosStrict, scenarioTiesBackToRaw, PROMPT_VERSION_V5 } from "./promptV5.js";
20
+ import { BUCKET_LABEL, deriveBucketSignals, selectLocalBuckets } from "./buckets.js";
21
+ const MAX_LIMIT = 5;
22
+ const DEFAULT_LIMIT = 3;
23
+ const MAX_RELATED_FILES = 4;
24
+ const SYMBOL_EXCERPT_CONTEXT_LINES = 3;
25
+ const MAX_EXCERPT_CHARS = 8000;
26
+ const MAX_TARGET_TYPE_EXCERPT_CHARS = 5000;
27
+ const STATIC_CHECK_TIMEOUT_MS = 3000;
28
+ const GO_COMPILE_CHECK_TIMEOUT_MS = 20000;
29
+ /** A source ref that names a test file (used to place a generated test next to it). */
30
+ const TEST_REF_RE = /(\.(test|spec)\.[cm]?[jt]sx?$)|((^|\/)test\.[cm]?[jt]sx?$)|(_test\.[a-z]+$)|(_spec\.[a-z]+$)|((^|\/)test_[^/]+\.[a-z]+$)/i;
31
+ function areaOf(relPath) {
32
+ const parts = relPath.split("/").filter(Boolean);
33
+ const skip = new Set(["src", "app", "lib", "packages", "tests", "test", "e2e", "__tests__", "spec"]);
34
+ for (const part of parts.slice(0, parts.length - 1)) {
35
+ if (!skip.has(part.toLowerCase()))
36
+ return part;
37
+ }
38
+ return parts.length > 1 ? parts[0] : "core";
39
+ }
40
+ function dedupe(items) {
41
+ return [...new Set(items.filter((s) => s && s.trim()))];
42
+ }
43
+ function asStringArray(value) {
44
+ if (Array.isArray(value))
45
+ return value.map((v) => String(v));
46
+ if (typeof value === "string" && value.trim())
47
+ return [value.trim()];
48
+ return [];
49
+ }
50
+ function isTsJsLanguage(language) {
51
+ return language === "typescript" || language === "javascript";
52
+ }
53
+ function testFilesForSourceFile(graph, sourceFile) {
54
+ const out = new Set();
55
+ for (const ce of graph.candidate_edges) {
56
+ if (ce.relationship_type !== "MAY_RELATE_TO")
57
+ continue;
58
+ if (ce.to_external_id !== sourceFile)
59
+ continue;
60
+ const testFile = ce.from_external_id;
61
+ const testNode = findNode(graph, `test:${testFile}`);
62
+ const fileNode = findNode(graph, testFile);
63
+ if (testNode?.kind === "TestCase" || fileNode?.properties.role === "test" || TEST_REF_RE.test(testFile))
64
+ out.add(testFile);
65
+ }
66
+ return [...out];
67
+ }
68
+ /**
69
+ * Strip a Markdown code fence (and any prose around it) from a model body. Real
70
+ * BYOK models frequently wrap the test in ```ts … ``` with a prose preamble/suffix;
71
+ * the fenced content is the runnable code. With no fence, return the body as-is so
72
+ * the deterministic stand-in and already-clean models are untouched.
73
+ */
74
+ export function stripCodeFence(body) {
75
+ const m = body.match(/```[a-zA-Z0-9]*\s*\n?([\s\S]*?)```/);
76
+ return (m ? m[1] : body).trim();
77
+ }
78
+ function acceptanceCriteriaFor(graph, behavior) {
79
+ const out = asStringArray(behavior.properties.acceptance_criteria);
80
+ for (const e of graph.edges) {
81
+ if (e.relationship_type === "HAS_ACCEPTANCE_CRITERION" && e.from_external_id === behavior.external_id) {
82
+ const ac = findNode(graph, e.to_external_id);
83
+ if (ac)
84
+ out.push(ac.title || String(ac.properties.text ?? ac.external_id));
85
+ }
86
+ }
87
+ return dedupe(out);
88
+ }
89
+ function relatedFilePaths(graph, behavior) {
90
+ const files = new Set();
91
+ const testFiles = new Set();
92
+ const area = String(behavior.properties.area ?? "");
93
+ if (behavior.kind === "CodeSymbol" && typeof behavior.properties.file === "string") {
94
+ const sourceFile = behavior.properties.file;
95
+ files.add(sourceFile);
96
+ for (const testFile of testFilesForSourceFile(graph, sourceFile)) {
97
+ files.add(testFile);
98
+ testFiles.add(testFile);
99
+ }
100
+ }
101
+ for (const ce of graph.candidate_edges) {
102
+ if (ce.from_external_id === behavior.external_id &&
103
+ (ce.relationship_type === "MAY_BE_TESTED_BY" || ce.relationship_type === "MAY_COVER")) {
104
+ const t = findNode(graph, ce.to_external_id);
105
+ if (t?.properties.file) {
106
+ files.add(String(t.properties.file));
107
+ testFiles.add(String(t.properties.file));
108
+ }
109
+ }
110
+ }
111
+ // Resolved test->source linkage (import graph): the source modules the
112
+ // behavior's test files actually IMPORT are the strongest code grounding —
113
+ // added before the same-area filler so they are never displaced by
114
+ // unrelated config-ish files in the same directory area.
115
+ if (testFiles.size) {
116
+ for (const ce of graph.candidate_edges) {
117
+ if (ce.relationship_type !== "MAY_RELATE_TO")
118
+ continue;
119
+ if (testFiles.has(ce.from_external_id))
120
+ files.add(ce.to_external_id);
121
+ else if (testFiles.has(ce.to_external_id))
122
+ files.add(ce.from_external_id);
123
+ }
124
+ }
125
+ for (const e of graph.edges) {
126
+ if ((e.relationship_type === "TESTED_BY" || e.relationship_type === "COVERS" || e.relationship_type === "IMPLEMENTED_IN") &&
127
+ (e.from_external_id === behavior.external_id || e.to_external_id === behavior.external_id)) {
128
+ const other = e.from_external_id === behavior.external_id ? e.to_external_id : e.from_external_id;
129
+ const t = findNode(graph, other);
130
+ if (t?.properties.file)
131
+ files.add(String(t.properties.file));
132
+ else if (t?.kind === "File")
133
+ files.add(t.external_id);
134
+ }
135
+ }
136
+ if (area) {
137
+ for (const n of graph.nodes) {
138
+ if (files.size >= MAX_RELATED_FILES)
139
+ break;
140
+ if (n.kind === "File" && n.properties.role === "code" && areaOf(n.external_id) === area)
141
+ files.add(n.external_id);
142
+ }
143
+ }
144
+ return { files: [...files].slice(0, MAX_RELATED_FILES), testFiles: [...testFiles] };
145
+ }
146
+ const MAX_SUBJECT_IMPORTS = 10;
147
+ /** Rebuild one import line from parse metadata (specifier + binding names — never source text). */
148
+ function reconstructImportLine(specifier, bindings) {
149
+ if (!bindings.length)
150
+ return null; // side-effect/dynamic imports are not reconstructable subjects
151
+ const def = bindings.find((b) => b.imported === "default");
152
+ const ns = bindings.find((b) => b.imported === "*");
153
+ const named = bindings.filter((b) => b.imported !== "default" && b.imported !== "*");
154
+ const parts = [];
155
+ if (def)
156
+ parts.push(def.local);
157
+ if (ns)
158
+ parts.push(`* as ${ns.local}`);
159
+ if (named.length) {
160
+ parts.push(`{${named.map((b) => (b.imported === b.local ? b.local : `${b.imported} as ${b.local}`)).join(", ")}}`);
161
+ }
162
+ // JSON.stringify escapes quotes/backslashes in the specifier (an unescaped
163
+ // quote would emit an invalid import line).
164
+ return `import ${parts.join(", ")} from ${JSON.stringify(specifier)};`;
165
+ }
166
+ /**
167
+ * Working import lines for a NEW sibling test, reconstructed from the linked
168
+ * EXISTING test file's own imports (the repo already proves these specifiers
169
+ * resolve under its tsconfig/jest config). Reconstruction uses parse METADATA
170
+ * only — specifier strings and binding names, never copied source text.
171
+ */
172
+ const TS_JS_FILE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
173
+ function subjectImportsFor(graph, testFiles) {
174
+ for (const rel of testFiles) {
175
+ if (!TS_JS_FILE_RE.test(rel))
176
+ continue; // parse metadata is TS/JS-only
177
+ const collected = [];
178
+ for (const imp of extractImports(path.join(graph.workspace.root, rel))) {
179
+ if (imp.kind !== "runtime" || !imp.bindings.length)
180
+ continue;
181
+ const line = reconstructImportLine(imp.specifier, imp.bindings);
182
+ if (line && !collected.some((c) => c.line === line)) {
183
+ collected.push({ line, internal: imp.specifier.startsWith(".") });
184
+ }
185
+ }
186
+ if (collected.length) {
187
+ // Relative (internal) imports first so the SUBJECT module — usually the
188
+ // last import in a long test file — always survives the cap. Stable sort
189
+ // keeps the original order within each group.
190
+ collected.sort((a, b) => Number(b.internal) - Number(a.internal));
191
+ return collected.slice(0, MAX_SUBJECT_IMPORTS).map((c) => c.line);
192
+ }
193
+ }
194
+ return [];
195
+ }
196
+ function stringProp(value) {
197
+ return typeof value === "string" && value.trim() ? value : null;
198
+ }
199
+ function numberProp(value) {
200
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
201
+ }
202
+ function symbolFile(node) {
203
+ return stringProp(node.properties.file);
204
+ }
205
+ function symbolStart(node) {
206
+ return numberProp(node.properties.start_line);
207
+ }
208
+ function symbolEnd(node) {
209
+ return numberProp(node.properties.end_line);
210
+ }
211
+ function symbolKind(node) {
212
+ return String(node.properties.symbol_kind ?? "");
213
+ }
214
+ function isTypeLikeSymbol(node) {
215
+ return /^(class|struct|type|interface|enum|record|trait)$/.test(symbolKind(node));
216
+ }
217
+ function symbolHasSpan(node) {
218
+ return node.kind === "CodeSymbol" && !!symbolFile(node) && symbolStart(node) !== null && symbolEnd(node) !== null;
219
+ }
220
+ function sliceLines(content, startLine, endLine, contextLines = 0) {
221
+ const lines = redactSecretsPreservingLineCount(content).split(/\r?\n/);
222
+ const start = Math.max(1, startLine - contextLines);
223
+ const end = Math.min(lines.length, endLine + contextLines);
224
+ return { text: lines.slice(start - 1, end).join("\n").trim(), start, end };
225
+ }
226
+ function sourceExcerptForSymbol(node, fileReader, label) {
227
+ const file = symbolFile(node);
228
+ const start = symbolStart(node);
229
+ const end = symbolEnd(node);
230
+ if (!file || start === null || end === null)
231
+ return null;
232
+ const raw = fileReader(file);
233
+ if (!raw)
234
+ return null;
235
+ let slice = sliceLines(raw, start, end, isTypeLikeSymbol(node) ? 0 : SYMBOL_EXCERPT_CONTEXT_LINES);
236
+ const exact = sliceLines(raw, start, end, 0);
237
+ if (!slice.text)
238
+ return null;
239
+ if (label === "target symbol" && isTypeLikeSymbol(node) && slice.text.length > MAX_TARGET_TYPE_EXCERPT_CHARS) {
240
+ slice = {
241
+ ...slice,
242
+ text: `${slice.text.slice(0, MAX_TARGET_TYPE_EXCERPT_CHARS - 58)}\n// [orangepro: target excerpt truncated to reserve type budget]`
243
+ };
244
+ }
245
+ const title = node.title || node.external_id.replace(/^sym:[^#]+#/, "");
246
+ return {
247
+ key: `${file}:${slice.start}-${slice.end}:${label}:${title}`,
248
+ file,
249
+ label,
250
+ text: `// file: ${file} lines ${slice.start}-${slice.end} (${label}: ${title})\n${slice.text}`,
251
+ snippet: exact.text || slice.text
252
+ };
253
+ }
254
+ function sourceExcerptForFileHead(file, fileReader, label, maxLines = 30) {
255
+ const raw = fileReader(file);
256
+ if (!raw)
257
+ return null;
258
+ const text = redactSecrets(raw)
259
+ .split(/\r?\n/)
260
+ .filter((l) => l.trim())
261
+ .slice(0, maxLines)
262
+ .join("\n");
263
+ if (!text)
264
+ return null;
265
+ return { key: `${file}:head:${label}`, file, label, text: `// file: ${file} (${label})\n${text}`, snippet: text };
266
+ }
267
+ function sourceExcerptForImports(file, fileReader) {
268
+ const raw = fileReader(file);
269
+ if (!raw)
270
+ return null;
271
+ const lines = redactSecrets(raw).split(/\r?\n/);
272
+ const out = [];
273
+ let inGoImportBlock = false;
274
+ for (const line of lines.slice(0, 80)) {
275
+ const trimmed = line.trim();
276
+ if (!trimmed) {
277
+ if (inGoImportBlock)
278
+ out.push(line);
279
+ continue;
280
+ }
281
+ if (/^(package|import|from\s+\S+\s+import|using\s+)/.test(trimmed))
282
+ out.push(line);
283
+ if (/^import\s*\($/.test(trimmed))
284
+ inGoImportBlock = true;
285
+ else if (inGoImportBlock) {
286
+ out.push(line);
287
+ if (trimmed === ")")
288
+ inGoImportBlock = false;
289
+ }
290
+ }
291
+ const text = out.join("\n").trim();
292
+ if (!text)
293
+ return null;
294
+ return { key: `${file}:imports`, file, label: "imports", text: `// file: ${file} (imports)\n${text}`, snippet: text };
295
+ }
296
+ function sourceExcerptForSymbolSignature(node, fileReader, label) {
297
+ const file = symbolFile(node);
298
+ const start = symbolStart(node);
299
+ const end = symbolEnd(node);
300
+ if (!file || start === null || end === null)
301
+ return null;
302
+ const raw = fileReader(file);
303
+ if (!raw)
304
+ return null;
305
+ const lines = raw.split(/\r?\n/);
306
+ const picked = [];
307
+ for (let i = start - 1; i < Math.min(lines.length, end, start + 3); i++) {
308
+ picked.push(lines[i]);
309
+ if (lines[i]?.includes("{"))
310
+ break;
311
+ }
312
+ const text = redactSecrets(picked.join("\n")).trim();
313
+ if (!text)
314
+ return null;
315
+ const title = node.title || node.external_id.replace(/^sym:[^#]+#/, "");
316
+ return {
317
+ key: `${file}:${start}:signature:${label}:${title}`,
318
+ file,
319
+ label,
320
+ text: `// file: ${file} line ${start} (${label}: ${title})\n${text}`,
321
+ snippet: text
322
+ };
323
+ }
324
+ function symbolTitleSet(graph) {
325
+ const out = new Map();
326
+ for (const n of graph.nodes) {
327
+ if (n.kind !== "CodeSymbol")
328
+ continue;
329
+ const name = n.title || n.external_id.replace(/^sym:[^#]+#/, "");
330
+ const list = out.get(name);
331
+ if (list)
332
+ list.push(n);
333
+ else
334
+ out.set(name, [n]);
335
+ }
336
+ return out;
337
+ }
338
+ const COMMON_IDENTIFIER_WORDS = new Set([
339
+ "true",
340
+ "false",
341
+ "nil",
342
+ "null",
343
+ "undefined",
344
+ "return",
345
+ "func",
346
+ "type",
347
+ "struct",
348
+ "interface",
349
+ "const",
350
+ "var",
351
+ "let",
352
+ "string",
353
+ "number",
354
+ "boolean",
355
+ "error",
356
+ "Error"
357
+ ]);
358
+ function identifiersIn(text) {
359
+ return dedupe([...text.matchAll(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)].map((m) => m[0])).filter((name) => name.length > 1 && !COMMON_IDENTIFIER_WORDS.has(name));
360
+ }
361
+ function directReferencedSymbols(graph, behavior) {
362
+ const out = [];
363
+ const add = (id) => {
364
+ if (id === behavior.external_id)
365
+ return;
366
+ const n = findNode(graph, id);
367
+ if (n?.kind === "CodeSymbol" && symbolHasSpan(n))
368
+ out.push(n);
369
+ };
370
+ for (const e of graph.edges) {
371
+ if (e.from_external_id === behavior.external_id && ["CALLS", "IMPORTS", "MAY_CALL", "USES"].includes(e.relationship_type))
372
+ add(e.to_external_id);
373
+ else if (e.to_external_id === behavior.external_id && ["CALLS", "IMPORTS", "MAY_CALL", "USES"].includes(e.relationship_type))
374
+ add(e.from_external_id);
375
+ }
376
+ for (const ce of graph.candidate_edges) {
377
+ if (ce.from_external_id === behavior.external_id && ["MAY_RELATE_TO", "MAY_COVER", "MAY_CALL"].includes(ce.relationship_type))
378
+ add(ce.to_external_id);
379
+ else if (ce.to_external_id === behavior.external_id && ["MAY_RELATE_TO", "MAY_COVER", "MAY_CALL"].includes(ce.relationship_type))
380
+ add(ce.from_external_id);
381
+ }
382
+ return out;
383
+ }
384
+ function referencedSymbolsByName(graph, targetBody, behavior) {
385
+ const byName = symbolTitleSet(graph);
386
+ const out = [];
387
+ for (const ident of identifiersIn(targetBody)) {
388
+ for (const candidate of byName.get(ident) ?? []) {
389
+ if (candidate.external_id !== behavior.external_id && symbolHasSpan(candidate))
390
+ out.push(candidate);
391
+ }
392
+ }
393
+ return out;
394
+ }
395
+ function symbolDir(node) {
396
+ const file = symbolFile(node) ?? "";
397
+ return file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : ".";
398
+ }
399
+ function rankedReferencedSymbols(graph, behavior, targetBody) {
400
+ const direct = directReferencedSymbols(graph, behavior);
401
+ const directIds = new Set(direct.map((n) => n.external_id));
402
+ const behaviorDir = behavior.kind === "CodeSymbol" ? symbolDir(behavior) : "";
403
+ const fallback = referencedSymbolsByName(graph, targetBody, behavior);
404
+ return dedupe([...direct, ...fallback].map((n) => n.external_id))
405
+ .map((id) => findNode(graph, id))
406
+ .filter((n) => !!n && n.kind === "CodeSymbol" && symbolHasSpan(n))
407
+ .sort((a, b) => {
408
+ const directRank = Number(directIds.has(b.external_id)) - Number(directIds.has(a.external_id));
409
+ if (directRank !== 0)
410
+ return directRank;
411
+ const samePackageRank = Number(symbolDir(b) === behaviorDir) - Number(symbolDir(a) === behaviorDir);
412
+ if (samePackageRank !== 0)
413
+ return samePackageRank;
414
+ return Number(isTypeLikeSymbol(b)) - Number(isTypeLikeSymbol(a)) || String(a.title ?? "").localeCompare(String(b.title ?? ""));
415
+ })
416
+ .slice(0, 8);
417
+ }
418
+ function associatedSymbolsForTypeTarget(graph, behavior) {
419
+ const file = symbolFile(behavior);
420
+ if (behavior.kind !== "CodeSymbol" || !file || !isTypeLikeSymbol(behavior))
421
+ return [];
422
+ return graph.nodes
423
+ .filter((n) => n.kind === "CodeSymbol" &&
424
+ n.external_id !== behavior.external_id &&
425
+ symbolHasSpan(n) &&
426
+ symbolFile(n) === file &&
427
+ !isTypeLikeSymbol(n))
428
+ .sort((a, b) => (symbolStart(a) ?? 0) - (symbolStart(b) ?? 0))
429
+ .slice(0, 4);
430
+ }
431
+ function addExcerpt(excerpts, candidate, seen, budget) {
432
+ if (seen.has(candidate.key))
433
+ return;
434
+ let text = candidate.text;
435
+ const remaining = MAX_EXCERPT_CHARS - budget.chars;
436
+ if (remaining <= 0)
437
+ return;
438
+ if (text.length > remaining) {
439
+ if (!excerpts.length && remaining > 240)
440
+ text = `${text.slice(0, remaining - 56)}\n// [orangepro: excerpt truncated to budget]`;
441
+ else
442
+ return;
443
+ }
444
+ excerpts.push(text);
445
+ seen.add(candidate.key);
446
+ budget.chars += text.length;
447
+ }
448
+ function buildSourceExcerpts(graph, behavior, relatedFiles, testFiles, fileReader) {
449
+ const excerpts = [];
450
+ const seen = new Set();
451
+ const budget = { chars: 0 };
452
+ const target = behavior.kind === "CodeSymbol" && symbolHasSpan(behavior) ? sourceExcerptForSymbol(behavior, fileReader, "target symbol") : null;
453
+ let hasBodyExcerpt = false;
454
+ if (target) {
455
+ addExcerpt(excerpts, target, seen, budget);
456
+ hasBodyExcerpt = true;
457
+ }
458
+ const targetBody = target?.snippet ?? "";
459
+ for (const sym of rankedReferencedSymbols(graph, behavior, targetBody)) {
460
+ const label = isTypeLikeSymbol(sym) ? "referenced type" : "referenced symbol";
461
+ const excerpt = sourceExcerptForSymbol(sym, fileReader, label);
462
+ if (excerpt) {
463
+ addExcerpt(excerpts, excerpt, seen, budget);
464
+ hasBodyExcerpt = true;
465
+ }
466
+ }
467
+ for (const sym of associatedSymbolsForTypeTarget(graph, behavior)) {
468
+ const label = symbolKind(sym) === "method" ? "associated method" : "associated symbol";
469
+ const excerpt = sourceExcerptForSymbolSignature(sym, fileReader, label);
470
+ if (excerpt) {
471
+ addExcerpt(excerpts, excerpt, seen, budget);
472
+ hasBodyExcerpt = true;
473
+ }
474
+ }
475
+ for (const file of testFiles) {
476
+ const excerpt = sourceExcerptForFileHead(file, fileReader, "existing test", 40);
477
+ if (excerpt) {
478
+ addExcerpt(excerpts, excerpt, seen, budget);
479
+ hasBodyExcerpt = true;
480
+ }
481
+ }
482
+ if (!hasBodyExcerpt) {
483
+ for (const file of relatedFiles) {
484
+ const excerpt = sourceExcerptForFileHead(file, fileReader, "related file", 30);
485
+ if (excerpt) {
486
+ addExcerpt(excerpts, excerpt, seen, budget);
487
+ hasBodyExcerpt = true;
488
+ }
489
+ }
490
+ }
491
+ for (const file of relatedFiles) {
492
+ const excerpt = sourceExcerptForImports(file, fileReader);
493
+ if (excerpt)
494
+ addExcerpt(excerpts, excerpt, seen, budget);
495
+ }
496
+ if (!excerpts.length) {
497
+ for (const file of relatedFiles) {
498
+ const excerpt = sourceExcerptForFileHead(file, fileReader, "related file", 30);
499
+ if (excerpt)
500
+ addExcerpt(excerpts, excerpt, seen, budget);
501
+ }
502
+ }
503
+ return excerpts;
504
+ }
505
+ /**
506
+ * Assemble the grounded generation context (behavior, acceptance criteria, code
507
+ * context, redacted source excerpts, weak/candidate disclosure) for one behavior.
508
+ * Exported so local comparison tools can reuse the same evidence-gathering path
509
+ * as normal graph-grounded generation.
510
+ */
511
+ export function gatherContext(graph, behavior, framework, fileReader) {
512
+ const acceptance = acceptanceCriteriaFor(graph, behavior);
513
+ const workflow = asStringArray(behavior.properties.workflow_steps);
514
+ const actors = asStringArray(behavior.properties.actors);
515
+ const examples = asStringArray(behavior.properties.example_behaviors);
516
+ const { files: relatedFiles, testFiles } = relatedFilePaths(graph, behavior);
517
+ // Subject imports are TS/JS lines — feeding them to a pytest/go target would
518
+ // produce an unparseable test in the other language.
519
+ const fwLower = framework.toLowerCase();
520
+ const subjectImports = fwLower.includes("pytest") || fwLower.includes("python") || fwLower.includes("go") || fwLower.includes("junit") || fwLower.includes("java")
521
+ ? []
522
+ : subjectImportsFor(graph, testFiles);
523
+ const codeContext = [...relatedFiles];
524
+ for (const file of relatedFiles) {
525
+ const syms = graph.nodes
526
+ .filter((n) => n.kind === "CodeSymbol" && n.properties.file === file)
527
+ .slice(0, 5)
528
+ .map((n) => `${file}:${n.title}`);
529
+ codeContext.push(...syms);
530
+ }
531
+ // In-process, redacted source excerpts — used for the prompt only, never stored.
532
+ const excerpts = buildSourceExcerpts(graph, behavior, relatedFiles, testFiles, fileReader);
533
+ const weakContext = [];
534
+ const weakUsed = [];
535
+ if (behavior.evidence_strength === "weak" || behavior.evidence_strength === "candidate") {
536
+ weakContext.push(`Behavior anchor "${behavior.title}" is inferred (${behavior.review_status}).`);
537
+ weakUsed.push(`inferred_anchor:${behavior.external_id}`);
538
+ }
539
+ for (const ce of graph.candidate_edges) {
540
+ if (ce.from_external_id === behavior.external_id) {
541
+ weakContext.push(`${ce.relationship_type}: ${ce.reason} (confidence ${ce.confidence}).`);
542
+ weakUsed.push(`${ce.relationship_type}:${ce.from_external_id}->${ce.to_external_id}`);
543
+ }
544
+ }
545
+ const flowChain = flowChainFor(graph, behavior);
546
+ const ctx = {
547
+ behavior_external_id: behavior.external_id,
548
+ behavior_title: behavior.title || behavior.external_id,
549
+ description: behavior.properties.description ? String(behavior.properties.description) : undefined,
550
+ actors,
551
+ priority: behavior.properties.priority ? String(behavior.properties.priority) : undefined,
552
+ acceptance_criteria: acceptance,
553
+ workflow_steps: workflow,
554
+ framework,
555
+ test_layer: inferLayer(behavior, framework),
556
+ code_context: dedupe(codeContext),
557
+ source_excerpts: excerpts,
558
+ weak_context: dedupe(weakContext),
559
+ // Existing coverage (observed test names): shown so the model generates what is
560
+ // MISSING, never a re-derivation of a test that already exists.
561
+ existing_tests: examples.slice(0, 10),
562
+ subject_imports: subjectImports,
563
+ ...(flowChain ? { flow_chain: flowChain } : {})
564
+ };
565
+ // entity_ids are graph external_ids: the behavior plus related file paths. A
566
+ // File node's external_id IS its workspace-relative path (analyzer), so these
567
+ // resolve in the citation index (graph/citations.ts). A path with no scanned
568
+ // File node would surface as an unresolved citation rather than fabricated proof.
569
+ const entityIds = dedupe([behavior.external_id, ...relatedFiles]);
570
+ const sourceRefs = dedupe([
571
+ behavior.provenance?.source_ref ?? "",
572
+ ...relatedFiles
573
+ ]);
574
+ return { ctx, entityIds, sourceRefs, weakUsed: dedupe(weakUsed) };
575
+ }
576
+ /**
577
+ * FLOW CHAIN context for the v5 planning prompt, fed ONLY from deterministic
578
+ * `analysis.flows` (hard/framework-derived). AI candidate flows
579
+ * (`analysis.candidate_flows`) are NEVER prompt input — no AI-feeds-AI.
580
+ * Undefined when no deterministic flow contains the target behavior.
581
+ */
582
+ function flowChainFor(graph, behavior) {
583
+ const flows = graph.analysis?.flows?.flows ?? [];
584
+ const id = behavior.external_id;
585
+ const flow = flows.find((f) => f.hops.length > 0 && (f.entry_point.external_id === id || f.hops.some((h) => h.from === id || h.to === id)));
586
+ if (!flow)
587
+ return undefined;
588
+ const nodesById = new Map(graph.nodes.map((n) => [n.external_id, n]));
589
+ const chain = [flow.hops[0].from, ...flow.hops.map((h) => h.to)];
590
+ return chain.map((symbolId, index) => {
591
+ const node = nodesById.get(symbolId);
592
+ const title = node?.title || symbolId.replace(/^sym:/, "").split("#").pop() || symbolId;
593
+ const dot = title.indexOf(".");
594
+ const file = typeof node?.properties.file === "string" ? node.properties.file : "";
595
+ const fileStem = file.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "";
596
+ return {
597
+ behavior_id: symbolId,
598
+ behavior_title: title,
599
+ service: dot > 0 ? title.slice(0, dot) : fileStem || title,
600
+ method: dot > 0 ? title.slice(dot + 1) : title,
601
+ position: index + 1
602
+ };
603
+ });
604
+ }
605
+ function inferLayer(behavior, framework) {
606
+ const fw = framework.toLowerCase();
607
+ if (fw.includes("playwright") || fw.includes("cypress"))
608
+ return "e2e";
609
+ if (fw.includes("supertest"))
610
+ return "api";
611
+ if (fw.includes("testing-library"))
612
+ return "component";
613
+ const hint = String(behavior.properties.test_layer ?? "");
614
+ if (hint)
615
+ return hint;
616
+ return "unit";
617
+ }
618
+ function tooThin(ctx) {
619
+ const needed = [];
620
+ if (ctx.acceptance_criteria.length === 0)
621
+ needed.push("acceptance criteria or expected outcomes");
622
+ if (!ctx.description && ctx.workflow_steps.length === 0)
623
+ needed.push("a behavior description or workflow steps");
624
+ if (ctx.code_context.length === 0)
625
+ needed.push("linked code, tests, or interface mapping");
626
+ // Too thin only when there is essentially nothing to ground a specific assertion.
627
+ // existing_tests counts: observed test names are real behavioral evidence (they
628
+ // used to ride in weak_context; moving them to their own section must not flip
629
+ // example-behaviors-only anchors to "too thin").
630
+ const hasAnyAnchor = ctx.acceptance_criteria.length > 0 ||
631
+ Boolean(ctx.description) ||
632
+ ctx.workflow_steps.length > 0 ||
633
+ ctx.code_context.length > 0 ||
634
+ ctx.weak_context.length > 0 ||
635
+ ctx.existing_tests.length > 0;
636
+ return { thin: !hasAnyAnchor, needed };
637
+ }
638
+ function frameworkForLanguage(language) {
639
+ if (language === "python")
640
+ return "pytest";
641
+ if (language === "go")
642
+ return "go";
643
+ if (language === "java")
644
+ return "junit";
645
+ return null;
646
+ }
647
+ function javaFrameworkFromGraph(graph) {
648
+ const names = nodesByKind(graph, "Framework")
649
+ .filter((n) => n.properties.category === "test")
650
+ .map((n) => String(n.title ?? "").toLowerCase());
651
+ if (names.some((n) => n.includes("junit4")))
652
+ return "junit4";
653
+ if (names.some((n) => n.includes("junit5") || n.includes("junit-jupiter")))
654
+ return "junit5";
655
+ return null;
656
+ }
657
+ function frameworkLanguageGroup(framework) {
658
+ const fw = framework.toLowerCase();
659
+ if (fw.includes("pytest") || fw.includes("python"))
660
+ return "python";
661
+ if (fw.includes("go"))
662
+ return "go";
663
+ if (fw.includes("junit") || fw.includes("java"))
664
+ return "java";
665
+ if (fw.includes("vitest") ||
666
+ fw.includes("jest") ||
667
+ fw.includes("ava") ||
668
+ fw.includes("playwright") ||
669
+ fw.includes("cypress") ||
670
+ fw.includes("mocha")) {
671
+ return "tsjs";
672
+ }
673
+ return null;
674
+ }
675
+ function languageMatchesFramework(language, framework) {
676
+ const group = frameworkLanguageGroup(framework);
677
+ if (!group || !language)
678
+ return true;
679
+ if (group === "tsjs")
680
+ return isTsJsLanguage(language);
681
+ return language === group;
682
+ }
683
+ function languageOfRelatedFile(graph, rel) {
684
+ const fileNode = findNode(graph, rel);
685
+ const fromNode = fileNode?.kind === "File" && typeof fileNode.properties.language === "string" ? fileNode.properties.language : "";
686
+ return fromNode || languageOf(rel);
687
+ }
688
+ function canGenerateForLanguage(language) {
689
+ return ["typescript", "javascript", "python", "go", "java"].includes(language);
690
+ }
691
+ function primaryTargetLanguage(graph, target) {
692
+ const files = relatedFilePaths(graph, target).files;
693
+ const firstCode = files.find((rel) => {
694
+ const fileNode = findNode(graph, rel);
695
+ return fileNode?.kind === "File" && fileNode.properties.role === "code";
696
+ });
697
+ const rel = firstCode ?? files[0];
698
+ return rel ? languageOfRelatedFile(graph, rel) : null;
699
+ }
700
+ function canGenerateForTarget(graph, target) {
701
+ const language = primaryTargetLanguage(graph, target);
702
+ if (!language) {
703
+ const hasEligibleCodeSymbols = graph.nodes.some((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && n.stale !== true);
704
+ return target.behavior_source !== "test_inferred" || !hasEligibleCodeSymbols;
705
+ }
706
+ return canGenerateForLanguage(language);
707
+ }
708
+ function isWeakTestNameBehavior(n) {
709
+ return n.behavior_source === "test_inferred" || n.properties.inferred_from === "test_describe";
710
+ }
711
+ function hasLinkedTestEvidence(graph, n) {
712
+ if (n.kind !== "CodeSymbol" || n.denominator_eligible !== true || n.stale === true)
713
+ return false;
714
+ const file = typeof n.properties.file === "string" ? n.properties.file : "";
715
+ const language = file ? languageOfRelatedFile(graph, file) : "";
716
+ // Real-model smoke shows concrete code symbols help TS/JS and Go, but currently
717
+ // reduce pass rate for Python/Java versus their test-name behavior anchors.
718
+ if (!file || (!isTsJsLanguage(language) && language !== "go"))
719
+ return false;
720
+ return testFilesForSourceFile(graph, file).length > 0;
721
+ }
722
+ function inferTsJsFrameworkFromTest(content) {
723
+ if (/from\s+["']ava["']|require\(["']ava["']\)/.test(content))
724
+ return "ava";
725
+ if (/@playwright\/test|from\s+["']playwright["']|require\(["']@playwright\/test["']\)/.test(content))
726
+ return "playwright";
727
+ if (/from\s+["']vitest["']|require\(["']vitest["']\)|\bvi\./.test(content))
728
+ return "vitest";
729
+ if (/from\s+["']@jest\/globals["']|require\(["']@jest\/globals["']\)|\bjest\./.test(content))
730
+ return "jest";
731
+ if (/from\s+["']cypress["']|require\(["']cypress["']\)|\bcy\./.test(content))
732
+ return "cypress";
733
+ if (/from\s+["']mocha["']|require\(["']mocha["']\)|from\s+["']chai["']|require\(["']chai["']\)/.test(content))
734
+ return "mocha";
735
+ return null;
736
+ }
737
+ function pickTsJsFrameworkFromRelatedTests(graph, targets, fileReader) {
738
+ const counts = new Map();
739
+ const priority = ["vitest", "jest", "ava", "playwright", "cypress", "mocha"];
740
+ for (const target of targets) {
741
+ if (!languageMatchesFramework(primaryTargetLanguage(graph, target), "vitest"))
742
+ continue;
743
+ const related = relatedFilePaths(graph, target);
744
+ const testRefs = [...related.testFiles, ...related.files.filter((r) => TEST_REF_RE.test(r))];
745
+ for (const rel of dedupe(testRefs)) {
746
+ const fw = inferTsJsFrameworkFromTest(fileReader(rel) ?? "");
747
+ if (fw)
748
+ counts.set(fw, (counts.get(fw) ?? 0) + 1);
749
+ }
750
+ }
751
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || priority.indexOf(a[0]) - priority.indexOf(b[0]))[0]?.[0] ?? null;
752
+ }
753
+ function pickFramework(graph, opts, targets = [], fileReader) {
754
+ if (opts.framework)
755
+ return opts.framework;
756
+ for (const target of targets) {
757
+ const language = primaryTargetLanguage(graph, target);
758
+ const inferred = language === "java" ? javaFrameworkFromGraph(graph) ?? "junit" : language ? frameworkForLanguage(language) : null;
759
+ if (inferred)
760
+ return inferred;
761
+ }
762
+ const relatedTsJs = fileReader ? pickTsJsFrameworkFromRelatedTests(graph, targets, fileReader) : null;
763
+ if (relatedTsJs)
764
+ return relatedTsJs;
765
+ const fw = nodesByKind(graph, "Framework").find((n) => n.properties.category === "test");
766
+ return fw?.title || "vitest";
767
+ }
768
+ function targetsForFramework(graph, targets, framework) {
769
+ const group = frameworkLanguageGroup(framework);
770
+ if (!group)
771
+ return { targets, warnings: [] };
772
+ const kept = targets.filter((target) => languageMatchesFramework(primaryTargetLanguage(graph, target), framework));
773
+ const skipped = targets.length - kept.length;
774
+ return {
775
+ targets: kept,
776
+ warnings: skipped
777
+ ? [
778
+ `${skipped} supported target(s) in other languages skipped for ${framework} generation; rerun those targets separately so every runnable test matches its language.`
779
+ ]
780
+ : []
781
+ };
782
+ }
783
+ export function selectTargets(graph, opts) {
784
+ const warnings = [];
785
+ const behaviors = behaviorNodes(graph);
786
+ const codeSymbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && n.stale !== true);
787
+ const explicitGenerationTargets = [...behaviors, ...codeSymbols];
788
+ const concreteLinkedTargets = behaviors.length > 0 && behaviors.every(isWeakTestNameBehavior) ? codeSymbols.filter((n) => hasLinkedTestEvidence(graph, n)) : [];
789
+ const concreteLinkedIds = new Set(concreteLinkedTargets.map((n) => n.external_id));
790
+ const rawDefaultTargets = behaviors.length ? [...concreteLinkedTargets, ...behaviors] : codeSymbols;
791
+ const defaultGenerationTargets = rawDefaultTargets.filter((n) => canGenerateForTarget(graph, n));
792
+ const nsc = structurallyUnconfirmable(graph);
793
+ const isNsc = (n) => nsc.has(n.external_id);
794
+ if (opts.target_ids && opts.target_ids.length) {
795
+ const targets = [];
796
+ for (const id of opts.target_ids) {
797
+ const node = findNode(graph, id);
798
+ if (!node) {
799
+ warnings.push(`Target ${id} not found in graph.`);
800
+ continue;
801
+ }
802
+ if (explicitGenerationTargets.includes(node) && canGenerateForTarget(graph, node))
803
+ targets.push(node);
804
+ else
805
+ warnings.push(`Target ${id} is a ${node.kind}, not a supported generation target; skipped.`);
806
+ }
807
+ return { targets, warnings, nsc_ids: targets.filter(isNsc).map((n) => n.external_id) };
808
+ }
809
+ const ranked = [...defaultGenerationTargets].sort((a, b) => {
810
+ const ca = concreteLinkedIds.has(a.external_id) ? 0 : 1;
811
+ const cb = concreteLinkedIds.has(b.external_id) ? 0 : 1;
812
+ if (ca !== cb)
813
+ return ca - cb;
814
+ // not_structurally_confirmable behaviors rank BELOW every structurally-
815
+ // confirmable gap (Phase 4.7): a generated test for an e2e/api-only behavior
816
+ // cannot be structurally confirmed, so it is a lower-value target.
817
+ const na = isNsc(a) ? 1 : 0;
818
+ const nb = isNsc(b) ? 1 : 0;
819
+ if (na !== nb)
820
+ return na - nb; // non-nsc (0) first
821
+ const pr = priorityRank(b.properties.priority) - priorityRank(a.properties.priority);
822
+ if (pr !== 0)
823
+ return pr;
824
+ const strengthOrder = (n) => (n.evidence_strength === "hard" || n.evidence_strength === "reviewed" ? 1 : 0);
825
+ return strengthOrder(b) - strengthOrder(a);
826
+ });
827
+ const nsc_ids = ranked.filter(isNsc).map((n) => n.external_id);
828
+ if (nsc_ids.length > 0) {
829
+ warnings.push(`${nsc_ids.length} behavior(s) are only covered by e2e/api tests (not structurally confirmable) — offered as targets but ranked last and excluded from the confirmed denominator.`);
830
+ }
831
+ if (!behaviors.length && codeSymbols.length) {
832
+ warnings.push("No requirement/user-flow anchors found; targeting eligible code symbols instead.");
833
+ }
834
+ if (concreteLinkedTargets.length > 0) {
835
+ warnings.push(`Generation prioritized ${concreteLinkedTargets.length} concrete code symbol target(s) with linked test evidence over weak test-name-only behavior labels.`);
836
+ }
837
+ const skippedUnsupported = rawDefaultTargets.length - defaultGenerationTargets.length;
838
+ if (skippedUnsupported > 0) {
839
+ warnings.push(`${skippedUnsupported} target(s) use languages without runnable local templates yet; graph evidence is kept, but generation is skipped.`);
840
+ }
841
+ return { targets: ranked, warnings, nsc_ids };
842
+ }
843
+ /**
844
+ * Post-generation guard. A strict pack schema cannot stop a model from echoing
845
+ * proprietary source inside the one free-text field (`body`), so we sanitize the
846
+ * output BEFORE it is stored or exported: blanket-redact secrets, then strip any
847
+ * line that overlaps a source excerpt we fed into the prompt.
848
+ */
849
+ export function sanitizeGeneratedBody(body, sourceExcerpts, commentPrefix = "//") {
850
+ const cleaned = redactSecrets(body);
851
+ const norm = (s) => s.replace(/\s+/g, " ").trim();
852
+ const secretLines = new Set();
853
+ const protectedLines = new Set();
854
+ const protectedBlocks = [];
855
+ for (const block of sourceExcerpts) {
856
+ const blockLines = block.split(/\r?\n/);
857
+ const header = blockLines[0] ?? "";
858
+ const protectVerbatim = !/\(existing test\)/.test(header);
859
+ const currentBlock = [];
860
+ let inImportBlock = false;
861
+ for (const line of blockLines) {
862
+ if (line.startsWith("// file:"))
863
+ continue;
864
+ const trimmed = line.trim();
865
+ if (/^import\s*\($/.test(trimmed)) {
866
+ if (currentBlock.length >= 2)
867
+ protectedBlocks.push([...currentBlock]);
868
+ currentBlock.length = 0;
869
+ inImportBlock = true;
870
+ continue;
871
+ }
872
+ if (inImportBlock) {
873
+ if (trimmed === ")")
874
+ inImportBlock = false;
875
+ continue;
876
+ }
877
+ const n = norm(line);
878
+ if (n.length < 20 || isPureImportLine(line) || isApiSurfaceLine(line)) {
879
+ if (currentBlock.length >= 2)
880
+ protectedBlocks.push([...currentBlock]);
881
+ currentBlock.length = 0;
882
+ continue;
883
+ }
884
+ if (n.includes("<redacted:"))
885
+ secretLines.add(n);
886
+ else if (!protectVerbatim)
887
+ continue;
888
+ else {
889
+ protectedLines.add(n);
890
+ currentBlock.push(n);
891
+ }
892
+ }
893
+ if (currentBlock.length >= 2)
894
+ protectedBlocks.push([...currentBlock]);
895
+ }
896
+ if (secretLines.size === 0 && protectedLines.size === 0 && protectedBlocks.length === 0)
897
+ return { body: cleaned, redactedLines: 0 };
898
+ let redactedLines = 0;
899
+ const lines = cleaned.split(/\r?\n/);
900
+ const normalizedLines = lines.map(norm);
901
+ const redactedIndexes = new Set();
902
+ for (let i = 0; i < lines.length; i++) {
903
+ if (isPureImportLine(lines[i]))
904
+ continue;
905
+ if (isGoImportSpecLine(lines[i]))
906
+ continue;
907
+ if (isApiSurfaceLine(lines[i]))
908
+ continue;
909
+ const n = normalizedLines[i];
910
+ if (n.length < 20)
911
+ continue;
912
+ for (const p of [...secretLines, ...protectedLines]) {
913
+ if (n.includes(p) || p.includes(n))
914
+ redactedIndexes.add(i);
915
+ }
916
+ }
917
+ for (const block of protectedBlocks) {
918
+ for (let i = 0; i <= normalizedLines.length - block.length; i++) {
919
+ let matched = true;
920
+ for (let j = 0; j < block.length; j++) {
921
+ const n = normalizedLines[i + j];
922
+ const p = block[j];
923
+ if (n.length < 20 ||
924
+ isPureImportLine(lines[i + j]) ||
925
+ isGoImportSpecLine(lines[i + j]) ||
926
+ isApiSurfaceLine(lines[i + j]) ||
927
+ !(n.includes(p) || p.includes(n))) {
928
+ matched = false;
929
+ break;
930
+ }
931
+ }
932
+ if (matched) {
933
+ for (let j = 0; j < block.length; j++)
934
+ redactedIndexes.add(i + j);
935
+ }
936
+ }
937
+ }
938
+ const out = lines
939
+ .map((line, i) => {
940
+ if (!redactedIndexes.has(i))
941
+ return line;
942
+ redactedLines++;
943
+ const indent = line.match(/^\s*/)?.[0] ?? "";
944
+ return `${indent}${commentPrefix} ${REDACTION_MARKER_TEXT}`;
945
+ })
946
+ .join("\n");
947
+ return { body: cleanRedactionMarkersInGoImportBlocks(out), redactedLines };
948
+ }
949
+ function isApiSurfaceLine(line) {
950
+ const t = line.trim();
951
+ if (!t || /^(\/\/|#|\/\*|\*|\*\/)/.test(t))
952
+ return true;
953
+ if (/^(package|namespace|using)\b/.test(t))
954
+ return true;
955
+ if (/^(export\s+)?(type|interface|enum|class|struct|record|trait)\b/.test(t))
956
+ return true;
957
+ if (/^(public|private|protected)?\s*(static\s+)?(final\s+)?(class|interface|enum|record)\b/.test(t))
958
+ return true;
959
+ if (/^(const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*([A-Z0-9_.'"`-]+|\[[^\]]*\]|\{[^{}]*\})\s*;?$/.test(t))
960
+ return true;
961
+ if (/^const\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*(\"[^\"]*\"|'[^']*'|`[^`]*`|[0-9_.-]+|true|false)\s*$/.test(t))
962
+ return true;
963
+ return false;
964
+ }
965
+ function isGoImportSpecLine(line) {
966
+ const t = line.trim();
967
+ return /^([A-Za-z_][A-Za-z0-9_]*|[._])?\s*"(?:[^"\\]|\\.)+"$/.test(t);
968
+ }
969
+ function cleanRedactionMarkersInGoImportBlocks(body) {
970
+ if (!body.includes(REDACTION_MARKER_TEXT))
971
+ return body;
972
+ const out = [];
973
+ let inBlock = false;
974
+ for (const line of body.split(/\r?\n/)) {
975
+ const trimmed = line.trim();
976
+ if (!inBlock) {
977
+ out.push(line);
978
+ if (/^import\s*\($/.test(trimmed))
979
+ inBlock = true;
980
+ continue;
981
+ }
982
+ if (line.includes(REDACTION_MARKER_TEXT))
983
+ continue;
984
+ if (trimmed === ")") {
985
+ out.push(line);
986
+ inBlock = false;
987
+ continue;
988
+ }
989
+ if (trimmed && !isGoImportSpecLine(line) && !trimmed.startsWith("//")) {
990
+ out.push(")");
991
+ inBlock = false;
992
+ out.push(line);
993
+ continue;
994
+ }
995
+ out.push(line);
996
+ }
997
+ if (inBlock)
998
+ out.push(")");
999
+ return out.join("\n");
1000
+ }
1001
+ /**
1002
+ * Import/from lines are module paths + exported names — metadata the kit already
1003
+ * discloses in source_refs — so they are exempt from source-excerpt redaction
1004
+ * (redacting them only breaks runnability; the model is EXPECTED to reproduce the
1005
+ * repo's import lines). Exemption requires the line to be a PURE import statement:
1006
+ * a prefix check alone would let `import x from "./m"; secretCode()` smuggle an
1007
+ * echoed source line through on its tail, so the line must parse as exactly one
1008
+ * import/export-from declaration with NOTHING after it (no second statement, no
1009
+ * trailing comment riding along).
1010
+ */
1011
+ function isPureImportLine(line) {
1012
+ const t = line.trim();
1013
+ if (!t)
1014
+ return false;
1015
+ // Python: `from x import a, b` / `import x, y` — char classes exclude `;`, `(`,
1016
+ // and `#`, so one-liners (`import os; os.system(...)`) and comment tails fail.
1017
+ if (/^(from\s+[\w.]+\s+import\s+[\w*,\s]+|import\s+[\w.,\s]+)$/.test(t))
1018
+ return true;
1019
+ if (/^import\s+(static\s+)?[A-Za-z_][A-Za-z0-9_.*]*(\.[A-Za-z_][A-Za-z0-9_*]*)*\s*;$/.test(t))
1020
+ return true;
1021
+ if (!/^(import\b|export\b)/.test(t))
1022
+ return false;
1023
+ const sf = ts.createSourceFile("line.ts", t, ts.ScriptTarget.Latest, /*setParentNodes*/ false);
1024
+ if (sf.statements.length !== 1)
1025
+ return false; // a second statement = smuggled code
1026
+ const s = sf.statements[0];
1027
+ const pure = ts.isImportDeclaration(s) || (ts.isExportDeclaration(s) && s.moduleSpecifier !== undefined);
1028
+ if (!pure)
1029
+ return false;
1030
+ // Nothing may trail the statement (s.end excludes trailing trivia, so an
1031
+ // appended comment — `import x from "./m"; // secret` — is rejected here).
1032
+ return t.slice(s.end).replace(/[;\s]/g, "") === "";
1033
+ }
1034
+ const REDACTION_MARKER_TEXT = "[orangepro: source excerpt redacted]";
1035
+ /**
1036
+ * Post-redaction AST cleanup: a statement whose head or body was redacted leaves
1037
+ * dangling fragments (e.g. an orphaned `}));` from a half-redacted `jest.mock`
1038
+ * block) that break the whole file. Drop every statement that CONTAINS a
1039
+ * redaction marker between its real start and end (a marker comment sitting
1040
+ * ABOVE a statement is leading trivia and never condemns it), so the emitted
1041
+ * test always parses. Parse-only (`ts.createSourceFile`), TS/JS frameworks only.
1042
+ */
1043
+ export function stripRedactedStatements(body) {
1044
+ if (!body.includes(REDACTION_MARKER_TEXT))
1045
+ return { body, dropped: 0 };
1046
+ const sf = ts.createSourceFile("generated.tsx", body, ts.ScriptTarget.Latest, /*setParentNodes*/ true, ts.ScriptKind.TSX);
1047
+ const ranges = [];
1048
+ for (const stmt of sf.statements) {
1049
+ const start = stmt.getStart(sf); // excludes leading trivia
1050
+ if (body.slice(start, stmt.getEnd()).includes(REDACTION_MARKER_TEXT))
1051
+ ranges.push([start, stmt.getEnd()]);
1052
+ }
1053
+ if (!ranges.length)
1054
+ return { body, dropped: 0 };
1055
+ let out = body;
1056
+ for (let i = ranges.length - 1; i >= 0; i--) {
1057
+ const [start, end] = ranges[i];
1058
+ out = out.slice(0, start) + "// [orangepro: statement removed — it echoed redacted source]" + out.slice(end);
1059
+ }
1060
+ return { body: out, dropped: ranges.length };
1061
+ }
1062
+ function firstGoPackage(files, fileReader) {
1063
+ for (const rel of files) {
1064
+ if (!/\.go$/i.test(rel))
1065
+ continue;
1066
+ const content = fileReader(rel);
1067
+ const m = content?.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)\b/m);
1068
+ if (m)
1069
+ return m[1];
1070
+ }
1071
+ return null;
1072
+ }
1073
+ function applyGoPackage(body, packageName) {
1074
+ if (!packageName)
1075
+ return body;
1076
+ if (/^\s*package\s+[A-Za-z_][A-Za-z0-9_]*\b/m.test(body)) {
1077
+ return body.replace(/^\s*package\s+[A-Za-z_][A-Za-z0-9_]*\b/m, `package ${packageName}`);
1078
+ }
1079
+ return `package ${packageName}\n\n${body}`;
1080
+ }
1081
+ function firstJavaPackage(files, fileReader) {
1082
+ for (const rel of files) {
1083
+ if (!/\.java$/i.test(rel))
1084
+ continue;
1085
+ const content = fileReader(rel);
1086
+ const m = content?.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m);
1087
+ if (m)
1088
+ return m[1];
1089
+ }
1090
+ return null;
1091
+ }
1092
+ function applyJavaPackage(body, packageName) {
1093
+ if (!packageName)
1094
+ return body;
1095
+ if (/^\s*package\s+[A-Za-z_][A-Za-z0-9_.]*\s*;/m.test(body)) {
1096
+ return body.replace(/^\s*package\s+[A-Za-z_][A-Za-z0-9_.]*\s*;/m, `package ${packageName};`);
1097
+ }
1098
+ return `package ${packageName};\n\n${body}`;
1099
+ }
1100
+ function leadingLicenseHeader(content) {
1101
+ const text = content.replace(/^\uFEFF/, "");
1102
+ const block = text.match(/^\s*(\/\*[\s\S]*?\*\/)/);
1103
+ const line = text.match(/^\s*((?:\/\/[^\n]*(?:\n|$))+)/);
1104
+ const header = (block?.[1] ?? line?.[1] ?? "").trimEnd();
1105
+ if (!header)
1106
+ return null;
1107
+ if (!/(SPDX-License-Identifier|Licensed under|Copyright|\blicense\b)/i.test(header))
1108
+ return null;
1109
+ return header;
1110
+ }
1111
+ function firstJavaLicenseHeader(files, fileReader) {
1112
+ for (const rel of files) {
1113
+ if (!/\.java$/i.test(rel))
1114
+ continue;
1115
+ const header = leadingLicenseHeader(fileReader(rel) ?? "");
1116
+ if (header)
1117
+ return header;
1118
+ }
1119
+ return null;
1120
+ }
1121
+ function applyJavaLicenseHeader(body, header) {
1122
+ if (!header || body.trimStart().startsWith(header))
1123
+ return body;
1124
+ return `${header}\n\n${body}`;
1125
+ }
1126
+ function ensureGoTestingImport(body) {
1127
+ if (!/\bfunc\s+Test[A-Za-z0-9_]*\s*\(\s*t\s+\*testing\.T\s*\)/m.test(body))
1128
+ return body;
1129
+ if (goImportSpecs(body).includes("testing"))
1130
+ return body;
1131
+ const lines = body.split(/\r?\n/);
1132
+ const singleImport = lines.findIndex((l) => /^\s*import\s+"[^"]+"\s*$/.test(l));
1133
+ if (singleImport >= 0) {
1134
+ const existing = lines[singleImport].trim().replace(/^import\s+/, "");
1135
+ lines.splice(singleImport, 1, "import (", `\t${existing}`, '\t"testing"', ")");
1136
+ return lines.join("\n");
1137
+ }
1138
+ const blockImport = lines.findIndex((l) => /^\s*import\s*\(\s*$/.test(l));
1139
+ if (blockImport >= 0) {
1140
+ lines.splice(blockImport + 1, 0, '\t"testing"');
1141
+ return lines.join("\n");
1142
+ }
1143
+ const pkg = lines.findIndex((l) => /^\s*package\s+[A-Za-z_][A-Za-z0-9_]*\b/.test(l));
1144
+ if (pkg >= 0) {
1145
+ lines.splice(pkg + 1, 0, "", 'import "testing"');
1146
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
1147
+ }
1148
+ return body;
1149
+ }
1150
+ function javaJUnitFlavor(framework) {
1151
+ return framework.toLowerCase().includes("junit4") ? "junit4" : "junit5";
1152
+ }
1153
+ function ensureJavaJUnitImports(body, framework) {
1154
+ if (!/@Test\b/.test(body) && !/\bassert(?:True|False|Equals|NotNull|Null|Throws)\s*\(|\bAssertions\./.test(body))
1155
+ return body;
1156
+ const imports = [];
1157
+ const flavor = javaJUnitFlavor(framework);
1158
+ const hasAnyTestImport = /\bimport\s+org\.junit(?:\.jupiter\.api)?\.Test\s*;/.test(body);
1159
+ const hasAnyStaticAssertImport = /\bimport\s+static\s+org\.junit\.(?:jupiter\.api\.Assertions|Assert)\./.test(body);
1160
+ if (/@Test\b/.test(body) && !hasAnyTestImport) {
1161
+ imports.push(flavor === "junit4" ? "import org.junit.Test;" : "import org.junit.jupiter.api.Test;");
1162
+ }
1163
+ if (/\bassert(?:True|False|Equals|NotNull|Null|Throws)\s*\(/.test(body) &&
1164
+ !hasAnyStaticAssertImport) {
1165
+ imports.push(flavor === "junit4" ? "import static org.junit.Assert.*;" : "import static org.junit.jupiter.api.Assertions.*;");
1166
+ }
1167
+ if (flavor === "junit4" && /\bAssert\./.test(body) && !/\bimport\s+org\.junit\.Assert\s*;/.test(body)) {
1168
+ imports.push("import org.junit.Assert;");
1169
+ }
1170
+ if (flavor === "junit5" && /\bAssertions\./.test(body) && !/\bimport\s+org\.junit\.jupiter\.api\.Assertions\s*;/.test(body)) {
1171
+ imports.push("import org.junit.jupiter.api.Assertions;");
1172
+ }
1173
+ if (!imports.length)
1174
+ return body;
1175
+ const lines = body.split(/\r?\n/);
1176
+ let insertAt = -1;
1177
+ for (let i = 0; i < lines.length; i++) {
1178
+ if (/^\s*package\s+[A-Za-z_][A-Za-z0-9_.]*\s*;/.test(lines[i]) || /^\s*import\s+/.test(lines[i]))
1179
+ insertAt = i;
1180
+ }
1181
+ lines.splice(insertAt + 1, 0, ...imports);
1182
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
1183
+ }
1184
+ function ensureFrameworkScaffold(body, framework) {
1185
+ const fw = framework.toLowerCase();
1186
+ if (fw.includes("go"))
1187
+ return ensureGoTestingImport(body);
1188
+ if (fw.includes("junit") || fw.includes("java"))
1189
+ return ensureJavaJUnitImports(body, framework);
1190
+ if (isResolverFramework(framework))
1191
+ return ensureTsJsFrameworkBindings(body, framework);
1192
+ return body;
1193
+ }
1194
+ function commentPrefixForFramework(framework) {
1195
+ const fw = framework.toLowerCase();
1196
+ if (fw.includes("pytest") || fw.includes("python"))
1197
+ return "#";
1198
+ return "//";
1199
+ }
1200
+ /**
1201
+ * True when the body still contains EXECUTABLE content. A body reduced to
1202
+ * comments/markers by redaction+strip must not ship as a "generated test" —
1203
+ * it would look like a test while asserting nothing. Import/export-only bodies
1204
+ * count as empty too: redaction can strip the only real test statement and
1205
+ * leave just the import lines behind, and an import-only "test" proves nothing.
1206
+ */
1207
+ export function hasExecutableContent(body, framework) {
1208
+ const fw = framework.toLowerCase();
1209
+ if (fw.includes("pytest") || fw.includes("python") || fw.includes("go") || fw.includes("junit") || fw.includes("java")) {
1210
+ // Tracks multi-line import groups (Go's `import ( ... )`, Python's
1211
+ // `from x import (a, b)` spread across lines) and Python docstrings.
1212
+ // Parens are counted on the line with any trailing #/// comment stripped:
1213
+ // a comment like `# fallback(legacy` must not open a phantom group that
1214
+ // swallows the next real line, and a Go in-group comment containing `)`
1215
+ // must not close the group early.
1216
+ let inImportBlock = false;
1217
+ let docDelim = null;
1218
+ return body.split(/\r?\n/).some((l) => {
1219
+ const t = l.trim();
1220
+ if (docDelim) {
1221
+ // Python closes a string only on the delimiter that OPENED it — a
1222
+ // """ inside a '''-docstring is prose, not a close.
1223
+ if (t.includes(docDelim))
1224
+ docDelim = null;
1225
+ return false;
1226
+ }
1227
+ if (t.length === 0 || t.startsWith("#") || t.startsWith("//"))
1228
+ return false;
1229
+ const doc = t.match(/^("""|''')/);
1230
+ if (doc) {
1231
+ const rest = t.slice(3);
1232
+ const close = rest.indexOf(doc[1]);
1233
+ if (close === -1) {
1234
+ docDelim = doc[1];
1235
+ return false;
1236
+ }
1237
+ // Self-closing one-liner: anything after the close is real code.
1238
+ return rest.slice(close + 3).replace(/(#|\/\/).*$/, "").trim().length > 0;
1239
+ }
1240
+ const code = t.replace(/(#|\/\/).*$/, "").trimEnd();
1241
+ if (inImportBlock) {
1242
+ if (code.includes(")"))
1243
+ inImportBlock = false;
1244
+ return false;
1245
+ }
1246
+ if (/^(import|from|package)\b/.test(code)) {
1247
+ if ((code.match(/\(/g) ?? []).length > (code.match(/\)/g) ?? []).length)
1248
+ inImportBlock = true;
1249
+ return false;
1250
+ }
1251
+ return true;
1252
+ });
1253
+ }
1254
+ const sf = ts.createSourceFile("check.tsx", body, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
1255
+ return sf.statements.some((s) => !ts.isImportDeclaration(s) && !ts.isExportDeclaration(s) && !ts.isImportEqualsDeclaration(s) && !ts.isExportAssignment(s));
1256
+ }
1257
+ /** Body already carries its own imports (the prompt demands complete imports). */
1258
+ const BODY_HAS_IMPORTS_RE = /^\s*(import\b|from\s+\S+\s+import\b)/m;
1259
+ function frameworkImport(framework) {
1260
+ const fw = framework.toLowerCase();
1261
+ if (fw.includes("ava"))
1262
+ return 'import test from "ava";';
1263
+ if (fw.includes("playwright"))
1264
+ return 'import { test, expect } from "@playwright/test";';
1265
+ if (fw.includes("cypress"))
1266
+ return "// Cypress globals (describe/it/cy) are ambient — no import needed.";
1267
+ if (fw.includes("jest"))
1268
+ return 'import { describe, it, expect, jest } from "@jest/globals";';
1269
+ if (fw.includes("mocha"))
1270
+ return 'import { describe, it } from "mocha";\nimport { expect } from "chai";';
1271
+ if (fw.includes("pytest"))
1272
+ return "import pytest";
1273
+ if (fw.includes("junit4"))
1274
+ return "import org.junit.Test;\nimport static org.junit.Assert.*;";
1275
+ if (fw.includes("junit") || fw.includes("java"))
1276
+ return "import org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;";
1277
+ return 'import { describe, it, expect, vi } from "vitest";';
1278
+ }
1279
+ /**
1280
+ * RUNTIME local names + module specifiers bound by a set of TS/JS import lines
1281
+ * (parse-only). Type-only clauses and elements are skipped: they bind no value
1282
+ * at runtime, so they must neither satisfy nor filter a framework import.
1283
+ */
1284
+ function importBindings(lines) {
1285
+ const names = new Set();
1286
+ const modules = new Set();
1287
+ const sf = ts.createSourceFile("imports.ts", lines.join("\n"), ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
1288
+ for (const s of sf.statements) {
1289
+ if (!ts.isImportDeclaration(s))
1290
+ continue;
1291
+ if (ts.isStringLiteral(s.moduleSpecifier))
1292
+ modules.add(s.moduleSpecifier.text);
1293
+ const c = s.importClause;
1294
+ if (!c || c.isTypeOnly)
1295
+ continue;
1296
+ if (c.name)
1297
+ names.add(c.name.text);
1298
+ if (c.namedBindings) {
1299
+ if (ts.isNamespaceImport(c.namedBindings))
1300
+ names.add(c.namedBindings.name.text);
1301
+ else
1302
+ for (const el of c.namedBindings.elements)
1303
+ if (!el.isTypeOnly)
1304
+ names.add(el.name.text);
1305
+ }
1306
+ }
1307
+ return { names, modules };
1308
+ }
1309
+ function usedIdentifiers(body, names) {
1310
+ return names.filter((name) => new RegExp(`\\b${name}\\b`).test(body));
1311
+ }
1312
+ function insertImportLines(body, imports) {
1313
+ if (!imports.length)
1314
+ return body;
1315
+ const lines = body.split(/\r?\n/);
1316
+ let insertAt = -1;
1317
+ for (let i = 0; i < lines.length; i++) {
1318
+ if (/^\s*import\s+/.test(lines[i]))
1319
+ insertAt = i;
1320
+ }
1321
+ lines.splice(insertAt + 1, 0, ...imports);
1322
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
1323
+ }
1324
+ function addNamedImportBindings(body, moduleName, names) {
1325
+ if (!names.length)
1326
+ return { body, added: false };
1327
+ const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1328
+ const re = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*["']${escaped}["'];?`);
1329
+ const m = body.match(re);
1330
+ if (!m)
1331
+ return { body, added: false };
1332
+ const existing = m[1].split(",").map((part) => part.trim()).filter(Boolean);
1333
+ const merged = [...existing, ...names.filter((name) => !existing.some((part) => part.split(/\s+as\s+/i).pop() === name))];
1334
+ return { body: body.replace(re, `import { ${merged.join(", ")} } from "${moduleName}";`), added: true };
1335
+ }
1336
+ function frameworkRuntimeNeeds(framework, body) {
1337
+ const fw = framework.toLowerCase();
1338
+ if (fw.includes("cypress"))
1339
+ return [];
1340
+ if (fw.includes("playwright"))
1341
+ return [{ module: "@playwright/test", names: usedIdentifiers(body, ["test", "expect"]) }];
1342
+ if (fw.includes("jest")) {
1343
+ return [
1344
+ {
1345
+ module: "@jest/globals",
1346
+ names: usedIdentifiers(body, ["describe", "it", "test", "expect", "jest", "beforeEach", "afterEach", "beforeAll", "afterAll"])
1347
+ }
1348
+ ];
1349
+ }
1350
+ if (fw.includes("mocha")) {
1351
+ return [
1352
+ { module: "mocha", names: usedIdentifiers(body, ["describe", "it", "before", "after", "beforeEach", "afterEach"]) },
1353
+ { module: "chai", names: usedIdentifiers(body, ["expect"]) }
1354
+ ];
1355
+ }
1356
+ if (fw.includes("vitest")) {
1357
+ return [
1358
+ {
1359
+ module: "vitest",
1360
+ names: usedIdentifiers(body, ["describe", "it", "test", "expect", "vi", "beforeEach", "afterEach", "beforeAll", "afterAll"])
1361
+ }
1362
+ ];
1363
+ }
1364
+ return [];
1365
+ }
1366
+ function ensureTsJsFrameworkBindings(body, framework) {
1367
+ if (!BODY_HAS_IMPORTS_RE.test(body))
1368
+ return body;
1369
+ const bound = importBindings([body]).names;
1370
+ let out = body;
1371
+ const importsToAdd = [];
1372
+ if (framework.toLowerCase().includes("ava") && /\btest\s*\(/.test(body) && !bound.has("test")) {
1373
+ importsToAdd.push('import test from "ava";');
1374
+ bound.add("test");
1375
+ }
1376
+ for (const need of frameworkRuntimeNeeds(framework, body)) {
1377
+ const missing = need.names.filter((name) => !bound.has(name));
1378
+ if (!missing.length)
1379
+ continue;
1380
+ const amended = addNamedImportBindings(out, need.module, missing);
1381
+ if (amended.added) {
1382
+ out = amended.body;
1383
+ }
1384
+ else {
1385
+ importsToAdd.push(`import { ${missing.join(", ")} } from "${need.module}";`);
1386
+ }
1387
+ for (const name of missing)
1388
+ bound.add(name);
1389
+ }
1390
+ return insertImportLines(out, importsToAdd);
1391
+ }
1392
+ /**
1393
+ * The framework import lines still needed once the subject imports are in
1394
+ * place. Name-aware per line: names a subject import already binds are filtered
1395
+ * out — subject lines like `import { expect } from "chai"` must not collide
1396
+ * with our `expect` into a duplicate declaration — and only the still-missing
1397
+ * names are emitted. Importing the same MODULE twice is deliberately allowed
1398
+ * (valid ESM; only duplicate local names collide): a subject line importing
1399
+ * `test`/`expect` from vitest must not suppress the `describe`/`it` the body
1400
+ * needs. mocha's two-line bundle (mocha + chai) is decided per line.
1401
+ */
1402
+ function frameworkImportsNeeded(framework, subjectImports) {
1403
+ const fw = framework.toLowerCase();
1404
+ if (fw.includes("cypress"))
1405
+ return []; // ambient globals — nothing to add
1406
+ if (fw.includes("pytest")) {
1407
+ // Product runs pass no subject imports for pytest/go/java (see gatherContext);
1408
+ // kept correct for direct callers.
1409
+ return subjectImports.some((l) => /\bpytest\b/.test(l)) ? [] : [frameworkImport(framework)];
1410
+ }
1411
+ const bound = importBindings(subjectImports);
1412
+ const out = [];
1413
+ for (const line of frameworkImport(framework).split("\n")) {
1414
+ const fwLine = importBindings([line]);
1415
+ const mod = [...fwLine.modules][0];
1416
+ if (!mod)
1417
+ continue;
1418
+ const missing = [...fwLine.names].filter((n) => !bound.names.has(n));
1419
+ if (missing.length === 0)
1420
+ continue;
1421
+ out.push(missing.length === fwLine.names.size ? line : `import { ${missing.join(", ")} } from "${mod}";`);
1422
+ }
1423
+ return out;
1424
+ }
1425
+ /**
1426
+ * Build usable imports from graph METADATA (never from the redacted source
1427
+ * excerpts). This keeps a grounded test runnable without copying proprietary
1428
+ * code: the framework import plus a best-effort subject import derived from the
1429
+ * inferred feature/module name.
1430
+ */
1431
+ export function synthesizeImports(framework, subjectImports) {
1432
+ // The repo itself proves these import lines work (they come from the linked
1433
+ // existing test file's parse metadata) — used instead of guessing. The framework
1434
+ // import is still ours to add: this path runs only when the model body has NO
1435
+ // imports, so dropping it would leave the test unrunnable. There is NO module-
1436
+ // name guess fallback — a subject with no derivable import is handled by the
1437
+ // caller (resolver-derivation, else a non-runnable grounded draft).
1438
+ return [
1439
+ "// Imports reconstructed by OrangePro from the repo's own test for this area (metadata only):",
1440
+ ...frameworkImportsNeeded(framework, subjectImports),
1441
+ ...subjectImports
1442
+ ];
1443
+ }
1444
+ /** Frameworks whose imports the kit does not synthesize (non-TS/JS import systems). */
1445
+ function isResolverFramework(framework) {
1446
+ const fw = framework.toLowerCase();
1447
+ return !(fw.includes("pytest") || fw.includes("python") || fw.includes("go") || fw.includes("junit") || fw.includes("java"));
1448
+ }
1449
+ /**
1450
+ * A generated test needs SOME subject import to run. For TS/JS that means provenance
1451
+ * other than "none" (the kit refuses to fabricate one). Python/Go/Java tests are never
1452
+ * kit-synthesized, so their runnability does not hinge on our import layer.
1453
+ */
1454
+ function importsOk(framework, provenance) {
1455
+ return isResolverFramework(framework) ? provenance !== "none" : true;
1456
+ }
1457
+ /** Static parse check (TS/JS): the body has no syntax errors. */
1458
+ function bodyParses(body) {
1459
+ const sf = ts.createSourceFile("__generated__.tsx", body, ts.ScriptTarget.Latest, false, ts.ScriptKind.TSX);
1460
+ const diags = sf.parseDiagnostics;
1461
+ return !diags || diags.length === 0;
1462
+ }
1463
+ function commandAvailable(command) {
1464
+ try {
1465
+ execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore", timeout: STATIC_CHECK_TIMEOUT_MS });
1466
+ return true;
1467
+ }
1468
+ catch {
1469
+ return false;
1470
+ }
1471
+ }
1472
+ function shortStaticDiag(message) {
1473
+ return message.replace(/\s+/g, " ").trim().slice(0, 240);
1474
+ }
1475
+ function escapeRegExp(value) {
1476
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1477
+ }
1478
+ function pythonModuleFromSrcLayout(relPath) {
1479
+ const parts = relPath.replace(/\\/g, "/").split("/");
1480
+ if (parts.length < 3 || parts[0] !== "src" || !parts.at(-1)?.endsWith(".py"))
1481
+ return null;
1482
+ const packageName = parts[1];
1483
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(packageName))
1484
+ return null;
1485
+ const moduleParts = parts.slice(2);
1486
+ moduleParts[moduleParts.length - 1] = moduleParts[moduleParts.length - 1].replace(/\.py$/, "");
1487
+ if (moduleParts.some((part) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(part)))
1488
+ return null;
1489
+ if (moduleParts.length === 1 && moduleParts[0] === "__init__")
1490
+ return packageName;
1491
+ return [packageName, ...moduleParts.filter((part) => part !== "__init__")].join(".");
1492
+ }
1493
+ function applyPythonSrcLayoutImports(body, relatedFiles) {
1494
+ const modules = dedupe(relatedFiles.map(pythonModuleFromSrcLayout).filter((m) => Boolean(m)));
1495
+ let next = body;
1496
+ for (const moduleName of modules.sort((a, b) => b.length - a.length)) {
1497
+ const escaped = escapeRegExp(moduleName);
1498
+ next = next.replace(new RegExp(`\\bfrom\\s+src\\.${escaped}\\s+import\\b`, "g"), `from ${moduleName} import`);
1499
+ next = next.replace(new RegExp(`\\bimport\\s+src\\.${escaped}(?=\\s|$|,|\\))`, "g"), `import ${moduleName}`);
1500
+ }
1501
+ return next;
1502
+ }
1503
+ function writeTempStaticFile(ext, body) {
1504
+ const dir = mkdtempSync(path.join(tmpdir(), "op-gen-static-"));
1505
+ const file = path.join(dir, `generated.${ext}`);
1506
+ writeFileSync(file, body.endsWith("\n") ? body : `${body}\n`, "utf8");
1507
+ return { dir, file };
1508
+ }
1509
+ function pythonStaticIssue(body) {
1510
+ if (!commandAvailable("python3"))
1511
+ return "python3 not found; cannot verify pytest syntax.";
1512
+ const { dir, file } = writeTempStaticFile("py", body);
1513
+ const pytestEntrypointCheck = [
1514
+ "import ast, sys",
1515
+ "path = sys.argv[1]",
1516
+ "tree = ast.parse(open(path, encoding='utf8').read(), filename=path)",
1517
+ "def is_test_func(n):",
1518
+ " return isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name.startswith('test_')",
1519
+ "ok = any(is_test_func(n) for n in tree.body)",
1520
+ "ok = ok or any(isinstance(c, ast.ClassDef) and c.name.startswith('Test') and any(is_test_func(m) for m in c.body) for c in tree.body)",
1521
+ "if not ok:",
1522
+ " raise SystemExit('pytest test entrypoint not found; expected def test_... or Test*.test_...')"
1523
+ ].join("\n");
1524
+ try {
1525
+ execFileSync("python3", ["-m", "py_compile", file], { stdio: "pipe", timeout: STATIC_CHECK_TIMEOUT_MS });
1526
+ execFileSync("python3", ["-c", pytestEntrypointCheck, file], { stdio: "pipe", timeout: STATIC_CHECK_TIMEOUT_MS });
1527
+ return null;
1528
+ }
1529
+ catch (e) {
1530
+ const err = e;
1531
+ const output = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`;
1532
+ return `Python syntax check failed: ${shortStaticDiag(output || err.message || "unknown error")}`;
1533
+ }
1534
+ finally {
1535
+ rmSync(dir, { recursive: true, force: true });
1536
+ }
1537
+ }
1538
+ function goStaticIssue(body) {
1539
+ if (!/^\s*package\s+[A-Za-z_][A-Za-z0-9_]*\b/m.test(body))
1540
+ return "Go test is missing a package declaration.";
1541
+ const imports = goImportSpecs(body);
1542
+ if (!imports.includes("testing"))
1543
+ return 'Go test is missing import "testing".';
1544
+ if (!/\bfunc\s+Test[A-Za-z0-9_]*\s*\(\s*t\s+\*testing\.T\s*\)/m.test(body)) {
1545
+ return "Go test is missing a func Test...(t *testing.T) entrypoint.";
1546
+ }
1547
+ const external = imports.filter((spec) => spec.split("/")[0].includes("."));
1548
+ if (external.length) {
1549
+ return `Go test imports module-path package(s) ${external.join(", ")}; OrangePro cannot verify module imports resolve from the generated file. Prefer same-package or stdlib-only code.`;
1550
+ }
1551
+ if (!commandAvailable("gofmt"))
1552
+ return "gofmt not found; cannot verify Go syntax.";
1553
+ const { dir, file } = writeTempStaticFile("go", body);
1554
+ try {
1555
+ execFileSync("gofmt", [file], { stdio: "pipe", timeout: STATIC_CHECK_TIMEOUT_MS });
1556
+ return null;
1557
+ }
1558
+ catch (e) {
1559
+ const err = e;
1560
+ const output = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`;
1561
+ return `Go syntax check failed: ${shortStaticDiag(output || err.message || "unknown error")}`;
1562
+ }
1563
+ finally {
1564
+ rmSync(dir, { recursive: true, force: true });
1565
+ }
1566
+ }
1567
+ function findGoModuleRoot(startDir, workspaceRoot) {
1568
+ let dir = path.resolve(startDir);
1569
+ const root = path.resolve(workspaceRoot);
1570
+ while (dir.startsWith(root)) {
1571
+ if (existsSync(path.join(dir, "go.mod")))
1572
+ return dir;
1573
+ const parent = path.dirname(dir);
1574
+ if (parent === dir)
1575
+ break;
1576
+ dir = parent;
1577
+ }
1578
+ return null;
1579
+ }
1580
+ function goCompileIssue(body, workspaceRoot, relatedFiles) {
1581
+ const sourceFile = relatedFiles.find((rel) => /\.go$/i.test(rel) && !TEST_REF_RE.test(rel));
1582
+ if (!sourceFile)
1583
+ return null;
1584
+ const packageDir = path.resolve(workspaceRoot, path.dirname(sourceFile));
1585
+ if (!existsSync(packageDir) || !findGoModuleRoot(packageDir, workspaceRoot))
1586
+ return null;
1587
+ if (!commandAvailable("go"))
1588
+ return "go not found; cannot verify generated Go test compiles.";
1589
+ const tempRel = `orangepro_compile_${process.pid}_${shortHash(body)}_test.go`;
1590
+ const tempFile = path.join(packageDir, tempRel);
1591
+ try {
1592
+ writeFileSync(tempFile, body);
1593
+ execFileSync("go", ["test", "-run", "^$", "."], { cwd: packageDir, stdio: "pipe", timeout: GO_COMPILE_CHECK_TIMEOUT_MS });
1594
+ return null;
1595
+ }
1596
+ catch (e) {
1597
+ const err = e;
1598
+ const output = `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}`;
1599
+ return `Go compile check failed: ${shortStaticDiag(output || err.message || "unknown error")}`;
1600
+ }
1601
+ finally {
1602
+ rmSync(tempFile, { force: true });
1603
+ }
1604
+ }
1605
+ function goImportSpecs(body) {
1606
+ const specs = [];
1607
+ const lines = body.split(/\r?\n/);
1608
+ for (let i = 0; i < lines.length; i++) {
1609
+ const trimmed = lines[i].trim();
1610
+ const single = trimmed.match(/^import\s+(?:[A-Za-z_][A-Za-z0-9_]*\s+|\.\s+|_\s+)?"([^"]+)"/);
1611
+ if (single) {
1612
+ specs.push(single[1]);
1613
+ continue;
1614
+ }
1615
+ if (/^import\s*\($/.test(trimmed)) {
1616
+ for (i++; i < lines.length; i++) {
1617
+ const inBlock = lines[i].trim();
1618
+ if (inBlock === ")")
1619
+ break;
1620
+ const block = inBlock.match(/^(?:[A-Za-z_][A-Za-z0-9_]*\s+|\.\s+|_\s+)?"([^"]+)"/);
1621
+ if (block)
1622
+ specs.push(block[1]);
1623
+ }
1624
+ }
1625
+ }
1626
+ return specs;
1627
+ }
1628
+ function hasBalancedBraces(body) {
1629
+ let depth = 0;
1630
+ let stringQuote = null;
1631
+ let lineComment = false;
1632
+ let blockComment = false;
1633
+ for (let i = 0; i < body.length; i++) {
1634
+ const ch = body[i];
1635
+ const next = body[i + 1];
1636
+ if (lineComment) {
1637
+ if (ch === "\n")
1638
+ lineComment = false;
1639
+ continue;
1640
+ }
1641
+ if (blockComment) {
1642
+ if (ch === "*" && next === "/") {
1643
+ blockComment = false;
1644
+ i++;
1645
+ }
1646
+ continue;
1647
+ }
1648
+ if (stringQuote) {
1649
+ if (ch === "\\") {
1650
+ i++;
1651
+ continue;
1652
+ }
1653
+ if (ch === stringQuote)
1654
+ stringQuote = null;
1655
+ continue;
1656
+ }
1657
+ if (ch === "/" && next === "/") {
1658
+ lineComment = true;
1659
+ i++;
1660
+ continue;
1661
+ }
1662
+ if (ch === "/" && next === "*") {
1663
+ blockComment = true;
1664
+ i++;
1665
+ continue;
1666
+ }
1667
+ if (ch === '"' || ch === "'") {
1668
+ stringQuote = ch;
1669
+ continue;
1670
+ }
1671
+ if (ch === "{")
1672
+ depth++;
1673
+ else if (ch === "}") {
1674
+ depth--;
1675
+ if (depth < 0)
1676
+ return false;
1677
+ }
1678
+ }
1679
+ return depth === 0 && !stringQuote && !blockComment;
1680
+ }
1681
+ function javaStaticIssue(body) {
1682
+ if (!/\bimport\s+org\.junit(?:\.jupiter\.api)?\.Test\s*;/.test(body))
1683
+ return "Java test is missing JUnit @Test import.";
1684
+ if (!/\bclass\s+[A-Za-z_][A-Za-z0-9_]*\s*\{/.test(body))
1685
+ return "Java test is missing a test class.";
1686
+ if (!/@Test\b/.test(body))
1687
+ return "Java test is missing an @Test method.";
1688
+ if (!/\bassert(?:True|False|Equals|NotNull|Null|Throws)\s*\(|\b(?:Assertions|Assert)\./.test(body)) {
1689
+ return "Java test is missing a JUnit assertion.";
1690
+ }
1691
+ if (!hasBalancedBraces(body))
1692
+ return "Java test has unbalanced braces.";
1693
+ return null;
1694
+ }
1695
+ function staticFormatIssue(body, framework) {
1696
+ const fw = framework.toLowerCase();
1697
+ if (fw.includes("pytest") || fw.includes("python"))
1698
+ return pythonStaticIssue(body);
1699
+ if (fw.includes("go"))
1700
+ return goStaticIssue(body);
1701
+ if (fw.includes("junit") || fw.includes("java"))
1702
+ return javaStaticIssue(body);
1703
+ if (isResolverFramework(framework) && !bodyParses(body))
1704
+ return "TypeScript/JavaScript test body does not parse.";
1705
+ return null;
1706
+ }
1707
+ /** Framework-aware check that the body contains at least one real assertion. */
1708
+ function hasAssertion(body, framework) {
1709
+ const fw = framework.toLowerCase();
1710
+ if (fw.includes("pytest") || fw.includes("python"))
1711
+ return /\bassert\b/.test(body);
1712
+ if (fw.includes("go"))
1713
+ return /\bt\.(Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\bassert\./.test(body);
1714
+ if (fw.includes("junit") || fw.includes("java"))
1715
+ return /\bassert(?:True|False|Equals|NotNull|Null|Throws)\s*\(|\b(?:Assertions|Assert)\./.test(body);
1716
+ if (fw.includes("cypress"))
1717
+ return /\.should\s*\(|\bexpect\s*\(/.test(body);
1718
+ if (fw.includes("ava"))
1719
+ return /\bt\.(?:is|deepEqual|true|false|truthy|falsy|throws|notThrows|regex|like)\s*\(/.test(body);
1720
+ // vitest / jest / mocha / playwright and the default.
1721
+ return /\bexpect\s*\(|\bassert\b|\.should\b/.test(body);
1722
+ }
1723
+ /**
1724
+ * True when a bare (non-relative) specifier matches a tsconfig `paths` alias key
1725
+ * (e.g. "@/foo" matches the alias `@/*`). Such a specifier is LOCAL — it resolves
1726
+ * into the repo via path mapping — so it must be validated like a relative import,
1727
+ * not waved through as an external package.
1728
+ */
1729
+ function matchesTsPathAlias(spec, paths) {
1730
+ if (!paths)
1731
+ return false;
1732
+ for (const key of Object.keys(paths)) {
1733
+ if (key === spec)
1734
+ return true;
1735
+ const star = key.indexOf("*");
1736
+ if (star < 0)
1737
+ continue;
1738
+ const prefix = key.slice(0, star);
1739
+ const suffix = key.slice(star + 1);
1740
+ if (spec.length >= prefix.length + suffix.length && spec.startsWith(prefix) && spec.endsWith(suffix))
1741
+ return true;
1742
+ }
1743
+ return false;
1744
+ }
1745
+ /** The package root of a bare specifier: "lodash/fp" -> "lodash", "@scope/p/x" -> "@scope/p". */
1746
+ function packageRoot(spec) {
1747
+ const parts = spec.split("/");
1748
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
1749
+ }
1750
+ /** Declared dependency names from a repo's package.json (all four dependency maps). */
1751
+ export function readDeclaredDeps(root) {
1752
+ try {
1753
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
1754
+ const names = new Set();
1755
+ for (const field of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
1756
+ const map = pkg[field];
1757
+ if (map && typeof map === "object")
1758
+ for (const name of Object.keys(map))
1759
+ names.add(name);
1760
+ }
1761
+ return names;
1762
+ }
1763
+ catch {
1764
+ return new Set();
1765
+ }
1766
+ }
1767
+ /**
1768
+ * Import specifiers in a body that WON'T LOAD from where the test will live, so the
1769
+ * test cannot honestly be called runnable. For each specifier:
1770
+ * - relative (`./x`) or tsconfig `paths` alias (`@/x`) -> must resolve from the
1771
+ * generated test's location;
1772
+ * - bare package (`@scope/pkg`, `vitest`) -> must be a declared package.json
1773
+ * dependency or a node builtin;
1774
+ * - bare baseUrl-local import (`utils/foo`) -> allowed only when it resolves to a
1775
+ * file inside the target repo.
1776
+ *
1777
+ * Bare packages are intentionally NOT trusted just because `resolveImport` can
1778
+ * find them from OrangePro's own workspace/node_modules; the target repo must
1779
+ * declare the dependency or the generated test cannot honestly be called runnable.
1780
+ * `declaredDeps` is the repo's package.json dependency names (computed once per run).
1781
+ */
1782
+ export function unresolvedLocalImports(body, containingFileAbs, repoRootAbs, declaredDeps) {
1783
+ const sf = ts.createSourceFile("__generated__.tsx", body, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
1784
+ const specs = new Set();
1785
+ const visit = (node) => {
1786
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
1787
+ specs.add(node.moduleSpecifier.text);
1788
+ }
1789
+ if (ts.isCallExpression(node) && node.arguments.length && ts.isStringLiteral(node.arguments[0])) {
1790
+ const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
1791
+ const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
1792
+ if (isRequire || isDynamicImport)
1793
+ specs.add(node.arguments[0].text);
1794
+ }
1795
+ ts.forEachChild(node, visit);
1796
+ };
1797
+ visit(sf);
1798
+ if (specs.size === 0)
1799
+ return [];
1800
+ const paths = loadTsConfigFor(containingFileAbs).options.paths;
1801
+ const unresolved = [];
1802
+ const isInsideRepo = (file) => {
1803
+ const rel = path.relative(repoRootAbs, file);
1804
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1805
+ };
1806
+ for (const spec of specs) {
1807
+ if (spec.startsWith(".") || matchesTsPathAlias(spec, paths)) {
1808
+ if (!resolveImport(spec, containingFileAbs).resolved)
1809
+ unresolved.push(spec);
1810
+ continue;
1811
+ }
1812
+ const resolved = resolveImport(spec, containingFileAbs);
1813
+ if (resolved.resolvedFileName && !resolved.isExternal && isInsideRepo(resolved.resolvedFileName))
1814
+ continue;
1815
+ if (!declaredDeps.has(packageRoot(spec)) && !isBuiltin(spec))
1816
+ unresolved.push(spec);
1817
+ }
1818
+ return unresolved;
1819
+ }
1820
+ /**
1821
+ * Mechanical (static, in-process) runnability: a real subject import that the
1822
+ * resolver confirms loads, a body that parses, and a real assertion. The kit never
1823
+ * EXECUTES the test — the calling agent is the runner — so this is the strongest
1824
+ * honest signal without running it. `importErrors` are unresolvable LOCAL imports
1825
+ * (relative or tsconfig-alias) already computed against the test's location. When
1826
+ * false the test ships as a grounded draft (see unresolved_reason).
1827
+ */
1828
+ function isRunnable(body, framework, provenance, importErrors) {
1829
+ if (!importsOk(framework, provenance))
1830
+ return false;
1831
+ if (importErrors.length > 0)
1832
+ return false; // a local import that does not resolve = not runnable
1833
+ if (!hasExecutableContent(body, framework))
1834
+ return false;
1835
+ if (!hasAssertion(body, framework))
1836
+ return false;
1837
+ if (staticFormatIssue(body, framework))
1838
+ return false;
1839
+ return true;
1840
+ }
1841
+ /** Diagnostic for a non-runnable test, most specific cause first. */
1842
+ function importFixHint(importErrors, declaredDeps) {
1843
+ const missingPackages = importErrors.filter((spec) => !spec.startsWith(".") && !isBuiltin(spec) && !declaredDeps.has(packageRoot(spec)));
1844
+ if (missingPackages.length > 0) {
1845
+ return `Fix: regenerate using the repo's existing test framework/imports, or add ${missingPackages.map(packageRoot).join(", ")} to package.json before running.`;
1846
+ }
1847
+ return "Fix: adjust the import path or write the generated test next to the existing linked test so relative imports resolve.";
1848
+ }
1849
+ function runnableFailureReason(body, framework, provenance, importErrors, declaredDeps = new Set()) {
1850
+ if (importErrors.length > 0) {
1851
+ return `Unresolved import(s) ${importErrors.join(", ")} — they do not resolve from the generated test's location. Grounded draft; no run command emitted. ${importFixHint(importErrors, declaredDeps)}`;
1852
+ }
1853
+ if (!importsOk(framework, provenance)) {
1854
+ return "No usable subject import — grounded draft, no run command emitted. Fix: add the module-under-test import or link this behavior to an existing test/source file.";
1855
+ }
1856
+ const staticIssue = staticFormatIssue(body, framework);
1857
+ if (staticIssue) {
1858
+ return `${staticIssue} Grounded draft; no run command emitted. Fix: regenerate or repair the ${framework} syntax using the repo's existing test style before running.`;
1859
+ }
1860
+ return `Test body does not parse or has no ${framework} assertion — grounded draft, no run command emitted. Fix: add a real framework assertion and rerun the static check.`;
1861
+ }
1862
+ function bucketForV5Scenario(scenario) {
1863
+ if (scenario.concern === "authorization_safety")
1864
+ return "security_privacy";
1865
+ if (scenario.concern === "integration_flow" || scenario.technique === "integration_chain" || scenario.technique === "data_flow_analysis") {
1866
+ return "integration_flow";
1867
+ }
1868
+ if (scenario.concern === "boundary_limits" || scenario.technique === "boundary_value_analysis")
1869
+ return "edge_case";
1870
+ if (scenario.concern === "failure_recovery" || scenario.technique === "rollback_recovery" || scenario.technique === "chaos_injection")
1871
+ return "regression";
1872
+ if (scenario.technique === "happy_path_validation")
1873
+ return "happy_path";
1874
+ if (scenario.technique === "contract_verification" || scenario.technique === "permission_matrix" || scenario.technique === "input_sanitization") {
1875
+ return "validation_error";
1876
+ }
1877
+ return "regression";
1878
+ }
1879
+ const SCENARIO_ALIGNMENT_STOPWORDS = new Set([
1880
+ "a",
1881
+ "an",
1882
+ "and",
1883
+ "are",
1884
+ "for",
1885
+ "from",
1886
+ "has",
1887
+ "have",
1888
+ "into",
1889
+ "its",
1890
+ "not",
1891
+ "of",
1892
+ "on",
1893
+ "or",
1894
+ "the",
1895
+ "to",
1896
+ "with"
1897
+ ]);
1898
+ function scenarioAlignmentTokens(scenario) {
1899
+ const text = [scenario.title, ...scenario.assertion_targets].join(" ").toLowerCase();
1900
+ return [...new Set(text.match(/[a-z0-9_]{3,}/g) ?? [])].filter((token) => !SCENARIO_ALIGNMENT_STOPWORDS.has(token));
1901
+ }
1902
+ function generatedBodyAlignsWithScenario(body, scenario) {
1903
+ const tokens = scenarioAlignmentTokens(scenario);
1904
+ if (tokens.length === 0)
1905
+ return false;
1906
+ const lowered = body.toLowerCase();
1907
+ return tokens.some((token) => lowered.includes(token));
1908
+ }
1909
+ /** Build the evidence corpus + structural facts used to derive bucket signals. */
1910
+ function bucketEvidenceFor(behavior, gc) {
1911
+ const examples = asStringArray(behavior.properties.example_behaviors);
1912
+ const corpus = [
1913
+ gc.ctx.behavior_title,
1914
+ gc.ctx.description ?? "",
1915
+ ...gc.ctx.acceptance_criteria,
1916
+ ...gc.ctx.workflow_steps,
1917
+ ...gc.ctx.code_context,
1918
+ ...gc.ctx.weak_context,
1919
+ ...examples
1920
+ ]
1921
+ .join(" \n ")
1922
+ .toLowerCase();
1923
+ const hasTestableAnchor = gc.ctx.acceptance_criteria.length > 0 ||
1924
+ Boolean(gc.ctx.description) ||
1925
+ gc.ctx.workflow_steps.length > 0 ||
1926
+ gc.ctx.code_context.length > 0;
1927
+ const inferredFromTests = behavior.review_status === "inferred" || gc.weakUsed.some((w) => w.startsWith("inferred_anchor"));
1928
+ return {
1929
+ corpus,
1930
+ relatedFiles: Math.max(0, gc.entityIds.length - 1),
1931
+ workflowSteps: gc.ctx.workflow_steps.length,
1932
+ testNames: examples.length,
1933
+ hasTestableAnchor,
1934
+ inferredFromTests
1935
+ };
1936
+ }
1937
+ /**
1938
+ * Plan (behavior, bucket) pairs for grounded generation, capped at `limit`:
1939
+ * default/single target -> up to `limit` bucket-diverse tests for the first
1940
+ * viable target; multiple explicit targets -> split the budget (each target >=1
1941
+ * test if justified, remaining slots to the strongest bucket opportunities).
1942
+ */
1943
+ function planGroundedBuckets(graph, targets, framework, fileReader, limit, explicitMulti, missing, warnings) {
1944
+ const viable = [];
1945
+ const thinSeen = [];
1946
+ for (const behavior of targets) {
1947
+ const gc = gatherContext(graph, behavior, framework, fileReader);
1948
+ const thin = tooThin(gc.ctx);
1949
+ if (thin.thin) {
1950
+ thinSeen.push({
1951
+ external_id: behavior.external_id,
1952
+ title: gc.ctx.behavior_title,
1953
+ reason: "Evidence is too thin to generate a specific, grounded test.",
1954
+ needed: thin.needed
1955
+ });
1956
+ continue;
1957
+ }
1958
+ const buckets = selectLocalBuckets(deriveBucketSignals(bucketEvidenceFor(behavior, gc)), limit);
1959
+ viable.push({ behavior, gc, buckets });
1960
+ if (!explicitMulti)
1961
+ break; // default/single target: focus the first viable behavior
1962
+ }
1963
+ // Default (single-focus) mode skips higher-ranked thin behaviors silently — they are
1964
+ // alternatives the caller did not target. Report them only when nothing was viable.
1965
+ // Explicit targets are always reported.
1966
+ if (viable.length === 0 || explicitMulti)
1967
+ missing.push(...thinSeen);
1968
+ if (viable.length === 0)
1969
+ return [];
1970
+ const mk = (v, bucket) => ({
1971
+ behavior: v.behavior,
1972
+ bucket,
1973
+ ctx: v.gc.ctx,
1974
+ entityIds: v.gc.entityIds,
1975
+ sourceRefs: v.gc.sourceRefs,
1976
+ weakUsed: v.gc.weakUsed
1977
+ });
1978
+ if (!explicitMulti || viable.length === 1) {
1979
+ return viable[0].buckets.slice(0, limit).map((b) => mk(viable[0], b));
1980
+ }
1981
+ // Multiple explicit targets: each gets >=1 test (if justified), then round-robin
1982
+ // the remaining budget across targets in selection-priority order.
1983
+ const plan = [];
1984
+ const used = new Set();
1985
+ for (let i = 0; i < viable.length && plan.length < limit; i++) {
1986
+ const b = viable[i].buckets[0];
1987
+ if (b) {
1988
+ plan.push(mk(viable[i], b));
1989
+ used.add(`${i}:${b}`);
1990
+ }
1991
+ }
1992
+ for (let depth = 1; plan.length < limit; depth++) {
1993
+ let progressed = false;
1994
+ for (let i = 0; i < viable.length && plan.length < limit; i++) {
1995
+ const b = viable[i].buckets[depth];
1996
+ if (b && !used.has(`${i}:${b}`)) {
1997
+ plan.push(mk(viable[i], b));
1998
+ used.add(`${i}:${b}`);
1999
+ progressed = true;
2000
+ }
2001
+ }
2002
+ if (!progressed)
2003
+ break;
2004
+ }
2005
+ const covered = new Set(plan.map((p) => p.behavior.external_id));
2006
+ const dropped = viable.filter((v) => !covered.has(v.behavior.external_id));
2007
+ if (dropped.length) {
2008
+ warnings.push(`Limit ${limit} is smaller than the ${viable.length} viable targets; ${dropped.length} lower-priority target(s) received no test: ${dropped.map((d) => d.behavior.external_id).join(", ")}.`);
2009
+ }
2010
+ return plan;
2011
+ }
2012
+ /**
2013
+ * Select the same target behaviors + gathered context for the A/B comparison, so
2014
+ * both arms target identical behaviors and differ ONLY in whether the KG evidence
2015
+ * is injected. Reuses the kit's target selection and context gathering.
2016
+ *
2017
+ * NOTE: opCompare no longer calls this because both arms route through generateTests.
2018
+ * Kept for local comparison helpers that need stable target/context selection.
2019
+ */
2020
+ export function selectCompareTargets(graph, opts, fileReader) {
2021
+ const limit = Math.max(1, Math.min(MAX_LIMIT, opts.limit ?? DEFAULT_LIMIT));
2022
+ const { targets } = selectTargets(graph, opts);
2023
+ const framework = pickFramework(graph, opts, targets, fileReader);
2024
+ const filtered = targetsForFramework(graph, targets, framework).targets;
2025
+ const items = filtered.slice(0, limit).map((behavior) => ({
2026
+ behavior,
2027
+ ...gatherContext(graph, behavior, framework, fileReader)
2028
+ }));
2029
+ return { framework, items };
2030
+ }
2031
+ export async function generateTests(graph, opts, provider, fileReader, clock = systemClock) {
2032
+ const limit = Math.max(1, Math.min(MAX_LIMIT, opts.limit ?? DEFAULT_LIMIT));
2033
+ const inputMode = opts.input_mode ?? "graph_grounded";
2034
+ const { targets, warnings } = selectTargets(graph, opts);
2035
+ const framework = pickFramework(graph, opts, targets, fileReader);
2036
+ const runSelection = targetsForFramework(graph, targets, framework);
2037
+ const runTargets = runSelection.targets;
2038
+ warnings.push(...runSelection.warnings);
2039
+ const systemPrompt = opts.systemPrompt ?? buildSystemPrompt();
2040
+ const promptVersion = inputMode === "graph_grounded" && opts.prompt_version === "v5" ? PROMPT_VERSION_V5 : PROMPT_VERSION;
2041
+ const created_at = clock();
2042
+ const runSeed = shortHash(created_at + provider.modelName + runTargets.map((t) => t.external_id).join(","));
2043
+ const run_id = `local-gen-${runSeed}`;
2044
+ const generated = [];
2045
+ const missing = [];
2046
+ if (inputMode === "raw_prompt") {
2047
+ // Internal baseline only: broad sampling, one raw test per target (no buckets).
2048
+ // Cap ATTEMPTS (not successes): with empty completions skipped below, capping
2049
+ // on generated.length would let a systemically-empty provider run across
2050
+ // EVERY target — an unbounded spend/latency path.
2051
+ for (const behavior of runTargets.slice(0, limit)) {
2052
+ const { ctx } = gatherContext(graph, behavior, framework, fileReader);
2053
+ reportProgress(`Generating "${ctx.behavior_title}"…`);
2054
+ // One failed call must not vaporize the whole run (loops are limit-bounded,
2055
+ // so even a systemic failure costs at most `limit` warnings, never a flood).
2056
+ let completion;
2057
+ try {
2058
+ completion = await provider.complete({ system: systemPrompt, user: buildRawUserPrompt(ctx.behavior_title, framework) });
2059
+ }
2060
+ catch (e) {
2061
+ const msg = redactSecrets(e instanceof Error ? e.message : String(e));
2062
+ warnings.push(`Model call failed for "${ctx.behavior_title}": ${msg} — no test emitted.`);
2063
+ missing.push({
2064
+ external_id: behavior.external_id,
2065
+ title: ctx.behavior_title,
2066
+ reason: `Model call failed: ${msg}`,
2067
+ needed: ["a successful model completion"]
2068
+ });
2069
+ continue;
2070
+ }
2071
+ const rawBody = stripCodeFence(completion.trim());
2072
+ const sanitized = sanitizeGeneratedBody(rawBody, ctx.source_excerpts, commentPrefixForFramework(framework));
2073
+ // Same post-redaction cleanup as the grounded path: a half-redacted
2074
+ // statement must not ship as a broken baseline body either.
2075
+ let cleanBody = sanitized.body;
2076
+ if (sanitized.redactedLines > 0) {
2077
+ const fw = framework.toLowerCase();
2078
+ if (!fw.includes("pytest") && !fw.includes("go") && !fw.includes("junit") && !fw.includes("java")) {
2079
+ const stripped = stripRedactedStatements(cleanBody);
2080
+ if (stripped.dropped > 0)
2081
+ cleanBody = stripped.body;
2082
+ }
2083
+ }
2084
+ if (!hasExecutableContent(cleanBody, framework)) {
2085
+ warnings.push(`Model returned an empty completion (no executable test code) for "${ctx.behavior_title}" — no test emitted.`);
2086
+ missing.push({
2087
+ external_id: behavior.external_id,
2088
+ title: ctx.behavior_title,
2089
+ reason: "Model returned an empty completion (no executable test code); no test emitted.",
2090
+ needed: ["a non-empty model completion"]
2091
+ });
2092
+ continue;
2093
+ }
2094
+ const relatedFiles = relatedFilePaths(graph, behavior).files;
2095
+ let body = cleanBody;
2096
+ if (framework.toLowerCase().includes("go")) {
2097
+ body = applyGoPackage(body, firstGoPackage(relatedFiles, fileReader));
2098
+ }
2099
+ if (framework.toLowerCase().includes("junit") || framework.toLowerCase().includes("java")) {
2100
+ const licenseHeader = firstJavaLicenseHeader(relatedFiles, fileReader);
2101
+ body = applyJavaPackage(body, firstJavaPackage(relatedFiles, fileReader));
2102
+ body = applyJavaLicenseHeader(body, licenseHeader);
2103
+ }
2104
+ if (framework.toLowerCase().includes("pytest") || framework.toLowerCase().includes("python")) {
2105
+ body = applyPythonSrcLayoutImports(body, relatedFiles);
2106
+ }
2107
+ body = ensureFrameworkScaffold(body, framework);
2108
+ const staticIssue = staticFormatIssue(body, framework);
2109
+ const compileIssue = framework.toLowerCase().includes("go") ? goCompileIssue(body, graph.workspace.root, relatedFiles) : null;
2110
+ const runnable = hasAssertion(body, framework) && !staticIssue && !compileIssue;
2111
+ generated.push({
2112
+ id: `${run_id}-t${generated.length + 1}`,
2113
+ run_id,
2114
+ title: ctx.behavior_title,
2115
+ test_type: ctx.test_layer,
2116
+ framework_hint: framework,
2117
+ body,
2118
+ grounding: { entity_ids: [behavior.external_id], source_refs: [], weak_relationships_used: [] },
2119
+ weak_evidence_used: false,
2120
+ prompt_version: PROMPT_VERSION,
2121
+ runnable,
2122
+ ...(!runnable
2123
+ ? {
2124
+ unresolved_reason: staticIssue ?? compileIssue ?? "Test body does not have a framework assertion — comparison draft, review before running."
2125
+ }
2126
+ : {})
2127
+ });
2128
+ }
2129
+ }
2130
+ else if (opts.prompt_version === "v5") {
2131
+ const declaredDeps = readDeclaredDeps(graph.workspace.root);
2132
+ for (const behavior of runTargets) {
2133
+ if (generated.length >= limit)
2134
+ break;
2135
+ const gc = gatherContext(graph, behavior, framework, fileReader);
2136
+ reportProgress(`Planning "${gc.ctx.behavior_title}" [v5]…`);
2137
+ let scenarios = [];
2138
+ // Transport first: a network/timeout failure is NOT malformed JSON, so it does
2139
+ // not warrant a repair pass — fail closed and emit no test.
2140
+ let rawPlan;
2141
+ try {
2142
+ rawPlan = await provider.complete({
2143
+ system: opts.systemPrompt ?? buildPlanningSystemPromptV5(),
2144
+ user: buildPlanningUserPromptV5(gc.ctx),
2145
+ maxTokens: 1600,
2146
+ temperature: 0
2147
+ });
2148
+ }
2149
+ catch (callErr) {
2150
+ const msg = redactSecrets(callErr instanceof Error ? callErr.message : String(callErr));
2151
+ warnings.push(`V5 planning call failed for "${gc.ctx.behavior_title}": ${msg} — no test emitted.`);
2152
+ missing.push({
2153
+ external_id: behavior.external_id,
2154
+ title: gc.ctx.behavior_title,
2155
+ reason: `V5 planning call failed: ${msg}`,
2156
+ needed: ["a reachable model provider"]
2157
+ });
2158
+ continue;
2159
+ }
2160
+ try {
2161
+ const result = parsePlannedScenariosStrict(rawPlan, 20);
2162
+ scenarios = result.scenarios;
2163
+ if (result.dropped > 0) {
2164
+ warnings.push(`Dropped ${result.dropped} invalid v5 planned scenario(s) for "${gc.ctx.behavior_title}": ${result.dropSummary.join("; ")}.`);
2165
+ }
2166
+ }
2167
+ catch (parseErr) {
2168
+ // Malformed/unvalidated planning JSON. Make ONE transient repair call — the
2169
+ // malformed output is sent to the model but NEVER persisted, and the parse
2170
+ // error is redacted before it reaches any warning/log.
2171
+ const parseMsg = redactSecrets(parseErr instanceof Error ? parseErr.message : String(parseErr));
2172
+ // Repair only RECOVERS scenarios already present in the malformed output — it must NEVER
2173
+ // invent a fresh plan from garbage. Pre-gate on recoverable JSON-array structure; without it,
2174
+ // fail closed with no repair call ("no JSON array" / total garbage → no invented plan).
2175
+ if (!hasRepairableScenarioStructure(rawPlan)) {
2176
+ warnings.push(`V5 planning for "${gc.ctx.behavior_title}" produced no recoverable scenario array (${parseMsg}) — failing closed, no repair, no test emitted.`);
2177
+ missing.push({
2178
+ external_id: behavior.external_id,
2179
+ title: gc.ctx.behavior_title,
2180
+ reason: `V5 planning JSON had no recoverable scenario array (repair would invent): ${parseMsg}`,
2181
+ needed: ["valid JSON planned scenarios"]
2182
+ });
2183
+ continue;
2184
+ }
2185
+ try {
2186
+ const repaired = await provider.complete({
2187
+ system: buildPlanningRepairSystemPromptV5(),
2188
+ user: buildPlanningRepairUserPromptV5(rawPlan),
2189
+ maxTokens: 1600,
2190
+ temperature: 0
2191
+ });
2192
+ const result = parsePlannedScenariosStrict(repaired, 20);
2193
+ // Keep ONLY repaired scenarios that tie back to the ORIGINAL malformed text; drop any the
2194
+ // model invented. If none tie back, fail closed — never generate from an invented plan.
2195
+ const tiedBack = result.scenarios.filter((s) => scenarioTiesBackToRaw(s, rawPlan));
2196
+ const invented = result.scenarios.length - tiedBack.length;
2197
+ if (tiedBack.length === 0) {
2198
+ warnings.push(`V5 planning repair for "${gc.ctx.behavior_title}" recovered no scenario tied to the original output (${invented} invented dropped) — failing closed, no test emitted.`);
2199
+ missing.push({
2200
+ external_id: behavior.external_id,
2201
+ title: gc.ctx.behavior_title,
2202
+ reason: "V5 planning repair produced only invented scenarios not tied to the original output.",
2203
+ needed: ["valid JSON planned scenarios"]
2204
+ });
2205
+ continue;
2206
+ }
2207
+ scenarios = tiedBack;
2208
+ warnings.push(`V5 planning output for "${gc.ctx.behavior_title}" was malformed (${parseMsg}); one repair call recovered ${tiedBack.length} tied-back scenario(s)${invented ? `, dropped ${invented} not tied to the original` : ""}.`);
2209
+ if (result.dropped > 0) {
2210
+ warnings.push(`Dropped ${result.dropped} invalid v5 planned scenario(s) after repair for "${gc.ctx.behavior_title}": ${result.dropSummary.join("; ")}.`);
2211
+ }
2212
+ }
2213
+ catch (repairErr) {
2214
+ // Fail closed: never generate from malformed/unvalidated scenario data.
2215
+ const repairMsg = redactSecrets(repairErr instanceof Error ? repairErr.message : String(repairErr));
2216
+ warnings.push(`V5 planning failed for "${gc.ctx.behavior_title}": malformed JSON and repair failed (${repairMsg}) — no test emitted.`);
2217
+ missing.push({
2218
+ external_id: behavior.external_id,
2219
+ title: gc.ctx.behavior_title,
2220
+ reason: `V5 planning JSON was malformed and could not be repaired: ${repairMsg}`,
2221
+ needed: ["valid JSON planned scenarios"]
2222
+ });
2223
+ continue;
2224
+ }
2225
+ }
2226
+ if (scenarios.length === 0) {
2227
+ missing.push({
2228
+ external_id: behavior.external_id,
2229
+ title: gc.ctx.behavior_title,
2230
+ reason: "V5 planning returned no missing scenarios.",
2231
+ needed: ["a distinct uncovered scenario"]
2232
+ });
2233
+ continue;
2234
+ }
2235
+ const selected = scenarios.slice(0, Math.max(1, limit - generated.length));
2236
+ const completions = [];
2237
+ try {
2238
+ reportProgress(`Generating "${gc.ctx.behavior_title}" [v5 batch: ${selected.length}]…`);
2239
+ completions.push(await provider.complete({
2240
+ system: buildBatchGenerationSystemPromptV5(),
2241
+ user: buildBatchGenerationUserPromptV5({ ...gc.ctx, scenarios: selected })
2242
+ }));
2243
+ }
2244
+ catch (e) {
2245
+ const msg = redactSecrets(e instanceof Error ? e.message : String(e));
2246
+ warnings.push(`V5 batch generation failed for "${gc.ctx.behavior_title}": ${msg} — retrying scenarios individually.`);
2247
+ for (const scenario of selected) {
2248
+ try {
2249
+ completions.push(await provider.complete({
2250
+ system: buildBatchGenerationSystemPromptV5(),
2251
+ user: buildBatchGenerationUserPromptV5({ ...gc.ctx, scenarios: [scenario] })
2252
+ }));
2253
+ }
2254
+ catch (singleErr) {
2255
+ const singleMsg = redactSecrets(singleErr instanceof Error ? singleErr.message : String(singleErr));
2256
+ warnings.push(`V5 single-scenario generation failed for "${gc.ctx.behavior_title}" / "${scenario.title}": ${singleMsg}.`);
2257
+ }
2258
+ }
2259
+ }
2260
+ const scenarioById = new Map(selected.map((s) => [s.id, s]));
2261
+ const parsed = completions.flatMap(parseBatchGeneratedTests);
2262
+ const seenScenarioIds = new Set();
2263
+ const relatedFiles = relatedFilePaths(graph, behavior).files;
2264
+ for (let i = 0; i < parsed.length && generated.length < limit; i++) {
2265
+ const parsedTest = parsed[i];
2266
+ let scenario;
2267
+ if (parsedTest.scenario_id === null) {
2268
+ if (selected.length === 1) {
2269
+ scenario = selected[0];
2270
+ }
2271
+ else {
2272
+ warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}": missing scenario delimiter/id in multi-scenario output.`);
2273
+ continue;
2274
+ }
2275
+ }
2276
+ else {
2277
+ scenario = scenarioById.get(parsedTest.scenario_id);
2278
+ if (!scenario) {
2279
+ warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}": unknown scenario id ${parsedTest.scenario_id}.`);
2280
+ continue;
2281
+ }
2282
+ if (seenScenarioIds.has(parsedTest.scenario_id)) {
2283
+ warnings.push(`Dropped duplicate v5 generated test for "${gc.ctx.behavior_title}" / scenario ${parsedTest.scenario_id}.`);
2284
+ continue;
2285
+ }
2286
+ seenScenarioIds.add(parsedTest.scenario_id);
2287
+ }
2288
+ if (!scenario)
2289
+ continue;
2290
+ const rawBody = stripCodeFence(parsedTest.body);
2291
+ const sanitized = sanitizeGeneratedBody(rawBody, gc.ctx.source_excerpts, commentPrefixForFramework(framework));
2292
+ if (sanitized.redactedLines > 0) {
2293
+ warnings.push(`Redacted ${sanitized.redactedLines} echoed source-excerpt line(s) from the v5 generated test for "${gc.ctx.behavior_title}".`);
2294
+ }
2295
+ if (!hasExecutableContent(sanitized.body, framework)) {
2296
+ warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": no executable test code.`);
2297
+ missing.push({
2298
+ external_id: behavior.external_id,
2299
+ title: gc.ctx.behavior_title,
2300
+ reason: `V5 generated no executable code for scenario "${scenario.title}".`,
2301
+ needed: ["a non-empty runnable test body"]
2302
+ });
2303
+ continue;
2304
+ }
2305
+ if (!generatedBodyAlignsWithScenario(sanitized.body, scenario)) {
2306
+ warnings.push(`Dropped v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": body did not reference the planned assertion target.`);
2307
+ missing.push({
2308
+ external_id: behavior.external_id,
2309
+ title: gc.ctx.behavior_title,
2310
+ reason: `V5 generated test did not align with scenario "${scenario.title}".`,
2311
+ needed: ["a generated test body that asserts the planned scenario target"]
2312
+ });
2313
+ continue;
2314
+ }
2315
+ let cleanBody = sanitized.body;
2316
+ if (sanitized.redactedLines > 0) {
2317
+ const fw = framework.toLowerCase();
2318
+ if (!fw.includes("pytest") && !fw.includes("go")) {
2319
+ const stripped = stripRedactedStatements(cleanBody);
2320
+ if (stripped.dropped > 0)
2321
+ cleanBody = stripped.body;
2322
+ }
2323
+ }
2324
+ if (framework.toLowerCase().includes("go")) {
2325
+ cleanBody = applyGoPackage(cleanBody, firstGoPackage(relatedFiles, fileReader));
2326
+ }
2327
+ if (framework.toLowerCase().includes("junit") || framework.toLowerCase().includes("java")) {
2328
+ cleanBody = applyJavaPackage(cleanBody, firstJavaPackage(relatedFiles, fileReader));
2329
+ cleanBody = applyJavaLicenseHeader(cleanBody, firstJavaLicenseHeader(relatedFiles, fileReader));
2330
+ }
2331
+ if (framework.toLowerCase().includes("pytest") || framework.toLowerCase().includes("python")) {
2332
+ cleanBody = applyPythonSrcLayoutImports(cleanBody, relatedFiles);
2333
+ }
2334
+ cleanBody = ensureFrameworkScaffold(cleanBody, framework);
2335
+ const linkedTest = gc.sourceRefs.find((r) => TEST_REF_RE.test(r)) ?? "";
2336
+ const testDir = linkedTest ? (linkedTest.includes("/") ? linkedTest.slice(0, linkedTest.lastIndexOf("/")) : ".") : GENERATED_DIR;
2337
+ const genTestRel = testDir === "." ? "__generated__.test.ts" : `${testDir}/__generated__.test.ts`;
2338
+ const genTestAbs = path.join(graph.workspace.root, genTestRel);
2339
+ let body;
2340
+ let import_provenance;
2341
+ let unresolved_reason;
2342
+ if (BODY_HAS_IMPORTS_RE.test(cleanBody)) {
2343
+ body = cleanBody;
2344
+ import_provenance = "model_provided";
2345
+ }
2346
+ else if (gc.ctx.subject_imports.length) {
2347
+ body = synthesizeImports(framework, gc.ctx.subject_imports).join("\n") + "\n\n" + cleanBody;
2348
+ import_provenance = "test_metadata";
2349
+ }
2350
+ else {
2351
+ const derived = deriveSubjectImport(graph, behavior, relatedFiles, genTestRel, framework);
2352
+ if (derived) {
2353
+ body = [frameworkImport(framework), derived.line, "", cleanBody].join("\n");
2354
+ import_provenance = "resolver_relative";
2355
+ }
2356
+ else {
2357
+ body = cleanBody;
2358
+ import_provenance = "none";
2359
+ unresolved_reason = isResolverFramework(framework)
2360
+ ? "No subject import could be derived without guessing; v5 output dropped from proof-ready set."
2361
+ : undefined;
2362
+ }
2363
+ }
2364
+ const importErrors = isResolverFramework(framework) ? unresolvedLocalImports(body, genTestAbs, graph.workspace.root, declaredDeps) : [];
2365
+ const compileIssue = framework.toLowerCase().includes("go") ? goCompileIssue(body, graph.workspace.root, relatedFiles) : null;
2366
+ const runnable = isRunnable(body, framework, import_provenance, importErrors) && !compileIssue;
2367
+ if (!runnable) {
2368
+ const reason = unresolved_reason ?? compileIssue ?? runnableFailureReason(body, framework, import_provenance, importErrors, declaredDeps);
2369
+ warnings.push(`Dropped non-runnable v5 generated test for "${gc.ctx.behavior_title}" / "${scenario.title}": ${reason}`);
2370
+ missing.push({
2371
+ external_id: behavior.external_id,
2372
+ title: gc.ctx.behavior_title,
2373
+ reason,
2374
+ needed: ["a compiling generated test with a real assertion and resolvable subject import"]
2375
+ });
2376
+ continue;
2377
+ }
2378
+ generated.push({
2379
+ id: `${run_id}-t${generated.length + 1}`,
2380
+ run_id,
2381
+ title: `${gc.ctx.behavior_title} — ${scenario.title}`,
2382
+ test_type: gc.ctx.test_layer,
2383
+ framework_hint: framework,
2384
+ body,
2385
+ bucket: bucketForV5Scenario(scenario),
2386
+ prompt_version: PROMPT_VERSION_V5,
2387
+ grounding: {
2388
+ entity_ids: gc.entityIds,
2389
+ source_refs: dedupe(gc.sourceRefs),
2390
+ weak_relationships_used: gc.weakUsed,
2391
+ import_provenance
2392
+ },
2393
+ weak_evidence_used: gc.weakUsed.length > 0,
2394
+ ...(behavior.kind === "CodeSymbol" ? { target_symbol_external_id: behavior.external_id } : {}),
2395
+ runnable: true
2396
+ });
2397
+ }
2398
+ }
2399
+ }
2400
+ else {
2401
+ // Default: target-focused, bucket-diverse generation (one test per local bucket).
2402
+ const explicitMulti = Boolean(opts.target_ids && opts.target_ids.length > 1);
2403
+ const plan = planGroundedBuckets(graph, runTargets, framework, fileReader, limit, explicitMulti, missing, warnings);
2404
+ // Repo dependency names (read once) — used by the runnable check to tell a missing
2405
+ // baseUrl-local import from a genuine external package the agent has installed.
2406
+ const declaredDeps = readDeclaredDeps(graph.workspace.root);
2407
+ for (const item of plan) {
2408
+ if (generated.length >= limit)
2409
+ break;
2410
+ reportProgress(`Generating "${item.ctx.behavior_title}" [${BUCKET_LABEL[item.bucket]}]…`);
2411
+ // One failed call (timeout, transient HTTP) must not vaporize the run:
2412
+ // disclose, skip this bucket, and keep the completed work. The plan is
2413
+ // limit-bounded, so a systemic failure costs at most `limit` warnings.
2414
+ let completion;
2415
+ try {
2416
+ completion = await provider.complete({ system: systemPrompt, user: buildGroundedUserPrompt(item.ctx, item.bucket) });
2417
+ }
2418
+ catch (e) {
2419
+ const msg = redactSecrets(e instanceof Error ? e.message : String(e));
2420
+ warnings.push(`Model call failed for "${item.ctx.behavior_title}" [${BUCKET_LABEL[item.bucket]}]: ${msg} — no test emitted.`);
2421
+ missing.push({
2422
+ external_id: item.behavior.external_id,
2423
+ title: item.ctx.behavior_title,
2424
+ reason: `Model call failed for the ${BUCKET_LABEL[item.bucket]} bucket: ${msg}`,
2425
+ needed: ["a successful model completion"]
2426
+ });
2427
+ continue;
2428
+ }
2429
+ const rawBody = stripCodeFence(completion.trim());
2430
+ const sanitized = sanitizeGeneratedBody(rawBody, item.ctx.source_excerpts, commentPrefixForFramework(framework));
2431
+ // The privacy disclosure comes FIRST, before any skip: a redaction event
2432
+ // must show in the audit trail even when the redaction empties the body.
2433
+ if (sanitized.redactedLines > 0) {
2434
+ warnings.push(`Redacted ${sanitized.redactedLines} echoed source-excerpt line(s) from the generated test for "${item.ctx.behavior_title}".`);
2435
+ }
2436
+ // NEVER package an empty completion as a test: an empty body with
2437
+ // synthesized imports reads as a generated test (and run_hints would tell
2438
+ // an agent to run it) while proving nothing. Skip + disclose instead —
2439
+ // attributing redaction-emptied bodies to redaction, not token starvation.
2440
+ if (!hasExecutableContent(sanitized.body, framework)) {
2441
+ if (sanitized.redactedLines > 0) {
2442
+ warnings.push(`Redaction removed all executable content for "${item.ctx.behavior_title}" [${BUCKET_LABEL[item.bucket]}] — no test emitted.`);
2443
+ missing.push({
2444
+ external_id: item.behavior.external_id,
2445
+ title: item.ctx.behavior_title,
2446
+ reason: `Redaction removed all executable content from the ${BUCKET_LABEL[item.bucket]} completion; no test emitted.`,
2447
+ needed: ["a completion that does not echo source excerpts"]
2448
+ });
2449
+ }
2450
+ else {
2451
+ warnings.push(`Model returned an empty completion (no executable test code) for "${item.ctx.behavior_title}" [${BUCKET_LABEL[item.bucket]}] — no test emitted. ` +
2452
+ `Reasoning models can exhaust their token budget on long grounded prompts; retry, or try another model.`);
2453
+ missing.push({
2454
+ external_id: item.behavior.external_id,
2455
+ title: item.ctx.behavior_title,
2456
+ reason: `Model returned an empty completion (no executable test code) for the ${BUCKET_LABEL[item.bucket]} bucket; no test emitted.`,
2457
+ needed: ["a non-empty model completion"]
2458
+ });
2459
+ }
2460
+ continue;
2461
+ }
2462
+ let cleanBody = sanitized.body;
2463
+ if (sanitized.redactedLines > 0) {
2464
+ const fw = framework.toLowerCase();
2465
+ if (!fw.includes("pytest") && !fw.includes("go")) {
2466
+ const stripped = stripRedactedStatements(cleanBody);
2467
+ if (stripped.dropped > 0) {
2468
+ cleanBody = stripped.body;
2469
+ warnings.push(`Removed ${stripped.dropped} statement(s) left broken by redaction in "${item.ctx.behavior_title}" — the emitted test stays parseable.`);
2470
+ }
2471
+ }
2472
+ if (!hasExecutableContent(cleanBody, framework)) {
2473
+ warnings.push(`Redaction removed all executable content for "${item.ctx.behavior_title}" [${BUCKET_LABEL[item.bucket]}] — no test emitted.`);
2474
+ missing.push({
2475
+ external_id: item.behavior.external_id,
2476
+ title: item.ctx.behavior_title,
2477
+ reason: `Redaction removed all executable content from the ${BUCKET_LABEL[item.bucket]} completion; no test emitted.`,
2478
+ needed: ["a completion that does not echo source excerpts"]
2479
+ });
2480
+ continue;
2481
+ }
2482
+ }
2483
+ const relatedFiles = relatedFilePaths(graph, item.behavior).files;
2484
+ if (framework.toLowerCase().includes("go")) {
2485
+ cleanBody = applyGoPackage(cleanBody, firstGoPackage(relatedFiles, fileReader));
2486
+ }
2487
+ if (framework.toLowerCase().includes("junit") || framework.toLowerCase().includes("java")) {
2488
+ const licenseHeader = firstJavaLicenseHeader([...item.sourceRefs, ...relatedFiles], fileReader);
2489
+ cleanBody = applyJavaPackage(cleanBody, firstJavaPackage(relatedFiles, fileReader));
2490
+ cleanBody = applyJavaLicenseHeader(cleanBody, licenseHeader);
2491
+ }
2492
+ if (framework.toLowerCase().includes("pytest") || framework.toLowerCase().includes("python")) {
2493
+ cleanBody = applyPythonSrcLayoutImports(cleanBody, relatedFiles);
2494
+ }
2495
+ cleanBody = ensureFrameworkScaffold(cleanBody, framework);
2496
+ // Where this generated test will LIVE drives both the relative-import
2497
+ // resolution context and the run-hint path: next to its linked existing test
2498
+ // when one is known, else the repo-root orangepro_generated/ dir.
2499
+ const linkedTest = item.sourceRefs.find((r) => TEST_REF_RE.test(r)) ?? "";
2500
+ const testDir = linkedTest ? (linkedTest.includes("/") ? linkedTest.slice(0, linkedTest.lastIndexOf("/")) : ".") : GENERATED_DIR;
2501
+ const genTestRel = testDir === "." ? "__generated__.test.ts" : `${testDir}/__generated__.test.ts`;
2502
+ const genTestAbs = path.join(graph.workspace.root, genTestRel);
2503
+ // Resolve the SUBJECT import + its provenance. The kit NEVER fabricates a
2504
+ // module specifier: when the model wrote no imports and none can be
2505
+ // resolver-derived, the test ships as a non-runnable grounded draft.
2506
+ let body;
2507
+ let import_provenance;
2508
+ let unresolved_reason;
2509
+ if (BODY_HAS_IMPORTS_RE.test(cleanBody)) {
2510
+ // The model wrote its own imports (the prompt demands complete imports);
2511
+ // prepending ours would duplicate declarations and break the single file.
2512
+ // These are NOT trusted blindly — their relative specifiers are resolver-
2513
+ // validated below, so a model-written `./missing` cannot ride as runnable.
2514
+ body = cleanBody;
2515
+ import_provenance = "model_provided";
2516
+ }
2517
+ else if (item.ctx.subject_imports.length) {
2518
+ // Reuse the linked existing test's real imports (the repo proves they resolve).
2519
+ body = synthesizeImports(framework, item.ctx.subject_imports).join("\n") + "\n\n" + cleanBody;
2520
+ import_provenance = "test_metadata";
2521
+ }
2522
+ else {
2523
+ // No model imports and no linked-test imports → derive a RESOLVER-VALIDATED
2524
+ // import from the import graph, or emit a grounded draft rather than guess.
2525
+ const derived = deriveSubjectImport(graph, item.behavior, relatedFiles, genTestRel, framework);
2526
+ if (derived) {
2527
+ body = [frameworkImport(framework), derived.line, "", cleanBody].join("\n");
2528
+ import_provenance = "resolver_relative";
2529
+ }
2530
+ else {
2531
+ body = cleanBody;
2532
+ import_provenance = "none";
2533
+ unresolved_reason = isResolverFramework(framework)
2534
+ ? "No subject import could be derived without guessing (no linked source file with a validated export). " +
2535
+ "Grounded draft — add the import for the module under test before running."
2536
+ : undefined;
2537
+ }
2538
+ }
2539
+ // Mechanical runnability — including validating that EVERY local import in the
2540
+ // final body (relative OR tsconfig-alias; model-written or kit-added) resolves
2541
+ // from where the test will live. A test whose own import won't load is never
2542
+ // marked runnable.
2543
+ const importErrors = isResolverFramework(framework) ? unresolvedLocalImports(body, genTestAbs, graph.workspace.root, declaredDeps) : [];
2544
+ const compileIssue = framework.toLowerCase().includes("go") ? goCompileIssue(body, graph.workspace.root, relatedFiles) : null;
2545
+ const runnable = isRunnable(body, framework, import_provenance, importErrors) && !compileIssue;
2546
+ if (!runnable && !unresolved_reason) {
2547
+ unresolved_reason = compileIssue ?? runnableFailureReason(body, framework, import_provenance, importErrors, declaredDeps);
2548
+ }
2549
+ generated.push({
2550
+ id: `${run_id}-t${generated.length + 1}`,
2551
+ run_id,
2552
+ title: `${item.ctx.behavior_title} — ${BUCKET_LABEL[item.bucket]}`,
2553
+ test_type: item.ctx.test_layer,
2554
+ framework_hint: framework,
2555
+ body,
2556
+ bucket: item.bucket,
2557
+ prompt_version: PROMPT_VERSION,
2558
+ grounding: {
2559
+ entity_ids: item.entityIds,
2560
+ source_refs: dedupe(item.sourceRefs),
2561
+ weak_relationships_used: item.weakUsed,
2562
+ import_provenance
2563
+ },
2564
+ weak_evidence_used: item.weakUsed.length > 0,
2565
+ ...(item.behavior.kind === "CodeSymbol" ? { target_symbol_external_id: item.behavior.external_id } : {}),
2566
+ runnable,
2567
+ ...(unresolved_reason ? { unresolved_reason } : {})
2568
+ });
2569
+ }
2570
+ }
2571
+ if (targets.length === 0) {
2572
+ warnings.push("No behavior anchors available to target. Add requirements/templates or analyze a path with tests.");
2573
+ }
2574
+ const run = generated.length
2575
+ ? {
2576
+ run_id,
2577
+ model_provider: provider.providerName,
2578
+ model_name: provider.modelName,
2579
+ input_mode: inputMode,
2580
+ prompt_version: promptVersion,
2581
+ created_at,
2582
+ generated_test_ids: generated.map((t) => t.id)
2583
+ }
2584
+ : null;
2585
+ return { run, generated_tests: generated, missing_evidence: missing, warnings };
2586
+ }