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