@gobing-ai/spur 0.3.78 → 0.3.81
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 +29 -18
- package/config/config.global.yaml +10 -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/AGENTS.md +26 -23
- package/config/templates/docs/00_ADR.md +13 -23
- package/config/templates/docs/01_PRD.md +5 -2
- package/config/templates/docs/02_ROADMAP.md +9 -13
- package/config/templates/docs/03_ARCHITECTURE.md +2 -2
- package/config/templates/docs/04_DESIGN.md +12 -31
- package/config/templates/docs/05_FEATURES.md +6 -18
- package/config/templates/docs/99_PROJECT_CONSTITUTION.md +162 -394
- package/config/transition-shims.json +7 -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 +22 -8
- package/plugins/sp/agents/expert-spur.md +41 -19
- package/plugins/sp/agents/super-reviewer.md +43 -8
- 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/quality-gate.mjs +165 -0
- package/plugins/sp/scripts/quality-gate.ts +217 -0
- package/plugins/sp/scripts/verify-answer-lint.ts +21 -3
- 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/conflict-finding/SKILL.md +6 -0
- package/plugins/sp/skills/daily-summary/SKILL.md +1 -1
- package/plugins/sp/skills/doc-evolve/SKILL.md +26 -40
- package/plugins/sp/skills/doc-evolve/references/operations.md +17 -30
- 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/references/agent.md +56 -14
- package/plugins/sp/skills/spur-cli/references/message.md +30 -3
- package/plugins/sp/skills/spur-cli/references/projects.md +45 -1
- package/plugins/sp/skills/spur-cli/references/self.md +5 -4
- package/plugins/sp/skills/spur-cli/references/serve.md +5 -4
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +17 -1
- package/plugins/sp/skills/spur-cli/references/tasks.md +32 -2
- package/plugins/sp/skills/spur-cli/references/team.md +21 -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 +14 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +3 -3
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +12 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +46 -4
- 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 +49 -0
- package/spur.js +46936 -44198
- package/web/_astro/{BoardApp.CHQ1lycZ.js → BoardApp.B1U26g3I.js} +97 -95
- package/web/_astro/BoardApp.Csgyg-lS.js +1 -0
- package/web/_astro/{TaskDetail.GKfQJ60c.js → TaskDetail.DwPqpq7v.js} +1 -1
- package/web/_astro/{arc.DWEtA3Tx.js → arc.CweZEjN2.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.DB42oWmP.js → architectureDiagram-3BPJPVTR.D89pbDuv.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.rhv-zNQV.js → blockDiagram-GPEHLZMM.BOuTeEpX.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.Ci4-4VvY.js → c4Diagram-AAUBKEIU.CASbkWZF.js} +1 -1
- package/web/_astro/channel.Cx6sXxhq.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.Cc9veUgf.js → chunk-2J33WTMH.BKQYtOvY.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.Bec9c4eI.js → chunk-4BX2VUAB.9sHLdMtG.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DoV8S1iB.js → chunk-55IACEB6.wOLXWlPs.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.DwR-Qlyj.js → chunk-727SXJPM.DovFbwg3.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.ND_a81WY.js → chunk-AQP2D5EJ.B1Weod1X.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.Wv_jwG48.js → chunk-FMBD7UC4.TEMS04st.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.CXKXCMmp.js → chunk-ND2GUHAM.Cp8VT1wQ.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.nkaoNYQq.js → chunk-QZHKN3VN.BzATdEcP.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.cMQcVlQu.js → classDiagram-4FO5ZUOK.C9BOCfAO.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.cMQcVlQu.js → classDiagram-v2-Q7XG4LA2.C9BOCfAO.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.OaDJ7Mr2.js → cose-bilkent-S5V4N54A.DUnr4UAw.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.Chi8IphF.js → cynefin-OW5HDTMX.rYq5uM3D.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.CWeNKe3I.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.DRvxlVS7.js → diagram-2AECGRRQ.DCkfls10.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.CnYvNdwA.js → diagram-5GNKFQAL.D5U4JCka.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.CpLpMw5R.js → diagram-KO2AKTUF.BZJgqaqG.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.JTb78qUA.js → diagram-LMA3HP47.DoMeHvPR.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.Bk-1jDIb.js → diagram-OG6HWLK6.B50qwwWX.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.D8hN9GZq.js → erDiagram-TEJ5UH35.DdGPG6LK.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.-6zQr6m5.js → flowDiagram-I6XJVG4X.QP2MJ12u.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.DboLQ9ca.js → ganttDiagram-6RSMTGT7.BI6LgKSy.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.4tYvJKGR.js → gitGraphDiagram-PVQCEYII.npPZiC2G.js} +1 -1
- package/web/_astro/index.DayyIngm.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.Bd9rXpsB.js → infoDiagram-5YYISTIA.DCJCBVbp.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.CvMoaf67.js → ishikawaDiagram-YF4QCWOH.BMLV-3I1.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.Ccy1CA7y.js → journeyDiagram-JHISSGLW.LE58crde.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.0MaMqHNS.js → kanban-definition-UN3LZRKU.BPbz8rH9.js} +1 -1
- package/web/_astro/{linear.CHXgcIbN.js → linear.DhZaBtYh.js} +1 -1
- package/web/_astro/{mermaid.core.Ca-kcelG.js → mermaid.core.BD5-jXum.js} +6 -6
- package/web/_astro/{mindmap-definition-RKZ34NQL.BUIDlHa0.js → mindmap-definition-RKZ34NQL.MTJyrQ65.js} +1 -1
- package/web/_astro/ordinal.BYWQX77i.js +1 -0
- package/web/_astro/{pieDiagram-4H26LBE5.2dX3CU1s.js → pieDiagram-4H26LBE5.BrDhDvIS.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.B3LBlRiv.js → quadrantDiagram-W4KKPZXB.71d73_5N.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.X12I2uNx.js → requirementDiagram-4Y6WPE33.Bga6UF-z.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.BXohIHqx.js → sankeyDiagram-5OEKKPKP.BnHs4K82.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.C37ZIUzg.js → sequenceDiagram-3UESZ5HK.DsfY2gnj.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BRgz317z.js → stateDiagram-AJRCARHV.DvsTSc9a.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.7VYSXN9-.js → stateDiagram-v2-BHNVJYJU.DxzzmHUR.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.BVNz_HiN.js → timeline-definition-PNZ67QCA.4ZuQmOTt.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.CHVDkPX4.js → vennDiagram-CIIHVFJN.Ck5Q86SG.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.EQQ_qT9v.js → wardleyDiagram-YWT4CUSO.BK7k2hXr.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.DrAT9WoP.js → xychartDiagram-2RQKCTM6.DfCrgauK.js} +1 -1
- package/web/index.html +2 -2
- 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,217 @@
|
|
|
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
|
+
export interface QualityGateEnv {
|
|
57
|
+
wbs: string;
|
|
58
|
+
qualityGateCmd?: string;
|
|
59
|
+
gateProbeCmd?: string;
|
|
60
|
+
proofDigest?: string;
|
|
61
|
+
[key: string]: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface QualityGateOptions {
|
|
65
|
+
/** Base directory for `.spur/run`; defaults to the process cwd (CLI behavior). */
|
|
66
|
+
cwd?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface QualityGateResult {
|
|
70
|
+
status: 'PASS' | 'FAIL';
|
|
71
|
+
attempts: number;
|
|
72
|
+
logFile: string;
|
|
73
|
+
findingsFile: string;
|
|
74
|
+
statusFile: string;
|
|
75
|
+
attemptFile: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isTransientLock(attemptOutput: string): boolean {
|
|
79
|
+
return LOCKED_PATTERN.test(attemptOutput);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Unique anchors, code-unit sorted, capped at MAX_FINDINGS, each followed by one space. */
|
|
83
|
+
export function extractFindings(logText: string): string {
|
|
84
|
+
const found = new Set<string>();
|
|
85
|
+
for (const match of logText.matchAll(FINDINGS_PATTERN)) found.add(match[0]);
|
|
86
|
+
return [...found]
|
|
87
|
+
.sort()
|
|
88
|
+
.slice(0, MAX_FINDINGS)
|
|
89
|
+
.map((anchor) => `${anchor} `)
|
|
90
|
+
.join('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `tail -n <count>` on POSIX text: last count lines, newline-terminated. */
|
|
94
|
+
export function tailLines(text: string, count: number): string {
|
|
95
|
+
const lines = text.split('\n');
|
|
96
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
97
|
+
const tail = lines.slice(Math.max(0, lines.length - count));
|
|
98
|
+
return tail.length === 0 ? '' : `${tail.join('\n')}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function retryMessage(attempt: number): string {
|
|
102
|
+
return `quality gate: database is locked; retrying (${attempt}/${MAX_GATE_ATTEMPTS}) in 10s\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function retryDelayMs(env: QualityGateEnv): number {
|
|
106
|
+
const raw = Number.parseInt(env[RETRY_DELAY_MS_ENV] ?? '', 10);
|
|
107
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : RETRY_DELAY_MS_DEFAULT;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function gateSleep(ms: number): void {
|
|
111
|
+
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `sh -c <cmd> > <attempt log> 2>&1` equivalent: merged output plus exit code. */
|
|
115
|
+
export function runShellCommand(cmd: string, cwd: string | undefined): { output: string; code: number } {
|
|
116
|
+
const dir = mkdtempSync(join(tmpdir(), 'spur-quality-gate-'));
|
|
117
|
+
const path = join(dir, 'output');
|
|
118
|
+
const fd = openSync(path, 'w');
|
|
119
|
+
try {
|
|
120
|
+
// One file descriptor preserves stream order and avoids spawnSync's pipe buffer limit.
|
|
121
|
+
const result = spawnSync('sh', ['-c', cmd], { cwd, stdio: ['ignore', fd, fd] });
|
|
122
|
+
if (result.error !== undefined) {
|
|
123
|
+
return { output: `sh -c failed: ${result.error.message}\n`, code: 1 };
|
|
124
|
+
}
|
|
125
|
+
return { output: readFileSync(path, 'utf8'), code: result.status ?? 1 };
|
|
126
|
+
} finally {
|
|
127
|
+
closeSync(fd);
|
|
128
|
+
rmSync(dir, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function runQualityGate(
|
|
133
|
+
mode: 'run' | 'recheck',
|
|
134
|
+
env: QualityGateEnv,
|
|
135
|
+
options: QualityGateOptions = {},
|
|
136
|
+
): QualityGateResult {
|
|
137
|
+
const cwd = options.cwd;
|
|
138
|
+
const runDir = join('.spur', 'run');
|
|
139
|
+
mkdirSync(cwd ? join(cwd, runDir) : runDir, { recursive: true });
|
|
140
|
+
const rel = (name: string): string => join(runDir, `${env.wbs}${name}`);
|
|
141
|
+
const logFile = rel('-test-gate.log');
|
|
142
|
+
const findingsFile = rel('-test-gate.findings');
|
|
143
|
+
const statusFile = rel('-test-gate.status');
|
|
144
|
+
const attemptFile = rel('-test-fix-attempt');
|
|
145
|
+
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
146
|
+
|
|
147
|
+
// run: `echo 0 > "$ATTEMPT_FILE" && : > "$LOG_FILE"`; recheck: truncate only.
|
|
148
|
+
writeFileSync(abs(logFile), '');
|
|
149
|
+
if (mode === 'run') writeFileSync(abs(attemptFile), '0\n');
|
|
150
|
+
|
|
151
|
+
let gateRc = 0;
|
|
152
|
+
let gateAttempt = 0;
|
|
153
|
+
|
|
154
|
+
// recheck probe: a probe failure is the gate failure; the gate loop is skipped.
|
|
155
|
+
if (mode === 'recheck' && (env.gateProbeCmd ?? '').length > 0) {
|
|
156
|
+
const probe = runShellCommand(env.gateProbeCmd ?? '', cwd);
|
|
157
|
+
writeFileSync(abs(`${logFile}.probe`), probe.output);
|
|
158
|
+
gateRc = probe.code;
|
|
159
|
+
appendFileSync(abs(logFile), probe.output);
|
|
160
|
+
rmSync(abs(`${logFile}.probe`), { force: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (gateRc === 0) {
|
|
164
|
+
const delayMs = retryDelayMs(env);
|
|
165
|
+
for (gateAttempt = 1; gateAttempt <= MAX_GATE_ATTEMPTS; gateAttempt++) {
|
|
166
|
+
const attemptLogPath = `${logFile}.attempt-${gateAttempt}`;
|
|
167
|
+
const attempt = runShellCommand(env.qualityGateCmd ?? '', cwd);
|
|
168
|
+
writeFileSync(abs(attemptLogPath), attempt.output);
|
|
169
|
+
const locked = isTransientLock(attempt.output);
|
|
170
|
+
appendFileSync(abs(logFile), attempt.output);
|
|
171
|
+
rmSync(abs(attemptLogPath), { force: true });
|
|
172
|
+
gateRc = attempt.code;
|
|
173
|
+
if (gateRc === 0 || !locked || gateAttempt >= MAX_GATE_ATTEMPTS) break;
|
|
174
|
+
const line = retryMessage(gateAttempt);
|
|
175
|
+
process.stdout.write(line); // tee: stdout and the log
|
|
176
|
+
appendFileSync(abs(logFile), line);
|
|
177
|
+
gateSleep(delayMs);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (gateRc === 0) {
|
|
182
|
+
const bytes = existsSync(abs(logFile)) ? statSync(abs(logFile)).size : 0;
|
|
183
|
+
const attemptsLabel = gateAttempt > 0 ? String(gateAttempt) : '';
|
|
184
|
+
process.stdout.write(`quality gate PASS (attempts: ${attemptsLabel}; log: ${logFile}; bytes: ${bytes})\n`);
|
|
185
|
+
} else {
|
|
186
|
+
process.stdout.write(`quality gate FAIL — last 40 lines follow (full log: ${logFile})\n`);
|
|
187
|
+
process.stdout.write(tailLines(readFileSync(abs(logFile), 'utf8'), 40));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
writeFileSync(abs(findingsFile), extractFindings(readFileSync(abs(logFile), 'utf8')));
|
|
191
|
+
const status: 'PASS' | 'FAIL' = gateRc === 0 ? 'PASS' : 'FAIL';
|
|
192
|
+
writeFileSync(abs(statusFile), `${status}\n`);
|
|
193
|
+
appendFileSync(abs(logFile), `proof-digest: ${env.proofDigest ?? ''}\n`);
|
|
194
|
+
|
|
195
|
+
return { status, attempts: gateAttempt, logFile, findingsFile, statusFile, attemptFile };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const QUALITY_GATE_USAGE =
|
|
199
|
+
'usage: quality-gate.ts <run|recheck> (env: wbs, qualityGateCmd, gateProbeCmd, proofDigest)';
|
|
200
|
+
|
|
201
|
+
export function main(argv: string[], env: QualityGateEnv = process.env): number {
|
|
202
|
+
const mode = argv[0];
|
|
203
|
+
if (mode !== 'run' && mode !== 'recheck') {
|
|
204
|
+
process.stderr.write(`${QUALITY_GATE_USAGE}\n`);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
if ((env.wbs ?? '').length === 0) {
|
|
208
|
+
process.stderr.write('quality-gate: env `wbs` is required\n');
|
|
209
|
+
return 2;
|
|
210
|
+
}
|
|
211
|
+
runQualityGate(mode, env);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (import.meta.main) {
|
|
216
|
+
process.exit(main(process.argv.slice(2)));
|
|
217
|
+
}
|
|
@@ -282,7 +282,8 @@ function stripAcWrappers(title: string): string {
|
|
|
282
282
|
* Normalize an AC identity to its canonical key, mirroring the documented
|
|
283
283
|
* matching behavior of feature-check `rowMatchesScenario` + ac-style-guide
|
|
284
284
|
* "Four accepted id forms" (exact/bare title, `Scenario:` prefix, bracket
|
|
285
|
-
* tags, `AC-N` ordinal)
|
|
285
|
+
* tags, `AC-N` ordinal) plus the task-side bold-trajectory paragraph
|
|
286
|
+
* (0817 R3), without importing that private matcher or adopting
|
|
286
287
|
* its permissive trailing-Gherkin fallback (0804 R4). Comparison is
|
|
287
288
|
* case/quote/whitespace-insensitive. A paraphrase normalizes differently
|
|
288
289
|
* and still fails.
|
|
@@ -307,7 +308,7 @@ function normalizeAcTitle(title: string): string {
|
|
|
307
308
|
* A checklist-declared spelling always wins over the positional alias.
|
|
308
309
|
*/
|
|
309
310
|
interface AcIdentityIndex {
|
|
310
|
-
/** normalized canonical title → a declared spelling (label, token, or
|
|
311
|
+
/** normalized canonical title → a declared spelling (label, token, title, or bold trajectory). */
|
|
311
312
|
readonly byTitle: Map<string, string>;
|
|
312
313
|
/** AC-N → task scenario title at that 1-based ordinal. */
|
|
313
314
|
readonly taskScenarios: string[];
|
|
@@ -331,6 +332,19 @@ function buildAcIdentityIndex(taskContent: string, featureContent: string | null
|
|
|
331
332
|
const leading = label.split(/\s+/)[0] ?? '';
|
|
332
333
|
if (leading && leading !== label) declareIdentity(leading);
|
|
333
334
|
}
|
|
335
|
+
// Bold-trajectory form (task 0817 R3): answers may cite an AC by a bare
|
|
336
|
+
// `**AC id**` paragraph (house style for long/complex ids). Only whole-line
|
|
337
|
+
// bold spans count — the line-anchored lazy regex rejects lines with two
|
|
338
|
+
// spans, keeping interpolated bold text out of the index. The head (text
|
|
339
|
+
// before the first colon) is declared like a checklist label, mirroring
|
|
340
|
+
// the `(?::|$)` label extraction above.
|
|
341
|
+
for (const m of section.matchAll(/^\*\*(.+?)\*\*\s*$/gm)) {
|
|
342
|
+
const inner = (m[1] ?? '').trim();
|
|
343
|
+
if (!inner) continue;
|
|
344
|
+
declareIdentity(inner);
|
|
345
|
+
const head = inner.split(':')[0]?.trim() ?? '';
|
|
346
|
+
if (head && head !== inner) declareIdentity(head);
|
|
347
|
+
}
|
|
334
348
|
const scenarioTitles = (content: string): string[] =>
|
|
335
349
|
[...content.matchAll(/^[ \t]*Scenario:\s*(.+)\s*$/gm)].map((m) => (m[1] ?? '').trim()).filter((t) => t !== '');
|
|
336
350
|
const taskScenarios = scenarioTitles(sectionBetween(taskContent, 'Acceptance Criteria'));
|
|
@@ -348,6 +362,10 @@ type AcIdentityResolution = { ok: true; canonical: string } | { ok: false; error
|
|
|
348
362
|
* alias — accepted only against a real scenario ordinal, and refused with an
|
|
349
363
|
* actionable diagnostic when task and feature ordinals disagree. Undeclared
|
|
350
364
|
* `ACn` tokens, paraphrases and invented ordinals never resolve.
|
|
365
|
+
* `ACn` tokens, paraphrases and invented ordinals never resolve.
|
|
366
|
+
*
|
|
367
|
+
* Declared forms recognized in the index include the bold-trajectory
|
|
368
|
+
* `**AC id**` paragraph (task 0817 R3); the AC-N failure hint names it.
|
|
351
369
|
*/
|
|
352
370
|
function resolveAcIdentity(rowId: string, index: AcIdentityIndex): AcIdentityResolution {
|
|
353
371
|
const canonical = index.byTitle.get(normalizeAcTitle(rowId));
|
|
@@ -365,7 +383,7 @@ function resolveAcIdentity(rowId: string, index: AcIdentityIndex): AcIdentityRes
|
|
|
365
383
|
ok: false,
|
|
366
384
|
error:
|
|
367
385
|
`AC id "${rowId}" uses the AC-${n} positional alias but no scenario exists at that ordinal ` +
|
|
368
|
-
'(task scenario list and linked-feature scenario list) — cite the exact scenario title or
|
|
386
|
+
'(task scenario list and linked-feature scenario list) — cite the exact scenario title, checklist label, or a bare `**AC id**` paragraph',
|
|
369
387
|
};
|
|
370
388
|
}
|
|
371
389
|
if (candidates.length > 1) {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/workflow-step-profile.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
var DEFAULT_LAST = 20;
|
|
9
|
+
var DEFAULT_WINDOW_SEC = 300;
|
|
10
|
+
function nearestRankP50(values) {
|
|
11
|
+
if (values.length === 0)
|
|
12
|
+
return null;
|
|
13
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
14
|
+
return sorted[Math.ceil(sorted.length / 2) - 1] ?? null;
|
|
15
|
+
}
|
|
16
|
+
function numeric(value) {
|
|
17
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
18
|
+
}
|
|
19
|
+
function msBetween(from, to) {
|
|
20
|
+
if (typeof from !== "string" || typeof to !== "string")
|
|
21
|
+
return null;
|
|
22
|
+
const start = Date.parse(from);
|
|
23
|
+
const end = Date.parse(to);
|
|
24
|
+
if (!Number.isFinite(start) || !Number.isFinite(end))
|
|
25
|
+
return null;
|
|
26
|
+
return end - start;
|
|
27
|
+
}
|
|
28
|
+
function sessionOf(event) {
|
|
29
|
+
const value = event.invocation?.continue;
|
|
30
|
+
if (value === true)
|
|
31
|
+
return "resumed";
|
|
32
|
+
if (value === false)
|
|
33
|
+
return "fresh";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function cacheHitOf(event) {
|
|
37
|
+
return numeric(event.cost?.exact?.cacheHit);
|
|
38
|
+
}
|
|
39
|
+
function startKey(event) {
|
|
40
|
+
const parsed = typeof event.startedAt === "string" ? Date.parse(event.startedAt) : Number.NaN;
|
|
41
|
+
return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
42
|
+
}
|
|
43
|
+
function extractExecutions(runId, events) {
|
|
44
|
+
const actions = events.filter((e) => e.kind === "action").sort((a, b) => {
|
|
45
|
+
const left = startKey(a);
|
|
46
|
+
const right = startKey(b);
|
|
47
|
+
return left === right ? 0 : left - right;
|
|
48
|
+
});
|
|
49
|
+
const executions = [];
|
|
50
|
+
for (const [index, event] of actions.entries()) {
|
|
51
|
+
const previous = index === 0 ? undefined : actions[index - 1];
|
|
52
|
+
executions.push({
|
|
53
|
+
runId,
|
|
54
|
+
node: event.node ?? "",
|
|
55
|
+
actionKind: event.actionKind ?? "",
|
|
56
|
+
durationMs: numeric(event.durationMs),
|
|
57
|
+
idleGapMs: msBetween(previous?.completedAt, event.startedAt),
|
|
58
|
+
session: sessionOf(event),
|
|
59
|
+
cacheHit: cacheHitOf(event)
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return executions;
|
|
63
|
+
}
|
|
64
|
+
function foldSession(executions) {
|
|
65
|
+
const known = new Set(executions.map((e) => e.session).filter((s) => s !== null));
|
|
66
|
+
if (known.size === 0)
|
|
67
|
+
return null;
|
|
68
|
+
if (known.size === 1)
|
|
69
|
+
return [...known][0] ?? null;
|
|
70
|
+
return "mixed";
|
|
71
|
+
}
|
|
72
|
+
function rowFlags(row, windowSec) {
|
|
73
|
+
const wMs = windowSec * 1000;
|
|
74
|
+
const flags = [];
|
|
75
|
+
const agentRun = row.actionKind === "agent.run";
|
|
76
|
+
const resumed = row.session === "resumed" || row.session === "mixed";
|
|
77
|
+
const p50 = row.durationMs.p50;
|
|
78
|
+
if (!agentRun && p50 !== null && p50 > wMs)
|
|
79
|
+
flags.push("step-over-window");
|
|
80
|
+
const idle = row.idleGapMs.p50;
|
|
81
|
+
if (agentRun && resumed && idle !== null && idle > wMs)
|
|
82
|
+
flags.push("resume-after-idle");
|
|
83
|
+
const hit = row.cacheHit.p50;
|
|
84
|
+
if (agentRun && resumed && row.cacheHit.known > 0 && hit !== null && hit < 0.5)
|
|
85
|
+
flags.push("resume-cold-cache");
|
|
86
|
+
if (agentRun && p50 !== null && p50 > 2 * wMs)
|
|
87
|
+
flags.push("agent-run-over-2w");
|
|
88
|
+
return flags;
|
|
89
|
+
}
|
|
90
|
+
function buildRows(executions, windowSec) {
|
|
91
|
+
const grouped = new Map;
|
|
92
|
+
for (const execution of executions) {
|
|
93
|
+
const key = `${execution.node}\x00${execution.actionKind}`;
|
|
94
|
+
const bucket = grouped.get(key);
|
|
95
|
+
if (bucket === undefined)
|
|
96
|
+
grouped.set(key, [execution]);
|
|
97
|
+
else
|
|
98
|
+
bucket.push(execution);
|
|
99
|
+
}
|
|
100
|
+
const rows = [];
|
|
101
|
+
for (const bucket of grouped.values()) {
|
|
102
|
+
const first = bucket[0];
|
|
103
|
+
if (first === undefined)
|
|
104
|
+
continue;
|
|
105
|
+
const durations = bucket.map((e) => e.durationMs).filter((d) => d !== null);
|
|
106
|
+
const gaps = bucket.map((e) => e.idleGapMs).filter((g) => g !== null);
|
|
107
|
+
const hits = bucket.map((e) => e.cacheHit).filter((h) => h !== null);
|
|
108
|
+
const row = {
|
|
109
|
+
node: first.node,
|
|
110
|
+
actionKind: first.actionKind,
|
|
111
|
+
runs: new Set(bucket.map((e) => e.runId)).size,
|
|
112
|
+
executions: bucket.length,
|
|
113
|
+
durationMs: {
|
|
114
|
+
p50: nearestRankP50(durations),
|
|
115
|
+
max: durations.length === 0 ? null : Math.max(...durations)
|
|
116
|
+
},
|
|
117
|
+
idleGapMs: { p50: nearestRankP50(gaps) },
|
|
118
|
+
session: first.actionKind === "agent.run" ? foldSession(bucket) : null,
|
|
119
|
+
cacheHit: {
|
|
120
|
+
p50: nearestRankP50(hits),
|
|
121
|
+
known: hits.length,
|
|
122
|
+
of: bucket.length
|
|
123
|
+
},
|
|
124
|
+
flags: []
|
|
125
|
+
};
|
|
126
|
+
row.flags = rowFlags(row, windowSec);
|
|
127
|
+
rows.push(row);
|
|
128
|
+
}
|
|
129
|
+
return rows.sort((a, b) => a.node === b.node ? a.actionKind.localeCompare(b.actionKind) : a.node.localeCompare(b.node));
|
|
130
|
+
}
|
|
131
|
+
function buildStepProfile(input) {
|
|
132
|
+
const executions = input.runs.flatMap((run) => extractExecutions(run.runId, run.events));
|
|
133
|
+
return {
|
|
134
|
+
workflow: input.workflow,
|
|
135
|
+
windowSec: input.windowSec,
|
|
136
|
+
sampledRuns: input.runs.length,
|
|
137
|
+
rows: buildRows(executions, input.windowSec)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function nonDryRuns(entries) {
|
|
141
|
+
return entries.filter((e) => e.isDryRun !== true && typeof e.runId === "string" && e.runId.length > 0);
|
|
142
|
+
}
|
|
143
|
+
function seconds(value) {
|
|
144
|
+
return value === null ? "?" : `${(value / 1000).toFixed(1)}s`;
|
|
145
|
+
}
|
|
146
|
+
function ratio(value) {
|
|
147
|
+
return value === null ? "?" : value.toFixed(2);
|
|
148
|
+
}
|
|
149
|
+
function formatStepProfileHuman(profile) {
|
|
150
|
+
const lines = [
|
|
151
|
+
`step profile \u2014 ${profile.workflow} (window ${profile.windowSec}s, ${profile.sampledRuns} sampled runs)`
|
|
152
|
+
];
|
|
153
|
+
for (const row of profile.rows) {
|
|
154
|
+
lines.push([
|
|
155
|
+
row.node.padEnd(20),
|
|
156
|
+
row.actionKind.padEnd(14),
|
|
157
|
+
`${row.runs}/${row.executions}`.padEnd(8),
|
|
158
|
+
`p50=${seconds(row.durationMs.p50)}`.padEnd(12),
|
|
159
|
+
`max=${seconds(row.durationMs.max)}`.padEnd(12),
|
|
160
|
+
`idle=${seconds(row.idleGapMs.p50)}`.padEnd(12),
|
|
161
|
+
`session=${row.session ?? "?"}`.padEnd(15),
|
|
162
|
+
`cacheHit=${ratio(row.cacheHit.p50)} (${row.cacheHit.known}/${row.cacheHit.of})`.padEnd(20),
|
|
163
|
+
`flags=${row.flags.length === 0 ? "-" : row.flags.join(",")}`
|
|
164
|
+
].join(" "));
|
|
165
|
+
}
|
|
166
|
+
return `${lines.join(`
|
|
167
|
+
`)}
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
function defaultSpurBin() {
|
|
171
|
+
if (process.env.SPUR_BIN)
|
|
172
|
+
return process.env.SPUR_BIN;
|
|
173
|
+
const local = fileURLToPath(new URL("../../../apps/cli/src/index.ts", import.meta.url));
|
|
174
|
+
if (existsSync(local))
|
|
175
|
+
return `bun ${local}`;
|
|
176
|
+
return "spur";
|
|
177
|
+
}
|
|
178
|
+
function positiveInt(raw, fallback) {
|
|
179
|
+
const parsed = Number.parseInt(raw ?? "", 10);
|
|
180
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
181
|
+
}
|
|
182
|
+
function parseStepProfileCliArgs(argv) {
|
|
183
|
+
let workflow = "";
|
|
184
|
+
let spurBin = defaultSpurBin();
|
|
185
|
+
let last = DEFAULT_LAST;
|
|
186
|
+
let windowSec = DEFAULT_WINDOW_SEC;
|
|
187
|
+
let json = false;
|
|
188
|
+
let help = false;
|
|
189
|
+
for (let i = 0;i < argv.length; i++) {
|
|
190
|
+
const a = argv[i];
|
|
191
|
+
if (a === "--help" || a === "-h")
|
|
192
|
+
help = true;
|
|
193
|
+
else if (a === "--json")
|
|
194
|
+
json = true;
|
|
195
|
+
else if (a === "--last")
|
|
196
|
+
last = positiveInt(argv[++i], last);
|
|
197
|
+
else if (a === "--window")
|
|
198
|
+
windowSec = positiveInt(argv[++i], windowSec);
|
|
199
|
+
else if (a === "--spur-bin")
|
|
200
|
+
spurBin = argv[++i] ?? spurBin;
|
|
201
|
+
else if (!a.startsWith("--") && workflow === "")
|
|
202
|
+
workflow = a;
|
|
203
|
+
}
|
|
204
|
+
return { workflow, last, windowSec, spurBin, json, help };
|
|
205
|
+
}
|
|
206
|
+
function runSpurJson(spurBin, args) {
|
|
207
|
+
const binParts = spurBin.split(/\s+/).filter(Boolean);
|
|
208
|
+
const cmd = binParts[0] ?? "spur";
|
|
209
|
+
const cmdArgs = [...binParts.slice(1), ...args];
|
|
210
|
+
const r = spawnSync(cmd, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
|
|
211
|
+
const decode = (b) => typeof b === "string" ? b : Buffer.from(b ?? []).toString("utf8");
|
|
212
|
+
return {
|
|
213
|
+
stdout: typeof r.stdout === "string" ? r.stdout : decode(r.stdout),
|
|
214
|
+
stderr: typeof r.stderr === "string" ? r.stderr : decode(r.stderr),
|
|
215
|
+
exitCode: r.status ?? (r.error ? 1 : 0),
|
|
216
|
+
ok: (r.status ?? (r.error ? 1 : 0)) === 0
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
var STEP_PROFILE_USAGE = `usage: workflow-step-profile <workflow> [--last <N>] [--window <sec>] [--json] [--spur-bin <cmd>]
|
|
220
|
+
|
|
221
|
+
Read-only step profile from \`spur workflow trace\`: per node and action kind it reports runs,
|
|
222
|
+
executions, p50/max durationMs, p50 idle gap, session mode and cacheHit p50 with coverage, then
|
|
223
|
+
flags the satellite \xA710 cache-window budgets. --last defaults to ${DEFAULT_LAST} runs and --window
|
|
224
|
+
(W) to ${DEFAULT_WINDOW_SEC} seconds. Unknown evidence is reported as \`?\` / null, never 0.
|
|
225
|
+
|
|
226
|
+
Exit: 0 = profile produced (flags included); 1 = a spur call failed, its JSON did not parse, or the
|
|
227
|
+
workflow argument is missing.`;
|
|
228
|
+
function failure(message) {
|
|
229
|
+
return { exitCode: 1, stdout: "", stderr: `workflow-step-profile: ${message}
|
|
230
|
+
` };
|
|
231
|
+
}
|
|
232
|
+
function runFailure(spurBin, result, what) {
|
|
233
|
+
const detail = result.stderr.trim().split(`
|
|
234
|
+
`).slice(-1)[0] ?? "";
|
|
235
|
+
return failure(`${what} failed (exit ${result.exitCode}${detail === "" ? "" : `: ${detail}`}) [${spurBin}]`);
|
|
236
|
+
}
|
|
237
|
+
function runStepProfileCli(argv) {
|
|
238
|
+
const args = parseStepProfileCliArgs(argv);
|
|
239
|
+
if (args.help)
|
|
240
|
+
return { exitCode: 0, stdout: `${STEP_PROFILE_USAGE}
|
|
241
|
+
`, stderr: "" };
|
|
242
|
+
if (args.workflow === "")
|
|
243
|
+
return failure(`a workflow name is required
|
|
244
|
+
${STEP_PROFILE_USAGE}`);
|
|
245
|
+
const listArgs = [
|
|
246
|
+
"workflow",
|
|
247
|
+
"trace",
|
|
248
|
+
"--workflow",
|
|
249
|
+
args.workflow,
|
|
250
|
+
"--status",
|
|
251
|
+
"done",
|
|
252
|
+
"--last",
|
|
253
|
+
String(args.last),
|
|
254
|
+
"--json"
|
|
255
|
+
];
|
|
256
|
+
const list = runSpurJson(args.spurBin, listArgs);
|
|
257
|
+
if (!list.ok)
|
|
258
|
+
return runFailure(args.spurBin, list, `spur ${listArgs.join(" ")}`);
|
|
259
|
+
let entries;
|
|
260
|
+
try {
|
|
261
|
+
const parsed = JSON.parse(list.stdout);
|
|
262
|
+
if (!Array.isArray(parsed.entries))
|
|
263
|
+
throw new Error('no "entries" array');
|
|
264
|
+
entries = parsed.entries;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return failure(`spur workflow trace output did not parse as JSON: ${String(err)}`);
|
|
267
|
+
}
|
|
268
|
+
const runs = [];
|
|
269
|
+
for (const entry of nonDryRuns(entries)) {
|
|
270
|
+
const runArgs = ["workflow", "trace", entry.runId, "--json"];
|
|
271
|
+
const timeline = runSpurJson(args.spurBin, runArgs);
|
|
272
|
+
if (!timeline.ok)
|
|
273
|
+
return runFailure(args.spurBin, timeline, `spur ${runArgs.join(" ")}`);
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(timeline.stdout);
|
|
276
|
+
if (!Array.isArray(parsed.events))
|
|
277
|
+
throw new Error('no "events" array');
|
|
278
|
+
runs.push({ runId: entry.runId, events: parsed.events });
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return failure(`spur workflow trace ${entry.runId} output did not parse as JSON: ${String(err)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const profile = buildStepProfile({ workflow: args.workflow, windowSec: args.windowSec, runs });
|
|
284
|
+
return {
|
|
285
|
+
exitCode: 0,
|
|
286
|
+
stdout: args.json ? `${JSON.stringify(profile, null, 2)}
|
|
287
|
+
` : formatStepProfileHuman(profile),
|
|
288
|
+
stderr: ""
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function main(argv) {
|
|
292
|
+
const { exitCode, stdout, stderr } = runStepProfileCli(argv);
|
|
293
|
+
if (stdout)
|
|
294
|
+
process.stdout.write(stdout);
|
|
295
|
+
if (stderr)
|
|
296
|
+
process.stderr.write(stderr.endsWith(`
|
|
297
|
+
`) ? stderr : `${stderr}
|
|
298
|
+
`);
|
|
299
|
+
return exitCode;
|
|
300
|
+
}
|
|
301
|
+
{
|
|
302
|
+
process.exit(main(process.argv.slice(2)));
|
|
303
|
+
}
|
|
304
|
+
export {
|
|
305
|
+
runStepProfileCli,
|
|
306
|
+
rowFlags,
|
|
307
|
+
parseStepProfileCliArgs,
|
|
308
|
+
nonDryRuns,
|
|
309
|
+
nearestRankP50,
|
|
310
|
+
main,
|
|
311
|
+
formatStepProfileHuman,
|
|
312
|
+
extractExecutions,
|
|
313
|
+
defaultSpurBin,
|
|
314
|
+
buildStepProfile,
|
|
315
|
+
buildRows,
|
|
316
|
+
STEP_PROFILE_USAGE,
|
|
317
|
+
DEFAULT_WINDOW_SEC,
|
|
318
|
+
DEFAULT_LAST
|
|
319
|
+
};
|