@fraction12/deepclean 0.1.0-alpha.0 → 0.1.0-alpha.2
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/CHANGELOG.md +15 -1
- package/README.md +72 -46
- package/dist/args.js +43 -0
- package/dist/args.js.map +1 -1
- package/dist/cli.js +1954 -40
- package/dist/cli.js.map +1 -1
- package/dist/defaults.js +12 -1
- package/dist/defaults.js.map +1 -1
- package/dist/features.d.ts +13 -0
- package/dist/features.js +286 -0
- package/dist/features.js.map +1 -0
- package/dist/identity.d.ts +15 -0
- package/dist/identity.js +183 -0
- package/dist/identity.js.map +1 -0
- package/dist/locks.d.ts +37 -0
- package/dist/locks.js +179 -0
- package/dist/locks.js.map +1 -0
- package/dist/plans.js +2 -2
- package/dist/plans.js.map +1 -1
- package/dist/reporting.js +3 -1
- package/dist/reporting.js.map +1 -1
- package/dist/revalidation.d.ts +8 -0
- package/dist/revalidation.js +114 -0
- package/dist/revalidation.js.map +1 -0
- package/dist/state.d.ts +31 -1
- package/dist/state.js +170 -1
- package/dist/state.js.map +1 -1
- package/dist/synthesis.d.ts +16 -2
- package/dist/synthesis.js +295 -30
- package/dist/synthesis.js.map +1 -1
- package/dist/types.d.ts +550 -2
- package/dist/types.js +321 -0
- package/dist/types.js.map +1 -1
- package/docs/privacy-and-trust.md +25 -1
- package/docs/public-readiness.md +1 -1
- package/docs/release.md +86 -0
- package/docs/troubleshooting.md +139 -2
- package/package.json +24 -8
package/dist/cli.js
CHANGED
|
@@ -1,27 +1,47 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
6
8
|
import { parseArgs, flagBoolean, flagString } from "./args.js";
|
|
7
9
|
import { candidatesFromEvidence, rankCandidates, reassignCandidateIds } from "./candidates.js";
|
|
8
10
|
import { buildClusters, unclusteredCandidateIds } from "./clusters.js";
|
|
9
11
|
import { discoverSourceFiles } from "./discovery.js";
|
|
10
12
|
import { runEvidenceAdapters } from "./evidence.js";
|
|
13
|
+
import { mapSemanticFeatures } from "./features.js";
|
|
14
|
+
import { attachStableIdentity } from "./identity.js";
|
|
11
15
|
import { fail, ok } from "./json.js";
|
|
16
|
+
import { LockContentionError, lockRecoveryCommand, readLockStatuses, recoverStaleLocks, withStateWriteLock, } from "./locks.js";
|
|
12
17
|
import { buildCandidatePlan, buildClusterPlan } from "./plans.js";
|
|
18
|
+
import { classifyRevalidation } from "./revalidation.js";
|
|
13
19
|
import { buildHandoff, buildReportRecord, renderMarkdownReport, renderMarkdownReportWithClusters, } from "./reporting.js";
|
|
14
|
-
import { ensureState, latestRunId, readLatestCandidates, readLatestClusters, readLatestEvidence, resolveStatePaths, updateLatestCandidates, writeCandidates, writeClusters, writeEvidence, writeHandoff, writePlan, writeReport, writeRun, writeTriage, } from "./state.js";
|
|
20
|
+
import { ensureState, latestRunId, readConfig, readCandidates, readFindings, readLatestCandidates, readLatestClusters, readLatestEvidence, readLatestFeatures, readLatestSynthesisAttempt, readLifecycleEvents, resolveStatePaths, updateLatestCandidates, writeCandidates, writeCandidateObservations, writeCiRun, writeClusters, writeEvidence, writeFeatures, writeFindings, writeFixAttempt, writeHandoff, writeLifecycleEvents, writePlan, writeReport, writeRetentionManifest, writeRevalidation, writeRun, writeSynthesisAttempt, writeTriage, } from "./state.js";
|
|
15
21
|
import { candidateStatuses, schemaVersion, } from "./types.js";
|
|
16
22
|
import { timestampId } from "./ids.js";
|
|
17
23
|
import { synthesizeWithCodex } from "./synthesis.js";
|
|
18
24
|
import { inferVerificationProfile } from "./verification.js";
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
19
26
|
const commands = [
|
|
20
27
|
"init",
|
|
28
|
+
"doctor",
|
|
29
|
+
"status",
|
|
30
|
+
"ci",
|
|
31
|
+
"map",
|
|
21
32
|
"scan",
|
|
22
33
|
"report",
|
|
23
34
|
"next",
|
|
35
|
+
"list",
|
|
36
|
+
"findings",
|
|
24
37
|
"show",
|
|
38
|
+
"explain",
|
|
39
|
+
"history",
|
|
40
|
+
"revalidate",
|
|
41
|
+
"unlock",
|
|
42
|
+
"prune",
|
|
43
|
+
"scrub",
|
|
44
|
+
"fix",
|
|
25
45
|
"cluster",
|
|
26
46
|
"plan",
|
|
27
47
|
"triage",
|
|
@@ -29,25 +49,71 @@ const commands = [
|
|
|
29
49
|
"export",
|
|
30
50
|
];
|
|
31
51
|
function printHelp() {
|
|
32
|
-
console.log(`deepclean:
|
|
52
|
+
console.log(`deepclean: local structure reports and agent-ready plans
|
|
33
53
|
|
|
34
54
|
Usage:
|
|
35
55
|
deepclean <command> [args] [flags]
|
|
36
56
|
|
|
37
57
|
Commands:
|
|
38
58
|
init Create or validate .deepclean state
|
|
59
|
+
doctor Check environment, config, state, git, provider, and privacy readiness
|
|
60
|
+
status Summarize current project-local Deepclean state
|
|
61
|
+
ci Run non-interactive scan and policy gates for CI
|
|
62
|
+
map Write semantic feature records without producing candidates
|
|
39
63
|
scan Collect local evidence and generate candidates
|
|
40
|
-
--synthesize Run local Codex synthesis over evidence
|
|
64
|
+
--synthesize Run local Codex synthesis over evidence (default)
|
|
65
|
+
--evidence-only Skip synthesis and produce local evidence candidates only
|
|
41
66
|
--allow-source-in-model Include source samples in Codex prompt
|
|
67
|
+
--offline Skip provider calls and network-style analyzers
|
|
68
|
+
--local-only Alias for --offline
|
|
69
|
+
--provider <provider> Provider adapter, currently codex
|
|
42
70
|
--model <model> Override Codex model for synthesis
|
|
71
|
+
--effort <effort> Record provider reasoning effort
|
|
72
|
+
--timeout <seconds> Provider timeout in seconds
|
|
73
|
+
--retries <n> Provider retry attempts
|
|
74
|
+
--rpm <n> Provider request-per-minute budget
|
|
75
|
+
--concurrency <n> Provider concurrency budget
|
|
76
|
+
--token-budget <n> Provider token budget metadata
|
|
77
|
+
--excerpt-budget <n> Source excerpt budget; 0 keeps prompts metadata-only
|
|
78
|
+
--privacy-mode <mode> local-only, metadata, or source-ok
|
|
79
|
+
--since <ref> Scan files changed since a git ref
|
|
80
|
+
--merge-base <ref> Use merge-base with ref for changed-file scope
|
|
81
|
+
--include-dirty Include uncommitted and untracked files in scope
|
|
82
|
+
--paths <a,b> Restrict scan to paths or path prefixes
|
|
83
|
+
--categories <a,b> Restrict emitted candidates to categories
|
|
84
|
+
--reviewers <a,b> Record reviewer-surface scope for synthesis/metadata
|
|
85
|
+
--only-existing Keep only findings previously known to Deepclean
|
|
86
|
+
--new-only Keep only newly discovered findings
|
|
43
87
|
report Write and print a ranked report
|
|
44
88
|
next Show the highest-priority open candidate
|
|
89
|
+
list List findings with shared filters
|
|
90
|
+
findings Alias for list
|
|
45
91
|
show <candidate-or-theme> Show one candidate or cleanup theme with evidence
|
|
92
|
+
explain <candidate-or-finding>
|
|
93
|
+
Explain evidence, validation, and fix-readiness for a finding
|
|
94
|
+
history <finding-or-candidate-id>
|
|
95
|
+
Show lifecycle history for a finding
|
|
96
|
+
revalidate <finding-id|candidate-id|all>
|
|
97
|
+
Freshly recheck whether findings still hold
|
|
98
|
+
unlock --stale Remove stale project-local writer locks
|
|
99
|
+
prune Remove stale Deepclean artifacts with retention safety
|
|
100
|
+
--dry-run Persist a manifest without deleting files
|
|
101
|
+
--keep-runs <n> Keep latest n runs, defaults to 5
|
|
102
|
+
--keep-days <n> Also keep runs newer than n days
|
|
103
|
+
scrub Emit source-safe generated-state export
|
|
104
|
+
fix <finding-or-candidate> Preview or apply a guarded local patch
|
|
105
|
+
--patch <file> Patch file to preview/apply
|
|
106
|
+
--dry-run Persist preview without changing source
|
|
107
|
+
--apply Apply the patch locally
|
|
108
|
+
--allow-source-mutation Required with --apply
|
|
109
|
+
--allow-dirty Allow dirty files inside target scope
|
|
110
|
+
--verification-command <c> Override verification command
|
|
46
111
|
cluster [theme-id] Group related candidates into cleanup themes
|
|
47
112
|
plan <candidate-or-theme> Generate an agent-ready cleanup plan
|
|
48
113
|
triage <candidate-id> Update candidate status with --status and --note
|
|
49
114
|
handoff <candidate-id> Generate an agent-ready handoff packet
|
|
50
115
|
export <candidate-id> Alias for handoff
|
|
116
|
+
export --source-safe Alias for scrub
|
|
51
117
|
|
|
52
118
|
Global flags:
|
|
53
119
|
--json Emit JSON envelope
|
|
@@ -58,6 +124,8 @@ Global flags:
|
|
|
58
124
|
--config <path> Config file, defaults to .deepclean/config.json
|
|
59
125
|
--quiet Suppress human success output
|
|
60
126
|
--debug Include stack traces for unexpected errors
|
|
127
|
+
--wait-lock Wait for an active writer lock instead of failing immediately
|
|
128
|
+
--lock-timeout-ms <ms> Maximum time to wait for --wait-lock
|
|
61
129
|
-h, --help Show help
|
|
62
130
|
--version Show version`);
|
|
63
131
|
}
|
|
@@ -94,28 +162,64 @@ export async function main(argv, cwd = process.cwd()) {
|
|
|
94
162
|
switch (command) {
|
|
95
163
|
case "init":
|
|
96
164
|
return await initCommand(context);
|
|
165
|
+
case "doctor":
|
|
166
|
+
return await doctorCommand(context);
|
|
167
|
+
case "status":
|
|
168
|
+
return await statusCommand(context);
|
|
169
|
+
case "ci":
|
|
170
|
+
return await withWriteLock(context, () => ciCommand(context));
|
|
171
|
+
case "map":
|
|
172
|
+
return await withWriteLock(context, () => mapCommand(context));
|
|
97
173
|
case "scan":
|
|
98
|
-
return await scanCommand(context);
|
|
174
|
+
return await withWriteLock(context, () => scanCommand(context));
|
|
99
175
|
case "report":
|
|
100
|
-
return await reportCommand(context);
|
|
176
|
+
return await withWriteLock(context, () => reportCommand(context));
|
|
101
177
|
case "next":
|
|
102
178
|
return await nextCommand(context);
|
|
179
|
+
case "list":
|
|
180
|
+
case "findings":
|
|
181
|
+
return await listCommand(context);
|
|
103
182
|
case "show":
|
|
104
183
|
return await showCommand(context);
|
|
184
|
+
case "explain":
|
|
185
|
+
return await explainCommand(context);
|
|
186
|
+
case "history":
|
|
187
|
+
return await historyCommand(context);
|
|
188
|
+
case "revalidate":
|
|
189
|
+
return await withWriteLock(context, () => revalidateCommand(context));
|
|
190
|
+
case "unlock":
|
|
191
|
+
return await unlockCommand(context);
|
|
192
|
+
case "prune":
|
|
193
|
+
return await withWriteLock(context, () => pruneCommand(context));
|
|
194
|
+
case "scrub":
|
|
195
|
+
return await scrubCommand(context);
|
|
196
|
+
case "fix":
|
|
197
|
+
return await withWriteLock(context, () => fixCommand(context));
|
|
105
198
|
case "cluster":
|
|
106
|
-
return await clusterCommand(context);
|
|
199
|
+
return await withWriteLock(context, () => clusterCommand(context));
|
|
107
200
|
case "plan":
|
|
108
|
-
return await planCommand(context);
|
|
201
|
+
return await withWriteLock(context, () => planCommand(context));
|
|
109
202
|
case "triage":
|
|
110
|
-
return await triageCommand(context);
|
|
203
|
+
return await withWriteLock(context, () => triageCommand(context));
|
|
111
204
|
case "handoff":
|
|
205
|
+
return await withWriteLock(context, () => handoffCommand(context));
|
|
112
206
|
case "export":
|
|
113
|
-
|
|
207
|
+
if (flagBoolean(context.parsed.flags, "source-safe")) {
|
|
208
|
+
return await scrubCommand(context);
|
|
209
|
+
}
|
|
210
|
+
return await withWriteLock(context, () => handoffCommand(context));
|
|
114
211
|
}
|
|
115
212
|
emit(json, fail(command, "unknown_command", `Unknown command: ${command}`));
|
|
116
213
|
return 2;
|
|
117
214
|
}
|
|
118
215
|
catch (error) {
|
|
216
|
+
if (error instanceof LockContentionError) {
|
|
217
|
+
emit(json, fail(command, "lock_contention", error.message, [error.diagnostic]));
|
|
218
|
+
if (!json && !quiet) {
|
|
219
|
+
console.error(error.message);
|
|
220
|
+
}
|
|
221
|
+
return 4;
|
|
222
|
+
}
|
|
119
223
|
const message = error instanceof Error ? error.message : String(error);
|
|
120
224
|
emit(json, fail(command, "command_failed", debug && error instanceof Error && error.stack ? error.stack : message));
|
|
121
225
|
return 1;
|
|
@@ -135,12 +239,546 @@ async function initCommand(context) {
|
|
|
135
239
|
}
|
|
136
240
|
return 0;
|
|
137
241
|
}
|
|
242
|
+
async function doctorCommand(context) {
|
|
243
|
+
const diagnostics = [];
|
|
244
|
+
const initialized = await pathExists(context.paths.stateDir);
|
|
245
|
+
const missingDirs = initialized ? await missingStateDirectories(context.paths) : [];
|
|
246
|
+
const locks = initialized ? await readLockStatuses(context.paths, {
|
|
247
|
+
staleAfterMs: staleLockMsFromFlags(context),
|
|
248
|
+
}) : [];
|
|
249
|
+
const staleLocks = locks.filter((lock) => lock.stale);
|
|
250
|
+
const configResult = await readConfigForDoctor(context.paths);
|
|
251
|
+
diagnostics.push(...configResult.diagnostics);
|
|
252
|
+
if (missingDirs.length > 0) {
|
|
253
|
+
diagnostics.push({
|
|
254
|
+
level: "warning",
|
|
255
|
+
code: "state_dirs_missing",
|
|
256
|
+
message: `Missing state directories: ${missingDirs.join(", ")}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (staleLocks.length > 0) {
|
|
260
|
+
diagnostics.push({
|
|
261
|
+
level: "warning",
|
|
262
|
+
code: "stale_locks",
|
|
263
|
+
message: `Found ${staleLocks.length} stale writer lock${staleLocks.length === 1 ? "" : "s"}. Run \`${lockRecoveryCommand(context.paths)}\` before the next write command.`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const git = await gitDoctor(context.paths.root);
|
|
267
|
+
if (!git.available) {
|
|
268
|
+
diagnostics.push({
|
|
269
|
+
level: "warning",
|
|
270
|
+
code: "git_unavailable",
|
|
271
|
+
message: git.error ?? "Git is unavailable for this repository.",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const provider = configResult.config
|
|
275
|
+
? await providerDoctor(context.paths.root, configResult.config.reviewSynthesis.command)
|
|
276
|
+
: { command: undefined, available: false, error: "Config is unavailable." };
|
|
277
|
+
if (configResult.config && !provider.available) {
|
|
278
|
+
diagnostics.push({
|
|
279
|
+
level: "warning",
|
|
280
|
+
code: "provider_unavailable",
|
|
281
|
+
message: provider.error ?? `Provider command is unavailable: ${provider.command}`,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const data = {
|
|
285
|
+
root: context.paths.root,
|
|
286
|
+
stateDir: context.paths.stateDir,
|
|
287
|
+
initialized,
|
|
288
|
+
packageVersion: await packageVersion(),
|
|
289
|
+
config: {
|
|
290
|
+
path: context.paths.configPath,
|
|
291
|
+
valid: configResult.valid,
|
|
292
|
+
error: configResult.error,
|
|
293
|
+
},
|
|
294
|
+
state: {
|
|
295
|
+
valid: initialized && missingDirs.length === 0,
|
|
296
|
+
missingDirs,
|
|
297
|
+
},
|
|
298
|
+
locks: {
|
|
299
|
+
active: locks.filter((lock) => !lock.stale).length,
|
|
300
|
+
stale: staleLocks.length,
|
|
301
|
+
records: locks.map((lock) => ({
|
|
302
|
+
id: lock.record?.id,
|
|
303
|
+
owner: lock.record?.owner,
|
|
304
|
+
pid: lock.record?.pid,
|
|
305
|
+
command: lock.record?.command,
|
|
306
|
+
statePath: lock.record?.statePath,
|
|
307
|
+
createdAt: lock.record?.createdAt,
|
|
308
|
+
stale: lock.stale,
|
|
309
|
+
reason: lock.reason,
|
|
310
|
+
recoveryCommand: lock.recoveryCommand,
|
|
311
|
+
})),
|
|
312
|
+
},
|
|
313
|
+
git,
|
|
314
|
+
provider,
|
|
315
|
+
privacy: configResult.config?.privacy,
|
|
316
|
+
supportedSurfaces: await supportedSurfaces(context.paths.root),
|
|
317
|
+
};
|
|
318
|
+
emit(context.json, ok("doctor", data, diagnostics));
|
|
319
|
+
if (!context.json && !context.quiet) {
|
|
320
|
+
console.log(`Deepclean ${data.packageVersion}`);
|
|
321
|
+
console.log(`root: ${data.root}`);
|
|
322
|
+
console.log(`state: ${data.state.valid ? "ok" : initialized ? "incomplete" : "not initialized"}`);
|
|
323
|
+
console.log(`config: ${data.config.valid ? "ok" : "invalid"}`);
|
|
324
|
+
console.log(`git: ${git.available ? git.dirty ? "dirty" : "clean" : "unavailable"}`);
|
|
325
|
+
console.log(`provider: ${provider.available ? "ok" : "unavailable"}`);
|
|
326
|
+
console.log(`locks: ${data.locks.active} active / ${data.locks.stale} stale`);
|
|
327
|
+
printDiagnostics(diagnostics);
|
|
328
|
+
}
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
async function statusCommand(context) {
|
|
332
|
+
const diagnostics = [];
|
|
333
|
+
const initialized = await pathExists(context.paths.stateDir);
|
|
334
|
+
const latest = initialized ? await latestRunId(context.paths) : undefined;
|
|
335
|
+
const candidates = latest ? await readLatestCandidates(context.paths) : [];
|
|
336
|
+
const clusters = latest ? await readLatestClusters(context.paths) : [];
|
|
337
|
+
const evidence = latest ? await readLatestEvidence(context.paths) : [];
|
|
338
|
+
const features = initialized ? await readLatestFeatures(context.paths) : [];
|
|
339
|
+
const git = await gitDoctor(context.paths.root);
|
|
340
|
+
if (!git.available) {
|
|
341
|
+
diagnostics.push({
|
|
342
|
+
level: "warning",
|
|
343
|
+
code: "git_unavailable",
|
|
344
|
+
message: git.error ?? "Git is unavailable for this repository.",
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const artifactCounts = await stateArtifactCounts(context.paths);
|
|
348
|
+
const locks = initialized ? await readLockStatuses(context.paths, {
|
|
349
|
+
staleAfterMs: staleLockMsFromFlags(context),
|
|
350
|
+
}) : [];
|
|
351
|
+
const statusCounts = countBy(candidates, (candidate) => candidate.status);
|
|
352
|
+
const data = {
|
|
353
|
+
root: context.paths.root,
|
|
354
|
+
stateDir: context.paths.stateDir,
|
|
355
|
+
initialized,
|
|
356
|
+
latestRunId: latest,
|
|
357
|
+
git: {
|
|
358
|
+
branch: git.branch,
|
|
359
|
+
dirty: git.dirty,
|
|
360
|
+
available: git.available,
|
|
361
|
+
},
|
|
362
|
+
queue: {
|
|
363
|
+
total: candidates.length,
|
|
364
|
+
open: candidates.filter((candidate) => candidate.status === "open").length,
|
|
365
|
+
byStatus: statusCounts,
|
|
366
|
+
themes: clusters.length,
|
|
367
|
+
evidence: evidence.length,
|
|
368
|
+
features: features.length,
|
|
369
|
+
},
|
|
370
|
+
locks: {
|
|
371
|
+
active: locks.filter((lock) => !lock.stale).length,
|
|
372
|
+
stale: locks.filter((lock) => lock.stale).length,
|
|
373
|
+
records: locks.map((lock) => ({
|
|
374
|
+
id: lock.record?.id,
|
|
375
|
+
owner: lock.record?.owner,
|
|
376
|
+
pid: lock.record?.pid,
|
|
377
|
+
command: lock.record?.command,
|
|
378
|
+
statePath: lock.record?.statePath,
|
|
379
|
+
createdAt: lock.record?.createdAt,
|
|
380
|
+
stale: lock.stale,
|
|
381
|
+
reason: lock.reason,
|
|
382
|
+
recoveryCommand: lock.recoveryCommand,
|
|
383
|
+
})),
|
|
384
|
+
},
|
|
385
|
+
pendingRevalidation: candidates.filter((candidate) => candidate.lifecycleState === "stale").length,
|
|
386
|
+
artifacts: artifactCounts,
|
|
387
|
+
};
|
|
388
|
+
emit(context.json, ok("status", data, diagnostics));
|
|
389
|
+
if (!context.json && !context.quiet) {
|
|
390
|
+
console.log(`root: ${data.root}`);
|
|
391
|
+
console.log(`state: ${initialized ? "initialized" : "not initialized"}`);
|
|
392
|
+
console.log(`latest run: ${latest ?? "none"}`);
|
|
393
|
+
console.log(`queue: ${data.queue.open} open / ${data.queue.total} total`);
|
|
394
|
+
console.log(`git: ${git.available ? git.dirty ? "dirty" : "clean" : "unavailable"}`);
|
|
395
|
+
console.log(`locks: ${data.locks.active} active / ${data.locks.stale} stale`);
|
|
396
|
+
printDiagnostics(diagnostics);
|
|
397
|
+
}
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
async function unlockCommand(context) {
|
|
401
|
+
if (!flagBoolean(context.parsed.flags, "stale")) {
|
|
402
|
+
emit(context.json, fail("unlock", "stale_required", "Only stale lock recovery is supported. Rerun with --stale."));
|
|
403
|
+
return 2;
|
|
404
|
+
}
|
|
405
|
+
const result = await recoverStaleLocks(context.paths, {
|
|
406
|
+
staleAfterMs: staleLockMsFromFlags(context),
|
|
407
|
+
});
|
|
408
|
+
emit(context.json, ok("unlock", {
|
|
409
|
+
removed: result.removed.map(lockStatusPayload),
|
|
410
|
+
active: result.active.map(lockStatusPayload),
|
|
411
|
+
recoveryCommand: lockRecoveryCommand(context.paths),
|
|
412
|
+
}));
|
|
413
|
+
if (!context.json && !context.quiet) {
|
|
414
|
+
console.log(`Removed ${result.removed.length} stale lock${result.removed.length === 1 ? "" : "s"}.`);
|
|
415
|
+
if (result.active.length > 0) {
|
|
416
|
+
console.log(`${result.active.length} active lock${result.active.length === 1 ? "" : "s"} left in place.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return result.active.length > 0 ? 4 : 0;
|
|
420
|
+
}
|
|
421
|
+
async function pruneCommand(context) {
|
|
422
|
+
await ensureState(context.paths);
|
|
423
|
+
const manifest = await buildRetentionManifest(context);
|
|
424
|
+
if (!manifest.dryRun) {
|
|
425
|
+
for (const relativePath of manifest.deletePaths) {
|
|
426
|
+
await rm(path.resolve(context.paths.root, relativePath), { force: true });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const manifestPath = await writeRetentionManifest(context.paths, manifest);
|
|
430
|
+
emit(context.json, ok("prune", {
|
|
431
|
+
dryRun: manifest.dryRun,
|
|
432
|
+
manifest,
|
|
433
|
+
manifestPath,
|
|
434
|
+
deleteCount: manifest.deletePaths.length,
|
|
435
|
+
retainedCount: manifest.retainedPaths.length,
|
|
436
|
+
blockedCount: manifest.blockedPaths.length,
|
|
437
|
+
}));
|
|
438
|
+
if (!context.json && !context.quiet) {
|
|
439
|
+
console.log(`${manifest.dryRun ? "Would delete" : "Deleted"} ${manifest.deletePaths.length} artifact${manifest.deletePaths.length === 1 ? "" : "s"}.`);
|
|
440
|
+
console.log(`Retention manifest written to ${path.relative(context.paths.root, manifestPath)}`);
|
|
441
|
+
}
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
async function scrubCommand(context) {
|
|
445
|
+
const [candidates, clusters, evidence, features] = await Promise.all([
|
|
446
|
+
readLatestCandidates(context.paths),
|
|
447
|
+
readLatestClusters(context.paths),
|
|
448
|
+
readLatestEvidence(context.paths),
|
|
449
|
+
readLatestFeatures(context.paths),
|
|
450
|
+
]);
|
|
451
|
+
const latest = await latestRunId(context.paths);
|
|
452
|
+
const output = {
|
|
453
|
+
schemaVersion,
|
|
454
|
+
sourceSafe: true,
|
|
455
|
+
generatedAt: new Date().toISOString(),
|
|
456
|
+
project: path.basename(context.paths.root),
|
|
457
|
+
latestRunId: latest,
|
|
458
|
+
counts: {
|
|
459
|
+
candidates: candidates.length,
|
|
460
|
+
clusters: clusters.length,
|
|
461
|
+
evidence: evidence.length,
|
|
462
|
+
features: features.length,
|
|
463
|
+
},
|
|
464
|
+
candidates: rankCandidates(candidates).map((candidate) => ({
|
|
465
|
+
id: candidate.id,
|
|
466
|
+
findingId: candidate.findingId,
|
|
467
|
+
title: candidate.title,
|
|
468
|
+
category: candidate.category,
|
|
469
|
+
status: candidate.status,
|
|
470
|
+
lifecycleState: candidate.lifecycleState,
|
|
471
|
+
priority: candidate.priority,
|
|
472
|
+
confidence: candidate.confidence,
|
|
473
|
+
impact: candidate.impact,
|
|
474
|
+
effort: candidate.effort,
|
|
475
|
+
risk: candidate.risk,
|
|
476
|
+
baselineStatus: candidate.baselineStatus,
|
|
477
|
+
evidenceIds: candidate.evidenceIds,
|
|
478
|
+
files: candidate.files.map((file) => sourceSafeFile(context.paths.root, file)),
|
|
479
|
+
verification: candidate.verification,
|
|
480
|
+
})),
|
|
481
|
+
clusters: clusters.map((cluster) => ({
|
|
482
|
+
id: cluster.id,
|
|
483
|
+
title: cluster.title,
|
|
484
|
+
category: cluster.category,
|
|
485
|
+
priority: cluster.priority,
|
|
486
|
+
confidence: cluster.confidence,
|
|
487
|
+
candidateIds: cluster.candidateIds,
|
|
488
|
+
evidenceIds: cluster.evidenceIds,
|
|
489
|
+
files: cluster.files.map((file) => sourceSafeFile(context.paths.root, file)),
|
|
490
|
+
actionability: cluster.actionability,
|
|
491
|
+
warnings: cluster.warnings,
|
|
492
|
+
})),
|
|
493
|
+
evidence: evidence.map((record) => ({
|
|
494
|
+
id: record.id,
|
|
495
|
+
kind: record.kind,
|
|
496
|
+
title: record.title,
|
|
497
|
+
files: record.files.map((file) => sourceSafeFile(context.paths.root, file)),
|
|
498
|
+
})),
|
|
499
|
+
features: features.map((feature) => ({
|
|
500
|
+
featureId: feature.featureId,
|
|
501
|
+
title: feature.title,
|
|
502
|
+
kind: feature.kind,
|
|
503
|
+
confidence: feature.confidence,
|
|
504
|
+
ownedFiles: feature.ownedFiles.map((file) => sourceSafeFile(context.paths.root, file)),
|
|
505
|
+
testFiles: feature.testFiles.map((file) => sourceSafeFile(context.paths.root, file)),
|
|
506
|
+
verification: feature.verification,
|
|
507
|
+
tags: feature.tags,
|
|
508
|
+
})),
|
|
509
|
+
privacyNotes: [
|
|
510
|
+
"Source-safe export omits source excerpts, model prompts, provider payloads, absolute state paths, and generated handoff/plan prose.",
|
|
511
|
+
"Repository-relative paths are retained so findings remain actionable.",
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
const outputPath = flagString(context.parsed.flags, "output");
|
|
515
|
+
if (outputPath) {
|
|
516
|
+
const resolved = path.resolve(context.paths.root, outputPath);
|
|
517
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
518
|
+
await writeFile(resolved, `${JSON.stringify(output, null, 2)}\n`, "utf8");
|
|
519
|
+
}
|
|
520
|
+
emit(context.json, ok("scrub", {
|
|
521
|
+
export: output,
|
|
522
|
+
...(outputPath ? { outputPath: path.resolve(context.paths.root, outputPath) } : {}),
|
|
523
|
+
}));
|
|
524
|
+
if (!context.json && !context.quiet) {
|
|
525
|
+
console.log(`Source-safe export: ${output.counts.candidates} candidates, ${output.counts.features} features, ${output.counts.evidence} evidence references`);
|
|
526
|
+
if (outputPath) {
|
|
527
|
+
console.log(`Export written to ${outputPath}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
async function fixCommand(context) {
|
|
533
|
+
const target = requireCandidateId(context);
|
|
534
|
+
if (target.startsWith("theme-")) {
|
|
535
|
+
emit(context.json, fail("fix", "fix_target_too_broad", "Fix execution requires one stable finding or candidate, not a broad theme."));
|
|
536
|
+
return 2;
|
|
537
|
+
}
|
|
538
|
+
const patch = flagString(context.parsed.flags, "patch");
|
|
539
|
+
if (!patch) {
|
|
540
|
+
emit(context.json, fail("fix", "patch_required", "--patch is required so Deepclean can preview/apply an explicit local patch."));
|
|
541
|
+
return 2;
|
|
542
|
+
}
|
|
543
|
+
const dryRun = flagBoolean(context.parsed.flags, "dry-run") || !flagBoolean(context.parsed.flags, "apply");
|
|
544
|
+
const config = await ensureState(context.paths);
|
|
545
|
+
if (!dryRun && (!config.fixExecution.enabled || !flagBoolean(context.parsed.flags, "allow-source-mutation"))) {
|
|
546
|
+
emit(context.json, fail("fix", "fix_opt_in_required", "Applying fixes requires fixExecution.enabled=true and --allow-source-mutation."));
|
|
547
|
+
return 2;
|
|
548
|
+
}
|
|
549
|
+
const resolved = await resolveFixTarget(context.paths, target);
|
|
550
|
+
if (!resolved) {
|
|
551
|
+
emit(context.json, fail("fix", "finding_not_found", `Finding or candidate not found: ${target}`));
|
|
552
|
+
return 1;
|
|
553
|
+
}
|
|
554
|
+
const blocked = fixReadinessBlocker(resolved.candidate);
|
|
555
|
+
if (blocked) {
|
|
556
|
+
emit(context.json, fail("fix", blocked.code, blocked.message));
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
const plan = await latestPlanForTarget(context.paths, resolved.candidate.id);
|
|
560
|
+
if (!plan) {
|
|
561
|
+
emit(context.json, fail("fix", "fix_plan_required", "Generate a current plan before fixing this finding."));
|
|
562
|
+
return 2;
|
|
563
|
+
}
|
|
564
|
+
const revalidation = await latestRevalidationForFinding(context.paths, resolved.findingId);
|
|
565
|
+
if (!revalidation || !["unchanged", "changed"].includes(revalidation.outcome)) {
|
|
566
|
+
emit(context.json, fail("fix", "fix_revalidation_required", "Run revalidation before applying or previewing this fix."));
|
|
567
|
+
return 2;
|
|
568
|
+
}
|
|
569
|
+
const patchPath = path.resolve(context.paths.root, patch);
|
|
570
|
+
const patchContent = await readFile(patchPath, "utf8");
|
|
571
|
+
const changedFiles = changedFilesFromPatch(patchContent);
|
|
572
|
+
if (changedFiles.length === 0) {
|
|
573
|
+
emit(context.json, fail("fix", "patch_empty", "Patch preview did not contain changed files."));
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
const dirty = await dirtyFiles(context.paths.root);
|
|
577
|
+
const targetPaths = new Set(resolved.candidate.files.map((file) => file.path));
|
|
578
|
+
const statePrefix = `${relativeStatePath(context.paths, context.paths.stateDir).replace(/\/$/, "")}/`;
|
|
579
|
+
const patchRelativePath = relativeStatePath(context.paths, patchPath);
|
|
580
|
+
const dirtyOutsideTarget = dirty.filter((file) => (!targetPaths.has(file)
|
|
581
|
+
&& file !== patchRelativePath
|
|
582
|
+
&& !file.startsWith(statePrefix)));
|
|
583
|
+
if (!dryRun && dirtyOutsideTarget.length > 0 && !flagBoolean(context.parsed.flags, "allow-dirty")) {
|
|
584
|
+
emit(context.json, fail("fix", "dirty_tree", `Dirty files outside target scope: ${dirtyOutsideTarget.join(", ")}`));
|
|
585
|
+
return 2;
|
|
586
|
+
}
|
|
587
|
+
const attemptId = timestampId("fix");
|
|
588
|
+
const patchPreviewPath = path.join(context.paths.fixesDir, `${attemptId}.patch`);
|
|
589
|
+
await mkdir(context.paths.fixesDir, { recursive: true });
|
|
590
|
+
await writeFile(patchPreviewPath, patchContent, "utf8");
|
|
591
|
+
const verificationCommands = verificationCommandsForFix(context, config, resolved.candidate);
|
|
592
|
+
let status = dryRun ? "previewed" : "unverified";
|
|
593
|
+
let verificationResults = [];
|
|
594
|
+
const diagnostics = [];
|
|
595
|
+
if (!dryRun) {
|
|
596
|
+
const apply = await execFileAsync("git", ["apply", patchPath], { cwd: context.paths.root, timeout: 30_000 })
|
|
597
|
+
.then(() => ({ ok: true, error: undefined }))
|
|
598
|
+
.catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
599
|
+
if (!apply.ok) {
|
|
600
|
+
diagnostics.push({ level: "error", code: "patch_apply_failed", message: apply.error ?? "Patch failed to apply." });
|
|
601
|
+
status = "failed";
|
|
602
|
+
}
|
|
603
|
+
else if (verificationCommands.length > 0) {
|
|
604
|
+
verificationResults = await runFixVerification(context.paths, attemptId, verificationCommands);
|
|
605
|
+
status = verificationResults.every((result) => result.passed) ? "passed" : "failed";
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const now = new Date().toISOString();
|
|
609
|
+
const attempt = {
|
|
610
|
+
schemaVersion,
|
|
611
|
+
recordType: "fix_attempt",
|
|
612
|
+
id: attemptId,
|
|
613
|
+
findingId: resolved.findingId,
|
|
614
|
+
planId: plan.id,
|
|
615
|
+
status,
|
|
616
|
+
dryRun,
|
|
617
|
+
changedFiles,
|
|
618
|
+
patchPreviewPath,
|
|
619
|
+
verificationCommands,
|
|
620
|
+
verificationResults,
|
|
621
|
+
diagnostics,
|
|
622
|
+
createdAt: now,
|
|
623
|
+
updatedAt: now,
|
|
624
|
+
};
|
|
625
|
+
const attemptPath = await writeFixAttempt(context.paths, attempt);
|
|
626
|
+
await writeLifecycleEvents(context.paths, [
|
|
627
|
+
{
|
|
628
|
+
schemaVersion,
|
|
629
|
+
recordType: "lifecycle_event",
|
|
630
|
+
id: timestampId("event"),
|
|
631
|
+
targetType: "fix_attempt",
|
|
632
|
+
targetId: attempt.id,
|
|
633
|
+
findingId: resolved.findingId,
|
|
634
|
+
kind: "fix-attempted",
|
|
635
|
+
command: "fix",
|
|
636
|
+
createdAt: now,
|
|
637
|
+
data: { dryRun, status, changedFiles },
|
|
638
|
+
},
|
|
639
|
+
...(status === "passed" || status === "failed"
|
|
640
|
+
? [{
|
|
641
|
+
schemaVersion,
|
|
642
|
+
recordType: "lifecycle_event",
|
|
643
|
+
id: timestampId("event"),
|
|
644
|
+
targetType: "fix_attempt",
|
|
645
|
+
targetId: attempt.id,
|
|
646
|
+
findingId: resolved.findingId,
|
|
647
|
+
kind: status === "passed" ? "verification-passed" : "verification-failed",
|
|
648
|
+
command: "fix",
|
|
649
|
+
createdAt: now,
|
|
650
|
+
data: { verificationResults },
|
|
651
|
+
}]
|
|
652
|
+
: []),
|
|
653
|
+
]);
|
|
654
|
+
emit(context.json, ok("fix", {
|
|
655
|
+
attempt,
|
|
656
|
+
attemptPath,
|
|
657
|
+
patchPreviewPath,
|
|
658
|
+
changedFiles,
|
|
659
|
+
externalSideEffects: [],
|
|
660
|
+
next: status === "passed" ? "Review local changes and open a PR manually if desired." : "Inspect the fix attempt artifact before continuing.",
|
|
661
|
+
}, diagnostics));
|
|
662
|
+
if (!context.json && !context.quiet) {
|
|
663
|
+
console.log(`${dryRun ? "Previewed" : "Applied"} fix ${attempt.id}: ${status}`);
|
|
664
|
+
}
|
|
665
|
+
return status === "failed" ? 3 : 0;
|
|
666
|
+
}
|
|
667
|
+
async function mapCommand(context) {
|
|
668
|
+
const result = await executeFeatureMap(context);
|
|
669
|
+
emit(context.json, ok("map", result));
|
|
670
|
+
if (!context.json && !context.quiet) {
|
|
671
|
+
console.log(`Mapped ${result.featureCount} semantic feature${result.featureCount === 1 ? "" : "s"}.`);
|
|
672
|
+
console.log(`Features written to ${path.relative(context.paths.root, result.path)}`);
|
|
673
|
+
}
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
async function executeFeatureMap(context) {
|
|
677
|
+
const createdAt = new Date().toISOString();
|
|
678
|
+
const mapId = timestampId("map");
|
|
679
|
+
const config = await ensureState(context.paths);
|
|
680
|
+
const verificationProfile = await inferVerificationProfile(context.paths.root);
|
|
681
|
+
const discoveredFiles = await discoverSourceFiles(context.paths.root, config.exclude);
|
|
682
|
+
const scope = await resolveScanScope(context, discoveredFiles);
|
|
683
|
+
const files = discoveredFiles.filter((file) => fileInScope(file, scope));
|
|
684
|
+
const features = await mapSemanticFeatures({
|
|
685
|
+
root: context.paths.root,
|
|
686
|
+
runId: mapId,
|
|
687
|
+
createdAt,
|
|
688
|
+
files,
|
|
689
|
+
verificationProfile,
|
|
690
|
+
excludes: config.exclude,
|
|
691
|
+
});
|
|
692
|
+
const featurePath = await writeFeatures(context.paths, mapId, features);
|
|
693
|
+
return {
|
|
694
|
+
mapId,
|
|
695
|
+
root: context.paths.root,
|
|
696
|
+
sourceFileCount: files.length,
|
|
697
|
+
featureCount: features.length,
|
|
698
|
+
features,
|
|
699
|
+
scope,
|
|
700
|
+
path: featurePath,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
async function ciCommand(context) {
|
|
704
|
+
const requireSynthesis = flagBoolean(context.parsed.flags, "require-synthesis");
|
|
705
|
+
const config = await ensureState(context.paths);
|
|
706
|
+
if (requireSynthesis && synthesisDisabledByPolicy(context, config)) {
|
|
707
|
+
const diagnostic = {
|
|
708
|
+
level: "error",
|
|
709
|
+
code: "ci_synthesis_required",
|
|
710
|
+
message: "CI policy requires synthesis; rerun without evidence-only/local-only flags and with a configured provider.",
|
|
711
|
+
};
|
|
712
|
+
emit(context.json, fail("ci", "ci_synthesis_required", diagnostic.message, [diagnostic]));
|
|
713
|
+
return 2;
|
|
714
|
+
}
|
|
715
|
+
const scan = await executeScan(context, {});
|
|
716
|
+
const synthesisFailure = requireSynthesis ? requiredSynthesisFailure(scan) : undefined;
|
|
717
|
+
if (synthesisFailure) {
|
|
718
|
+
const diagnostics = [
|
|
719
|
+
synthesisFailure,
|
|
720
|
+
...scan.diagnostics.filter((diagnostic) => !sameSynthesisFailure(diagnostic, synthesisFailure)),
|
|
721
|
+
];
|
|
722
|
+
emit(context.json, fail("ci", "ci_synthesis_failed", synthesisFailure.message, diagnostics));
|
|
723
|
+
return 2;
|
|
724
|
+
}
|
|
725
|
+
const policy = ciPolicyFromFlags(context);
|
|
726
|
+
const gate = evaluateCiPolicy(scan.data.candidates, policy);
|
|
727
|
+
const createdAt = new Date().toISOString();
|
|
728
|
+
const artifactPaths = await writeCiArtifacts(context, scan.data, gate);
|
|
729
|
+
const ciRun = {
|
|
730
|
+
schemaVersion,
|
|
731
|
+
recordType: "ci_run",
|
|
732
|
+
id: timestampId("ci"),
|
|
733
|
+
runId: scan.runId,
|
|
734
|
+
baselineRef: scan.data.scope.since ?? scan.data.scope.mergeBase,
|
|
735
|
+
status: gate.blockingFindingIds.length > 0 ? "policy-failed" : "passed",
|
|
736
|
+
policy,
|
|
737
|
+
blockingFindingIds: gate.blockingFindingIds,
|
|
738
|
+
artifactPaths,
|
|
739
|
+
diagnostics: scan.diagnostics,
|
|
740
|
+
createdAt,
|
|
741
|
+
};
|
|
742
|
+
await writeCiRun(context.paths, ciRun);
|
|
743
|
+
emit(context.json, ok("ci", {
|
|
744
|
+
ciRun,
|
|
745
|
+
policy,
|
|
746
|
+
result: gate,
|
|
747
|
+
scan: scan.data,
|
|
748
|
+
}, scan.diagnostics));
|
|
749
|
+
if (!context.json && !context.quiet) {
|
|
750
|
+
console.log(`CI ${ciRun.status}: ${gate.blockingFindingIds.length} blocking finding${gate.blockingFindingIds.length === 1 ? "" : "s"}`);
|
|
751
|
+
}
|
|
752
|
+
return gate.blockingFindingIds.length > 0 ? 3 : 0;
|
|
753
|
+
}
|
|
138
754
|
async function scanCommand(context) {
|
|
755
|
+
const result = await executeScan(context, {});
|
|
756
|
+
emit(context.json, ok("scan", result.data, result.diagnostics));
|
|
757
|
+
if (!context.json && !context.quiet) {
|
|
758
|
+
const synthesisText = result.data.synthesis.requested
|
|
759
|
+
? `, ${result.data.synthesis.candidateCount} synthesized`
|
|
760
|
+
: "";
|
|
761
|
+
console.log(`Scan complete: ${result.data.evidenceCount} evidence records, ${result.data.candidateCount} candidates, ${result.data.clusterCount} clusters${synthesisText}`);
|
|
762
|
+
printCandidateSummary(result.data.candidates);
|
|
763
|
+
}
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
async function executeScan(context, options) {
|
|
139
767
|
const startedAt = new Date().toISOString();
|
|
140
768
|
const runId = timestampId("run");
|
|
141
769
|
const config = await ensureState(context.paths);
|
|
142
770
|
const verificationProfile = await inferVerificationProfile(context.paths.root);
|
|
143
|
-
const
|
|
771
|
+
const discoveredFiles = await discoverSourceFiles(context.paths.root, config.exclude);
|
|
772
|
+
const scope = await resolveScanScope(context, discoveredFiles);
|
|
773
|
+
const files = discoveredFiles.filter((file) => fileInScope(file, scope));
|
|
774
|
+
const features = await mapSemanticFeatures({
|
|
775
|
+
root: context.paths.root,
|
|
776
|
+
runId,
|
|
777
|
+
createdAt: startedAt,
|
|
778
|
+
files,
|
|
779
|
+
verificationProfile,
|
|
780
|
+
excludes: config.exclude,
|
|
781
|
+
});
|
|
144
782
|
const adapterResult = await runEvidenceAdapters(config.enabledAdapters, {
|
|
145
783
|
root: context.paths.root,
|
|
146
784
|
runId,
|
|
@@ -148,32 +786,56 @@ async function scanCommand(context) {
|
|
|
148
786
|
files,
|
|
149
787
|
config,
|
|
150
788
|
});
|
|
789
|
+
const evidence = markDirtyTreeEvidence(adapterResult.evidence, scope);
|
|
151
790
|
const completedAt = new Date().toISOString();
|
|
152
|
-
const localCandidates = candidatesFromEvidence(runId,
|
|
153
|
-
const synthesisRequested =
|
|
154
|
-
|
|
155
|
-
|
|
791
|
+
const localCandidates = candidatesFromEvidence(runId, evidence, completedAt, config.candidateCaps, verificationProfile);
|
|
792
|
+
const synthesisRequested = options.synthesize ?? true;
|
|
793
|
+
const runtime = providerRuntimeControls(context, config);
|
|
794
|
+
if (synthesisRequested && runtime.offline) {
|
|
795
|
+
adapterResult.diagnostics.push({
|
|
796
|
+
level: "info",
|
|
797
|
+
code: "synthesis_skipped_by_policy",
|
|
798
|
+
message: "Provider synthesis was skipped because evidence-only/offline/local-only mode is active.",
|
|
799
|
+
adapter: "codex-synthesis",
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
const shouldSynthesize = synthesisRequested && !runtime.offline;
|
|
803
|
+
const synthesisResult = shouldSynthesize
|
|
156
804
|
? await synthesizeWithCodex({
|
|
157
805
|
root: context.paths.root,
|
|
158
806
|
runId,
|
|
159
807
|
createdAt: completedAt,
|
|
160
|
-
evidence
|
|
808
|
+
evidence,
|
|
161
809
|
config,
|
|
162
810
|
existingCandidates: localCandidates,
|
|
163
|
-
includeSource:
|
|
164
|
-
|
|
165
|
-
model: flagString(context.parsed.flags, "model"),
|
|
811
|
+
includeSource: runtime.allowSourceInModel,
|
|
812
|
+
runtime,
|
|
166
813
|
verificationProfile,
|
|
167
814
|
})
|
|
168
815
|
: { candidates: [], diagnostics: [] };
|
|
169
816
|
const diagnostics = [...adapterResult.diagnostics, ...synthesisResult.diagnostics];
|
|
170
|
-
const
|
|
817
|
+
const rankedCandidates = reassignCandidateIds(rankCandidates([
|
|
171
818
|
...localCandidates,
|
|
172
819
|
...synthesisResult.candidates,
|
|
173
820
|
]));
|
|
174
|
-
const
|
|
175
|
-
|
|
821
|
+
const identity = attachStableIdentity({
|
|
822
|
+
runId,
|
|
823
|
+
candidates: rankedCandidates,
|
|
824
|
+
evidence,
|
|
825
|
+
existingFindings: await readFindings(context.paths),
|
|
826
|
+
observedAt: completedAt,
|
|
827
|
+
});
|
|
828
|
+
const candidates = filterCandidatesByScanScope(identity.candidates, scope);
|
|
829
|
+
const clusters = buildClusters(runId, candidates, evidence, completedAt, config.clusters);
|
|
830
|
+
await writeFeatures(context.paths, runId, features);
|
|
831
|
+
await writeEvidence(context.paths, runId, evidence);
|
|
832
|
+
if (synthesisResult.attempt) {
|
|
833
|
+
await writeSynthesisAttempt(context.paths, remapSynthesisAttemptCandidateIds(synthesisResult.attempt, candidates));
|
|
834
|
+
}
|
|
176
835
|
await writeCandidates(context.paths, runId, candidates);
|
|
836
|
+
await writeFindings(context.paths, identity.findings);
|
|
837
|
+
await writeCandidateObservations(context.paths, runId, identity.observations);
|
|
838
|
+
await writeLifecycleEvents(context.paths, identity.lifecycleEvents);
|
|
177
839
|
await writeClusters(context.paths, runId, clusters);
|
|
178
840
|
await writeRun(context.paths, {
|
|
179
841
|
schemaVersion,
|
|
@@ -183,44 +845,66 @@ async function scanCommand(context) {
|
|
|
183
845
|
root: context.paths.root,
|
|
184
846
|
startedAt,
|
|
185
847
|
completedAt,
|
|
186
|
-
|
|
848
|
+
featureCount: features.length,
|
|
849
|
+
evidenceCount: evidence.length,
|
|
187
850
|
candidateCount: candidates.length,
|
|
188
851
|
clusterCount: clusters.length,
|
|
189
852
|
synthesis: {
|
|
190
|
-
requested:
|
|
191
|
-
provider:
|
|
853
|
+
requested: shouldSynthesize,
|
|
854
|
+
provider: shouldSynthesize ? runtime.provider : undefined,
|
|
192
855
|
candidateCount: synthesisResult.candidates.length,
|
|
856
|
+
attemptId: synthesisResult.attempt?.id,
|
|
857
|
+
acceptedCandidateCount: synthesisResult.attempt?.acceptedCandidateCount,
|
|
858
|
+
rejectedCandidateCount: synthesisResult.attempt?.rejectedCandidateCount,
|
|
859
|
+
runtime: providerRuntimeSummary(runtime),
|
|
193
860
|
},
|
|
861
|
+
scope,
|
|
194
862
|
diagnostics,
|
|
195
863
|
});
|
|
196
864
|
const data = {
|
|
197
865
|
runId,
|
|
198
866
|
root: context.paths.root,
|
|
199
867
|
sourceFileCount: files.length,
|
|
200
|
-
|
|
868
|
+
featureCount: features.length,
|
|
869
|
+
evidenceCount: evidence.length,
|
|
201
870
|
candidateCount: candidates.length,
|
|
202
871
|
clusterCount: clusters.length,
|
|
203
872
|
synthesis: {
|
|
204
|
-
requested:
|
|
873
|
+
requested: shouldSynthesize,
|
|
205
874
|
candidateCount: synthesisResult.candidates.length,
|
|
875
|
+
attemptId: synthesisResult.attempt?.id,
|
|
876
|
+
acceptedCandidateCount: synthesisResult.attempt?.acceptedCandidateCount,
|
|
877
|
+
rejectedCandidateCount: synthesisResult.attempt?.rejectedCandidateCount,
|
|
878
|
+
runtime: providerRuntimeSummary(runtime),
|
|
206
879
|
},
|
|
207
880
|
candidates,
|
|
208
881
|
clusters,
|
|
882
|
+
features,
|
|
883
|
+
scope,
|
|
884
|
+
};
|
|
885
|
+
return { runId, diagnostics, data };
|
|
886
|
+
}
|
|
887
|
+
function remapSynthesisAttemptCandidateIds(attempt, candidates) {
|
|
888
|
+
const candidateIdByValidationId = new Map(candidates
|
|
889
|
+
.filter((candidate) => candidate.provenance.source === "model-synthesis")
|
|
890
|
+
.flatMap((candidate) => candidate.provenance.validationId
|
|
891
|
+
? [[candidate.provenance.validationId, candidate.id]]
|
|
892
|
+
: []));
|
|
893
|
+
return {
|
|
894
|
+
...attempt,
|
|
895
|
+
validations: attempt.validations.map((validation) => ({
|
|
896
|
+
...validation,
|
|
897
|
+
candidateId: candidateIdByValidationId.get(validation.id),
|
|
898
|
+
})),
|
|
209
899
|
};
|
|
210
|
-
emit(context.json, ok("scan", data, diagnostics));
|
|
211
|
-
if (!context.json && !context.quiet) {
|
|
212
|
-
const synthesisText = synthesisRequested
|
|
213
|
-
? `, ${synthesisResult.candidates.length} synthesized`
|
|
214
|
-
: "";
|
|
215
|
-
console.log(`Scan complete: ${adapterResult.evidence.length} evidence records, ${candidates.length} candidates, ${clusters.length} clusters${synthesisText}`);
|
|
216
|
-
printCandidateSummary(candidates);
|
|
217
|
-
}
|
|
218
|
-
return 0;
|
|
219
900
|
}
|
|
220
901
|
async function reportCommand(context) {
|
|
221
902
|
const { candidates, evidence, runId } = await latestState(context.paths);
|
|
222
903
|
const config = await ensureState(context.paths);
|
|
223
|
-
const
|
|
904
|
+
const latestClusters = await readLatestClusters(context.paths);
|
|
905
|
+
const filter = queryFilterFromFlags(context);
|
|
906
|
+
const filtered = filterCandidatesForQuery(candidates, latestClusters, filter);
|
|
907
|
+
const ranked = rankCandidates(filtered);
|
|
224
908
|
const clusters = buildClusters(runId, ranked, evidence, new Date().toISOString(), config.clusters);
|
|
225
909
|
await writeClusters(context.paths, runId, clusters);
|
|
226
910
|
const report = buildReportRecord(runId, ranked, clusters);
|
|
@@ -236,6 +920,7 @@ async function reportCommand(context) {
|
|
|
236
920
|
jsonPath: paths.jsonPath,
|
|
237
921
|
candidates: ranked,
|
|
238
922
|
clusters,
|
|
923
|
+
filters: filter,
|
|
239
924
|
evidenceCount: evidence.length,
|
|
240
925
|
}));
|
|
241
926
|
if (!context.json && !context.quiet) {
|
|
@@ -246,8 +931,13 @@ async function reportCommand(context) {
|
|
|
246
931
|
return 0;
|
|
247
932
|
}
|
|
248
933
|
async function nextCommand(context) {
|
|
249
|
-
const candidates =
|
|
250
|
-
|
|
934
|
+
const [candidates, clusters] = await Promise.all([
|
|
935
|
+
readLatestCandidates(context.paths),
|
|
936
|
+
readLatestClusters(context.paths),
|
|
937
|
+
]);
|
|
938
|
+
const filtered = filterCandidatesForQuery(candidates, clusters, queryFilterFromFlags(context));
|
|
939
|
+
const ranked = rankCandidates(filtered);
|
|
940
|
+
const candidate = ranked.find((item) => item.status === "open");
|
|
251
941
|
emit(context.json, ok("next", { candidate: candidate ?? null }));
|
|
252
942
|
if (!context.json && !context.quiet) {
|
|
253
943
|
if (!candidate) {
|
|
@@ -259,6 +949,28 @@ async function nextCommand(context) {
|
|
|
259
949
|
}
|
|
260
950
|
return 0;
|
|
261
951
|
}
|
|
952
|
+
async function listCommand(context) {
|
|
953
|
+
const [candidates, clusters] = await Promise.all([
|
|
954
|
+
readLatestCandidates(context.paths),
|
|
955
|
+
readLatestClusters(context.paths),
|
|
956
|
+
]);
|
|
957
|
+
const filter = queryFilterFromFlags(context);
|
|
958
|
+
const filtered = rankCandidates(filterCandidatesForQuery(candidates, clusters, filter));
|
|
959
|
+
const format = flagString(context.parsed.flags, "format");
|
|
960
|
+
const queue = format === "codex" ? filtered.map(candidateQueueItem) : undefined;
|
|
961
|
+
emit(context.json, ok("list", {
|
|
962
|
+
filters: filter,
|
|
963
|
+
count: filtered.length,
|
|
964
|
+
candidates: filtered,
|
|
965
|
+
...(queue ? { queue } : {}),
|
|
966
|
+
}));
|
|
967
|
+
if (!context.json && !context.quiet) {
|
|
968
|
+
for (const candidate of filtered) {
|
|
969
|
+
console.log(`${candidate.findingId ?? candidate.id} ${candidate.priority} ${candidate.title}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return 0;
|
|
973
|
+
}
|
|
262
974
|
async function showCommand(context) {
|
|
263
975
|
const id = requireCandidateId(context);
|
|
264
976
|
const { candidates, evidence, clusters, runId } = await latestState(context.paths);
|
|
@@ -292,6 +1004,180 @@ async function showCommand(context) {
|
|
|
292
1004
|
}
|
|
293
1005
|
return 0;
|
|
294
1006
|
}
|
|
1007
|
+
async function explainCommand(context) {
|
|
1008
|
+
const id = requireCandidateId(context);
|
|
1009
|
+
const { candidates, evidence } = await latestState(context.paths);
|
|
1010
|
+
const attempt = await readLatestSynthesisAttempt(context.paths);
|
|
1011
|
+
const candidate = candidates.find((item) => item.id === id || item.findingId === id);
|
|
1012
|
+
if (!candidate) {
|
|
1013
|
+
emit(context.json, fail("explain", "candidate_not_found", `Candidate or finding not found: ${id}`));
|
|
1014
|
+
return 1;
|
|
1015
|
+
}
|
|
1016
|
+
const supportingEvidence = evidenceForIds(evidence, candidate.evidenceIds);
|
|
1017
|
+
const validation = validationForCandidate(candidate, attempt);
|
|
1018
|
+
const diagnostics = validation?.diagnostics ?? [];
|
|
1019
|
+
const explanation = {
|
|
1020
|
+
candidate,
|
|
1021
|
+
evidence: supportingEvidence,
|
|
1022
|
+
synthesisAttempt: attempt ? {
|
|
1023
|
+
id: attempt.id,
|
|
1024
|
+
runId: attempt.runId,
|
|
1025
|
+
provider: attempt.provider,
|
|
1026
|
+
model: attempt.model,
|
|
1027
|
+
promptVersion: attempt.promptVersion,
|
|
1028
|
+
promptBytes: attempt.promptBytes,
|
|
1029
|
+
rawCandidateCount: attempt.rawCandidateCount,
|
|
1030
|
+
acceptedCandidateCount: attempt.acceptedCandidateCount,
|
|
1031
|
+
rejectedCandidateCount: attempt.rejectedCandidateCount,
|
|
1032
|
+
evidenceManifest: attempt.evidenceManifest,
|
|
1033
|
+
} : undefined,
|
|
1034
|
+
validation,
|
|
1035
|
+
fixReadiness: candidate.fixReadiness,
|
|
1036
|
+
verification: candidate.verification,
|
|
1037
|
+
diagnostics,
|
|
1038
|
+
};
|
|
1039
|
+
emit(context.json, ok("explain", explanation, diagnostics));
|
|
1040
|
+
if (!context.json && !context.quiet) {
|
|
1041
|
+
printCandidate(candidate);
|
|
1042
|
+
console.log("");
|
|
1043
|
+
console.log("Why this exists:");
|
|
1044
|
+
console.log(` ${candidate.whyItMatters}`);
|
|
1045
|
+
console.log("");
|
|
1046
|
+
console.log("Evidence:");
|
|
1047
|
+
for (const record of supportingEvidence) {
|
|
1048
|
+
console.log(` ${record.id} ${record.kind}: ${record.summary}`);
|
|
1049
|
+
for (const file of record.files) {
|
|
1050
|
+
console.log(` ${formatFileRef(file)}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (validation) {
|
|
1054
|
+
console.log("");
|
|
1055
|
+
console.log(`Validation: ${validation.status} (${validation.id})`);
|
|
1056
|
+
if (validation.diagnostics.length === 0) {
|
|
1057
|
+
console.log(" All cited evidence IDs, file paths, line ranges, and quotes passed validation.");
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
for (const diagnostic of validation.diagnostics) {
|
|
1061
|
+
console.log(` ${diagnostic.code}: ${diagnostic.message}`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (candidate.fixReadiness) {
|
|
1066
|
+
console.log("");
|
|
1067
|
+
console.log("Fix readiness:");
|
|
1068
|
+
console.log(` scope: ${candidate.fixReadiness.minimumFixScope}`);
|
|
1069
|
+
console.log(` regression: ${candidate.fixReadiness.suggestedRegressionTest}`);
|
|
1070
|
+
console.log(` test gap: ${candidate.fixReadiness.whyCurrentTestsMissIt}`);
|
|
1071
|
+
for (const reason of candidate.fixReadiness.confidenceDowngradeReasons) {
|
|
1072
|
+
console.log(` confidence note: ${reason}`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
console.log("");
|
|
1076
|
+
console.log(`Verification: ${candidate.verification.join("; ") || "n/a"}`);
|
|
1077
|
+
}
|
|
1078
|
+
return 0;
|
|
1079
|
+
}
|
|
1080
|
+
async function historyCommand(context) {
|
|
1081
|
+
const id = requireCandidateId(context);
|
|
1082
|
+
const runId = flagString(context.parsed.flags, "run");
|
|
1083
|
+
const [findings, events] = await Promise.all([
|
|
1084
|
+
readFindings(context.paths),
|
|
1085
|
+
readLifecycleEvents(context.paths),
|
|
1086
|
+
]);
|
|
1087
|
+
const candidate = id.startsWith("candidate-")
|
|
1088
|
+
? await candidateForHistoryLookup(context.paths, id, runId)
|
|
1089
|
+
: undefined;
|
|
1090
|
+
const findingId = candidate?.findingId ?? id;
|
|
1091
|
+
const finding = findings.find((item) => item.id === findingId);
|
|
1092
|
+
if (!finding) {
|
|
1093
|
+
emit(context.json, fail("history", "finding_not_found", `Finding not found: ${id}`));
|
|
1094
|
+
return 1;
|
|
1095
|
+
}
|
|
1096
|
+
const history = events.filter((event) => event.findingId === finding.id || event.targetId === finding.id);
|
|
1097
|
+
emit(context.json, ok("history", { finding, candidate, events: history }));
|
|
1098
|
+
if (!context.json && !context.quiet) {
|
|
1099
|
+
console.log(`${finding.id}: ${finding.title}`);
|
|
1100
|
+
for (const event of history) {
|
|
1101
|
+
console.log(`${event.createdAt} ${event.kind}${event.toState ? ` -> ${event.toState}` : ""}`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return 0;
|
|
1105
|
+
}
|
|
1106
|
+
async function revalidateCommand(context) {
|
|
1107
|
+
const target = requireCandidateId(context);
|
|
1108
|
+
const beforeFindings = await readFindings(context.paths);
|
|
1109
|
+
const targetFindings = await resolveRevalidationTargets(context.paths, target, beforeFindings);
|
|
1110
|
+
if (targetFindings.length === 0 && target !== "all") {
|
|
1111
|
+
emit(context.json, fail("revalidate", "finding_not_found", `Finding not found: ${target}`));
|
|
1112
|
+
return 1;
|
|
1113
|
+
}
|
|
1114
|
+
const scan = await executeScan(context, { synthesize: false });
|
|
1115
|
+
const now = new Date().toISOString();
|
|
1116
|
+
const records = [];
|
|
1117
|
+
for (const finding of target === "all" ? beforeFindings : targetFindings) {
|
|
1118
|
+
records.push(await classifyRevalidation({
|
|
1119
|
+
root: context.paths.root,
|
|
1120
|
+
finding,
|
|
1121
|
+
currentCandidates: scan.data.candidates,
|
|
1122
|
+
runId: scan.runId,
|
|
1123
|
+
createdAt: now,
|
|
1124
|
+
}));
|
|
1125
|
+
}
|
|
1126
|
+
if (target === "all" && beforeFindings.length === 0) {
|
|
1127
|
+
records.push(await classifyRevalidation({
|
|
1128
|
+
root: context.paths.root,
|
|
1129
|
+
finding: undefined,
|
|
1130
|
+
currentCandidates: scan.data.candidates,
|
|
1131
|
+
runId: scan.runId,
|
|
1132
|
+
createdAt: now,
|
|
1133
|
+
}));
|
|
1134
|
+
}
|
|
1135
|
+
for (const record of records) {
|
|
1136
|
+
await writeRevalidation(context.paths, record);
|
|
1137
|
+
}
|
|
1138
|
+
const updatedFindings = beforeFindings.map((finding) => {
|
|
1139
|
+
const record = records.find((item) => item.targetId === finding.id);
|
|
1140
|
+
if (!record) {
|
|
1141
|
+
return finding;
|
|
1142
|
+
}
|
|
1143
|
+
const state = revalidationOutcomeToLifecycleState(record.outcome);
|
|
1144
|
+
return {
|
|
1145
|
+
...finding,
|
|
1146
|
+
lifecycleState: state,
|
|
1147
|
+
status: revalidationOutcomeToStatus(record.outcome, finding.status),
|
|
1148
|
+
updatedAt: record.createdAt,
|
|
1149
|
+
};
|
|
1150
|
+
});
|
|
1151
|
+
await writeFindings(context.paths, updatedFindings);
|
|
1152
|
+
await writeLifecycleEvents(context.paths, records.flatMap((record) => (record.targetId
|
|
1153
|
+
? [{
|
|
1154
|
+
schemaVersion,
|
|
1155
|
+
recordType: "lifecycle_event",
|
|
1156
|
+
id: timestampId("event"),
|
|
1157
|
+
targetType: "finding",
|
|
1158
|
+
targetId: record.targetId,
|
|
1159
|
+
findingId: record.targetId,
|
|
1160
|
+
runId: record.runId,
|
|
1161
|
+
kind: "revalidated",
|
|
1162
|
+
toState: record.outcome,
|
|
1163
|
+
command: "revalidate",
|
|
1164
|
+
createdAt: record.createdAt,
|
|
1165
|
+
data: { revalidationId: record.id, outcome: record.outcome },
|
|
1166
|
+
}]
|
|
1167
|
+
: [])));
|
|
1168
|
+
emit(context.json, ok("revalidate", {
|
|
1169
|
+
target,
|
|
1170
|
+
runId: scan.runId,
|
|
1171
|
+
revalidations: records,
|
|
1172
|
+
candidates: scan.data.candidates,
|
|
1173
|
+
}, scan.diagnostics));
|
|
1174
|
+
if (!context.json && !context.quiet) {
|
|
1175
|
+
for (const record of records) {
|
|
1176
|
+
console.log(`${record.targetId ?? "all"}: ${record.outcome}`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return 0;
|
|
1180
|
+
}
|
|
295
1181
|
async function clusterCommand(context) {
|
|
296
1182
|
const id = context.parsed.positional[0];
|
|
297
1183
|
const { candidates, evidence, runId } = await latestState(context.paths);
|
|
@@ -417,6 +1303,24 @@ async function triageCommand(context) {
|
|
|
417
1303
|
createdAt: now,
|
|
418
1304
|
};
|
|
419
1305
|
await writeTriage(context.paths, triage);
|
|
1306
|
+
if (updated.findingId) {
|
|
1307
|
+
await writeLifecycleEvents(context.paths, [{
|
|
1308
|
+
schemaVersion,
|
|
1309
|
+
recordType: "lifecycle_event",
|
|
1310
|
+
id: timestampId("event"),
|
|
1311
|
+
targetType: "finding",
|
|
1312
|
+
targetId: updated.findingId,
|
|
1313
|
+
findingId: updated.findingId,
|
|
1314
|
+
runId: updated.runId,
|
|
1315
|
+
kind: "triaged",
|
|
1316
|
+
fromState: existing.status,
|
|
1317
|
+
toState: updated.status,
|
|
1318
|
+
note: note ?? "",
|
|
1319
|
+
command: "triage",
|
|
1320
|
+
createdAt: now,
|
|
1321
|
+
data: { candidateId: id, triageId: triage.id },
|
|
1322
|
+
}]);
|
|
1323
|
+
}
|
|
420
1324
|
emit(context.json, ok("triage", { candidate: updated, triage }));
|
|
421
1325
|
if (!context.json && !context.quiet) {
|
|
422
1326
|
console.log(`${id}: ${existing.status} -> ${updated.status}`);
|
|
@@ -435,8 +1339,12 @@ async function handoffCommand(context) {
|
|
|
435
1339
|
const supportingEvidence = evidenceForIds(evidence, candidate.evidenceIds);
|
|
436
1340
|
const handoff = buildHandoff(candidate, supportingEvidence, format);
|
|
437
1341
|
const handoffPath = await writeHandoff(context.paths, handoff);
|
|
438
|
-
|
|
1342
|
+
const warnings = handoffFreshnessWarnings(candidate);
|
|
1343
|
+
emit(context.json, ok("handoff", { handoff, path: handoffPath, warnings }));
|
|
439
1344
|
if (!context.json && !context.quiet) {
|
|
1345
|
+
for (const warning of warnings) {
|
|
1346
|
+
console.log(`warning: ${warning}`);
|
|
1347
|
+
}
|
|
440
1348
|
console.log(handoff.content);
|
|
441
1349
|
console.log("");
|
|
442
1350
|
console.log(`Handoff written to ${path.relative(context.paths.root, handoffPath)}`);
|
|
@@ -455,6 +1363,149 @@ async function latestState(paths) {
|
|
|
455
1363
|
]);
|
|
456
1364
|
return { runId, candidates, clusters, evidence };
|
|
457
1365
|
}
|
|
1366
|
+
async function resolveFixTarget(paths, target) {
|
|
1367
|
+
const candidates = await readLatestCandidates(paths);
|
|
1368
|
+
const candidate = candidates.find((item) => item.id === target || item.findingId === target);
|
|
1369
|
+
if (!candidate) {
|
|
1370
|
+
return undefined;
|
|
1371
|
+
}
|
|
1372
|
+
return { findingId: candidate.findingId ?? candidate.id, candidate };
|
|
1373
|
+
}
|
|
1374
|
+
function fixReadinessBlocker(candidate) {
|
|
1375
|
+
if (candidate.confidence === "low") {
|
|
1376
|
+
return { code: "fix_low_confidence", message: "Low-confidence findings must be confirmed before fix execution." };
|
|
1377
|
+
}
|
|
1378
|
+
const lifecycleState = candidate.lifecycleState ?? "open";
|
|
1379
|
+
if (["stale", "fixed", "superseded", "inconclusive", "suppressed"].includes(lifecycleState)) {
|
|
1380
|
+
return { code: "fix_not_current", message: `Finding lifecycle state is ${lifecycleState}; revalidate or choose another finding.` };
|
|
1381
|
+
}
|
|
1382
|
+
if (candidate.risk === "design-needed") {
|
|
1383
|
+
return { code: "fix_ambiguous", message: "Design-needed findings are too ambiguous for guarded fix execution." };
|
|
1384
|
+
}
|
|
1385
|
+
return undefined;
|
|
1386
|
+
}
|
|
1387
|
+
async function latestPlanForTarget(paths, candidateId) {
|
|
1388
|
+
const files = await filesWithExtension(paths.plansDir, "json");
|
|
1389
|
+
const plans = [];
|
|
1390
|
+
for (const file of files) {
|
|
1391
|
+
try {
|
|
1392
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1393
|
+
if (typeof parsed.id === "string" && typeof parsed.targetId === "string") {
|
|
1394
|
+
plans.push({
|
|
1395
|
+
id: parsed.id,
|
|
1396
|
+
targetId: parsed.targetId,
|
|
1397
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
// Ignore malformed historical plan records here; schema validation handles them elsewhere.
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return plans
|
|
1406
|
+
.filter((plan) => plan.targetId === candidateId)
|
|
1407
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
1408
|
+
.at(-1);
|
|
1409
|
+
}
|
|
1410
|
+
async function latestRevalidationForFinding(paths, findingId) {
|
|
1411
|
+
const files = await filesWithExtension(paths.revalidationsDir, "json");
|
|
1412
|
+
const records = [];
|
|
1413
|
+
for (const file of files) {
|
|
1414
|
+
try {
|
|
1415
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1416
|
+
if (parsed.targetId === findingId && typeof parsed.outcome === "string") {
|
|
1417
|
+
records.push({
|
|
1418
|
+
targetId: findingId,
|
|
1419
|
+
outcome: parsed.outcome,
|
|
1420
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
catch {
|
|
1425
|
+
// Ignore malformed historical revalidation records.
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1);
|
|
1429
|
+
}
|
|
1430
|
+
function changedFilesFromPatch(patchContent) {
|
|
1431
|
+
const files = new Set();
|
|
1432
|
+
for (const line of patchContent.split(/\r?\n/)) {
|
|
1433
|
+
const match = line.match(/^\+\+\+ b\/(.+)$/);
|
|
1434
|
+
if (match?.[1] && match[1] !== "/dev/null") {
|
|
1435
|
+
files.add(match[1]);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return [...files].sort();
|
|
1439
|
+
}
|
|
1440
|
+
async function dirtyFiles(root) {
|
|
1441
|
+
try {
|
|
1442
|
+
const { stdout } = await execFileAsync("git", ["status", "--short"], { cwd: root, timeout: 5000 });
|
|
1443
|
+
return stdout.split(/\r?\n/)
|
|
1444
|
+
.map((line) => line.slice(3).trim())
|
|
1445
|
+
.filter(Boolean)
|
|
1446
|
+
.map((line) => line.split(" -> ").at(-1) ?? line)
|
|
1447
|
+
.sort();
|
|
1448
|
+
}
|
|
1449
|
+
catch {
|
|
1450
|
+
return [];
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
function verificationCommandsForFix(context, config, candidate) {
|
|
1454
|
+
const override = flagString(context.parsed.flags, "verification-command");
|
|
1455
|
+
if (override) {
|
|
1456
|
+
return [override];
|
|
1457
|
+
}
|
|
1458
|
+
if (config.fixExecution.verificationCommands.length > 0) {
|
|
1459
|
+
return config.fixExecution.verificationCommands;
|
|
1460
|
+
}
|
|
1461
|
+
return candidate.verification;
|
|
1462
|
+
}
|
|
1463
|
+
async function runFixVerification(paths, attemptId, commandsToRun) {
|
|
1464
|
+
const results = [];
|
|
1465
|
+
for (const [index, command] of commandsToRun.entries()) {
|
|
1466
|
+
const outputPath = path.join(paths.fixesDir, `${attemptId}-verification-${String(index + 1).padStart(2, "0")}.txt`);
|
|
1467
|
+
const result = await execFileAsync("sh", ["-lc", command], { cwd: paths.root, timeout: 120_000 })
|
|
1468
|
+
.then((output) => ({ exitCode: 0, output: `${output.stdout}${output.stderr}` }))
|
|
1469
|
+
.catch((error) => ({
|
|
1470
|
+
exitCode: typeof error === "object" && error && "code" in error && typeof error.code === "number" ? error.code : 1,
|
|
1471
|
+
output: error instanceof Error ? error.message : String(error),
|
|
1472
|
+
}));
|
|
1473
|
+
await writeFile(outputPath, result.output, "utf8");
|
|
1474
|
+
results.push({
|
|
1475
|
+
command,
|
|
1476
|
+
exitCode: result.exitCode,
|
|
1477
|
+
passed: result.exitCode === 0,
|
|
1478
|
+
outputPath,
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
return results;
|
|
1482
|
+
}
|
|
1483
|
+
async function candidateForHistoryLookup(paths, id, runId) {
|
|
1484
|
+
if (runId) {
|
|
1485
|
+
return (await readCandidates(paths, runId)).find((candidate) => candidate.id === id);
|
|
1486
|
+
}
|
|
1487
|
+
return (await readLatestCandidates(paths)).find((candidate) => candidate.id === id);
|
|
1488
|
+
}
|
|
1489
|
+
async function resolveRevalidationTargets(paths, target, findings) {
|
|
1490
|
+
if (target === "all") {
|
|
1491
|
+
return findings;
|
|
1492
|
+
}
|
|
1493
|
+
if (target.startsWith("candidate-")) {
|
|
1494
|
+
const candidate = await candidateForHistoryLookup(paths, target, undefined);
|
|
1495
|
+
return candidate?.findingId
|
|
1496
|
+
? findings.filter((finding) => finding.id === candidate.findingId)
|
|
1497
|
+
: [];
|
|
1498
|
+
}
|
|
1499
|
+
return findings.filter((finding) => finding.id === target);
|
|
1500
|
+
}
|
|
1501
|
+
async function withWriteLock(context, fn) {
|
|
1502
|
+
return withStateWriteLock(context.paths, {
|
|
1503
|
+
command: context.parsed.command ?? "unknown",
|
|
1504
|
+
wait: flagBoolean(context.parsed.flags, "wait-lock"),
|
|
1505
|
+
timeoutMs: numberFlag(context, "lock-timeout-ms") ?? 0,
|
|
1506
|
+
staleAfterMs: staleLockMsFromFlags(context),
|
|
1507
|
+
}, fn);
|
|
1508
|
+
}
|
|
458
1509
|
function requireCandidateId(context) {
|
|
459
1510
|
const id = context.parsed.positional[0];
|
|
460
1511
|
if (!id) {
|
|
@@ -462,6 +1513,19 @@ function requireCandidateId(context) {
|
|
|
462
1513
|
}
|
|
463
1514
|
return id;
|
|
464
1515
|
}
|
|
1516
|
+
function lockStatusPayload(lock) {
|
|
1517
|
+
return {
|
|
1518
|
+
id: lock.record?.id,
|
|
1519
|
+
owner: lock.record?.owner,
|
|
1520
|
+
pid: lock.record?.pid,
|
|
1521
|
+
command: lock.record?.command,
|
|
1522
|
+
statePath: lock.record?.statePath,
|
|
1523
|
+
createdAt: lock.record?.createdAt,
|
|
1524
|
+
stale: lock.stale,
|
|
1525
|
+
reason: lock.reason,
|
|
1526
|
+
recoveryCommand: lock.recoveryCommand,
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
465
1529
|
function emit(json, value) {
|
|
466
1530
|
if (json) {
|
|
467
1531
|
console.log(JSON.stringify(value, null, 2));
|
|
@@ -503,6 +1567,856 @@ function evidenceForIds(evidence, ids) {
|
|
|
503
1567
|
const wanted = new Set(ids);
|
|
504
1568
|
return evidence.filter((item) => wanted.has(item.id));
|
|
505
1569
|
}
|
|
1570
|
+
function validationForCandidate(candidate, attempt) {
|
|
1571
|
+
const validationId = candidate.provenance.validationId;
|
|
1572
|
+
if (!attempt || !validationId) {
|
|
1573
|
+
return undefined;
|
|
1574
|
+
}
|
|
1575
|
+
return attempt.validations.find((validation) => validation.id === validationId);
|
|
1576
|
+
}
|
|
1577
|
+
function formatFileRef(file) {
|
|
1578
|
+
if (file.startLine !== undefined && file.endLine !== undefined) {
|
|
1579
|
+
return `${file.path}:${file.startLine}-${file.endLine}`;
|
|
1580
|
+
}
|
|
1581
|
+
if (file.startLine !== undefined) {
|
|
1582
|
+
return `${file.path}:${file.startLine}`;
|
|
1583
|
+
}
|
|
1584
|
+
return file.path;
|
|
1585
|
+
}
|
|
1586
|
+
function printDiagnostics(diagnostics) {
|
|
1587
|
+
for (const diagnostic of diagnostics) {
|
|
1588
|
+
console.log(`${diagnostic.level}: ${diagnostic.code}: ${diagnostic.message}`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
async function pathExists(filePath) {
|
|
1592
|
+
try {
|
|
1593
|
+
await stat(filePath);
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
catch {
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
async function missingStateDirectories(paths) {
|
|
1601
|
+
const expected = [
|
|
1602
|
+
["runs", paths.runsDir],
|
|
1603
|
+
["findings", paths.findingsDir],
|
|
1604
|
+
["observations", paths.observationsDir],
|
|
1605
|
+
["features", paths.featuresDir],
|
|
1606
|
+
["evidence", paths.evidenceDir],
|
|
1607
|
+
["candidates", paths.candidatesDir],
|
|
1608
|
+
["clusters", paths.clustersDir],
|
|
1609
|
+
["reports", paths.reportsDir],
|
|
1610
|
+
["triage", paths.triageDir],
|
|
1611
|
+
["handoffs", paths.handoffsDir],
|
|
1612
|
+
["plans", paths.plansDir],
|
|
1613
|
+
["lifecycle", paths.lifecycleDir],
|
|
1614
|
+
["revalidations", paths.revalidationsDir],
|
|
1615
|
+
["ci", paths.ciDir],
|
|
1616
|
+
["locks", paths.locksDir],
|
|
1617
|
+
["retention", paths.retentionDir],
|
|
1618
|
+
["fixes", paths.fixesDir],
|
|
1619
|
+
["synthesis", paths.synthesisDir],
|
|
1620
|
+
];
|
|
1621
|
+
const missing = [];
|
|
1622
|
+
for (const [name, dir] of expected) {
|
|
1623
|
+
if (!(await pathExists(dir))) {
|
|
1624
|
+
missing.push(name);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return missing;
|
|
1628
|
+
}
|
|
1629
|
+
async function readConfigForDoctor(paths) {
|
|
1630
|
+
if (!(await pathExists(paths.configPath))) {
|
|
1631
|
+
return {
|
|
1632
|
+
valid: false,
|
|
1633
|
+
error: "Config file is missing.",
|
|
1634
|
+
diagnostics: [{
|
|
1635
|
+
level: "info",
|
|
1636
|
+
code: "config_missing",
|
|
1637
|
+
message: "Run `deepclean init` to create project-local configuration.",
|
|
1638
|
+
}],
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
try {
|
|
1642
|
+
return {
|
|
1643
|
+
valid: true,
|
|
1644
|
+
config: await readConfig(paths),
|
|
1645
|
+
diagnostics: [],
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
catch (error) {
|
|
1649
|
+
return {
|
|
1650
|
+
valid: false,
|
|
1651
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1652
|
+
diagnostics: [{
|
|
1653
|
+
level: "error",
|
|
1654
|
+
code: "config_invalid",
|
|
1655
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1656
|
+
}],
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
async function gitDoctor(root) {
|
|
1661
|
+
try {
|
|
1662
|
+
const { stdout: statusOutput } = await execFileAsync("git", ["status", "--short"], { cwd: root, timeout: 5000 });
|
|
1663
|
+
const branchOutput = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, timeout: 5000 })
|
|
1664
|
+
.then((result) => result.stdout.trim())
|
|
1665
|
+
.catch(() => undefined);
|
|
1666
|
+
const result = {
|
|
1667
|
+
available: true,
|
|
1668
|
+
dirty: statusOutput.trim().length > 0,
|
|
1669
|
+
};
|
|
1670
|
+
if (branchOutput) {
|
|
1671
|
+
result.branch = branchOutput;
|
|
1672
|
+
}
|
|
1673
|
+
return result;
|
|
1674
|
+
}
|
|
1675
|
+
catch (error) {
|
|
1676
|
+
return {
|
|
1677
|
+
available: false,
|
|
1678
|
+
dirty: false,
|
|
1679
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
async function providerDoctor(root, command) {
|
|
1684
|
+
try {
|
|
1685
|
+
await execFileAsync(command, ["--version"], { cwd: root, timeout: 5000 });
|
|
1686
|
+
return { command, available: true };
|
|
1687
|
+
}
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
return {
|
|
1690
|
+
command,
|
|
1691
|
+
available: false,
|
|
1692
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
async function resolveScanScope(context, files) {
|
|
1697
|
+
const since = flagString(context.parsed.flags, "since");
|
|
1698
|
+
const mergeBaseRef = flagString(context.parsed.flags, "merge-base");
|
|
1699
|
+
const includeDirty = flagBoolean(context.parsed.flags, "include-dirty");
|
|
1700
|
+
const paths = csvFlag(context, "paths");
|
|
1701
|
+
const categories = csvFlag(context, "categories");
|
|
1702
|
+
const reviewers = csvFlag(context, "reviewers");
|
|
1703
|
+
const dirtyPaths = includeDirty ? await gitChangedPaths(context.paths.root, ["diff", "--name-only"]) : [];
|
|
1704
|
+
const untrackedPaths = includeDirty ? await gitChangedPaths(context.paths.root, ["ls-files", "--others", "--exclude-standard"]) : [];
|
|
1705
|
+
const committedChangedPaths = mergeBaseRef
|
|
1706
|
+
? await gitMergeBaseChangedPaths(context.paths.root, mergeBaseRef)
|
|
1707
|
+
: since
|
|
1708
|
+
? await gitChangedPaths(context.paths.root, ["diff", "--name-only", `${since}...HEAD`])
|
|
1709
|
+
: [];
|
|
1710
|
+
const changedPaths = uniqueNormalized([
|
|
1711
|
+
...committedChangedPaths,
|
|
1712
|
+
...dirtyPaths,
|
|
1713
|
+
...untrackedPaths,
|
|
1714
|
+
]).filter((filePath) => files.some((file) => file.path === filePath));
|
|
1715
|
+
return {
|
|
1716
|
+
incremental: Boolean(since || mergeBaseRef || includeDirty || paths.length > 0),
|
|
1717
|
+
...(since ? { since } : {}),
|
|
1718
|
+
...(mergeBaseRef ? { mergeBase: mergeBaseRef } : {}),
|
|
1719
|
+
includeDirty,
|
|
1720
|
+
paths,
|
|
1721
|
+
changedPaths,
|
|
1722
|
+
categories,
|
|
1723
|
+
reviewers,
|
|
1724
|
+
onlyExisting: flagBoolean(context.parsed.flags, "only-existing"),
|
|
1725
|
+
newOnly: flagBoolean(context.parsed.flags, "new-only"),
|
|
1726
|
+
dirtyPaths: uniqueNormalized([...dirtyPaths, ...untrackedPaths]),
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
function fileInScope(file, scope) {
|
|
1730
|
+
const pathMatched = scope.paths.length === 0
|
|
1731
|
+
|| scope.paths.some((prefix) => file.path === prefix || file.path.startsWith(`${prefix.replace(/\/$/, "")}/`));
|
|
1732
|
+
if (!pathMatched) {
|
|
1733
|
+
return false;
|
|
1734
|
+
}
|
|
1735
|
+
if (scope.changedPaths.length === 0) {
|
|
1736
|
+
return true;
|
|
1737
|
+
}
|
|
1738
|
+
return scope.changedPaths.includes(file.path);
|
|
1739
|
+
}
|
|
1740
|
+
function filterCandidatesByScanScope(candidates, scope) {
|
|
1741
|
+
return candidates.filter((candidate) => {
|
|
1742
|
+
if (scope.categories.length > 0 && !scope.categories.includes(candidate.category)) {
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
if (scope.onlyExisting && candidate.baselineStatus !== "existing") {
|
|
1746
|
+
return false;
|
|
1747
|
+
}
|
|
1748
|
+
if (scope.newOnly && candidate.baselineStatus !== "new") {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
return true;
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
function markDirtyTreeEvidence(evidence, scope) {
|
|
1755
|
+
if (scope.dirtyPaths.length === 0) {
|
|
1756
|
+
return evidence;
|
|
1757
|
+
}
|
|
1758
|
+
const dirty = new Set(scope.dirtyPaths);
|
|
1759
|
+
return evidence.map((record) => {
|
|
1760
|
+
const dirtyTree = record.files.some((file) => dirty.has(file.path));
|
|
1761
|
+
return dirtyTree
|
|
1762
|
+
? {
|
|
1763
|
+
...record,
|
|
1764
|
+
data: {
|
|
1765
|
+
...record.data,
|
|
1766
|
+
dirtyTree: true,
|
|
1767
|
+
freshness: "dirty",
|
|
1768
|
+
},
|
|
1769
|
+
}
|
|
1770
|
+
: record;
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
function queryFilterFromFlags(context) {
|
|
1774
|
+
const filter = {};
|
|
1775
|
+
const entries = [
|
|
1776
|
+
["status", "status"],
|
|
1777
|
+
["priority", "priority"],
|
|
1778
|
+
["category", "category"],
|
|
1779
|
+
["risk", "risk"],
|
|
1780
|
+
["source", "source"],
|
|
1781
|
+
["theme", "theme"],
|
|
1782
|
+
["path", "path"],
|
|
1783
|
+
["lifecycleState", "lifecycle-state"],
|
|
1784
|
+
["revalidationState", "revalidation-state"],
|
|
1785
|
+
["baselineStatus", "baseline-status"],
|
|
1786
|
+
];
|
|
1787
|
+
for (const [property, flag] of entries) {
|
|
1788
|
+
const value = flagString(context.parsed.flags, flag);
|
|
1789
|
+
if (value) {
|
|
1790
|
+
filter[property] = value;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return filter;
|
|
1794
|
+
}
|
|
1795
|
+
function filterCandidatesForQuery(candidates, clusters, filter) {
|
|
1796
|
+
const themeCandidateIds = filter.theme
|
|
1797
|
+
? new Set(clusters.find((cluster) => cluster.id === filter.theme)?.candidateIds ?? [])
|
|
1798
|
+
: undefined;
|
|
1799
|
+
return candidates.filter((candidate) => {
|
|
1800
|
+
if (filter.status && candidate.status !== filter.status) {
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
if (filter.priority && candidate.priority !== filter.priority.toUpperCase()) {
|
|
1804
|
+
return false;
|
|
1805
|
+
}
|
|
1806
|
+
if (filter.category && candidate.category !== filter.category) {
|
|
1807
|
+
return false;
|
|
1808
|
+
}
|
|
1809
|
+
if (filter.risk && candidate.risk !== filter.risk) {
|
|
1810
|
+
return false;
|
|
1811
|
+
}
|
|
1812
|
+
if (filter.source && candidate.provenance.source !== filter.source) {
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
if (themeCandidateIds && !themeCandidateIds.has(candidate.id)) {
|
|
1816
|
+
return false;
|
|
1817
|
+
}
|
|
1818
|
+
if (filter.path && !candidate.files.some((file) => file.path === filter.path || file.path.startsWith(`${filter.path}/`))) {
|
|
1819
|
+
return false;
|
|
1820
|
+
}
|
|
1821
|
+
if (filter.lifecycleState && (candidate.lifecycleState ?? "open") !== filter.lifecycleState) {
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
if (filter.revalidationState && (candidate.lifecycleState ?? "open") !== filter.revalidationState) {
|
|
1825
|
+
return false;
|
|
1826
|
+
}
|
|
1827
|
+
if (filter.baselineStatus && (candidate.baselineStatus ?? "unknown") !== filter.baselineStatus) {
|
|
1828
|
+
return false;
|
|
1829
|
+
}
|
|
1830
|
+
return true;
|
|
1831
|
+
});
|
|
1832
|
+
}
|
|
1833
|
+
function candidateQueueItem(candidate) {
|
|
1834
|
+
return {
|
|
1835
|
+
findingId: candidate.findingId ?? candidate.id,
|
|
1836
|
+
candidateId: candidate.id,
|
|
1837
|
+
title: candidate.title,
|
|
1838
|
+
priority: candidate.priority,
|
|
1839
|
+
category: candidate.category,
|
|
1840
|
+
risk: candidate.risk,
|
|
1841
|
+
status: candidate.status,
|
|
1842
|
+
lifecycleState: candidate.lifecycleState ?? "open",
|
|
1843
|
+
baselineStatus: candidate.baselineStatus ?? "unknown",
|
|
1844
|
+
problem: candidate.whyItMatters,
|
|
1845
|
+
evidenceIds: candidate.evidenceIds,
|
|
1846
|
+
files: candidate.files,
|
|
1847
|
+
constraints: [
|
|
1848
|
+
"Keep changes scoped to this finding.",
|
|
1849
|
+
"Preserve behavior unless verification proves current behavior is wrong.",
|
|
1850
|
+
],
|
|
1851
|
+
verification: candidate.verification,
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
function handoffFreshnessWarnings(candidate) {
|
|
1855
|
+
const warnings = [];
|
|
1856
|
+
const lifecycleState = candidate.lifecycleState ?? "open";
|
|
1857
|
+
if (["stale", "fixed", "superseded", "inconclusive"].includes(lifecycleState)) {
|
|
1858
|
+
warnings.push(`Finding lifecycle state is ${lifecycleState}; revalidate before assigning implementation work.`);
|
|
1859
|
+
}
|
|
1860
|
+
if (candidate.confidence === "low") {
|
|
1861
|
+
warnings.push("Finding confidence is low; confirm evidence before implementation.");
|
|
1862
|
+
}
|
|
1863
|
+
return warnings;
|
|
1864
|
+
}
|
|
1865
|
+
function ciPolicyFromFlags(context) {
|
|
1866
|
+
const policy = {};
|
|
1867
|
+
for (const key of [
|
|
1868
|
+
"max-p0",
|
|
1869
|
+
"max-p1",
|
|
1870
|
+
"max-p2",
|
|
1871
|
+
"max-p3",
|
|
1872
|
+
"max-new-p0",
|
|
1873
|
+
"max-new-p1",
|
|
1874
|
+
"max-new-p2",
|
|
1875
|
+
"max-new-p3",
|
|
1876
|
+
"max-stale",
|
|
1877
|
+
]) {
|
|
1878
|
+
const value = flagString(context.parsed.flags, key);
|
|
1879
|
+
if (value !== undefined && value !== "") {
|
|
1880
|
+
policy[key] = Number(value);
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
const minConfidence = flagString(context.parsed.flags, "min-confidence");
|
|
1884
|
+
if (minConfidence) {
|
|
1885
|
+
policy["min-confidence"] = minConfidence;
|
|
1886
|
+
}
|
|
1887
|
+
const failCategory = csvFlag(context, "fail-category");
|
|
1888
|
+
if (failCategory.length > 0) {
|
|
1889
|
+
policy["fail-category"] = failCategory;
|
|
1890
|
+
}
|
|
1891
|
+
return policy;
|
|
1892
|
+
}
|
|
1893
|
+
async function buildRetentionManifest(context) {
|
|
1894
|
+
const keepRuns = numberFlag(context, "keep-runs") ?? 5;
|
|
1895
|
+
const keepDays = numberFlag(context, "keep-days");
|
|
1896
|
+
const dryRun = flagBoolean(context.parsed.flags, "dry-run");
|
|
1897
|
+
const now = new Date();
|
|
1898
|
+
const runRecords = await readRunRetentionRecords(context.paths);
|
|
1899
|
+
const sortedRuns = [...runRecords].sort((a, b) => a.id.localeCompare(b.id));
|
|
1900
|
+
const retainedRunIds = new Set();
|
|
1901
|
+
for (const run of sortedRuns.slice(Math.max(0, sortedRuns.length - keepRuns))) {
|
|
1902
|
+
retainedRunIds.add(run.id);
|
|
1903
|
+
}
|
|
1904
|
+
const latest = sortedRuns.at(-1);
|
|
1905
|
+
if (latest) {
|
|
1906
|
+
retainedRunIds.add(latest.id);
|
|
1907
|
+
}
|
|
1908
|
+
if (keepDays !== undefined) {
|
|
1909
|
+
const cutoff = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
|
|
1910
|
+
for (const run of sortedRuns) {
|
|
1911
|
+
if (Date.parse(run.createdAt) >= cutoff) {
|
|
1912
|
+
retainedRunIds.add(run.id);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
const retainedPaths = new Set();
|
|
1917
|
+
const deletePaths = new Set();
|
|
1918
|
+
const blockedPaths = [{
|
|
1919
|
+
path: relativeStatePath(context.paths, context.paths.configPath),
|
|
1920
|
+
reason: "config is never pruned",
|
|
1921
|
+
}];
|
|
1922
|
+
const activeLocks = (await readLockStatuses(context.paths)).filter((lock) => !lock.stale);
|
|
1923
|
+
for (const lock of activeLocks) {
|
|
1924
|
+
blockedPaths.push({
|
|
1925
|
+
path: relativeStatePath(context.paths, lock.filePath),
|
|
1926
|
+
reason: "active locks are never pruned",
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
for (const [dir, extension] of [
|
|
1930
|
+
[context.paths.runsDir, "json"],
|
|
1931
|
+
[context.paths.featuresDir, "json"],
|
|
1932
|
+
[context.paths.evidenceDir, "json"],
|
|
1933
|
+
[context.paths.candidatesDir, "json"],
|
|
1934
|
+
[context.paths.clustersDir, "json"],
|
|
1935
|
+
[context.paths.observationsDir, "json"],
|
|
1936
|
+
[context.paths.synthesisDir, "json"],
|
|
1937
|
+
]) {
|
|
1938
|
+
const files = await filesWithExtension(dir, extension);
|
|
1939
|
+
for (const file of files) {
|
|
1940
|
+
const runId = path.basename(file, `.${extension}`);
|
|
1941
|
+
const relativePath = relativeStatePath(context.paths, file);
|
|
1942
|
+
if (retainedRunIds.has(runId)) {
|
|
1943
|
+
retainedPaths.add(relativePath);
|
|
1944
|
+
}
|
|
1945
|
+
else {
|
|
1946
|
+
deletePaths.add(relativePath);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
const retainedCandidateIds = await candidateIdsForRuns(context.paths, retainedRunIds);
|
|
1951
|
+
await classifyRunLinkedArtifacts(context.paths, context.paths.reportsDir, retainedRunIds, retainedPaths, deletePaths, ["json", "md"]);
|
|
1952
|
+
await classifyRunLinkedArtifacts(context.paths, context.paths.plansDir, retainedRunIds, retainedPaths, deletePaths, ["json"]);
|
|
1953
|
+
await classifyHandoffArtifacts(context.paths, retainedCandidateIds, retainedPaths, deletePaths);
|
|
1954
|
+
return {
|
|
1955
|
+
schemaVersion,
|
|
1956
|
+
recordType: "retention_manifest",
|
|
1957
|
+
id: timestampId("retention"),
|
|
1958
|
+
dryRun,
|
|
1959
|
+
keepRuns,
|
|
1960
|
+
...(keepDays !== undefined ? { keepDays } : {}),
|
|
1961
|
+
deletePaths: [...deletePaths].sort(),
|
|
1962
|
+
retainedPaths: [...retainedPaths].sort(),
|
|
1963
|
+
blockedPaths,
|
|
1964
|
+
privacyNotes: [
|
|
1965
|
+
"Prune never deletes .deepclean/config.json.",
|
|
1966
|
+
"Active locks and latest retained run artifacts are preserved.",
|
|
1967
|
+
"Source-safe sharing should use deepclean scrub or export --source-safe before attaching generated state outside the local machine.",
|
|
1968
|
+
],
|
|
1969
|
+
createdAt: now.toISOString(),
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
async function readRunRetentionRecords(paths) {
|
|
1973
|
+
const files = await filesWithExtension(paths.runsDir, "json");
|
|
1974
|
+
const records = [];
|
|
1975
|
+
for (const file of files) {
|
|
1976
|
+
try {
|
|
1977
|
+
const raw = JSON.parse(await readFile(file, "utf8"));
|
|
1978
|
+
const id = typeof raw.id === "string" ? raw.id : path.basename(file, ".json");
|
|
1979
|
+
const createdAt = typeof raw.completedAt === "string"
|
|
1980
|
+
? raw.completedAt
|
|
1981
|
+
: typeof raw.startedAt === "string"
|
|
1982
|
+
? raw.startedAt
|
|
1983
|
+
: "1970-01-01T00:00:00.000Z";
|
|
1984
|
+
records.push({ id, createdAt });
|
|
1985
|
+
}
|
|
1986
|
+
catch {
|
|
1987
|
+
records.push({ id: path.basename(file, ".json"), createdAt: "1970-01-01T00:00:00.000Z" });
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return records;
|
|
1991
|
+
}
|
|
1992
|
+
async function candidateIdsForRuns(paths, retainedRunIds) {
|
|
1993
|
+
const ids = new Set();
|
|
1994
|
+
for (const runId of retainedRunIds) {
|
|
1995
|
+
for (const candidate of await readCandidates(paths, runId).catch(() => [])) {
|
|
1996
|
+
ids.add(candidate.id);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
return ids;
|
|
2000
|
+
}
|
|
2001
|
+
async function classifyRunLinkedArtifacts(paths, dir, retainedRunIds, retainedPaths, deletePaths, extensions) {
|
|
2002
|
+
const jsonFiles = await filesWithExtension(dir, "json");
|
|
2003
|
+
for (const jsonFile of jsonFiles) {
|
|
2004
|
+
let runId;
|
|
2005
|
+
try {
|
|
2006
|
+
const raw = JSON.parse(await readFile(jsonFile, "utf8"));
|
|
2007
|
+
runId = typeof raw.runId === "string" ? raw.runId : undefined;
|
|
2008
|
+
}
|
|
2009
|
+
catch {
|
|
2010
|
+
runId = undefined;
|
|
2011
|
+
}
|
|
2012
|
+
const id = path.basename(jsonFile, ".json");
|
|
2013
|
+
for (const extension of extensions) {
|
|
2014
|
+
const artifact = path.join(dir, `${id}.${extension}`);
|
|
2015
|
+
if (!(await pathExists(artifact))) {
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
const relativePath = relativeStatePath(paths, artifact);
|
|
2019
|
+
if (runId && retainedRunIds.has(runId)) {
|
|
2020
|
+
retainedPaths.add(relativePath);
|
|
2021
|
+
}
|
|
2022
|
+
else {
|
|
2023
|
+
deletePaths.add(relativePath);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
async function classifyHandoffArtifacts(paths, retainedCandidateIds, retainedPaths, deletePaths) {
|
|
2029
|
+
const jsonFiles = await filesWithExtension(paths.handoffsDir, "json");
|
|
2030
|
+
for (const file of jsonFiles) {
|
|
2031
|
+
let candidateId;
|
|
2032
|
+
try {
|
|
2033
|
+
const raw = JSON.parse(await readFile(file, "utf8"));
|
|
2034
|
+
candidateId = typeof raw.candidateId === "string" ? raw.candidateId : undefined;
|
|
2035
|
+
}
|
|
2036
|
+
catch {
|
|
2037
|
+
candidateId = undefined;
|
|
2038
|
+
}
|
|
2039
|
+
const relativePath = relativeStatePath(paths, file);
|
|
2040
|
+
if (candidateId && retainedCandidateIds.has(candidateId)) {
|
|
2041
|
+
retainedPaths.add(relativePath);
|
|
2042
|
+
}
|
|
2043
|
+
else {
|
|
2044
|
+
deletePaths.add(relativePath);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
async function filesWithExtension(dir, extension) {
|
|
2049
|
+
try {
|
|
2050
|
+
return (await readdir(dir))
|
|
2051
|
+
.filter((file) => file.endsWith(`.${extension}`))
|
|
2052
|
+
.map((file) => path.join(dir, file))
|
|
2053
|
+
.sort();
|
|
2054
|
+
}
|
|
2055
|
+
catch {
|
|
2056
|
+
return [];
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
function relativeStatePath(paths, filePath) {
|
|
2060
|
+
return path.relative(paths.root, filePath).split(path.sep).join("/");
|
|
2061
|
+
}
|
|
2062
|
+
function sourceSafeFile(root, file) {
|
|
2063
|
+
const normalized = path.isAbsolute(file.path)
|
|
2064
|
+
? path.relative(root, file.path)
|
|
2065
|
+
: file.path;
|
|
2066
|
+
return {
|
|
2067
|
+
path: normalized.split(path.sep).join("/"),
|
|
2068
|
+
...(file.startLine ? { startLine: file.startLine } : {}),
|
|
2069
|
+
...(file.endLine ? { endLine: file.endLine } : {}),
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
function evaluateCiPolicy(candidates, policy) {
|
|
2073
|
+
const blockers = new Map();
|
|
2074
|
+
const byPriority = countBy(candidates, (candidate) => candidate.priority.toLowerCase());
|
|
2075
|
+
for (const priority of ["p0", "p1", "p2", "p3"]) {
|
|
2076
|
+
const max = numberPolicy(policy, `max-${priority}`);
|
|
2077
|
+
if (max !== undefined && (byPriority[priority] ?? 0) > max) {
|
|
2078
|
+
for (const candidate of candidates.filter((item) => item.priority.toLowerCase() === priority).slice(max)) {
|
|
2079
|
+
blockers.set(candidate.findingId ?? candidate.id, `max-${priority}`);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
const maxNew = numberPolicy(policy, `max-new-${priority}`);
|
|
2083
|
+
if (maxNew !== undefined) {
|
|
2084
|
+
const newCandidates = candidates.filter((item) => (item.priority.toLowerCase() === priority
|
|
2085
|
+
&& item.baselineStatus === "new"));
|
|
2086
|
+
if (newCandidates.length > maxNew) {
|
|
2087
|
+
for (const candidate of newCandidates.slice(maxNew)) {
|
|
2088
|
+
blockers.set(candidate.findingId ?? candidate.id, `max-new-${priority}`);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
const maxStale = numberPolicy(policy, "max-stale");
|
|
2094
|
+
if (maxStale !== undefined) {
|
|
2095
|
+
const stale = candidates.filter((candidate) => candidate.lifecycleState === "stale" || candidate.status === "stale");
|
|
2096
|
+
if (stale.length > maxStale) {
|
|
2097
|
+
for (const candidate of stale.slice(maxStale)) {
|
|
2098
|
+
blockers.set(candidate.findingId ?? candidate.id, "max-stale");
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
const categories = Array.isArray(policy["fail-category"]) ? policy["fail-category"] : [];
|
|
2103
|
+
for (const candidate of candidates) {
|
|
2104
|
+
if (categories.includes(candidate.category)) {
|
|
2105
|
+
blockers.set(candidate.findingId ?? candidate.id, `fail-category:${candidate.category}`);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
const minConfidence = typeof policy["min-confidence"] === "string" ? policy["min-confidence"] : undefined;
|
|
2109
|
+
if (minConfidence) {
|
|
2110
|
+
const order = ["low", "medium", "high"];
|
|
2111
|
+
const minimum = order.indexOf(minConfidence);
|
|
2112
|
+
if (minimum >= 0) {
|
|
2113
|
+
for (const candidate of candidates) {
|
|
2114
|
+
if (order.indexOf(candidate.confidence) < minimum) {
|
|
2115
|
+
blockers.set(candidate.findingId ?? candidate.id, `min-confidence:${minConfidence}`);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return {
|
|
2121
|
+
blockingFindingIds: [...blockers.keys()].sort(),
|
|
2122
|
+
reasons: [...blockers.entries()].map(([findingId, reason]) => ({ findingId, reason })),
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
async function writeCiArtifacts(context, scan, gate) {
|
|
2126
|
+
const artifactPaths = {};
|
|
2127
|
+
const output = flagString(context.parsed.flags, "output");
|
|
2128
|
+
if (output) {
|
|
2129
|
+
const markdownPath = path.resolve(context.paths.root, output);
|
|
2130
|
+
await mkdir(path.dirname(markdownPath), { recursive: true });
|
|
2131
|
+
await writeFile(markdownPath, renderCiMarkdown(scan, gate), "utf8");
|
|
2132
|
+
artifactPaths.markdown = markdownPath;
|
|
2133
|
+
}
|
|
2134
|
+
const sarif = flagString(context.parsed.flags, "sarif");
|
|
2135
|
+
if (sarif) {
|
|
2136
|
+
const sarifPath = path.resolve(context.paths.root, sarif);
|
|
2137
|
+
await mkdir(path.dirname(sarifPath), { recursive: true });
|
|
2138
|
+
await writeFile(sarifPath, JSON.stringify(renderCiSarif(scan.candidates), null, 2) + "\n", "utf8");
|
|
2139
|
+
artifactPaths.sarif = sarifPath;
|
|
2140
|
+
}
|
|
2141
|
+
return artifactPaths;
|
|
2142
|
+
}
|
|
2143
|
+
function renderCiMarkdown(scan, gate) {
|
|
2144
|
+
return [
|
|
2145
|
+
"# Deepclean CI",
|
|
2146
|
+
"",
|
|
2147
|
+
`Run: ${scan.runId}`,
|
|
2148
|
+
`Candidates: ${scan.candidateCount}`,
|
|
2149
|
+
`Blocking: ${gate.blockingFindingIds.length}`,
|
|
2150
|
+
"",
|
|
2151
|
+
"## Blocking Findings",
|
|
2152
|
+
"",
|
|
2153
|
+
...(gate.reasons.length > 0
|
|
2154
|
+
? gate.reasons.map((reason) => `- ${reason.findingId}: ${reason.reason}`)
|
|
2155
|
+
: ["None"]),
|
|
2156
|
+
"",
|
|
2157
|
+
].join("\n");
|
|
2158
|
+
}
|
|
2159
|
+
function renderCiSarif(candidates) {
|
|
2160
|
+
return {
|
|
2161
|
+
version: "2.1.0",
|
|
2162
|
+
runs: [{
|
|
2163
|
+
tool: { driver: { name: "Deepclean" } },
|
|
2164
|
+
results: candidates.map((candidate) => ({
|
|
2165
|
+
ruleId: `deepclean/${candidate.category}`,
|
|
2166
|
+
level: candidate.priority === "P0" || candidate.priority === "P1" ? "warning" : "note",
|
|
2167
|
+
message: { text: `${candidate.id}: ${candidate.title}` },
|
|
2168
|
+
locations: candidate.files.slice(0, 1).map((file) => ({
|
|
2169
|
+
physicalLocation: {
|
|
2170
|
+
artifactLocation: { uri: file.path },
|
|
2171
|
+
region: { startLine: file.startLine ?? 1, endLine: file.endLine ?? file.startLine ?? 1 },
|
|
2172
|
+
},
|
|
2173
|
+
})),
|
|
2174
|
+
properties: {
|
|
2175
|
+
findingId: candidate.findingId,
|
|
2176
|
+
priority: candidate.priority,
|
|
2177
|
+
confidence: candidate.confidence,
|
|
2178
|
+
baselineStatus: candidate.baselineStatus,
|
|
2179
|
+
},
|
|
2180
|
+
})),
|
|
2181
|
+
}],
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
function numberPolicy(policy, key) {
|
|
2185
|
+
const value = policy[key];
|
|
2186
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
2187
|
+
}
|
|
2188
|
+
async function gitMergeBaseChangedPaths(root, ref) {
|
|
2189
|
+
try {
|
|
2190
|
+
const { stdout } = await execFileAsync("git", ["merge-base", ref, "HEAD"], { cwd: root, timeout: 5000 });
|
|
2191
|
+
const mergeBase = stdout.trim();
|
|
2192
|
+
return mergeBase ? gitChangedPaths(root, ["diff", "--name-only", `${mergeBase}...HEAD`]) : [];
|
|
2193
|
+
}
|
|
2194
|
+
catch {
|
|
2195
|
+
return [];
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
async function gitChangedPaths(root, args) {
|
|
2199
|
+
try {
|
|
2200
|
+
const { stdout } = await execFileAsync("git", args, { cwd: root, timeout: 5000 });
|
|
2201
|
+
return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2202
|
+
}
|
|
2203
|
+
catch {
|
|
2204
|
+
return [];
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
function csvFlag(context, key) {
|
|
2208
|
+
const value = flagString(context.parsed.flags, key);
|
|
2209
|
+
if (!value) {
|
|
2210
|
+
return [];
|
|
2211
|
+
}
|
|
2212
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
2213
|
+
}
|
|
2214
|
+
function staleLockMsFromFlags(context) {
|
|
2215
|
+
return numberFlag(context, "stale-lock-ms");
|
|
2216
|
+
}
|
|
2217
|
+
function providerRuntimeControls(context, config) {
|
|
2218
|
+
const provider = flagString(context.parsed.flags, "provider");
|
|
2219
|
+
if (provider && provider !== "codex") {
|
|
2220
|
+
throw new Error(`Unsupported provider: ${provider}. Only codex is currently supported.`);
|
|
2221
|
+
}
|
|
2222
|
+
const timeoutSeconds = numberFlag(context, "timeout");
|
|
2223
|
+
const timeoutMs = numberFlag(context, "timeout-ms") ?? (timeoutSeconds !== undefined ? timeoutSeconds * 1000 : config.reviewSynthesis.timeoutMs);
|
|
2224
|
+
const privacyMode = privacyModeFromFlag(flagString(context.parsed.flags, "privacy-mode"))
|
|
2225
|
+
?? config.reviewSynthesis.privacyMode;
|
|
2226
|
+
const offline = flagBoolean(context.parsed.flags, "offline")
|
|
2227
|
+
|| flagBoolean(context.parsed.flags, "local-only")
|
|
2228
|
+
|| flagBoolean(context.parsed.flags, "evidence-only")
|
|
2229
|
+
|| config.reviewSynthesis.offline
|
|
2230
|
+
|| privacyMode === "local-only";
|
|
2231
|
+
const excerptBudget = numberFlag(context, "excerpt-budget") ?? config.reviewSynthesis.excerptBudget;
|
|
2232
|
+
const allowSourceInModel = !offline && (flagBoolean(context.parsed.flags, "allow-source-in-model")
|
|
2233
|
+
|| config.privacy.allowSourceInModel
|
|
2234
|
+
|| privacyMode === "source-ok") && excerptBudget > 0;
|
|
2235
|
+
const runtime = {
|
|
2236
|
+
provider: "codex",
|
|
2237
|
+
command: config.reviewSynthesis.command,
|
|
2238
|
+
timeoutMs,
|
|
2239
|
+
retries: numberFlag(context, "retries") ?? config.reviewSynthesis.retries,
|
|
2240
|
+
rpm: numberFlag(context, "rpm") ?? config.reviewSynthesis.rpm,
|
|
2241
|
+
concurrency: numberFlag(context, "concurrency") ?? config.reviewSynthesis.concurrency,
|
|
2242
|
+
tokenBudget: numberFlag(context, "token-budget") ?? config.reviewSynthesis.tokenBudget,
|
|
2243
|
+
excerptBudget,
|
|
2244
|
+
offline,
|
|
2245
|
+
privacyMode,
|
|
2246
|
+
allowSourceInModel,
|
|
2247
|
+
};
|
|
2248
|
+
const model = flagString(context.parsed.flags, "model") ?? config.reviewSynthesis.model;
|
|
2249
|
+
if (model) {
|
|
2250
|
+
runtime.model = model;
|
|
2251
|
+
}
|
|
2252
|
+
const effort = flagString(context.parsed.flags, "effort") ?? config.reviewSynthesis.effort;
|
|
2253
|
+
if (effort) {
|
|
2254
|
+
runtime.effort = effort;
|
|
2255
|
+
}
|
|
2256
|
+
return runtime;
|
|
2257
|
+
}
|
|
2258
|
+
function synthesisDisabledByPolicy(context, config) {
|
|
2259
|
+
const privacyMode = privacyModeFromFlag(flagString(context.parsed.flags, "privacy-mode"))
|
|
2260
|
+
?? config.reviewSynthesis.privacyMode;
|
|
2261
|
+
return flagBoolean(context.parsed.flags, "offline")
|
|
2262
|
+
|| flagBoolean(context.parsed.flags, "local-only")
|
|
2263
|
+
|| flagBoolean(context.parsed.flags, "evidence-only")
|
|
2264
|
+
|| config.reviewSynthesis.offline
|
|
2265
|
+
|| privacyMode === "local-only";
|
|
2266
|
+
}
|
|
2267
|
+
const requiredSynthesisFailureCodes = new Set([
|
|
2268
|
+
"codex_provider_unavailable",
|
|
2269
|
+
"codex_synthesis_timeout",
|
|
2270
|
+
"codex_synthesis_failed",
|
|
2271
|
+
"codex_synthesis_error",
|
|
2272
|
+
]);
|
|
2273
|
+
function requiredSynthesisFailure(scan) {
|
|
2274
|
+
if (!scan.data.synthesis.requested) {
|
|
2275
|
+
return {
|
|
2276
|
+
level: "error",
|
|
2277
|
+
code: "ci_synthesis_required",
|
|
2278
|
+
message: "CI policy requires synthesis, but the scan did not run provider synthesis.",
|
|
2279
|
+
adapter: "codex-synthesis",
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
const diagnostic = scan.diagnostics.find((item) => (item.adapter === "codex-synthesis"
|
|
2283
|
+
&& requiredSynthesisFailureCodes.has(item.code)));
|
|
2284
|
+
if (!diagnostic) {
|
|
2285
|
+
return undefined;
|
|
2286
|
+
}
|
|
2287
|
+
return {
|
|
2288
|
+
...diagnostic,
|
|
2289
|
+
level: "error",
|
|
2290
|
+
message: `CI policy requires synthesis, but provider synthesis failed: ${diagnostic.message}`,
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
function sameSynthesisFailure(diagnostic, failure) {
|
|
2294
|
+
return diagnostic.adapter === failure.adapter
|
|
2295
|
+
&& diagnostic.code === failure.code
|
|
2296
|
+
&& requiredSynthesisFailureCodes.has(diagnostic.code);
|
|
2297
|
+
}
|
|
2298
|
+
function providerRuntimeSummary(runtime) {
|
|
2299
|
+
return {
|
|
2300
|
+
provider: runtime.provider,
|
|
2301
|
+
model: runtime.model,
|
|
2302
|
+
effort: runtime.effort,
|
|
2303
|
+
timeoutMs: runtime.timeoutMs,
|
|
2304
|
+
retries: runtime.retries,
|
|
2305
|
+
rpm: runtime.rpm,
|
|
2306
|
+
concurrency: runtime.concurrency,
|
|
2307
|
+
tokenBudget: runtime.tokenBudget,
|
|
2308
|
+
excerptBudget: runtime.excerptBudget,
|
|
2309
|
+
offline: runtime.offline,
|
|
2310
|
+
privacyMode: runtime.privacyMode,
|
|
2311
|
+
allowSourceInModel: runtime.allowSourceInModel,
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
function privacyModeFromFlag(value) {
|
|
2315
|
+
if (value === "local-only" || value === "metadata" || value === "source-ok") {
|
|
2316
|
+
return value;
|
|
2317
|
+
}
|
|
2318
|
+
return undefined;
|
|
2319
|
+
}
|
|
2320
|
+
function numberFlag(context, key) {
|
|
2321
|
+
const value = flagString(context.parsed.flags, key);
|
|
2322
|
+
if (value === undefined || value === "") {
|
|
2323
|
+
return undefined;
|
|
2324
|
+
}
|
|
2325
|
+
const parsed = Number(value);
|
|
2326
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
2327
|
+
}
|
|
2328
|
+
function uniqueNormalized(values) {
|
|
2329
|
+
return [...new Set(values.map((value) => value.split(path.sep).join("/")))].sort();
|
|
2330
|
+
}
|
|
2331
|
+
async function supportedSurfaces(root) {
|
|
2332
|
+
const checks = [
|
|
2333
|
+
["node", "package.json"],
|
|
2334
|
+
["typescript", "tsconfig.json"],
|
|
2335
|
+
["python", "pyproject.toml"],
|
|
2336
|
+
["python", "requirements.txt"],
|
|
2337
|
+
["make", "Makefile"],
|
|
2338
|
+
];
|
|
2339
|
+
const found = new Set();
|
|
2340
|
+
for (const [surface, file] of checks) {
|
|
2341
|
+
if (await pathExists(path.join(root, file))) {
|
|
2342
|
+
found.add(surface);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
return [...found].sort();
|
|
2346
|
+
}
|
|
2347
|
+
async function stateArtifactCounts(paths) {
|
|
2348
|
+
const dirs = [
|
|
2349
|
+
["runs", paths.runsDir],
|
|
2350
|
+
["findings", paths.findingsDir],
|
|
2351
|
+
["observations", paths.observationsDir],
|
|
2352
|
+
["features", paths.featuresDir],
|
|
2353
|
+
["evidence", paths.evidenceDir],
|
|
2354
|
+
["candidates", paths.candidatesDir],
|
|
2355
|
+
["clusters", paths.clustersDir],
|
|
2356
|
+
["reports", paths.reportsDir],
|
|
2357
|
+
["triage", paths.triageDir],
|
|
2358
|
+
["handoffs", paths.handoffsDir],
|
|
2359
|
+
["plans", paths.plansDir],
|
|
2360
|
+
["lifecycle", paths.lifecycleDir],
|
|
2361
|
+
["revalidations", paths.revalidationsDir],
|
|
2362
|
+
["ci", paths.ciDir],
|
|
2363
|
+
["locks", paths.locksDir],
|
|
2364
|
+
["retention", paths.retentionDir],
|
|
2365
|
+
["fixes", paths.fixesDir],
|
|
2366
|
+
["synthesis", paths.synthesisDir],
|
|
2367
|
+
];
|
|
2368
|
+
const counts = {};
|
|
2369
|
+
for (const [name, dir] of dirs) {
|
|
2370
|
+
counts[name] = await countJsonFiles(dir);
|
|
2371
|
+
}
|
|
2372
|
+
return counts;
|
|
2373
|
+
}
|
|
2374
|
+
async function countJsonFiles(dir) {
|
|
2375
|
+
try {
|
|
2376
|
+
const files = await readdir(dir);
|
|
2377
|
+
return files.filter((file) => file.endsWith(".json")).length;
|
|
2378
|
+
}
|
|
2379
|
+
catch {
|
|
2380
|
+
return 0;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
function countBy(items, keyFor) {
|
|
2384
|
+
const counts = {};
|
|
2385
|
+
for (const item of items) {
|
|
2386
|
+
const key = keyFor(item);
|
|
2387
|
+
counts[key] = (counts[key] ?? 0) + 1;
|
|
2388
|
+
}
|
|
2389
|
+
return counts;
|
|
2390
|
+
}
|
|
2391
|
+
function revalidationOutcomeToLifecycleState(outcome) {
|
|
2392
|
+
switch (outcome) {
|
|
2393
|
+
case "fixed":
|
|
2394
|
+
return "fixed";
|
|
2395
|
+
case "stale":
|
|
2396
|
+
return "stale";
|
|
2397
|
+
case "superseded":
|
|
2398
|
+
return "superseded";
|
|
2399
|
+
case "inconclusive":
|
|
2400
|
+
return "inconclusive";
|
|
2401
|
+
case "changed":
|
|
2402
|
+
case "unchanged":
|
|
2403
|
+
return "open";
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
function revalidationOutcomeToStatus(outcome, fallback) {
|
|
2407
|
+
switch (outcome) {
|
|
2408
|
+
case "fixed":
|
|
2409
|
+
return "fixed";
|
|
2410
|
+
case "stale":
|
|
2411
|
+
return "stale";
|
|
2412
|
+
case "superseded":
|
|
2413
|
+
return "superseded";
|
|
2414
|
+
case "inconclusive":
|
|
2415
|
+
case "changed":
|
|
2416
|
+
case "unchanged":
|
|
2417
|
+
return fallback === "fixed" || fallback === "stale" || fallback === "superseded" ? "open" : fallback;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
506
2420
|
async function packageVersion() {
|
|
507
2421
|
try {
|
|
508
2422
|
const packagePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|