@orangepro/orangepro-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/local/agentWorkflow.js +81 -0
- package/dist/local/aiGraph/links.js +635 -0
- package/dist/local/analyze/analyzer.js +2129 -0
- package/dist/local/analyze/behaviorContracts.js +169 -0
- package/dist/local/analyze/boilerplate.js +42 -0
- package/dist/local/analyze/callGraph.js +458 -0
- package/dist/local/analyze/classify.js +219 -0
- package/dist/local/analyze/clustering.js +357 -0
- package/dist/local/analyze/confirm.js +2422 -0
- package/dist/local/analyze/coverage.js +518 -0
- package/dist/local/analyze/coverageArtifacts.js +607 -0
- package/dist/local/analyze/frameworks.js +115 -0
- package/dist/local/analyze/linkage/conventions.js +160 -0
- package/dist/local/analyze/parseCache.js +164 -0
- package/dist/local/analyze/selfAssert.js +53 -0
- package/dist/local/analyze/symbols.js +430 -0
- package/dist/local/analyze/testLayer.js +135 -0
- package/dist/local/analyze/treeSitter/engine.js +1253 -0
- package/dist/local/analyze/treeSitter/languages.js +101 -0
- package/dist/local/autoProve.js +620 -0
- package/dist/local/cli.js +1468 -0
- package/dist/local/cliArgs.js +112 -0
- package/dist/local/corpusScope.js +162 -0
- package/dist/local/enrich/csv.js +348 -0
- package/dist/local/enrich/index.js +43 -0
- package/dist/local/enrich/markdown.js +193 -0
- package/dist/local/explain/explain.js +91 -0
- package/dist/local/exportCli.js +26 -0
- package/dist/local/flows/flowWalker.js +215 -0
- package/dist/local/flows/llmFlowDiscovery.js +567 -0
- package/dist/local/freshness/changed.js +280 -0
- package/dist/local/freshness/manifest.js +35 -0
- package/dist/local/freshness/status.js +30 -0
- package/dist/local/gaps/gaps.js +114 -0
- package/dist/local/generate/buckets.js +73 -0
- package/dist/local/generate/compareJudge.js +124 -0
- package/dist/local/generate/compareReport.js +538 -0
- package/dist/local/generate/compareScore.js +105 -0
- package/dist/local/generate/deriveImports.js +91 -0
- package/dist/local/generate/generator.js +2586 -0
- package/dist/local/generate/prompt.js +144 -0
- package/dist/local/generate/promptV5.js +438 -0
- package/dist/local/generate/providers.js +400 -0
- package/dist/local/generate/runHints.js +304 -0
- package/dist/local/graph/citations.js +73 -0
- package/dist/local/graph/confirmable.js +72 -0
- package/dist/local/graph/factories.js +210 -0
- package/dist/local/graph/ontology.js +18 -0
- package/dist/local/interactive.js +53 -0
- package/dist/local/jobs/jobStore.js +80 -0
- package/dist/local/jobs/notify.js +29 -0
- package/dist/local/jobs/runner.js +75 -0
- package/dist/local/ledger.js +117 -0
- package/dist/local/localConfig.js +112 -0
- package/dist/local/mcp.js +548 -0
- package/dist/local/operations.js +1749 -0
- package/dist/local/pack/coverageReport.js +192 -0
- package/dist/local/pack/exporter.js +195 -0
- package/dist/local/pack/schema.js +128 -0
- package/dist/local/pack/summary.js +127 -0
- package/dist/local/pack/validate.js +25 -0
- package/dist/local/proofRunnability.js +366 -0
- package/dist/local/recipe/dbSqljs.js +255 -0
- package/dist/local/reprove/paths.js +13 -0
- package/dist/local/reprove/scoped.js +136 -0
- package/dist/local/resolve/barrelWalker.js +178 -0
- package/dist/local/resolve/exportIndex.js +270 -0
- package/dist/local/resolve/importGraph.js +347 -0
- package/dist/local/resolve/resolver.js +122 -0
- package/dist/local/resolve/resolverCache.js +117 -0
- package/dist/local/rtm.js +413 -0
- package/dist/local/score/coverage.js +99 -0
- package/dist/local/score/doctor.js +67 -0
- package/dist/local/score/risk.js +362 -0
- package/dist/local/score/score.js +182 -0
- package/dist/local/types.js +1 -0
- package/dist/local/util/hash.js +16 -0
- package/dist/local/util/ids.js +16 -0
- package/dist/local/util/progress.js +8 -0
- package/dist/local/util/redact.js +39 -0
- package/dist/local/util/time.js +1 -0
- package/dist/local/util/walk.js +174 -0
- package/dist/local/viz/behaviorReportData.js +367 -0
- package/dist/local/viz/behaviorReportHtml.js +664 -0
- package/dist/local/viz/d3.bundle.js +3 -0
- package/dist/local/viz/html.js +1152 -0
- package/dist/local/viz/payload.js +525 -0
- package/dist/local/workspace.js +99 -0
- package/docs/agent-workflow.md +167 -0
- package/docs/agents/claude-code.md +43 -0
- package/docs/agents/codex.md +52 -0
- package/docs/agents/cursor.md +39 -0
- package/docs/agents/opencode.md +43 -0
- package/docs/agents/vscode.md +34 -0
- package/docs/local-proof-kit.md +269 -0
- package/package.json +92 -0
- package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
- package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
- package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
- package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
- package/scripts/spikes/failure-summary.mjs +29 -0
|
@@ -0,0 +1,1749 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { LOCAL_GRAPH_SCHEMA_VERSION } from "./graph/ontology.js";
|
|
6
|
+
import { systemClock } from "./util/time.js";
|
|
7
|
+
import { redactSecrets } from "./util/redact.js";
|
|
8
|
+
import { isPathIgnored, loadIgnore, walkFiles } from "./util/walk.js";
|
|
9
|
+
import { WORKSPACE_DIR, deriveWorkspaceName, graphExists, initWorkspace, loadConfig, saveConfig, loadGraph, saveGraph, workspaceInitialized, workspacePaths } from "./workspace.js";
|
|
10
|
+
import { loadProviderEnv, resolveProviderConfig } from "./localConfig.js";
|
|
11
|
+
import { behaviorNodes, denominatorComposition } from "./graph/factories.js";
|
|
12
|
+
import { summarizeTestEvidence } from "./graph/citations.js";
|
|
13
|
+
import { analyzeRepo } from "./analyze/analyzer.js";
|
|
14
|
+
import { ParseCache } from "./analyze/parseCache.js";
|
|
15
|
+
import { ResolverCache } from "./resolve/resolverCache.js";
|
|
16
|
+
import { manifestKindOf, roleOf } from "./analyze/classify.js";
|
|
17
|
+
import { enrichFromContent } from "./enrich/index.js";
|
|
18
|
+
import { scoreGraph } from "./score/score.js";
|
|
19
|
+
import { doctorGraph } from "./score/doctor.js";
|
|
20
|
+
import { findGaps } from "./gaps/gaps.js";
|
|
21
|
+
import { rankRiskGaps } from "./score/risk.js";
|
|
22
|
+
import { generateTests } from "./generate/generator.js";
|
|
23
|
+
import { autoProve, NO_KEY_MESSAGE, isEligibleProvableTarget } from "./autoProve.js";
|
|
24
|
+
import { AGENT_RUN_WORKFLOW, GROUNDING_CONTRACT, runnableRunHintsFor } from "./generate/runHints.js";
|
|
25
|
+
import { buildProvider, DeterministicProvider } from "./generate/providers.js";
|
|
26
|
+
import { buildOracle, scoreArm, armMetrics } from "./generate/compareScore.js";
|
|
27
|
+
import { judgeComparison, buildJudgeContext } from "./generate/compareJudge.js";
|
|
28
|
+
import { renderCompareReportMarkdown, renderArmTestsFile, renderArmTestsJson, testsFileExt, testsArtifactName, compareTestsFramework } from "./generate/compareReport.js";
|
|
29
|
+
import { buildPack } from "./pack/exporter.js";
|
|
30
|
+
import { packToMarkdown } from "./pack/summary.js";
|
|
31
|
+
import { validatePack } from "./pack/validate.js";
|
|
32
|
+
import { buildManifest, readGitInfo } from "./freshness/manifest.js";
|
|
33
|
+
import { computeFreshness } from "./freshness/status.js";
|
|
34
|
+
import { changedImpact } from "./freshness/changed.js";
|
|
35
|
+
import { explainTest } from "./explain/explain.js";
|
|
36
|
+
import { buildVizPayload } from "./viz/payload.js";
|
|
37
|
+
import { renderVizHtml } from "./viz/html.js";
|
|
38
|
+
import { buildBehaviorReportData, dominantBlockReason } from "./viz/behaviorReportData.js";
|
|
39
|
+
import { renderBehaviorReport } from "./viz/behaviorReportHtml.js";
|
|
40
|
+
import { renderCoverageReport } from "./pack/coverageReport.js";
|
|
41
|
+
import { confirmedCoverageByLayer } from "./score/coverage.js";
|
|
42
|
+
import { prepareRuntimeCoverage } from "./analyze/coverageArtifacts.js";
|
|
43
|
+
import { appendLedgerRecord, canReproveLanguage, loadLedger, ledgerStats, proofEdgesFor, reproveTarget, resolveTargetSymbol, targetFingerprint, targetLanguage } from "./ledger.js";
|
|
44
|
+
import { buildRtm, renderRtmCsv, renderRtmMarkdown } from "./rtm.js";
|
|
45
|
+
import { tryScopedReprove } from "./reprove/scoped.js";
|
|
46
|
+
import { resolveContained, toWorkspaceRel } from "./reprove/paths.js";
|
|
47
|
+
import { hashBuffer } from "./util/hash.js";
|
|
48
|
+
import { applyAiLinks, generateAiLinks, summarizeAiLinks } from "./aiGraph/links.js";
|
|
49
|
+
import { applyAiFlows, generateAiFlows, isValidStoredCandidateFlowMeta, revalidateCandidateFlowMeta } from "./flows/llmFlowDiscovery.js";
|
|
50
|
+
import { summarizeCorpusScope } from "./corpusScope.js";
|
|
51
|
+
import { reportProgress } from "./util/progress.js";
|
|
52
|
+
function defaultDeps() {
|
|
53
|
+
return { clock: systemClock, env: process.env };
|
|
54
|
+
}
|
|
55
|
+
function dynamicProofSpikePath() {
|
|
56
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
57
|
+
return resolve(here, "..", "..", "scripts", "spikes", "dynamic-proof-spike.mjs");
|
|
58
|
+
}
|
|
59
|
+
function defaultDynamicProofRunner(args, opts = {}) {
|
|
60
|
+
const script = dynamicProofSpikePath();
|
|
61
|
+
if (!existsSync(script)) {
|
|
62
|
+
throw new Error(`Dynamic proof spike runner not found at ${script}. Run this command from an OrangePro source checkout with scripts/spikes available.`);
|
|
63
|
+
}
|
|
64
|
+
const child = spawnSync(process.execPath, [script, ...args], {
|
|
65
|
+
cwd: opts.cwd,
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
68
|
+
});
|
|
69
|
+
if (child.error)
|
|
70
|
+
throw child.error;
|
|
71
|
+
return {
|
|
72
|
+
stdout: child.stdout ?? "",
|
|
73
|
+
stderr: child.stderr ?? "",
|
|
74
|
+
exitCode: child.status ?? 1
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function parseDynamicProofJson(stdout, stderr) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(stdout);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const detail = redactSecrets((stderr || stdout).slice(0, 2000));
|
|
83
|
+
throw new Error(`Dynamic proof runner did not return JSON.${detail ? ` stderr/stdout: ${detail}` : ""}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function dynamicProofSucceeded(oracle) {
|
|
87
|
+
return (oracle.status === "proven" &&
|
|
88
|
+
oracle.proven === true &&
|
|
89
|
+
oracle.baseline?.exitCode === 0 &&
|
|
90
|
+
oracle.baseline?.timedOut !== true &&
|
|
91
|
+
oracle.mutant?.exitCode !== 0 &&
|
|
92
|
+
oracle.mutant?.assertionFailure === true &&
|
|
93
|
+
oracle.mutant?.timedOut !== true);
|
|
94
|
+
}
|
|
95
|
+
function symbolTargetParts(symExtId) {
|
|
96
|
+
const match = /^sym:(.+)#([^#]+)$/.exec(symExtId);
|
|
97
|
+
if (!match) {
|
|
98
|
+
throw new Error(`Cannot derive dynamic proof target from symbol id: ${symExtId}`);
|
|
99
|
+
}
|
|
100
|
+
const [, file, symbolName] = match;
|
|
101
|
+
const method = symbolName.split(".").filter(Boolean).pop();
|
|
102
|
+
if (!file || !method) {
|
|
103
|
+
throw new Error(`Cannot derive dynamic proof target from symbol id: ${symExtId}`);
|
|
104
|
+
}
|
|
105
|
+
return { file, method };
|
|
106
|
+
}
|
|
107
|
+
function assertProofTargetMatchesSymbol(opts, symbolTarget) {
|
|
108
|
+
if (opts.target_path !== undefined && opts.target_path !== "") {
|
|
109
|
+
const provided = opts.target_path.split(/[\\/]+/).join("/");
|
|
110
|
+
if (provided !== symbolTarget.file) {
|
|
111
|
+
throw new Error(`prove target mismatch: --target-file ${provided} does not match resolved symbol file ${symbolTarget.file}. Dynamic proof certificates must mutate the credited symbol.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (opts.method !== undefined && opts.method !== "" && opts.method !== symbolTarget.method) {
|
|
115
|
+
throw new Error(`prove target mismatch: --method ${opts.method} does not match resolved symbol member ${symbolTarget.method}. Dynamic proof certificates must mutate the credited symbol.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function assertProofSourceMatchesGraph(sourceRoot, graph) {
|
|
119
|
+
const graphRoot = resolve(graph.workspace.root);
|
|
120
|
+
if (sourceRoot !== graphRoot) {
|
|
121
|
+
throw new Error(`prove source mismatch: --source ${sourceRoot} does not match the analyzed graph root ${graphRoot}. Run OrangePro analyze/prove against the same checkout before minting public Proven.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Refuse to prove against a target file that changed since analyze: the cert's
|
|
126
|
+
* target_fingerprint is derived from the analyzed graph manifest hash, so proving
|
|
127
|
+
* against different current bytes would credit a proof of the CURRENT source to a
|
|
128
|
+
* STALE graph revision and RTM would show Proven for code the graph never indexed.
|
|
129
|
+
* No manifest entry → the fingerprint is undefined and RTM never counts it.
|
|
130
|
+
*/
|
|
131
|
+
function assertTargetFileFresh(sourceRoot, targetRel, graph) {
|
|
132
|
+
const analyzedHash = graph.manifest.files[targetRel]?.hash;
|
|
133
|
+
if (analyzedHash === undefined)
|
|
134
|
+
return;
|
|
135
|
+
const targetAbs = resolveContained(sourceRoot, targetRel);
|
|
136
|
+
if (existsSync(targetAbs) && hashBuffer(readFileSync(targetAbs)) !== analyzedHash) {
|
|
137
|
+
throw new Error(`prove: target file ${targetRel} changed since analyze; rerun \`opro analyze\` before proving so the proof binds to the current code.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Parse a positive-int env override; undefined falls back to the analyzer default. */
|
|
141
|
+
function positiveIntEnv(raw) {
|
|
142
|
+
if (!raw)
|
|
143
|
+
return undefined;
|
|
144
|
+
const n = Number.parseInt(raw, 10);
|
|
145
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
146
|
+
}
|
|
147
|
+
/** Read the configurable inferred-flow cap (ORANGEPRO_MAX_FLOWS) from the env. */
|
|
148
|
+
function maxFlowsFromEnv(env) {
|
|
149
|
+
return positiveIntEnv(env.ORANGEPRO_MAX_FLOWS);
|
|
150
|
+
}
|
|
151
|
+
/** Read the configurable file-scan cap (ORANGEPRO_MAX_FILES) from the env. */
|
|
152
|
+
function maxFilesFromEnv(env) {
|
|
153
|
+
return positiveIntEnv(env.ORANGEPRO_MAX_FILES);
|
|
154
|
+
}
|
|
155
|
+
/** Read the configurable symbol-extraction cap (ORANGEPRO_MAX_SYMBOLS) from the env. */
|
|
156
|
+
function maxSymbolsFromEnv(env) {
|
|
157
|
+
return positiveIntEnv(env.ORANGEPRO_MAX_SYMBOLS);
|
|
158
|
+
}
|
|
159
|
+
/** Read the configurable per-file-scan wall-clock budget (ORANGEPRO_MAX_ANALYZE_MS) from the env. */
|
|
160
|
+
function maxAnalyzeMsFromEnv(env) {
|
|
161
|
+
return positiveIntEnv(env.ORANGEPRO_MAX_ANALYZE_MS);
|
|
162
|
+
}
|
|
163
|
+
/** Path of the persistent parse cache (next to the workspace graph). */
|
|
164
|
+
function parseCachePath(graphPath) {
|
|
165
|
+
return join(dirname(graphPath), "parse-cache.json");
|
|
166
|
+
}
|
|
167
|
+
/** Load the persistent parse cache; a missing/garbage/old-version file starts empty (never trusted). */
|
|
168
|
+
function loadParseCache(graphPath) {
|
|
169
|
+
try {
|
|
170
|
+
const raw = readFileSync(parseCachePath(graphPath), "utf8");
|
|
171
|
+
return new ParseCache(JSON.parse(raw));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return new ParseCache(null);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Path of the persistent resolver cache (next to the workspace graph). */
|
|
178
|
+
function resolverCachePath(graphPath) {
|
|
179
|
+
return join(dirname(graphPath), "resolver-cache.json");
|
|
180
|
+
}
|
|
181
|
+
/** Load the persistent resolver cache; a missing/garbage file starts empty (the gate re-validates). */
|
|
182
|
+
function loadResolverCache(graphPath) {
|
|
183
|
+
try {
|
|
184
|
+
const raw = readFileSync(resolverCachePath(graphPath), "utf8");
|
|
185
|
+
return new ResolverCache(JSON.parse(raw));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return new ResolverCache(null);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
192
|
+
/** Write atomically (temp + rename) so a concurrent poller never reads a half file. */
|
|
193
|
+
function writeFileAtomic(path, data) {
|
|
194
|
+
const tmp = `${path}.tmp`;
|
|
195
|
+
writeFileSync(tmp, data, "utf8");
|
|
196
|
+
renameSync(tmp, path);
|
|
197
|
+
}
|
|
198
|
+
function gitRunner(root) {
|
|
199
|
+
return (args) => {
|
|
200
|
+
try {
|
|
201
|
+
return execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/** Injection-safe `gh` runner (array args, never a shell string); null on failure. */
|
|
209
|
+
function ghRunner(root) {
|
|
210
|
+
return (args) => {
|
|
211
|
+
try {
|
|
212
|
+
return execFileSync("gh", args, { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString();
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function fileReaderFor(root) {
|
|
220
|
+
const absRoot = resolve(root);
|
|
221
|
+
return (relPath) => {
|
|
222
|
+
const abs = resolve(absRoot, relPath);
|
|
223
|
+
// Guard against traversal AND sibling-prefix escapes (e.g. /root vs /root-evil).
|
|
224
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + sep))
|
|
225
|
+
return null;
|
|
226
|
+
try {
|
|
227
|
+
return readFileSync(abs, "utf8");
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** Lightweight content-hash scan (no node building) for freshness checks. */
|
|
235
|
+
function scanFileEntries(root) {
|
|
236
|
+
const ignore = loadIgnore(root);
|
|
237
|
+
const files = walkFiles(root, ignore);
|
|
238
|
+
const entries = {};
|
|
239
|
+
for (const f of files) {
|
|
240
|
+
entries[f.relPath] = { hash: f.hash, size: f.size, kind: manifestKindOf(f.relPath) };
|
|
241
|
+
}
|
|
242
|
+
return entries;
|
|
243
|
+
}
|
|
244
|
+
function dedupeNodesByExternalId(nodes) {
|
|
245
|
+
const seen = new Map();
|
|
246
|
+
for (const n of nodes) {
|
|
247
|
+
const prev = seen.get(n.external_id);
|
|
248
|
+
if (!prev || n.confidence > prev.confidence)
|
|
249
|
+
seen.set(n.external_id, n);
|
|
250
|
+
}
|
|
251
|
+
return [...seen.values()];
|
|
252
|
+
}
|
|
253
|
+
function dedupeById(items) {
|
|
254
|
+
const seen = new Map();
|
|
255
|
+
for (const it of items)
|
|
256
|
+
if (!seen.has(it.id))
|
|
257
|
+
seen.set(it.id, it);
|
|
258
|
+
return [...seen.values()];
|
|
259
|
+
}
|
|
260
|
+
/** Drop edges whose endpoints are not present — enforces "no dangling edges". */
|
|
261
|
+
function pruneDanglingEdges(nodes, edges) {
|
|
262
|
+
const ids = new Set(nodes.map((n) => n.external_id));
|
|
263
|
+
return edges.filter((e) => ids.has(e.from_external_id) && ids.has(e.to_external_id));
|
|
264
|
+
}
|
|
265
|
+
/** Discover optional enricher inputs (templates/docs) without LLM. */
|
|
266
|
+
function collectEnricherFragments(root, extraPaths, includeMarkdown) {
|
|
267
|
+
const fragments = [];
|
|
268
|
+
const reader = fileReaderFor(root);
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
const tryFile = (relPath) => {
|
|
271
|
+
if (seen.has(relPath))
|
|
272
|
+
return;
|
|
273
|
+
seen.add(relPath);
|
|
274
|
+
const content = reader(relPath);
|
|
275
|
+
if (!content)
|
|
276
|
+
return;
|
|
277
|
+
const frag = enrichFromContent(relPath, content);
|
|
278
|
+
if (frag)
|
|
279
|
+
fragments.push(frag);
|
|
280
|
+
};
|
|
281
|
+
for (const p of extraPaths)
|
|
282
|
+
tryFile(p);
|
|
283
|
+
const ignore = loadIgnore(root);
|
|
284
|
+
for (const f of walkFiles(root, ignore)) {
|
|
285
|
+
const lower = f.relPath.toLowerCase();
|
|
286
|
+
if (lower.endsWith(".csv"))
|
|
287
|
+
tryFile(f.relPath);
|
|
288
|
+
else if (includeMarkdown &&
|
|
289
|
+
/\.(md|mdx|markdown)$/.test(lower) &&
|
|
290
|
+
/(requirement|template|acceptance|stories|prd|spec|intake)/.test(lower)) {
|
|
291
|
+
tryFile(f.relPath);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return fragments.filter(Boolean);
|
|
295
|
+
}
|
|
296
|
+
function buildGraphFromFragments(workspaceRoot, scanRoot, analyzeFragment, enrichFragments, now) {
|
|
297
|
+
const paths = workspacePaths(workspaceRoot);
|
|
298
|
+
const config = loadConfig(paths);
|
|
299
|
+
const repoSource = analyzeFragment.sources[0];
|
|
300
|
+
const nodes = dedupeNodesByExternalId([
|
|
301
|
+
...analyzeFragment.nodes,
|
|
302
|
+
...enrichFragments.flatMap((f) => f.nodes)
|
|
303
|
+
]);
|
|
304
|
+
const edges = dedupeById([...analyzeFragment.edges, ...enrichFragments.flatMap((f) => f.edges)]);
|
|
305
|
+
const candidate_edges = dedupeById([
|
|
306
|
+
...analyzeFragment.candidate_edges,
|
|
307
|
+
...enrichFragments.flatMap((f) => f.candidate_edges)
|
|
308
|
+
]);
|
|
309
|
+
const sources = [...analyzeFragment.sources, ...enrichFragments.flatMap((f) => f.sources)];
|
|
310
|
+
const manifest = buildManifest(analyzeFragment.file_entries, readGitInfo(gitRunner(scanRoot)), now);
|
|
311
|
+
const prunedEdges = pruneDanglingEdges(nodes, edges);
|
|
312
|
+
return {
|
|
313
|
+
schema_version: LOCAL_GRAPH_SCHEMA_VERSION,
|
|
314
|
+
workspace: {
|
|
315
|
+
name: config.workspace_name || deriveWorkspaceName(scanRoot),
|
|
316
|
+
root: resolve(scanRoot),
|
|
317
|
+
root_hash: repoSource?.content_hash ?? "sha256:",
|
|
318
|
+
source_upload_policy: "metadata_only"
|
|
319
|
+
},
|
|
320
|
+
created_at: now,
|
|
321
|
+
updated_at: now,
|
|
322
|
+
sources,
|
|
323
|
+
nodes,
|
|
324
|
+
edges: prunedEdges,
|
|
325
|
+
candidate_edges: pruneDanglingEdges(nodes, candidate_edges),
|
|
326
|
+
generation_runs: [],
|
|
327
|
+
generated_tests: [],
|
|
328
|
+
manifest,
|
|
329
|
+
// Composition AND confirmed-by-layer are computed over the MERGED node set
|
|
330
|
+
// (analyzer + enrichers) and the pruned edges, so template/markdown
|
|
331
|
+
// requirements land in the denominator and the confirmed metric agrees with it.
|
|
332
|
+
analysis: {
|
|
333
|
+
...analyzeFragment.analysis,
|
|
334
|
+
denominator: denominatorComposition({ nodes }),
|
|
335
|
+
confirmed_by_layer: confirmedCoverageByLayer({ nodes, edges: prunedEdges })
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// ── operations ───────────────────────────────────────────────────────
|
|
340
|
+
export function opInit(root, deps = defaultDeps()) {
|
|
341
|
+
const { paths } = initWorkspace(root, deps.clock());
|
|
342
|
+
return { graph_path: paths.graphPath, config_path: paths.configPath };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Carry an applied AI candidate-flow lane across a re-analysis. The stored lane
|
|
346
|
+
* is untrusted input: a missing/corrupt old graph or a malformed/invariant-
|
|
347
|
+
* violating lane is dropped (never throws), and re-validation touches ONLY
|
|
348
|
+
* analysis.candidate_flows — never edges, tiers, or the denominator.
|
|
349
|
+
*/
|
|
350
|
+
function preserveCandidateFlows(graphPath, graph) {
|
|
351
|
+
let previous;
|
|
352
|
+
try {
|
|
353
|
+
if (existsSync(graphPath))
|
|
354
|
+
previous = loadGraph(graphPath).analysis?.candidate_flows;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return graph;
|
|
358
|
+
}
|
|
359
|
+
if (!previous || !isValidStoredCandidateFlowMeta(previous))
|
|
360
|
+
return graph;
|
|
361
|
+
try {
|
|
362
|
+
return {
|
|
363
|
+
...graph,
|
|
364
|
+
analysis: { ...graph.analysis, candidate_flows: revalidateCandidateFlowMeta(previous, graph) }
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return graph;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
export function opAnalyze(root, opts = {}, deps = defaultDeps()) {
|
|
372
|
+
const now = deps.clock();
|
|
373
|
+
const paths = workspacePaths(root);
|
|
374
|
+
const scanRoot = opts.source ? resolve(opts.source) : resolve(root);
|
|
375
|
+
// Fail loudly on a typo'd path instead of silently producing an empty graph.
|
|
376
|
+
if (!existsSync(scanRoot)) {
|
|
377
|
+
throw new Error(`Path not found: ${scanRoot}. Pass an existing directory to analyze (e.g. \`opro analyze .\`).`);
|
|
378
|
+
}
|
|
379
|
+
if (!statSync(scanRoot).isDirectory()) {
|
|
380
|
+
throw new Error(`Not a directory: ${scanRoot}. Pass a directory to analyze (e.g. \`opro analyze .\`).`);
|
|
381
|
+
}
|
|
382
|
+
if (!opts.suppressProgress)
|
|
383
|
+
reportProgress(`analyze: source ${scanRoot}`, { current: 1, total: 4 });
|
|
384
|
+
const runtimeCoveragePrepare = opts.generateCoverage
|
|
385
|
+
? (!opts.suppressProgress && reportProgress("coverage: generating local runtime coverage before graph build", { current: 2, total: 4 }),
|
|
386
|
+
prepareRuntimeCoverage(scanRoot, { generate: true, timeoutMs: opts.coverageTimeoutMs, runner: deps.coverageRunner }))
|
|
387
|
+
: undefined;
|
|
388
|
+
if (!workspaceInitialized(root))
|
|
389
|
+
initWorkspace(root, now);
|
|
390
|
+
if (!opts.suppressProgress) {
|
|
391
|
+
reportProgress("analyze: parsing source and building deterministic graph", {
|
|
392
|
+
current: opts.generateCoverage ? 3 : 2,
|
|
393
|
+
total: opts.generateCoverage ? 4 : 3
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const parseCache = loadParseCache(paths.graphPath);
|
|
397
|
+
const resolverCache = loadResolverCache(paths.graphPath);
|
|
398
|
+
const analyzeFragment = analyzeRepo(scanRoot, {
|
|
399
|
+
readContent: true,
|
|
400
|
+
maxInferredFlows: maxFlowsFromEnv(deps.env),
|
|
401
|
+
maxFiles: maxFilesFromEnv(deps.env),
|
|
402
|
+
maxSymbols: maxSymbolsFromEnv(deps.env),
|
|
403
|
+
maxAnalyzeMs: maxAnalyzeMsFromEnv(deps.env),
|
|
404
|
+
parseCache,
|
|
405
|
+
resolverCache
|
|
406
|
+
});
|
|
407
|
+
// Persist the (pruned-to-this-run) parse cache so the next analyze reuses unchanged files.
|
|
408
|
+
// Best-effort: a cache write failure must never fail analyze.
|
|
409
|
+
try {
|
|
410
|
+
writeFileAtomic(parseCachePath(paths.graphPath), JSON.stringify(parseCache.toData()));
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* ignore */
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
writeFileAtomic(resolverCachePath(paths.graphPath), JSON.stringify(resolverCache.toData()));
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
/* ignore */
|
|
420
|
+
}
|
|
421
|
+
const extraPaths = (opts.paths ?? []).filter((p) => existsSync(resolve(scanRoot, p)) && /\.(csv|md|mdx|markdown|txt)$/i.test(p));
|
|
422
|
+
const enrichFragments = collectEnricherFragments(scanRoot, extraPaths, opts.includeMarkdown ?? true);
|
|
423
|
+
const builtGraph = buildGraphFromFragments(root, scanRoot, analyzeFragment, enrichFragments, now);
|
|
424
|
+
// Applied AI candidate flows must SURVIVE re-analysis, but the stored lane is
|
|
425
|
+
// untrusted (any process can rewrite graph.json) — preserve+re-validate it
|
|
426
|
+
// without ever letting a malformed lane fail analyze.
|
|
427
|
+
const graph = preserveCandidateFlows(paths.graphPath, builtGraph);
|
|
428
|
+
if (!opts.suppressProgress) {
|
|
429
|
+
reportProgress("analyze: writing graph.json", { current: opts.generateCoverage ? 4 : 3, total: opts.generateCoverage ? 4 : 3 });
|
|
430
|
+
}
|
|
431
|
+
saveGraph(paths.graphPath, graph);
|
|
432
|
+
const coverageWarnings = runtimeCoveragePrepare?.generated
|
|
433
|
+
.filter((g) => !g.ok)
|
|
434
|
+
.map((g) => `coverage generation failed for ${g.language} module ${g.module_dir}: ${g.reason ?? "unknown failure"}`) ?? [];
|
|
435
|
+
const coverageSuccess = runtimeCoveragePrepare?.generated
|
|
436
|
+
.filter((g) => g.ok && g.artifact_path)
|
|
437
|
+
.map((g) => `coverage artifact generated for ${g.language} module ${g.module_dir}: ${g.artifact_path}`) ?? [];
|
|
438
|
+
const warnings = [...coverageSuccess, ...coverageWarnings, ...(runtimeCoveragePrepare?.warnings ?? []), ...analyzeFragment.warnings, ...enrichFragments.flatMap((f) => f.warnings)];
|
|
439
|
+
return {
|
|
440
|
+
graph_path: paths.graphPath,
|
|
441
|
+
sources_count: graph.sources.length,
|
|
442
|
+
entities_count: graph.nodes.length,
|
|
443
|
+
relationships_count: graph.edges.length,
|
|
444
|
+
candidate_relationships_count: graph.candidate_edges.length,
|
|
445
|
+
behavior_anchors_count: behaviorNodes(graph).length,
|
|
446
|
+
analysis: graph.analysis ?? analyzeFragment.analysis,
|
|
447
|
+
...(runtimeCoveragePrepare ? { runtime_coverage_prepare: runtimeCoveragePrepare } : {}),
|
|
448
|
+
ai_linked: summarizeAiLinks(graph),
|
|
449
|
+
warnings
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
export function opRuntimeCoverage(root, opts = {}) {
|
|
453
|
+
const scanRoot = opts.source ? resolve(opts.source) : resolve(root);
|
|
454
|
+
if (!existsSync(scanRoot)) {
|
|
455
|
+
throw new Error(`Path not found: ${scanRoot}. Pass an existing directory to coverage (e.g. \`opro coverage .\`).`);
|
|
456
|
+
}
|
|
457
|
+
if (!statSync(scanRoot).isDirectory()) {
|
|
458
|
+
throw new Error(`Not a directory: ${scanRoot}. Pass a directory to coverage (e.g. \`opro coverage .\`).`);
|
|
459
|
+
}
|
|
460
|
+
return prepareRuntimeCoverage(scanRoot, { generate: opts.generate, timeoutMs: opts.timeoutMs });
|
|
461
|
+
}
|
|
462
|
+
/** Persist the default provider/model chosen via `opro setup`. Keys are never stored. */
|
|
463
|
+
export function opSetModelDefault(root, sel, deps = defaultDeps()) {
|
|
464
|
+
if (!workspaceInitialized(root))
|
|
465
|
+
initWorkspace(root, deps.clock());
|
|
466
|
+
const paths = workspacePaths(root);
|
|
467
|
+
saveConfig(paths, { ...loadConfig(paths), model_default: sel });
|
|
468
|
+
}
|
|
469
|
+
/** Read the saved default provider/model, if `opro setup` was run. */
|
|
470
|
+
export function getModelDefault(root) {
|
|
471
|
+
if (!workspaceInitialized(root))
|
|
472
|
+
return null;
|
|
473
|
+
return loadConfig(workspacePaths(root)).model_default ?? null;
|
|
474
|
+
}
|
|
475
|
+
function startProviderOverride(root, opts) {
|
|
476
|
+
if (opts.provider || opts.model)
|
|
477
|
+
return { provider: opts.provider, model: opts.model };
|
|
478
|
+
const saved = getModelDefault(root);
|
|
479
|
+
return saved ? { provider: saved.provider, model: saved.model } : {};
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Resolve the behaviors a diff touches, so `generate --base <ref>` targets only the
|
|
483
|
+
* changed code (PR-scoped generation) instead of the global top gap. Never throws or
|
|
484
|
+
* fabricates: returns structured guidance when there is no usable diff (not a git
|
|
485
|
+
* repo / missing base ref / no changes), or when the diff touches no tracked behavior.
|
|
486
|
+
*/
|
|
487
|
+
export function resolveDiffTargets(root, baseRef) {
|
|
488
|
+
const ch = opChanged(root, baseRef);
|
|
489
|
+
if (ch.status !== "ok") {
|
|
490
|
+
return { status: ch.status, base_ref: ch.base_ref, target_ids: [], guidance: ch.guidance };
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
status: "ok",
|
|
494
|
+
base_ref: ch.base_ref,
|
|
495
|
+
target_ids: ch.affected_behaviors,
|
|
496
|
+
guidance: ch.affected_behaviors.length
|
|
497
|
+
? undefined
|
|
498
|
+
: `The diff vs ${ch.base_ref} touched no tracked behaviors. Generate without --base, or add requirements/tests for the changed area.`
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* One-command PR support for `generate --pr <n>`: check out the PR via `gh` and
|
|
503
|
+
* resolve its base branch so the diff is scoped to the PR's changes. Side-effecting
|
|
504
|
+
* (switches the working tree via `gh pr checkout` + `git fetch`) and CLI-only.
|
|
505
|
+
*
|
|
506
|
+
* The mutation is gated: it refuses on a dirty working tree (`dirty_tree`) and
|
|
507
|
+
* does NOTHING unless `opts.confirmed` is true — without confirmation it returns
|
|
508
|
+
* `needs_confirmation` and performs no `gh`/`git` write. The non-mutating default
|
|
509
|
+
* is `--base <ref>` (read-only `git diff`); `--pr` is the opt-in escape hatch.
|
|
510
|
+
*
|
|
511
|
+
* Never throws; returns structured guidance for every refusal. Runners are
|
|
512
|
+
* injectable for tests.
|
|
513
|
+
*/
|
|
514
|
+
export function resolvePrCheckout(root, prNumber, opts = {}) {
|
|
515
|
+
const gh = opts.gh ?? ghRunner(root);
|
|
516
|
+
const git = opts.git ?? gitRunner(root);
|
|
517
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
518
|
+
return { status: "invalid_pr", pr: prNumber, guidance: "Pass a positive PR number, e.g. `opro generate --pr 123`." };
|
|
519
|
+
}
|
|
520
|
+
if (gh(["--version"]) === null) {
|
|
521
|
+
return {
|
|
522
|
+
status: "gh_missing",
|
|
523
|
+
pr: prNumber,
|
|
524
|
+
guidance: "GitHub CLI `gh` was not found. Install it (https://cli.github.com) and run `gh auth login`, or check out the PR yourself and pass --base <ref>."
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
// Refuse on a dirty working tree BEFORE any mutation: `gh pr checkout` would
|
|
528
|
+
// either fail or strand uncommitted work. A null porcelain result means git
|
|
529
|
+
// could not report status (not a repo / git error); the checkout below will
|
|
530
|
+
// surface that as checkout_failed rather than us guessing "dirty".
|
|
531
|
+
const porcelain = git(["status", "--porcelain"]);
|
|
532
|
+
if (porcelain !== null && porcelain.trim() !== "") {
|
|
533
|
+
return {
|
|
534
|
+
status: "dirty_tree",
|
|
535
|
+
pr: prNumber,
|
|
536
|
+
guidance: `Working tree has uncommitted changes — \`opro generate --pr ${prNumber}\` would run \`gh pr checkout\` and switch branches, ` +
|
|
537
|
+
"risking your work. Commit/stash first, or check out the PR yourself and use the non-mutating `--base <ref>`."
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// No mutation without explicit confirmation. Non-TTY / --json / MCP callers
|
|
541
|
+
// never auto-confirm, so they get this and stop; a TTY user confirms (or
|
|
542
|
+
// passes --yes/--force) before we touch the working tree.
|
|
543
|
+
if (!opts.confirmed) {
|
|
544
|
+
return {
|
|
545
|
+
status: "needs_confirmation",
|
|
546
|
+
pr: prNumber,
|
|
547
|
+
guidance: `\`--pr ${prNumber}\` mutates your working tree: it runs \`gh pr checkout ${prNumber}\` (switches branch) and \`git fetch\`. ` +
|
|
548
|
+
"Re-run with --yes (or --force) to confirm, or use the non-mutating default `--base <ref>` to diff without checking out."
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
if (gh(["pr", "checkout", String(prNumber)]) === null) {
|
|
552
|
+
return {
|
|
553
|
+
status: "checkout_failed",
|
|
554
|
+
pr: prNumber,
|
|
555
|
+
guidance: `Could not check out PR #${prNumber}. Confirm gh is authenticated (gh auth status), this is the correct GitHub repo, ` +
|
|
556
|
+
`the working tree is clean, and #${prNumber} is an open PR.`
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
// Resolve the PR's base branch and make it locally diffable.
|
|
560
|
+
const baseName = gh(["pr", "view", String(prNumber), "--json", "baseRefName", "-q", ".baseRefName"])?.trim();
|
|
561
|
+
let base_ref;
|
|
562
|
+
if (baseName) {
|
|
563
|
+
git(["fetch", "origin", baseName]); // best-effort; the base may already be local
|
|
564
|
+
const remoteRef = `origin/${baseName}`;
|
|
565
|
+
if (git(["rev-parse", "--verify", "--quiet", `${remoteRef}^{commit}`]) !== null)
|
|
566
|
+
base_ref = remoteRef;
|
|
567
|
+
else if (git(["rev-parse", "--verify", "--quiet", `${baseName}^{commit}`]) !== null)
|
|
568
|
+
base_ref = baseName;
|
|
569
|
+
else
|
|
570
|
+
base_ref = remoteRef; // resolveDiffContext will surface a missing-ref guidance if unresolved
|
|
571
|
+
}
|
|
572
|
+
return { status: "ok", pr: prNumber, base_ref };
|
|
573
|
+
}
|
|
574
|
+
export function opStatus(root, deps = defaultDeps()) {
|
|
575
|
+
const paths = workspacePaths(root);
|
|
576
|
+
const initialized = workspaceInitialized(root);
|
|
577
|
+
if (!initialized || !graphExists(root)) {
|
|
578
|
+
return {
|
|
579
|
+
workspace_initialized: initialized,
|
|
580
|
+
graph_path: paths.graphPath,
|
|
581
|
+
last_analyzed_at: null,
|
|
582
|
+
local_only: true,
|
|
583
|
+
sources: {},
|
|
584
|
+
quality_score: null,
|
|
585
|
+
can_generate_tests: false,
|
|
586
|
+
freshness: "missing",
|
|
587
|
+
changed_files: 0,
|
|
588
|
+
analysis: null,
|
|
589
|
+
privacy: { graph_storage: "local", upload_enabled: false, source_snippets_in_pack: false }
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const graph = loadGraph(paths.graphPath);
|
|
593
|
+
const current = scanFileEntries(graph.workspace.root);
|
|
594
|
+
const fresh = computeFreshness(graph, current);
|
|
595
|
+
const score = scoreGraph(graph);
|
|
596
|
+
const sources = {};
|
|
597
|
+
for (const s of graph.sources)
|
|
598
|
+
sources[s.source_system] = (sources[s.source_system] ?? 0) + 1;
|
|
599
|
+
return {
|
|
600
|
+
workspace_initialized: true,
|
|
601
|
+
graph_path: paths.graphPath,
|
|
602
|
+
last_analyzed_at: graph.updated_at,
|
|
603
|
+
local_only: true,
|
|
604
|
+
sources,
|
|
605
|
+
quality_score: score.overall,
|
|
606
|
+
can_generate_tests: score.overall >= 1 && graph.nodes.length > 0,
|
|
607
|
+
freshness: fresh.state,
|
|
608
|
+
changed_files: fresh.changed_files.length,
|
|
609
|
+
analysis: graph.analysis ?? null,
|
|
610
|
+
privacy: { graph_storage: "local", upload_enabled: false, source_snippets_in_pack: false }
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
export function opScore(root) {
|
|
614
|
+
return scoreGraph(loadGraph(workspacePaths(root).graphPath));
|
|
615
|
+
}
|
|
616
|
+
export function opDoctor(root) {
|
|
617
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
618
|
+
return doctorGraph(graph, scoreGraph(graph));
|
|
619
|
+
}
|
|
620
|
+
export function opGaps(root, opts = {}) {
|
|
621
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
622
|
+
const gaps = findGaps(graph, opts);
|
|
623
|
+
const topRiskGaps = rankRiskGaps(graph, { limit: opts.limit ?? 10, repoRoot: root }).map((gap) => ({
|
|
624
|
+
external_id: gap.id,
|
|
625
|
+
title: gap.title,
|
|
626
|
+
file: gap.file,
|
|
627
|
+
risk_score: gap.risk_score,
|
|
628
|
+
incoming_refs: gap.incoming_refs,
|
|
629
|
+
git_churn: gap.git_churn,
|
|
630
|
+
entry_point: gap.entry_point,
|
|
631
|
+
reasons: gap.reasons,
|
|
632
|
+
probability: gap.probability,
|
|
633
|
+
impact: gap.impact,
|
|
634
|
+
detection_difficulty: gap.detection_difficulty,
|
|
635
|
+
fan_out: gap.fan_out,
|
|
636
|
+
route_weight: gap.route_weight,
|
|
637
|
+
data_sensitivity: gap.data_sensitivity,
|
|
638
|
+
flow_position: gap.flow_position,
|
|
639
|
+
complexity_proxy: gap.complexity_proxy,
|
|
640
|
+
is_new_code: gap.is_new_code,
|
|
641
|
+
integration_signal: gap.integration_signal
|
|
642
|
+
}));
|
|
643
|
+
return {
|
|
644
|
+
...gaps,
|
|
645
|
+
top_risk_gaps: topRiskGaps,
|
|
646
|
+
risk_model: {
|
|
647
|
+
formula: "OrangePro Risk Score = Probability(1-10) × Impact(1-10) × DetectionDifficulty(1|5|10); P = normalize(git_churn*0.35 + fan_out*0.30 + new_code*15 + complexity*0.20); I = normalize(fan_in*0.30 + route_weight*0.30 + flow_position*0.20 + data_sensitivity*0.20); D = {proven:1, associated:5, none:10}",
|
|
648
|
+
note: "Risk ranking is prioritization only. It does not change Proven, Runtime-covered, Associated signal, No integration signal, or coverage percentages."
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
export function opRecordRun(root, opts, deps = defaultDeps()) {
|
|
653
|
+
const paths = workspacePaths(root);
|
|
654
|
+
const preGraph = loadGraph(paths.graphPath);
|
|
655
|
+
const targetInput = opts.target_symbol ?? opts.target_id;
|
|
656
|
+
const target = targetInput ? resolveTargetSymbol(preGraph, targetInput) : null;
|
|
657
|
+
if (!target) {
|
|
658
|
+
throw new Error("record requires --target-symbol sym:<file>#<Symbol>, or a target id that resolves to exactly one CodeSymbol.");
|
|
659
|
+
}
|
|
660
|
+
const language = targetLanguage(target);
|
|
661
|
+
const preEdges = proofEdgesFor(preGraph, target);
|
|
662
|
+
const sourceRoot = resolve(opts.source ?? root);
|
|
663
|
+
const testRel = opts.test_path ? toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.test_path)) : undefined;
|
|
664
|
+
let status = "unproven";
|
|
665
|
+
let closed = false;
|
|
666
|
+
let newEdges = [];
|
|
667
|
+
let reason = "";
|
|
668
|
+
let reproveMode;
|
|
669
|
+
let reproveReason;
|
|
670
|
+
if (!canReproveLanguage(language)) {
|
|
671
|
+
status = "generated_unverifiable";
|
|
672
|
+
reason = `OrangePro cannot dynamically verify ${language} targets yet; recorded separately from kept-rate.`;
|
|
673
|
+
}
|
|
674
|
+
else if (preEdges.length > 0) {
|
|
675
|
+
status = "unproven";
|
|
676
|
+
reason = "Target already had a static hard edge before this run; record_run is static diagnostics only and public Proven requires orangepro_prove.";
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
const scoped = tryScopedReprove({
|
|
680
|
+
root: opts.source ?? root,
|
|
681
|
+
graph: preGraph,
|
|
682
|
+
targetSymbol: target,
|
|
683
|
+
preEdges,
|
|
684
|
+
testPath: opts.test_path,
|
|
685
|
+
now: deps.clock()
|
|
686
|
+
});
|
|
687
|
+
let reproved;
|
|
688
|
+
if (scoped && scoped.newEdges.length > 0) {
|
|
689
|
+
saveGraph(paths.graphPath, scoped.graph);
|
|
690
|
+
reproved = { closed: true, newEdges: scoped.newEdges };
|
|
691
|
+
reproveMode = "scoped";
|
|
692
|
+
reproveReason = scoped.reason;
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
(deps.analyze ?? opAnalyze)(root, { source: opts.source ?? root }, deps);
|
|
696
|
+
const postGraph = loadGraph(paths.graphPath);
|
|
697
|
+
reproved = reproveTarget(preEdges, postGraph, target);
|
|
698
|
+
reproveMode = "full";
|
|
699
|
+
reproveReason = opts.test_path
|
|
700
|
+
? "Scoped confirmer did not produce a hard edge; full re-analysis decided the outcome."
|
|
701
|
+
: "No explicit test path was provided; full re-analysis decided the outcome.";
|
|
702
|
+
}
|
|
703
|
+
newEdges = testRel ? reproved.newEdges.filter((edge) => edge.startsWith(`test:${testRel}->`)) : reproved.newEdges;
|
|
704
|
+
const staticAssociated = newEdges.length > 0;
|
|
705
|
+
closed = false;
|
|
706
|
+
status = "unproven";
|
|
707
|
+
if (testRel && reproved.closed && newEdges.length === 0) {
|
|
708
|
+
reproveReason = `A hard COVERS edge for the target exists, but none came from the provided test (${testRel}); not credited to this attempt.`;
|
|
709
|
+
}
|
|
710
|
+
reason = staticAssociated
|
|
711
|
+
? reproveMode === "scoped"
|
|
712
|
+
? "Static COVERS edge found by scoped deterministic confirmation; public Proven still requires a dynamic targeted-proof certificate."
|
|
713
|
+
: "Static COVERS edge found after re-analysis; public Proven still requires a dynamic targeted-proof certificate."
|
|
714
|
+
: opts.vacuous
|
|
715
|
+
? "No new hard edge; attempt marked vacuous."
|
|
716
|
+
: "No new hard COVERS edge found after re-analysis.";
|
|
717
|
+
}
|
|
718
|
+
return appendLedgerRecord(root, {
|
|
719
|
+
run_id: opts.run_id,
|
|
720
|
+
target_id: opts.target_id,
|
|
721
|
+
target_symbol: target,
|
|
722
|
+
pre_edges: preEdges,
|
|
723
|
+
new_edges: newEdges,
|
|
724
|
+
closed,
|
|
725
|
+
vacuous: opts.vacuous === true,
|
|
726
|
+
agent_pass: opts.agent_pass,
|
|
727
|
+
evidence_ids: opts.evidence_ids ?? [],
|
|
728
|
+
provider: opts.provider,
|
|
729
|
+
model: opts.model,
|
|
730
|
+
prompt_version: opts.prompt_version,
|
|
731
|
+
language,
|
|
732
|
+
status,
|
|
733
|
+
reprove_mode: reproveMode,
|
|
734
|
+
reprove_reason: reproveReason,
|
|
735
|
+
reason,
|
|
736
|
+
ts: deps.clock()
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
export function opDynamicProof(root, opts, deps = defaultDeps()) {
|
|
740
|
+
const paths = workspacePaths(root);
|
|
741
|
+
const graph = loadGraph(paths.graphPath);
|
|
742
|
+
const targetInput = opts.target_symbol ?? opts.target_id;
|
|
743
|
+
const target = targetInput ? resolveTargetSymbol(graph, targetInput) : null;
|
|
744
|
+
if (!target) {
|
|
745
|
+
throw new Error("prove requires --target-symbol sym:<file>#<Symbol>, or a target id that resolves to exactly one CodeSymbol.");
|
|
746
|
+
}
|
|
747
|
+
const language = targetLanguage(target);
|
|
748
|
+
if (language !== "typescript") {
|
|
749
|
+
throw new Error(`prove currently supports JavaScript/TypeScript targets only; found ${language}.`);
|
|
750
|
+
}
|
|
751
|
+
if (!opts.test_path)
|
|
752
|
+
throw new Error("prove requires --test <path>.");
|
|
753
|
+
if (opts.replacement === undefined)
|
|
754
|
+
throw new Error("prove requires --replacement <sentinel>.");
|
|
755
|
+
const sourceRoot = resolve(opts.source ?? root);
|
|
756
|
+
assertProofSourceMatchesGraph(sourceRoot, graph);
|
|
757
|
+
const symbolTarget = symbolTargetParts(target);
|
|
758
|
+
const providedTargetRel = opts.target_path ? toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.target_path)) : undefined;
|
|
759
|
+
assertProofTargetMatchesSymbol({ ...opts, target_path: providedTargetRel }, symbolTarget);
|
|
760
|
+
const testRel = toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.test_path));
|
|
761
|
+
const targetRel = symbolTarget.file;
|
|
762
|
+
const method = symbolTarget.method;
|
|
763
|
+
assertTargetFileFresh(sourceRoot, targetRel, graph);
|
|
764
|
+
const replacementMode = opts.replacement_mode ?? "return-json";
|
|
765
|
+
const runner = opts.runner ?? "auto";
|
|
766
|
+
if (replacementMode !== "return-json" && replacementMode !== "promise-json") {
|
|
767
|
+
throw new Error("prove --replacement-mode must be one of: return-json, promise-json.");
|
|
768
|
+
}
|
|
769
|
+
if (runner !== "auto" && runner !== "vitest" && runner !== "jest" && runner !== "mocha") {
|
|
770
|
+
throw new Error("prove --runner must be one of: auto, vitest, jest, mocha.");
|
|
771
|
+
}
|
|
772
|
+
const args = [
|
|
773
|
+
"--root",
|
|
774
|
+
sourceRoot,
|
|
775
|
+
"--test",
|
|
776
|
+
testRel,
|
|
777
|
+
"--target",
|
|
778
|
+
targetRel,
|
|
779
|
+
"--method",
|
|
780
|
+
method,
|
|
781
|
+
"--replacement",
|
|
782
|
+
opts.replacement,
|
|
783
|
+
"--replacement-mode",
|
|
784
|
+
replacementMode,
|
|
785
|
+
"--runner",
|
|
786
|
+
runner,
|
|
787
|
+
"--json"
|
|
788
|
+
];
|
|
789
|
+
if (opts.timeout_ms !== undefined)
|
|
790
|
+
args.push("--timeout-ms", String(opts.timeout_ms));
|
|
791
|
+
if (opts.link_node_modules)
|
|
792
|
+
args.push("--link-node-modules");
|
|
793
|
+
if (opts.vitest_config)
|
|
794
|
+
args.push("--vitest-config", toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.vitest_config)));
|
|
795
|
+
if (opts.jest_config)
|
|
796
|
+
args.push("--jest-config", toWorkspaceRel(sourceRoot, resolveContained(sourceRoot, opts.jest_config)));
|
|
797
|
+
for (const entry of opts.test_env ?? [])
|
|
798
|
+
args.push("--test-env", entry);
|
|
799
|
+
const run = (deps.dynamicProofRunner ?? defaultDynamicProofRunner)(args, { cwd: sourceRoot });
|
|
800
|
+
const oracle = parseDynamicProofJson(run.stdout, run.stderr);
|
|
801
|
+
const closed = dynamicProofSucceeded(oracle);
|
|
802
|
+
const baselineGreen = oracle.baseline?.exitCode === 0 && oracle.baseline?.timedOut !== true;
|
|
803
|
+
const mutantFailedAssertion = oracle.mutant?.assertionFailure === true && oracle.mutant?.timedOut !== true;
|
|
804
|
+
const dynamicProof = {
|
|
805
|
+
proof_kind: "dynamic_targeted",
|
|
806
|
+
baseline_green: baselineGreen,
|
|
807
|
+
mutant_failed_assertion: mutantFailedAssertion,
|
|
808
|
+
// Derived from the targeted mutation kill: if the credited subject is mocked/replaced,
|
|
809
|
+
// mutating its real body cannot cause the same test to fail at an assertion.
|
|
810
|
+
target_not_mocked: closed,
|
|
811
|
+
sentinel: oracle.replacementMode ?? replacementMode,
|
|
812
|
+
runner: oracle.runner ?? runner,
|
|
813
|
+
test_path: oracle.test ?? testRel,
|
|
814
|
+
mutant_status: oracle.status
|
|
815
|
+
};
|
|
816
|
+
const result = appendLedgerRecord(root, {
|
|
817
|
+
run_id: opts.run_id,
|
|
818
|
+
target_id: opts.target_id,
|
|
819
|
+
target_symbol: target,
|
|
820
|
+
pre_edges: proofEdgesFor(graph, target),
|
|
821
|
+
new_edges: [],
|
|
822
|
+
closed,
|
|
823
|
+
evidence_ids: [],
|
|
824
|
+
language,
|
|
825
|
+
dynamic_proof: dynamicProof,
|
|
826
|
+
target_fingerprint: targetFingerprint(graph, target),
|
|
827
|
+
status: closed ? "reproven" : "unproven",
|
|
828
|
+
reason: oracle.reason ?? (closed ? "Dynamic targeted proof closed." : "Dynamic targeted proof did not close."),
|
|
829
|
+
ts: deps.clock()
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
...result,
|
|
833
|
+
oracle: {
|
|
834
|
+
status: oracle.status,
|
|
835
|
+
proven: oracle.proven,
|
|
836
|
+
reason: oracle.reason,
|
|
837
|
+
runner: oracle.runner,
|
|
838
|
+
replacementMode: oracle.replacementMode,
|
|
839
|
+
test: oracle.test,
|
|
840
|
+
target: oracle.target,
|
|
841
|
+
method: oracle.method,
|
|
842
|
+
baseline: oracle.baseline
|
|
843
|
+
? {
|
|
844
|
+
exitCode: oracle.baseline.exitCode,
|
|
845
|
+
timedOut: oracle.baseline.timedOut,
|
|
846
|
+
// Surface the oracle's already-redacted, single-line baseline failure summary so
|
|
847
|
+
// autoProve can classify WHY a baseline was red (R-1). Transient return-only field:
|
|
848
|
+
// it is NOT written to the ledger cert (the record carries only the dynamic_proof
|
|
849
|
+
// certificate), so raw stderr never lands in the ledger or the report.
|
|
850
|
+
failureSummary: oracle.baseline.failureSummary ?? null
|
|
851
|
+
}
|
|
852
|
+
: undefined,
|
|
853
|
+
mutant: oracle.mutant
|
|
854
|
+
? { exitCode: oracle.mutant.exitCode, timedOut: oracle.mutant.timedOut, assertionFailure: oracle.mutant.assertionFailure }
|
|
855
|
+
: undefined,
|
|
856
|
+
medianProofMs: oracle.medianProofMs
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
const SETUP_DEFAULT_TIMEOUT_MS = 30_000;
|
|
861
|
+
/** Validate a prove-loop setup command shape (ported from the spike cost-runner). */
|
|
862
|
+
function validateSetupCommand(command, index) {
|
|
863
|
+
if (!command || typeof command !== "object") {
|
|
864
|
+
throw new Error(`prove-loop setup command ${index} must be an object`);
|
|
865
|
+
}
|
|
866
|
+
if (typeof command.command !== "string" || command.command.trim() === "") {
|
|
867
|
+
throw new Error(`prove-loop setup command ${index} missing command`);
|
|
868
|
+
}
|
|
869
|
+
if (command.args !== undefined && (!Array.isArray(command.args) || command.args.some((a) => typeof a !== "string"))) {
|
|
870
|
+
throw new Error(`prove-loop setup command ${index} has invalid args`);
|
|
871
|
+
}
|
|
872
|
+
if (command.timeout_ms !== undefined && (!Number.isInteger(command.timeout_ms) || command.timeout_ms <= 0)) {
|
|
873
|
+
throw new Error(`prove-loop setup command ${index} has invalid timeout_ms (must be a positive integer)`);
|
|
874
|
+
}
|
|
875
|
+
return { command: command.command, args: command.args ?? [], timeout_ms: command.timeout_ms };
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Run setup_commands in the source checkout before the oracle makes its isolated
|
|
879
|
+
* baseline/mutant copies. Trusted-local prep only (same posture as test_env). The
|
|
880
|
+
* first non-zero exit or timeout stops the run; the reason redacts secret-looking output.
|
|
881
|
+
*/
|
|
882
|
+
function runProveLoopSetup(sourceRoot, commands, defaultTimeoutMs) {
|
|
883
|
+
for (const command of commands) {
|
|
884
|
+
const timeoutMs = command.timeout_ms ?? defaultTimeoutMs ?? SETUP_DEFAULT_TIMEOUT_MS;
|
|
885
|
+
const result = spawnSync(command.command, command.args, {
|
|
886
|
+
cwd: sourceRoot,
|
|
887
|
+
encoding: "utf8",
|
|
888
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
889
|
+
timeout: timeoutMs,
|
|
890
|
+
// A successful `npm ci` / `npm run build` (the main use case) easily exceeds the
|
|
891
|
+
// 1 MiB default, which would SIGTERM the child and be misread as a failure.
|
|
892
|
+
maxBuffer: 64 * 1024 * 1024
|
|
893
|
+
});
|
|
894
|
+
const label = redactSecrets([command.command, ...command.args].join(" "));
|
|
895
|
+
if (result.error) {
|
|
896
|
+
// Spawn-level failure (status is null): ENOENT (bad command), ENOBUFS (output
|
|
897
|
+
// over maxBuffer), ETIMEDOUT, etc. Surface the real code so it is not fabricated
|
|
898
|
+
// as "(exit 1)".
|
|
899
|
+
const err = result.error;
|
|
900
|
+
const code = err.code ?? "spawn error";
|
|
901
|
+
const detail = redactSecrets(err.message ?? "").split("\n", 1)[0] ?? "";
|
|
902
|
+
return { ok: false, reason: `setup command failed: ${label} (${code}${detail ? `: ${detail}` : ""})` };
|
|
903
|
+
}
|
|
904
|
+
const exitCode = result.status ?? 1;
|
|
905
|
+
if (exitCode !== 0) {
|
|
906
|
+
const detail = redactSecrets((result.stderr || result.stdout || "").trim().split("\n", 1)[0] ?? "");
|
|
907
|
+
return { ok: false, reason: `setup command failed: ${label} (exit ${exitCode})${detail ? `: ${detail}` : ""}` };
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return { ok: true };
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Product wrapper around opDynamicProof: run trusted-local setup in the source
|
|
914
|
+
* checkout, then call the UNCHANGED oracle, then best-effort refresh the report.
|
|
915
|
+
* Setup lives entirely here so the proof primitive stays a small single-shot.
|
|
916
|
+
* A setup failure returns `unrunnable` WITHOUT touching the ledger (setup-didn't-run
|
|
917
|
+
* is an environmental non-event); the oracle is not called and no cert is minted.
|
|
918
|
+
*/
|
|
919
|
+
export function opProveLoop(root, opts, deps = defaultDeps()) {
|
|
920
|
+
// Resolve the credited symbol up front and apply the SAME TS-only guard opDynamicProof
|
|
921
|
+
// enforces — both BEFORE running setup — so a bad/non-TS target fails fast like `opro
|
|
922
|
+
// prove` and never runs setup or mints a divergent record the primitive would reject.
|
|
923
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
924
|
+
const targetInput = opts.target_symbol ?? opts.target_id;
|
|
925
|
+
const target = targetInput ? resolveTargetSymbol(graph, targetInput) : null;
|
|
926
|
+
if (!target) {
|
|
927
|
+
throw new Error("prove requires --target-symbol sym:<file>#<Symbol>, or a target id that resolves to exactly one CodeSymbol.");
|
|
928
|
+
}
|
|
929
|
+
const language = targetLanguage(target);
|
|
930
|
+
if (language !== "typescript") {
|
|
931
|
+
throw new Error(`prove currently supports JavaScript/TypeScript targets only; found ${language}.`);
|
|
932
|
+
}
|
|
933
|
+
const setupCommands = (opts.setup_commands ?? []).map((command, index) => validateSetupCommand(command, index));
|
|
934
|
+
const sourceRoot = resolve(opts.source ?? root);
|
|
935
|
+
const setup = runProveLoopSetup(sourceRoot, setupCommands, opts.setup_timeout_ms);
|
|
936
|
+
if (!setup.ok) {
|
|
937
|
+
// Setup did not run: an environmental non-event, not a proof attempt of record.
|
|
938
|
+
// Append NOTHING — a setup flake (e.g. `npm ci` network blip) must never write a
|
|
939
|
+
// newer ledger record that, via RTM latest-wins, clobbers a prior closed cert for
|
|
940
|
+
// this symbol. The oracle is NOT called.
|
|
941
|
+
return { status: "unrunnable", reason: setup.reason };
|
|
942
|
+
}
|
|
943
|
+
// Setup passed (or none) → call opDynamicProof VERBATIM. It copies the now-prepared
|
|
944
|
+
// source, derives the mutation target from the credited symbol, mints the cert.
|
|
945
|
+
const proof = opDynamicProof(root, {
|
|
946
|
+
target_symbol: opts.target_symbol,
|
|
947
|
+
target_id: opts.target_id,
|
|
948
|
+
source: opts.source,
|
|
949
|
+
test_path: opts.test_path,
|
|
950
|
+
target_path: opts.target_path,
|
|
951
|
+
method: opts.method,
|
|
952
|
+
replacement: opts.replacement,
|
|
953
|
+
replacement_mode: opts.replacement_mode,
|
|
954
|
+
runner: opts.runner,
|
|
955
|
+
timeout_ms: opts.timeout_ms,
|
|
956
|
+
link_node_modules: opts.link_node_modules,
|
|
957
|
+
vitest_config: opts.vitest_config,
|
|
958
|
+
jest_config: opts.jest_config,
|
|
959
|
+
test_env: opts.test_env,
|
|
960
|
+
run_id: opts.run_id
|
|
961
|
+
}, deps);
|
|
962
|
+
// Best-effort report refresh (exactly like ai-flows --apply): a render failure
|
|
963
|
+
// must never fail a completed proof — the ledger/cert is already written.
|
|
964
|
+
let behaviorCoveragePath;
|
|
965
|
+
try {
|
|
966
|
+
behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
|
|
967
|
+
}
|
|
968
|
+
catch {
|
|
969
|
+
// ponytail: swallow — refresh is advisory; DynamicProofResult carries no warnings channel.
|
|
970
|
+
}
|
|
971
|
+
return { ...proof, ...(behaviorCoveragePath ? { behavior_coverage_path: behaviorCoveragePath } : {}) };
|
|
972
|
+
}
|
|
973
|
+
export function opStats(root) {
|
|
974
|
+
return ledgerStats(root);
|
|
975
|
+
}
|
|
976
|
+
export function opRtm(root, opts = {}) {
|
|
977
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
978
|
+
let targetIds;
|
|
979
|
+
let changedFiles;
|
|
980
|
+
let scope;
|
|
981
|
+
if (opts.baseRef) {
|
|
982
|
+
const diff = opChanged(root, opts.baseRef);
|
|
983
|
+
targetIds = diff.affected_behaviors;
|
|
984
|
+
changedFiles = diff.status === "ok" ? diff.changed_files : [];
|
|
985
|
+
scope = {
|
|
986
|
+
base_ref: diff.base_ref,
|
|
987
|
+
status: diff.status,
|
|
988
|
+
...(diff.guidance ? { guidance: diff.guidance } : {}),
|
|
989
|
+
target_ids: diff.affected_behaviors,
|
|
990
|
+
changed_files: changedFiles
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
const format = opts.format ?? "md";
|
|
994
|
+
const result = buildRtm(graph, loadLedger(root), { targetIds, changedFiles, statuses: opts.statuses, limit: opts.limit, scope });
|
|
995
|
+
const ext = format === "json" ? "json" : format === "csv" ? "csv" : "md";
|
|
996
|
+
const absRoot = resolve(root);
|
|
997
|
+
// --out resolves like the sibling commands (export/graph-html/coverage-report):
|
|
998
|
+
// relative to the workspace root, absolute paths honored as-is, no confinement.
|
|
999
|
+
const rtmPath = opts.outputPath ? resolve(absRoot, opts.outputPath) : join(workspacePaths(root).dir, `rtm.${ext}`);
|
|
1000
|
+
mkdirSync(dirname(rtmPath), { recursive: true });
|
|
1001
|
+
const content = format === "json" ? JSON.stringify(result, null, 2) + "\n" : format === "csv" ? renderRtmCsv(result) : renderRtmMarkdown(result);
|
|
1002
|
+
writeFileAtomic(rtmPath, content);
|
|
1003
|
+
return { ...result, rtm_path: rtmPath, format };
|
|
1004
|
+
}
|
|
1005
|
+
export async function opAiLinks(root, opts = {}, deps = defaultDeps()) {
|
|
1006
|
+
const paths = workspacePaths(root);
|
|
1007
|
+
const graph = loadGraph(paths.graphPath);
|
|
1008
|
+
if (opts.apply) {
|
|
1009
|
+
const { result, graph: next } = applyAiLinks(root, graph);
|
|
1010
|
+
saveGraph(paths.graphPath, { ...next, updated_at: deps.clock() });
|
|
1011
|
+
return result;
|
|
1012
|
+
}
|
|
1013
|
+
const providerEnv = loadProviderEnv([root], deps.env);
|
|
1014
|
+
const cfg = opts.provider === "deterministic" ? null : resolveProviderConfig(providerEnv, opts);
|
|
1015
|
+
const provider = deps.aiProvider ?? (cfg ? buildProvider(cfg) : null);
|
|
1016
|
+
if (!provider) {
|
|
1017
|
+
throw new Error("No model provider configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OLLAMA_BASE_URL before running ai-links.");
|
|
1018
|
+
}
|
|
1019
|
+
return generateAiLinks(root, graph, provider, {
|
|
1020
|
+
all: opts.all,
|
|
1021
|
+
symbolsPerBehavior: opts.symbolsPerBehavior ?? positiveIntEnv(providerEnv.ORANGEPRO_AI_LINK_SYMBOLS_PER_BEHAVIOR),
|
|
1022
|
+
maxPromptTokens: opts.maxPromptTokens ?? positiveIntEnv(providerEnv.ORANGEPRO_AI_LINK_MAX_PROMPT_TOKENS),
|
|
1023
|
+
maxBehaviors: opts.maxBehaviors ?? positiveIntEnv(providerEnv.ORANGEPRO_AI_LINK_MAX_BEHAVIORS),
|
|
1024
|
+
progressRange: opts.progressRange
|
|
1025
|
+
}, deps.clock);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* LLM candidate flow discovery (Slice 4). Mirrors opAiLinks: BYOK provider,
|
|
1029
|
+
* two-phase generate (stage artifact) / apply (store analysis.candidate_flows).
|
|
1030
|
+
* Explicit opt-in only — NEVER auto-run by opStart. Candidate flows are a
|
|
1031
|
+
* "verify these" worklist, never evidence.
|
|
1032
|
+
*/
|
|
1033
|
+
export async function opAiFlows(root, opts = {}, deps = defaultDeps()) {
|
|
1034
|
+
const paths = workspacePaths(root);
|
|
1035
|
+
const graph = loadGraph(paths.graphPath);
|
|
1036
|
+
if (opts.apply) {
|
|
1037
|
+
const { result, graph: next } = applyAiFlows(root, graph);
|
|
1038
|
+
saveGraph(paths.graphPath, { ...next, updated_at: deps.clock() });
|
|
1039
|
+
// Re-render the behavior report so applied candidate flows are visible.
|
|
1040
|
+
// Best-effort: a render failure must never fail apply (graph is saved).
|
|
1041
|
+
const warnings = [...result.warnings];
|
|
1042
|
+
let behaviorCoveragePath;
|
|
1043
|
+
try {
|
|
1044
|
+
behaviorCoveragePath = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`).behavior_coverage_path;
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
warnings.push(`behavior coverage view not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1048
|
+
}
|
|
1049
|
+
return { ...result, warnings, ...(behaviorCoveragePath ? { behavior_coverage_path: behaviorCoveragePath } : {}) };
|
|
1050
|
+
}
|
|
1051
|
+
const providerEnv = loadProviderEnv([root], deps.env);
|
|
1052
|
+
const cfg = opts.provider === "deterministic" ? null : resolveProviderConfig(providerEnv, opts);
|
|
1053
|
+
const provider = deps.aiProvider ?? (cfg ? buildProvider(cfg) : null);
|
|
1054
|
+
if (!provider) {
|
|
1055
|
+
throw new Error("No model provider configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OLLAMA_BASE_URL before running ai-flows.");
|
|
1056
|
+
}
|
|
1057
|
+
return generateAiFlows(root, graph, provider, deps.clock);
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Dependency-manager lockfiles / installed-dep dirs — a diff of ONLY these maps to no behaviors.
|
|
1061
|
+
* Language-agnostic (monorepos exist in every ecosystem), not JS-only: JS/TS, Rust, Go, Python,
|
|
1062
|
+
* Ruby, PHP, Gradle.
|
|
1063
|
+
*/
|
|
1064
|
+
const INSTALL_ARTIFACT_BASENAMES = new Set([
|
|
1065
|
+
"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", // JS/TS
|
|
1066
|
+
"cargo.lock", // Rust
|
|
1067
|
+
"go.sum", // Go
|
|
1068
|
+
"poetry.lock", "pipfile.lock", // Python
|
|
1069
|
+
"gemfile.lock", // Ruby
|
|
1070
|
+
"composer.lock", // PHP
|
|
1071
|
+
"gradle.lockfile" // Gradle
|
|
1072
|
+
]);
|
|
1073
|
+
export function isInstallArtifact(rel) {
|
|
1074
|
+
const p = rel.split(/[\\/]+/).join("/");
|
|
1075
|
+
// installed-dep dirs
|
|
1076
|
+
if (/(?:^|\/)(?:node_modules|vendor\/bundle|\.venv)(?:\/|$)/.test(p))
|
|
1077
|
+
return true;
|
|
1078
|
+
return INSTALL_ARTIFACT_BASENAMES.has((p.split("/").pop() ?? "").toLowerCase());
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Auto-prove scope for `opStart`. Explicit `--base` (PR/diff mode) stays scoped to the diff verbatim.
|
|
1082
|
+
* DEFAULT `opro start` must NOT let an install-artifact-only diff (e.g. a `package-lock.json` bump)
|
|
1083
|
+
* scope auto-prove to zero behaviors and skip the keyless existing-tests-first lane: ignore install
|
|
1084
|
+
* artifacts, and scope to the diff ONLY when it maps to >=1 eligible provable CodeSymbol target —
|
|
1085
|
+
* otherwise return undefined so autoProve runs the GLOBAL top-5 existing-tests-first pass.
|
|
1086
|
+
*/
|
|
1087
|
+
export function autoProveChangedScope(graph, changed, baseRef) {
|
|
1088
|
+
if (changed.status !== "ok")
|
|
1089
|
+
return undefined;
|
|
1090
|
+
if (baseRef)
|
|
1091
|
+
return changed.changed_files; // explicit PR/diff mode: unchanged, stay scoped
|
|
1092
|
+
const meaningful = changed.changed_files.filter((f) => !isInstallArtifact(f));
|
|
1093
|
+
if (meaningful.length === 0)
|
|
1094
|
+
return undefined; // only install artifacts → global top-5
|
|
1095
|
+
const changedSet = new Set(meaningful);
|
|
1096
|
+
const hasEligibleTarget = graph.nodes.some((n) => {
|
|
1097
|
+
if (n.kind !== "CodeSymbol" || !isEligibleProvableTarget(n))
|
|
1098
|
+
return false;
|
|
1099
|
+
const ref = n.provenance?.source_ref;
|
|
1100
|
+
const file = typeof n.properties.file === "string" ? n.properties.file : undefined;
|
|
1101
|
+
return (ref != null && changedSet.has(ref)) || changedSet.has(n.external_id) || (file != null && changedSet.has(file));
|
|
1102
|
+
});
|
|
1103
|
+
return hasEligibleTarget ? meaningful : undefined; // no eligible provable target in scope → global top-5
|
|
1104
|
+
}
|
|
1105
|
+
export async function opStart(root, opts = {}, deps = defaultDeps()) {
|
|
1106
|
+
const providerOpts = startProviderOverride(root, opts);
|
|
1107
|
+
const scanRoot = opts.source ? resolve(opts.source) : resolve(root);
|
|
1108
|
+
const providerEnv = loadProviderEnv([root, scanRoot], deps.env);
|
|
1109
|
+
const providerDeps = { ...deps, env: providerEnv };
|
|
1110
|
+
const scope = summarizeCorpusScope(scanRoot);
|
|
1111
|
+
reportProgress(`start: preflight found ${scope.files.toLocaleString()} source/doc file(s)`, { current: 1, total: 8 });
|
|
1112
|
+
reportProgress("start: running deterministic analysis", { current: 2, total: 8 });
|
|
1113
|
+
const analyze = (deps.analyze ?? opAnalyze)(root, {
|
|
1114
|
+
source: opts.source ?? root,
|
|
1115
|
+
includeMarkdown: opts.includeMarkdown,
|
|
1116
|
+
generateCoverage: opts.generateCoverage,
|
|
1117
|
+
coverageTimeoutMs: opts.coverageTimeoutMs,
|
|
1118
|
+
suppressProgress: true
|
|
1119
|
+
}, deps);
|
|
1120
|
+
const warnings = [...analyze.warnings];
|
|
1121
|
+
reportProgress("start: deterministic graph is ready", { current: 4, total: 8 });
|
|
1122
|
+
const providerConfigured = deps.aiProvider !== undefined || resolveProviderConfig(providerEnv, providerOpts) !== null;
|
|
1123
|
+
let aiLinks = { status: "skipped", reason: "AI candidate links disabled for this run." };
|
|
1124
|
+
if (opts.ai !== false) {
|
|
1125
|
+
if (!providerConfigured) {
|
|
1126
|
+
reportProgress("ai: skipped — no local provider key/base URL found", { current: 5, total: 8 });
|
|
1127
|
+
aiLinks = {
|
|
1128
|
+
status: "skipped",
|
|
1129
|
+
reason: "No model provider configured; set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OLLAMA_BASE_URL to auto-apply weak AI grounding."
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
try {
|
|
1134
|
+
reportProgress("ai: generating weak candidate links from the coverage-aware graph", { current: 5, total: 8 });
|
|
1135
|
+
const generated = await opAiLinks(root, { ...providerOpts, all: opts.aiAll, progressRange: { start: 63, end: 75 } }, providerDeps);
|
|
1136
|
+
reportProgress("ai: applying weak candidate links", { current: 6, total: 8 });
|
|
1137
|
+
const applied = await opAiLinks(root, { apply: true }, providerDeps);
|
|
1138
|
+
aiLinks = { status: "applied", generate: generated, apply: applied };
|
|
1139
|
+
warnings.push(...generated.warnings, ...applied.warnings);
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1143
|
+
aiLinks = { status: "failed", reason };
|
|
1144
|
+
warnings.push(`AI candidate links skipped: ${reason}`);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
let aiFlows = { status: "skipped", reason: "AI candidate flows disabled for this run." };
|
|
1149
|
+
if (opts.ai !== false && opts.aiFlows !== false) {
|
|
1150
|
+
if (!providerConfigured) {
|
|
1151
|
+
aiFlows = {
|
|
1152
|
+
status: "skipped",
|
|
1153
|
+
reason: "No model provider configured; set OPENAI_API_KEY, ANTHROPIC_API_KEY, or OLLAMA_BASE_URL to auto-apply AI candidate flows."
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
try {
|
|
1158
|
+
reportProgress("ai-flows: generating candidate behavior-flow worklist", { current: 6, total: 8 });
|
|
1159
|
+
const generated = await opAiFlows(root, providerOpts, providerDeps);
|
|
1160
|
+
reportProgress("ai-flows: applying candidate behavior-flow worklist", { current: 6, total: 8 });
|
|
1161
|
+
const applied = await opAiFlows(root, { apply: true }, providerDeps);
|
|
1162
|
+
aiFlows = { status: "applied", generate: generated, apply: applied };
|
|
1163
|
+
warnings.push(...generated.warnings, ...applied.warnings);
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1167
|
+
aiFlows = { status: "failed", reason };
|
|
1168
|
+
warnings.push(`AI candidate flows skipped: ${reason}`);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
// PR scope is computed once here so auto-prove can scope to the diff and the later
|
|
1173
|
+
// summary can reuse it.
|
|
1174
|
+
const changed = opChanged(root, opts.baseRef);
|
|
1175
|
+
// Auto-prove (PR 1): key-gated generate→prove for the top provable TS/JS targets.
|
|
1176
|
+
// Runs BEFORE the report/RTM writes below so they reflect any freshly-minted Proven.
|
|
1177
|
+
// Proof is minted only by opProveLoop's UNCHANGED oracle; no key ⇒ no files, no proof.
|
|
1178
|
+
reportProgress("auto-prove: driving generate → prove on the top provable targets", { current: 6, total: 8 });
|
|
1179
|
+
let autoProveResult;
|
|
1180
|
+
try {
|
|
1181
|
+
autoProveResult = await autoProve(root, {
|
|
1182
|
+
autoLimit: opts.autoLimit,
|
|
1183
|
+
noAuto: opts.noAuto,
|
|
1184
|
+
provider: providerOpts.provider,
|
|
1185
|
+
model: providerOpts.model,
|
|
1186
|
+
changedFiles: autoProveChangedScope(loadGraph(workspacePaths(root).graphPath), changed, opts.baseRef)
|
|
1187
|
+
}, { ...providerDeps, proveLoop: opProveLoop });
|
|
1188
|
+
for (const skip of autoProveResult.skipped)
|
|
1189
|
+
warnings.push(`auto-prove skipped ${skip.target_symbol ?? skip.title}: ${skip.reason}`);
|
|
1190
|
+
}
|
|
1191
|
+
catch (error) {
|
|
1192
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1193
|
+
autoProveResult = {
|
|
1194
|
+
ran: false,
|
|
1195
|
+
status: "no-targets",
|
|
1196
|
+
reason: `auto-prove failed but deterministic artifacts are ready: ${reason}`,
|
|
1197
|
+
attempted: 0,
|
|
1198
|
+
proven: 0,
|
|
1199
|
+
needs_setup: [],
|
|
1200
|
+
skipped: [],
|
|
1201
|
+
generated_files: [],
|
|
1202
|
+
attempts: []
|
|
1203
|
+
};
|
|
1204
|
+
warnings.push(`auto-prove skipped: ${reason}`);
|
|
1205
|
+
}
|
|
1206
|
+
let coverageReport;
|
|
1207
|
+
try {
|
|
1208
|
+
reportProgress("artifacts: writing coverage report", { current: 6, total: 8 });
|
|
1209
|
+
coverageReport = opCoverageReport(root, `${WORKSPACE_DIR}/COVERAGE_REPORT.md`).coverage_report_path;
|
|
1210
|
+
}
|
|
1211
|
+
catch (error) {
|
|
1212
|
+
warnings.push(`coverage report not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1213
|
+
}
|
|
1214
|
+
let coverageHtml;
|
|
1215
|
+
try {
|
|
1216
|
+
reportProgress("artifacts: writing behavior coverage view", { current: 7, total: 8 });
|
|
1217
|
+
// Forward THIS-RUN dynamic-proof outcome so the report can name the dominant setup/runnability
|
|
1218
|
+
// block reason when 0 behaviors closed (metadata only; mints nothing, changes no tier count).
|
|
1219
|
+
const dynForReport = {
|
|
1220
|
+
attempted: autoProveResult.attempted,
|
|
1221
|
+
proven: autoProveResult.proven,
|
|
1222
|
+
needsSetup: autoProveResult.needs_setup.map((a) => ({ category: a.category, reason: a.reason }))
|
|
1223
|
+
};
|
|
1224
|
+
coverageHtml = opBehaviorCoverageHtml(root, `${WORKSPACE_DIR}/behavior-coverage.html`, dynForReport).behavior_coverage_path;
|
|
1225
|
+
}
|
|
1226
|
+
catch (error) {
|
|
1227
|
+
warnings.push(`behavior coverage view not written: ${error instanceof Error ? error.message : String(error)}`);
|
|
1228
|
+
}
|
|
1229
|
+
reportProgress("artifacts: writing RTM", { current: 8, total: 8 });
|
|
1230
|
+
const rtm = opRtm(root, { format: "md", baseRef: opts.baseRef, limit: START_RTM_LIMIT });
|
|
1231
|
+
const finalGraph = loadGraph(workspacePaths(root).graphPath);
|
|
1232
|
+
const aiLinked = summarizeAiLinks(finalGraph);
|
|
1233
|
+
const finalAnalyze = {
|
|
1234
|
+
...analyze,
|
|
1235
|
+
analysis: finalGraph.analysis ?? analyze.analysis,
|
|
1236
|
+
candidate_relationships_count: finalGraph.candidate_edges.length,
|
|
1237
|
+
ai_linked: aiLinked
|
|
1238
|
+
};
|
|
1239
|
+
reportProgress("artifacts: selecting gap targets", { current: 8, total: 8 });
|
|
1240
|
+
const gaps = opGaps(root, { limit: 10 });
|
|
1241
|
+
const nextActions = [];
|
|
1242
|
+
if (coverageHtml)
|
|
1243
|
+
nextActions.push(`Open ${coverageHtml} for the behavior report (codebase, behaviors, composed flows, priority risks).`);
|
|
1244
|
+
// Auto-prove summary: "auto-proved M of N attempted" + needs_setup/skipped reasons + files.
|
|
1245
|
+
if (autoProveResult.status === "skipped-no-key") {
|
|
1246
|
+
nextActions.push(autoProveResult.reason ?? NO_KEY_MESSAGE);
|
|
1247
|
+
}
|
|
1248
|
+
else if (autoProveResult.status === "disabled") {
|
|
1249
|
+
nextActions.push("Dynamic proof was not attempted (--no-auto); run without it to auto-mint Dynamically Proven from generated tests. Static mapping and Associated signals are unaffected.");
|
|
1250
|
+
}
|
|
1251
|
+
else if (autoProveResult.ran) {
|
|
1252
|
+
nextActions.push(`Dynamic proof: attempted the top ${autoProveResult.attempted} target(s); ${autoProveResult.proven} dynamically proven. Static breadth (behaviors, flows, Associated signals) is mapped regardless.`);
|
|
1253
|
+
if (autoProveResult.proven === 0) {
|
|
1254
|
+
const dom = dominantBlockReason(autoProveResult.needs_setup);
|
|
1255
|
+
if (dom)
|
|
1256
|
+
nextActions.push(`Dynamic proof closed 0: blocked because ${dom.label} (${dom.count}/${dom.total}). This is a sandbox setup gap, not a static-test failure; Static Associated signals are still shown.`);
|
|
1257
|
+
}
|
|
1258
|
+
for (const file of autoProveResult.generated_files)
|
|
1259
|
+
nextActions.push(`Auto-prove wrote generated test: ${file}`);
|
|
1260
|
+
for (const attempt of autoProveResult.needs_setup)
|
|
1261
|
+
nextActions.push(`Auto-prove needs setup for ${attempt.target_symbol} (${attempt.test_path}): ${attempt.reason ?? "baseline/setup did not run"}.`);
|
|
1262
|
+
// Fix 2: the existing-tests lane ran without a key but minted 0 Proven (all survived) —
|
|
1263
|
+
// still surface the add-a-key guidance, since a key would unlock the generation lane.
|
|
1264
|
+
// (`reason` carries NO_KEY_MESSAGE only on the no-key path; skipped-no-key is handled above.)
|
|
1265
|
+
if (autoProveResult.proven === 0 && autoProveResult.reason === NO_KEY_MESSAGE)
|
|
1266
|
+
nextActions.push(autoProveResult.reason);
|
|
1267
|
+
}
|
|
1268
|
+
if (rtm.summary.total > 0 && rtm.summary.proven === 0) {
|
|
1269
|
+
nextActions.push("Dynamically Proven is 0 because no dynamic proof has closed yet; this is expected after analyze-only. In a coding agent, generate a test for the top gap and follow the returned prove_run/prove_loop handoff to mint Dynamically Proven through the dynamic oracle.");
|
|
1270
|
+
}
|
|
1271
|
+
nextActions.push(rtm.rows.length < rtm.summary.total
|
|
1272
|
+
? `Share ${rtm.rtm_path} as the capped deterministic traceability matrix (${rtm.rows.length}/${rtm.summary.total} rows).`
|
|
1273
|
+
: `Share ${rtm.rtm_path} as the deterministic traceability matrix.`);
|
|
1274
|
+
if (rtm.rows.length < rtm.summary.total)
|
|
1275
|
+
nextActions.push("For full machine-readable RTM, run `opro rtm --format json --out .orangepro/rtm-full.json`; avoid opening full Markdown on very large repos.");
|
|
1276
|
+
if (changed.status === "ok" && changed.affected_behaviors.length > 0) {
|
|
1277
|
+
nextActions.push(`In your coding agent, call orangepro_generate_tests with base_ref=${changed.base_ref}; write runnable tests, then follow each returned handoff: use prove_run when present for public Proven, otherwise record_run for static diagnostics.`);
|
|
1278
|
+
}
|
|
1279
|
+
else if (gaps.gaps.length > 0) {
|
|
1280
|
+
nextActions.push(`In your coding agent, call orangepro_generate_tests for ${gaps.gaps[0].external_id}; write runnable tests, then follow each returned handoff: use prove_run when present for public Proven, otherwise record_run for static diagnostics.`);
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
nextActions.push("No deterministic gap target was found; inspect the RTM and graph before generating tests.");
|
|
1284
|
+
}
|
|
1285
|
+
if (aiLinks.status === "skipped")
|
|
1286
|
+
nextActions.push(aiLinks.reason ?? "AI grounding was skipped.");
|
|
1287
|
+
if (aiLinks.status === "failed")
|
|
1288
|
+
nextActions.push(`AI grounding failed but deterministic artifacts are ready: ${aiLinks.reason}`);
|
|
1289
|
+
if (aiFlows.status === "applied")
|
|
1290
|
+
nextActions.push("Review the AI-suggested flows section in the behavior report; these are candidate chains to verify, not evidence.");
|
|
1291
|
+
if (aiFlows.status === "failed")
|
|
1292
|
+
nextActions.push(`AI candidate flows failed but deterministic artifacts are ready: ${aiFlows.reason}`);
|
|
1293
|
+
return {
|
|
1294
|
+
scope,
|
|
1295
|
+
analyze: finalAnalyze,
|
|
1296
|
+
ai_links: aiLinks,
|
|
1297
|
+
ai_flows: aiFlows,
|
|
1298
|
+
ai_linked: aiLinked,
|
|
1299
|
+
behavior_coverage_path: coverageHtml,
|
|
1300
|
+
coverage_report_path: coverageReport,
|
|
1301
|
+
rtm,
|
|
1302
|
+
changed,
|
|
1303
|
+
gaps,
|
|
1304
|
+
auto_prove: autoProveResult,
|
|
1305
|
+
next_actions: nextActions,
|
|
1306
|
+
agent_workflow: AGENT_RUN_WORKFLOW,
|
|
1307
|
+
grounding_contract: GROUNDING_CONTRACT,
|
|
1308
|
+
warnings
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
const NO_PROVIDER_MESSAGE = 'No model provider configured. Set OPENAI_API_KEY (or OLLAMA_BASE_URL / ANTHROPIC_API_KEY) in your shell environment or a .env.provider.local file to generate with your own model, or pass provider="deterministic" (or set ORANGEPRO_ALLOW_DETERMINISTIC=1) to use the offline deterministic stand-in. No tests were generated.';
|
|
1312
|
+
const START_RTM_LIMIT = 500;
|
|
1313
|
+
const EMPTY_EVIDENCE_SUMMARY = {
|
|
1314
|
+
tests: 0,
|
|
1315
|
+
tests_with_proof: 0,
|
|
1316
|
+
tests_without_validated_evidence: 0,
|
|
1317
|
+
invalid_citations: 0
|
|
1318
|
+
};
|
|
1319
|
+
/**
|
|
1320
|
+
* Resolve the generation provider per the BYOK contract. Returns null when no
|
|
1321
|
+
* provider/key is configured and the deterministic stand-in was not opted into,
|
|
1322
|
+
* so callers can surface NO_PROVIDER_MESSAGE instead of silently degrading.
|
|
1323
|
+
*/
|
|
1324
|
+
function resolveGenerationProvider(env, opts) {
|
|
1325
|
+
if (opts.provider === "deterministic")
|
|
1326
|
+
return new DeterministicProvider();
|
|
1327
|
+
const cfg = resolveProviderConfig(env, { provider: opts.provider, model: opts.model });
|
|
1328
|
+
if (cfg)
|
|
1329
|
+
return buildProvider(cfg);
|
|
1330
|
+
if (/^(1|true|yes)$/i.test(String(env.ORANGEPRO_ALLOW_DETERMINISTIC ?? "")))
|
|
1331
|
+
return new DeterministicProvider();
|
|
1332
|
+
return null;
|
|
1333
|
+
}
|
|
1334
|
+
export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
|
|
1335
|
+
const paths = workspacePaths(root);
|
|
1336
|
+
const graph = loadGraph(paths.graphPath);
|
|
1337
|
+
// BYOK contract: real customer generation uses their own key/local model.
|
|
1338
|
+
// Load provider credentials from the workspace's .env.provider.local/.env.local/.env
|
|
1339
|
+
// (never persisted) the same way ai-links/ai-flows/start do, so `opro generate`
|
|
1340
|
+
// picks up a key file without exporting it into the shell. The offline
|
|
1341
|
+
// deterministic stand-in is opt-in only; otherwise return setup guidance
|
|
1342
|
+
// instead of silently degrading.
|
|
1343
|
+
const providerEnv = loadProviderEnv([root], deps.env);
|
|
1344
|
+
const provider = resolveGenerationProvider(providerEnv, opts);
|
|
1345
|
+
if (!provider) {
|
|
1346
|
+
return {
|
|
1347
|
+
run_id: null,
|
|
1348
|
+
model_provider: "none",
|
|
1349
|
+
model_name: "none",
|
|
1350
|
+
generated_tests: [],
|
|
1351
|
+
evidence: [],
|
|
1352
|
+
evidence_summary: EMPTY_EVIDENCE_SUMMARY,
|
|
1353
|
+
missing_evidence: [],
|
|
1354
|
+
warnings: [NO_PROVIDER_MESSAGE],
|
|
1355
|
+
wrote_repo_files: false
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
const reader = fileReaderFor(graph.workspace.root);
|
|
1359
|
+
const result = await generateTests(graph, { target_ids: opts.target_ids, framework: opts.framework, limit: opts.limit, input_mode: opts.input_mode, prompt_version: opts.prompt_version }, provider, reader, deps.clock);
|
|
1360
|
+
if (result.run && result.generated_tests.length) {
|
|
1361
|
+
const next = {
|
|
1362
|
+
...graph,
|
|
1363
|
+
generation_runs: [...graph.generation_runs, result.run],
|
|
1364
|
+
generated_tests: [...graph.generated_tests, ...result.generated_tests],
|
|
1365
|
+
updated_at: deps.clock()
|
|
1366
|
+
};
|
|
1367
|
+
saveGraph(paths.graphPath, next);
|
|
1368
|
+
}
|
|
1369
|
+
// Validate each test's grounding citations against the graph the tests cite.
|
|
1370
|
+
// This is the keyless grounding contract: provenance must be verifiable, and a
|
|
1371
|
+
// test that cites nothing resolvable is surfaced loudly, not returned as success.
|
|
1372
|
+
const { per_test, summary } = summarizeTestEvidence(graph, result.generated_tests);
|
|
1373
|
+
const warnings = [...result.warnings];
|
|
1374
|
+
if (summary.tests_without_validated_evidence > 0) {
|
|
1375
|
+
warnings.push(`${summary.tests_without_validated_evidence} generated test(s) cite no evidence that resolves to the local graph — provenance unverified.`);
|
|
1376
|
+
}
|
|
1377
|
+
return {
|
|
1378
|
+
run_id: result.run?.run_id ?? null,
|
|
1379
|
+
model_provider: provider.providerName,
|
|
1380
|
+
model_name: provider.modelName,
|
|
1381
|
+
generated_tests: result.generated_tests,
|
|
1382
|
+
evidence: per_test,
|
|
1383
|
+
evidence_summary: summary,
|
|
1384
|
+
missing_evidence: result.missing_evidence,
|
|
1385
|
+
warnings,
|
|
1386
|
+
wrote_repo_files: false
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Run BOTH arms — prompt-only baseline vs Local KG (graph-grounded) — with the
|
|
1391
|
+
* SAME model and the SAME (kit default) system prompt via the single-call path, so
|
|
1392
|
+
* both arms emit runnable framework code (the only difference is KG injection).
|
|
1393
|
+
* Score them across four dimensions and return the side-by-side comparison.
|
|
1394
|
+
* Non-persisting testing view: it does not write generation runs into the graph.
|
|
1395
|
+
*/
|
|
1396
|
+
export async function opCompare(root, opts = {}, deps = defaultDeps()) {
|
|
1397
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
1398
|
+
const provider = resolveGenerationProvider(deps.env, opts);
|
|
1399
|
+
const ZERO = { completeness: 0, context_awareness: 0, accuracy: 0, domain_specificity: 0 };
|
|
1400
|
+
const ZERO_METRICS = {
|
|
1401
|
+
tests: 0,
|
|
1402
|
+
concrete_assertions_avg: 0,
|
|
1403
|
+
traceability_refs: 0,
|
|
1404
|
+
weak_evidence_disclosed: 0,
|
|
1405
|
+
smoke_only: 0
|
|
1406
|
+
};
|
|
1407
|
+
const emptyArm = () => ({ generated_tests: [], missing_evidence: [], warnings: [], run_hints: [] });
|
|
1408
|
+
if (!provider) {
|
|
1409
|
+
return {
|
|
1410
|
+
model_provider: "none",
|
|
1411
|
+
model_name: "none",
|
|
1412
|
+
system_prompt_source: "kit_default",
|
|
1413
|
+
scoring_method: "heuristic",
|
|
1414
|
+
baseline: emptyArm(),
|
|
1415
|
+
grounded: emptyArm(),
|
|
1416
|
+
scores: { baseline: ZERO, grounded: ZERO },
|
|
1417
|
+
matrix: { baseline: ZERO_METRICS, grounded: ZERO_METRICS },
|
|
1418
|
+
warnings: [NO_PROVIDER_MESSAGE],
|
|
1419
|
+
wrote_repo_files: false
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
const reader = fileReaderFor(graph.workspace.root);
|
|
1423
|
+
// Both arms use the SAME single-call generation with the SAME (kit) system
|
|
1424
|
+
// prompt; the ONLY difference is whether Local KG evidence is injected
|
|
1425
|
+
// (graph_grounded) vs not (raw_prompt). Both arms emit RUNNABLE framework code,
|
|
1426
|
+
// so a tester can run AND compare the actual scripts side by side.
|
|
1427
|
+
const shared = { target_ids: opts.target_ids, framework: opts.framework, limit: opts.limit };
|
|
1428
|
+
const baseline = await generateTests(graph, { ...shared, input_mode: "raw_prompt" }, provider, reader, deps.clock);
|
|
1429
|
+
const grounded = await generateTests(graph, { ...shared, input_mode: "graph_grounded", prompt_version: opts.prompt_version }, provider, reader, deps.clock);
|
|
1430
|
+
// Score holistically — the arms produce different test cases, so never per-test.
|
|
1431
|
+
// Use the LLM judge when a real model is configured; fall back to the deterministic
|
|
1432
|
+
// heuristic offline so the comparison still runs with no key.
|
|
1433
|
+
const oracle = buildOracle(graph);
|
|
1434
|
+
const haveTests = baseline.generated_tests.length > 0 || grounded.generated_tests.length > 0;
|
|
1435
|
+
const judged = provider.providerName !== "deterministic" && haveTests
|
|
1436
|
+
? await judgeComparison(provider, buildJudgeContext(graph, grounded.generated_tests), baseline.generated_tests.map((t) => t.body).join("\n\n"), grounded.generated_tests.map((t) => t.body).join("\n\n"))
|
|
1437
|
+
: null;
|
|
1438
|
+
let scores;
|
|
1439
|
+
let scoring_method;
|
|
1440
|
+
let rationale;
|
|
1441
|
+
if (judged) {
|
|
1442
|
+
scores = { baseline: judged.baseline, grounded: judged.grounded };
|
|
1443
|
+
scoring_method = "llm_judge";
|
|
1444
|
+
rationale = judged.rationale;
|
|
1445
|
+
}
|
|
1446
|
+
else {
|
|
1447
|
+
scores = {
|
|
1448
|
+
baseline: scoreArm(baseline.generated_tests, oracle),
|
|
1449
|
+
grounded: scoreArm(grounded.generated_tests, oracle)
|
|
1450
|
+
};
|
|
1451
|
+
scoring_method = "heuristic";
|
|
1452
|
+
}
|
|
1453
|
+
const matrix = {
|
|
1454
|
+
baseline: armMetrics(baseline.generated_tests, oracle),
|
|
1455
|
+
grounded: armMetrics(grounded.generated_tests, oracle)
|
|
1456
|
+
};
|
|
1457
|
+
return {
|
|
1458
|
+
model_provider: provider.providerName,
|
|
1459
|
+
model_name: provider.modelName,
|
|
1460
|
+
system_prompt_source: "kit_default",
|
|
1461
|
+
scoring_method,
|
|
1462
|
+
...(rationale ? { rationale } : {}),
|
|
1463
|
+
baseline: {
|
|
1464
|
+
generated_tests: baseline.generated_tests,
|
|
1465
|
+
missing_evidence: baseline.missing_evidence,
|
|
1466
|
+
warnings: baseline.warnings,
|
|
1467
|
+
run_hints: runnableRunHintsFor(baseline.generated_tests, graph.workspace.root)
|
|
1468
|
+
},
|
|
1469
|
+
grounded: {
|
|
1470
|
+
generated_tests: grounded.generated_tests,
|
|
1471
|
+
missing_evidence: grounded.missing_evidence,
|
|
1472
|
+
warnings: grounded.warnings,
|
|
1473
|
+
run_hints: runnableRunHintsFor(grounded.generated_tests, graph.workspace.root)
|
|
1474
|
+
},
|
|
1475
|
+
scores,
|
|
1476
|
+
matrix,
|
|
1477
|
+
// Both arms share the same target-selection warnings (e.g. "no behavior
|
|
1478
|
+
// anchors"), so dedupe instead of emitting each one twice.
|
|
1479
|
+
warnings: [...new Set([...baseline.warnings, ...grounded.warnings])],
|
|
1480
|
+
wrote_repo_files: false
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Write a fresh A/B comparison report (Markdown + JSON) into the workspace's
|
|
1485
|
+
* .orangepro/ dir. Overwritten on every run. Local testing artifact only — not the
|
|
1486
|
+
* evidence pack, never uploaded.
|
|
1487
|
+
*/
|
|
1488
|
+
export function writeCompareReport(root, cmp, deps = defaultDeps()) {
|
|
1489
|
+
const dir = dirname(workspacePaths(root).graphPath);
|
|
1490
|
+
const now = deps.clock();
|
|
1491
|
+
// Per-arm extensions: an arm whose bodies carry JSX gets .tsx (JSX in .ts is a TS error).
|
|
1492
|
+
const fw = compareTestsFramework(cmp);
|
|
1493
|
+
const groundedExt = testsFileExt(fw, cmp.grounded.generated_tests.map((t) => t.body));
|
|
1494
|
+
const baselineExt = testsFileExt(fw, cmp.baseline.generated_tests.map((t) => t.body));
|
|
1495
|
+
const names = {
|
|
1496
|
+
localKgTests: testsArtifactName("compare-tests.local-kg", groundedExt),
|
|
1497
|
+
baselineTests: testsArtifactName("compare-tests.baseline", baselineExt),
|
|
1498
|
+
localKgJson: "compare-tests.local-kg.json",
|
|
1499
|
+
baselineJson: "compare-tests.baseline.json"
|
|
1500
|
+
};
|
|
1501
|
+
const report_path = join(dir, "compare-report.md");
|
|
1502
|
+
const report_json_path = join(dir, "compare-report.json");
|
|
1503
|
+
const local_kg_tests_path = join(dir, names.localKgTests);
|
|
1504
|
+
const baseline_tests_path = join(dir, names.baselineTests);
|
|
1505
|
+
const local_kg_json_path = join(dir, names.localKgJson);
|
|
1506
|
+
const baseline_json_path = join(dir, names.baselineJson);
|
|
1507
|
+
// Per-arm test files are the durable artifacts (run + compare each arm); the
|
|
1508
|
+
// Markdown report is a slim pointer + scores. Atomic writes so a background
|
|
1509
|
+
// poller reading these never sees a half-written file.
|
|
1510
|
+
writeFileAtomic(local_kg_tests_path, renderArmTestsFile(cmp, "grounded", now));
|
|
1511
|
+
writeFileAtomic(baseline_tests_path, renderArmTestsFile(cmp, "baseline", now));
|
|
1512
|
+
writeFileAtomic(local_kg_json_path, renderArmTestsJson(cmp, "grounded", now));
|
|
1513
|
+
writeFileAtomic(baseline_json_path, renderArmTestsJson(cmp, "baseline", now));
|
|
1514
|
+
writeFileAtomic(report_path, renderCompareReportMarkdown(cmp, now, names));
|
|
1515
|
+
writeFileAtomic(report_json_path, JSON.stringify(cmp, null, 2));
|
|
1516
|
+
return {
|
|
1517
|
+
report_path,
|
|
1518
|
+
report_json_path,
|
|
1519
|
+
local_kg_tests_path,
|
|
1520
|
+
baseline_tests_path,
|
|
1521
|
+
local_kg_json_path,
|
|
1522
|
+
baseline_json_path
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
export function opExplain(root, testId) {
|
|
1526
|
+
return explainTest(loadGraph(workspacePaths(root).graphPath), testId);
|
|
1527
|
+
}
|
|
1528
|
+
export function opExport(root, outputPath, opts = {}, deps = defaultDeps()) {
|
|
1529
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
1530
|
+
const score = scoreGraph(graph);
|
|
1531
|
+
const pack = buildPack(graph, score, { include_generated_bodies: opts.include_generated_bodies ?? false }, deps.clock);
|
|
1532
|
+
const validation = validatePack(pack);
|
|
1533
|
+
const packPath = resolve(root, outputPath);
|
|
1534
|
+
writeFileSync(packPath, JSON.stringify(pack, null, 2) + "\n", "utf8");
|
|
1535
|
+
const summaryPath = packPath.replace(/\.json$/i, "") + ".md";
|
|
1536
|
+
writeFileSync(summaryPath, packToMarkdown(pack, graph.analysis), "utf8");
|
|
1537
|
+
const result = { pack_path: packPath, summary_path: summaryPath, validation };
|
|
1538
|
+
if (opts.graph_html) {
|
|
1539
|
+
const htmlPath = packPath.replace(/\.json$/i, "") + ".html";
|
|
1540
|
+
writeFileSync(htmlPath, renderVizHtml(buildVizPayload(graph, score, loadLedger(root))), "utf8");
|
|
1541
|
+
result.graph_html_path = htmlPath;
|
|
1542
|
+
}
|
|
1543
|
+
return result;
|
|
1544
|
+
}
|
|
1545
|
+
/** Write the self-contained offline evidence-graph explorer (metadata only). */
|
|
1546
|
+
export function opGraphHtml(root, outputPath = "orangepro-graph.html") {
|
|
1547
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
1548
|
+
const html = renderVizHtml(buildVizPayload(graph, scoreGraph(graph), loadLedger(root)));
|
|
1549
|
+
const htmlPath = resolve(root, outputPath);
|
|
1550
|
+
writeFileSync(htmlPath, html, "utf8");
|
|
1551
|
+
return { graph_html_path: htmlPath };
|
|
1552
|
+
}
|
|
1553
|
+
/** Write the self-contained offline behavior-coverage view (deterministic, metadata only). */
|
|
1554
|
+
export function opBehaviorCoverageHtml(root, outputPath = "orangepro-behavior-coverage.html", dynamicProof) {
|
|
1555
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
1556
|
+
const html = renderBehaviorReport(buildBehaviorReportData(graph, loadLedger(root), { repoRoot: root, dynamicProof }));
|
|
1557
|
+
const htmlPath = resolve(root, outputPath);
|
|
1558
|
+
writeFileSync(htmlPath, html, "utf8");
|
|
1559
|
+
return { behavior_coverage_path: htmlPath };
|
|
1560
|
+
}
|
|
1561
|
+
/** Phase 5.2 — write the human-readable COVERAGE_REPORT.md (3-file contract). */
|
|
1562
|
+
export function opCoverageReport(root, outputPath = "COVERAGE_REPORT.md") {
|
|
1563
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
1564
|
+
const reportPath = resolve(root, outputPath);
|
|
1565
|
+
writeFileSync(reportPath, renderCoverageReport(graph, loadLedger(root)), "utf8");
|
|
1566
|
+
return { coverage_report_path: reportPath };
|
|
1567
|
+
}
|
|
1568
|
+
export function opUpdate(root, opts = {}, deps = defaultDeps()) {
|
|
1569
|
+
const paths = workspacePaths(root);
|
|
1570
|
+
if (!graphExists(root)) {
|
|
1571
|
+
opAnalyze(root, {}, deps);
|
|
1572
|
+
return { status: "rebuilt", changed_files: 0, updated_entities: 0, stale_generated_tests: 0, warnings: ["No graph existed; performed full analyze."] };
|
|
1573
|
+
}
|
|
1574
|
+
const old = loadGraph(paths.graphPath);
|
|
1575
|
+
const scanRoot = old.workspace.root;
|
|
1576
|
+
const current = scanFileEntries(scanRoot);
|
|
1577
|
+
const fresh = computeFreshness(old, current);
|
|
1578
|
+
if (opts.force_full_rebuild) {
|
|
1579
|
+
const summary = opAnalyze(root, { source: scanRoot }, deps);
|
|
1580
|
+
return { status: "rebuilt", changed_files: fresh.changed_files.length, updated_entities: summary.entities_count, stale_generated_tests: 0, warnings: summary.warnings };
|
|
1581
|
+
}
|
|
1582
|
+
if (fresh.changed_files.length === 0) {
|
|
1583
|
+
return { status: "fresh", changed_files: 0, updated_entities: 0, stale_generated_tests: 0, warnings: [] };
|
|
1584
|
+
}
|
|
1585
|
+
const next = incrementalMerge(scanRoot, old, fresh.changed_files, deps.clock(), maxFlowsFromEnv(deps.env), maxFilesFromEnv(deps.env), maxSymbolsFromEnv(deps.env));
|
|
1586
|
+
saveGraph(paths.graphPath, next.graph);
|
|
1587
|
+
return {
|
|
1588
|
+
status: "updated",
|
|
1589
|
+
changed_files: fresh.changed_files.length,
|
|
1590
|
+
updated_entities: next.updated_entities,
|
|
1591
|
+
stale_generated_tests: next.stale_generated_tests,
|
|
1592
|
+
warnings: next.warnings
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
function incrementalMerge(root, old, changedFiles, now, maxInferredFlows, maxFiles, maxSymbols) {
|
|
1596
|
+
const changed = new Set(changedFiles);
|
|
1597
|
+
// maxSymbols MUST match the analyze run: a lower cap here would drop live
|
|
1598
|
+
// exports beyond it from the fresh scan and mark them stale, silently
|
|
1599
|
+
// shrinking the denominator on every `opro update`.
|
|
1600
|
+
const freshFragment = analyzeRepo(root, { readContent: true, maxInferredFlows, maxFiles, maxSymbols });
|
|
1601
|
+
const enrichFragments = collectEnricherFragments(root, [], true);
|
|
1602
|
+
const repoScopeId = freshFragment.sources[0]?.source_scope_id;
|
|
1603
|
+
// Preserve enricher/manual nodes (non-analyzer); fully refresh analyzer nodes from fresh scan.
|
|
1604
|
+
const preservedNodes = old.nodes.filter((n) => n.provenance.source_scope_id !== repoScopeId);
|
|
1605
|
+
const freshExt = new Set(freshFragment.nodes.map((n) => n.external_id));
|
|
1606
|
+
const removedAnalyzer = old.nodes
|
|
1607
|
+
.filter((n) => n.provenance.source_scope_id === repoScopeId && !freshExt.has(n.external_id))
|
|
1608
|
+
.map((n) => ({ ...n, stale: true }));
|
|
1609
|
+
const nodes = dedupeNodesByExternalId([
|
|
1610
|
+
...freshFragment.nodes,
|
|
1611
|
+
...enrichFragments.flatMap((f) => f.nodes),
|
|
1612
|
+
...preservedNodes,
|
|
1613
|
+
...removedAnalyzer
|
|
1614
|
+
]);
|
|
1615
|
+
const preservedEdges = old.edges.filter((e) => e.provenance.source_scope_id !== repoScopeId);
|
|
1616
|
+
const edges = dedupeById([...freshFragment.edges, ...enrichFragments.flatMap((f) => f.edges), ...preservedEdges]);
|
|
1617
|
+
const candidate_edges = dedupeById([
|
|
1618
|
+
...freshFragment.candidate_edges,
|
|
1619
|
+
...enrichFragments.flatMap((f) => f.candidate_edges),
|
|
1620
|
+
...old.candidate_edges.filter((e) => e.provenance?.source_scope_id !== repoScopeId)
|
|
1621
|
+
]);
|
|
1622
|
+
// Mark generated tests stale when their grounding touches a changed file.
|
|
1623
|
+
let staleCount = 0;
|
|
1624
|
+
const generated_tests = old.generated_tests.map((t) => {
|
|
1625
|
+
const touches = [...t.grounding.entity_ids, ...t.grounding.source_refs].some((ref) => changed.has(ref));
|
|
1626
|
+
if (touches && !t.stale) {
|
|
1627
|
+
staleCount++;
|
|
1628
|
+
return { ...t, stale: true };
|
|
1629
|
+
}
|
|
1630
|
+
return t;
|
|
1631
|
+
});
|
|
1632
|
+
const manifest = buildManifest(freshFragment.file_entries, readGitInfo(gitRunner(root)), now);
|
|
1633
|
+
const updatedEntities = freshFragment.nodes.filter((n) => {
|
|
1634
|
+
const ref = n.provenance.source_ref;
|
|
1635
|
+
return (ref && changed.has(ref)) || changed.has(n.external_id) || (typeof n.properties.file === "string" && changed.has(n.properties.file));
|
|
1636
|
+
}).length;
|
|
1637
|
+
const prunedEdges = pruneDanglingEdges(nodes, edges);
|
|
1638
|
+
const graph = {
|
|
1639
|
+
...old,
|
|
1640
|
+
updated_at: now,
|
|
1641
|
+
sources: [...freshFragment.sources, ...enrichFragments.flatMap((f) => f.sources)],
|
|
1642
|
+
nodes,
|
|
1643
|
+
edges: prunedEdges,
|
|
1644
|
+
candidate_edges: pruneDanglingEdges(nodes, candidate_edges),
|
|
1645
|
+
generated_tests,
|
|
1646
|
+
manifest,
|
|
1647
|
+
analysis: {
|
|
1648
|
+
...freshFragment.analysis,
|
|
1649
|
+
denominator: denominatorComposition({ nodes }),
|
|
1650
|
+
confirmed_by_layer: confirmedCoverageByLayer({ nodes, edges: prunedEdges })
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
return { graph, updated_entities: updatedEntities, stale_generated_tests: staleCount, warnings: freshFragment.warnings };
|
|
1654
|
+
}
|
|
1655
|
+
const NO_DIFF_GUIDANCE = "No changed files found. Run this on a feature branch, pass base_ref, or use the test-gaps tool for baseline opportunities (CLI: opro gaps; MCP: orangepro_find_test_gaps).";
|
|
1656
|
+
const NO_CODE_CHANGES_GUIDANCE = "The diff vs the base ref only touched docs (.md/.txt/etc) — there is no code change to generate tests for. Generate without --base for baseline coverage, or use the test-gaps tool (CLI: opro gaps; MCP: orangepro_find_test_gaps).";
|
|
1657
|
+
/**
|
|
1658
|
+
* Diff/PR tool-mode contract. Every tool that needs a real change set resolves
|
|
1659
|
+
* its diff context here. It NEVER throws and NEVER fabricates impact: when the
|
|
1660
|
+
* workspace is not a git repo, the base ref is missing, or there is no diff, it
|
|
1661
|
+
* returns a structured guidance status instead. Baseline tools must NOT call it.
|
|
1662
|
+
*
|
|
1663
|
+
* `base_ref` defaults to `main`. The diff runs from `git merge-base <base> HEAD`
|
|
1664
|
+
* to the working tree: the branch's own commits AND uncommitted edits, never
|
|
1665
|
+
* upstream churn on the base (falls back to the base tip when no merge-base
|
|
1666
|
+
* exists). Read-only: no repo writes, no source upload.
|
|
1667
|
+
*/
|
|
1668
|
+
export function resolveDiffContext(graph, baseRefInput) {
|
|
1669
|
+
const scanRoot = graph.workspace.root;
|
|
1670
|
+
const git = gitRunner(scanRoot);
|
|
1671
|
+
const explicitBase = (baseRefInput && baseRefInput.trim()) || "";
|
|
1672
|
+
let base_ref = explicitBase || "main";
|
|
1673
|
+
const insideWorkTree = git(["rev-parse", "--is-inside-work-tree"]);
|
|
1674
|
+
if (!insideWorkTree || insideWorkTree.trim() !== "true") {
|
|
1675
|
+
return {
|
|
1676
|
+
status: "not_a_git_repo",
|
|
1677
|
+
base_ref,
|
|
1678
|
+
changed_files: [],
|
|
1679
|
+
guidance: "Workspace is not a git repository, so there is no diff to analyze. Use the test-gaps tool for baseline opportunities on the current checkout (CLI: opro gaps; MCP: orangepro_find_test_gaps)."
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
// No explicit base (e.g. bare diff / `--changed`): autodetect the default branch
|
|
1683
|
+
// so it works on master-default repos too, not just "main".
|
|
1684
|
+
if (!explicitBase) {
|
|
1685
|
+
for (const cand of ["main", "master"]) {
|
|
1686
|
+
if (git(["rev-parse", "--verify", "--quiet", `${cand}^{commit}`])) {
|
|
1687
|
+
base_ref = cand;
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
// Peel to a commit (^{commit}) so branches, tags, remotes, and shas all validate
|
|
1693
|
+
// uniformly (and annotated tags resolve to their commit). Injection-safe: git
|
|
1694
|
+
// args are passed as an array to execFileSync, never a shell string.
|
|
1695
|
+
const verified = git(["rev-parse", "--verify", "--quiet", `${base_ref}^{commit}`]);
|
|
1696
|
+
if (!verified) {
|
|
1697
|
+
return {
|
|
1698
|
+
status: "missing_base_ref",
|
|
1699
|
+
base_ref,
|
|
1700
|
+
changed_files: [],
|
|
1701
|
+
guidance: `Base ref '${base_ref}' was not found locally. Pass an existing local ref — a branch (try "master" if this repo does not use "main"), a tag, a commit sha, or a fetched remote like "origin/main" — or use the test-gaps tool for baseline opportunities (CLI: opro gaps; MCP: orangepro_find_test_gaps).`
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
// Diff from the MERGE-BASE of base and HEAD to the working tree: the branch's
|
|
1705
|
+
// own changes (plus uncommitted edits), never upstream churn. Diffing the base
|
|
1706
|
+
// TIP directly counted every file changed ON the base since the branch point as
|
|
1707
|
+
// "changed" — on an active repo that flooded --pr targeting with upstream-
|
|
1708
|
+
// modified test files (Mattermost dogfood: 513 files / 243 tests reported for a
|
|
1709
|
+
// PR whose real diff was 5). Falls back to the base tip when merge-base is
|
|
1710
|
+
// unavailable (shallow clone, unrelated histories) — degraded, never fabricated.
|
|
1711
|
+
const mergeBase = git(["merge-base", base_ref, "HEAD"])?.trim();
|
|
1712
|
+
const diff = git(["diff", "--name-only", mergeBase || base_ref]) ?? "";
|
|
1713
|
+
const rules = loadIgnore(scanRoot);
|
|
1714
|
+
const allChanged = diff
|
|
1715
|
+
.split(/\r?\n/)
|
|
1716
|
+
.map((s) => s.trim())
|
|
1717
|
+
.filter(Boolean)
|
|
1718
|
+
.filter((f) => !isPathIgnored(f, rules));
|
|
1719
|
+
if (allChanged.length === 0) {
|
|
1720
|
+
return { status: "no_diff", base_ref, changed_files: [], guidance: NO_DIFF_GUIDANCE };
|
|
1721
|
+
}
|
|
1722
|
+
// PR-scoped generation targets REAL code changes only: a docs-only diff
|
|
1723
|
+
// (README/.md/.txt/etc) has nothing to test. Drop role 'doc'; keep
|
|
1724
|
+
// code/test/config/other so the change set (and the area mapping) is not
|
|
1725
|
+
// inflated by documentation edits.
|
|
1726
|
+
const changed_files = allChanged.filter((f) => roleOf(f) !== "doc");
|
|
1727
|
+
if (changed_files.length === 0) {
|
|
1728
|
+
return { status: "no_code_changes", base_ref, changed_files: [], guidance: NO_CODE_CHANGES_GUIDANCE };
|
|
1729
|
+
}
|
|
1730
|
+
return { status: "ok", base_ref, changed_files };
|
|
1731
|
+
}
|
|
1732
|
+
export function opChanged(root, baseRef) {
|
|
1733
|
+
const graph = loadGraph(workspacePaths(root).graphPath);
|
|
1734
|
+
const ctx = resolveDiffContext(graph, baseRef);
|
|
1735
|
+
if (ctx.status !== "ok") {
|
|
1736
|
+
return {
|
|
1737
|
+
status: ctx.status,
|
|
1738
|
+
base_ref: ctx.base_ref,
|
|
1739
|
+
changed_files: [],
|
|
1740
|
+
affected_behaviors: [],
|
|
1741
|
+
link_kinds: {},
|
|
1742
|
+
affected_tests: [],
|
|
1743
|
+
recommended_actions: [],
|
|
1744
|
+
guidance: ctx.guidance
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
return changedImpact(graph, ctx.changed_files, ctx.base_ref);
|
|
1748
|
+
}
|
|
1749
|
+
export { redactSecrets };
|