hstack 0.7.1 → 0.16.0
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/CHANGELOG.md +271 -0
- package/README.md +39 -13
- package/VERSION +1 -1
- package/dist/commands/doctor.js +51 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/update.js +8 -2
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/descriptions.js +167 -0
- package/dist/lib/descriptions.js.map +1 -0
- package/dist/lib/diff.js +1 -1
- package/dist/lib/git.js +16 -0
- package/dist/lib/git.js.map +1 -1
- package/dist/lib/wire.js +108 -4
- package/dist/lib/wire.js.map +1 -1
- package/dist/manifest.js +17 -2
- package/dist/manifest.js.map +1 -1
- package/package.json +3 -1
- package/template/.claude/agents/adversarial-reviewer.md +16 -64
- package/template/.claude/agents/app-architect.md +12 -49
- package/template/.claude/agents/data-architect.md +13 -51
- package/template/.claude/agents/data-specialist.md +5 -50
- package/template/.claude/agents/implementer.md +8 -65
- package/template/.claude/agents/kernel-fit-analyst.md +7 -68
- package/template/.claude/agents/planner.md +7 -42
- package/template/.claude/agents/product-discovery.md +12 -48
- package/template/.claude/agents/product-manager.md +8 -43
- package/template/.claude/agents/researcher.md +5 -41
- package/template/.claude/agents/security-reviewer.md +19 -54
- package/template/.claude/agents/spec-author.md +18 -52
- package/template/.claude/agents/stack-architect.md +14 -43
- package/template/.claude/agents/test-strategist.md +16 -57
- package/template/.claude/agents/ui-ux-briefer.md +6 -36
- package/template/.claude/agents/verifier.md +13 -45
- package/template/.claude/skills/hstack-adr-new/SKILL.md +6 -33
- package/template/.claude/skills/hstack-adversarial-review/SKILL.md +31 -52
- package/template/.claude/skills/hstack-adversarial-review/references/finding-categories.md +157 -0
- package/template/.claude/skills/hstack-app-architecture/SKILL.md +2 -29
- package/template/.claude/skills/hstack-branch/SKILL.md +4 -31
- package/template/.claude/skills/hstack-brownfield-init/SKILL.md +10 -37
- package/template/.claude/skills/hstack-change-new/SKILL.md +4 -31
- package/template/.claude/skills/hstack-change-plan/SKILL.md +21 -32
- package/template/.claude/skills/hstack-commit/SKILL.md +7 -35
- package/template/.claude/skills/hstack-configure/SKILL.md +7 -34
- package/template/.claude/skills/hstack-coord/SKILL.md +3 -39
- package/template/.claude/skills/hstack-data-architecture/SKILL.md +4 -30
- package/template/.claude/skills/hstack-data-review/SKILL.md +3 -42
- package/template/.claude/skills/hstack-finalize/SKILL.md +30 -49
- package/template/.claude/skills/hstack-flag/SKILL.md +9 -48
- package/template/.claude/skills/hstack-greenfield-init/SKILL.md +9 -36
- package/template/.claude/skills/hstack-help/SKILL.md +11 -37
- package/template/.claude/skills/hstack-implement/SKILL.md +28 -58
- package/template/.claude/skills/hstack-kernel-fit-promote/SKILL.md +7 -46
- package/template/.claude/skills/hstack-kernel-fit-scan/SKILL.md +5 -60
- package/template/.claude/skills/hstack-kernel-fit-scan/references/slack-setup.md +42 -0
- package/template/.claude/skills/hstack-kernel-fit-triage/SKILL.md +12 -50
- package/template/.claude/skills/hstack-module-spec/SKILL.md +5 -32
- package/template/.claude/skills/hstack-product-discovery/SKILL.md +5 -31
- package/template/.claude/skills/hstack-research/SKILL.md +3 -33
- package/template/.claude/skills/hstack-scaffold/SKILL.md +2 -29
- package/template/.claude/skills/hstack-security-review/SKILL.md +5 -43
- package/template/.claude/skills/hstack-ship/SKILL.md +43 -53
- package/template/.claude/skills/hstack-stack-decide/SKILL.md +3 -30
- package/template/.claude/skills/hstack-story-draft/SKILL.md +6 -33
- package/template/.claude/skills/hstack-tech-debt-new/SKILL.md +4 -31
- package/template/.claude/skills/hstack-tech-debt-resolve/SKILL.md +9 -44
- package/template/.claude/skills/hstack-tech-debt-stale/SKILL.md +10 -37
- package/template/.claude/skills/hstack-tech-debt-wontfix/SKILL.md +8 -35
- package/template/.claude/skills/hstack-telemetry/SKILL.md +5 -30
- package/template/.claude/skills/hstack-test-plan/SKILL.md +23 -46
- package/template/.claude/skills/hstack-ui-brief/SKILL.md +3 -30
- package/template/.claude/skills/hstack-verify/SKILL.md +26 -48
- package/template/KERNEL.md +410 -0
- package/template/scripts/compute-merge-readiness.mjs +780 -0
- package/template/scripts/run-gates.sh +388 -0
- package/template/scripts/telemetry/insights/kernel_fit.py +1 -1
- package/template/scripts/telemetry/insights/token_economics.py +181 -8
- package/template/scripts/telemetry/parsers/sidecars.py +61 -0
- package/template/scripts/telemetry/parsers/transcripts.py +135 -22
- package/template/scripts/telemetry/render.py +68 -3
- package/template/scripts/telemetry/report.py +16 -4
- package/template/scripts/telemetry/run_kernel_fit.py +6 -2
- package/template/scripts/telemetry/session_id.py +139 -0
- package/template/scripts/validate-spec.mjs +3303 -0
- package/template/templates/adr.md +7 -0
- package/template/templates/adversarial-review.md +5 -5
- package/template/templates/ci-cd.md +14 -0
- package/template/templates/coord-message.md +3 -2
- package/template/templates/data-architecture.md +3 -6
- package/template/templates/kernel-fit-finding.md +2 -2
- package/template/templates/kernel-fit-flag.md +2 -2
- package/template/templates/plan.md +4 -0
- package/template/templates/product-brief.md +2 -2
- package/template/templates/roadmap.md +41 -0
- package/template/templates/security-review.md +1 -1
- package/template/templates/telemetry-sidecar.md +56 -13
- package/template/templates/test-plan.md +1 -1
- package/template/CLAUDE.md +0 -443
- package/template/templates/mvp-scope.md +0 -34
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hstack merge-readiness scorecard — the twelve gates `/hstack:ship` reports,
|
|
4
|
+
* and the thing `{{TODO-SCRIPT: compute-merge-readiness.ts}}` stood in for.
|
|
5
|
+
*
|
|
6
|
+
* node hstack/scripts/compute-merge-readiness.mjs <change-id>
|
|
7
|
+
* node hstack/scripts/compute-merge-readiness.mjs <change-id> --json
|
|
8
|
+
* node hstack/scripts/compute-merge-readiness.mjs <change-id> --base origin/main
|
|
9
|
+
* node hstack/scripts/compute-merge-readiness.mjs --gates # the registry
|
|
10
|
+
*
|
|
11
|
+
* Exit codes: 0 every applicable gate passes, 1 at least one gate blocks,
|
|
12
|
+
* 2 usage / environment error (no change folder, no change-spec, …).
|
|
13
|
+
*
|
|
14
|
+
* ## Why .mjs and not .ts
|
|
15
|
+
*
|
|
16
|
+
* The name declared in `hstack-ship` was `compute-merge-readiness.ts`. Same
|
|
17
|
+
* decision as ADR-0001 and `validate-spec.mjs`: consuming repos have no
|
|
18
|
+
* `node_modules` for hstack, so a `.ts` entrypoint would impose a runtime
|
|
19
|
+
* (node >= 22.6 with `--experimental-strip-types`, or `npx tsx`) on every
|
|
20
|
+
* consumer. Plain ESM runs on the node the consumer already has and imports
|
|
21
|
+
* `validate-spec.mjs` — the frontmatter parser, the section parser and the
|
|
22
|
+
* glob matcher are already there and are not written twice.
|
|
23
|
+
*
|
|
24
|
+
* ## The registry is the inventory
|
|
25
|
+
*
|
|
26
|
+
* `GATES` carries one entry per gate id named in `hstack-ship`, and
|
|
27
|
+
* `DEFERRED_GATES` names the ones that are NOT computable together with the
|
|
28
|
+
* reason. Nothing disappears: a gate is either computed here or listed as
|
|
29
|
+
* deferred with a stated cause. The Skill states each gate as a one-line
|
|
30
|
+
* contract; this file is where the arithmetic lives, so the two cannot drift
|
|
31
|
+
* into two different answers.
|
|
32
|
+
*
|
|
33
|
+
* ## Verdicts
|
|
34
|
+
*
|
|
35
|
+
* pass the gate's condition holds
|
|
36
|
+
* fail the gate's condition is violated — blocks
|
|
37
|
+
* unknown the gate could not be evaluated (no git, lints not run) — blocks
|
|
38
|
+
* not-applicable the gate's precondition does not hold for this change
|
|
39
|
+
* deferred the gate's content is not defined anywhere in the repo
|
|
40
|
+
*
|
|
41
|
+
* `fail` and `unknown` block ready-to-ship. "Not evaluated" is not "passed" —
|
|
42
|
+
* the same rule V-05 applies to a suite that executed zero tests.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { execFileSync } from "node:child_process";
|
|
46
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
47
|
+
import { join, resolve, sep } from "node:path";
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
loadArtifact,
|
|
51
|
+
buildWorld,
|
|
52
|
+
findHstackRoot,
|
|
53
|
+
globMatches,
|
|
54
|
+
substantiveLines,
|
|
55
|
+
} from "./validate-spec.mjs";
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// 1. Verdict helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const PASS = (detail) => ({ verdict: "pass", detail: detail ?? null });
|
|
62
|
+
const FAIL = (detail) => ({ verdict: "fail", detail });
|
|
63
|
+
const UNKNOWN = (detail) => ({ verdict: "unknown", detail });
|
|
64
|
+
const NA = (detail) => ({ verdict: "not-applicable", detail });
|
|
65
|
+
const DEFERRED = (detail) => ({ verdict: "deferred", detail });
|
|
66
|
+
|
|
67
|
+
/** Verdicts that stop a change from being ready to ship. */
|
|
68
|
+
const BLOCKING = new Set(["fail", "unknown"]);
|
|
69
|
+
|
|
70
|
+
const present = (v) => v !== null && v !== undefined && String(v).trim() !== "";
|
|
71
|
+
|
|
72
|
+
const arr = (v) => (Array.isArray(v) ? v : v === null || v === undefined ? [] : [v]);
|
|
73
|
+
|
|
74
|
+
const list = (xs, max = 6) =>
|
|
75
|
+
xs.length <= max ? xs.join(", ") : `${xs.slice(0, max).join(", ")}, … (+${xs.length - max})`;
|
|
76
|
+
|
|
77
|
+
/** Status of an artifact that may be absent. */
|
|
78
|
+
const statusOf = (a) => (a && a.fm ? String(a.fm.status) : null);
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// 2. Gate registry
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
//
|
|
84
|
+
// Each entry: { id, title, description, check(ctx) }.
|
|
85
|
+
// `check` returns one of the verdict helpers above.
|
|
86
|
+
//
|
|
87
|
+
// `ctx` is built by `buildContext` below and carries:
|
|
88
|
+
// spec the change-spec artifact record
|
|
89
|
+
// art { plan, testPlan, securityReview, dataReview, uiBrief,
|
|
90
|
+
// figmaHandoff, verification, adversarialReview } — null when absent
|
|
91
|
+
// world the whole-tree artifact index (validate-spec's buildWorld)
|
|
92
|
+
// git { available, branch, base, mergeBase, files, onBase, error }
|
|
93
|
+
// lints repo-relative paths of hstack/lints/*.yaml
|
|
94
|
+
// gatesExit exit code of run-gates.sh, or null when it was not run
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Paths hstack itself writes on the change branch by construction — every
|
|
98
|
+
* Skill's auto-commit lands in one of them. GT-02 defends the *implementer's*
|
|
99
|
+
* write boundary (kernel § Scope rules: "CI enforces the write boundary at PR
|
|
100
|
+
* time"); the framework's own audit trail is not what it is defending, and a
|
|
101
|
+
* gate that failed on the artifacts the workflow just wrote would be switched
|
|
102
|
+
* off within a week. Named here rather than inferred, so the carve-out is
|
|
103
|
+
* reviewable.
|
|
104
|
+
*
|
|
105
|
+
* `<id>` is substituted with the change id — a change may write its OWN
|
|
106
|
+
* artifact folder, never a sibling change's.
|
|
107
|
+
*/
|
|
108
|
+
const WORKFLOW_OWNED = [
|
|
109
|
+
"hstack/specs/changes/<id>/**",
|
|
110
|
+
"hstack/adr/**",
|
|
111
|
+
"hstack/tech-debt/**",
|
|
112
|
+
"hstack/kernel-fit/**",
|
|
113
|
+
"hstack/coord/**",
|
|
114
|
+
"hstack/research/**",
|
|
115
|
+
"hstack/telemetry/reports/**",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/** Terminal statuses per artifact type, as `hstack-ship` states them. */
|
|
119
|
+
const REVIEW_TERMINAL = ["passed", "concerns-acknowledged"];
|
|
120
|
+
|
|
121
|
+
export const GATES = [
|
|
122
|
+
{
|
|
123
|
+
id: "GT-01",
|
|
124
|
+
title: "spec presence",
|
|
125
|
+
description:
|
|
126
|
+
"The change folder exists with a change-spec past `draft`, or the change carries `trivial: true`. A draft spec is not a contract anything can be scored against.",
|
|
127
|
+
check(ctx) {
|
|
128
|
+
if (ctx.spec.fm.trivial === true) {
|
|
129
|
+
return PASS("`trivial: true` — spec-presence is bypassed per the kernel's trivial carve-out");
|
|
130
|
+
}
|
|
131
|
+
const s = statusOf(ctx.spec);
|
|
132
|
+
if (s === "draft" || s === "archived") {
|
|
133
|
+
return FAIL(`change-spec is at \`${s}\`; a shippable spec is past draft and not archived`);
|
|
134
|
+
}
|
|
135
|
+
return PASS(`change-spec at \`${s}\``);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "GT-02",
|
|
140
|
+
title: "diff within scope",
|
|
141
|
+
description:
|
|
142
|
+
"Every file in the PR diff (against the merge target) is a subset of `change-spec.in-scope`, plus the named workflow-owned carve-out. Mandatory even for `trivial: true`.",
|
|
143
|
+
check(ctx) {
|
|
144
|
+
if (!ctx.git.available) {
|
|
145
|
+
return UNKNOWN(`the diff could not be read: ${ctx.git.error}`);
|
|
146
|
+
}
|
|
147
|
+
if (ctx.git.onBase) {
|
|
148
|
+
return UNKNOWN(
|
|
149
|
+
`HEAD is \`${ctx.git.branch}\`, which is the merge target — there is no PR diff to score. ` +
|
|
150
|
+
`Check out \`change/${ctx.changeId}\` (\`/hstack:branch ${ctx.changeId}\`) and re-run.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const inScope = arr(ctx.spec.fm["in-scope"]).filter(present).map(String);
|
|
154
|
+
if (inScope.length === 0) {
|
|
155
|
+
return FAIL("`in-scope` is empty — every changed file is out of scope by definition");
|
|
156
|
+
}
|
|
157
|
+
const owned = WORKFLOW_OWNED.map((g) => g.replace("<id>", ctx.changeId));
|
|
158
|
+
const offenders = ctx.git.files.filter(
|
|
159
|
+
(f) => !inScope.some((g) => globMatches(g, f)) && !owned.some((g) => globMatches(g, f)),
|
|
160
|
+
);
|
|
161
|
+
if (offenders.length > 0) {
|
|
162
|
+
return FAIL(
|
|
163
|
+
`${offenders.length} of ${ctx.git.files.length} changed file(s) outside \`in-scope\`: ${list(offenders)}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return PASS(`${ctx.git.files.length} changed file(s), all within \`in-scope\``);
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "GT-03",
|
|
171
|
+
title: "pattern lints",
|
|
172
|
+
description:
|
|
173
|
+
"Every rule under `hstack/lints/*.yaml` passes. Computed from the exit code of `hstack/scripts/run-gates.sh`, passed in with `--gates-exit`.",
|
|
174
|
+
check(ctx) {
|
|
175
|
+
if (ctx.lints.length === 0) {
|
|
176
|
+
return NA("no `hstack/lints/*.yaml` rules are declared in this repo");
|
|
177
|
+
}
|
|
178
|
+
if (ctx.gatesExit === null) {
|
|
179
|
+
return UNKNOWN(
|
|
180
|
+
`${ctx.lints.length} lint rule file(s) declared but the gate runner was not run — ` +
|
|
181
|
+
"run `hstack/scripts/run-gates.sh` and re-run with `--gates-exit <code>`",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return ctx.gatesExit === 0
|
|
185
|
+
? PASS(`run-gates.sh exited 0 against ${ctx.lints.length} rule file(s)`)
|
|
186
|
+
: FAIL(`run-gates.sh exited ${ctx.gatesExit} — read the captured output for the failing rule`);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
id: "GT-04",
|
|
191
|
+
title: "adversarial-review resolved",
|
|
192
|
+
description: "`adversarial-review.md` is at `findings-resolved`.",
|
|
193
|
+
check(ctx) {
|
|
194
|
+
return terminalGate(ctx.art.adversarialReview, "adversarial-review.md", ["findings-resolved"]);
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "GT-05",
|
|
199
|
+
title: "security-review terminal",
|
|
200
|
+
description: "`security-review.md` is at `passed` or `concerns-acknowledged`.",
|
|
201
|
+
check(ctx) {
|
|
202
|
+
return terminalGate(ctx.art.securityReview, "security-review.md", REVIEW_TERMINAL);
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: "GT-06",
|
|
207
|
+
title: "data-review terminal",
|
|
208
|
+
description:
|
|
209
|
+
"`data-review.md` is at `passed` or `concerns-acknowledged`, when `surfaces` includes `db`.",
|
|
210
|
+
check(ctx) {
|
|
211
|
+
if (!ctx.surfaces.includes("db")) {
|
|
212
|
+
return NA("`surfaces` does not include `db`");
|
|
213
|
+
}
|
|
214
|
+
return terminalGate(ctx.art.dataReview, "data-review.md", REVIEW_TERMINAL);
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "GT-07",
|
|
219
|
+
title: "ui artifacts terminal",
|
|
220
|
+
description:
|
|
221
|
+
"`ui-brief.md` is at `drafted` and `figma-handoff.md` is at `ready`, when `surfaces` includes `ui`.",
|
|
222
|
+
check(ctx) {
|
|
223
|
+
if (!ctx.surfaces.includes("ui")) {
|
|
224
|
+
return NA("`surfaces` does not include `ui`");
|
|
225
|
+
}
|
|
226
|
+
const parts = [
|
|
227
|
+
terminalGate(ctx.art.uiBrief, "ui-brief.md", ["drafted"]),
|
|
228
|
+
terminalGate(ctx.art.figmaHandoff, "figma-handoff.md", ["ready"]),
|
|
229
|
+
];
|
|
230
|
+
const bad = parts.filter((p) => p.verdict !== "pass");
|
|
231
|
+
return bad.length === 0
|
|
232
|
+
? PASS(parts.map((p) => p.detail).join("; "))
|
|
233
|
+
: FAIL(bad.map((p) => p.detail).join("; "));
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "GT-08",
|
|
238
|
+
title: "user value declared",
|
|
239
|
+
description:
|
|
240
|
+
"`user-stories` is non-empty, UNLESS exactly one no-story carve-out is declared: Category A `internal-tooling: true`, Category B non-empty `enables`, Category C `area: bootstrap`. Restates SP-09; GT-12 owns the mutual exclusion.",
|
|
241
|
+
check(ctx) {
|
|
242
|
+
const stories = arr(ctx.spec.fm["user-stories"]).filter(present);
|
|
243
|
+
if (stories.length > 0) return PASS(`${stories.length} user story reference(s)`);
|
|
244
|
+
if (ctx.spec.fm["internal-tooling"] === true) {
|
|
245
|
+
return PASS("Category A — `internal-tooling: true`, never on a user path");
|
|
246
|
+
}
|
|
247
|
+
const enables = arr(ctx.spec.fm.enables).filter(present).map(String);
|
|
248
|
+
if (enables.length > 0) {
|
|
249
|
+
// The audit-chain assumption, stated so it is not mistaken for a bug:
|
|
250
|
+
// a Category-B spec's user value lives in one of the specs it enables.
|
|
251
|
+
// This gate does not transitively verify that downstream spec has
|
|
252
|
+
// `user-stories` non-empty — that is the downstream's own GT-08, run at
|
|
253
|
+
// its own ship time. Category C terminates the chain by construction.
|
|
254
|
+
return PASS(
|
|
255
|
+
`Category B — user value is realized downstream by ${list(enables)} ` +
|
|
256
|
+
"(not transitively verified here; that is the downstream's own GT-08)",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (String(ctx.spec.fm.area) === "bootstrap") {
|
|
260
|
+
return PASS("Category C — `area: bootstrap`");
|
|
261
|
+
}
|
|
262
|
+
return FAIL(
|
|
263
|
+
"`user-stories` is empty and no carve-out is declared — set `internal-tooling: true` (A), " +
|
|
264
|
+
"a non-empty `enables` (B), or `area: bootstrap` (C)",
|
|
265
|
+
);
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "GT-09",
|
|
270
|
+
title: "cross-reference rules",
|
|
271
|
+
description:
|
|
272
|
+
"Every cross-reference rule CG-01..CG-04 passes. The range is named by `hstack-ship`; no repo source states what the four rules are.",
|
|
273
|
+
check() {
|
|
274
|
+
return DEFERRED(
|
|
275
|
+
"CG-01..CG-04 are named as a range but stated nowhere — implementing them would mean " +
|
|
276
|
+
"inventing them. Assigning the four statements is a kernel change, not a script change. " +
|
|
277
|
+
"The reciprocity and presence rules the range would plausibly cover are already enforced " +
|
|
278
|
+
"by `validate-spec.mjs` (SP-14, TD-01, TD-04, TD-05, KF-04) and by GT-12.",
|
|
279
|
+
);
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
id: "GT-10",
|
|
284
|
+
title: "test coverage honoured",
|
|
285
|
+
description:
|
|
286
|
+
"`test-plan.md` is at `passed` or `concerns-acknowledged`, and `verification.test-plan-coverage` shows no missing tenant-isolation tests and no out-of-budget performance assertions.",
|
|
287
|
+
check(ctx) {
|
|
288
|
+
const tp = terminalGate(ctx.art.testPlan, "test-plan.md", REVIEW_TERMINAL);
|
|
289
|
+
if (tp.verdict !== "pass") return tp;
|
|
290
|
+
|
|
291
|
+
const v = ctx.art.verification;
|
|
292
|
+
if (!v) return FAIL("verification.md is missing — coverage cannot be scored");
|
|
293
|
+
const cov = v.fm["test-plan-coverage"];
|
|
294
|
+
if (!cov || typeof cov !== "object") {
|
|
295
|
+
return FAIL("verification.md carries no `test-plan-coverage` map");
|
|
296
|
+
}
|
|
297
|
+
const problems = [];
|
|
298
|
+
const tenant = String(cov["tenant-isolation"]);
|
|
299
|
+
if (!["all-observed", "not-applicable"].includes(tenant)) {
|
|
300
|
+
problems.push(`tenant-isolation is \`${tenant}\` (must be all-observed or not-applicable)`);
|
|
301
|
+
}
|
|
302
|
+
const perf = String(cov["performance-budgets"]);
|
|
303
|
+
if (!["all-within-budget", "not-applicable"].includes(perf)) {
|
|
304
|
+
problems.push(
|
|
305
|
+
`performance-budgets is \`${perf}\` (must be all-within-budget or not-applicable)`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
return problems.length === 0
|
|
309
|
+
? PASS(`${tp.detail}; tenant-isolation \`${tenant}\`, performance-budgets \`${perf}\``)
|
|
310
|
+
: FAIL(problems.join("; "));
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
id: "GT-11",
|
|
315
|
+
title: "tech-debt resolution honest",
|
|
316
|
+
description:
|
|
317
|
+
"When `resolves-tech-debt` is non-empty: every referenced tech-debt exists at `status: in-progress` with `resolution-attempted-at` set, none already carries a `resolved-by`, and the adversarial-review carries its AR-07 Acceptance-satisfied confirmation.",
|
|
318
|
+
check(ctx) {
|
|
319
|
+
const tds = arr(ctx.spec.fm["resolves-tech-debt"]).filter(present).map(String);
|
|
320
|
+
if (tds.length === 0) return NA("`resolves-tech-debt` is empty");
|
|
321
|
+
|
|
322
|
+
const problems = [];
|
|
323
|
+
for (const id of tds) {
|
|
324
|
+
const td = ctx.world.byId.get(id);
|
|
325
|
+
if (!td || td.type !== "tech-debt") {
|
|
326
|
+
problems.push(`${id} names no tech-debt on disk`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const s = statusOf(td);
|
|
330
|
+
if (s !== "in-progress") {
|
|
331
|
+
problems.push(
|
|
332
|
+
`${id} is at \`${s}\`; a debt under active resolution is at \`in-progress\` ` +
|
|
333
|
+
"(`/hstack:tech-debt-resolve` flips it, `/hstack:finalize` closes it post-merge)",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (!present(td.fm["resolution-attempted-at"])) {
|
|
337
|
+
problems.push(`${id} has no \`resolution-attempted-at\``);
|
|
338
|
+
}
|
|
339
|
+
if (present(td.fm["resolved-by"])) {
|
|
340
|
+
problems.push(
|
|
341
|
+
`${id} already carries \`resolved-by: ${td.fm["resolved-by"]}\` — double resolution`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// (b) AR-07: presence of the Acceptance-satisfied confirmation. This is
|
|
347
|
+
// the one place ship reads a body section rather than frontmatter — the
|
|
348
|
+
// confirmation has no frontmatter representation.
|
|
349
|
+
const ar = ctx.art.adversarialReview;
|
|
350
|
+
if (!ar) {
|
|
351
|
+
problems.push("adversarial-review.md is missing, so the AR-07 confirmation cannot be present");
|
|
352
|
+
} else {
|
|
353
|
+
const hit = ar.sections.find((s) => /acceptance[- ]satisfied/i.test(s.title));
|
|
354
|
+
if (!hit) {
|
|
355
|
+
problems.push('adversarial-review.md carries no "Acceptance Satisfied" subsection (AR-07)');
|
|
356
|
+
} else if (substantiveLines(hit).length === 0) {
|
|
357
|
+
problems.push('the adversarial-review\'s "Acceptance Satisfied" subsection is empty (AR-07)');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return problems.length === 0
|
|
362
|
+
? PASS(`${tds.length} tech-debt item(s) at \`in-progress\` with the AR-07 confirmation present`)
|
|
363
|
+
: FAIL(problems.join("; "));
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
id: "GT-12",
|
|
368
|
+
title: "category exclusivity and reciprocity",
|
|
369
|
+
description:
|
|
370
|
+
"SP-13: `internal-tooling: true` AND non-empty `enables` is forbidden. SP-14: every id in `enables` names a change-spec on disk that lists this change-id in its `enabled-by`, and every `enabled-by` entry reciprocates. Both directions are FAIL.",
|
|
371
|
+
check(ctx) {
|
|
372
|
+
const problems = [];
|
|
373
|
+
const id = String(ctx.spec.fm.id);
|
|
374
|
+
const enables = arr(ctx.spec.fm.enables).filter(present).map(String);
|
|
375
|
+
|
|
376
|
+
// SP-13 — hard FAIL, and the reason the two halves are one gate: a spec
|
|
377
|
+
// that is both A and B has no coherent user-value story to audit.
|
|
378
|
+
const declared = [];
|
|
379
|
+
if (ctx.spec.fm["internal-tooling"] === true) declared.push("`internal-tooling: true` (A)");
|
|
380
|
+
if (enables.length > 0) declared.push("non-empty `enables` (B)");
|
|
381
|
+
if (String(ctx.spec.fm.area) === "bootstrap") declared.push("`area: bootstrap` (C)");
|
|
382
|
+
if (declared.length > 1) {
|
|
383
|
+
problems.push(
|
|
384
|
+
`the no-story categories are mutually exclusive but this spec declares ${declared.length}: ${declared.join(", ")}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// SP-14 — reciprocity, both directions. By ship time a forward reference
|
|
389
|
+
// must have been scaffolded; only authoring time tolerates a dangling id.
|
|
390
|
+
for (const target of enables) {
|
|
391
|
+
const other = ctx.world.byId.get(target);
|
|
392
|
+
if (!other || other.type !== "change-spec") {
|
|
393
|
+
problems.push(
|
|
394
|
+
`enables \`${target}\` names no change-spec on disk — by ship time the downstream must be scaffolded`,
|
|
395
|
+
);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!arr(other.fm["enabled-by"]).map(String).includes(id)) {
|
|
399
|
+
problems.push(`enables \`${target}\` but ${other.relpath} does not list \`${id}\` in \`enabled-by\``);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
for (const source of arr(ctx.spec.fm["enabled-by"]).filter(present).map(String)) {
|
|
403
|
+
const other = ctx.world.byId.get(source);
|
|
404
|
+
if (!other || other.type !== "change-spec") {
|
|
405
|
+
problems.push(`enabled-by \`${source}\` names no change-spec on disk`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (!arr(other.fm.enables).map(String).includes(id)) {
|
|
409
|
+
problems.push(`enabled-by \`${source}\` but ${other.relpath} does not list \`${id}\` in \`enables\``);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return problems.length === 0 ? PASS("categories exclusive, reciprocity holds") : FAIL(problems.join("; "));
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Gates named by `hstack-ship` that this script does NOT compute, with the
|
|
420
|
+
* reason. Printed by `--gates` and surfaced in `--json`, mirroring
|
|
421
|
+
* `validate-spec.mjs`'s `DEFERRED_RULES`, so nothing quietly disappears
|
|
422
|
+
* between "documented" and "enforced".
|
|
423
|
+
*/
|
|
424
|
+
export const DEFERRED_GATES = [
|
|
425
|
+
{
|
|
426
|
+
id: "CG-01..CG-04",
|
|
427
|
+
gate: "GT-09",
|
|
428
|
+
reason:
|
|
429
|
+
"The repo names the range (`GT-09: every cross-reference rule (CG-01..CG-04) passes`) but no source states what the four rules are. Implementing them means inventing them. Defining the four statements is a kernel change — a kernel-fit candidate, not a TODO in this file.",
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
/** Shared shape for the "artifact X is at one of these statuses" gates. */
|
|
434
|
+
function terminalGate(a, filename, terminals) {
|
|
435
|
+
if (!a) return FAIL(`${filename} is missing`);
|
|
436
|
+
const s = statusOf(a);
|
|
437
|
+
return terminals.includes(s)
|
|
438
|
+
? PASS(`${filename} at \`${s}\``)
|
|
439
|
+
: FAIL(`${filename} is at \`${s}\`; required: ${terminals.map((t) => `\`${t}\``).join(" | ")}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// 3. Git facts
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
function git(repoRoot, args) {
|
|
447
|
+
try {
|
|
448
|
+
const out = execFileSync("git", ["-C", repoRoot, ...args], {
|
|
449
|
+
encoding: "utf8",
|
|
450
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
451
|
+
});
|
|
452
|
+
return { ok: true, out: out.trim() };
|
|
453
|
+
} catch (err) {
|
|
454
|
+
const msg = (err && (err.stderr || err.message)) || String(err);
|
|
455
|
+
return { ok: false, out: "", error: String(msg).trim().split("\n")[0] };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const refExists = (repoRoot, ref) => git(repoRoot, ["rev-parse", "--verify", "--quiet", ref]).ok;
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The merge target. `--base` wins; otherwise the default branch from
|
|
463
|
+
* `hstack/config.yaml` (fallback `main`), preferring its remote-tracking ref
|
|
464
|
+
* because that is what the PR will actually be diffed against.
|
|
465
|
+
*/
|
|
466
|
+
function resolveBase(repoRoot, baseOpt, defaultBranch) {
|
|
467
|
+
if (baseOpt) return baseOpt;
|
|
468
|
+
for (const candidate of [`origin/${defaultBranch}`, defaultBranch]) {
|
|
469
|
+
if (refExists(repoRoot, candidate)) return candidate;
|
|
470
|
+
}
|
|
471
|
+
return defaultBranch;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The PR diff. `git diff --name-only <merge-base>` rather than `<base>...HEAD`
|
|
476
|
+
* so uncommitted work counts too — ship runs *before* the PR exists, and a
|
|
477
|
+
* scorecard that scored only committed files would pass a change whose
|
|
478
|
+
* out-of-scope edit is still in the working tree. Untracked files are added
|
|
479
|
+
* separately because `git diff` cannot see them.
|
|
480
|
+
*/
|
|
481
|
+
export function gitContext(repoRoot, baseOpt, defaultBranch) {
|
|
482
|
+
const head = git(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
483
|
+
if (!head.ok) {
|
|
484
|
+
return { available: false, branch: null, base: null, files: [], error: head.error };
|
|
485
|
+
}
|
|
486
|
+
const branch = head.out;
|
|
487
|
+
const base = resolveBase(repoRoot, baseOpt, defaultBranch);
|
|
488
|
+
if (!refExists(repoRoot, base)) {
|
|
489
|
+
return {
|
|
490
|
+
available: false,
|
|
491
|
+
branch,
|
|
492
|
+
base,
|
|
493
|
+
files: [],
|
|
494
|
+
error: `merge target \`${base}\` does not resolve — pass \`--base <ref>\``,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const mb = git(repoRoot, ["merge-base", base, "HEAD"]);
|
|
498
|
+
if (!mb.ok) {
|
|
499
|
+
return { available: false, branch, base, files: [], error: `no merge-base with \`${base}\`: ${mb.error}` };
|
|
500
|
+
}
|
|
501
|
+
const changed = git(repoRoot, ["diff", "--name-only", mb.out]);
|
|
502
|
+
const untracked = git(repoRoot, ["ls-files", "--others", "--exclude-standard"]);
|
|
503
|
+
const files = [
|
|
504
|
+
...new Set([...changed.out.split("\n"), ...untracked.out.split("\n")].map((s) => s.trim()).filter(Boolean)),
|
|
505
|
+
].sort();
|
|
506
|
+
|
|
507
|
+
// HEAD is the merge target itself: the diff is empty by construction, and an
|
|
508
|
+
// empty diff would make GT-02 pass without having looked at anything.
|
|
509
|
+
const baseBranch = base.replace(/^[^/]+\//, "");
|
|
510
|
+
const onBase = branch === base || branch === baseBranch;
|
|
511
|
+
|
|
512
|
+
return { available: true, branch, base, mergeBase: mb.out, files, onBase, error: null };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// 4. Context assembly
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
/** Per-change artifact filenames, in the order the scorecard reports them. */
|
|
520
|
+
const CHANGE_ARTIFACTS = {
|
|
521
|
+
plan: "plan.md",
|
|
522
|
+
testPlan: "test-plan.md",
|
|
523
|
+
securityReview: "security-review.md",
|
|
524
|
+
dataReview: "data-review.md",
|
|
525
|
+
uiBrief: "ui-brief.md",
|
|
526
|
+
figmaHandoff: "figma-handoff.md",
|
|
527
|
+
verification: "verification.md",
|
|
528
|
+
adversarialReview: "adversarial-review.md",
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/** Best-effort read of `default-branch` from hstack/config.yaml. */
|
|
532
|
+
export function readDefaultBranch(hstackRoot) {
|
|
533
|
+
const cfg = join(hstackRoot, "config.yaml");
|
|
534
|
+
if (!existsSync(cfg)) return "main";
|
|
535
|
+
try {
|
|
536
|
+
const m = /^\s*default-branch:\s*(\S+)\s*$/m.exec(readFileSync(cfg, "utf8"));
|
|
537
|
+
return m ? m[1].replace(/^["']|["']$/g, "") : "main";
|
|
538
|
+
} catch {
|
|
539
|
+
return "main";
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function readLintRules(hstackRoot) {
|
|
544
|
+
const dir = join(hstackRoot, "lints");
|
|
545
|
+
try {
|
|
546
|
+
return readdirSync(dir)
|
|
547
|
+
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
|
548
|
+
.sort()
|
|
549
|
+
.map((f) => `hstack/lints/${f}`);
|
|
550
|
+
} catch {
|
|
551
|
+
return [];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Assemble everything the gates read. Separated from `main` so the tests can
|
|
557
|
+
* hand-build a context — including a synthetic `git` — and score it without a
|
|
558
|
+
* repository.
|
|
559
|
+
*
|
|
560
|
+
* Returns `{ error }` for the two stop conditions `hstack-ship` names: the
|
|
561
|
+
* change folder is missing, or the change-spec is missing / unparseable.
|
|
562
|
+
*/
|
|
563
|
+
export function buildContext(repoRoot, hstackRoot, changeId, opts = {}) {
|
|
564
|
+
const changeDir = join(hstackRoot, "specs", "changes", changeId);
|
|
565
|
+
if (!existsSync(changeDir)) {
|
|
566
|
+
return { error: `no change folder at hstack/specs/changes/${changeId}/` };
|
|
567
|
+
}
|
|
568
|
+
const specPath = join(changeDir, "spec.md");
|
|
569
|
+
if (!existsSync(specPath)) {
|
|
570
|
+
return { error: `no change-spec at hstack/specs/changes/${changeId}/spec.md` };
|
|
571
|
+
}
|
|
572
|
+
const spec = loadArtifact(specPath, repoRoot);
|
|
573
|
+
if (spec.noFrontmatter || spec.unreadable) {
|
|
574
|
+
return { error: `hstack/specs/changes/${changeId}/spec.md: ${spec.parseError ?? spec.unreadable}` };
|
|
575
|
+
}
|
|
576
|
+
if (spec.type !== "change-spec") {
|
|
577
|
+
return { error: `hstack/specs/changes/${changeId}/spec.md carries \`type: ${spec.type}\`, not change-spec` };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const art = {};
|
|
581
|
+
const missing = [];
|
|
582
|
+
for (const [key, filename] of Object.entries(CHANGE_ARTIFACTS)) {
|
|
583
|
+
const p = join(changeDir, filename);
|
|
584
|
+
if (!existsSync(p)) {
|
|
585
|
+
art[key] = null;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
const a = loadArtifact(p, repoRoot);
|
|
589
|
+
if (a.noFrontmatter || a.unreadable) {
|
|
590
|
+
// A present-but-unreadable artifact is a halt, not a FAIL: the scorecard
|
|
591
|
+
// would be scoring a file it could not parse.
|
|
592
|
+
missing.push(`${filename}: ${a.parseError ?? a.unreadable}`);
|
|
593
|
+
art[key] = null;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
art[key] = a;
|
|
597
|
+
}
|
|
598
|
+
if (missing.length > 0) return { error: `unparseable frontmatter — ${missing.join("; ")}` };
|
|
599
|
+
|
|
600
|
+
const defaultBranch = readDefaultBranch(hstackRoot);
|
|
601
|
+
return {
|
|
602
|
+
changeId,
|
|
603
|
+
repoRoot,
|
|
604
|
+
hstackRoot,
|
|
605
|
+
spec,
|
|
606
|
+
art,
|
|
607
|
+
surfaces: arr(spec.fm.surfaces).filter(present).map(String),
|
|
608
|
+
world: buildWorld(repoRoot, hstackRoot),
|
|
609
|
+
defaultBranch,
|
|
610
|
+
lints: readLintRules(hstackRoot),
|
|
611
|
+
gatesExit: opts.gatesExit ?? null,
|
|
612
|
+
git: opts.git ?? gitContext(repoRoot, opts.base ?? null, defaultBranch),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// 5. Runner
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
|
|
620
|
+
export function score(ctx) {
|
|
621
|
+
const results = [];
|
|
622
|
+
for (const gate of GATES) {
|
|
623
|
+
let r;
|
|
624
|
+
try {
|
|
625
|
+
r = gate.check(ctx);
|
|
626
|
+
} catch (err) {
|
|
627
|
+
r = UNKNOWN(`scorecard error while computing this gate: ${err && err.message ? err.message : err}`);
|
|
628
|
+
}
|
|
629
|
+
results.push({ id: gate.id, title: gate.title, verdict: r.verdict, detail: r.detail ?? null });
|
|
630
|
+
}
|
|
631
|
+
const counts = { pass: 0, fail: 0, unknown: 0, "not-applicable": 0, deferred: 0 };
|
|
632
|
+
for (const r of results) counts[r.verdict]++;
|
|
633
|
+
return { gates: results, counts, ready: results.every((r) => !BLOCKING.has(r.verdict)) };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// 6. CLI
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
function usage() {
|
|
641
|
+
return `hstack compute-merge-readiness — the twelve-gate merge-readiness scorecard
|
|
642
|
+
|
|
643
|
+
node hstack/scripts/compute-merge-readiness.mjs <change-id> [options]
|
|
644
|
+
|
|
645
|
+
Options
|
|
646
|
+
--json emit the scorecard as JSON on stdout
|
|
647
|
+
--base REF merge target to diff against (default: origin/<default-branch>)
|
|
648
|
+
--gates-exit N exit code from hstack/scripts/run-gates.sh, for GT-03
|
|
649
|
+
--gates print the gate registry (computed + deferred) and exit
|
|
650
|
+
--root DIR repo root to resolve hstack/ from (default: search upward)
|
|
651
|
+
-h, --help this text
|
|
652
|
+
|
|
653
|
+
Exit codes: 0 every applicable gate passes, 1 at least one gate blocks,
|
|
654
|
+
2 usage or environment error.`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function printGates() {
|
|
658
|
+
console.log(
|
|
659
|
+
`# hstack merge-readiness registry — ${GATES.length} gates, ${DEFERRED_GATES.length} deferred entries\n`,
|
|
660
|
+
);
|
|
661
|
+
console.log("## Computed\n");
|
|
662
|
+
for (const g of GATES) console.log(`- ${g.id} ${g.title}\n ${g.description}`);
|
|
663
|
+
console.log("\n## Deferred — named, not computable\n");
|
|
664
|
+
for (const d of DEFERRED_GATES) console.log(`- ${d.id} [${d.gate}]\n ${d.reason}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const TAG = {
|
|
668
|
+
pass: "pass",
|
|
669
|
+
fail: "FAIL",
|
|
670
|
+
unknown: "? ",
|
|
671
|
+
"not-applicable": "n/a ",
|
|
672
|
+
deferred: "def ",
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
function render(ctx, result) {
|
|
676
|
+
const g = ctx.git;
|
|
677
|
+
console.log(`hstack merge-readiness — ${ctx.changeId}\n`);
|
|
678
|
+
console.log(` branch: ${g.branch ?? "(unknown)"}`);
|
|
679
|
+
console.log(` base: ${g.base ?? "(unresolved)"}${g.mergeBase ? ` (merge-base ${g.mergeBase.slice(0, 8)})` : ""}`);
|
|
680
|
+
console.log(` diff: ${g.available ? `${g.files.length} file(s)` : `unavailable — ${g.error}`}`);
|
|
681
|
+
console.log("");
|
|
682
|
+
for (const r of result.gates) {
|
|
683
|
+
console.log(` ${r.id} ${TAG[r.verdict]} ${r.title}`);
|
|
684
|
+
if (r.detail) console.log(` ${r.detail}`);
|
|
685
|
+
}
|
|
686
|
+
console.log("");
|
|
687
|
+
const c = result.counts;
|
|
688
|
+
console.log(
|
|
689
|
+
`merge-readiness: ${c.pass} pass, ${c.fail} fail, ${c.unknown} unknown, ` +
|
|
690
|
+
`${c["not-applicable"]} n/a, ${c.deferred} deferred — ` +
|
|
691
|
+
(result.ready ? "READY (advance to `ready-to-ship`)" : "NOT READY"),
|
|
692
|
+
);
|
|
693
|
+
if (!result.ready) {
|
|
694
|
+
const blocking = result.gates.filter((r) => BLOCKING.has(r.verdict)).map((r) => r.id);
|
|
695
|
+
console.log(` blocking: ${blocking.join(", ")}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function main(argv) {
|
|
700
|
+
const args = argv.slice(2);
|
|
701
|
+
const opts = { json: false, gates: false, base: null, gatesExit: null, root: null, positional: [] };
|
|
702
|
+
for (let i = 0; i < args.length; i++) {
|
|
703
|
+
const a = args[i];
|
|
704
|
+
if (a === "--json") opts.json = true;
|
|
705
|
+
else if (a === "--gates") opts.gates = true;
|
|
706
|
+
else if (a === "--base") opts.base = args[++i];
|
|
707
|
+
else if (a === "--gates-exit") opts.gatesExit = Number(args[++i]);
|
|
708
|
+
else if (a === "--root") opts.root = args[++i];
|
|
709
|
+
else if (a === "-h" || a === "--help") {
|
|
710
|
+
console.log(usage());
|
|
711
|
+
return 0;
|
|
712
|
+
} else if (a.startsWith("-")) {
|
|
713
|
+
console.error(`compute-merge-readiness: unknown option ${a}\n\n${usage()}`);
|
|
714
|
+
return 2;
|
|
715
|
+
} else opts.positional.push(a);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (opts.gates) {
|
|
719
|
+
printGates();
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
722
|
+
if (opts.positional.length !== 1) {
|
|
723
|
+
console.error(`compute-merge-readiness: expected exactly one <change-id>\n\n${usage()}`);
|
|
724
|
+
return 2;
|
|
725
|
+
}
|
|
726
|
+
if (opts.gatesExit !== null && !Number.isInteger(opts.gatesExit)) {
|
|
727
|
+
console.error("compute-merge-readiness: --gates-exit takes an integer exit code");
|
|
728
|
+
return 2;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const found = findHstackRoot(opts.root ?? process.cwd());
|
|
732
|
+
if (!found) {
|
|
733
|
+
console.error(
|
|
734
|
+
"compute-merge-readiness: no hstack/ tree found (looked for hstack/KERNEL.md upward from " +
|
|
735
|
+
(opts.root ?? process.cwd()) +
|
|
736
|
+
"). Pass --root <repo>.",
|
|
737
|
+
);
|
|
738
|
+
return 2;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const ctx = buildContext(found.repoRoot, found.hstackRoot, opts.positional[0], {
|
|
742
|
+
base: opts.base,
|
|
743
|
+
gatesExit: opts.gatesExit,
|
|
744
|
+
});
|
|
745
|
+
if (ctx.error) {
|
|
746
|
+
console.error(`compute-merge-readiness: ${ctx.error}`);
|
|
747
|
+
return 2;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const result = score(ctx);
|
|
751
|
+
if (opts.json) {
|
|
752
|
+
console.log(
|
|
753
|
+
JSON.stringify(
|
|
754
|
+
{
|
|
755
|
+
ok: result.ready,
|
|
756
|
+
changeId: ctx.changeId,
|
|
757
|
+
branch: ctx.git.branch,
|
|
758
|
+
base: ctx.git.base,
|
|
759
|
+
mergeBase: ctx.git.mergeBase ?? null,
|
|
760
|
+
changedFiles: ctx.git.files,
|
|
761
|
+
gates: result.gates,
|
|
762
|
+
counts: result.counts,
|
|
763
|
+
deferred: DEFERRED_GATES,
|
|
764
|
+
},
|
|
765
|
+
null,
|
|
766
|
+
2,
|
|
767
|
+
),
|
|
768
|
+
);
|
|
769
|
+
} else {
|
|
770
|
+
render(ctx, result);
|
|
771
|
+
}
|
|
772
|
+
return result.ready ? 0 : 1;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Only run the CLI when invoked directly, so the Skill and the tests can import.
|
|
776
|
+
const invokedDirectly =
|
|
777
|
+
process.argv[1] && resolve(process.argv[1]).endsWith(`${sep}compute-merge-readiness.mjs`);
|
|
778
|
+
if (invokedDirectly) {
|
|
779
|
+
process.exit(main(process.argv));
|
|
780
|
+
}
|