@gobing-ai/spur 0.3.80 → 0.3.82
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/config.example.yaml +31 -18
- package/config/config.global.yaml +13 -11
- package/config/pipeline-budgets.json +34 -2
- package/config/plugin-scripts.json +25 -0
- package/config/rules/boundary/config-loading-ownership.yaml +0 -3
- package/config/rules/boundary/dao-boundary.yaml +4 -17
- package/config/rules/boundary/planning-folder-hardcode.yaml +0 -1
- package/config/rules/boundary/sp-no-vendor-refs.yaml +3 -2
- package/config/rules/boundary/sp-runtime-path.yaml +3 -14
- package/config/rules/quality/coverage-gate.yaml +3 -14
- package/config/rules/quality/tsdoc-exports.yaml +4 -7
- package/config/rules/strict/http-boundaries.yaml +5 -8
- package/config/rules/strict/runtime-boundaries.yaml +1 -5
- package/config/rules/structure/protected-files.yaml +9 -3
- package/config/rules/structure/test-focus-skip.yaml +0 -2
- package/config/rules/structure/test-location.yaml +0 -5
- package/config/rules/surface/check-cli-surface.yaml +3 -2
- package/config/rules/typescript/bun-tooling.yaml +5 -7
- package/config/rules/typescript/guarded-happy-dom-register.yaml +0 -2
- package/config/rules/typescript/happy-dom-teardown.yaml +0 -2
- package/config/rules/typescript/no-biome-suppressions.yaml +0 -2
- package/config/rules/typescript/no-debugger.yaml +0 -2
- package/config/rules/typescript/no-eslint-suppressions.yaml +0 -4
- package/config/rules/typescript/no-leaky-module-mocks.yaml +6 -13
- package/config/rules/typescript/no-module-scope-import-calls.yaml +0 -2
- package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +0 -3
- package/config/rules/typescript/no-unmocked-module-eval-side-effects.yaml +0 -3
- package/config/rules/typescript/output-boundaries.yaml +0 -3
- package/config/rules/typescript/prefer-accessible-role-for-button-queries.yaml +0 -3
- package/config/rules/ui/ui-import-boundary.yaml +1 -5
- package/config/templates/docs/99_PROJECT_CONSTITUTION.md +75 -13
- package/config/transition-shims.json +0 -7
- package/config/workflows/basic.yaml +4 -0
- package/config/workflows/docs-pipeline.yaml +13 -14
- package/config/workflows/feature-dev.yaml +20 -65
- package/config/workflows/history-anatomy.yaml +22 -1
- package/config/workflows/idea-pipeline.yaml +53 -97
- package/config/workflows/pr-review.yaml +21 -33
- package/config/workflows/task-pipeline.yaml +87 -330
- package/config/workflows/wayfinder-resolution.yaml +12 -26
- package/config/workflows/wrapup-pipeline.yaml +48 -189
- package/package.json +9 -9
- package/plugins/sp/README.md +12 -3
- package/plugins/sp/agents/expert-spur.md +41 -19
- package/plugins/sp/lib/idea-handoff.generated.d.mts +17 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +1301 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/feature-dev-precheck.mjs +146 -0
- package/plugins/sp/scripts/feature-dev-precheck.ts +238 -0
- package/plugins/sp/scripts/idea-handoff.mjs +27 -0
- package/plugins/sp/scripts/idea-handoff.ts +44 -0
- package/plugins/sp/scripts/inline-run-setup.ts +69 -1
- package/plugins/sp/scripts/quality-gate.mjs +216 -0
- package/plugins/sp/scripts/quality-gate.ts +287 -0
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -2
- package/plugins/sp/scripts/task-size-precheck.ts +44 -16
- package/plugins/sp/scripts/verify-answer-lint.ts +17 -1
- package/plugins/sp/scripts/workflow-step-profile.mjs +319 -0
- package/plugins/sp/scripts/workflow-step-profile.ts +456 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +350 -0
- package/plugins/sp/scripts/wrapup-steps.ts +466 -0
- package/plugins/sp/skills/code-review/references/review-lenses.md +3 -0
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +29 -0
- package/plugins/sp/skills/spur-cli/SKILL.md +4 -8
- package/plugins/sp/skills/spur-cli/references/agent.md +44 -69
- package/plugins/sp/skills/spur-cli/references/message.md +30 -3
- package/plugins/sp/skills/spur-cli/references/projects.md +25 -1
- package/plugins/sp/skills/spur-cli/references/self.md +6 -5
- package/plugins/sp/skills/spur-cli/references/serve.md +8 -7
- package/plugins/sp/skills/spur-cli/references/tasks.md +1 -1
- package/plugins/sp/skills/spur-cli/references/workflows/operations.md +6 -3
- package/plugins/sp/skills/spur-cli/references/workflows/workflow-fit-and-tuning.md +57 -18
- package/plugins/sp/skills/spur-composer/SKILL.md +145 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +10 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +1 -1
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +4 -6
- package/plugins/sp/skills/spur-dev/references/glossary.md +1 -1
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +11 -1
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +24 -0
- package/plugins/sp/skills/spur-doctor/SKILL.md +138 -0
- package/plugins/sp/skills/taste-refactoring-api/README.md +43 -0
- package/plugins/sp/skills/taste-refactoring-api/SKILL.md +334 -0
- package/plugins/sp/skills/taste-refactoring-api/checklists/daily-api-review.md +71 -0
- package/plugins/sp/skills/taste-refactoring-api/examples/refactor-example.md +72 -0
- package/plugins/sp/skills/taste-refactoring-api/examples/review-template.md +93 -0
- package/plugins/sp/skills/taste-refactoring-api/references/api-refactoring-playbook.md +253 -0
- package/plugins/sp/skills/taste-refactoring-api/references/protocol-modes.md +79 -0
- package/plugins/sp/skills/taste-refactoring-api/references/research-basis.md +58 -0
- package/plugins/sp/skills/taste-refactoring-architect/README.md +26 -0
- package/plugins/sp/skills/taste-refactoring-architect/SKILL.md +471 -0
- package/plugins/sp/skills/taste-refactoring-architect/checklists/daily-architecture-review.md +48 -0
- package/plugins/sp/skills/taste-refactoring-architect/examples/refactor-example.md +55 -0
- package/plugins/sp/skills/taste-refactoring-architect/examples/review-template.md +51 -0
- package/plugins/sp/skills/taste-refactoring-architect/references/architecture-refactoring-playbook.md +173 -0
- package/plugins/sp/skills/taste-refactoring-architect/references/research-basis.md +28 -0
- package/plugins/sp/skills/taste-refactoring-tests/README.md +28 -0
- package/plugins/sp/skills/taste-refactoring-tests/SKILL.md +482 -0
- package/plugins/sp/skills/taste-refactoring-tests/checklists/daily-test-review.md +39 -0
- package/plugins/sp/skills/taste-refactoring-tests/examples/refactor-example.md +85 -0
- package/plugins/sp/skills/taste-refactoring-tests/examples/review-template.md +59 -0
- package/plugins/sp/skills/taste-refactoring-tests/references/research-basis.md +47 -0
- package/plugins/sp/skills/taste-refactoring-tests/references/test-refactoring-playbook.md +222 -0
- package/plugins/sp/skills/taste-refactoring-ui/README.md +12 -0
- package/plugins/sp/skills/taste-refactoring-ui/SKILL.md +290 -0
- package/plugins/sp/skills/taste-refactoring-ui/checklists/daily-ui-review.md +72 -0
- package/plugins/sp/skills/taste-refactoring-ui/examples/review-template.md +51 -0
- package/plugins/sp/skills/taste-refactoring-ui/references/refactoring-ui-playbook.md +170 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +2 -2
- package/plugins/sp/skills/wayfinder/references/pipeline-resolution.md +30 -0
- package/schemas/spur-config.schema.json +105 -85
- package/spur.js +44616 -43320
- package/web/_astro/BoardApp.D-WlxiN2.js +1 -0
- package/web/_astro/{BoardApp.CHQ1lycZ.js → BoardApp.D8bM9pKL.js} +97 -95
- package/web/_astro/{TaskDetail.GKfQJ60c.js → TaskDetail.BPRqgVUE.js} +1 -1
- package/web/_astro/{arc.DWEtA3Tx.js → arc.BPrPES3z.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.DB42oWmP.js → architectureDiagram-3BPJPVTR.qX_7q02P.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.rhv-zNQV.js → blockDiagram-GPEHLZMM.CUZfj5V7.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.Ci4-4VvY.js → c4Diagram-AAUBKEIU.CTaOr8hH.js} +1 -1
- package/web/_astro/channel.DGZaFHZx.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.Cc9veUgf.js → chunk-2J33WTMH.Dt-9wf3h.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.Bec9c4eI.js → chunk-4BX2VUAB.CTC2sdoN.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DoV8S1iB.js → chunk-55IACEB6.DQcxt2_g.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DwR-Qlyj.js → chunk-727SXJPM.DXFPSn-a.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.ND_a81WY.js → chunk-AQP2D5EJ.BCx3U4bT.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Wv_jwG48.js → chunk-FMBD7UC4.DL2tJkdO.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.CXKXCMmp.js → chunk-ND2GUHAM.DZyflMro.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.nkaoNYQq.js → chunk-QZHKN3VN.CUI2mT09.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.cMQcVlQu.js → classDiagram-4FO5ZUOK.g4rX4Fr1.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.cMQcVlQu.js → classDiagram-v2-Q7XG4LA2.g4rX4Fr1.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.OaDJ7Mr2.js → cose-bilkent-S5V4N54A.CzWJLqp0.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.Chi8IphF.js → cynefin-OW5HDTMX.WgsvQeCR.js} +1 -1
- package/web/_astro/{cytoscape.esm.DzSz-X2X.js → cytoscape.esm.BB4DxJjf.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.CzK2t_Fp.js → dagre-BM42HDAG.Dzv6ngql.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DRvxlVS7.js → diagram-2AECGRRQ.CpJ4a9rU.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.CnYvNdwA.js → diagram-5GNKFQAL.CzPlF_dq.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.CpLpMw5R.js → diagram-KO2AKTUF.TAkZNTcQ.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.JTb78qUA.js → diagram-LMA3HP47.uCjoKSag.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.Bk-1jDIb.js → diagram-OG6HWLK6.eMplIjoK.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.D8hN9GZq.js → erDiagram-TEJ5UH35.Bf7zoXGz.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.-6zQr6m5.js → flowDiagram-I6XJVG4X.B_bHj3gN.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.DboLQ9ca.js → ganttDiagram-6RSMTGT7.BasrHRMj.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.4tYvJKGR.js → gitGraphDiagram-PVQCEYII.C6iphq1x.js} +1 -1
- package/web/_astro/index.DayyIngm.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.Bd9rXpsB.js → infoDiagram-5YYISTIA.HXmDMhW4.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.CvMoaf67.js → ishikawaDiagram-YF4QCWOH.BSmW8NiU.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.Ccy1CA7y.js → journeyDiagram-JHISSGLW.DEQow5fo.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.0MaMqHNS.js → kanban-definition-UN3LZRKU.IVm9cTdc.js} +1 -1
- package/web/_astro/{linear.CHXgcIbN.js → linear.CrsM73_9.js} +1 -1
- package/web/_astro/{mermaid.core.Ca-kcelG.js → mermaid.core.CfBeDJls.js} +6 -6
- package/web/_astro/{mindmap-definition-RKZ34NQL.BUIDlHa0.js → mindmap-definition-RKZ34NQL.C3j60Y-0.js} +1 -1
- package/web/_astro/ordinal.BYWQX77i.js +1 -0
- package/web/_astro/{pieDiagram-4H26LBE5.2dX3CU1s.js → pieDiagram-4H26LBE5.B-aCMeEA.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.B3LBlRiv.js → quadrantDiagram-W4KKPZXB.Cib965yq.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.X12I2uNx.js → requirementDiagram-4Y6WPE33.D61cS4O-.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.BXohIHqx.js → sankeyDiagram-5OEKKPKP.GKF2qVPy.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.C37ZIUzg.js → sequenceDiagram-3UESZ5HK.DZnq8F2h.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BRgz317z.js → stateDiagram-AJRCARHV.DXUFmdgM.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.7VYSXN9-.js → stateDiagram-v2-BHNVJYJU.BtHmhLEz.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.BVNz_HiN.js → timeline-definition-PNZ67QCA.Cy-WW2ln.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.CHVDkPX4.js → vennDiagram-CIIHVFJN.SLp5b9KI.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.EQQ_qT9v.js → wardleyDiagram-YWT4CUSO.Bww45mWV.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.DrAT9WoP.js → xychartDiagram-2RQKCTM6.DR4swI6a.js} +1 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/favicon.ico +0 -0
- package/web/favicon.svg +17 -4
- package/web/icon-192.png +0 -0
- package/web/icon-512.png +0 -0
- package/web/index.html +2 -2
- package/web/site.webmanifest +31 -0
- package/web/spur_logo.svg +1 -0
- package/plugins/sp/skills/spur-cli/references/team.md +0 -145
- package/web/_astro/BoardApp.DV9kx0wo.js +0 -1
- package/web/_astro/channel.BAI6xLeV.js +0 -1
- package/web/_astro/index.Dcr_8fiK.css +0 -1
- package/web/_astro/ordinal.DBvzRdQf.js +0 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/quality-gate.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import {
|
|
7
|
+
appendFileSync,
|
|
8
|
+
closeSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
mkdtempSync,
|
|
12
|
+
openSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
statSync,
|
|
16
|
+
writeFileSync
|
|
17
|
+
} from "fs";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
var MAX_GATE_ATTEMPTS = 5;
|
|
21
|
+
var MAX_FINDINGS = 20;
|
|
22
|
+
var RETRY_DELAY_MS_DEFAULT = 1e4;
|
|
23
|
+
var RETRY_DELAY_MS_ENV = "SPUR_QUALITY_GATE_RETRY_DELAY_MS";
|
|
24
|
+
var LOCKED_PATTERN = /SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY/;
|
|
25
|
+
var FINDINGS_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
|
|
26
|
+
var COVERAGE_ROW_PATTERN = /^\s*(\S+\.[A-Za-z]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*(\S*)/;
|
|
27
|
+
function isTransientLock(attemptOutput) {
|
|
28
|
+
return LOCKED_PATTERN.test(attemptOutput);
|
|
29
|
+
}
|
|
30
|
+
function extractFindings(logText) {
|
|
31
|
+
const found = new Set;
|
|
32
|
+
for (const match of logText.matchAll(FINDINGS_PATTERN))
|
|
33
|
+
found.add(match[0]);
|
|
34
|
+
return [...found].sort().slice(0, MAX_FINDINGS).map((anchor) => `${anchor} `).join("");
|
|
35
|
+
}
|
|
36
|
+
function tailLines(text, count) {
|
|
37
|
+
const lines = text.split(`
|
|
38
|
+
`);
|
|
39
|
+
if (lines.length > 0 && lines[lines.length - 1] === "")
|
|
40
|
+
lines.pop();
|
|
41
|
+
const tail = lines.slice(Math.max(0, lines.length - count));
|
|
42
|
+
return tail.length === 0 ? "" : `${tail.join(`
|
|
43
|
+
`)}
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
function retryMessage(attempt) {
|
|
47
|
+
return `quality gate: database is locked; retrying (${attempt}/${MAX_GATE_ATTEMPTS}) in 10s
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
function parseCoverageThreshold(bunfigText) {
|
|
51
|
+
const block = bunfigText.match(/coverageThreshold\s*=\s*\{([^}]*)\}/);
|
|
52
|
+
if (!block)
|
|
53
|
+
return null;
|
|
54
|
+
const threshold = {};
|
|
55
|
+
for (const axis of ["functions", "lines"]) {
|
|
56
|
+
const raw = block[1]?.match(new RegExp(`\\b${axis}\\s*=\\s*([\\d.]+)`))?.[1];
|
|
57
|
+
const value = raw === undefined ? Number.NaN : Number.parseFloat(raw);
|
|
58
|
+
if (Number.isFinite(value))
|
|
59
|
+
threshold[axis] = value;
|
|
60
|
+
}
|
|
61
|
+
return threshold.functions === undefined && threshold.lines === undefined ? null : threshold;
|
|
62
|
+
}
|
|
63
|
+
function scanCoverageShortfalls(logText, threshold) {
|
|
64
|
+
const shortfalls = new Map;
|
|
65
|
+
for (const line of logText.split(`
|
|
66
|
+
`)) {
|
|
67
|
+
const row = line.match(COVERAGE_ROW_PATTERN);
|
|
68
|
+
if (!row)
|
|
69
|
+
continue;
|
|
70
|
+
const [, path, funcs, lines, uncovered] = row;
|
|
71
|
+
if (!path || path === "All files" || shortfalls.has(path))
|
|
72
|
+
continue;
|
|
73
|
+
const belowFunctions = threshold.functions !== undefined && Number.parseFloat(funcs ?? "") < threshold.functions * 100;
|
|
74
|
+
const belowLines = threshold.lines !== undefined && Number.parseFloat(lines ?? "") < threshold.lines * 100;
|
|
75
|
+
if (!belowFunctions && !belowLines)
|
|
76
|
+
continue;
|
|
77
|
+
const firstUncovered = uncovered?.match(/\d+/)?.[0] ?? "1";
|
|
78
|
+
shortfalls.set(path, `quality gate: coverage shortfall ${path}:${firstUncovered} funcs=${funcs} lines=${lines}`);
|
|
79
|
+
}
|
|
80
|
+
return [...shortfalls.values()];
|
|
81
|
+
}
|
|
82
|
+
function retryDelayMs(env) {
|
|
83
|
+
const raw = Number.parseInt(env[RETRY_DELAY_MS_ENV] ?? "", 10);
|
|
84
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : RETRY_DELAY_MS_DEFAULT;
|
|
85
|
+
}
|
|
86
|
+
function gateSleep(ms) {
|
|
87
|
+
if (ms > 0)
|
|
88
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
89
|
+
}
|
|
90
|
+
function runShellCommand(cmd, cwd) {
|
|
91
|
+
const dir = mkdtempSync(join(tmpdir(), "spur-quality-gate-"));
|
|
92
|
+
const path = join(dir, "output");
|
|
93
|
+
const fd = openSync(path, "w");
|
|
94
|
+
try {
|
|
95
|
+
const result = spawnSync("sh", ["-c", cmd], { cwd, stdio: ["ignore", fd, fd] });
|
|
96
|
+
if (result.error !== undefined) {
|
|
97
|
+
return { output: `sh -c failed: ${result.error.message}
|
|
98
|
+
`, code: 1 };
|
|
99
|
+
}
|
|
100
|
+
return { output: readFileSync(path, "utf8"), code: result.status ?? 1 };
|
|
101
|
+
} finally {
|
|
102
|
+
closeSync(fd);
|
|
103
|
+
rmSync(dir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function runQualityGate(mode, env, options = {}) {
|
|
107
|
+
const cwd = options.cwd;
|
|
108
|
+
const runDir = join(".spur", "run");
|
|
109
|
+
mkdirSync(cwd ? join(cwd, runDir) : runDir, { recursive: true });
|
|
110
|
+
const rel = (name) => join(runDir, `${env.wbs}${name}`);
|
|
111
|
+
const logFile = rel("-test-gate.log");
|
|
112
|
+
const findingsFile = rel("-test-gate.findings");
|
|
113
|
+
const statusFile = rel("-test-gate.status");
|
|
114
|
+
const attemptFile = rel("-test-fix-attempt");
|
|
115
|
+
const abs = (p) => cwd ? join(cwd, p) : p;
|
|
116
|
+
writeFileSync(abs(logFile), "");
|
|
117
|
+
if (mode === "run")
|
|
118
|
+
writeFileSync(abs(attemptFile), `0
|
|
119
|
+
`);
|
|
120
|
+
let gateRc = 0;
|
|
121
|
+
let gateAttempt = 0;
|
|
122
|
+
if (mode === "recheck" && (env.gateProbeCmd ?? "").length > 0) {
|
|
123
|
+
const probe = runShellCommand(env.gateProbeCmd ?? "", cwd);
|
|
124
|
+
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
125
|
+
gateRc = probe.code;
|
|
126
|
+
appendFileSync(abs(logFile), probe.output);
|
|
127
|
+
rmSync(abs(`${logFile}.probe`), { force: true });
|
|
128
|
+
}
|
|
129
|
+
if (gateRc === 0) {
|
|
130
|
+
const delayMs = retryDelayMs(env);
|
|
131
|
+
for (gateAttempt = 1;gateAttempt <= MAX_GATE_ATTEMPTS; gateAttempt++) {
|
|
132
|
+
const attemptLogPath = `${logFile}.attempt-${gateAttempt}`;
|
|
133
|
+
const attempt = runShellCommand(env.qualityGateCmd ?? "", cwd);
|
|
134
|
+
writeFileSync(abs(attemptLogPath), attempt.output);
|
|
135
|
+
const locked = isTransientLock(attempt.output);
|
|
136
|
+
appendFileSync(abs(logFile), attempt.output);
|
|
137
|
+
rmSync(abs(attemptLogPath), { force: true });
|
|
138
|
+
gateRc = attempt.code;
|
|
139
|
+
if (gateRc === 0 || !locked || gateAttempt >= MAX_GATE_ATTEMPTS)
|
|
140
|
+
break;
|
|
141
|
+
const line = retryMessage(gateAttempt);
|
|
142
|
+
process.stdout.write(line);
|
|
143
|
+
appendFileSync(abs(logFile), line);
|
|
144
|
+
gateSleep(delayMs);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (gateRc !== 0) {
|
|
148
|
+
const gateLog = existsSync(abs(logFile)) ? readFileSync(abs(logFile), "utf8") : "";
|
|
149
|
+
if (/^\s*0 fail\b/m.test(gateLog) && !/^\s*[1-9]\d*\s+fail\b/m.test(gateLog)) {
|
|
150
|
+
const bunfigPath = cwd ? join(cwd, "bunfig.toml") : "bunfig.toml";
|
|
151
|
+
const threshold = existsSync(bunfigPath) ? parseCoverageThreshold(readFileSync(bunfigPath, "utf8")) : null;
|
|
152
|
+
if (threshold) {
|
|
153
|
+
for (const shortfall of scanCoverageShortfalls(gateLog, threshold)) {
|
|
154
|
+
process.stdout.write(`${shortfall}
|
|
155
|
+
`);
|
|
156
|
+
appendFileSync(abs(logFile), `${shortfall}
|
|
157
|
+
`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (gateRc === 0) {
|
|
163
|
+
const bytes = existsSync(abs(logFile)) ? statSync(abs(logFile)).size : 0;
|
|
164
|
+
const attemptsLabel = gateAttempt > 0 ? String(gateAttempt) : "";
|
|
165
|
+
process.stdout.write(`quality gate PASS (attempts: ${attemptsLabel}; log: ${logFile}; bytes: ${bytes})
|
|
166
|
+
`);
|
|
167
|
+
} else {
|
|
168
|
+
process.stdout.write(`quality gate FAIL \u2014 last 40 lines follow (full log: ${logFile})
|
|
169
|
+
`);
|
|
170
|
+
process.stdout.write(tailLines(readFileSync(abs(logFile), "utf8"), 40));
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(abs(findingsFile), extractFindings(readFileSync(abs(logFile), "utf8")));
|
|
173
|
+
const status = gateRc === 0 ? "PASS" : "FAIL";
|
|
174
|
+
writeFileSync(abs(statusFile), `${status}
|
|
175
|
+
`);
|
|
176
|
+
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ""}
|
|
177
|
+
`);
|
|
178
|
+
return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile };
|
|
179
|
+
}
|
|
180
|
+
var QUALITY_GATE_USAGE = "usage: quality-gate.ts <run|recheck> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest)";
|
|
181
|
+
function main(argv, env = process.env) {
|
|
182
|
+
const mode = argv[0];
|
|
183
|
+
if (mode !== "run" && mode !== "recheck") {
|
|
184
|
+
process.stderr.write(`${QUALITY_GATE_USAGE}
|
|
185
|
+
`);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
if ((env.wbs ?? "").length === 0) {
|
|
189
|
+
process.stderr.write("quality-gate: env `wbs` is required\n");
|
|
190
|
+
return 2;
|
|
191
|
+
}
|
|
192
|
+
runQualityGate(mode, env);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
{
|
|
196
|
+
process.exit(main(process.argv.slice(2)));
|
|
197
|
+
}
|
|
198
|
+
export {
|
|
199
|
+
tailLines,
|
|
200
|
+
scanCoverageShortfalls,
|
|
201
|
+
runShellCommand,
|
|
202
|
+
runQualityGate,
|
|
203
|
+
retryMessage,
|
|
204
|
+
parseCoverageThreshold,
|
|
205
|
+
main,
|
|
206
|
+
isTransientLock,
|
|
207
|
+
extractFindings,
|
|
208
|
+
RETRY_DELAY_MS_ENV,
|
|
209
|
+
RETRY_DELAY_MS_DEFAULT,
|
|
210
|
+
QUALITY_GATE_USAGE,
|
|
211
|
+
MAX_GATE_ATTEMPTS,
|
|
212
|
+
MAX_FINDINGS,
|
|
213
|
+
LOCKED_PATTERN,
|
|
214
|
+
FINDINGS_PATTERN,
|
|
215
|
+
COVERAGE_ROW_PATTERN
|
|
216
|
+
};
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* quality-gate — shared retry/bounded-findings gate behind the task-pipeline test-gate
|
|
4
|
+
* wrappers (task 0823, feature I21, governance §1.2 composition budgets).
|
|
5
|
+
*
|
|
6
|
+
* Reproduces the former task-pipeline `test:onEnter` and `test-recheck:onEnter` shell
|
|
7
|
+
* programs one-for-one so the workflow stays inside the shell-program caps while
|
|
8
|
+
* writing the same `.spur/run` artifacts:
|
|
9
|
+
* - `<wbs>-test-gate.log` merged gate attempt output + `proof-digest:` trailer
|
|
10
|
+
* - `<wbs>-test-gate.findings` unique `file.ext:line` anchors, code-unit sorted, capped at 20
|
|
11
|
+
* - `<wbs>-test-gate.status` `PASS`/`FAIL`
|
|
12
|
+
* - `<wbs>-test-fix-attempt` remediation counter, reset to `0` by `run` only
|
|
13
|
+
*
|
|
14
|
+
* Modes:
|
|
15
|
+
* run reset the attempt counter, truncate the log, execute the gate loop
|
|
16
|
+
* recheck truncate the log, execute the probe first (probe failure is the gate
|
|
17
|
+
* failure, the gate loop is skipped), then execute the gate loop
|
|
18
|
+
*
|
|
19
|
+
* Retry contract: a failed attempt is retried (up to 5 total attempts) only when its
|
|
20
|
+
* output matches a SQLite busy/locked error; the delay defaults to 10 seconds and is
|
|
21
|
+
* overridable via `SPUR_QUALITY_GATE_RETRY_DELAY_MS` (tests).
|
|
22
|
+
*
|
|
23
|
+
* Environment: `wbs` (required), `qualityGateCmd`, `gateProbeCmd` (recheck), `proofDigest`.
|
|
24
|
+
* Soft-fail contract: the process always exits 0; the verdict lives in the status file.
|
|
25
|
+
*
|
|
26
|
+
* Node-builtin imports only; pure helpers are exported for unit testing (ADR-065).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import {
|
|
31
|
+
appendFileSync,
|
|
32
|
+
closeSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
mkdtempSync,
|
|
36
|
+
openSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
rmSync,
|
|
39
|
+
statSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { tmpdir } from 'node:os';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
|
|
45
|
+
export const MAX_GATE_ATTEMPTS = 5;
|
|
46
|
+
export const MAX_FINDINGS = 20;
|
|
47
|
+
export const RETRY_DELAY_MS_DEFAULT = 10_000;
|
|
48
|
+
export const RETRY_DELAY_MS_ENV = 'SPUR_QUALITY_GATE_RETRY_DELAY_MS';
|
|
49
|
+
|
|
50
|
+
/** Transient SQLite lock/busy markers that justify a gate retry (same as the former grep -Eq). */
|
|
51
|
+
export const LOCKED_PATTERN = /SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY/;
|
|
52
|
+
|
|
53
|
+
/** `file.ext:line` anchor extraction, mirroring the former grep -oE. */
|
|
54
|
+
export const FINDINGS_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* bun's coverage table row: `File | % Funcs | % Lines | Uncovered Line #s` (0862 R2).
|
|
58
|
+
* Group 4 (uncovered line numbers) may be empty — a fully covered file has no entry.
|
|
59
|
+
*/
|
|
60
|
+
export const COVERAGE_ROW_PATTERN = /^\s*(\S+\.[A-Za-z]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*(\S*)/;
|
|
61
|
+
|
|
62
|
+
export interface QualityGateEnv {
|
|
63
|
+
wbs: string;
|
|
64
|
+
qualityGateCmd?: string;
|
|
65
|
+
gateProbeCmd?: string;
|
|
66
|
+
proofDigest?: string;
|
|
67
|
+
[key: string]: string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface QualityGateOptions {
|
|
71
|
+
/** Base directory for `.spur/run`; defaults to the process cwd (CLI behavior). */
|
|
72
|
+
cwd?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface QualityGateResult {
|
|
76
|
+
status: 'PASS' | 'FAIL';
|
|
77
|
+
attempts: number;
|
|
78
|
+
logFile: string;
|
|
79
|
+
findingsFile: string;
|
|
80
|
+
statusFile: string;
|
|
81
|
+
attemptFile: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isTransientLock(attemptOutput: string): boolean {
|
|
85
|
+
return LOCKED_PATTERN.test(attemptOutput);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Unique anchors, code-unit sorted, capped at MAX_FINDINGS, each followed by one space. */
|
|
89
|
+
export function extractFindings(logText: string): string {
|
|
90
|
+
const found = new Set<string>();
|
|
91
|
+
for (const match of logText.matchAll(FINDINGS_PATTERN)) found.add(match[0]);
|
|
92
|
+
return [...found]
|
|
93
|
+
.sort()
|
|
94
|
+
.slice(0, MAX_FINDINGS)
|
|
95
|
+
.map((anchor) => `${anchor} `)
|
|
96
|
+
.join('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `tail -n <count>` on POSIX text: last count lines, newline-terminated. */
|
|
100
|
+
export function tailLines(text: string, count: number): string {
|
|
101
|
+
const lines = text.split('\n');
|
|
102
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
103
|
+
const tail = lines.slice(Math.max(0, lines.length - count));
|
|
104
|
+
return tail.length === 0 ? '' : `${tail.join('\n')}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function retryMessage(attempt: number): string {
|
|
108
|
+
return `quality gate: database is locked; retrying (${attempt}/${MAX_GATE_ATTEMPTS}) in 10s\n`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Per-axis coverage floor from `bunfig.toml`; a missing key leaves that axis unchecked. */
|
|
112
|
+
export interface CoverageThreshold {
|
|
113
|
+
functions?: number;
|
|
114
|
+
lines?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse `coverageThreshold = { lines = 0.9, functions = 0.9 }` out of bunfig text.
|
|
119
|
+
* `null` when the setting is absent — the caller then skips the shortfall scan entirely.
|
|
120
|
+
*/
|
|
121
|
+
export function parseCoverageThreshold(bunfigText: string): CoverageThreshold | null {
|
|
122
|
+
const block = bunfigText.match(/coverageThreshold\s*=\s*\{([^}]*)\}/);
|
|
123
|
+
if (!block) return null;
|
|
124
|
+
const threshold: CoverageThreshold = {};
|
|
125
|
+
for (const axis of ['functions', 'lines'] as const) {
|
|
126
|
+
const raw = block[1]?.match(new RegExp(`\\b${axis}\\s*=\\s*([\\d.]+)`))?.[1];
|
|
127
|
+
const value = raw === undefined ? Number.NaN : Number.parseFloat(raw);
|
|
128
|
+
if (Number.isFinite(value)) threshold[axis] = value;
|
|
129
|
+
}
|
|
130
|
+
return threshold.functions === undefined && threshold.lines === undefined ? null : threshold;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One `quality gate: coverage shortfall <path>:<line> funcs=<f> lines=<l>` line per distinct table
|
|
135
|
+
* row below either axis (thresholds are fractions, table numbers are percentages). `line` is the
|
|
136
|
+
* first number of the uncovered column, else `1`.
|
|
137
|
+
*/
|
|
138
|
+
export function scanCoverageShortfalls(logText: string, threshold: CoverageThreshold): string[] {
|
|
139
|
+
const shortfalls = new Map<string, string>();
|
|
140
|
+
for (const line of logText.split('\n')) {
|
|
141
|
+
const row = line.match(COVERAGE_ROW_PATTERN);
|
|
142
|
+
if (!row) continue;
|
|
143
|
+
const [, path, funcs, lines, uncovered] = row;
|
|
144
|
+
if (!path || path === 'All files' || shortfalls.has(path)) continue;
|
|
145
|
+
const belowFunctions =
|
|
146
|
+
threshold.functions !== undefined && Number.parseFloat(funcs ?? '') < threshold.functions * 100;
|
|
147
|
+
const belowLines = threshold.lines !== undefined && Number.parseFloat(lines ?? '') < threshold.lines * 100;
|
|
148
|
+
if (!belowFunctions && !belowLines) continue;
|
|
149
|
+
const firstUncovered = uncovered?.match(/\d+/)?.[0] ?? '1';
|
|
150
|
+
shortfalls.set(
|
|
151
|
+
path,
|
|
152
|
+
`quality gate: coverage shortfall ${path}:${firstUncovered} funcs=${funcs} lines=${lines}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return [...shortfalls.values()];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function retryDelayMs(env: QualityGateEnv): number {
|
|
159
|
+
const raw = Number.parseInt(env[RETRY_DELAY_MS_ENV] ?? '', 10);
|
|
160
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : RETRY_DELAY_MS_DEFAULT;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function gateSleep(ms: number): void {
|
|
164
|
+
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `sh -c <cmd> > <attempt log> 2>&1` equivalent: merged output plus exit code. */
|
|
168
|
+
export function runShellCommand(cmd: string, cwd: string | undefined): { output: string; code: number } {
|
|
169
|
+
const dir = mkdtempSync(join(tmpdir(), 'spur-quality-gate-'));
|
|
170
|
+
const path = join(dir, 'output');
|
|
171
|
+
const fd = openSync(path, 'w');
|
|
172
|
+
try {
|
|
173
|
+
// One file descriptor preserves stream order and avoids spawnSync's pipe buffer limit.
|
|
174
|
+
const result = spawnSync('sh', ['-c', cmd], { cwd, stdio: ['ignore', fd, fd] });
|
|
175
|
+
if (result.error !== undefined) {
|
|
176
|
+
return { output: `sh -c failed: ${result.error.message}\n`, code: 1 };
|
|
177
|
+
}
|
|
178
|
+
return { output: readFileSync(path, 'utf8'), code: result.status ?? 1 };
|
|
179
|
+
} finally {
|
|
180
|
+
closeSync(fd);
|
|
181
|
+
rmSync(dir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function runQualityGate(
|
|
186
|
+
mode: 'run' | 'recheck',
|
|
187
|
+
env: QualityGateEnv,
|
|
188
|
+
options: QualityGateOptions = {},
|
|
189
|
+
): QualityGateResult {
|
|
190
|
+
const cwd = options.cwd;
|
|
191
|
+
const runDir = join('.spur', 'run');
|
|
192
|
+
mkdirSync(cwd ? join(cwd, runDir) : runDir, { recursive: true });
|
|
193
|
+
const rel = (name: string): string => join(runDir, `${env.wbs}${name}`);
|
|
194
|
+
const logFile = rel('-test-gate.log');
|
|
195
|
+
const findingsFile = rel('-test-gate.findings');
|
|
196
|
+
const statusFile = rel('-test-gate.status');
|
|
197
|
+
const attemptFile = rel('-test-fix-attempt');
|
|
198
|
+
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
199
|
+
|
|
200
|
+
// run: `echo 0 > "$ATTEMPT_FILE" && : > "$LOG_FILE"`; recheck: truncate only.
|
|
201
|
+
writeFileSync(abs(logFile), '');
|
|
202
|
+
if (mode === 'run') writeFileSync(abs(attemptFile), '0\n');
|
|
203
|
+
|
|
204
|
+
let gateRc = 0;
|
|
205
|
+
let gateAttempt = 0;
|
|
206
|
+
|
|
207
|
+
// recheck probe: a probe failure is the gate failure; the gate loop is skipped.
|
|
208
|
+
if (mode === 'recheck' && (env.gateProbeCmd ?? '').length > 0) {
|
|
209
|
+
const probe = runShellCommand(env.gateProbeCmd ?? '', cwd);
|
|
210
|
+
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
211
|
+
gateRc = probe.code;
|
|
212
|
+
appendFileSync(abs(logFile), probe.output);
|
|
213
|
+
rmSync(abs(`${logFile}.probe`), { force: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (gateRc === 0) {
|
|
217
|
+
const delayMs = retryDelayMs(env);
|
|
218
|
+
for (gateAttempt = 1; gateAttempt <= MAX_GATE_ATTEMPTS; gateAttempt++) {
|
|
219
|
+
const attemptLogPath = `${logFile}.attempt-${gateAttempt}`;
|
|
220
|
+
const attempt = runShellCommand(env.qualityGateCmd ?? '', cwd);
|
|
221
|
+
writeFileSync(abs(attemptLogPath), attempt.output);
|
|
222
|
+
const locked = isTransientLock(attempt.output);
|
|
223
|
+
appendFileSync(abs(logFile), attempt.output);
|
|
224
|
+
rmSync(abs(attemptLogPath), { force: true });
|
|
225
|
+
gateRc = attempt.code;
|
|
226
|
+
if (gateRc === 0 || !locked || gateAttempt >= MAX_GATE_ATTEMPTS) break;
|
|
227
|
+
const line = retryMessage(gateAttempt);
|
|
228
|
+
process.stdout.write(line); // tee: stdout and the log
|
|
229
|
+
appendFileSync(abs(logFile), line);
|
|
230
|
+
gateSleep(delayMs);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Coverage-only failures carry no `file.ext:line` anchor, so findings extraction would hand the
|
|
235
|
+
// fix hop an empty file. Name one shortfall line per under-threshold row instead (0862 R2) —
|
|
236
|
+
// real test failures, an absent threshold and the exit-0 contract are untouched.
|
|
237
|
+
if (gateRc !== 0) {
|
|
238
|
+
const gateLog = existsSync(abs(logFile)) ? readFileSync(abs(logFile), 'utf8') : '';
|
|
239
|
+
if (/^\s*0 fail\b/m.test(gateLog) && !/^\s*[1-9]\d*\s+fail\b/m.test(gateLog)) {
|
|
240
|
+
const bunfigPath = cwd ? join(cwd, 'bunfig.toml') : 'bunfig.toml';
|
|
241
|
+
const threshold = existsSync(bunfigPath) ? parseCoverageThreshold(readFileSync(bunfigPath, 'utf8')) : null;
|
|
242
|
+
if (threshold) {
|
|
243
|
+
for (const shortfall of scanCoverageShortfalls(gateLog, threshold)) {
|
|
244
|
+
process.stdout.write(`${shortfall}\n`); // tee: stdout and the log
|
|
245
|
+
appendFileSync(abs(logFile), `${shortfall}\n`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (gateRc === 0) {
|
|
252
|
+
const bytes = existsSync(abs(logFile)) ? statSync(abs(logFile)).size : 0;
|
|
253
|
+
const attemptsLabel = gateAttempt > 0 ? String(gateAttempt) : '';
|
|
254
|
+
process.stdout.write(`quality gate PASS (attempts: ${attemptsLabel}; log: ${logFile}; bytes: ${bytes})\n`);
|
|
255
|
+
} else {
|
|
256
|
+
process.stdout.write(`quality gate FAIL — last 40 lines follow (full log: ${logFile})\n`);
|
|
257
|
+
process.stdout.write(tailLines(readFileSync(abs(logFile), 'utf8'), 40));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
writeFileSync(abs(findingsFile), extractFindings(readFileSync(abs(logFile), 'utf8')));
|
|
261
|
+
const status: 'PASS' | 'FAIL' = gateRc === 0 ? 'PASS' : 'FAIL';
|
|
262
|
+
writeFileSync(abs(statusFile), `${status}\n`);
|
|
263
|
+
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ''}\n`);
|
|
264
|
+
|
|
265
|
+
return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const QUALITY_GATE_USAGE =
|
|
269
|
+
'usage: quality-gate.ts <run|recheck> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest)';
|
|
270
|
+
|
|
271
|
+
export function main(argv: string[], env: QualityGateEnv = process.env): number {
|
|
272
|
+
const mode = argv[0];
|
|
273
|
+
if (mode !== 'run' && mode !== 'recheck') {
|
|
274
|
+
process.stderr.write(`${QUALITY_GATE_USAGE}\n`);
|
|
275
|
+
return 2;
|
|
276
|
+
}
|
|
277
|
+
if ((env.wbs ?? '').length === 0) {
|
|
278
|
+
process.stderr.write('quality-gate: env `wbs` is required\n');
|
|
279
|
+
return 2;
|
|
280
|
+
}
|
|
281
|
+
runQualityGate(mode, env);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (import.meta.main) {
|
|
286
|
+
process.exit(main(process.argv.slice(2)));
|
|
287
|
+
}
|
|
@@ -235,7 +235,6 @@ export function nounOfReference(file: string): string | null {
|
|
|
235
235
|
workflows: 'workflow',
|
|
236
236
|
agent: 'agent',
|
|
237
237
|
message: 'message',
|
|
238
|
-
team: 'team',
|
|
239
238
|
init: 'init',
|
|
240
239
|
serve: 'serve',
|
|
241
240
|
};
|
|
@@ -641,7 +640,6 @@ const JSON_PROBES: string[][] = [
|
|
|
641
640
|
['workflow', 'list', '--json'],
|
|
642
641
|
['workflow', 'validate', 'basic.yaml', '--json'],
|
|
643
642
|
['status', '--json'],
|
|
644
|
-
['team', 'status', '--json'],
|
|
645
643
|
['projects', 'list', '--json'],
|
|
646
644
|
];
|
|
647
645
|
|
|
@@ -27,13 +27,50 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
|
|
30
|
-
// ───
|
|
30
|
+
// ─── Counting (sync with packages/app/src/services/task-size-precheck.ts) ────
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Requirement item: list marker required, then an optional checkbox, then the R-number in the
|
|
34
|
+
* house `- **R1** — <text>` or legacy `- [ ] R1. <text>` form. A bare `R1 <text>` line is prose.
|
|
35
|
+
*/
|
|
36
|
+
const R_ITEM_RE_SOURCE = '^\\s*[-*]\\s+(?:\\[[ xX]\\]\\s*)?[*_]{0,2}R(\\d+)\\.?[*_]{0,2}(?:\\s|$)';
|
|
37
|
+
|
|
38
|
+
/** Top-level numbered Plan step (`1. <text>`). */
|
|
39
|
+
const NUMBERED_PLAN_ITEM_RE_SOURCE = '^\\d+\\.\\s';
|
|
40
|
+
|
|
41
|
+
/** Checklist Plan item (`- [ ] <text>`, `- [x] <text>`). */
|
|
42
|
+
const CHECKLIST_ITEM_RE_SOURCE = '^\\s*[-*]\\s+\\[[ xX]\\]';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Body of a `##`/`###` section (heading line excluded), up to the next `##`/`###` heading.
|
|
46
|
+
* `null` when the heading is absent — callers decide the fallback.
|
|
47
|
+
*/
|
|
48
|
+
function sectionBody(content: string, headingPattern: string): string | null {
|
|
49
|
+
const heading = content.match(new RegExp(headingPattern, 'm'));
|
|
50
|
+
if (!heading) return null;
|
|
51
|
+
const rest = content.slice((heading.index ?? 0) + heading[0].length);
|
|
52
|
+
const nextHeading = rest.match(/^#{2,3}\s+/m);
|
|
53
|
+
return nextHeading ? rest.slice(0, nextHeading.index ?? 0) : rest;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Distinct R-items in `## Requirements` (whole content when the heading is absent). */
|
|
57
|
+
function countRItems(taskContent: string): number {
|
|
58
|
+
const body = sectionBody(taskContent, '^#{2,3}\\s+Requirements\\s*$') ?? taskContent;
|
|
59
|
+
const numbers = new Set<string>();
|
|
60
|
+
for (const match of body.matchAll(new RegExp(R_ITEM_RE_SOURCE, 'gm'))) {
|
|
61
|
+
numbers.add(match[1] ?? '');
|
|
62
|
+
}
|
|
63
|
+
return numbers.size;
|
|
64
|
+
}
|
|
34
65
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
66
|
+
/** Plan items: top-level numbered steps plus checklist items under `## Plan`. */
|
|
67
|
+
function countPlanItems(taskContent: string): number {
|
|
68
|
+
const body = sectionBody(taskContent, '^#{2,3}\\s+Plan\\s*$');
|
|
69
|
+
if (body === null) return 0;
|
|
70
|
+
const numbered = body.match(new RegExp(NUMBERED_PLAN_ITEM_RE_SOURCE, 'gm'))?.length ?? 0;
|
|
71
|
+
const checklist = body.match(new RegExp(CHECKLIST_ITEM_RE_SOURCE, 'gm'))?.length ?? 0;
|
|
72
|
+
return numbered + checklist;
|
|
73
|
+
}
|
|
37
74
|
|
|
38
75
|
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
39
76
|
|
|
@@ -128,17 +165,8 @@ function main(): void {
|
|
|
128
165
|
process.exit(0);
|
|
129
166
|
}
|
|
130
167
|
|
|
131
|
-
const reqCount = taskContent
|
|
132
|
-
|
|
133
|
-
const planMatch = taskContent.match(/^#{2,3}\s+Plan\s*$/m);
|
|
134
|
-
const planBody = planMatch
|
|
135
|
-
? (() => {
|
|
136
|
-
const rest = taskContent.slice((planMatch.index ?? 0) + planMatch[0].length);
|
|
137
|
-
const nextSection = rest.match(/^#{2,3}\s+/m);
|
|
138
|
-
return nextSection ? rest.slice(0, nextSection.index ?? 0) : rest;
|
|
139
|
-
})()
|
|
140
|
-
: '';
|
|
141
|
-
const planItemCount = planBody.match(new RegExp(CHECKLIST_ITEM_RE.source, 'gm'))?.length ?? 0;
|
|
168
|
+
const reqCount = countRItems(taskContent);
|
|
169
|
+
const planItemCount = countPlanItems(taskContent);
|
|
142
170
|
|
|
143
171
|
const reasons: string[] = [];
|
|
144
172
|
if (reqCount > maxReqs) {
|
|
@@ -298,6 +298,11 @@ function normalizeAcTitle(title: string): string {
|
|
|
298
298
|
// U+02BC stays a meaningful character, never removable punctuation.
|
|
299
299
|
.replace(/[\u0027\u2018\u2019\u201c\u201d]/g, '')
|
|
300
300
|
.replace(/\s+/g, ' ')
|
|
301
|
+
// A declared bold head keeps its sentence-final period (`- **AC1: … form.**`) while an
|
|
302
|
+
// answer row normally cites it without one. Both the declared spelling and the queried
|
|
303
|
+
// row normalize through here, so dropping it is symmetric — it can only merge titles
|
|
304
|
+
// that already differ by nothing but trailing periods.
|
|
305
|
+
.replace(/\s*\.+$/, '')
|
|
301
306
|
.trim()
|
|
302
307
|
);
|
|
303
308
|
}
|
|
@@ -332,6 +337,16 @@ function buildAcIdentityIndex(taskContent: string, featureContent: string | null
|
|
|
332
337
|
const leading = label.split(/\s+/)[0] ?? '';
|
|
333
338
|
if (leading && leading !== label) declareIdentity(leading);
|
|
334
339
|
}
|
|
340
|
+
// Bold head of a single-line criterion bullet (task 0862 R4): the bold span and its
|
|
341
|
+
// head before the first ` — ` / `:` are declared ids, so `- **AC2 — Title.** Given …`
|
|
342
|
+
// answers to both `AC2` and the full bold title.
|
|
343
|
+
for (const m of section.matchAll(/^[-*]\s+(?:\[[ xX]\]\s+)?\*\*(.+?)\*\*/gm)) {
|
|
344
|
+
const inner = (m[1] ?? '').trim();
|
|
345
|
+
if (!inner) continue;
|
|
346
|
+
declareIdentity(inner);
|
|
347
|
+
const head = inner.split(/\s+[—–]\s+|:/)[0]?.trim() ?? '';
|
|
348
|
+
if (head && head !== inner) declareIdentity(head);
|
|
349
|
+
}
|
|
335
350
|
// Bold-trajectory form (task 0817 R3): answers may cite an AC by a bare
|
|
336
351
|
// `**AC id**` paragraph (house style for long/complex ids). Only whole-line
|
|
337
352
|
// bold spans count — the line-anchored lazy regex rejects lines with two
|
|
@@ -492,7 +507,8 @@ function main(): void {
|
|
|
492
507
|
else
|
|
493
508
|
add(
|
|
494
509
|
`line ${row.line}: AC ID "${row.id.slice(0, 60)}" matches no task AC checklist label or scenario title ` +
|
|
495
|
-
|
|
510
|
+
"(accepted forms: exact title, bare title, a criterion bullet's bold head or full bold span, " +
|
|
511
|
+
'`Scenario:` prefix, bracket tags, declared AC-N alias)',
|
|
496
512
|
);
|
|
497
513
|
} else if (canonicalKey !== null && seenAc.has(canonicalKey)) {
|
|
498
514
|
const first = seenAc.get(canonicalKey) ?? '';
|