@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +42 -0
- package/board/index.d.ts +38 -0
- package/config/plugin-scripts.json +3 -52
- package/config/rules/boundary/sp-script-placement.yaml +19 -0
- package/config/rules/strict/runtime-boundaries.yaml +5 -0
- package/config/rules/structure/test-location.yaml +2 -0
- package/config/rules/typescript/no-syscall-emulation-in-boundary-mock.yaml +1 -1
- package/config/rules/typescript/output-boundaries.yaml +15 -2
- package/config/script-placement-baseline.json +51 -0
- package/config/workflows/feature-verification.yaml +7 -4
- package/config/workflows/history-anatomy.yaml +23 -11
- package/config/workflows/idea-pipeline.yaml +87 -86
- package/config/workflows/pr-review.yaml +18 -9
- package/config/workflows/task-pipeline.yaml +84 -109
- package/config/workflows/wrapup-pipeline.yaml +173 -46
- package/package.json +15 -11
- package/plugins/sp/README.md +8 -10
- package/plugins/sp/agents/super-reviewer.md +43 -18
- package/plugins/sp/commands/dev-fixgha.md +83 -0
- package/plugins/sp/commands/dev-gitmsg.md +8 -6
- package/plugins/sp/commands/dev-gtd.md +2 -2
- package/plugins/sp/commands/dev-idea.md +22 -12
- package/plugins/sp/commands/dev-plan.md +8 -9
- package/plugins/sp/commands/dev-review.md +22 -13
- package/plugins/sp/commands/dev-verify.md +7 -4
- package/plugins/sp/commands/dev-verifyall.md +5 -4
- package/plugins/sp/commands/dev-wrap.md +1 -1
- package/plugins/sp/commands/spur-init.md +2 -2
- package/plugins/sp/hooks/context-post-tool.ts +2 -2
- package/plugins/sp/hooks/context-session-start.ts +2 -1
- package/plugins/sp/hooks/context-session-stop.ts +22 -14
- package/plugins/sp/hooks/pi/guard-extension.ts +109 -150
- package/plugins/sp/lib/history-anatomy.generated.d.mts +112 -0
- package/plugins/sp/lib/history-anatomy.generated.mjs +686 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +6 -5
- package/plugins/sp/lib/inline-run.generated.d.mts +13 -2
- package/plugins/sp/lib/inline-run.generated.mjs +33 -16
- package/plugins/sp/lib/quality-gate.generated.d.mts +104 -0
- package/plugins/sp/lib/quality-gate.generated.mjs +438 -0
- package/plugins/sp/lib/residual-scan.generated.d.mts +62 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +210 -0
- package/plugins/sp/lib/spur-bin.ts +36 -0
- package/plugins/sp/lib/step-profile.generated.d.mts +71 -0
- package/plugins/sp/lib/step-profile.generated.mjs +174 -0
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
- package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
- package/plugins/sp/scripts/history-anatomy-cache.mjs +23 -22
- package/plugins/sp/scripts/history-anatomy-cache.ts +23 -928
- package/plugins/sp/scripts/inline-run-setup.mjs +90 -315
- package/plugins/sp/scripts/inline-run-setup.ts +109 -639
- package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
- package/plugins/sp/scripts/pr-reviewing.ts +1 -0
- package/plugins/sp/scripts/quality-gate.mjs +38 -21
- package/plugins/sp/scripts/quality-gate.ts +20 -660
- package/plugins/sp/scripts/residual-scan.mjs +138 -159
- package/plugins/sp/scripts/residual-scan.ts +102 -499
- package/plugins/sp/scripts/script-root.mjs +129 -0
- package/plugins/sp/scripts/script-root.ts +199 -0
- package/plugins/sp/scripts/workflow-step-profile.mjs +27 -16
- package/plugins/sp/scripts/workflow-step-profile.ts +22 -314
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +12 -3
- package/plugins/sp/scripts/wrapup-drift-probe.ts +6 -3
- package/plugins/sp/scripts/wrapup-steps.mjs +42 -40
- package/plugins/sp/scripts/wrapup-steps.ts +64 -46
- package/plugins/sp/skills/code-improvement/SKILL.md +5 -4
- package/plugins/sp/skills/code-verification/SKILL.md +46 -16
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +13 -2
- package/plugins/sp/skills/functional-review/SKILL.md +8 -5
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +1 -1
- package/plugins/sp/skills/history-anatomy/references/modes.md +2 -1
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +1 -1
- package/plugins/sp/skills/next-router/references/routing-table.md +2 -2
- package/plugins/sp/skills/spur-cli/SKILL.md +3 -3
- package/plugins/sp/skills/spur-cli/references/agent.md +10 -10
- package/plugins/sp/skills/spur-cli/references/features.md +17 -6
- package/plugins/sp/skills/spur-cli/references/init.md +17 -16
- package/plugins/sp/skills/spur-cli/references/self.md +3 -2
- package/plugins/sp/skills/spur-cli/references/serve.md +10 -10
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +28 -5
- package/plugins/sp/skills/spur-cli/references/tasks.md +14 -8
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +4 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +11 -9
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +1 -1
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +86 -70
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +3 -3
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +107 -44
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +5 -9
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +3 -3
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +31 -21
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +19 -19
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +4 -3
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +35 -6
- package/plugins/sp/skills/spur-doctor/SKILL.md +1 -1
- package/plugins/sp/skills/sys-architecture/SKILL.md +3 -2
- package/plugins/sp/tsconfig.json +8 -0
- package/spur.js +6638 -4775
- package/web/_astro/BoardApp.BIjMatT1.js +1 -0
- package/web/_astro/{BoardApp.FTEs3-N8.js → BoardApp.GvjIe9Z6.js} +150 -153
- package/web/_astro/TaskDetail.BZM3EAFx.js +1 -0
- package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
- package/web/_astro/{arc.uuAf51IT.js → arc.HkRiZnoI.js} +1 -1
- package/web/_astro/architectureDiagram-3BPJPVTR.BDdK-tgZ.js +36 -0
- package/web/_astro/{blockDiagram-GPEHLZMM.D1mGCq3p.js → blockDiagram-GPEHLZMM.CZ9kOBxx.js} +1 -1
- package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
- package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
- package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
- package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
- package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
- package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
- package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
- package/web/_astro/{c4Diagram-AAUBKEIU.CMsolcde.js → c4Diagram-AAUBKEIU.DXK9qeRD.js} +1 -1
- package/web/_astro/channel.DlhX2MEt.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.CrGA3fik.js → chunk-2J33WTMH.BTD2WeX5.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.DsLVVla0.js → chunk-4BX2VUAB.Ci6Qbcvk.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.Dxc59Tfi.js → chunk-55IACEB6.yl3zsj7p.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.CaEVE2Wy.js → chunk-727SXJPM.ClXZsfyR.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.BiJ4HXeI.js → chunk-AQP2D5EJ.BmKWZBcP.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Mxf1fru5.js → chunk-FMBD7UC4.By30fcb7.js} +1 -1
- package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
- package/web/_astro/{chunk-ND2GUHAM.pyOWQixH.js → chunk-ND2GUHAM.24NmrD-k.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.BmEsg4vr.js → chunk-QZHKN3VN.BN4sKdcS.js} +1 -1
- package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
- package/web/_astro/{classDiagram-4FO5ZUOK.BHhhMFTO.js → classDiagram-4FO5ZUOK.CJpoMPb5.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.BHhhMFTO.js → classDiagram-v2-Q7XG4LA2.CJpoMPb5.js} +1 -1
- package/web/_astro/client.BhluLQqe.js +1 -0
- package/web/_astro/client.DBpSRLnx.js +9 -0
- package/web/_astro/cose-bilkent-S5V4N54A.BraxQ2Nt.js +1 -0
- package/web/_astro/{cynefin-OW5HDTMX.C2j1_lKL.js → cynefin-OW5HDTMX.gzoU73oL.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.hZ2NCTdT.js → dagre-BM42HDAG.wyDfWySU.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.Bfw5_EzK.js → diagram-2AECGRRQ.h2_EbMxV.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Bt1V_Tmk.js → diagram-5GNKFQAL.Db6dcs89.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.DEk-YFwp.js → diagram-KO2AKTUF.NVwzxu_U.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.CDjndtQm.js → diagram-LMA3HP47.B-eNgDQk.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.cFnUHScG.js → diagram-OG6HWLK6.Bsa8ZRbJ.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.DlhYp7NV.js → erDiagram-TEJ5UH35.BospRE5Q.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DNTpsxfx.js → flowDiagram-I6XJVG4X.DMzvHwAA.js} +1 -1
- package/web/_astro/ganttDiagram-6RSMTGT7.Dmdxgd-M.js +292 -0
- package/web/_astro/{gitGraphDiagram-PVQCEYII.DkEqNI0P.js → gitGraphDiagram-PVQCEYII.CaqdO0rL.js} +1 -1
- package/web/_astro/index.5dn-Zm-2.js +1 -0
- package/web/_astro/index.C-IqPpW2.js +1 -0
- package/web/_astro/index.Cyc_GA4f.js +1 -0
- package/web/_astro/index.EoZlzLK-.css +1 -0
- package/web/_astro/index.tZfqwSut.js +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CrjioCTG.js → infoDiagram-5YYISTIA.DDVD9Wn7.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.BcK0CN8n.js → ishikawaDiagram-YF4QCWOH.EKyCUFsL.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.p0CbBDX1.js → journeyDiagram-JHISSGLW.By2-RkYB.js} +1 -1
- package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
- package/web/_astro/{kanban-definition-UN3LZRKU.puHIFt6J.js → kanban-definition-UN3LZRKU.ar-WmlyR.js} +1 -1
- package/web/_astro/{linear.Bb-3a1d7.js → linear.BnzMBgo_.js} +1 -1
- package/web/_astro/{mermaid.core.CJDgXOJs.js → mermaid.core.DSqeFu2Y.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BoAZEI9t.js → mindmap-definition-RKZ34NQL.1XcF8Wh2.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.CJNSdqY7.js → pieDiagram-4H26LBE5.DnItHOdK.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.DtTy7_0Z.js → quadrantDiagram-W4KKPZXB.BQQyrnBt.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.BNYV98Xg.js → requirementDiagram-4Y6WPE33.Dmzxun82.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DRq9DGHp.js → sankeyDiagram-5OEKKPKP.BBIeRKYB.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.B7KdBFCm.js → sequenceDiagram-3UESZ5HK.CUNVdBBf.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.CD62ZJ2H.js → stateDiagram-AJRCARHV.DlFf2sJm.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.CeVk1TAj.js → stateDiagram-v2-BHNVJYJU.DXR7I1H5.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.C_ZPA5tT.js → timeline-definition-PNZ67QCA.BcO-GyX2.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.CrThO_FQ.js → vennDiagram-CIIHVFJN.ChCECmmW.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.DSZCA5nl.js → wardleyDiagram-YWT4CUSO.CRaT12mD.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.KI7baTuk.js → xychartDiagram-2RQKCTM6.B3lsHVpj.js} +1 -1
- package/web/board-runtime.json +69 -0
- package/web/index.html +2 -2
- package/plugins/sp/lib/artifact-digest.generated.d.mts +0 -7
- package/plugins/sp/lib/artifact-digest.generated.mjs +0 -48
- package/plugins/sp/scripts/feature-sync-bounded.mjs +0 -298
- package/plugins/sp/scripts/feature-sync-bounded.ts +0 -479
- package/plugins/sp/scripts/idea-coverage-check.ts +0 -168
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +0 -298
- package/plugins/sp/scripts/record-feature-sync.mjs +0 -63
- package/plugins/sp/scripts/record-feature-sync.ts +0 -84
- package/plugins/sp/scripts/script-contract-check.ts +0 -377
- package/plugins/sp/scripts/stage-registry-adapter.ts +0 -1533
- package/plugins/sp/scripts/surface-drift-inventory.ts +0 -989
- package/plugins/sp/scripts/task-evidence-precheck.ts +0 -187
- package/plugins/sp/scripts/task-size-precheck.ts +0 -210
- package/plugins/sp/scripts/transition-shim-check.ts +0 -238
- package/plugins/sp/scripts/validate-commands.ts +0 -689
- package/plugins/sp/scripts/validate-flag-contracts.ts +0 -878
- package/plugins/sp/scripts/verify-answer-lint.ts +0 -547
- package/web/_astro/BoardApp.C02hAHPO.js +0 -1
- package/web/_astro/TaskDetail.C-GdsS-t.js +0 -1
- package/web/_astro/architectureDiagram-3BPJPVTR.CGe629A1.js +0 -36
- package/web/_astro/channel.fsgl7o5j.js +0 -1
- package/web/_astro/client.yhYJvxCU.js +0 -9
- package/web/_astro/cose-bilkent-S5V4N54A.2fH4YOlp.js +0 -1
- package/web/_astro/ganttDiagram-6RSMTGT7.DC_p36PI.js +0 -292
- package/web/_astro/index.De90oHcH.js +0 -1
- package/web/_astro/index.Hjbr15fG.css +0 -1
|
@@ -3,683 +3,43 @@
|
|
|
3
3
|
* quality-gate — shared retry/bounded-findings gate behind the task-pipeline test-gate
|
|
4
4
|
* wrappers (task 0823, feature I21, governance §1.2 composition budgets).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
6
|
+
* Task 1006 R1: the gate logic moved to packages/app/src/services/quality-gate.ts (unit-tested
|
|
7
|
+
* there); this script is ADR-130 glue — argv/env parsing only — over the bundled core in
|
|
8
|
+
* ../lib/quality-gate.generated.* (regenerate: `bun run build:plugin-lib && bun run build:scripts`).
|
|
13
9
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* then the gate loop
|
|
20
|
-
* light changed-scope tier (task 0939, ADR-124): biome on the changed files,
|
|
21
|
-
* per-workspace typecheck, and filename-mapped related tests run inside
|
|
22
|
-
* their workspace; merges rows under `tier: light`, soft-fails
|
|
23
|
-
* status print `{reuse, reason}` for the task's receipt and exit 0
|
|
24
|
-
*
|
|
25
|
-
* Retry contract: a failed attempt is retried (up to 5 total attempts) only when its
|
|
26
|
-
* output matches a SQLite busy/locked error; the delay defaults to 10 seconds and is
|
|
27
|
-
* overridable via `SPUR_QUALITY_GATE_RETRY_DELAY_MS` (tests).
|
|
28
|
-
*
|
|
29
|
-
* Check receipts (task 0939, ADR-124): `run` and `recheck` write `.spur/run/<wbs>-check-receipt.json`
|
|
30
|
-
* (`check-receipt/v1`) when `proofDigest` is set (a no-progress skip keeps the receipt it matched); without a digest no receipt is written and the
|
|
31
|
-
* log says why. The gate executes `qualityGateCmd` as one unit, so the full-tier receipt carries
|
|
32
|
-
* a single `test` row for the whole `bun run spur-check` chain (lint | typecheck | test-pre-check
|
|
33
|
-
* | test | test-post-check). `light` accumulates: a sub-check already PASS in a light receipt at
|
|
34
|
-
* the same `{id, inputDigest}` is skipped, and light rows never make a receipt reusable for
|
|
35
|
-
* review — `status` reuses only `PASS` + `tier: full` + matching digest. Reuse observability
|
|
36
|
-
* (0940 R3): `status` reuse and light accumulation emit `check.reused`, and the no-progress
|
|
37
|
-
* recheck emits `check.skipped-no-progress`, in the gate log and on stdout (the action result
|
|
38
|
-
* `data`); both surfaces run this same script, so there is no surface branch.
|
|
39
|
-
*
|
|
40
|
-
* Standalone contract: the digest is consumed, never computed — the pipeline captures it with
|
|
41
|
-
* `proof.fingerprint` into `env.proofDigest`; standalone callers use
|
|
42
|
-
* `inline-run-setup.ts --fingerprint`. No `@gobing-ai/*` value imports.
|
|
43
|
-
*
|
|
44
|
-
* Environment: `wbs` (required), `qualityGateCmd`, `gateProbeCmd` (recheck), `proofDigest`,
|
|
45
|
-
* `runId` (receipt identity; falls back to `pipeline-<wbs>`).
|
|
46
|
-
* Soft-fail contract: the process always exits 0; the verdict lives in the status file.
|
|
47
|
-
*
|
|
48
|
-
* Node-builtin imports only; pure helpers are exported for unit testing (ADR-065).
|
|
10
|
+
* Behavioral contract (unchanged): modes run|recheck|light|status (env: wbs, qualityGateCmd,
|
|
11
|
+
* gateProbeCmd, proofDigest, runId); exit 0 on PASS/reuse, 1 on FAIL, 2 on usage error; the
|
|
12
|
+
* `{reuse, reason}` JSON stays the last stdout line in status mode. Invoked from
|
|
13
|
+
* task-pipeline.yaml via `superskill script path sp quality-gate.mjs` — keep the committed twin
|
|
14
|
+
* in lockstep.
|
|
49
15
|
*/
|
|
50
|
-
|
|
51
|
-
import { spawnSync } from 'node:child_process';
|
|
52
|
-
import {
|
|
53
|
-
appendFileSync,
|
|
54
|
-
closeSync,
|
|
55
|
-
existsSync,
|
|
56
|
-
mkdirSync,
|
|
57
|
-
mkdtempSync,
|
|
58
|
-
openSync,
|
|
59
|
-
readFileSync,
|
|
60
|
-
rmSync,
|
|
61
|
-
statSync,
|
|
62
|
-
writeFileSync,
|
|
63
|
-
} from 'node:fs';
|
|
64
|
-
import { tmpdir } from 'node:os';
|
|
16
|
+
import { appendFileSync } from 'node:fs';
|
|
65
17
|
import { join } from 'node:path';
|
|
66
18
|
import { getEnvVars } from '../lib/env';
|
|
19
|
+
import type { QualityGateEnv, QualityGateOptions } from '../lib/quality-gate.generated.mjs';
|
|
20
|
+
import { readReceiptStatus, runLightGate, runQualityGate } from '../lib/quality-gate.generated.mjs';
|
|
67
21
|
|
|
68
|
-
export
|
|
69
|
-
export const MAX_FINDINGS = 20;
|
|
70
|
-
export const RETRY_DELAY_MS_DEFAULT = 10_000;
|
|
71
|
-
export const RETRY_DELAY_MS_ENV = 'SPUR_QUALITY_GATE_RETRY_DELAY_MS';
|
|
72
|
-
|
|
73
|
-
/** Transient SQLite lock/busy markers that justify a gate retry (same as the former grep -Eq). */
|
|
74
|
-
export const LOCKED_PATTERN = /SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY/;
|
|
75
|
-
|
|
76
|
-
/** `file.ext:line` anchor extraction, mirroring the former grep -oE. */
|
|
77
|
-
export const FINDINGS_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* bun's coverage table row: `File | % Funcs | % Lines | Uncovered Line #s` (0862 R2).
|
|
81
|
-
* Group 4 (uncovered line numbers) may be empty — a fully covered file has no entry.
|
|
82
|
-
*/
|
|
83
|
-
export const COVERAGE_ROW_PATTERN = /^\s*(\S+\.[A-Za-z]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*(\S*)/;
|
|
84
|
-
|
|
85
|
-
export interface QualityGateEnv {
|
|
86
|
-
wbs: string;
|
|
87
|
-
qualityGateCmd?: string;
|
|
88
|
-
gateProbeCmd?: string;
|
|
89
|
-
proofDigest?: string;
|
|
90
|
-
/** Receipt run identity; `pipeline-<wbs>` when absent (task-pipeline convention). */
|
|
91
|
-
runId?: string;
|
|
92
|
-
[key: string]: string | undefined;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface QualityGateOptions {
|
|
96
|
-
/** Base directory for `.spur/run`; defaults to the process cwd (CLI behavior). */
|
|
97
|
-
cwd?: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export interface QualityGateResult {
|
|
101
|
-
status: 'PASS' | 'FAIL';
|
|
102
|
-
attempts: number;
|
|
103
|
-
logFile: string;
|
|
104
|
-
findingsFile: string;
|
|
105
|
-
statusFile: string;
|
|
106
|
-
attemptFile: string;
|
|
107
|
-
/** Written only by `run` with `proofDigest` set (0939 R2). */
|
|
108
|
-
receiptFile?: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function isTransientLock(attemptOutput: string): boolean {
|
|
112
|
-
return LOCKED_PATTERN.test(attemptOutput);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Unique anchors, code-unit sorted, capped at MAX_FINDINGS, each followed by one space. */
|
|
116
|
-
export function extractFindings(logText: string): string {
|
|
117
|
-
const found = new Set<string>();
|
|
118
|
-
for (const match of logText.matchAll(FINDINGS_PATTERN)) found.add(match[0]);
|
|
119
|
-
return [...found]
|
|
120
|
-
.sort()
|
|
121
|
-
.slice(0, MAX_FINDINGS)
|
|
122
|
-
.map((anchor) => `${anchor} `)
|
|
123
|
-
.join('');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** `tail -n <count>` on POSIX text: last count lines, newline-terminated. */
|
|
127
|
-
export function tailLines(text: string, count: number): string {
|
|
128
|
-
const lines = text.split('\n');
|
|
129
|
-
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
130
|
-
const tail = lines.slice(Math.max(0, lines.length - count));
|
|
131
|
-
return tail.length === 0 ? '' : `${tail.join('\n')}\n`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function retryMessage(attempt: number): string {
|
|
135
|
-
return `quality gate: database is locked; retrying (${attempt}/${MAX_GATE_ATTEMPTS}) in 10s\n`;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Per-axis coverage floor from `bunfig.toml`; a missing key leaves that axis unchecked. */
|
|
139
|
-
export interface CoverageThreshold {
|
|
140
|
-
functions?: number;
|
|
141
|
-
lines?: number;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Parse `coverageThreshold = { lines = 0.9, functions = 0.9 }` out of bunfig text.
|
|
146
|
-
* `null` when the setting is absent — the caller then skips the shortfall scan entirely.
|
|
147
|
-
*/
|
|
148
|
-
export function parseCoverageThreshold(bunfigText: string): CoverageThreshold | null {
|
|
149
|
-
const block = bunfigText.match(/coverageThreshold\s*=\s*\{([^}]*)\}/);
|
|
150
|
-
if (!block) return null;
|
|
151
|
-
const threshold: CoverageThreshold = {};
|
|
152
|
-
for (const axis of ['functions', 'lines'] as const) {
|
|
153
|
-
const raw = block[1]?.match(new RegExp(`\\b${axis}\\s*=\\s*([\\d.]+)`))?.[1];
|
|
154
|
-
const value = raw === undefined ? Number.NaN : Number.parseFloat(raw);
|
|
155
|
-
if (Number.isFinite(value)) threshold[axis] = value;
|
|
156
|
-
}
|
|
157
|
-
return threshold.functions === undefined && threshold.lines === undefined ? null : threshold;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* One `quality gate: coverage shortfall <path>:<line> funcs=<f> lines=<l>` line per distinct table
|
|
162
|
-
* row below either axis (thresholds are fractions, table numbers are percentages). `line` is the
|
|
163
|
-
* first number of the uncovered column, else `1`.
|
|
164
|
-
*/
|
|
165
|
-
export function scanCoverageShortfalls(logText: string, threshold: CoverageThreshold): string[] {
|
|
166
|
-
const shortfalls = new Map<string, string>();
|
|
167
|
-
for (const line of logText.split('\n')) {
|
|
168
|
-
const row = line.match(COVERAGE_ROW_PATTERN);
|
|
169
|
-
if (!row) continue;
|
|
170
|
-
const [, path, funcs, lines, uncovered] = row;
|
|
171
|
-
if (!path || path === 'All files' || shortfalls.has(path)) continue;
|
|
172
|
-
const belowFunctions =
|
|
173
|
-
threshold.functions !== undefined && Number.parseFloat(funcs ?? '') < threshold.functions * 100;
|
|
174
|
-
const belowLines = threshold.lines !== undefined && Number.parseFloat(lines ?? '') < threshold.lines * 100;
|
|
175
|
-
if (!belowFunctions && !belowLines) continue;
|
|
176
|
-
const firstUncovered = uncovered?.match(/\d+/)?.[0] ?? '1';
|
|
177
|
-
shortfalls.set(
|
|
178
|
-
path,
|
|
179
|
-
`quality gate: coverage shortfall ${path}:${firstUncovered} funcs=${funcs} lines=${lines}`,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
return [...shortfalls.values()];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ─── Check receipts + light tier (task 0939, ADR-124) ───
|
|
186
|
-
|
|
187
|
-
export const RECEIPT_SCHEMA_VERSION = 'check-receipt/v1';
|
|
188
|
-
|
|
189
|
-
export type ReceiptTier = 'full' | 'light';
|
|
190
|
-
export type CheckStatus = 'PASS' | 'FAIL';
|
|
191
|
-
/** Frozen non-reuse reasons of `status` mode (0939 R3). */
|
|
192
|
-
export type ReceiptReuseReason = 'missing' | 'failed' | 'stale' | 'light-only';
|
|
193
|
-
|
|
194
|
-
export interface CheckReceiptRow {
|
|
195
|
-
id: string;
|
|
196
|
-
cmd: string;
|
|
197
|
-
status: CheckStatus;
|
|
198
|
-
durationMs: number;
|
|
199
|
-
logPath: string;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export interface CheckReceipt {
|
|
203
|
-
schemaVersion: 'check-receipt/v1';
|
|
204
|
-
wbs: string;
|
|
205
|
-
runId: string;
|
|
206
|
-
tier: ReceiptTier;
|
|
207
|
-
inputDigest: string;
|
|
208
|
-
checks: CheckReceiptRow[];
|
|
209
|
-
status: CheckStatus;
|
|
210
|
-
completedAt: string;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export interface ReceiptReadStatus {
|
|
214
|
-
reuse: boolean;
|
|
215
|
-
/** `'ok'` only when reuse holds; otherwise one of the frozen reasons. */
|
|
216
|
-
reason: ReceiptReuseReason | 'ok';
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export interface BuildReceiptInput {
|
|
220
|
-
wbs: string;
|
|
221
|
-
runId: string;
|
|
222
|
-
tier: ReceiptTier;
|
|
223
|
-
inputDigest: string;
|
|
224
|
-
checks: CheckReceiptRow[];
|
|
225
|
-
/** ISO-8601 completion timestamp; injected so receipts stay reproducible in tests. */
|
|
226
|
-
completedAt: string;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Stamp `check-receipt/v1`; the overall status derives from the rows (no rows → PASS). */
|
|
230
|
-
export function buildReceipt(input: BuildReceiptInput): CheckReceipt {
|
|
231
|
-
return {
|
|
232
|
-
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
233
|
-
wbs: input.wbs,
|
|
234
|
-
runId: input.runId,
|
|
235
|
-
tier: input.tier,
|
|
236
|
-
inputDigest: input.inputDigest,
|
|
237
|
-
checks: input.checks,
|
|
238
|
-
status: input.checks.every((row) => row.status === 'PASS') ? 'PASS' : 'FAIL',
|
|
239
|
-
completedAt: input.completedAt,
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Parse a check receipt; `null` when absent, unreadable, or corrupt. */
|
|
244
|
-
function readReceipt(receiptPath: string): CheckReceipt | null {
|
|
245
|
-
try {
|
|
246
|
-
return JSON.parse(readFileSync(receiptPath, 'utf8')) as CheckReceipt;
|
|
247
|
-
} catch {
|
|
248
|
-
return null;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* 0940 R2 — no-progress recheck shape: a full-tier FAIL receipt bound to the current proof-input
|
|
254
|
-
* digest, i.e. the fix pass changed nothing tracked and the full chain can only reproduce the
|
|
255
|
-
* failure. `readReceiptStatus` cannot express this decision (a FAIL receipt is `reason: 'failed'`
|
|
256
|
-
* at every digest), so the two fields are compared directly. Light-tier receipts and any digest
|
|
257
|
-
* mismatch never skip — the probe and full gate run unchanged (anti-pattern: reusing a light
|
|
258
|
-
* receipt, or comparing against a digest captured before `test-fix`).
|
|
259
|
-
*/
|
|
260
|
-
export function receiptFailsAtDigest(receipt: CheckReceipt | null, currentDigest: string): boolean {
|
|
261
|
-
return (
|
|
262
|
-
receipt !== null &&
|
|
263
|
-
receipt.schemaVersion === RECEIPT_SCHEMA_VERSION &&
|
|
264
|
-
receipt.tier === 'full' &&
|
|
265
|
-
receipt.status === 'FAIL' &&
|
|
266
|
-
currentDigest.length > 0 &&
|
|
267
|
-
receipt.inputDigest === currentDigest
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Reuse verdict for `.spur/run/<wbs>-check-receipt.json` against the current proof-input digest.
|
|
273
|
-
* Evaluated missing → failed → stale → light-only; reuse requires `PASS` + `tier: full` + digest
|
|
274
|
-
* match, so light rows can never flip a receipt reusable (0939 invariant).
|
|
275
|
-
*/
|
|
276
|
-
export function readReceiptStatus(receiptPath: string, currentDigest: string): ReceiptReadStatus {
|
|
277
|
-
const receipt = readReceipt(receiptPath);
|
|
278
|
-
if (receipt === null || receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION) {
|
|
279
|
-
return { reuse: false, reason: 'missing' };
|
|
280
|
-
}
|
|
281
|
-
if (receipt.status !== 'PASS') return { reuse: false, reason: 'failed' };
|
|
282
|
-
if (currentDigest.length === 0 || receipt.inputDigest !== currentDigest) {
|
|
283
|
-
return { reuse: false, reason: 'stale' };
|
|
284
|
-
}
|
|
285
|
-
if (receipt.tier !== 'full') return { reuse: false, reason: 'light-only' };
|
|
286
|
-
return { reuse: true, reason: 'ok' };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
290
|
-
|
|
291
|
-
export interface LightScope {
|
|
292
|
-
files: string[];
|
|
293
|
-
workspaces: string[];
|
|
294
|
-
tests: string[];
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* The bun workspace a changed file belongs to: the shortest directory prefix below the repo root
|
|
299
|
-
* that holds a package.json (`apps/*`, `packages/*`). Null for root-level files, which no
|
|
300
|
-
* workspace owns. Pure via the injected `exists` predicate.
|
|
301
|
-
*/
|
|
302
|
-
function workspaceOf(file: string, exists: (p: string) => boolean): string | null {
|
|
303
|
-
const segments = file.split('/');
|
|
304
|
-
for (let depth = 1; depth < segments.length; depth++) {
|
|
305
|
-
const prefix = segments.slice(0, depth).join('/');
|
|
306
|
-
if (exists(`${prefix}/package.json`)) return prefix;
|
|
307
|
-
}
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Light-tier scope from changed repo-relative paths: every touched workspace plus the related
|
|
313
|
-
* tests. `<ws>/src/**x.ts` maps to `<ws>/tests/**x.test.ts` by filename (refine decision —
|
|
314
|
-
* deterministic and cheap; the import graph is not consulted), and a changed `*.test.ts` under a
|
|
315
|
-
* workspace includes itself. The full tier stays the safety net for everything the mapping
|
|
316
|
-
* misses. Unmapped-but-existing candidates are dropped by the `exists` check.
|
|
317
|
-
*/
|
|
318
|
-
export function lightScope(changedFiles: string[], exists: (p: string) => boolean = existsSync): LightScope {
|
|
319
|
-
const files: string[] = [];
|
|
320
|
-
const workspaces = new Set<string>();
|
|
321
|
-
const tests = new Set<string>();
|
|
322
|
-
for (const file of changedFiles) {
|
|
323
|
-
files.push(file);
|
|
324
|
-
const workspace = workspaceOf(file, exists);
|
|
325
|
-
if (workspace === null) continue;
|
|
326
|
-
workspaces.add(workspace);
|
|
327
|
-
const rest = file.slice(workspace.length + 1);
|
|
328
|
-
if (rest.startsWith('src/')) {
|
|
329
|
-
const candidate = `${workspace}/tests/${rest.slice('src/'.length).replace(/\.tsx?$/, (ext) => `.test${ext}`)}`;
|
|
330
|
-
if (exists(candidate)) tests.add(candidate);
|
|
331
|
-
} else if (rest.startsWith('tests/') && TEST_FILE_PATTERN.test(rest)) {
|
|
332
|
-
tests.add(file);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return { files, workspaces: [...workspaces].sort(), tests: [...tests].sort() };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Quote one argument for `sh -c`. Changed-file names come from the working tree (untracked files
|
|
340
|
-
* included), so an unquoted `x$(cmd).ts` would run `cmd` and a space would split the argument.
|
|
341
|
-
* Plain paths pass through unchanged to keep receipts readable.
|
|
342
|
-
*/
|
|
343
|
-
export function shQuote(arg: string): string {
|
|
344
|
-
return /^[\w./@+-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export interface LightCheckPlan {
|
|
348
|
-
id: string;
|
|
349
|
-
cmd: string;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Does the workspace package.json declare a `typecheck` script? (fs default; tests inject.) */
|
|
353
|
-
export function workspaceHasTypecheck(workspace: string): boolean {
|
|
354
|
-
try {
|
|
355
|
-
const pkg = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')) as {
|
|
356
|
-
scripts?: { typecheck?: string };
|
|
357
|
-
};
|
|
358
|
-
return typeof pkg.scripts?.typecheck === 'string';
|
|
359
|
-
} catch {
|
|
360
|
-
return false;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* The light sub-checks for a scope, in run order: `format-lint:changed` (biome, repo root),
|
|
366
|
-
* `typecheck:<ws>` per workspace declaring the script, `test:<ws>` per workspace with related
|
|
367
|
-
* tests. Tests always run through `cd <ws> &&` — never from the repo root, whose bunfig preload
|
|
368
|
-
* does not apply inside the workspace.
|
|
369
|
-
*/
|
|
370
|
-
export function planLightChecks(
|
|
371
|
-
scope: LightScope,
|
|
372
|
-
hasTypecheck: (workspace: string) => boolean = workspaceHasTypecheck,
|
|
373
|
-
): LightCheckPlan[] {
|
|
374
|
-
const plans: LightCheckPlan[] = [];
|
|
375
|
-
if (scope.files.length > 0) {
|
|
376
|
-
plans.push({ id: 'format-lint:changed', cmd: `bunx biome check ${scope.files.map(shQuote).join(' ')}` });
|
|
377
|
-
}
|
|
378
|
-
for (const workspace of scope.workspaces) {
|
|
379
|
-
if (hasTypecheck(workspace)) {
|
|
380
|
-
plans.push({ id: `typecheck:${workspace}`, cmd: `cd ${shQuote(workspace)} && bun run typecheck` });
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
const testsByWorkspace = new Map<string, string[]>();
|
|
384
|
-
for (const test of scope.tests) {
|
|
385
|
-
const workspace = test.slice(0, test.indexOf('/tests/'));
|
|
386
|
-
const paths = testsByWorkspace.get(workspace) ?? [];
|
|
387
|
-
paths.push(test.slice(workspace.length + 1));
|
|
388
|
-
testsByWorkspace.set(workspace, paths);
|
|
389
|
-
}
|
|
390
|
-
for (const [workspace, paths] of testsByWorkspace) {
|
|
391
|
-
plans.push({
|
|
392
|
-
id: `test:${workspace}`,
|
|
393
|
-
cmd: `cd ${shQuote(workspace)} && bun test ${paths.map(shQuote).join(' ')}`,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
return plans;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/** Changed paths for the light tier: `git diff --name-only HEAD` plus untracked; existing only. */
|
|
400
|
-
function gitChangedFiles(cwd: string | undefined): string[] {
|
|
401
|
-
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
402
|
-
const changed = new Set<string>();
|
|
403
|
-
for (const cmd of ['git diff --name-only HEAD', 'git ls-files --others --exclude-standard']) {
|
|
404
|
-
const result = runShellCommand(cmd, cwd);
|
|
405
|
-
for (const line of result.output.split('\n')) {
|
|
406
|
-
const file = line.trim();
|
|
407
|
-
if (file.length > 0 && existsSync(abs(file))) changed.add(file);
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
return [...changed].sort();
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function receiptRunId(env: QualityGateEnv): string {
|
|
414
|
-
return (env.runId ?? '').length > 0 ? (env.runId as string) : `pipeline-${env.wbs}`;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export interface LightGateResult {
|
|
418
|
-
status: CheckStatus;
|
|
419
|
-
scope: LightScope;
|
|
420
|
-
checks: CheckReceiptRow[];
|
|
421
|
-
logFile: string;
|
|
422
|
-
receiptFile: string;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Light tier (0939 R1): run the planned sub-checks, merge their rows into the receipt under
|
|
427
|
-
* `tier: light`, exit soft. A sub-check already PASS in a light receipt at the same
|
|
428
|
-
* `{id, inputDigest}` is skipped (accumulation, AC1); prior rows whose id left the plan are
|
|
429
|
-
* dropped with the rest of the old receipt.
|
|
430
|
-
*/
|
|
431
|
-
export function runLightGate(env: QualityGateEnv, options: QualityGateOptions = {}): LightGateResult {
|
|
432
|
-
const cwd = options.cwd;
|
|
433
|
-
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
434
|
-
const runDir = join('.spur', 'run');
|
|
435
|
-
mkdirSync(abs(runDir), { recursive: true });
|
|
436
|
-
const logFile = join(runDir, `${env.wbs}-light-gate.log`);
|
|
437
|
-
const receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
|
|
438
|
-
writeFileSync(abs(logFile), '');
|
|
439
|
-
|
|
440
|
-
const digest = env.proofDigest ?? '';
|
|
441
|
-
const scope = lightScope(gitChangedFiles(cwd), (p) => existsSync(abs(p)));
|
|
442
|
-
// Bind the fs predicates to the gate cwd, not the process cwd (tests run gates in fixtures).
|
|
443
|
-
const plans = planLightChecks(scope, (workspace) => workspaceHasTypecheck(abs(workspace)));
|
|
444
|
-
|
|
445
|
-
const reusable = new Map<string, CheckReceiptRow>();
|
|
446
|
-
let preserveFullReceipt = false;
|
|
447
|
-
if (digest.length > 0) {
|
|
448
|
-
try {
|
|
449
|
-
const prior = JSON.parse(readFileSync(abs(receiptFile), 'utf8')) as CheckReceipt;
|
|
450
|
-
if (prior?.schemaVersion === RECEIPT_SCHEMA_VERSION && prior.inputDigest === digest) {
|
|
451
|
-
if (prior.tier === 'full') {
|
|
452
|
-
// A full-tier receipt at the same digest is boundary evidence read by status
|
|
453
|
-
// mode (0940/0943); light must never demote it to a tier-light receipt.
|
|
454
|
-
preserveFullReceipt = true;
|
|
455
|
-
} else {
|
|
456
|
-
for (const row of prior.checks ?? []) if (row.status === 'PASS') reusable.set(row.id, row);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
} catch {
|
|
460
|
-
// No prior receipt (or unreadable) — every planned sub-check runs.
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
const checks: CheckReceiptRow[] = [];
|
|
465
|
-
let skipped = 0;
|
|
466
|
-
for (const plan of plans) {
|
|
467
|
-
const priorRow = reusable.get(plan.id);
|
|
468
|
-
if (priorRow !== undefined) {
|
|
469
|
-
checks.push(priorRow);
|
|
470
|
-
skipped++;
|
|
471
|
-
// 0940 R3: accumulation reuse is observable in the gate log and on stdout (the
|
|
472
|
-
// action result `data`); both surfaces run this same script.
|
|
473
|
-
const line = `--- light ${plan.id}: check.reused — skipped (PASS at the same input digest)\n`;
|
|
474
|
-
process.stdout.write(line); // tee: stdout and the log
|
|
475
|
-
appendFileSync(abs(logFile), line);
|
|
476
|
-
continue;
|
|
477
|
-
}
|
|
478
|
-
const startedAtMs = Date.now();
|
|
479
|
-
const result = runShellCommand(plan.cmd, cwd);
|
|
480
|
-
const durationMs = Date.now() - startedAtMs;
|
|
481
|
-
const status: CheckStatus = result.code === 0 ? 'PASS' : 'FAIL';
|
|
482
|
-
appendFileSync(abs(logFile), `--- light ${plan.id}: ${status} (${durationMs}ms)\n${result.output}`);
|
|
483
|
-
checks.push({ id: plan.id, cmd: plan.cmd, status, durationMs, logPath: logFile });
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
const receipt = buildReceipt({
|
|
487
|
-
wbs: env.wbs,
|
|
488
|
-
runId: receiptRunId(env),
|
|
489
|
-
tier: 'light',
|
|
490
|
-
inputDigest: digest,
|
|
491
|
-
checks,
|
|
492
|
-
completedAt: new Date().toISOString(),
|
|
493
|
-
});
|
|
494
|
-
if (preserveFullReceipt) {
|
|
495
|
-
// Light ran for its log; the file keeps the full-tier receipt untouched.
|
|
496
|
-
appendFileSync(
|
|
497
|
-
abs(logFile),
|
|
498
|
-
'--- light receipt: not written — the full-tier receipt at the same input digest is preserved\n',
|
|
499
|
-
);
|
|
500
|
-
process.stdout.write(
|
|
501
|
-
`light gate ${receipt.status} (${scope.files.length} changed files; checks: ${checks.length},` +
|
|
502
|
-
` skipped: ${skipped}; receipt: ${receiptFile} preserved (full tier); log: ${logFile})\n`,
|
|
503
|
-
);
|
|
504
|
-
} else {
|
|
505
|
-
writeFileSync(abs(receiptFile), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
506
|
-
process.stdout.write(
|
|
507
|
-
`light gate ${receipt.status} (${scope.files.length} changed files; checks: ${checks.length},` +
|
|
508
|
-
` skipped: ${skipped}; receipt: ${receiptFile}; log: ${logFile})\n`,
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
return { status: receipt.status, scope, checks, logFile, receiptFile };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function retryDelayMs(env: QualityGateEnv): number {
|
|
515
|
-
const raw = Number.parseInt(env[RETRY_DELAY_MS_ENV] ?? '', 10);
|
|
516
|
-
return Number.isFinite(raw) && raw >= 0 ? raw : RETRY_DELAY_MS_DEFAULT;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function gateSleep(ms: number): void {
|
|
520
|
-
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** `sh -c <cmd> > <attempt log> 2>&1` equivalent: merged output plus exit code. */
|
|
524
|
-
export function runShellCommand(cmd: string, cwd: string | undefined): { output: string; code: number } {
|
|
525
|
-
const dir = mkdtempSync(join(tmpdir(), 'spur-quality-gate-'));
|
|
526
|
-
const path = join(dir, 'output');
|
|
527
|
-
const fd = openSync(path, 'w');
|
|
528
|
-
try {
|
|
529
|
-
// One file descriptor preserves stream order and avoids spawnSync's pipe buffer limit.
|
|
530
|
-
const result = spawnSync('sh', ['-c', cmd], { cwd, stdio: ['ignore', fd, fd] });
|
|
531
|
-
if (result.error !== undefined) {
|
|
532
|
-
return { output: `sh -c failed: ${result.error.message}\n`, code: 1 };
|
|
533
|
-
}
|
|
534
|
-
return { output: readFileSync(path, 'utf8'), code: result.status ?? 1 };
|
|
535
|
-
} finally {
|
|
536
|
-
closeSync(fd);
|
|
537
|
-
rmSync(dir, { recursive: true, force: true });
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
export function runQualityGate(
|
|
542
|
-
mode: 'run' | 'recheck',
|
|
543
|
-
env: QualityGateEnv,
|
|
544
|
-
options: QualityGateOptions = {},
|
|
545
|
-
): QualityGateResult {
|
|
546
|
-
const cwd = options.cwd;
|
|
547
|
-
const runDir = join('.spur', 'run');
|
|
548
|
-
mkdirSync(cwd ? join(cwd, runDir) : runDir, { recursive: true });
|
|
549
|
-
const rel = (name: string): string => join(runDir, `${env.wbs}${name}`);
|
|
550
|
-
const logFile = rel('-test-gate.log');
|
|
551
|
-
const findingsFile = rel('-test-gate.findings');
|
|
552
|
-
const statusFile = rel('-test-gate.status');
|
|
553
|
-
const attemptFile = rel('-test-fix-attempt');
|
|
554
|
-
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
555
|
-
|
|
556
|
-
// run: `echo 0 > "$ATTEMPT_FILE" && : > "$LOG_FILE"`; recheck: truncate only.
|
|
557
|
-
writeFileSync(abs(logFile), '');
|
|
558
|
-
if (mode === 'run') writeFileSync(abs(attemptFile), '0\n');
|
|
559
|
-
const gateStartedAtMs = Date.now();
|
|
560
|
-
|
|
561
|
-
let gateRc = 0;
|
|
562
|
-
let gateAttempt = 0;
|
|
563
|
-
|
|
564
|
-
// 0940 R2 — no-progress skip, before the probe: a full-tier FAIL receipt at the current
|
|
565
|
-
// proof-input digest means the fix pass changed nothing tracked, so the full chain can only
|
|
566
|
-
// reproduce the failure. The marker tees to stdout (the action result `data`) and the log,
|
|
567
|
-
// then the shared FAIL path below writes findings/status/verdict exactly as a red gate does.
|
|
568
|
-
// The attempt counter is pipeline-owned (only the test-fix hop increments it; `recheck` never
|
|
569
|
-
// touches it), so the existing cap still bounds the loop.
|
|
570
|
-
const noProgressSkip =
|
|
571
|
-
mode === 'recheck' && receiptFailsAtDigest(readReceipt(abs(rel('-check-receipt.json'))), env.proofDigest ?? '');
|
|
572
|
-
if (noProgressSkip) {
|
|
573
|
-
const line = `check.skipped-no-progress — full-tier FAIL receipt at input digest ${env.proofDigest ?? ''}; recheck skipped\n`;
|
|
574
|
-
process.stdout.write(line); // tee: stdout and the log
|
|
575
|
-
appendFileSync(abs(logFile), line);
|
|
576
|
-
gateRc = 1;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// recheck probe: a probe failure is the gate failure; the gate loop is skipped.
|
|
580
|
-
if (mode === 'recheck' && !noProgressSkip && (env.gateProbeCmd ?? '').length > 0) {
|
|
581
|
-
const probe = runShellCommand(env.gateProbeCmd ?? '', cwd);
|
|
582
|
-
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
583
|
-
gateRc = probe.code;
|
|
584
|
-
appendFileSync(abs(logFile), probe.output);
|
|
585
|
-
rmSync(abs(`${logFile}.probe`), { force: true });
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
if (gateRc === 0) {
|
|
589
|
-
const delayMs = retryDelayMs(env);
|
|
590
|
-
for (gateAttempt = 1; gateAttempt <= MAX_GATE_ATTEMPTS; gateAttempt++) {
|
|
591
|
-
const attemptLogPath = `${logFile}.attempt-${gateAttempt}`;
|
|
592
|
-
const attempt = runShellCommand(env.qualityGateCmd ?? '', cwd);
|
|
593
|
-
writeFileSync(abs(attemptLogPath), attempt.output);
|
|
594
|
-
const locked = isTransientLock(attempt.output);
|
|
595
|
-
appendFileSync(abs(logFile), attempt.output);
|
|
596
|
-
rmSync(abs(attemptLogPath), { force: true });
|
|
597
|
-
gateRc = attempt.code;
|
|
598
|
-
if (gateRc === 0 || !locked || gateAttempt >= MAX_GATE_ATTEMPTS) break;
|
|
599
|
-
const line = retryMessage(gateAttempt);
|
|
600
|
-
process.stdout.write(line); // tee: stdout and the log
|
|
601
|
-
appendFileSync(abs(logFile), line);
|
|
602
|
-
gateSleep(delayMs);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Coverage-only failures carry no `file.ext:line` anchor, so findings extraction would hand the
|
|
607
|
-
// fix hop an empty file. Name one shortfall line per under-threshold row instead (0862 R2) —
|
|
608
|
-
// real test failures, an absent threshold and the exit-0 contract are untouched.
|
|
609
|
-
if (gateRc !== 0) {
|
|
610
|
-
const gateLog = existsSync(abs(logFile)) ? readFileSync(abs(logFile), 'utf8') : '';
|
|
611
|
-
if (/^\s*0 fail\b/m.test(gateLog) && !/^\s*[1-9]\d*\s+fail\b/m.test(gateLog)) {
|
|
612
|
-
const bunfigPath = cwd ? join(cwd, 'bunfig.toml') : 'bunfig.toml';
|
|
613
|
-
const threshold = existsSync(bunfigPath) ? parseCoverageThreshold(readFileSync(bunfigPath, 'utf8')) : null;
|
|
614
|
-
if (threshold) {
|
|
615
|
-
for (const shortfall of scanCoverageShortfalls(gateLog, threshold)) {
|
|
616
|
-
process.stdout.write(`${shortfall}\n`); // tee: stdout and the log
|
|
617
|
-
appendFileSync(abs(logFile), `${shortfall}\n`);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (gateRc === 0) {
|
|
624
|
-
const bytes = existsSync(abs(logFile)) ? statSync(abs(logFile)).size : 0;
|
|
625
|
-
const attemptsLabel = gateAttempt > 0 ? String(gateAttempt) : '';
|
|
626
|
-
process.stdout.write(`quality gate PASS (attempts: ${attemptsLabel}; log: ${logFile}; bytes: ${bytes})\n`);
|
|
627
|
-
} else {
|
|
628
|
-
process.stdout.write(`quality gate FAIL — last 40 lines follow (full log: ${logFile})\n`);
|
|
629
|
-
process.stdout.write(tailLines(readFileSync(abs(logFile), 'utf8'), 40));
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
writeFileSync(abs(findingsFile), extractFindings(readFileSync(abs(logFile), 'utf8')));
|
|
633
|
-
const status: 'PASS' | 'FAIL' = gateRc === 0 ? 'PASS' : 'FAIL';
|
|
634
|
-
writeFileSync(abs(statusFile), `${status}\n`);
|
|
635
|
-
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ''}\n`);
|
|
636
|
-
|
|
637
|
-
// 0939 R2: the gate writes the full-tier receipt only with a digest to bind it to. 0976 R1:
|
|
638
|
-
// `recheck` persists the receipt it evaluated too, so a second recheck at the same digest can
|
|
639
|
-
// take the no-progress skip. A skip leaves the FAIL receipt it matched untouched — a skip never
|
|
640
|
-
// rewrites a receipt, so it can never launder FAIL into PASS.
|
|
641
|
-
let receiptFile: string | undefined;
|
|
642
|
-
if (!noProgressSkip) {
|
|
643
|
-
if ((env.proofDigest ?? '').length > 0) {
|
|
644
|
-
receiptFile = join(runDir, `${env.wbs}-check-receipt.json`);
|
|
645
|
-
const receipt = buildReceipt({
|
|
646
|
-
wbs: env.wbs,
|
|
647
|
-
runId: receiptRunId(env),
|
|
648
|
-
tier: 'full',
|
|
649
|
-
inputDigest: env.proofDigest ?? '',
|
|
650
|
-
checks: [
|
|
651
|
-
{
|
|
652
|
-
id: 'test',
|
|
653
|
-
cmd: env.qualityGateCmd ?? '',
|
|
654
|
-
status,
|
|
655
|
-
durationMs: Date.now() - gateStartedAtMs,
|
|
656
|
-
logPath: logFile,
|
|
657
|
-
},
|
|
658
|
-
],
|
|
659
|
-
completedAt: new Date().toISOString(),
|
|
660
|
-
});
|
|
661
|
-
writeFileSync(abs(receiptFile), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
662
|
-
} else {
|
|
663
|
-
appendFileSync(abs(logFile), 'check-receipt: not written — env `proofDigest` is not set\n');
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile, receiptFile };
|
|
668
|
-
}
|
|
22
|
+
export * from '../lib/quality-gate.generated.mjs';
|
|
669
23
|
|
|
670
24
|
export const QUALITY_GATE_USAGE =
|
|
671
25
|
'usage: quality-gate.ts <run|recheck|light|status> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest, runId)';
|
|
672
26
|
|
|
673
|
-
export function main(
|
|
27
|
+
export function main(
|
|
28
|
+
argv: string[],
|
|
29
|
+
rawEnv: Partial<QualityGateEnv> = getEnvVars(),
|
|
30
|
+
options: QualityGateOptions = {},
|
|
31
|
+
): number {
|
|
674
32
|
const mode = argv[0];
|
|
675
33
|
if (mode !== 'run' && mode !== 'recheck' && mode !== 'light' && mode !== 'status') {
|
|
676
34
|
process.stderr.write(`${QUALITY_GATE_USAGE}\n`);
|
|
677
35
|
return 2;
|
|
678
36
|
}
|
|
679
|
-
|
|
37
|
+
const wbs = rawEnv.wbs ?? '';
|
|
38
|
+
if (wbs.length === 0) {
|
|
680
39
|
process.stderr.write('quality-gate: env `wbs` is required\n');
|
|
681
40
|
return 2;
|
|
682
41
|
}
|
|
42
|
+
const env: QualityGateEnv = { ...rawEnv, wbs };
|
|
683
43
|
if (mode === 'light') {
|
|
684
44
|
runLightGate(env);
|
|
685
45
|
} else if (mode === 'status') {
|