@gobing-ai/spur 0.3.95 → 0.3.96
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 +5 -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/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 +89 -37
- package/package.json +1 -1
- package/plugins/sp/README.md +8 -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-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 +25 -8
- package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
- package/plugins/sp/lib/quality-gate.generated.mjs +438 -0
- package/plugins/sp/lib/residual-scan.generated.d.mts +62 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +210 -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 +1 -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 +34 -19
- package/plugins/sp/scripts/quality-gate.ts +12 -658
- package/plugins/sp/scripts/residual-scan.mjs +135 -156
- package/plugins/sp/scripts/residual-scan.ts +102 -499
- 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 +10 -32
- package/plugins/sp/scripts/wrapup-steps.ts +8 -36
- 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/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/verbs.md +21 -6
- package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +11 -9
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +86 -70
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -3
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +77 -34
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +5 -9
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +31 -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 +29 -3
- package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
- package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
- package/spur.js +1495 -369
- package/web/_astro/{BoardApp.Cpxntzad.js → BoardApp.BIjMatT1.js} +1 -1
- package/web/_astro/{BoardApp.BxJuwD7I.js → BoardApp.GvjIe9Z6.js} +42 -42
- package/web/_astro/{TaskDetail.C5bW4WGV.js → TaskDetail.BZM3EAFx.js} +1 -1
- package/web/_astro/{arc.CyjRvNMY.js → arc.HkRiZnoI.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.B4lWRzJA.js → architectureDiagram-3BPJPVTR.BDdK-tgZ.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.Be38USQ2.js → blockDiagram-GPEHLZMM.CZ9kOBxx.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DT9Fj5Qx.js → c4Diagram-AAUBKEIU.DXK9qeRD.js} +1 -1
- package/web/_astro/channel.DlhX2MEt.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.SeSBWLg5.js → chunk-2J33WTMH.BTD2WeX5.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.DoO4VE14.js → chunk-4BX2VUAB.Ci6Qbcvk.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DrmTFA53.js → chunk-55IACEB6.yl3zsj7p.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.Bi-TUb_V.js → chunk-727SXJPM.ClXZsfyR.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.B-xp8Uhi.js → chunk-AQP2D5EJ.BmKWZBcP.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.DGWrDnUU.js → chunk-FMBD7UC4.By30fcb7.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM._BagvPDy.js → chunk-ND2GUHAM.24NmrD-k.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.VOboYWQ0.js → chunk-QZHKN3VN.BN4sKdcS.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.D_apfHS7.js → classDiagram-4FO5ZUOK.CJpoMPb5.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.D_apfHS7.js → classDiagram-v2-Q7XG4LA2.CJpoMPb5.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.DNLU_L-x.js → cose-bilkent-S5V4N54A.BraxQ2Nt.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.D8borhV-.js → cynefin-OW5HDTMX.gzoU73oL.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.Uc0lvjcV.js → dagre-BM42HDAG.wyDfWySU.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DDIxtDBo.js → diagram-2AECGRRQ.h2_EbMxV.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.DVoDnD3H.js → diagram-5GNKFQAL.Db6dcs89.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.CRubTJ-0.js → diagram-KO2AKTUF.NVwzxu_U.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.m9SeYcXQ.js → diagram-LMA3HP47.B-eNgDQk.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.C6s2ZjV5.js → diagram-OG6HWLK6.Bsa8ZRbJ.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.nSzr5KTj.js → erDiagram-TEJ5UH35.BospRE5Q.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DrZcys16.js → flowDiagram-I6XJVG4X.DMzvHwAA.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.Do4M1b-K.js → ganttDiagram-6RSMTGT7.Dmdxgd-M.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.BWc9oJ4l.js → gitGraphDiagram-PVQCEYII.CaqdO0rL.js} +1 -1
- package/web/_astro/index.EoZlzLK-.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.BIdOctFk.js → infoDiagram-5YYISTIA.DDVD9Wn7.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.CV5SuG2t.js → ishikawaDiagram-YF4QCWOH.EKyCUFsL.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.CxE-rEJ-.js → journeyDiagram-JHISSGLW.By2-RkYB.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BPRdKWs_.js → kanban-definition-UN3LZRKU.ar-WmlyR.js} +1 -1
- package/web/_astro/{linear.zRsuuDTE.js → linear.BnzMBgo_.js} +1 -1
- package/web/_astro/{mermaid.core.jAJTcMKc.js → mermaid.core.DSqeFu2Y.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BffJxfCr.js → mindmap-definition-RKZ34NQL.1XcF8Wh2.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.CacwWaE3.js → pieDiagram-4H26LBE5.DnItHOdK.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.hXELD06-.js → quadrantDiagram-W4KKPZXB.BQQyrnBt.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.DdEbPcqT.js → requirementDiagram-4Y6WPE33.Dmzxun82.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DgaDocKv.js → sankeyDiagram-5OEKKPKP.BBIeRKYB.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.B0KScnAu.js → sequenceDiagram-3UESZ5HK.CUNVdBBf.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.CV9M_WNc.js → stateDiagram-AJRCARHV.DlFf2sJm.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.BOAo74Et.js → stateDiagram-v2-BHNVJYJU.DXR7I1H5.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.CqqepUMe.js → timeline-definition-PNZ67QCA.BcO-GyX2.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DDqe4dOG.js → vennDiagram-CIIHVFJN.ChCECmmW.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.B3R9OPdO.js → wardleyDiagram-YWT4CUSO.CRaT12mD.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.B3SvgXpQ.js → xychartDiagram-2RQKCTM6.B3lsHVpj.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/channel.CI6N_tCg.js +0 -1
- package/web/_astro/index.CENnIEqT.css +0 -1
|
@@ -2,179 +2,49 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* residual-scan — deterministic task-leftover scanner behind feature F96 (ADR-071).
|
|
4
4
|
*
|
|
5
|
-
* Contract owner: docs/design/task-residual-sweep.md.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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).
|
|
5
|
+
* Contract owner: docs/design/task-residual-sweep.md. IO glue only (task 1003 R5): argv, git/spur spawning, file
|
|
6
|
+
* IO, and the four modes; parsing/classification/folding/reporting live in the generated standalone bundle
|
|
7
|
+
* `plugins/sp/lib/residual-scan.generated.mjs` (source: packages/app/src/services/residual-scan.ts), re-exported via
|
|
8
|
+
* `export *`; the local `scanResiduals` wrapper (old 5-arg IO signature) shadows the core of the same name.
|
|
9
|
+
* Modes: scan <wbs> (write `.spur/run/<wbs>-residuals.json`); fold <wbs> (fold blocking residuals into
|
|
10
|
+
* `<wbs>-verdict.json` with PASS→PARTIAL downgrade + `<wbs>-test-gate.findings`); settle <wbs> (file one follow-up
|
|
11
|
+
* task for deferrables, delete `/tmp/<wbs>-*` residue); report <wbs> (write `<wbs>-residual-report.md` when the residual-sweep check failed).
|
|
17
12
|
*/
|
|
18
|
-
|
|
19
13
|
import { spawnSync } from 'node:child_process';
|
|
20
|
-
import { createHash } from 'node:crypto';
|
|
21
14
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
22
15
|
import { tmpdir } from 'node:os';
|
|
23
16
|
import { join } from 'node:path';
|
|
24
17
|
import { getEnvVars } from '../lib/env';
|
|
18
|
+
import * as core from '../lib/residual-scan.generated.mjs';
|
|
19
|
+
import { spurCommand } from '../lib/spur-bin';
|
|
25
20
|
|
|
21
|
+
export * from '../lib/residual-scan.generated.mjs';
|
|
26
22
|
export const RESIDUAL_SCAN_USAGE =
|
|
27
23
|
'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 } {
|
|
24
|
+
export type ScanEnv = { spurBin?: string } & Record<string, string | undefined>;
|
|
25
|
+
export type ScanIo = { out: (line: string) => void; err: (line: string) => void };
|
|
26
|
+
export type ScanOptions = { cwd?: string; io?: ScanIo };
|
|
27
|
+
type RunResult = { status: number; stdout: string };
|
|
28
|
+
type ParsedArgs = { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string };
|
|
29
|
+
type ModeFn = (o: ParsedArgs, e: ScanEnv, i: ScanIo) => number;
|
|
30
|
+
type ScanTask = { content: string; featureId: string };
|
|
31
|
+
type VerdictFile = { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> };
|
|
32
|
+
function run(cmd: string, args: string[], cwd: string): RunResult {
|
|
88
33
|
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
|
89
34
|
if (result.error !== undefined) return { status: result.status ?? 1, stdout: '' };
|
|
90
35
|
return { status: result.status ?? 1, stdout: result.stdout ?? '' };
|
|
91
36
|
}
|
|
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);
|
|
37
|
+
function spur(env: ScanEnv, spurBinFlag: string | undefined, args: string[], cwd: string): RunResult {
|
|
38
|
+
const { cmd, prefix } = spurCommand(spurBinFlag ?? env.spurBin);
|
|
39
|
+
return run(cmd, [...prefix, ...args], cwd);
|
|
110
40
|
}
|
|
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
|
-
}
|
|
41
|
+
function isRegularFile(path: string): boolean {
|
|
42
|
+
try {
|
|
43
|
+
return statSync(path).isFile();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
164
46
|
}
|
|
165
|
-
return out;
|
|
166
47
|
}
|
|
167
|
-
|
|
168
|
-
function splitRow(line: string): string[] {
|
|
169
|
-
return line
|
|
170
|
-
.trim()
|
|
171
|
-
.replace(/^\|/, '')
|
|
172
|
-
.replace(/\|$/, '')
|
|
173
|
-
.split('|')
|
|
174
|
-
.map((c) => c.trim());
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Added lines (`<file>:<line>:<text>`) from `git diff --unified=0 <base>` plus untracked files. */
|
|
178
48
|
export function collectAddedLines(root: string, base: string): Array<{ file: string; line: number; text: string }> {
|
|
179
49
|
const out: Array<{ file: string; line: number; text: string }> = [];
|
|
180
50
|
const diff = run('git', ['diff', '--unified=0', base], root);
|
|
@@ -182,52 +52,18 @@ export function collectAddedLines(root: string, base: string): Array<{ file: str
|
|
|
182
52
|
let newLine = 0;
|
|
183
53
|
for (const line of diff.stdout.split('\n')) {
|
|
184
54
|
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
|
-
}
|
|
55
|
+
else if (line.startsWith('@@')) newLine = Number.parseInt((line.match(/\+[0-9]+/) ?? ['+0'])[0].slice(1), 10);
|
|
56
|
+
else if (line.startsWith('+') && !line.startsWith('+++'))
|
|
57
|
+
out.push({ file, line: newLine++, text: line.slice(1) });
|
|
192
58
|
}
|
|
193
59
|
const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], root);
|
|
194
60
|
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) => {
|
|
61
|
+
if (f.length === 0 || !isRegularFile(join(root, f))) continue;
|
|
62
|
+
for (const [idx, text] of readFileSync(join(root, f), 'utf8').split('\n').entries())
|
|
203
63
|
out.push({ file: f, line: idx + 1, text });
|
|
204
|
-
});
|
|
205
64
|
}
|
|
206
65
|
return out;
|
|
207
66
|
}
|
|
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
67
|
export function listStagingResidue(tmpDir: string, wbs: string): string[] {
|
|
232
68
|
let names: string[];
|
|
233
69
|
try {
|
|
@@ -235,206 +71,34 @@ export function listStagingResidue(tmpDir: string, wbs: string): string[] {
|
|
|
235
71
|
} catch {
|
|
236
72
|
return [];
|
|
237
73
|
}
|
|
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;
|
|
74
|
+
return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join(tmpDir, n))).map((n) => join(tmpDir, n));
|
|
253
75
|
}
|
|
254
|
-
|
|
255
|
-
function readDeferrals(runDir: string, wbs: string): Deferral[] {
|
|
76
|
+
function readDeferrals(runDir: string, wbs: string): core.Deferral[] {
|
|
256
77
|
const path = join(runDir, `${wbs}-residual-deferrals.json`);
|
|
257
78
|
if (!existsSync(path)) return [];
|
|
79
|
+
const isDeferral = (e: unknown): e is core.Deferral =>
|
|
80
|
+
typeof e === 'object' &&
|
|
81
|
+
e !== null &&
|
|
82
|
+
typeof (e as core.Deferral).id === 'string' &&
|
|
83
|
+
typeof (e as core.Deferral).reason === 'string' &&
|
|
84
|
+
(e as core.Deferral).reason.trim().length > 0;
|
|
258
85
|
try {
|
|
259
86
|
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
|
-
);
|
|
87
|
+
return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
|
|
269
88
|
} catch {
|
|
270
89
|
return [];
|
|
271
90
|
}
|
|
272
91
|
}
|
|
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 {
|
|
92
|
+
export function scanResiduals(root: string, wbs: string, tmpDir: string, taskContent: string, _env: ScanEnv) {
|
|
315
93
|
const runDir = join(root, '.spur', 'run');
|
|
316
94
|
const basePath = join(runDir, `${wbs}-base.sha`);
|
|
317
95
|
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];
|
|
374
|
-
}
|
|
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`;
|
|
96
|
+
const stagingResidue = listStagingResidue(tmpDir, wbs);
|
|
97
|
+
const deferrals = readDeferrals(runDir, wbs);
|
|
98
|
+
const addedLines = base === null ? [] : collectAddedLines(root, base);
|
|
99
|
+
return core.scanResiduals({ wbs, base, taskContent, addedLines, stagingResidue, deferrals });
|
|
431
100
|
}
|
|
432
|
-
|
|
433
|
-
// ── impure modes ─────────────────────────────────────────────────────────────
|
|
434
|
-
|
|
435
|
-
function parseArgs(
|
|
436
|
-
argv: string[],
|
|
437
|
-
): { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string } | null {
|
|
101
|
+
function parseArgs(argv: string[]): ParsedArgs | null {
|
|
438
102
|
let mode = '';
|
|
439
103
|
let wbs = '';
|
|
440
104
|
let spurBin: string | undefined;
|
|
@@ -450,192 +114,131 @@ function parseArgs(
|
|
|
450
114
|
else if (mode === '') mode = a;
|
|
451
115
|
else if (wbs === '') wbs = a;
|
|
452
116
|
}
|
|
453
|
-
|
|
454
|
-
return { mode, wbs, spurBin, root, tmpDir };
|
|
455
|
-
}
|
|
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
|
-
);
|
|
117
|
+
return mode === '' || wbs === '' ? null : { mode, wbs, spurBin, root, tmpDir };
|
|
468
118
|
}
|
|
469
|
-
|
|
470
|
-
function loadTask(
|
|
471
|
-
env: ScanEnv,
|
|
472
|
-
spurBinFlag: string | undefined,
|
|
473
|
-
wbs: string,
|
|
474
|
-
root: string,
|
|
475
|
-
): { content: string; featureId: string } {
|
|
119
|
+
function loadTask(env: ScanEnv, spurBinFlag: string | undefined, wbs: string, root: string): ScanTask {
|
|
476
120
|
const res = spur(env, spurBinFlag, ['task', 'show', wbs, '--json'], root);
|
|
477
121
|
if (res.status !== 0) throw new Error(`task show ${wbs} failed`);
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
const fm = parsed.frontmatter;
|
|
122
|
+
type TaskJson = { content?: unknown; feature_id?: unknown; frontmatter?: { feature_id?: unknown } | null };
|
|
123
|
+
const parsed = JSON.parse(res.stdout) as TaskJson;
|
|
481
124
|
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 };
|
|
125
|
+
[parsed.feature_id, parsed.frontmatter?.feature_id].find((v): v is string => typeof v === 'string') ?? '';
|
|
126
|
+
return { content: typeof parsed.content === 'string' ? parsed.content : '', featureId };
|
|
488
127
|
}
|
|
489
|
-
|
|
490
|
-
function loadVerdict(
|
|
491
|
-
runDir: string,
|
|
492
|
-
wbs: string,
|
|
493
|
-
): { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> } {
|
|
128
|
+
function loadVerdict(runDir: string, wbs: string): VerdictFile {
|
|
494
129
|
return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), 'utf8'));
|
|
495
130
|
}
|
|
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);
|
|
131
|
+
function scanMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
499
132
|
const runDir = join(opts.root, '.spur', 'run');
|
|
500
133
|
mkdirSync(runDir, { recursive: true });
|
|
501
|
-
const
|
|
134
|
+
const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
135
|
+
const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
|
|
502
136
|
writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
|
|
503
137
|
io.out(
|
|
504
138
|
`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}\n`,
|
|
505
139
|
);
|
|
506
140
|
return 0;
|
|
507
141
|
}
|
|
508
|
-
|
|
509
|
-
function foldMode(opts: NonNullable<ReturnType<typeof parseArgs>>, _env: ScanEnv, io: ScanIo): number {
|
|
142
|
+
function foldMode(opts: ParsedArgs, _env: ScanEnv, io: ScanIo): number {
|
|
510
143
|
const runDir = join(opts.root, '.spur', 'run');
|
|
511
|
-
const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8')) as ResidualArtifact;
|
|
144
|
+
const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8')) as core.ResidualArtifact;
|
|
512
145
|
const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
|
|
513
146
|
const verdict = loadVerdict(runDir, opts.wbs);
|
|
514
147
|
const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
|
|
515
|
-
const
|
|
516
|
-
const folded = foldVerdict(verdict, scan, existing);
|
|
148
|
+
const fold = core.foldVerdict(verdict, scan, existsSync(findingsPath) ? readFileSync(findingsPath, 'utf8') : '');
|
|
517
149
|
writeFileSync(
|
|
518
150
|
verdictPath,
|
|
519
|
-
`${JSON.stringify({ ...verdict, verdict:
|
|
151
|
+
`${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}\n`,
|
|
520
152
|
);
|
|
521
|
-
writeFileSync(findingsPath,
|
|
153
|
+
writeFileSync(findingsPath, fold.findings);
|
|
522
154
|
io.out(
|
|
523
|
-
`residual-fold: ${opts.wbs} verdict=${
|
|
155
|
+
`residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
|
|
524
156
|
);
|
|
525
157
|
return 0;
|
|
526
158
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const task = loadTask(env, opts.spurBin,
|
|
159
|
+
function settleMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
160
|
+
const wbs = opts.wbs;
|
|
161
|
+
const task = loadTask(env, opts.spurBin, wbs, opts.root);
|
|
530
162
|
const runDir = join(opts.root, '.spur', 'run');
|
|
531
|
-
const scan = scanResiduals(opts.root,
|
|
532
|
-
const residualsPath = join(runDir, `${
|
|
163
|
+
const scan = scanResiduals(opts.root, wbs, opts.tmpDir, task.content, env);
|
|
164
|
+
const residualsPath = join(runDir, `${wbs}-residuals.json`);
|
|
165
|
+
let prior: Partial<core.ResidualArtifact & { followUp?: string }> = {};
|
|
166
|
+
if (existsSync(residualsPath)) prior = JSON.parse(readFileSync(residualsPath, 'utf8'));
|
|
533
167
|
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
168
|
if (deferred.length > 0 && prior.followUp === undefined) {
|
|
538
169
|
if (task.featureId === '') {
|
|
539
170
|
io.err(
|
|
540
|
-
`residual-settle: ${
|
|
171
|
+
`residual-settle: ${wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${wbs}\n`,
|
|
541
172
|
);
|
|
542
173
|
return 0;
|
|
543
174
|
}
|
|
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
|
-
}
|
|
175
|
+
const args = ['task', 'create', `Residuals from ${wbs}`, '--feature', task.featureId, '--skip-ready', '--json'];
|
|
176
|
+
const created = spur(env, opts.spurBin, args, opts.root);
|
|
554
177
|
let wbsNew = '';
|
|
555
178
|
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
|
-
: '');
|
|
179
|
+
const parsed = JSON.parse(created.stdout) as { wbs?: unknown; data?: { wbs?: unknown } | null };
|
|
180
|
+
const data = typeof parsed.data === 'object' && parsed.data !== null ? parsed.data : {};
|
|
181
|
+
wbsNew = [parsed.wbs, data.wbs].find((v): v is string => typeof v === 'string') ?? '';
|
|
563
182
|
} catch {
|
|
564
183
|
wbsNew = '';
|
|
565
184
|
}
|
|
566
|
-
if (wbsNew === '') {
|
|
567
|
-
io.err(`residual-settle:
|
|
185
|
+
if (created.status !== 0 || wbsNew === '') {
|
|
186
|
+
io.err(`residual-settle: task create failed or unreadable wbs; re-run: residual-scan settle ${wbs}\n`);
|
|
568
187
|
return 0;
|
|
569
188
|
}
|
|
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`);
|
|
189
|
+
const rows = deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`);
|
|
190
|
+
const head = `Source task: ${wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`;
|
|
191
|
+
const bgFile = join(runDir, `${wbs}-residual-background.md`);
|
|
192
|
+
writeFileSync(bgFile, `${[head, '', ...rows].join('\n')}\n`);
|
|
193
|
+
const updArgs = ['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile];
|
|
194
|
+
if (spur(env, opts.spurBin, updArgs, opts.root).status !== 0) {
|
|
195
|
+
io.err(`residual-settle: background write failed; re-run: residual-scan settle ${wbs}\n`);
|
|
585
196
|
return 0;
|
|
586
197
|
}
|
|
587
198
|
prior.followUp = wbsNew;
|
|
588
199
|
io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)\n`);
|
|
589
200
|
}
|
|
590
|
-
|
|
591
|
-
for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
|
|
201
|
+
for (const path of listStagingResidue(opts.tmpDir, wbs)) {
|
|
592
202
|
try {
|
|
593
203
|
rmSync(path, { force: true });
|
|
594
204
|
} catch {
|
|
595
|
-
io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${
|
|
205
|
+
io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}\n`);
|
|
596
206
|
return 0;
|
|
597
207
|
}
|
|
598
208
|
}
|
|
599
209
|
writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
|
|
600
210
|
return 0;
|
|
601
211
|
}
|
|
602
|
-
|
|
603
|
-
function reportMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
|
|
212
|
+
function reportMode(opts: ParsedArgs, env: ScanEnv, io: ScanIo): number {
|
|
604
213
|
const runDir = join(opts.root, '.spur', 'run');
|
|
605
|
-
const
|
|
606
|
-
const sweep = verdict.checks.find((c) => c.name === 'residual-sweep');
|
|
214
|
+
const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === 'residual-sweep');
|
|
607
215
|
if (sweep === undefined || sweep.status !== 'fail') return 0;
|
|
608
|
-
const
|
|
609
|
-
const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir,
|
|
216
|
+
const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
217
|
+
const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, content, env);
|
|
610
218
|
const blocking = scan.items.filter((i) => i.class === 'blocking');
|
|
611
219
|
const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
|
|
612
220
|
const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, 'utf8').trim() || '0', 10) : 0;
|
|
613
|
-
|
|
614
|
-
|
|
221
|
+
writeFileSync(
|
|
222
|
+
join(runDir, `${opts.wbs}-residual-report.md`),
|
|
223
|
+
core.renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts),
|
|
224
|
+
);
|
|
615
225
|
io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}\n`);
|
|
616
226
|
return 0;
|
|
617
227
|
}
|
|
618
|
-
|
|
619
228
|
export function main(argv: string[], env: ScanEnv = getEnvVars(), options: ScanOptions = {}): number {
|
|
620
229
|
const io: ScanIo = options.io ?? {
|
|
621
230
|
out: (line) => process.stdout.write(line),
|
|
622
231
|
err: (line) => process.stderr.write(line),
|
|
623
232
|
};
|
|
233
|
+
const cwd = options.cwd ?? process.cwd();
|
|
624
234
|
const opts = parseArgs(argv);
|
|
625
|
-
|
|
235
|
+
const modes: Record<string, ModeFn> = { scan: scanMode, fold: foldMode, settle: settleMode, report: reportMode };
|
|
236
|
+
const modeFn = opts === null ? undefined : modes[opts.mode];
|
|
237
|
+
if (opts === null || modeFn === undefined) {
|
|
626
238
|
io.err(`${RESIDUAL_SCAN_USAGE}\n`);
|
|
627
239
|
return 2;
|
|
628
240
|
}
|
|
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)));
|
|
241
|
+
const resolved: ParsedArgs = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
|
|
242
|
+
return modeFn(resolved, env, io);
|
|
641
243
|
}
|
|
244
|
+
if (import.meta.main) process.exit(main(process.argv.slice(2)));
|