@gobing-ai/spur 0.3.96 → 0.3.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/rules/strict/runtime-boundaries.yaml +1 -0
- package/config/templates/AGENTS.md +3 -3
- package/config/workflows/wrapup-pipeline.yaml +2 -1
- package/package.json +2 -2
- package/plugins/sp/README.md +6 -0
- package/plugins/sp/commands/dev-job-dump.md +29 -0
- package/plugins/sp/commands/dev-job-resume.md +29 -0
- package/plugins/sp/lib/inline-run.generated.mjs +25 -16
- package/plugins/sp/lib/quality-gate.generated.mjs +12 -5
- package/plugins/sp/lib/residual-scan.generated.d.mts +1 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +16 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +2 -2
- package/plugins/sp/scripts/feature-verification-steps.mjs +14 -1
- package/plugins/sp/scripts/feature-verification-steps.ts +14 -1
- package/plugins/sp/scripts/quality-gate.mjs +13 -6
- package/plugins/sp/scripts/quality-gate.ts +1 -1
- package/plugins/sp/scripts/residual-scan.mjs +64 -38
- package/plugins/sp/scripts/residual-scan.ts +39 -34
- package/plugins/sp/scripts/wrapup-steps.mjs +64 -3
- package/plugins/sp/scripts/wrapup-steps.ts +122 -3
- package/plugins/sp/skills/source-driven-development/SKILL.md +11 -0
- package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -5
- package/plugins/sp/skills/spur-cli/references/workflows.md +17 -14
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +3 -1
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +79 -5
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +4 -2
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +8 -6
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +3 -3
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +9 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +43 -12
- package/spur.js +4246 -2996
- package/web/_astro/{BoardApp.BIjMatT1.js → BoardApp.Bmv5WkJ9.js} +1 -1
- package/web/_astro/BoardApp.vveLOdDq.js +322 -0
- package/web/_astro/{TaskDetail.BZM3EAFx.js → TaskDetail.IKc3uDYV.js} +1 -1
- package/web/_astro/{arc.HkRiZnoI.js → arc.C63Ufg35.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.BDdK-tgZ.js → architectureDiagram-3BPJPVTR.CP8Hyldk.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.CZ9kOBxx.js → blockDiagram-GPEHLZMM.Bo_48MRA.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DXK9qeRD.js → c4Diagram-AAUBKEIU.Ch61sw-O.js} +1 -1
- package/web/_astro/channel.BKCmJpWM.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.BTD2WeX5.js → chunk-2J33WTMH.CHpzS7xf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.Ci6Qbcvk.js → chunk-4BX2VUAB.DzMNNWIw.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.yl3zsj7p.js → chunk-55IACEB6.BqPrDw_Q.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.ClXZsfyR.js → chunk-727SXJPM.ZzBHhNw-.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.BmKWZBcP.js → chunk-AQP2D5EJ.1_O_LLkx.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.By30fcb7.js → chunk-FMBD7UC4.GS4UHg7_.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.24NmrD-k.js → chunk-ND2GUHAM.BnqotN4g.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.BN4sKdcS.js → chunk-QZHKN3VN.Ghuh8zoJ.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CJpoMPb5.js → classDiagram-4FO5ZUOK.BW9vC3zg.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CJpoMPb5.js → classDiagram-v2-Q7XG4LA2.BW9vC3zg.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.BraxQ2Nt.js → cose-bilkent-S5V4N54A.D6jNC0ti.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.gzoU73oL.js → cynefin-OW5HDTMX.BOGoCs4L.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.wyDfWySU.js → dagre-BM42HDAG.8iU5VcWi.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.h2_EbMxV.js → diagram-2AECGRRQ.CVt88HwH.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Db6dcs89.js → diagram-5GNKFQAL.C9HxUUMl.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.NVwzxu_U.js → diagram-KO2AKTUF.4ywkQN3t.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.B-eNgDQk.js → diagram-LMA3HP47.C48A6gpz.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.Bsa8ZRbJ.js → diagram-OG6HWLK6.DxFieQW4.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.BospRE5Q.js → erDiagram-TEJ5UH35.CtjtQqYs.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DMzvHwAA.js → flowDiagram-I6XJVG4X.C5zAiahU.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.Dmdxgd-M.js → ganttDiagram-6RSMTGT7.D_L6zsny.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CaqdO0rL.js → gitGraphDiagram-PVQCEYII.Ignnrk3V.js} +1 -1
- package/web/_astro/index.CbNz17Sx.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.DDVD9Wn7.js → infoDiagram-5YYISTIA.DLv8-P5u.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.EKyCUFsL.js → ishikawaDiagram-YF4QCWOH.p0OTpwFO.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.By2-RkYB.js → journeyDiagram-JHISSGLW.CIaBt-DD.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.ar-WmlyR.js → kanban-definition-UN3LZRKU.DGySIntq.js} +1 -1
- package/web/_astro/{linear.BnzMBgo_.js → linear.BATV4RXu.js} +1 -1
- package/web/_astro/{mermaid.core.DSqeFu2Y.js → mermaid.core.DzkwM3VX.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.1XcF8Wh2.js → mindmap-definition-RKZ34NQL.3piBRRzA.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.DnItHOdK.js → pieDiagram-4H26LBE5.DUNueLub.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.BQQyrnBt.js → quadrantDiagram-W4KKPZXB.9bihVbrt.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.Dmzxun82.js → requirementDiagram-4Y6WPE33.CkjsC-YT.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.BBIeRKYB.js → sankeyDiagram-5OEKKPKP.Dbq4vaoQ.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.CUNVdBBf.js → sequenceDiagram-3UESZ5HK.BivrH99P.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.DlFf2sJm.js → stateDiagram-AJRCARHV.CqLQ1Vzz.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.DXR7I1H5.js → stateDiagram-v2-BHNVJYJU.DvvoLD3b.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.BcO-GyX2.js → timeline-definition-PNZ67QCA.B-KR78gv.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.ChCECmmW.js → vennDiagram-CIIHVFJN.Ck0Eieb5.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.CRaT12mD.js → wardleyDiagram-YWT4CUSO.CRfdd_do.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.B3lsHVpj.js → xychartDiagram-2RQKCTM6.Ck_Jxk9C.js} +1 -1
- package/web/index.html +2 -2
- package/web/_astro/BoardApp.GvjIe9Z6.js +0 -191
- package/web/_astro/channel.DlhX2MEt.js +0 -1
- package/web/_astro/index.EoZlzLK-.css +0 -1
|
@@ -111,6 +111,9 @@ function readReceiptStatus(receiptPath, currentDigest) {
|
|
|
111
111
|
}
|
|
112
112
|
if (receipt.tier !== "full")
|
|
113
113
|
return { reuse: false, reason: "light-only" };
|
|
114
|
+
if (!Array.isArray(receipt.checks) || receipt.checks.length === 0 || receipt.checks.some((row) => row === null || typeof row !== "object" || row.status !== "PASS" || typeof row.cmd !== "string" || row.cmd.trim().length === 0)) {
|
|
115
|
+
return { reuse: false, reason: "failed" };
|
|
116
|
+
}
|
|
114
117
|
return { reuse: true, reason: "ok" };
|
|
115
118
|
}
|
|
116
119
|
var TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
@@ -305,9 +308,13 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
305
308
|
writeFileSync(abs(attemptFile), `0
|
|
306
309
|
`);
|
|
307
310
|
const gateStartedAtMs = Date.now();
|
|
308
|
-
|
|
311
|
+
const commandPresent = (env.qualityGateCmd ?? "").trim().length > 0;
|
|
312
|
+
let gateRc = commandPresent ? 0 : 1;
|
|
309
313
|
let gateAttempt = 0;
|
|
310
|
-
|
|
314
|
+
if (!commandPresent)
|
|
315
|
+
appendFileSync(abs(logFile), `quality-gate: mode ${mode} requires env \`qualityGateCmd\` to be non-empty
|
|
316
|
+
`);
|
|
317
|
+
const reusePass = commandPresent && readReceiptStatus(abs(rel("-check-receipt.json")), env.proofDigest ?? "").reuse;
|
|
311
318
|
if (reusePass) {
|
|
312
319
|
const line = `check.reused — full-tier PASS receipt at input digest ${env.proofDigest ?? ""}; gate skipped
|
|
313
320
|
`;
|
|
@@ -318,7 +325,7 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
318
325
|
`);
|
|
319
326
|
return { status: "PASS", attempts: 0, logFile, findingsFile, statusFile, attemptFile };
|
|
320
327
|
}
|
|
321
|
-
const noProgressSkip = mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
|
|
328
|
+
const noProgressSkip = commandPresent && mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
|
|
322
329
|
if (noProgressSkip) {
|
|
323
330
|
const line = `check.skipped-no-progress — full-tier FAIL receipt at input digest ${env.proofDigest ?? ""}; recheck skipped
|
|
324
331
|
`;
|
|
@@ -326,7 +333,7 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
326
333
|
appendFileSync(abs(logFile), line);
|
|
327
334
|
gateRc = 1;
|
|
328
335
|
}
|
|
329
|
-
if (mode === "recheck" &&
|
|
336
|
+
if (mode === "recheck" && gateRc === 0 && (env.gateProbeCmd ?? "").length > 0) {
|
|
330
337
|
const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
|
|
331
338
|
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
332
339
|
gateRc = probe.code;
|
|
@@ -383,7 +390,7 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
383
390
|
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
|
|
384
391
|
`);
|
|
385
392
|
let receiptFile;
|
|
386
|
-
if (!noProgressSkip) {
|
|
393
|
+
if (!noProgressSkip && commandPresent) {
|
|
387
394
|
if ((env.proofDigest ?? "").length > 0) {
|
|
388
395
|
receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
|
|
389
396
|
const receipt = buildReceipt({
|
|
@@ -37,6 +37,7 @@ export declare interface FoldResult {
|
|
|
37
37
|
findings: string;
|
|
38
38
|
}
|
|
39
39
|
export declare const ALLOW_PRAGMA: 'residual-scan:allow';
|
|
40
|
+
export declare function recordedVerdictPath(runDir: string, wbs: string, fs: Pick<typeof import('node:fs'), 'lstatSync' | 'existsSync'>): string;
|
|
40
41
|
export declare function makeItemId(category: ResidualCategory, location: string, text: string): string;
|
|
41
42
|
export declare function normalizeAnchor(location: string): string;
|
|
42
43
|
export declare function locationOf(locationCell: string, finding: string): string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// packages/app/src/services/residual-scan.ts
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
|
|
4
5
|
var PRIORITY_PATTERN = /^P[1-4]/;
|
|
@@ -194,9 +195,24 @@ function renderReport(wbs, items, attemptCount) {
|
|
|
194
195
|
`)}
|
|
195
196
|
`;
|
|
196
197
|
}
|
|
198
|
+
function recordedVerdictPath(runDir, wbs, fs) {
|
|
199
|
+
const evidence = join(runDir, "..", "memory", "evidence");
|
|
200
|
+
const durable = join(evidence, `${wbs}-verdict.json`);
|
|
201
|
+
for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
|
|
202
|
+
try {
|
|
203
|
+
if (fs.lstatSync(path).isSymbolicLink())
|
|
204
|
+
throw new Error(`residual-scan: symlink evidence path: ${path}`);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error.code !== "ENOENT")
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
|
|
211
|
+
}
|
|
197
212
|
export {
|
|
198
213
|
scanResiduals,
|
|
199
214
|
renderReport,
|
|
215
|
+
recordedVerdictPath,
|
|
200
216
|
parseReviewFindings,
|
|
201
217
|
parseDiffMarkers,
|
|
202
218
|
normalizeAnchor,
|
package/plugins/sp/plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.97",
|
|
4
4
|
"description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
|
|
5
5
|
"extensions": {
|
|
6
6
|
"pi": ["./hooks/pi/guard-extension.ts"]
|
|
@@ -50,7 +50,7 @@ version: 1
|
|
|
50
50
|
roles:
|
|
51
51
|
- id: scribe
|
|
52
52
|
tier: cheap
|
|
53
|
-
commands: [dev-gitmsg, dev-handover, dev-daily, dev-changelog, dev-refresh, rule-add, rule-refine, workflow-add, workflow-refine, spur-init]
|
|
53
|
+
commands: [dev-gitmsg, dev-handover, dev-job-dump, dev-daily, dev-changelog, dev-refresh, rule-add, rule-refine, workflow-add, workflow-refine, spur-init]
|
|
54
54
|
stages: [changelog]
|
|
55
55
|
- id: coder
|
|
56
56
|
tier: standard
|
|
@@ -62,7 +62,7 @@ roles:
|
|
|
62
62
|
stages: [verify, review, dogfood]
|
|
63
63
|
- id: planner
|
|
64
64
|
tier: capable-2
|
|
65
|
-
commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
|
|
65
|
+
commands: [dev-plan, dev-refine, dev-brainstorm, dev-idea, dev-runall, dev-parallel, dev-next, dev-job-resume, dev-arch, dev-refineall, dev-find-next, dev-feature-change]
|
|
66
66
|
stages: [plan, refine, brainstorm]
|
|
67
67
|
```
|
|
68
68
|
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
openSync,
|
|
11
11
|
readdirSync,
|
|
12
12
|
readFileSync,
|
|
13
|
+
realpathSync,
|
|
13
14
|
renameSync,
|
|
15
|
+
statSync,
|
|
14
16
|
writeFileSync
|
|
15
17
|
} from "fs";
|
|
16
18
|
import { join } from "path";
|
|
@@ -74,6 +76,17 @@ function findFeatureFile(featureDir, featureId) {
|
|
|
74
76
|
return name === undefined ? undefined : join(featureDir, name);
|
|
75
77
|
}
|
|
76
78
|
var nodeFsShim = {
|
|
79
|
+
realPath: realpathSync,
|
|
80
|
+
readDir: readdirSync,
|
|
81
|
+
stat: (path) => {
|
|
82
|
+
try {
|
|
83
|
+
return statSync(path);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code === "ENOENT")
|
|
86
|
+
return null;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
77
90
|
ensureDir: (dir) => {
|
|
78
91
|
mkdirSync(dir, { recursive: true });
|
|
79
92
|
},
|
|
@@ -123,7 +136,7 @@ async function verify(featureId, runId, cmdOverride, spurBin, mode) {
|
|
|
123
136
|
try {
|
|
124
137
|
await new mod.ArtifactDao(db.adapter).record({
|
|
125
138
|
runId,
|
|
126
|
-
path:
|
|
139
|
+
path: mod.featureReceiptPaths(runDir, featureId, runId).runScoped,
|
|
127
140
|
kind: "feature-verification"
|
|
128
141
|
});
|
|
129
142
|
} finally {
|
|
@@ -33,7 +33,9 @@ import {
|
|
|
33
33
|
openSync,
|
|
34
34
|
readdirSync,
|
|
35
35
|
readFileSync,
|
|
36
|
+
realpathSync,
|
|
36
37
|
renameSync,
|
|
38
|
+
statSync,
|
|
37
39
|
writeFileSync,
|
|
38
40
|
} from 'node:fs';
|
|
39
41
|
import { join } from 'node:path';
|
|
@@ -45,6 +47,7 @@ const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
|
45
47
|
|
|
46
48
|
/** Shape of the generated inline bundle / repo app source this script consumes. */
|
|
47
49
|
interface VerificationModule {
|
|
50
|
+
featureReceiptPaths: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['featureReceiptPaths'];
|
|
48
51
|
startFeatureVerificationReceipt: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['startFeatureVerificationReceipt'];
|
|
49
52
|
completeFeatureVerificationReceipt: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['completeFeatureVerificationReceipt'];
|
|
50
53
|
captureFeatureReceiptDigest: typeof import('../../../packages/app/src/workflow/feature-verification-receipt')['captureFeatureReceiptDigest'];
|
|
@@ -143,6 +146,16 @@ function findFeatureFile(featureDir: string, featureId: string): string | undefi
|
|
|
143
146
|
// FileSystem port; only ensureDir/writeFile/rename are exercised on the
|
|
144
147
|
// start/complete paths. Swap to createNodeFileSystem if more surface is needed.
|
|
145
148
|
const nodeFsShim = {
|
|
149
|
+
realPath: realpathSync,
|
|
150
|
+
readDir: readdirSync,
|
|
151
|
+
stat: (path: string) => {
|
|
152
|
+
try {
|
|
153
|
+
return statSync(path);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as { code?: string }).code === 'ENOENT') return null;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
},
|
|
146
159
|
ensureDir: (dir: string) => {
|
|
147
160
|
mkdirSync(dir, { recursive: true });
|
|
148
161
|
},
|
|
@@ -209,7 +222,7 @@ async function verify(
|
|
|
209
222
|
try {
|
|
210
223
|
await new mod.ArtifactDao(db.adapter as never).record({
|
|
211
224
|
runId,
|
|
212
|
-
path:
|
|
225
|
+
path: mod.featureReceiptPaths(runDir, featureId, runId).runScoped,
|
|
213
226
|
kind: 'feature-verification',
|
|
214
227
|
});
|
|
215
228
|
} finally {
|
|
@@ -123,6 +123,9 @@ function readReceiptStatus(receiptPath, currentDigest) {
|
|
|
123
123
|
}
|
|
124
124
|
if (receipt.tier !== "full")
|
|
125
125
|
return { reuse: false, reason: "light-only" };
|
|
126
|
+
if (!Array.isArray(receipt.checks) || receipt.checks.length === 0 || receipt.checks.some((row) => row === null || typeof row !== "object" || row.status !== "PASS" || typeof row.cmd !== "string" || row.cmd.trim().length === 0)) {
|
|
127
|
+
return { reuse: false, reason: "failed" };
|
|
128
|
+
}
|
|
126
129
|
return { reuse: true, reason: "ok" };
|
|
127
130
|
}
|
|
128
131
|
var TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
@@ -317,9 +320,13 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
317
320
|
writeFileSync(abs(attemptFile), `0
|
|
318
321
|
`);
|
|
319
322
|
const gateStartedAtMs = Date.now();
|
|
320
|
-
|
|
323
|
+
const commandPresent = (env.qualityGateCmd ?? "").trim().length > 0;
|
|
324
|
+
let gateRc = commandPresent ? 0 : 1;
|
|
321
325
|
let gateAttempt = 0;
|
|
322
|
-
|
|
326
|
+
if (!commandPresent)
|
|
327
|
+
appendFileSync(abs(logFile), `quality-gate: mode ${mode} requires env \`qualityGateCmd\` to be non-empty
|
|
328
|
+
`);
|
|
329
|
+
const reusePass = commandPresent && readReceiptStatus(abs(rel("-check-receipt.json")), env.proofDigest ?? "").reuse;
|
|
323
330
|
if (reusePass) {
|
|
324
331
|
const line = `check.reused — full-tier PASS receipt at input digest ${env.proofDigest ?? ""}; gate skipped
|
|
325
332
|
`;
|
|
@@ -330,7 +337,7 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
330
337
|
`);
|
|
331
338
|
return { status: "PASS", attempts: 0, logFile, findingsFile, statusFile, attemptFile };
|
|
332
339
|
}
|
|
333
|
-
const noProgressSkip = mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
|
|
340
|
+
const noProgressSkip = commandPresent && mode === "recheck" && receiptFailsAtDigest(readReceipt(abs(rel("-check-receipt.json"))), env.proofDigest ?? "");
|
|
334
341
|
if (noProgressSkip) {
|
|
335
342
|
const line = `check.skipped-no-progress — full-tier FAIL receipt at input digest ${env.proofDigest ?? ""}; recheck skipped
|
|
336
343
|
`;
|
|
@@ -338,7 +345,7 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
338
345
|
appendFileSync(abs(logFile), line);
|
|
339
346
|
gateRc = 1;
|
|
340
347
|
}
|
|
341
|
-
if (mode === "recheck" &&
|
|
348
|
+
if (mode === "recheck" && gateRc === 0 && (env.gateProbeCmd ?? "").length > 0) {
|
|
342
349
|
const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
|
|
343
350
|
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
344
351
|
gateRc = probe.code;
|
|
@@ -395,7 +402,7 @@ function runQualityGate(mode, env, options = {}) {
|
|
|
395
402
|
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
|
|
396
403
|
`);
|
|
397
404
|
let receiptFile;
|
|
398
|
-
if (!noProgressSkip) {
|
|
405
|
+
if (!noProgressSkip && commandPresent) {
|
|
399
406
|
if ((env.proofDigest ?? "").length > 0) {
|
|
400
407
|
receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
|
|
401
408
|
const receipt = buildReceipt({
|
|
@@ -452,7 +459,7 @@ function main(argv, rawEnv = getEnvVars(), options = {}) {
|
|
|
452
459
|
process.stdout.write(`${JSON.stringify(verdict)}
|
|
453
460
|
`);
|
|
454
461
|
} else {
|
|
455
|
-
runQualityGate(mode, env);
|
|
462
|
+
return runQualityGate(mode, env, options).status === "PASS" ? 0 : 1;
|
|
456
463
|
}
|
|
457
464
|
return 0;
|
|
458
465
|
}
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
// plugins/sp/scripts/residual-scan.ts
|
|
5
5
|
import { spawnSync } from "child_process";
|
|
6
|
-
import
|
|
6
|
+
import * as fs from "fs";
|
|
7
7
|
import { tmpdir } from "os";
|
|
8
|
-
import { join } from "path";
|
|
8
|
+
import { join as join2 } from "path";
|
|
9
9
|
|
|
10
10
|
// plugins/sp/lib/env.ts
|
|
11
11
|
function getEnvVars() {
|
|
@@ -13,6 +13,7 @@ function getEnvVars() {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
// plugins/sp/lib/residual-scan.generated.mjs
|
|
16
|
+
import { join } from "node:path";
|
|
16
17
|
import { createHash } from "node:crypto";
|
|
17
18
|
var MARKER_PATTERN = /TODO|FIXME|XXX|HACK/;
|
|
18
19
|
var PRIORITY_PATTERN = /^P[1-4]/;
|
|
@@ -208,6 +209,20 @@ function renderReport(wbs, items, attemptCount) {
|
|
|
208
209
|
`)}
|
|
209
210
|
`;
|
|
210
211
|
}
|
|
212
|
+
function recordedVerdictPath(runDir, wbs, fs) {
|
|
213
|
+
const evidence = join(runDir, "..", "memory", "evidence");
|
|
214
|
+
const durable = join(evidence, `${wbs}-verdict.json`);
|
|
215
|
+
for (const path of [join(runDir, ".."), join(evidence, ".."), evidence, durable]) {
|
|
216
|
+
try {
|
|
217
|
+
if (fs.lstatSync(path).isSymbolicLink())
|
|
218
|
+
throw new Error(`residual-scan: symlink evidence path: ${path}`);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error.code !== "ENOENT")
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return fs.existsSync(durable) ? durable : join(runDir, `${wbs}-verdict.json`);
|
|
225
|
+
}
|
|
211
226
|
|
|
212
227
|
// plugins/sp/lib/spur-bin.ts
|
|
213
228
|
function spurCommand(spurBin) {
|
|
@@ -229,7 +244,7 @@ function spur(env, spurBinFlag, args, cwd) {
|
|
|
229
244
|
}
|
|
230
245
|
function isRegularFile(path) {
|
|
231
246
|
try {
|
|
232
|
-
return statSync(path).isFile();
|
|
247
|
+
return fs.statSync(path).isFile();
|
|
233
248
|
} catch {
|
|
234
249
|
return false;
|
|
235
250
|
}
|
|
@@ -251,9 +266,9 @@ function collectAddedLines(root, base) {
|
|
|
251
266
|
const untracked = run("git", ["ls-files", "--others", "--exclude-standard"], root);
|
|
252
267
|
for (const f of untracked.stdout.split(`
|
|
253
268
|
`)) {
|
|
254
|
-
if (f.length === 0 || !isRegularFile(
|
|
269
|
+
if (f.length === 0 || !isRegularFile(join2(root, f)))
|
|
255
270
|
continue;
|
|
256
|
-
for (const [idx, text] of readFileSync(
|
|
271
|
+
for (const [idx, text] of fs.readFileSync(join2(root, f), "utf8").split(`
|
|
257
272
|
`).entries())
|
|
258
273
|
out.push({ file: f, line: idx + 1, text });
|
|
259
274
|
}
|
|
@@ -262,28 +277,28 @@ function collectAddedLines(root, base) {
|
|
|
262
277
|
function listStagingResidue(tmpDir, wbs) {
|
|
263
278
|
let names;
|
|
264
279
|
try {
|
|
265
|
-
names = readdirSync(tmpDir);
|
|
280
|
+
names = fs.readdirSync(tmpDir);
|
|
266
281
|
} catch {
|
|
267
282
|
return [];
|
|
268
283
|
}
|
|
269
|
-
return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(
|
|
284
|
+
return names.filter((n) => n.startsWith(`${wbs}-`) && isRegularFile(join2(tmpDir, n))).map((n) => join2(tmpDir, n));
|
|
270
285
|
}
|
|
271
286
|
function readDeferrals(runDir, wbs) {
|
|
272
|
-
const path =
|
|
273
|
-
if (!existsSync(path))
|
|
287
|
+
const path = join2(runDir, `${wbs}-residual-deferrals.json`);
|
|
288
|
+
if (!fs.existsSync(path))
|
|
274
289
|
return [];
|
|
275
290
|
const isDeferral = (e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.reason === "string" && e.reason.trim().length > 0;
|
|
276
291
|
try {
|
|
277
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
292
|
+
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
278
293
|
return (Array.isArray(parsed) ? parsed : []).filter(isDeferral);
|
|
279
294
|
} catch {
|
|
280
295
|
return [];
|
|
281
296
|
}
|
|
282
297
|
}
|
|
283
298
|
function scanResiduals2(root, wbs, tmpDir, taskContent, _env) {
|
|
284
|
-
const runDir =
|
|
285
|
-
const basePath =
|
|
286
|
-
const base = existsSync(basePath) ? readFileSync(basePath, "utf8").trim() : null;
|
|
299
|
+
const runDir = join2(root, ".spur", "run");
|
|
300
|
+
const basePath = join2(runDir, `${wbs}-base.sha`);
|
|
301
|
+
const base = fs.existsSync(basePath) ? fs.readFileSync(basePath, "utf8").trim() : null;
|
|
287
302
|
const stagingResidue = listStagingResidue(tmpDir, wbs);
|
|
288
303
|
const deferrals = readDeferrals(runDir, wbs);
|
|
289
304
|
const addedLines = base === null ? [] : collectAddedLines(root, base);
|
|
@@ -323,29 +338,39 @@ function loadTask(env, spurBinFlag, wbs, root) {
|
|
|
323
338
|
return { content: typeof parsed.content === "string" ? parsed.content : "", featureId };
|
|
324
339
|
}
|
|
325
340
|
function loadVerdict(runDir, wbs) {
|
|
326
|
-
return JSON.parse(readFileSync(
|
|
341
|
+
return JSON.parse(fs.readFileSync(recordedVerdictPath(runDir, wbs, fs), "utf8"));
|
|
327
342
|
}
|
|
328
343
|
function scanMode(opts, env, io) {
|
|
329
|
-
const runDir =
|
|
330
|
-
mkdirSync(runDir, { recursive: true });
|
|
344
|
+
const runDir = join2(opts.root, ".spur", "run");
|
|
345
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
331
346
|
const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
332
347
|
const artifact = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
|
|
333
|
-
writeFileSync(
|
|
348
|
+
fs.writeFileSync(join2(runDir, `${opts.wbs}-residuals.json`), `${JSON.stringify(artifact, null, 2)}
|
|
334
349
|
`);
|
|
335
350
|
io.out(`residual-scan: ${opts.wbs} blocking=${artifact.counts.blocking} deferrable=${artifact.counts.deferrable} advisory=${artifact.counts.advisory} housekeeping=${artifact.counts.housekeeping}
|
|
336
351
|
`);
|
|
337
352
|
return 0;
|
|
338
353
|
}
|
|
339
354
|
function foldMode(opts, _env, io) {
|
|
340
|
-
const runDir =
|
|
341
|
-
const scan = JSON.parse(readFileSync(
|
|
342
|
-
const
|
|
355
|
+
const runDir = join2(opts.root, ".spur", "run");
|
|
356
|
+
const scan = JSON.parse(fs.readFileSync(join2(runDir, `${opts.wbs}-residuals.json`), "utf8"));
|
|
357
|
+
const target = recordedVerdictPath(runDir, opts.wbs, fs);
|
|
343
358
|
const verdict = loadVerdict(runDir, opts.wbs);
|
|
344
|
-
const findingsPath =
|
|
345
|
-
const fold = foldVerdict(verdict, scan, existsSync(findingsPath) ? readFileSync(findingsPath, "utf8") : "");
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
359
|
+
const findingsPath = join2(runDir, `${opts.wbs}-test-gate.findings`);
|
|
360
|
+
const fold = foldVerdict(verdict, scan, fs.existsSync(findingsPath) ? fs.readFileSync(findingsPath, "utf8") : "");
|
|
361
|
+
const bytes = `${JSON.stringify({ ...verdict, verdict: fold.verdict, checks: fold.checks }, null, 2)}
|
|
362
|
+
`;
|
|
363
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
364
|
+
try {
|
|
365
|
+
fs.writeFileSync(temporary, bytes, { flag: "wx" });
|
|
366
|
+
fs.renameSync(temporary, target);
|
|
367
|
+
} finally {
|
|
368
|
+
fs.rmSync(temporary, { force: true });
|
|
369
|
+
}
|
|
370
|
+
const scratch = join2(runDir, `${opts.wbs}-verdict.json`);
|
|
371
|
+
if (target !== scratch)
|
|
372
|
+
fs.writeFileSync(scratch, bytes);
|
|
373
|
+
fs.writeFileSync(findingsPath, fold.findings);
|
|
349
374
|
io.out(`residual-fold: ${opts.wbs} verdict=${fold.verdict} residual-sweep=${fold.checks.find((c) => c.name === "residual-sweep")?.status}
|
|
350
375
|
`);
|
|
351
376
|
return 0;
|
|
@@ -353,12 +378,12 @@ function foldMode(opts, _env, io) {
|
|
|
353
378
|
function settleMode(opts, env, io) {
|
|
354
379
|
const wbs = opts.wbs;
|
|
355
380
|
const task = loadTask(env, opts.spurBin, wbs, opts.root);
|
|
356
|
-
const runDir =
|
|
381
|
+
const runDir = join2(opts.root, ".spur", "run");
|
|
357
382
|
const scan = scanResiduals2(opts.root, wbs, opts.tmpDir, task.content, env);
|
|
358
|
-
const residualsPath =
|
|
383
|
+
const residualsPath = join2(runDir, `${wbs}-residuals.json`);
|
|
359
384
|
let prior = {};
|
|
360
|
-
if (existsSync(residualsPath))
|
|
361
|
-
prior = JSON.parse(readFileSync(residualsPath, "utf8"));
|
|
385
|
+
if (fs.existsSync(residualsPath))
|
|
386
|
+
prior = JSON.parse(fs.readFileSync(residualsPath, "utf8"));
|
|
362
387
|
const deferred = scan.items.filter((i) => i.class === "deferrable");
|
|
363
388
|
if (deferred.length > 0 && prior.followUp === undefined) {
|
|
364
389
|
if (task.featureId === "") {
|
|
@@ -383,8 +408,8 @@ function settleMode(opts, env, io) {
|
|
|
383
408
|
}
|
|
384
409
|
const rows = deferred.map((i) => `- ${i.id} \u2014 ${i.location}: ${i.text}`);
|
|
385
410
|
const head = `Source task: ${wbs} (feature ${task.featureId}) \u2014 deferred residuals filed by residual-scan settle.`;
|
|
386
|
-
const bgFile =
|
|
387
|
-
writeFileSync(bgFile, `${[head, "", ...rows].join(`
|
|
411
|
+
const bgFile = join2(runDir, `${wbs}-residual-background.md`);
|
|
412
|
+
fs.writeFileSync(bgFile, `${[head, "", ...rows].join(`
|
|
388
413
|
`)}
|
|
389
414
|
`);
|
|
390
415
|
const updArgs = ["task", "update", wbsNew, "--section", "Background", "--from-file", bgFile];
|
|
@@ -399,28 +424,28 @@ function settleMode(opts, env, io) {
|
|
|
399
424
|
}
|
|
400
425
|
for (const path of listStagingResidue(opts.tmpDir, wbs)) {
|
|
401
426
|
try {
|
|
402
|
-
rmSync(path, { force: true });
|
|
427
|
+
fs.rmSync(path, { force: true });
|
|
403
428
|
} catch {
|
|
404
429
|
io.err(`residual-settle: could not remove ${path}; re-run: residual-scan settle ${wbs}
|
|
405
430
|
`);
|
|
406
431
|
return 0;
|
|
407
432
|
}
|
|
408
433
|
}
|
|
409
|
-
writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}
|
|
434
|
+
fs.writeFileSync(residualsPath, `${JSON.stringify({ ...scan, ...prior }, null, 2)}
|
|
410
435
|
`);
|
|
411
436
|
return 0;
|
|
412
437
|
}
|
|
413
438
|
function reportMode(opts, env, io) {
|
|
414
|
-
const runDir =
|
|
439
|
+
const runDir = join2(opts.root, ".spur", "run");
|
|
415
440
|
const sweep = loadVerdict(runDir, opts.wbs).checks.find((c) => c.name === "residual-sweep");
|
|
416
441
|
if (sweep === undefined || sweep.status !== "fail")
|
|
417
442
|
return 0;
|
|
418
443
|
const { content } = loadTask(env, opts.spurBin, opts.wbs, opts.root);
|
|
419
444
|
const scan = scanResiduals2(opts.root, opts.wbs, opts.tmpDir, content, env);
|
|
420
445
|
const blocking = scan.items.filter((i) => i.class === "blocking");
|
|
421
|
-
const attemptFile =
|
|
422
|
-
const attempts = existsSync(attemptFile) ? Number.parseInt(readFileSync(attemptFile, "utf8").trim() || "0", 10) : 0;
|
|
423
|
-
writeFileSync(
|
|
446
|
+
const attemptFile = join2(runDir, `${opts.wbs}-test-fix-attempt`);
|
|
447
|
+
const attempts = fs.existsSync(attemptFile) ? Number.parseInt(fs.readFileSync(attemptFile, "utf8").trim() || "0", 10) : 0;
|
|
448
|
+
fs.writeFileSync(join2(runDir, `${opts.wbs}-residual-report.md`), renderReport(opts.wbs, blocking, Number.isNaN(attempts) ? 0 : attempts));
|
|
424
449
|
io.out(`Recovery: fix the items in .spur/run/${opts.wbs}-residual-report.md, then /sp:dev-run ${opts.wbs}
|
|
425
450
|
`);
|
|
426
451
|
return 0;
|
|
@@ -439,13 +464,14 @@ function main(argv, env = getEnvVars(), options = {}) {
|
|
|
439
464
|
`);
|
|
440
465
|
return 2;
|
|
441
466
|
}
|
|
442
|
-
const resolved = { ...opts, root: opts.root.startsWith("/") ? opts.root :
|
|
467
|
+
const resolved = { ...opts, root: opts.root.startsWith("/") ? opts.root : join2(cwd, opts.root) };
|
|
443
468
|
return modeFn(resolved, env, io);
|
|
444
469
|
}
|
|
445
470
|
process.exit(main(process.argv.slice(2)));
|
|
446
471
|
export {
|
|
447
472
|
scanResiduals2 as scanResiduals,
|
|
448
473
|
renderReport,
|
|
474
|
+
recordedVerdictPath,
|
|
449
475
|
parseReviewFindings,
|
|
450
476
|
parseDiffMarkers,
|
|
451
477
|
normalizeAnchor,
|