@gobing-ai/spur 0.3.95 → 0.3.97
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/.claude-plugin/marketplace.json +1 -1
- package/config/plugin-scripts.json +0 -54
- package/config/rules/boundary/sp-script-placement.yaml +19 -0
- package/config/rules/strict/runtime-boundaries.yaml +6 -0
- package/config/rules/structure/test-location.yaml +2 -0
- package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
- package/config/rules/typescript/output-boundaries.yaml +15 -2
- package/config/script-placement-baseline.json +51 -0
- package/config/templates/AGENTS.md +3 -3
- package/config/workflows/feature-verification.yaml +1 -1
- package/config/workflows/history-anatomy.yaml +23 -11
- package/config/workflows/idea-pipeline.yaml +80 -85
- package/config/workflows/pr-review.yaml +18 -9
- package/config/workflows/task-pipeline.yaml +42 -95
- package/config/workflows/wrapup-pipeline.yaml +91 -38
- package/package.json +2 -2
- package/plugins/sp/README.md +14 -10
- package/plugins/sp/agents/super-reviewer.md +43 -18
- package/plugins/sp/commands/dev-fixgha.md +83 -0
- package/plugins/sp/commands/dev-gitmsg.md +8 -6
- package/plugins/sp/commands/dev-gtd.md +2 -2
- package/plugins/sp/commands/dev-idea.md +22 -12
- package/plugins/sp/commands/dev-job-dump.md +29 -0
- package/plugins/sp/commands/dev-job-resume.md +29 -0
- package/plugins/sp/commands/dev-plan.md +8 -9
- package/plugins/sp/commands/dev-review.md +22 -13
- package/plugins/sp/commands/dev-verifyall.md +1 -1
- package/plugins/sp/commands/spur-init.md +2 -2
- package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
- package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +5 -4
- package/plugins/sp/lib/inline-run.generated.d.mts +11 -0
- package/plugins/sp/lib/inline-run.generated.mjs +39 -13
- package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
- package/plugins/sp/lib/quality-gate.generated.mjs +445 -0
- package/plugins/sp/lib/residual-scan.generated.d.mts +63 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +226 -0
- package/plugins/sp/lib/spur-bin.ts +36 -0
- package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
- package/plugins/sp/lib/step-profile.generated.mjs +174 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +3 -3
- package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
- package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
- package/plugins/sp/scripts/history-anatomy-cache.mjs +20 -19
- package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
- package/plugins/sp/scripts/inline-run-setup.mjs +85 -320
- package/plugins/sp/scripts/inline-run-setup.ts +104 -667
- package/plugins/sp/scripts/quality-gate.mjs +46 -24
- package/plugins/sp/scripts/quality-gate.ts +13 -659
- package/plugins/sp/scripts/residual-scan.mjs +179 -174
- package/plugins/sp/scripts/residual-scan.ts +125 -517
- package/plugins/sp/scripts/script-root.mjs +5 -1
- package/plugins/sp/scripts/script-root.ts +5 -1
- package/plugins/sp/scripts/workflow-step-profile.mjs +25 -17
- package/plugins/sp/scripts/workflow-step-profile.ts +21 -315
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +8 -2
- package/plugins/sp/scripts/wrapup-drift-probe.ts +3 -2
- package/plugins/sp/scripts/wrapup-steps.mjs +72 -33
- package/plugins/sp/scripts/wrapup-steps.ts +128 -37
- package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
- package/plugins/sp/skills/code-verification/SKILL.md +34 -9
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +3 -3
- package/plugins/sp/skills/functional-review/SKILL.md +7 -4
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
- package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
- package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
- package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
- package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
- package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
- package/plugins/sp/skills/spur-cli/references/features.md +17 -6
- package/plugins/sp/skills/spur-cli/references/init.md +17 -16
- package/plugins/sp/skills/spur-cli/references/self.md +3 -2
- package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
- package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +21 -6
- package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
- package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -10
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +165 -75
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +7 -5
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +84 -39
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +8 -12
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +40 -21
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +72 -15
- package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
- package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
- package/spur.js +4461 -2085
- package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.Bmv5WkJ9.js} +1 -1
- package/web/_astro/BoardApp.vveLOdDq.js +322 -0
- package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.IKc3uDYV.js} +1 -1
- package/web/_astro/{arc.CyjRvNMY.js → arc.C63Ufg35.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
- package/web/_astro/channel.BKCmJpWM.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
- package/web/_astro/index.CbNz17Sx.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
- package/web/_astro/{linear.zRsuuDTE.js → linear.BATV4RXu.js} +1 -1
- package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DzkwM3VX.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
- package/web/index.html +2 -2
- package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
- package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
- package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -301
- package/plugins/sp/scripts/feature-sync-bounded.ts +0 -481
- package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
- package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
- package/plugins/sp/scripts/record-feature-sync.ts +0 -84
- package/plugins/sp/scripts/script-contract-check.ts +0 -506
- package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
- package/plugins/sp/scripts/task-evidence-precheck.ts +0 -189
- package/plugins/sp/scripts/task-size-precheck.ts +0 -212
- package/plugins/sp/scripts/transition-shim-check.ts +0 -238
- package/plugins/sp/scripts/validate-commands.ts +0 -689
- package/plugins/sp/scripts/validate-flag-contracts.ts +0 -890
- package/plugins/sp/scripts/verify-answer-lint.ts +0 -549
- package/web/_astro/BoardApp.BxJuwD7I.js +0 -191
- package/web/_astro/channel.CI6N_tCg.js +0 -1
- package/web/_astro/index.CENnIEqT.css +0 -1
|
@@ -1,180 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
* residual-scan — deterministic task-leftover scanner behind feature F96 (ADR-071).
|
|
4
|
-
*
|
|
5
|
-
* Contract owner: docs/design/task-residual-sweep.md. Model-free detection; writes only
|
|
6
|
-
* under `.spur/run/` (observe-only). Four modes:
|
|
7
|
-
* scan <wbs> write `.spur/run/<wbs>-residuals.json`
|
|
8
|
-
* fold <wbs> fold blocking residuals into `<wbs>-verdict.json` (residual-sweep check,
|
|
9
|
-
* PASS→PARTIAL downgrade) and `<wbs>-test-gate.findings`
|
|
10
|
-
* settle <wbs> file one follow-up task for deferrables; delete `/tmp/<wbs>-*` residue
|
|
11
|
-
* report <wbs> write `.spur/run/<wbs>-residual-report.md` when the verdict's
|
|
12
|
-
* residual-sweep check failed; print the recovery line
|
|
13
|
-
*
|
|
14
|
-
* Environment: `spurBin` (CLI resolution, mirrors wrapup-steps `spurCommand`).
|
|
15
|
-
* Pure helpers (parsers, classify, foldVerdict, renderReport) are exported for unit
|
|
16
|
-
* testing; node/bun builtin imports only (plugin standalone contract).
|
|
17
|
-
*/
|
|
18
|
-
|
|
2
|
+
/** F96 residual IO glue. Pure logic: residual-scan.generated.mjs; contract: task-residual-sweep.md. */
|
|
19
3
|
import { spawnSync } from 'node:child_process';
|
|
20
|
-
import
|
|
21
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
22
5
|
import { tmpdir } from 'node:os';
|
|
23
6
|
import { join } from 'node:path';
|
|
24
7
|
import { getEnvVars } from '../lib/env';
|
|
8
|
+
import * as core from '../lib/residual-scan.generated.mjs';
|
|
9
|
+
import { spurCommand } from '../lib/spur-bin';
|
|
25
10
|
|
|
11
|
+
export * from '../lib/residual-scan.generated.mjs';
|
|
26
12
|
export const RESIDUAL_SCAN_USAGE =
|
|
27
13
|
'usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]';
|
|
28
|
-
|
|
29
|
-
export type
|
|
30
|
-
export type
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
location: string;
|
|
38
|
-
text: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface ResidualArtifact {
|
|
42
|
-
wbs: string;
|
|
43
|
-
base: string | null;
|
|
44
|
-
scanned: Record<ResidualCategory, boolean>;
|
|
45
|
-
items: ResidualItem[];
|
|
46
|
-
counts: { blocking: number; deferrable: number; advisory: number; housekeeping: number };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface ScanEnv {
|
|
50
|
-
spurBin?: string;
|
|
51
|
-
[key: string]: string | undefined;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ScanIo {
|
|
55
|
-
out: (line: string) => void;
|
|
56
|
-
err: (line: string) => void;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface ScanOptions {
|
|
60
|
-
cwd?: string;
|
|
61
|
-
/** Injectable output sinks; defaults to process streams (tests inject no-ops to keep reporter output clean). */
|
|
62
|
-
io?: ScanIo;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
|
|
66
|
-
const PRIORITY_PATTERN = /^P[1-4]/;
|
|
67
|
-
// Placeholder "no finding" cells, optionally with a trailing "(…)" note; "None of X…" is a real finding.
|
|
68
|
-
const NONE_FINDING = /^(none( found)?|no (findings?|issues?)( found)?|—)\s*(\(.*\))?\.?$/i;
|
|
69
|
-
const DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
|
|
70
|
-
const RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
|
|
71
|
-
const DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
|
|
72
|
-
const ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
|
|
73
|
-
/** `path:12-18` range anchor → single-line `path:12`. */
|
|
74
|
-
const RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
|
|
75
|
-
const EXCLUDED_PATHS = ['docs/tasks', 'docs/features/', '.spur/'];
|
|
76
|
-
export const ALLOW_PRAGMA = 'residual-scan:allow';
|
|
77
|
-
|
|
78
|
-
/** `spurBin` splits on whitespace into command + prefix args (same as wrapup-steps). */
|
|
79
|
-
export function spurCommand(spurBin: string | undefined): { cmd: string; prefix: string[] } {
|
|
80
|
-
const parts = (spurBin ?? 'spur')
|
|
81
|
-
.trim()
|
|
82
|
-
.split(/\s+/)
|
|
83
|
-
.filter((p) => p.length > 0);
|
|
84
|
-
return { cmd: parts[0] ?? 'spur', prefix: parts.slice(1) };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function run(cmd: string, args: string[], cwd: string): { status: number; stdout: string } {
|
|
14
|
+
export type ScanEnv = { spurBin?: string } & Record<string, string | undefined>;
|
|
15
|
+
export type ScanIo = { out: (line: string) => void; err: (line: string) => void };
|
|
16
|
+
export type ScanOptions = { cwd?: string; io?: ScanIo };
|
|
17
|
+
type RunResult = { status: number; stdout: string };
|
|
18
|
+
type ParsedArgs = { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string };
|
|
19
|
+
type ModeFn = (o: ParsedArgs, e: ScanEnv, i: ScanIo) => number;
|
|
20
|
+
type ScanTask = { content: string; featureId: string };
|
|
21
|
+
type VerdictFile = { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> };
|
|
22
|
+
function run(cmd: string, args: string[], cwd: string): RunResult {
|
|
88
23
|
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
|
89
24
|
if (result.error !== undefined) return { status: result.status ?? 1, stdout: '' };
|
|
90
25
|
return { status: result.status ?? 1, stdout: result.stdout ?? '' };
|
|
91
26
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const hex = createHash('sha256').update(`${location}${normalized}`).digest('hex');
|
|
96
|
-
return `${category}:${hex.slice(0, 8)}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** `path:12-18` → `path:12` (anchor normalization from the design doc). */
|
|
100
|
-
export function normalizeAnchor(location: string): string {
|
|
101
|
-
return location.replace(RANGE_ANCHOR, '$1:$2');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Normalize a finding cell to an anchor: Location column, else first backticked `path:line`. */
|
|
105
|
-
export function locationOf(locationCell: string, finding: string): string {
|
|
106
|
-
const cell = locationCell.trim().replace(/`/g, '');
|
|
107
|
-
if (cell.length > 0 && cell !== '—') return normalizeAnchor(cell);
|
|
108
|
-
const backtick = finding.match(/`([^`]+)`/)?.[1];
|
|
109
|
-
return backtick === undefined ? '' : normalizeAnchor(backtick);
|
|
27
|
+
function spur(env: ScanEnv, spurBinFlag: string | undefined, args: string[], cwd: string): RunResult {
|
|
28
|
+
const { cmd, prefix } = spurCommand(spurBinFlag ?? env.spurBin);
|
|
29
|
+
return run(cmd, [...prefix, ...args], cwd);
|
|
110
30
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* `RESOLVED`/`DONE` rows are dropped; `DEFER` rows carry the cell as an in-table deferral reason.
|
|
117
|
-
*/
|
|
118
|
-
export function parseReviewFindings(
|
|
119
|
-
taskContent: string,
|
|
120
|
-
): Array<{ priority: string; location: string; text: string; deferral?: string }> {
|
|
121
|
-
const section = taskContent.split(/^### Review\b/m)[1];
|
|
122
|
-
if (section === undefined) return [];
|
|
123
|
-
const body = section.split(/^### /m)[0] ?? '';
|
|
124
|
-
const out: Array<{ priority: string; location: string; text: string; deferral?: string }> = [];
|
|
125
|
-
const lines = body.split('\n');
|
|
126
|
-
for (let i = 0; i < lines.length; i++) {
|
|
127
|
-
const line = lines[i];
|
|
128
|
-
if (line === undefined || !line.trimStart().startsWith('|')) continue;
|
|
129
|
-
const header = splitRow(line);
|
|
130
|
-
const priorityCol = header.findIndex((h) => h.trim() === 'Priority');
|
|
131
|
-
if (priorityCol === -1) {
|
|
132
|
-
// Not a Priority table; skip its separator + body rows.
|
|
133
|
-
while (i + 1 < lines.length && lines[i + 1]?.trimStart().startsWith('|')) i++;
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
const findingCol = header.findIndex((h) => h.trim() === 'Finding');
|
|
137
|
-
const locationCol = header.findIndex((h) => h.trim() === 'Location');
|
|
138
|
-
const dispositionCol = header.findIndex((h) => DISPOSITION_HEADER.test(h.trim()));
|
|
139
|
-
i++; // skip header
|
|
140
|
-
const sep = lines[i];
|
|
141
|
-
if (sep !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(sep)) i++; // skip separator
|
|
142
|
-
while (i < lines.length) {
|
|
143
|
-
const row = lines[i];
|
|
144
|
-
if (row === undefined || !row.trimStart().startsWith('|')) break;
|
|
145
|
-
const cells = splitRow(row);
|
|
146
|
-
const priority = (cells[priorityCol] ?? '').trim();
|
|
147
|
-
const finding = (cells[findingCol] ?? '').trim();
|
|
148
|
-
const disposition = dispositionCol === -1 ? '' : (cells[dispositionCol] ?? '').trim();
|
|
149
|
-
if (
|
|
150
|
-
PRIORITY_PATTERN.test(priority) &&
|
|
151
|
-
!NONE_FINDING.test(finding) &&
|
|
152
|
-
finding.length > 0 &&
|
|
153
|
-
!RESOLVED_DISPOSITION.test(disposition)
|
|
154
|
-
) {
|
|
155
|
-
const location = locationOf(cells[locationCol] ?? '', finding);
|
|
156
|
-
out.push(
|
|
157
|
-
DEFERRED_DISPOSITION.test(disposition)
|
|
158
|
-
? { priority, location, text: finding, deferral: disposition }
|
|
159
|
-
: { priority, location, text: finding },
|
|
160
|
-
);
|
|
161
|
-
}
|
|
162
|
-
i++;
|
|
163
|
-
}
|
|
31
|
+
function isRegularFile(path: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
return fs.statSync(path).isFile();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
164
36
|
}
|
|
165
|
-
return out;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function splitRow(line: string): string[] {
|
|
169
|
-
return line
|
|
170
|
-
.trim()
|
|
171
|
-
.replace(/^\|/, '')
|
|
172
|
-
.replace(/\|$/, '')
|
|
173
|
-
.split('|')
|
|
174
|
-
.map((c) => c.trim());
|
|
175
37
|
}
|
|
176
|
-
|
|
177
|
-
/** Added lines (`<file>:<line>:<text>`) from `git diff --unified=0 <base>` plus untracked files. */
|
|
178
38
|
export function collectAddedLines(root: string, base: string): Array<{ file: string; line: number; text: string }> {
|
|
179
39
|
const out: Array<{ file: string; line: number; text: string }> = [];
|
|
180
40
|
const diff = run('git', ['diff', '--unified=0', base], root);
|
|
@@ -182,259 +42,53 @@ export function collectAddedLines(root: string, base: string): Array<{ file: str
|
|
|
182
42
|
let newLine = 0;
|
|
183
43
|
for (const line of diff.stdout.split('\n')) {
|
|
184
44
|
if (line.startsWith('+++ b/')) file = line.slice(6);
|
|
185
|
-
else if (line.startsWith('@@'))
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
189
|
-
out.push({ file, line: newLine, text: line.slice(1) });
|
|
190
|
-
newLine++;
|
|
191
|
-
}
|
|
45
|
+
else if (line.startsWith('@@')) newLine = Number.parseInt((line.match(/\+[0-9]+/) ?? ['+0'])[0].slice(1), 10);
|
|
46
|
+
else if (line.startsWith('+') && !line.startsWith('+++'))
|
|
47
|
+
out.push({ file, line: newLine++, text: line.slice(1) });
|
|
192
48
|
}
|
|
193
49
|
const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], root);
|
|
194
50
|
for (const f of untracked.stdout.split('\n')) {
|
|
195
|
-
if (f.length === 0) continue;
|
|
196
|
-
|
|
197
|
-
if (!statSync(join(root, f)).isFile()) continue;
|
|
198
|
-
} catch {
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
const content = readFileSync(join(root, f), 'utf8').split('\n');
|
|
202
|
-
content.forEach((text, idx) => {
|
|
51
|
+
if (f.length === 0 || !isRegularFile(join(root, f))) continue;
|
|
52
|
+
for (const [idx, text] of fs.readFileSync(join(root, f), 'utf8').split('\n').entries())
|
|
203
53
|
out.push({ file: f, line: idx + 1, text });
|
|
204
|
-
});
|
|
205
54
|
}
|
|
206
55
|
return out;
|
|
207
56
|
}
|
|
208
|
-
|
|
209
|
-
/** TODO/FIXME/XXX/HACK markers on added lines, honoring path exclusions + allow pragma. */
|
|
210
|
-
export function parseDiffMarkers(
|
|
211
|
-
addedLines: Array<{ file: string; line: number; text: string }>,
|
|
212
|
-
): Array<{ location: string; text: string }> {
|
|
213
|
-
return addedLines
|
|
214
|
-
.filter((l) => !EXCLUDED_PATHS.some((p) => l.file.startsWith(p)))
|
|
215
|
-
.filter((l) => !l.text.includes(ALLOW_PRAGMA))
|
|
216
|
-
.filter((l) => MARKER_PATTERN.test(l.text))
|
|
217
|
-
.map((l) => ({ location: `${l.file}:${l.line}`, text: l.text.trim() }));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** `- [ ]` lines in the task file. */
|
|
221
|
-
export function findUncheckedBoxes(taskContent: string): Array<{ location: string; text: string }> {
|
|
222
|
-
const path = 'task-file';
|
|
223
|
-
return taskContent
|
|
224
|
-
.split('\n')
|
|
225
|
-
.map((text, idx) => ({ text: text.trim(), line: idx + 1 }))
|
|
226
|
-
.filter((l) => l.text.startsWith('- [ ]'))
|
|
227
|
-
.map((l) => ({ location: `${path}:${l.line}`, text: l.text }));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Regular files matching `<tmpDir>/<wbs>-*`. */
|
|
231
57
|
export function listStagingResidue(tmpDir: string, wbs: string): string[] {
|
|
232
58
|
let names: string[];
|
|
233
59
|
try {
|
|
234
|
-
names = readdirSync(tmpDir);
|
|
60
|
+
names = fs.readdirSync(tmpDir);
|
|
235
61
|
} catch {
|
|
236
62
|
return [];
|
|
237
63
|
}
|
|
238
|
-
return names
|
|
239
|
-
.filter((n) => n.startsWith(`${wbs}-`))
|
|
240
|
-
.filter((n) => {
|
|
241
|
-
try {
|
|
242
|
-
return statSync(join(tmpDir, n)).isFile();
|
|
243
|
-
} catch {
|
|
244
|
-
return false;
|
|
245
|
-
}
|
|
246
|
-
})
|
|
247
|
-
.map((n) => join(tmpDir, n));
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
interface Deferral {
|
|
251
|
-
id: string;
|
|
252
|
-
reason: string;
|
|
64
|
+
return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join(tmpDir, n))).map((n) => join(tmpDir, n));
|
|
253
65
|
}
|
|
254
|
-
|
|
255
|
-
function readDeferrals(runDir: string, wbs: string): Deferral[] {
|
|
66
|
+
function readDeferrals(runDir: string, wbs: string): core.Deferral[] {
|
|
256
67
|
const path = join(runDir, `${wbs}-residual-deferrals.json`);
|
|
257
|
-
if (!existsSync(path)) return [];
|
|
68
|
+
if (!fs.existsSync(path)) return [];
|
|
69
|
+
const isDeferral = (e: unknown): e is core.Deferral =>
|
|
70
|
+
typeof e === 'object' &&
|
|
71
|
+
e !== null &&
|
|
72
|
+
typeof (e as core.Deferral).id === 'string' &&
|
|
73
|
+
typeof (e as core.Deferral).reason === 'string' &&
|
|
74
|
+
(e as core.Deferral).reason.trim().length > 0;
|
|
258
75
|
try {
|
|
259
|
-
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
260
|
-
|
|
261
|
-
return parsed.filter(
|
|
262
|
-
(e): e is Deferral =>
|
|
263
|
-
typeof e === 'object' &&
|
|
264
|
-
e !== null &&
|
|
265
|
-
typeof (e as Deferral).id === 'string' &&
|
|
266
|
-
typeof (e as Deferral).reason === 'string' &&
|
|
267
|
-
(e as Deferral).reason.trim().length > 0,
|
|
268
|
-
);
|
|
76
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
77
|
+
return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
|
|
269
78
|
} catch {
|
|
270
79
|
return [];
|
|
271
80
|
}
|
|
272
81
|
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Classification: P1–P3 findings, diff markers and unchecked boxes are blocking; P4 is
|
|
276
|
-
* advisory; staging residue is housekeeping. A deferral entry with a non-empty reason
|
|
277
|
-
* reclassifies a P3 finding or diff marker as deferrable — never P1/P2 or unchecked boxes.
|
|
278
|
-
*/
|
|
279
|
-
export function classify(
|
|
280
|
-
items: Array<Omit<ResidualItem, 'id' | 'class'> & { category: ResidualCategory }>,
|
|
281
|
-
deferrals: Deferral[],
|
|
282
|
-
): ResidualItem[] {
|
|
283
|
-
const deferred = new Map(deferrals.map((d) => [d.id, d.reason]));
|
|
284
|
-
return items.map((item) => {
|
|
285
|
-
const id = makeItemId(item.category, item.location, item.text);
|
|
286
|
-
let klass: ResidualClass;
|
|
287
|
-
if (item.category === 'review-finding') klass = item.priority?.startsWith('P4') ? 'advisory' : 'blocking';
|
|
288
|
-
else if (item.category === 'staging-residue') klass = 'housekeeping';
|
|
289
|
-
else klass = 'blocking';
|
|
290
|
-
if (klass === 'blocking' && item.category !== 'unchecked-box') {
|
|
291
|
-
const p3Like =
|
|
292
|
-
item.category === 'diff-marker' ||
|
|
293
|
-
(item.category === 'review-finding' && (item.priority ?? '').startsWith('P3'));
|
|
294
|
-
const reason = deferred.get(id);
|
|
295
|
-
if (p3Like && reason !== undefined && reason.trim().length > 0) klass = 'deferrable';
|
|
296
|
-
}
|
|
297
|
-
return {
|
|
298
|
-
id,
|
|
299
|
-
category: item.category,
|
|
300
|
-
class: klass,
|
|
301
|
-
priority: item.priority,
|
|
302
|
-
location: item.location,
|
|
303
|
-
text: item.text,
|
|
304
|
-
};
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function scanResiduals(
|
|
309
|
-
root: string,
|
|
310
|
-
wbs: string,
|
|
311
|
-
tmpDir: string,
|
|
312
|
-
taskContent: string,
|
|
313
|
-
_env: ScanEnv,
|
|
314
|
-
): ResidualArtifact {
|
|
82
|
+
export function scanResiduals(root: string, wbs: string, tmpDir: string, taskContent: string, _env: ScanEnv) {
|
|
315
83
|
const runDir = join(root, '.spur', 'run');
|
|
316
84
|
const basePath = join(runDir, `${wbs}-base.sha`);
|
|
317
|
-
const base = existsSync(basePath) ? readFileSync(basePath, 'utf8').trim() : null;
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
);
|
|
322
|
-
const review = reviewRows.map((r) => ({
|
|
323
|
-
category: 'review-finding' as const,
|
|
324
|
-
priority: r.priority,
|
|
325
|
-
location: r.location,
|
|
326
|
-
text: r.text,
|
|
327
|
-
}));
|
|
328
|
-
const markers =
|
|
329
|
-
base === null
|
|
330
|
-
? []
|
|
331
|
-
: parseDiffMarkers(collectAddedLines(root, base)).map((m) => ({
|
|
332
|
-
category: 'diff-marker' as const,
|
|
333
|
-
location: m.location,
|
|
334
|
-
text: m.text,
|
|
335
|
-
}));
|
|
336
|
-
const boxes = findUncheckedBoxes(taskContent).map((b) => ({
|
|
337
|
-
category: 'unchecked-box' as const,
|
|
338
|
-
location: b.location,
|
|
339
|
-
text: b.text,
|
|
340
|
-
}));
|
|
341
|
-
const residue = listStagingResidue(tmpDir, wbs).map((p) => ({
|
|
342
|
-
category: 'staging-residue' as const,
|
|
343
|
-
location: p,
|
|
344
|
-
text: p,
|
|
345
|
-
}));
|
|
346
|
-
const items = classify(
|
|
347
|
-
[...review, ...markers, ...boxes, ...residue],
|
|
348
|
-
[...tableDeferrals, ...readDeferrals(runDir, wbs)],
|
|
349
|
-
);
|
|
350
|
-
const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
|
|
351
|
-
for (const item of items) counts[item.class]++;
|
|
352
|
-
return {
|
|
353
|
-
wbs,
|
|
354
|
-
base,
|
|
355
|
-
scanned: {
|
|
356
|
-
'review-finding': true,
|
|
357
|
-
'diff-marker': base !== null,
|
|
358
|
-
'unchecked-box': true,
|
|
359
|
-
'staging-residue': true,
|
|
360
|
-
},
|
|
361
|
-
items,
|
|
362
|
-
counts,
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/** Blocking locations that match the `file.ext:line` findings-anchor shape. */
|
|
367
|
-
export function blockingAnchors(items: ResidualItem[]): string[] {
|
|
368
|
-
const anchors = new Set<string>();
|
|
369
|
-
for (const item of items) {
|
|
370
|
-
if (item.class !== 'blocking') continue;
|
|
371
|
-
for (const m of normalizeAnchor(item.location).matchAll(ANCHOR_PATTERN)) anchors.add(m[0]);
|
|
372
|
-
}
|
|
373
|
-
return [...anchors];
|
|
85
|
+
const base = fs.existsSync(basePath) ? fs.readFileSync(basePath, 'utf8').trim() : null;
|
|
86
|
+
const stagingResidue = listStagingResidue(tmpDir, wbs);
|
|
87
|
+
const deferrals = readDeferrals(runDir, wbs);
|
|
88
|
+
const addedLines = base === null ? [] : collectAddedLines(root, base);
|
|
89
|
+
return core.scanResiduals({ wbs, base, taskContent, addedLines, stagingResidue, deferrals });
|
|
374
90
|
}
|
|
375
|
-
|
|
376
|
-
export interface FoldResult {
|
|
377
|
-
verdict: 'PASS' | 'PARTIAL' | 'FAIL';
|
|
378
|
-
checks: Array<{ name: string; status: string; evidence: string }>;
|
|
379
|
-
findings: string;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Fold the scan into a verdict: replace the `residual-sweep` check (fail when blocking > 0),
|
|
384
|
-
* downgrade PASS→PARTIAL when blocking > 0 (PARTIAL/FAIL unchanged), and merge blocking
|
|
385
|
-
* anchors into the gate findings (unique, sorted, cap 20). Idempotent.
|
|
386
|
-
*/
|
|
387
|
-
export function foldVerdict(
|
|
388
|
-
verdict: { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> },
|
|
389
|
-
scan: ResidualArtifact,
|
|
390
|
-
existingFindings: string,
|
|
391
|
-
maxFindings = 20,
|
|
392
|
-
): FoldResult {
|
|
393
|
-
const blocking = scan.items.filter((i) => i.class === 'blocking');
|
|
394
|
-
const deferrable = scan.items.filter((i) => i.class === 'deferrable');
|
|
395
|
-
const evidence =
|
|
396
|
-
`blocking=${blocking.length} deferrable=${deferrable.length} advisory=${scan.counts.advisory} housekeeping=${scan.counts.housekeeping}` +
|
|
397
|
-
(blocking.length > 0 ? `; blocking ids: ${blocking.map((i) => i.id).join(', ')}` : '') +
|
|
398
|
-
(deferrable.length > 0 ? `; deferrable ids: ${deferrable.map((i) => i.id).join(', ')}` : '');
|
|
399
|
-
const checks = verdict.checks.filter((c) => c.name !== 'residual-sweep');
|
|
400
|
-
checks.push({ name: 'residual-sweep', status: blocking.length > 0 ? 'fail' : 'pass', evidence });
|
|
401
|
-
const merged = new Set([
|
|
402
|
-
...existingFindings.split(/\s+/).filter((a) => a.length > 0),
|
|
403
|
-
...blockingAnchors(scan.items),
|
|
404
|
-
]);
|
|
405
|
-
const findings = [...merged]
|
|
406
|
-
.sort()
|
|
407
|
-
.slice(0, maxFindings)
|
|
408
|
-
.map((a) => `${a} `)
|
|
409
|
-
.join('');
|
|
410
|
-
let verdictStatus: FoldResult['verdict'] = verdict.verdict === 'PASS' ? 'PASS' : 'FAIL';
|
|
411
|
-
if (verdict.verdict === 'PARTIAL') verdictStatus = 'PARTIAL';
|
|
412
|
-
else if (verdict.verdict === 'FAIL') verdictStatus = 'FAIL';
|
|
413
|
-
else if (blocking.length > 0) verdictStatus = 'PARTIAL';
|
|
414
|
-
return { verdict: verdictStatus, checks, findings };
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** Render the recovery report for blocking items. */
|
|
418
|
-
export function renderReport(wbs: string, items: ResidualItem[], attemptCount: number): string {
|
|
419
|
-
const lines = [
|
|
420
|
-
`# Residual report — ${wbs}`,
|
|
421
|
-
'',
|
|
422
|
-
`Attempt: ${attemptCount}`,
|
|
423
|
-
'',
|
|
424
|
-
'| Category | Class | Location | Text |',
|
|
425
|
-
'| --- | --- | --- | --- |',
|
|
426
|
-
];
|
|
427
|
-
for (const item of items) {
|
|
428
|
-
lines.push(`| ${item.category} | ${item.class} | ${item.location} | ${item.text.replace(/\|/g, '\\|')} |`);
|
|
429
|
-
}
|
|
430
|
-
return `${lines.join('\n')}\n`;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// ── impure modes ─────────────────────────────────────────────────────────────
|
|
434
|
-
|
|
435
|
-
function parseArgs(
|
|
436
|
-
argv: string[],
|
|
437
|
-
): { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string } | null {
|
|
91
|
+
function parseArgs(argv: string[]): ParsedArgs | null {
|
|
438
92
|
let mode = '';
|
|
439
93
|
let wbs = '';
|
|
440
94
|
let spurBin: string | undefined;
|
|
@@ -450,192 +104,146 @@ function parseArgs(
|
|
|
450
104
|
else if (mode === '') mode = a;
|
|
451
105
|
else if (wbs === '') wbs = a;
|
|
452
106
|
}
|
|
453
|
-
|
|
454
|
-
return { mode, wbs, spurBin, root, tmpDir };
|
|
107
|
+
return mode === '' || wbs === '' ? null : { mode, wbs, spurBin, root, tmpDir };
|
|
455
108
|
}
|
|
456
|
-
|
|
457
|
-
function spur(
|
|
458
|
-
env: ScanEnv,
|
|
459
|
-
spurBinFlag: string | undefined,
|
|
460
|
-
args: string[],
|
|
461
|
-
cwd: string,
|
|
462
|
-
): { status: number; stdout: string } {
|
|
463
|
-
return run(
|
|
464
|
-
spurCommand(spurBinFlag ?? env.spurBin).cmd,
|
|
465
|
-
[...spurCommand(spurBinFlag ?? env.spurBin).prefix, ...args],
|
|
466
|
-
cwd,
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function loadTask(
|
|
471
|
-
env: ScanEnv,
|
|
472
|
-
spurBinFlag: string | undefined,
|
|
473
|
-
wbs: string,
|
|
474
|
-
root: string,
|
|
475
|
-
): { content: string; featureId: string } {
|
|
109
|
+
function loadTask(env: ScanEnv, spurBinFlag: string | undefined, wbs: string, root: string): ScanTask {
|
|
476
110
|
const res = spur(env, spurBinFlag, ['task', 'show', wbs, '--json'], root);
|
|
477
111
|
if (res.status !== 0) throw new Error(`task show ${wbs} failed`);
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
const fm = parsed.frontmatter;
|
|
112
|
+
type TaskJson = { content?: unknown; feature_id?: unknown; frontmatter?: { feature_id?: unknown } | null };
|
|
113
|
+
const parsed = JSON.parse(res.stdout) as TaskJson;
|
|
481
114
|
const featureId =
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
: fm !== null && typeof fm === 'object' && typeof (fm as Record<string, unknown>).feature_id === 'string'
|
|
485
|
-
? ((fm as Record<string, unknown>).feature_id as string)
|
|
486
|
-
: '';
|
|
487
|
-
return { content, featureId };
|
|
115
|
+
[parsed.feature_id, parsed.frontmatter?.feature_id].find((v): v is string => typeof v === 'string') ?? '';
|
|
116
|
+
return { content: typeof parsed.content === 'string' ? parsed.content : '', featureId };
|
|
488
117
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
runDir: string,
|
|
492
|
-
wbs: string,
|
|
493
|
-
): { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> } {
|
|
494
|
-
return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), 'utf8'));
|
|
118
|
+
function loadVerdict(runDir: string, wbs: string): VerdictFile {
|
|
119
|
+
return JSON.parse(fs.readFileSync(core.recordedVerdictPath(runDir, wbs, fs), 'utf8'));
|
|
495
120
|
}
|
|
496
|
-
|
|
497
|
-
function scanMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
|
|
498
|
-
const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
121
|
+
function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
499
122
|
const runDir = join(opts.root, '.spur', 'run');
|
|
500
|
-
mkdirSync(runDir, { recursive: true });
|
|
501
|
-
const
|
|
502
|
-
|
|
123
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
124
|
+
const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
125
|
+
const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
|
|
126
|
+
fs.writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
|
|
503
127
|
io.out(
|
|
504
128
|
`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}\n`,
|
|
505
129
|
);
|
|
506
130
|
return 0;
|
|
507
131
|
}
|
|
508
|
-
|
|
509
|
-
function foldMode(opts: NonNullable<ReturnType<typeof parseArgs>>, _env: ScanEnv, io: ScanIo): number {
|
|
132
|
+
function foldMode(opts: ParsedArgs, _env: ScanEnv, io: ScanIo): number {
|
|
510
133
|
const runDir = join(opts.root, '.spur', 'run');
|
|
511
|
-
const scan = JSON.parse(
|
|
512
|
-
|
|
134
|
+
const scan = JSON.parse(
|
|
135
|
+
fs.readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8'),
|
|
136
|
+
) as core.ResidualArtifact;
|
|
137
|
+
const target = core.recordedVerdictPath(runDir, opts.wbs, fs);
|
|
513
138
|
const verdict = loadVerdict(runDir, opts.wbs);
|
|
514
139
|
const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
`${JSON.stringify({ ...verdict, verdict: folded.verdict, checks: folded.checks }, null, 2)}\n`,
|
|
140
|
+
const fold = core.foldVerdict(
|
|
141
|
+
verdict,
|
|
142
|
+
scan,
|
|
143
|
+
fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, 'utf8') : '',
|
|
520
144
|
);
|
|
521
|
-
|
|
145
|
+
const bytes = `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}\n`;
|
|
146
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
147
|
+
try {
|
|
148
|
+
fs.writeFileSync(temporary, bytes, { flag: 'wx' });
|
|
149
|
+
fs.renameSync(temporary, target);
|
|
150
|
+
} finally {
|
|
151
|
+
fs.rmSync(temporary, { force: true });
|
|
152
|
+
}
|
|
153
|
+
// The pipeline still consumes the attempt copy after folding; the recorded copy is authoritative.
|
|
154
|
+
const scratch = join(runDir, `${opts.wbs}-verdict.json`);
|
|
155
|
+
if (target !== scratch) fs.writeFileSync(scratch, bytes);
|
|
156
|
+
fs.writeFileSync(findingsPath, fold.findings);
|
|
522
157
|
io.out(
|
|
523
|
-
`residual-fold: ${opts.wbs} verdict=${
|
|
158
|
+
`residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
|
|
524
159
|
);
|
|
525
160
|
return 0;
|
|
526
161
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const task = loadTask(env, opts.spurBin,
|
|
162
|
+
function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
163
|
+
const wbs = opts.wbs;
|
|
164
|
+
const task = loadTask(env, opts.spurBin, wbs, opts.root);
|
|
530
165
|
const runDir = join(opts.root, '.spur', 'run');
|
|
531
|
-
const scan = scanResiduals(opts.root,
|
|
532
|
-
const residualsPath = join(runDir, `${
|
|
166
|
+
const scan = scanResiduals(opts.root, wbs, opts.tmpDir, task.content, env);
|
|
167
|
+
const residualsPath = join(runDir, `${wbs}-residuals.json`);
|
|
168
|
+
let prior: Partial<core.ResidualArtifact & { followUp?: string }> = {};
|
|
169
|
+
if (fs.existsSync(residualsPath)) prior = JSON.parse(fs.readFileSync(residualsPath, 'utf8'));
|
|
533
170
|
const deferred = scan.items.filter((i) => i.class === 'deferrable');
|
|
534
|
-
const prior = existsSync(residualsPath)
|
|
535
|
-
? (JSON.parse(readFileSync(residualsPath, 'utf8')) as Partial<ResidualArtifact & { followUp?: string }>)
|
|
536
|
-
: {};
|
|
537
171
|
if (deferred.length > 0 && prior.followUp === undefined) {
|
|
538
172
|
if (task.featureId === '') {
|
|
539
173
|
io.err(
|
|
540
|
-
`residual-settle: ${
|
|
174
|
+
`residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}\n`,
|
|
541
175
|
);
|
|
542
176
|
return 0;
|
|
543
177
|
}
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
opts.spurBin,
|
|
547
|
-
['task', 'create', `Residuals from ${opts.wbs}`, '--feature', task.featureId, '--skip-ready', '--json'],
|
|
548
|
-
opts.root,
|
|
549
|
-
);
|
|
550
|
-
if (created.status !== 0) {
|
|
551
|
-
io.err(`residual-settle: task create failed; re-run: residual-scan settle ${opts.wbs}\n`);
|
|
552
|
-
return 0;
|
|
553
|
-
}
|
|
178
|
+
const args = ['task', 'create', `Residuals from ${wbs}`, '--feature', task.featureId, '--skip-ready', '--json'];
|
|
179
|
+
const created = spur(env, opts.spurBin, args, opts.root);
|
|
554
180
|
let wbsNew = '';
|
|
555
181
|
try {
|
|
556
|
-
const parsed = JSON.parse(created.stdout) as
|
|
557
|
-
const
|
|
558
|
-
wbsNew =
|
|
559
|
-
pick(parsed) ||
|
|
560
|
-
(parsed.data !== null && typeof parsed.data === 'object'
|
|
561
|
-
? pick(parsed.data as Record<string, unknown>)
|
|
562
|
-
: '');
|
|
182
|
+
const parsed = JSON.parse(created.stdout) as { wbs?: unknown; data?: { wbs?: unknown } | null };
|
|
183
|
+
const data = typeof parsed.data === 'object' && parsed.data !== null ? parsed.data : {};
|
|
184
|
+
wbsNew = [parsed.wbs, data.wbs].find((v): v is string => typeof v === 'string') ?? '';
|
|
563
185
|
} catch {
|
|
564
186
|
wbsNew = '';
|
|
565
187
|
}
|
|
566
|
-
if (wbsNew === '') {
|
|
567
|
-
io.err(`residual-settle:
|
|
188
|
+
if (created.status !== 0 || wbsNew === '') {
|
|
189
|
+
io.err(`residual-settle: task create failed or unreadable wbs; re-run: residual-scan settle ${wbs}\n`);
|
|
568
190
|
return 0;
|
|
569
191
|
}
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const upd = spur(
|
|
578
|
-
env,
|
|
579
|
-
opts.spurBin,
|
|
580
|
-
['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile],
|
|
581
|
-
opts.root,
|
|
582
|
-
);
|
|
583
|
-
if (upd.status !== 0) {
|
|
584
|
-
io.err(`residual-settle: background write failed; re-run: residual-scan settle ${opts.wbs}\n`);
|
|
192
|
+
const rows = deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`);
|
|
193
|
+
const head = `Source task: ${wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`;
|
|
194
|
+
const bgFile = join(runDir, `${wbs}-residual-background.md`);
|
|
195
|
+
fs.writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
|
|
196
|
+
const updArgs = ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile];
|
|
197
|
+
if (spur(env, opts.spurBin, updArgs, opts.root).status !== 0) {
|
|
198
|
+
io.err(`residual-settle: background write failed; re-run: residual-scan settle ${wbs}\n`);
|
|
585
199
|
return 0;
|
|
586
200
|
}
|
|
587
201
|
prior.followUp = wbsNew;
|
|
588
202
|
io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)\n`);
|
|
589
203
|
}
|
|
590
|
-
|
|
591
|
-
for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
|
|
204
|
+
for (const path of listStagingResidue(opts.tmpDir, wbs)) {
|
|
592
205
|
try {
|
|
593
|
-
rmSync(path, { force: true });
|
|
206
|
+
fs.rmSync(path, { force: true });
|
|
594
207
|
} catch {
|
|
595
|
-
io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${
|
|
208
|
+
io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}\n`);
|
|
596
209
|
return 0;
|
|
597
210
|
}
|
|
598
211
|
}
|
|
599
|
-
writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
|
|
212
|
+
fs.writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
|
|
600
213
|
return 0;
|
|
601
214
|
}
|
|
602
|
-
|
|
603
|
-
function reportMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
|
|
215
|
+
function reportMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
604
216
|
const runDir = join(opts.root, '.spur', 'run');
|
|
605
|
-
const
|
|
606
|
-
const sweep = verdict.checks.find((c) => c.name === 'residual-sweep');
|
|
217
|
+
const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === 'residual-sweep');
|
|
607
218
|
if (sweep === undefined || sweep.status !== 'fail') return 0;
|
|
608
|
-
const
|
|
609
|
-
const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir,
|
|
219
|
+
const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
220
|
+
const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
|
|
610
221
|
const blocking = scan.items.filter((i) => i.class === 'blocking');
|
|
611
222
|
const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
|
|
612
|
-
const attempts = existsSync(attemptFile)
|
|
613
|
-
|
|
614
|
-
|
|
223
|
+
const attempts = fs.existsSync(attemptFile)
|
|
224
|
+
? Number.parseInt(fs.readFileSync(attemptFile, 'utf8').trim() || '0', 10)
|
|
225
|
+
: 0;
|
|
226
|
+
fs.writeFileSync(
|
|
227
|
+
join(runDir, `${opts.wbs}-residual-report.md`),
|
|
228
|
+
core.renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts),
|
|
229
|
+
);
|
|
615
230
|
io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}\n`);
|
|
616
231
|
return 0;
|
|
617
232
|
}
|
|
618
|
-
|
|
619
233
|
export function main(argv: string[], env: ScanEnv = getEnvVars(), options: ScanOptions = {}): number {
|
|
620
234
|
const io: ScanIo = options.io ?? {
|
|
621
235
|
out: (line) => process.stdout.write(line),
|
|
622
236
|
err: (line) => process.stderr.write(line),
|
|
623
237
|
};
|
|
238
|
+
const cwd = options.cwd ?? process.cwd();
|
|
624
239
|
const opts = parseArgs(argv);
|
|
625
|
-
|
|
240
|
+
const modes: Record<string, ModeFn> = { scan: scanMode, fold: foldMode, settle: settleMode, report: reportMode };
|
|
241
|
+
const modeFn = opts === null ? undefined : modes[opts.mode];
|
|
242
|
+
if (opts === null || modeFn === undefined) {
|
|
626
243
|
io.err(`${RESIDUAL_SCAN_USAGE}\n`);
|
|
627
244
|
return 2;
|
|
628
245
|
}
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
if (resolved.mode === 'scan') return scanMode(resolved, env, io);
|
|
632
|
-
if (resolved.mode === 'fold') return foldMode(resolved, env, io);
|
|
633
|
-
if (resolved.mode === 'settle') return settleMode(resolved, env, io);
|
|
634
|
-
if (resolved.mode === 'report') return reportMode(resolved, env, io);
|
|
635
|
-
io.err(`${RESIDUAL_SCAN_USAGE}\n`);
|
|
636
|
-
return 2;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
if (import.meta.main) {
|
|
640
|
-
process.exit(main(process.argv.slice(2)));
|
|
246
|
+
const resolved: ParsedArgs = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
|
|
247
|
+
return modeFn(resolved, env, io);
|
|
641
248
|
}
|
|
249
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|