@gobing-ai/spur 0.3.94 → 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.
Files changed (198) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +42 -0
  3. package/board/index.d.ts +38 -0
  4. package/config/plugin-scripts.json +3 -52
  5. package/config/rules/boundary/sp-script-placement.yaml +19 -0
  6. package/config/rules/strict/runtime-boundaries.yaml +5 -0
  7. package/config/rules/structure/test-location.yaml +2 -0
  8. package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
  9. package/config/rules/typescript/output-boundaries.yaml +15 -2
  10. package/config/script-placement-baseline.json +51 -0
  11. package/config/workflows/feature-verification.yaml +7 -4
  12. package/config/workflows/history-anatomy.yaml +23 -11
  13. package/config/workflows/idea-pipeline.yaml +87 -86
  14. package/config/workflows/pr-review.yaml +18 -9
  15. package/config/workflows/task-pipeline.yaml +84 -109
  16. package/config/workflows/wrapup-pipeline.yaml +173 -46
  17. package/package.json +15 -11
  18. package/plugins/sp/README.md +8 -10
  19. package/plugins/sp/agents/super-reviewer.md +43 -18
  20. package/plugins/sp/commands/dev-fixgha.md +83 -0
  21. package/plugins/sp/commands/dev-gitmsg.md +8 -6
  22. package/plugins/sp/commands/dev-gtd.md +2 -2
  23. package/plugins/sp/commands/dev-idea.md +22 -12
  24. package/plugins/sp/commands/dev-plan.md +8 -9
  25. package/plugins/sp/commands/dev-review.md +22 -13
  26. package/plugins/sp/commands/dev-verify.md +7 -4
  27. package/plugins/sp/commands/dev-verifyall.md +5 -4
  28. package/plugins/sp/commands/dev-wrap.md +1 -1
  29. package/plugins/sp/commands/spur-init.md +2 -2
  30. package/plugins/sp/hooks/context-post-tool.ts +2 -2
  31. package/plugins/sp/hooks/context-session-start.ts +2 -1
  32. package/plugins/sp/hooks/context-session-stop.ts +22 -14
  33. package/plugins/sp/hooks/pi/guard-extension.ts +109 -150
  34. package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
  35. package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
  36. package/plugins/sp/lib/idea-handoff.generated.mjs +6 -5
  37. package/plugins/sp/lib/inline-run.generated.d.mts +13 -2
  38. package/plugins/sp/lib/inline-run.generated.mjs +33 -16
  39. package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
  40. package/plugins/sp/lib/quality-gate.generated.mjs +438 -0
  41. package/plugins/sp/lib/residual-scan.generated.d.mts +62 -0
  42. package/plugins/sp/lib/residual-scan.generated.mjs +210 -0
  43. package/plugins/sp/lib/spur-bin.ts +36 -0
  44. package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
  45. package/plugins/sp/lib/step-profile.generated.mjs +174 -0
  46. package/plugins/sp/plugin.json +1 -1
  47. package/plugins/sp/references/roles.md +1 -1
  48. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
  49. package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
  50. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
  51. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
  52. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
  53. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
  54. package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
  55. package/plugins/sp/scripts/history-anatomy-cache.mjs +23 -22
  56. package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
  57. package/plugins/sp/scripts/inline-run-setup.mjs +90 -315
  58. package/plugins/sp/scripts/inline-run-setup.ts +109 -639
  59. package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
  60. package/plugins/sp/scripts/pr-reviewing.ts +1 -0
  61. package/plugins/sp/scripts/quality-gate.mjs +38 -21
  62. package/plugins/sp/scripts/quality-gate.ts +20 -660
  63. package/plugins/sp/scripts/residual-scan.mjs +138 -159
  64. package/plugins/sp/scripts/residual-scan.ts +102 -499
  65. package/plugins/sp/scripts/script-root.mjs +129 -0
  66. package/plugins/sp/scripts/script-root.ts +199 -0
  67. package/plugins/sp/scripts/workflow-step-profile.mjs +27 -16
  68. package/plugins/sp/scripts/workflow-step-profile.ts +22 -314
  69. package/plugins/sp/scripts/wrapup-drift-probe.mjs +12 -3
  70. package/plugins/sp/scripts/wrapup-drift-probe.ts +6 -3
  71. package/plugins/sp/scripts/wrapup-steps.mjs +42 -40
  72. package/plugins/sp/scripts/wrapup-steps.ts +64 -46
  73. package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
  74. package/plugins/sp/skills/code-verification/SKILL.md +46 -16
  75. package/plugins/sp/skills/code-verification/references/verdict-schema.md +13 -2
  76. package/plugins/sp/skills/functional-review/SKILL.md +8 -5
  77. package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
  78. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
  79. package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
  80. package/plugins/sp/skills/next-router/references/routing-table.md +2 -2
  81. package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
  82. package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
  83. package/plugins/sp/skills/spur-cli/references/features.md +17 -6
  84. package/plugins/sp/skills/spur-cli/references/init.md +17 -16
  85. package/plugins/sp/skills/spur-cli/references/self.md +3 -2
  86. package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
  87. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +28 -5
  88. package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
  89. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
  90. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +11 -9
  91. package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
  92. package/plugins/sp/skills/spur-dev/references/dev-operations.md +86 -70
  93. package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -3
  94. package/plugins/sp/skills/spur-dev/references/execution-batch.md +107 -44
  95. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +5 -9
  96. package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
  97. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +31 -21
  98. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
  99. package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
  100. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +35 -6
  101. package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
  102. package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
  103. package/plugins/sp/tsconfig.json +8 -0
  104. package/spur.js +6638 -4775
  105. package/web/_astro/BoardApp.BIjMatT1.js +1 -0
  106. package/web/_astro/{BoardApp.FTEs3-N8.js → BoardApp.GvjIe9Z6.js} +150 -153
  107. package/web/_astro/TaskDetail.BZM3EAFx.js +1 -0
  108. package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
  109. package/web/_astro/{arc.uuAf51IT.js → arc.HkRiZnoI.js} +1 -1
  110. package/web/_astro/architectureDiagram-3BPJPVTR.BDdK-tgZ.js +36 -0
  111. package/web/_astro/{blockDiagram-GPEHLZMM.D1mGCq3p.js → blockDiagram-GPEHLZMM.CZ9kOBxx.js} +1 -1
  112. package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
  113. package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
  114. package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
  115. package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
  116. package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
  117. package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
  118. package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
  119. package/web/_astro/{c4Diagram-AAUBKEIU.CMsolcde.js → c4Diagram-AAUBKEIU.DXK9qeRD.js} +1 -1
  120. package/web/_astro/channel.DlhX2MEt.js +1 -0
  121. package/web/_astro/{chunk-2J33WTMH.CrGA3fik.js → chunk-2J33WTMH.BTD2WeX5.js} +1 -1
  122. package/web/_astro/{chunk-4BX2VUAB.DsLVVla0.js → chunk-4BX2VUAB.Ci6Qbcvk.js} +1 -1
  123. package/web/_astro/{chunk-55IACEB6.Dxc59Tfi.js → chunk-55IACEB6.yl3zsj7p.js} +1 -1
  124. package/web/_astro/{chunk-727SXJPM.CaEVE2Wy.js → chunk-727SXJPM.ClXZsfyR.js} +1 -1
  125. package/web/_astro/{chunk-AQP2D5EJ.BiJ4HXeI.js → chunk-AQP2D5EJ.BmKWZBcP.js} +1 -1
  126. package/web/_astro/{chunk-FMBD7UC4.Mxf1fru5.js → chunk-FMBD7UC4.By30fcb7.js} +1 -1
  127. package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
  128. package/web/_astro/{chunk-ND2GUHAM.pyOWQixH.js → chunk-ND2GUHAM.24NmrD-k.js} +1 -1
  129. package/web/_astro/{chunk-QZHKN3VN.BmEsg4vr.js → chunk-QZHKN3VN.BN4sKdcS.js} +1 -1
  130. package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
  131. package/web/_astro/{classDiagram-4FO5ZUOK.BHhhMFTO.js → classDiagram-4FO5ZUOK.CJpoMPb5.js} +1 -1
  132. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BHhhMFTO.js → classDiagram-v2-Q7XG4LA2.CJpoMPb5.js} +1 -1
  133. package/web/_astro/client.BhluLQqe.js +1 -0
  134. package/web/_astro/client.DBpSRLnx.js +9 -0
  135. package/web/_astro/cose-bilkent-S5V4N54A.BraxQ2Nt.js +1 -0
  136. package/web/_astro/{cynefin-OW5HDTMX.C2j1_lKL.js → cynefin-OW5HDTMX.gzoU73oL.js} +1 -1
  137. package/web/_astro/{dagre-BM42HDAG.hZ2NCTdT.js → dagre-BM42HDAG.wyDfWySU.js} +1 -1
  138. package/web/_astro/{diagram-2AECGRRQ.Bfw5_EzK.js → diagram-2AECGRRQ.h2_EbMxV.js} +1 -1
  139. package/web/_astro/{diagram-5GNKFQAL.Bt1V_Tmk.js → diagram-5GNKFQAL.Db6dcs89.js} +1 -1
  140. package/web/_astro/{diagram-KO2AKTUF.DEk-YFwp.js → diagram-KO2AKTUF.NVwzxu_U.js} +1 -1
  141. package/web/_astro/{diagram-LMA3HP47.CDjndtQm.js → diagram-LMA3HP47.B-eNgDQk.js} +1 -1
  142. package/web/_astro/{diagram-OG6HWLK6.cFnUHScG.js → diagram-OG6HWLK6.Bsa8ZRbJ.js} +1 -1
  143. package/web/_astro/{erDiagram-TEJ5UH35.DlhYp7NV.js → erDiagram-TEJ5UH35.BospRE5Q.js} +1 -1
  144. package/web/_astro/{flowDiagram-I6XJVG4X.DNTpsxfx.js → flowDiagram-I6XJVG4X.DMzvHwAA.js} +1 -1
  145. package/web/_astro/ganttDiagram-6RSMTGT7.Dmdxgd-M.js +292 -0
  146. package/web/_astro/{gitGraphDiagram-PVQCEYII.DkEqNI0P.js → gitGraphDiagram-PVQCEYII.CaqdO0rL.js} +1 -1
  147. package/web/_astro/index.5dn-Zm-2.js +1 -0
  148. package/web/_astro/index.C-IqPpW2.js +1 -0
  149. package/web/_astro/index.Cyc_GA4f.js +1 -0
  150. package/web/_astro/index.EoZlzLK-.css +1 -0
  151. package/web/_astro/index.tZfqwSut.js +1 -0
  152. package/web/_astro/{infoDiagram-5YYISTIA.CrjioCTG.js → infoDiagram-5YYISTIA.DDVD9Wn7.js} +1 -1
  153. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BcK0CN8n.js → ishikawaDiagram-YF4QCWOH.EKyCUFsL.js} +1 -1
  154. package/web/_astro/{journeyDiagram-JHISSGLW.p0CbBDX1.js → journeyDiagram-JHISSGLW.By2-RkYB.js} +1 -1
  155. package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
  156. package/web/_astro/{kanban-definition-UN3LZRKU.puHIFt6J.js → kanban-definition-UN3LZRKU.ar-WmlyR.js} +1 -1
  157. package/web/_astro/{linear.Bb-3a1d7.js → linear.BnzMBgo_.js} +1 -1
  158. package/web/_astro/{mermaid.core.CJDgXOJs.js → mermaid.core.DSqeFu2Y.js} +4 -4
  159. package/web/_astro/{mindmap-definition-RKZ34NQL.BoAZEI9t.js → mindmap-definition-RKZ34NQL.1XcF8Wh2.js} +1 -1
  160. package/web/_astro/{pieDiagram-4H26LBE5.CJNSdqY7.js → pieDiagram-4H26LBE5.DnItHOdK.js} +1 -1
  161. package/web/_astro/{quadrantDiagram-W4KKPZXB.DtTy7_0Z.js → quadrantDiagram-W4KKPZXB.BQQyrnBt.js} +1 -1
  162. package/web/_astro/{requirementDiagram-4Y6WPE33.BNYV98Xg.js → requirementDiagram-4Y6WPE33.Dmzxun82.js} +1 -1
  163. package/web/_astro/{sankeyDiagram-5OEKKPKP.DRq9DGHp.js → sankeyDiagram-5OEKKPKP.BBIeRKYB.js} +1 -1
  164. package/web/_astro/{sequenceDiagram-3UESZ5HK.B7KdBFCm.js → sequenceDiagram-3UESZ5HK.CUNVdBBf.js} +1 -1
  165. package/web/_astro/{stateDiagram-AJRCARHV.CD62ZJ2H.js → stateDiagram-AJRCARHV.DlFf2sJm.js} +1 -1
  166. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CeVk1TAj.js → stateDiagram-v2-BHNVJYJU.DXR7I1H5.js} +1 -1
  167. package/web/_astro/{timeline-definition-PNZ67QCA.C_ZPA5tT.js → timeline-definition-PNZ67QCA.BcO-GyX2.js} +1 -1
  168. package/web/_astro/{vennDiagram-CIIHVFJN.CrThO_FQ.js → vennDiagram-CIIHVFJN.ChCECmmW.js} +1 -1
  169. package/web/_astro/{wardleyDiagram-YWT4CUSO.DSZCA5nl.js → wardleyDiagram-YWT4CUSO.CRaT12mD.js} +1 -1
  170. package/web/_astro/{xychartDiagram-2RQKCTM6.KI7baTuk.js → xychartDiagram-2RQKCTM6.B3lsHVpj.js} +1 -1
  171. package/web/board-runtime.json +69 -0
  172. package/web/index.html +2 -2
  173. package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
  174. package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
  175. package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -298
  176. package/plugins/sp/scripts/feature-sync-bounded.ts +0 -479
  177. package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
  178. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
  179. package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
  180. package/plugins/sp/scripts/record-feature-sync.ts +0 -84
  181. package/plugins/sp/scripts/script-contract-check.ts +0 -377
  182. package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
  183. package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
  184. package/plugins/sp/scripts/task-evidence-precheck.ts +0 -187
  185. package/plugins/sp/scripts/task-size-precheck.ts +0 -210
  186. package/plugins/sp/scripts/transition-shim-check.ts +0 -238
  187. package/plugins/sp/scripts/validate-commands.ts +0 -689
  188. package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
  189. package/plugins/sp/scripts/verify-answer-lint.ts +0 -547
  190. package/web/_astro/BoardApp.C02hAHPO.js +0 -1
  191. package/web/_astro/TaskDetail.C-GdsS-t.js +0 -1
  192. package/web/_astro/architectureDiagram-3BPJPVTR.CGe629A1.js +0 -36
  193. package/web/_astro/channel.fsgl7o5j.js +0 -1
  194. package/web/_astro/client.yhYJvxCU.js +0 -9
  195. package/web/_astro/cose-bilkent-S5V4N54A.2fH4YOlp.js +0 -1
  196. package/web/_astro/ganttDiagram-6RSMTGT7.DC_p36PI.js +0 -292
  197. package/web/_astro/index.De90oHcH.js +0 -1
  198. package/web/_astro/index.Hjbr15fG.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. 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).
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 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
- // 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
- export function makeItemId(category: ResidualCategory, location: string, text: string): string {
94
- const normalized = text.trim().replace(/\s+/g, ' ');
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(/`([^`]+)`/);
109
- return backtick === null ? '' : normalizeAnchor(backtick[1]);
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
- * Extract review-finding rows: any `### Review` section table whose header carries a
114
- * Priority column. Rows need `^P[1-4]` priority and a finding other than `none`/`—`.
115
- * A disposition column (Disposition/Action/Status/Resolution/Fixed) is honored: `FIXED`/
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
- const m = line.match(/\+[0-9]+/);
187
- newLine = m === null ? newLine : Number.parseInt(m[0].slice(1), 10);
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
- try {
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
- if (!Array.isArray(parsed)) return [];
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 reviewRows = parseReviewFindings(taskContent);
319
- const tableDeferrals = reviewRows.flatMap((r) =>
320
- r.deferral === undefined ? [] : [{ id: makeItemId('review-finding', r.location, r.text), reason: r.deferral }],
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
- if (mode === '' || wbs === '') return null;
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
- const parsed = JSON.parse(res.stdout) as Record<string, unknown>;
479
- const content = typeof parsed.content === 'string' ? parsed.content : '';
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
- typeof parsed.feature_id === 'string'
483
- ? parsed.feature_id
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 artifact = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
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 existing = existsSync(findingsPath) ? readFileSync(findingsPath, 'utf8') : '';
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: folded.verdict, checks: folded.checks }, null, 2)}\n`,
151
+ `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}\n`,
520
152
  );
521
- writeFileSync(findingsPath, folded.findings);
153
+ writeFileSync(findingsPath, fold.findings);
522
154
  io.out(
523
- `residual-fold: ${opts.wbs} verdict=${folded.verdict} residual-sweep=${folded.checks.find((c) => c.name === 'residual-sweep')?.status}\n`,
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
- function settleMode(opts: NonNullable<ReturnType<typeof parseArgs>>, env: ScanEnv, io: ScanIo): number {
529
- const task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
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, opts.wbs, opts.tmpDir, task.content, env);
532
- const residualsPath = join(runDir, `${opts.wbs}-residuals.json`);
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: ${opts.wbs} deferrals pending but feature_id unknown; re-run: residual-scan settle ${opts.wbs}\n`,
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 created = spur(
545
- env,
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 Record<string, unknown>;
557
- const pick = (o: Record<string, unknown>): string => (typeof o.wbs === 'string' ? o.wbs : '');
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: could not read created task wbs; re-run: residual-scan settle ${opts.wbs}\n`);
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 bg = [
571
- `Source task: ${opts.wbs} (feature ${task.featureId}) — deferred residuals filed by residual-scan settle.`,
572
- '',
573
- ...deferred.map((i) => `- ${i.id} — ${i.location}: ${i.text}`),
574
- ].join('\n');
575
- const bgFile = join(runDir, `${opts.wbs}-residual-background.md`);
576
- writeFileSync(bgFile, `${bg}\n`);
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
- // Cleanup: only regular files `<tmpDir>/<wbs>-*`. Never directories, never other prefixes.
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 ${opts.wbs}\n`);
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 verdict = loadVerdict(runDir, opts.wbs);
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 task = loadTask(env, opts.spurBin, opts.wbs, opts.root);
609
- const scan = scanResiduals(opts.root, opts.wbs, opts.tmpDir, task.content, env);
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
- const reportPath = join(runDir, `${opts.wbs}-residual-report.md`);
614
- writeFileSync(reportPath, renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
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
- if (opts === null) {
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 cwd = options.cwd ?? process.cwd();
630
- const resolved = { ...opts, root: opts.root.startsWith('/') ? opts.root : join(cwd, opts.root) };
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)));