@gobing-ai/spur 0.3.91 → 0.3.93
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/pipeline-budgets.json +2 -9
- package/config/plugin-scripts.json +27 -1
- package/config/templates/AGENTS.md +4 -0
- package/config/templates/docs/02_ROADMAP.md +2 -0
- package/config/templates/docs/03_ARCHITECTURE.md +3 -1
- package/config/templates/docs/04_DESIGN.md +2 -0
- package/config/templates/docs/99_PROJECT_CONSTITUTION.md +10 -2
- package/config/workflow-candidates.json +72 -1
- package/config/workflows/feature-lifecycle.yaml +14 -5
- package/config/workflows/feature-verification.yaml +36 -27
- package/config/workflows/history-anatomy.yaml +16 -25
- package/config/workflows/idea-pipeline.yaml +76 -23
- package/config/workflows/pr-review.yaml +8 -0
- package/config/workflows/task-pipeline.yaml +362 -40
- package/config/workflows/wayfinder-resolution.yaml +5 -0
- package/config/workflows/wrapup-pipeline.yaml +102 -19
- package/package.json +9 -9
- package/plugins/sp/README.md +7 -2
- package/plugins/sp/agents/super-planner.md +14 -5
- package/plugins/sp/commands/dev-dogfood.md +4 -4
- package/plugins/sp/commands/dev-fixall.md +8 -5
- package/plugins/sp/commands/dev-run.md +8 -2
- package/plugins/sp/commands/dev-runall.md +14 -8
- package/plugins/sp/commands/dev-verify.md +9 -0
- package/plugins/sp/commands/dev-verifyall.md +5 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +306 -301
- package/plugins/sp/lib/inline-run.generated.d.mts +18 -0
- package/plugins/sp/lib/inline-run.generated.mjs +1468 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/environment-lens.md +1 -1
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +136 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +196 -2
- package/plugins/sp/scripts/feature-verification-steps.mjs +174 -0
- package/plugins/sp/scripts/feature-verification-steps.ts +275 -0
- package/plugins/sp/scripts/history-anatomy-cache.mjs +104 -4
- package/plugins/sp/scripts/history-anatomy-cache.ts +137 -13
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +2 -1
- package/plugins/sp/scripts/inline-run-setup.mjs +421 -0
- package/plugins/sp/scripts/inline-run-setup.ts +325 -78
- package/plugins/sp/scripts/quality-gate.mjs +248 -6
- package/plugins/sp/scripts/quality-gate.ts +410 -9
- package/plugins/sp/scripts/record-feature-sync.mjs +63 -0
- package/plugins/sp/scripts/record-feature-sync.ts +84 -0
- package/plugins/sp/scripts/residual-scan.mjs +484 -0
- package/plugins/sp/scripts/residual-scan.ts +640 -0
- package/plugins/sp/scripts/task-diffstat.mjs +156 -0
- package/plugins/sp/scripts/task-diffstat.ts +229 -0
- package/plugins/sp/scripts/task-evidence-precheck.ts +8 -3
- package/plugins/sp/scripts/task-size-precheck.ts +8 -3
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +181 -0
- package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +60 -1
- package/plugins/sp/scripts/wrapup-steps.ts +89 -4
- package/plugins/sp/skills/brainstorm/SKILL.md +2 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +17 -2
- package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +2 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +17 -0
- package/plugins/sp/skills/code-verification/SKILL.md +21 -0
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -2
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +1 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +5 -3
- package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +63 -26
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +33 -10
- package/plugins/sp/skills/history-anatomy/references/modes.md +5 -3
- package/plugins/sp/skills/next-feature/references/ranking-rubric.md +1 -1
- package/plugins/sp/skills/next-router/references/routing-table.md +7 -0
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/session-review/SKILL.md +12 -2
- package/plugins/sp/skills/spur-check/SKILL.md +112 -0
- package/plugins/sp/skills/spur-cli/references/features.md +1 -1
- package/plugins/sp/skills/spur-cli/references/projects.md +3 -1
- package/plugins/sp/skills/spur-cli/references/workflows.md +39 -19
- package/plugins/sp/skills/spur-dev/SKILL.md +13 -5
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +18 -3
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -2
- package/plugins/sp/skills/spur-dev/references/document-authoring.md +85 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +286 -58
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +1 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +27 -4
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +3 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +61 -16
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +10 -5
- package/plugins/sp/skills/spur-dev/templates/design.md +31 -0
- package/plugins/sp/skills/spur-dev/templates/plan.md +32 -0
- package/plugins/sp/skills/spur-doctor/SKILL.md +60 -14
- package/schemas/state-machine-workflow.schema.json +4 -0
- package/spur.js +22501 -19644
- package/web/_astro/BoardApp.CerSBgis.js +192 -0
- package/web/_astro/BoardApp.eoTz0pZs.js +1 -0
- package/web/_astro/{TaskDetail.CXGltuT_.js → TaskDetail.DCqiC-OZ.js} +1 -1
- package/web/_astro/arc.CPwg6Rw0.js +1 -0
- package/web/_astro/{architectureDiagram-3BPJPVTR.DJ8DHkWE.js → architectureDiagram-3BPJPVTR.DM_vp_hO.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.D8DHK3Jl.js → blockDiagram-GPEHLZMM.DXVIiv0p.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.BugQbX9u.js → c4Diagram-AAUBKEIU.BbF_zCxW.js} +1 -1
- package/web/_astro/channel.MYZLKNwy.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.Mv26KlVn.js → chunk-2J33WTMH.CAgQHpPC.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.CD51JoT_.js → chunk-4BX2VUAB.BN-5tpw4.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.D_PFaIEe.js → chunk-55IACEB6.CnPkEEr0.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DemMW1ao.js → chunk-727SXJPM.BQzQeMVm.js} +4 -4
- package/web/_astro/{chunk-AQP2D5EJ.Iu2V5-ex.js → chunk-AQP2D5EJ.B6xNyDnL.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.MsNgSP-E.js → chunk-FMBD7UC4.C7f9Ih78.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.DfbRaAlm.js → chunk-ND2GUHAM.CNV1dFXT.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.BbJAQ4h-.js → chunk-QZHKN3VN.Cudn2TkJ.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CPurtiC2.js → classDiagram-4FO5ZUOK.D1NwP50q.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CPurtiC2.js → classDiagram-v2-Q7XG4LA2.D1NwP50q.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CKKdx1bM.js → cose-bilkent-S5V4N54A.B1wSL-Xb.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.CoKMTg-R.js → cynefin-OW5HDTMX.BmK52w8G.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.D4h4_k56.js → dagre-BM42HDAG.Bfy5CTDT.js} +2 -2
- package/web/_astro/diagram-2AECGRRQ.DhNnvUvX.js +43 -0
- package/web/_astro/diagram-5GNKFQAL.lTX5KwnS.js +10 -0
- package/web/_astro/{diagram-KO2AKTUF.BFoCkiCr.js → diagram-KO2AKTUF.CW_vMJ4z.js} +3 -3
- package/web/_astro/{diagram-LMA3HP47.exHn9OVx.js → diagram-LMA3HP47.B_8ZGF67.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.CeqO34nN.js → diagram-OG6HWLK6.BppnHsdS.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.D_v7HqxR.js → erDiagram-TEJ5UH35.BEuHXcjJ.js} +5 -5
- package/web/_astro/{flowDiagram-I6XJVG4X.EUmrpbwh.js → flowDiagram-I6XJVG4X.CH-UlnGr.js} +4 -4
- package/web/_astro/{ganttDiagram-6RSMTGT7.BOCF5lII.js → ganttDiagram-6RSMTGT7.BO81S85v.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.Di7otYZD.js → gitGraphDiagram-PVQCEYII.XnPxPPZN.js} +1 -1
- package/web/_astro/index.Hjbr15fG.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.TcBkCAJk.js → infoDiagram-5YYISTIA.JyjYRu_T.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.D-2y4M0c.js → ishikawaDiagram-YF4QCWOH.BBRBF-Fo.js} +5 -5
- package/web/_astro/{journeyDiagram-JHISSGLW.DPbJI_n2.js → journeyDiagram-JHISSGLW.C_iymSyp.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.EFxhQ9Fj.js → kanban-definition-UN3LZRKU.DdfW-Oqt.js} +7 -7
- package/web/_astro/{linear.DSAsQLzs.js → linear.C2_IkbZT.js} +1 -1
- package/web/_astro/mermaid.core.GAOYeSR0.js +303 -0
- package/web/_astro/{mindmap-definition-RKZ34NQL.CJY1N_7V.js → mindmap-definition-RKZ34NQL.DAZIxQSK.js} +2 -2
- package/web/_astro/{pieDiagram-4H26LBE5.567ZNoL2.js → pieDiagram-4H26LBE5.CN8sIhKM.js} +3 -3
- package/web/_astro/{quadrantDiagram-W4KKPZXB.mqfz9-MY.js → quadrantDiagram-W4KKPZXB.3dGcX5GP.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.Bv1Gv9In.js → requirementDiagram-4Y6WPE33.BV2y4dd6.js} +3 -3
- package/web/_astro/{sankeyDiagram-5OEKKPKP.B6Gs4X4r.js → sankeyDiagram-5OEKKPKP.Cqo15Tvo.js} +4 -4
- package/web/_astro/{sequenceDiagram-3UESZ5HK.BhYj4v-m.js → sequenceDiagram-3UESZ5HK.CROCPMJB.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BPbBnkpw.js → stateDiagram-AJRCARHV.RfXZrkFE.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.C4squMNK.js → stateDiagram-v2-BHNVJYJU.CPXmbBs9.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.C_SwIHgl.js → timeline-definition-PNZ67QCA.DdgKTiO8.js} +3 -3
- package/web/_astro/{vennDiagram-CIIHVFJN.Bz4NZGpQ.js → vennDiagram-CIIHVFJN.CPNVSHF1.js} +5 -5
- package/web/_astro/{wardleyDiagram-YWT4CUSO.CozMVZ3i.js → wardleyDiagram-YWT4CUSO.CQhA0Jyr.js} +3 -3
- package/web/_astro/{xychartDiagram-2RQKCTM6.BwMGBwjB.js → xychartDiagram-2RQKCTM6.n61BWyy4.js} +1 -1
- package/web/index.html +2 -2
- package/config/workflows/decision-routing-example.yaml +0 -134
- package/web/_astro/BoardApp.BEDWpzsr.js +0 -188
- package/web/_astro/BoardApp.DQG2xfEz.js +0 -1
- package/web/_astro/arc.C0rrflm_.js +0 -1
- package/web/_astro/channel.SRrg1P-w.js +0 -1
- package/web/_astro/diagram-2AECGRRQ.DJ0h9zgw.js +0 -43
- package/web/_astro/diagram-5GNKFQAL.DvPk1jYd.js +0 -10
- package/web/_astro/index.Bx6GY4RH.css +0 -1
- package/web/_astro/mermaid.core.kAZjgJHG.js +0 -301
|
@@ -0,0 +1,640 @@
|
|
|
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
|
+
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { getEnvVars } from '../lib/env';
|
|
25
|
+
|
|
26
|
+
export const RESIDUAL_SCAN_USAGE =
|
|
27
|
+
'usage: residual-scan.ts <scan|fold|settle|report> <wbs> [--spur-bin <bin>] [--root <dir>] [--tmp-dir <dir>]';
|
|
28
|
+
|
|
29
|
+
export type ResidualCategory = 'review-finding' | 'diff-marker' | 'unchecked-box' | 'staging-residue';
|
|
30
|
+
export type ResidualClass = 'blocking' | 'deferrable' | 'advisory' | 'housekeeping';
|
|
31
|
+
|
|
32
|
+
export interface ResidualItem {
|
|
33
|
+
id: string;
|
|
34
|
+
category: ResidualCategory;
|
|
35
|
+
class: ResidualClass;
|
|
36
|
+
priority?: string;
|
|
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
|
+
const NONE_FINDING = /^(none|—)$/i;
|
|
68
|
+
const DISPOSITION_HEADER = /^(Disposition|Action|Status|Resolution|Fixed)$/i;
|
|
69
|
+
const RESOLVED_DISPOSITION = /^(FIXED|RESOLVED|DONE)\b/i;
|
|
70
|
+
const DEFERRED_DISPOSITION = /^DEFER(RED)?\b/i;
|
|
71
|
+
const ANCHOR_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
|
|
72
|
+
/** `path:12-18` range anchor → single-line `path:12`. */
|
|
73
|
+
const RANGE_ANCHOR = /([A-Za-z0-9_./-]+\.[A-Za-z]+):([0-9]+)-[0-9]+/g;
|
|
74
|
+
const EXCLUDED_PATHS = ['docs/tasks', 'docs/features/', '.spur/'];
|
|
75
|
+
export const ALLOW_PRAGMA = 'residual-scan:allow';
|
|
76
|
+
|
|
77
|
+
/** `spurBin` splits on whitespace into command + prefix args (same as wrapup-steps). */
|
|
78
|
+
export function spurCommand(spurBin: string | undefined): { cmd: string; prefix: string[] } {
|
|
79
|
+
const parts = (spurBin ?? 'spur')
|
|
80
|
+
.trim()
|
|
81
|
+
.split(/\s+/)
|
|
82
|
+
.filter((p) => p.length > 0);
|
|
83
|
+
return { cmd: parts[0] ?? 'spur', prefix: parts.slice(1) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function run(cmd: string, args: string[], cwd: string): { status: number; stdout: string } {
|
|
87
|
+
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
|
88
|
+
if (result.error !== undefined) return { status: result.status ?? 1, stdout: '' };
|
|
89
|
+
return { status: result.status ?? 1, stdout: result.stdout ?? '' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function makeItemId(category: ResidualCategory, location: string, text: string): string {
|
|
93
|
+
const normalized = text.trim().replace(/\s+/g, ' ');
|
|
94
|
+
const hex = createHash('sha256').update(`${location}${normalized}`).digest('hex');
|
|
95
|
+
return `${category}:${hex.slice(0, 8)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `path:12-18` → `path:12` (anchor normalization from the design doc). */
|
|
99
|
+
export function normalizeAnchor(location: string): string {
|
|
100
|
+
return location.replace(RANGE_ANCHOR, '$1:$2');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Normalize a finding cell to an anchor: Location column, else first backticked `path:line`. */
|
|
104
|
+
export function locationOf(locationCell: string, finding: string): string {
|
|
105
|
+
const cell = locationCell.trim().replace(/`/g, '');
|
|
106
|
+
if (cell.length > 0 && cell !== '—') return normalizeAnchor(cell);
|
|
107
|
+
const backtick = finding.match(/`([^`]+)`/);
|
|
108
|
+
return backtick === null ? '' : normalizeAnchor(backtick[1]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract review-finding rows: any `### Review` section table whose header carries a
|
|
113
|
+
* Priority column. Rows need `^P[1-4]` priority and a finding other than `none`/`—`.
|
|
114
|
+
* A disposition column (Disposition/Action/Status/Resolution/Fixed) is honored: `FIXED`/
|
|
115
|
+
* `RESOLVED`/`DONE` rows are dropped; `DEFER` rows carry the cell as an in-table deferral reason.
|
|
116
|
+
*/
|
|
117
|
+
export function parseReviewFindings(
|
|
118
|
+
taskContent: string,
|
|
119
|
+
): Array<{ priority: string; location: string; text: string; deferral?: string }> {
|
|
120
|
+
const section = taskContent.split(/^### Review\b/m)[1];
|
|
121
|
+
if (section === undefined) return [];
|
|
122
|
+
const body = section.split(/^### /m)[0];
|
|
123
|
+
const out: Array<{ priority: string; location: string; text: string; deferral?: string }> = [];
|
|
124
|
+
const lines = body.split('\n');
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
if (line === undefined || !line.trimStart().startsWith('|')) continue;
|
|
128
|
+
const header = splitRow(line);
|
|
129
|
+
const priorityCol = header.findIndex((h) => h.trim() === 'Priority');
|
|
130
|
+
if (priorityCol === -1) {
|
|
131
|
+
// Not a Priority table; skip its separator + body rows.
|
|
132
|
+
while (i + 1 < lines.length && lines[i + 1]?.trimStart().startsWith('|')) i++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const findingCol = header.findIndex((h) => h.trim() === 'Finding');
|
|
136
|
+
const locationCol = header.findIndex((h) => h.trim() === 'Location');
|
|
137
|
+
const dispositionCol = header.findIndex((h) => DISPOSITION_HEADER.test(h.trim()));
|
|
138
|
+
i++; // skip header
|
|
139
|
+
const sep = lines[i];
|
|
140
|
+
if (sep !== undefined && /^\s*\|[\s:|-]+\|\s*$/.test(sep)) i++; // skip separator
|
|
141
|
+
while (i < lines.length) {
|
|
142
|
+
const row = lines[i];
|
|
143
|
+
if (row === undefined || !row.trimStart().startsWith('|')) break;
|
|
144
|
+
const cells = splitRow(row);
|
|
145
|
+
const priority = (cells[priorityCol] ?? '').trim();
|
|
146
|
+
const finding = (cells[findingCol] ?? '').trim();
|
|
147
|
+
const disposition = dispositionCol === -1 ? '' : (cells[dispositionCol] ?? '').trim();
|
|
148
|
+
if (
|
|
149
|
+
PRIORITY_PATTERN.test(priority) &&
|
|
150
|
+
!NONE_FINDING.test(finding) &&
|
|
151
|
+
finding.length > 0 &&
|
|
152
|
+
!RESOLVED_DISPOSITION.test(disposition)
|
|
153
|
+
) {
|
|
154
|
+
const location = locationOf(cells[locationCol] ?? '', finding);
|
|
155
|
+
out.push(
|
|
156
|
+
DEFERRED_DISPOSITION.test(disposition)
|
|
157
|
+
? { priority, location, text: finding, deferral: disposition }
|
|
158
|
+
: { priority, location, text: finding },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
i++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function splitRow(line: string): string[] {
|
|
168
|
+
return line
|
|
169
|
+
.trim()
|
|
170
|
+
.replace(/^\|/, '')
|
|
171
|
+
.replace(/\|$/, '')
|
|
172
|
+
.split('|')
|
|
173
|
+
.map((c) => c.trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Added lines (`<file>:<line>:<text>`) from `git diff --unified=0 <base>` plus untracked files. */
|
|
177
|
+
export function collectAddedLines(root: string, base: string): Array<{ file: string; line: number; text: string }> {
|
|
178
|
+
const out: Array<{ file: string; line: number; text: string }> = [];
|
|
179
|
+
const diff = run('git', ['diff', '--unified=0', base], root);
|
|
180
|
+
let file = '';
|
|
181
|
+
let newLine = 0;
|
|
182
|
+
for (const line of diff.stdout.split('\n')) {
|
|
183
|
+
if (line.startsWith('+++ b/')) file = line.slice(6);
|
|
184
|
+
else if (line.startsWith('@@')) {
|
|
185
|
+
const m = line.match(/\+[0-9]+/);
|
|
186
|
+
newLine = m === null ? newLine : Number.parseInt(m[0].slice(1), 10);
|
|
187
|
+
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
188
|
+
out.push({ file, line: newLine, text: line.slice(1) });
|
|
189
|
+
newLine++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const untracked = run('git', ['ls-files', '--others', '--exclude-standard'], root);
|
|
193
|
+
for (const f of untracked.stdout.split('\n')) {
|
|
194
|
+
if (f.length === 0) continue;
|
|
195
|
+
try {
|
|
196
|
+
if (!statSync(join(root, f)).isFile()) continue;
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const content = readFileSync(join(root, f), 'utf8').split('\n');
|
|
201
|
+
content.forEach((text, idx) => {
|
|
202
|
+
out.push({ file: f, line: idx + 1, text });
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** TODO/FIXME/XXX/HACK markers on added lines, honoring path exclusions + allow pragma. */
|
|
209
|
+
export function parseDiffMarkers(
|
|
210
|
+
addedLines: Array<{ file: string; line: number; text: string }>,
|
|
211
|
+
): Array<{ location: string; text: string }> {
|
|
212
|
+
return addedLines
|
|
213
|
+
.filter((l) => !EXCLUDED_PATHS.some((p) => l.file.startsWith(p)))
|
|
214
|
+
.filter((l) => !l.text.includes(ALLOW_PRAGMA))
|
|
215
|
+
.filter((l) => MARKER_PATTERN.test(l.text))
|
|
216
|
+
.map((l) => ({ location: `${l.file}:${l.line}`, text: l.text.trim() }));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** `- [ ]` lines in the task file. */
|
|
220
|
+
export function findUncheckedBoxes(taskContent: string): Array<{ location: string; text: string }> {
|
|
221
|
+
const path = 'task-file';
|
|
222
|
+
return taskContent
|
|
223
|
+
.split('\n')
|
|
224
|
+
.map((text, idx) => ({ text: text.trim(), line: idx + 1 }))
|
|
225
|
+
.filter((l) => l.text.startsWith('- [ ]'))
|
|
226
|
+
.map((l) => ({ location: `${path}:${l.line}`, text: l.text }));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Regular files matching `<tmpDir>/<wbs>-*`. */
|
|
230
|
+
export function listStagingResidue(tmpDir: string, wbs: string): string[] {
|
|
231
|
+
let names: string[];
|
|
232
|
+
try {
|
|
233
|
+
names = readdirSync(tmpDir);
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
return names
|
|
238
|
+
.filter((n) => n.startsWith(`${wbs}-`))
|
|
239
|
+
.filter((n) => {
|
|
240
|
+
try {
|
|
241
|
+
return statSync(join(tmpDir, n)).isFile();
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
.map((n) => join(tmpDir, n));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
interface Deferral {
|
|
250
|
+
id: string;
|
|
251
|
+
reason: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readDeferrals(runDir: string, wbs: string): Deferral[] {
|
|
255
|
+
const path = join(runDir, `${wbs}-residual-deferrals.json`);
|
|
256
|
+
if (!existsSync(path)) return [];
|
|
257
|
+
try {
|
|
258
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
259
|
+
if (!Array.isArray(parsed)) return [];
|
|
260
|
+
return parsed.filter(
|
|
261
|
+
(e): e is Deferral =>
|
|
262
|
+
typeof e === 'object' &&
|
|
263
|
+
e !== null &&
|
|
264
|
+
typeof (e as Deferral).id === 'string' &&
|
|
265
|
+
typeof (e as Deferral).reason === 'string' &&
|
|
266
|
+
(e as Deferral).reason.trim().length > 0,
|
|
267
|
+
);
|
|
268
|
+
} catch {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Classification: P1–P3 findings, diff markers and unchecked boxes are blocking; P4 is
|
|
275
|
+
* advisory; staging residue is housekeeping. A deferral entry with a non-empty reason
|
|
276
|
+
* reclassifies a P3 finding or diff marker as deferrable — never P1/P2 or unchecked boxes.
|
|
277
|
+
*/
|
|
278
|
+
export function classify(
|
|
279
|
+
items: Array<Omit<ResidualItem, 'id' | 'class'> & { category: ResidualCategory }>,
|
|
280
|
+
deferrals: Deferral[],
|
|
281
|
+
): ResidualItem[] {
|
|
282
|
+
const deferred = new Map(deferrals.map((d) => [d.id, d.reason]));
|
|
283
|
+
return items.map((item) => {
|
|
284
|
+
const id = makeItemId(item.category, item.location, item.text);
|
|
285
|
+
let klass: ResidualClass;
|
|
286
|
+
if (item.category === 'review-finding') klass = item.priority?.startsWith('P4') ? 'advisory' : 'blocking';
|
|
287
|
+
else if (item.category === 'staging-residue') klass = 'housekeeping';
|
|
288
|
+
else klass = 'blocking';
|
|
289
|
+
if (klass === 'blocking' && item.category !== 'unchecked-box') {
|
|
290
|
+
const p3Like =
|
|
291
|
+
item.category === 'diff-marker' ||
|
|
292
|
+
(item.category === 'review-finding' && (item.priority ?? '').startsWith('P3'));
|
|
293
|
+
const reason = deferred.get(id);
|
|
294
|
+
if (p3Like && reason !== undefined && reason.trim().length > 0) klass = 'deferrable';
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
id,
|
|
298
|
+
category: item.category,
|
|
299
|
+
class: klass,
|
|
300
|
+
priority: item.priority,
|
|
301
|
+
location: item.location,
|
|
302
|
+
text: item.text,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function scanResiduals(
|
|
308
|
+
root: string,
|
|
309
|
+
wbs: string,
|
|
310
|
+
tmpDir: string,
|
|
311
|
+
taskContent: string,
|
|
312
|
+
_env: ScanEnv,
|
|
313
|
+
): ResidualArtifact {
|
|
314
|
+
const runDir = join(root, '.spur', 'run');
|
|
315
|
+
const basePath = join(runDir, `${wbs}-base.sha`);
|
|
316
|
+
const base = existsSync(basePath) ? readFileSync(basePath, 'utf8').trim() : null;
|
|
317
|
+
const reviewRows = parseReviewFindings(taskContent);
|
|
318
|
+
const tableDeferrals = reviewRows.flatMap((r) =>
|
|
319
|
+
r.deferral === undefined ? [] : [{ id: makeItemId('review-finding', r.location, r.text), reason: r.deferral }],
|
|
320
|
+
);
|
|
321
|
+
const review = reviewRows.map((r) => ({
|
|
322
|
+
category: 'review-finding' as const,
|
|
323
|
+
priority: r.priority,
|
|
324
|
+
location: r.location,
|
|
325
|
+
text: r.text,
|
|
326
|
+
}));
|
|
327
|
+
const markers =
|
|
328
|
+
base === null
|
|
329
|
+
? []
|
|
330
|
+
: parseDiffMarkers(collectAddedLines(root, base)).map((m) => ({
|
|
331
|
+
category: 'diff-marker' as const,
|
|
332
|
+
location: m.location,
|
|
333
|
+
text: m.text,
|
|
334
|
+
}));
|
|
335
|
+
const boxes = findUncheckedBoxes(taskContent).map((b) => ({
|
|
336
|
+
category: 'unchecked-box' as const,
|
|
337
|
+
location: b.location,
|
|
338
|
+
text: b.text,
|
|
339
|
+
}));
|
|
340
|
+
const residue = listStagingResidue(tmpDir, wbs).map((p) => ({
|
|
341
|
+
category: 'staging-residue' as const,
|
|
342
|
+
location: p,
|
|
343
|
+
text: p,
|
|
344
|
+
}));
|
|
345
|
+
const items = classify(
|
|
346
|
+
[...review, ...markers, ...boxes, ...residue],
|
|
347
|
+
[...tableDeferrals, ...readDeferrals(runDir, wbs)],
|
|
348
|
+
);
|
|
349
|
+
const counts = { blocking: 0, deferrable: 0, advisory: 0, housekeeping: 0 };
|
|
350
|
+
for (const item of items) counts[item.class]++;
|
|
351
|
+
return {
|
|
352
|
+
wbs,
|
|
353
|
+
base,
|
|
354
|
+
scanned: {
|
|
355
|
+
'review-finding': true,
|
|
356
|
+
'diff-marker': base !== null,
|
|
357
|
+
'unchecked-box': true,
|
|
358
|
+
'staging-residue': true,
|
|
359
|
+
},
|
|
360
|
+
items,
|
|
361
|
+
counts,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Blocking locations that match the `file.ext:line` findings-anchor shape. */
|
|
366
|
+
export function blockingAnchors(items: ResidualItem[]): string[] {
|
|
367
|
+
const anchors = new Set<string>();
|
|
368
|
+
for (const item of items) {
|
|
369
|
+
if (item.class !== 'blocking') continue;
|
|
370
|
+
for (const m of normalizeAnchor(item.location).matchAll(ANCHOR_PATTERN)) anchors.add(m[0]);
|
|
371
|
+
}
|
|
372
|
+
return [...anchors];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface FoldResult {
|
|
376
|
+
verdict: 'PASS' | 'PARTIAL' | 'FAIL';
|
|
377
|
+
checks: Array<{ name: string; status: string; evidence: string }>;
|
|
378
|
+
findings: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Fold the scan into a verdict: replace the `residual-sweep` check (fail when blocking > 0),
|
|
383
|
+
* downgrade PASS→PARTIAL when blocking > 0 (PARTIAL/FAIL unchanged), and merge blocking
|
|
384
|
+
* anchors into the gate findings (unique, sorted, cap 20). Idempotent.
|
|
385
|
+
*/
|
|
386
|
+
export function foldVerdict(
|
|
387
|
+
verdict: { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> },
|
|
388
|
+
scan: ResidualArtifact,
|
|
389
|
+
existingFindings: string,
|
|
390
|
+
maxFindings = 20,
|
|
391
|
+
): FoldResult {
|
|
392
|
+
const blocking = scan.items.filter((i) => i.class === 'blocking');
|
|
393
|
+
const deferrable = scan.items.filter((i) => i.class === 'deferrable');
|
|
394
|
+
const evidence =
|
|
395
|
+
`blocking=${blocking.length} deferrable=${deferrable.length} advisory=${scan.counts.advisory} housekeeping=${scan.counts.housekeeping}` +
|
|
396
|
+
(blocking.length > 0 ? `; blocking ids: ${blocking.map((i) => i.id).join(', ')}` : '') +
|
|
397
|
+
(deferrable.length > 0 ? `; deferrable ids: ${deferrable.map((i) => i.id).join(', ')}` : '');
|
|
398
|
+
const checks = verdict.checks.filter((c) => c.name !== 'residual-sweep');
|
|
399
|
+
checks.push({ name: 'residual-sweep', status: blocking.length > 0 ? 'fail' : 'pass', evidence });
|
|
400
|
+
const merged = new Set([
|
|
401
|
+
...existingFindings.split(/\s+/).filter((a) => a.length > 0),
|
|
402
|
+
...blockingAnchors(scan.items),
|
|
403
|
+
]);
|
|
404
|
+
const findings = [...merged]
|
|
405
|
+
.sort()
|
|
406
|
+
.slice(0, maxFindings)
|
|
407
|
+
.map((a) => `${a} `)
|
|
408
|
+
.join('');
|
|
409
|
+
let verdictStatus: FoldResult['verdict'] = verdict.verdict === 'PASS' ? 'PASS' : 'FAIL';
|
|
410
|
+
if (verdict.verdict === 'PARTIAL') verdictStatus = 'PARTIAL';
|
|
411
|
+
else if (verdict.verdict === 'FAIL') verdictStatus = 'FAIL';
|
|
412
|
+
else if (blocking.length > 0) verdictStatus = 'PARTIAL';
|
|
413
|
+
return { verdict: verdictStatus, checks, findings };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Render the recovery report for blocking items. */
|
|
417
|
+
export function renderReport(wbs: string, items: ResidualItem[], attemptCount: number): string {
|
|
418
|
+
const lines = [
|
|
419
|
+
`# Residual report — ${wbs}`,
|
|
420
|
+
'',
|
|
421
|
+
`Attempt: ${attemptCount}`,
|
|
422
|
+
'',
|
|
423
|
+
'| Category | Class | Location | Text |',
|
|
424
|
+
'| --- | --- | --- | --- |',
|
|
425
|
+
];
|
|
426
|
+
for (const item of items) {
|
|
427
|
+
lines.push(`| ${item.category} | ${item.class} | ${item.location} | ${item.text.replace(/\|/g, '\\|')} |`);
|
|
428
|
+
}
|
|
429
|
+
return `${lines.join('\n')}\n`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── impure modes ─────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
function parseArgs(
|
|
435
|
+
argv: string[],
|
|
436
|
+
): { mode: string; wbs: string; spurBin?: string; root: string; tmpDir: string } | null {
|
|
437
|
+
let mode = '';
|
|
438
|
+
let wbs = '';
|
|
439
|
+
let spurBin: string | undefined;
|
|
440
|
+
let root = process.cwd();
|
|
441
|
+
let tmpDir = tmpdir();
|
|
442
|
+
for (let i = 0; i < argv.length; i++) {
|
|
443
|
+
const a = argv[i];
|
|
444
|
+
if (a === undefined) break;
|
|
445
|
+
if (a === '--spur-bin') spurBin = argv[++i];
|
|
446
|
+
else if (a === '--root') root = argv[++i] ?? root;
|
|
447
|
+
else if (a === '--tmp-dir') tmpDir = argv[++i] ?? tmpDir;
|
|
448
|
+
else if (a === '--help' || a === '-h') return null;
|
|
449
|
+
else if (mode === '') mode = a;
|
|
450
|
+
else if (wbs === '') wbs = a;
|
|
451
|
+
}
|
|
452
|
+
if (mode === '' || wbs === '') return null;
|
|
453
|
+
return { mode, wbs, spurBin, root, tmpDir };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function spur(
|
|
457
|
+
env: ScanEnv,
|
|
458
|
+
spurBinFlag: string | undefined,
|
|
459
|
+
args: string[],
|
|
460
|
+
cwd: string,
|
|
461
|
+
): { status: number; stdout: string } {
|
|
462
|
+
return run(
|
|
463
|
+
spurCommand(spurBinFlag ?? env.spurBin).cmd,
|
|
464
|
+
[...spurCommand(spurBinFlag ?? env.spurBin).prefix, ...args],
|
|
465
|
+
cwd,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function loadTask(
|
|
470
|
+
env: ScanEnv,
|
|
471
|
+
spurBinFlag: string | undefined,
|
|
472
|
+
wbs: string,
|
|
473
|
+
root: string,
|
|
474
|
+
): { content: string; featureId: string } {
|
|
475
|
+
const res = spur(env, spurBinFlag, ['task', 'show', wbs, '--json'], root);
|
|
476
|
+
if (res.status !== 0) throw new Error(`task show ${wbs} failed`);
|
|
477
|
+
const parsed = JSON.parse(res.stdout) as Record<string, unknown>;
|
|
478
|
+
const content = typeof parsed.content === 'string' ? parsed.content : '';
|
|
479
|
+
const fm = parsed.frontmatter;
|
|
480
|
+
const featureId =
|
|
481
|
+
typeof parsed.feature_id === 'string'
|
|
482
|
+
? parsed.feature_id
|
|
483
|
+
: fm !== null && typeof fm === 'object' && typeof (fm as Record<string, unknown>).feature_id === 'string'
|
|
484
|
+
? ((fm as Record<string, unknown>).feature_id as string)
|
|
485
|
+
: '';
|
|
486
|
+
return { content, featureId };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function loadVerdict(
|
|
490
|
+
runDir: string,
|
|
491
|
+
wbs: string,
|
|
492
|
+
): { verdict: string; checks: Array<{ name: string; status: string; evidence: string }> } {
|
|
493
|
+
return JSON.parse(readFileSync(join(runDir, `${wbs}-verdict.json`), 'utf8'));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function scanMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
|
|
497
|
+
const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
498
|
+
const runDir = join(opts.root, '.spur', 'run');
|
|
499
|
+
mkdirSync(runDir, { recursive: true });
|
|
500
|
+
const artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
|
|
501
|
+
writeFileSync(join(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}\n`);
|
|
502
|
+
io.out(
|
|
503
|
+
`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}\n`,
|
|
504
|
+
);
|
|
505
|
+
return 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function foldMode(opts: NonNullable<ReturnType<typeof parseArgs>>, _env: ScanEnv, io: ScanIo): number {
|
|
509
|
+
const runDir = join(opts.root, '.spur', 'run');
|
|
510
|
+
const scan = JSON.parse(readFileSync(join(runDir, `${opts.wbs}-residuals.json`), 'utf8')) as ResidualArtifact;
|
|
511
|
+
const verdictPath = join(runDir, `${opts.wbs}-verdict.json`);
|
|
512
|
+
const verdict = loadVerdict(runDir, opts.wbs);
|
|
513
|
+
const findingsPath = join(runDir, `${opts.wbs}-test-gate.findings`);
|
|
514
|
+
const existing = existsSync(findingsPath) ? readFileSync(findingsPath, 'utf8') : '';
|
|
515
|
+
const folded = foldVerdict(verdict, scan, existing);
|
|
516
|
+
writeFileSync(
|
|
517
|
+
verdictPath,
|
|
518
|
+
`${JSON.stringify({ ...verdict, verdict: folded.verdict, checks: folded.checks }, null, 2)}\n`,
|
|
519
|
+
);
|
|
520
|
+
writeFileSync(findingsPath, folded.findings);
|
|
521
|
+
io.out(
|
|
522
|
+
`residual-fold: ${opts.wbs} verdict=${folded.verdict} residual-sweep=${folded.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
|
|
523
|
+
);
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function settleMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
|
|
528
|
+
const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
529
|
+
const runDir = join(opts.root, '.spur', 'run');
|
|
530
|
+
const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
|
|
531
|
+
const residualsPath = join(runDir, `${opts.wbs}-residuals.json`);
|
|
532
|
+
const deferred = scan.items.filter((i) => i.class === 'deferrable');
|
|
533
|
+
const prior = existsSync(residualsPath)
|
|
534
|
+
? (JSON.parse(readFileSync(residualsPath, 'utf8')) as Partial<ResidualArtifact & { followUp?: string }>)
|
|
535
|
+
: {};
|
|
536
|
+
if (deferred.length > 0 && prior.followUp === undefined) {
|
|
537
|
+
if (task.featureId === '') {
|
|
538
|
+
io.err(
|
|
539
|
+
`residual-settle: ${opts.wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${opts.wbs}\n`,
|
|
540
|
+
);
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
const created = spur(
|
|
544
|
+
env,
|
|
545
|
+
opts.spurBin,
|
|
546
|
+
['task', 'create', `Residuals from ${opts.wbs}`, '--feature', task.featureId, '--skip-ready', '--json'],
|
|
547
|
+
opts.root,
|
|
548
|
+
);
|
|
549
|
+
if (created.status !== 0) {
|
|
550
|
+
io.err(`residual-settle: task create failed; re-run: residual-scan settle ${opts.wbs}\n`);
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
let wbsNew = '';
|
|
554
|
+
try {
|
|
555
|
+
const parsed = JSON.parse(created.stdout) as Record<string, unknown>;
|
|
556
|
+
const pick = (o: Record<string, unknown>): string => (typeof o.wbs === 'string' ? o.wbs : '');
|
|
557
|
+
wbsNew =
|
|
558
|
+
pick(parsed) ||
|
|
559
|
+
(parsed.data !== null && typeof parsed.data === 'object'
|
|
560
|
+
? pick(parsed.data as Record<string, unknown>)
|
|
561
|
+
: '');
|
|
562
|
+
} catch {
|
|
563
|
+
wbsNew = '';
|
|
564
|
+
}
|
|
565
|
+
if (wbsNew === '') {
|
|
566
|
+
io.err(`residual-settle: could not read created task wbs; re-run: residual-scan settle ${opts.wbs}\n`);
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
const bg = [
|
|
570
|
+
`Source task: ${opts.wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`,
|
|
571
|
+
'',
|
|
572
|
+
...deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`),
|
|
573
|
+
].join('\n');
|
|
574
|
+
const bgFile = join(runDir, `${opts.wbs}-residual-background.md`);
|
|
575
|
+
writeFileSync(bgFile, `${bg}\n`);
|
|
576
|
+
const upd = spur(
|
|
577
|
+
env,
|
|
578
|
+
opts.spurBin,
|
|
579
|
+
['task', 'update', wbsNew, '--section', 'Background', '--from-file', bgFile],
|
|
580
|
+
opts.root,
|
|
581
|
+
);
|
|
582
|
+
if (upd.status !== 0) {
|
|
583
|
+
io.err(`residual-settle: background write failed; re-run: residual-scan settle ${opts.wbs}\n`);
|
|
584
|
+
return 0;
|
|
585
|
+
}
|
|
586
|
+
prior.followUp = wbsNew;
|
|
587
|
+
io.out(`residual-settle: filed follow-up ${wbsNew} for ${deferred.length} deferred item(s)\n`);
|
|
588
|
+
}
|
|
589
|
+
// Cleanup: only regular files `<tmpDir>/<wbs>-*`. Never directories, never other prefixes.
|
|
590
|
+
for (const path of listStagingResidue(opts.tmpDir, opts.wbs)) {
|
|
591
|
+
try {
|
|
592
|
+
rmSync(path, { force: true });
|
|
593
|
+
} catch {
|
|
594
|
+
io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${opts.wbs}\n`);
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}\n`);
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function reportMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
|
|
603
|
+
const runDir = join(opts.root, '.spur', 'run');
|
|
604
|
+
const verdict = loadVerdict(runDir, opts.wbs);
|
|
605
|
+
const sweep = verdict.checks.find((c) => c.name === 'residual-sweep');
|
|
606
|
+
if (sweep === undefined || sweep.status !== 'fail') return 0;
|
|
607
|
+
const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
608
|
+
const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
|
|
609
|
+
const blocking = scan.items.filter((i) => i.class === 'blocking');
|
|
610
|
+
const attemptFile = join(runDir, `${opts.wbs}-test-fix-attempt`);
|
|
611
|
+
const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, 'utf8').trim() || '0', 10) : 0;
|
|
612
|
+
const reportPath = join(runDir, `${opts.wbs}-residual-report.md`);
|
|
613
|
+
writeFileSync(reportPath, renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
|
|
614
|
+
io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}\n`);
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function main(argv: string[], env: ScanEnv = getEnvVars(), options: ScanOptions = {}): number {
|
|
619
|
+
const io: ScanIo = options.io ?? {
|
|
620
|
+
out: (line) => process.stdout.write(line),
|
|
621
|
+
err: (line) => process.stderr.write(line),
|
|
622
|
+
};
|
|
623
|
+
const opts = parseArgs(argv);
|
|
624
|
+
if (opts === null) {
|
|
625
|
+
io.err(`${RESIDUAL_SCAN_USAGE}\n`);
|
|
626
|
+
return 2;
|
|
627
|
+
}
|
|
628
|
+
const cwd = options.cwd ?? process.cwd();
|
|
629
|
+
const resolved = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
|
|
630
|
+
if (resolved.mode === 'scan') return scanMode(resolved, env, io);
|
|
631
|
+
if (resolved.mode === 'fold') return foldMode(resolved, env, io);
|
|
632
|
+
if (resolved.mode === 'settle') return settleMode(resolved, env, io);
|
|
633
|
+
if (resolved.mode === 'report') return reportMode(resolved, env, io);
|
|
634
|
+
io.err(`${RESIDUAL_SCAN_USAGE}\n`);
|
|
635
|
+
return 2;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (import.meta.main) {
|
|
639
|
+
process.exit(main(process.argv.slice(2)));
|
|
640
|
+
}
|