trackfw 7.6.0 → 8.0.0-rc2
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/README.md +28 -11
- package/bin/trackfw.js +81 -0
- package/package.json +14 -8
- package/bin/trackfw +0 -41
- package/src/branch/prune.js +0 -450
- package/src/branch/runner.js +0 -153
- package/src/changelog/index.js +0 -114
- package/src/commands/adr.js +0 -56
- package/src/commands/agents.js +0 -3
- package/src/commands/audit-surface.js +0 -520
- package/src/commands/barrier.js +0 -822
- package/src/commands/baseline.js +0 -13
- package/src/commands/branch.js +0 -98
- package/src/commands/changelog.js +0 -29
- package/src/commands/commit.js +0 -66
- package/src/commands/configure.js +0 -106
- package/src/commands/context.js +0 -215
- package/src/commands/discover.js +0 -628
- package/src/commands/doctor.js +0 -66
- package/src/commands/help.js +0 -358
- package/src/commands/identity-wizard.js +0 -258
- package/src/commands/index.js +0 -68
- package/src/commands/init.js +0 -296
- package/src/commands/integrations.js +0 -305
- package/src/commands/log.js +0 -31
- package/src/commands/metrics.js +0 -242
- package/src/commands/note.js +0 -24
- package/src/commands/push.js +0 -51
- package/src/commands/release.js +0 -47
- package/src/commands/req.js +0 -98
- package/src/commands/roadmap.js +0 -44
- package/src/commands/serve.js +0 -345
- package/src/commands/ship.js +0 -58
- package/src/commands/skills.js +0 -3
- package/src/commands/status.js +0 -12
- package/src/commands/sync.js +0 -348
- package/src/commands/thirdparty.js +0 -390
- package/src/commands/update-harness.js +0 -1002
- package/src/commands/update.js +0 -500
- package/src/commands/validate.js +0 -57
- package/src/commands/version.js +0 -10
- package/src/commit/runner.js +0 -352
- package/src/config/index.js +0 -457
- package/src/forge/adapter.js +0 -189
- package/src/forge/resolve.js +0 -191
- package/src/generators/adr.js +0 -198
- package/src/generators/codex.js +0 -79
- package/src/generators/date.js +0 -23
- package/src/generators/hooks.js +0 -2414
- package/src/generators/init.js +0 -1563
- package/src/generators/note.js +0 -154
- package/src/generators/req.js +0 -436
- package/src/generators/roadmap.js +0 -841
- package/src/homedir.js +0 -30
- package/src/i18n/index.js +0 -53
- package/src/i18n/locales/en-US.json +0 -124
- package/src/i18n/locales/es-ES.json +0 -124
- package/src/i18n/locales/pt-BR.json +0 -124
- package/src/identity/config.js +0 -134
- package/src/identity/index.js +0 -19
- package/src/identity/preset.js +0 -219
- package/src/identity/slug.js +0 -62
- package/src/integrations/assets/agents/architect.md +0 -61
- package/src/integrations/assets/agents/backend.md +0 -41
- package/src/integrations/assets/agents/code-quality.md +0 -48
- package/src/integrations/assets/agents/data.md +0 -41
- package/src/integrations/assets/agents/dba.md +0 -41
- package/src/integrations/assets/agents/frontend.md +0 -41
- package/src/integrations/assets/agents/iac.md +0 -46
- package/src/integrations/assets/agents/infra.md +0 -43
- package/src/integrations/assets/agents/qa.md +0 -41
- package/src/integrations/assets/agents/security.md +0 -51
- package/src/integrations/assets/agents/tooling.md +0 -47
- package/src/integrations/assets/agents/ux.md +0 -48
- package/src/integrations/assets/catalog.json +0 -48
- package/src/integrations/assets/skills/architecture.md +0 -66
- package/src/integrations/assets/skills/backend.md +0 -67
- package/src/integrations/assets/skills/code-quality.md +0 -78
- package/src/integrations/assets/skills/data.md +0 -75
- package/src/integrations/assets/skills/dba.md +0 -77
- package/src/integrations/assets/skills/frontend.md +0 -70
- package/src/integrations/assets/skills/governance.md +0 -6
- package/src/integrations/assets/skills/iac.md +0 -90
- package/src/integrations/assets/skills/implement.md +0 -6
- package/src/integrations/assets/skills/infra.md +0 -62
- package/src/integrations/assets/skills/plan.md +0 -6
- package/src/integrations/assets/skills/qa.md +0 -63
- package/src/integrations/assets/skills/release.md +0 -6
- package/src/integrations/assets/skills/review.md +0 -6
- package/src/integrations/assets/skills/security.md +0 -74
- package/src/integrations/assets/skills/tooling.md +0 -80
- package/src/integrations/assets/skills/ux.md +0 -70
- package/src/integrations/catalog.js +0 -58
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +0 -224
- package/src/integrations/index.js +0 -171
- package/src/integrations/legacy.js +0 -35
- package/src/integrations/manager.js +0 -438
- package/src/integrations/register-agent.js +0 -106
- package/src/integrations/render.js +0 -563
- package/src/integrations/scaffold_doctor.js +0 -395
- package/src/lib/fatal-error.js +0 -64
- package/src/lib/pathfmt.js +0 -43
- package/src/lib/unknown-command.js +0 -82
- package/src/lib/update-engine.js +0 -242
- package/src/push/runner.js +0 -226
- package/src/release/runner.js +0 -474
- package/src/serve/api_attention.js +0 -24
- package/src/serve/api_board.js +0 -105
- package/src/serve/api_chain.js +0 -246
- package/src/serve/api_file.js +0 -117
- package/src/serve/api_metrics.js +0 -139
- package/src/serve/static/app.js +0 -1151
- package/src/serve/static/index.html +0 -338
- package/src/serve/static/style.css +0 -297
- package/src/ship/runner.js +0 -775
- package/src/thirdparty/fetch.js +0 -140
- package/src/thirdparty/markers.js +0 -207
- package/src/thirdparty/provenance.js +0 -114
- package/src/thirdparty/quarantine.js +0 -113
- package/src/thirdparty/references.js +0 -194
- package/src/validator/git-exec.js +0 -72
- package/src/validator/index.js +0 -4086
- package/src/validator/traceid.js +0 -155
package/src/commands/barrier.js
DELETED
|
@@ -1,822 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// trackfw barrier <roadmap> --wave <n> [--json]
|
|
4
|
-
//
|
|
5
|
-
// Deterministic core of the wave-release barrier. Stack-agnostic: every executable
|
|
6
|
-
// check comes from the roadmap itself (see docs/cli-parity.md → `## trackfw barrier`).
|
|
7
|
-
// This command never assumes a build tool, a test runner or a parity rule, and it
|
|
8
|
-
// never performs git operations or invokes specialist agents — that orchestration
|
|
9
|
-
// lives in the `/trackfw:barrier` slash command.
|
|
10
|
-
|
|
11
|
-
const { Command } = require('commander')
|
|
12
|
-
const fs = require('fs')
|
|
13
|
-
const path = require('path')
|
|
14
|
-
const { spawnSync } = require('child_process')
|
|
15
|
-
const config = require('../config')
|
|
16
|
-
const validator = require('../validator')
|
|
17
|
-
|
|
18
|
-
// UsageError signals a resolution/parsing failure that must map to exit code 2 —
|
|
19
|
-
// distinct from a "blocked" (exit 1) evaluation result.
|
|
20
|
-
class UsageError extends Error {}
|
|
21
|
-
|
|
22
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
23
|
-
// Roadmap resolution
|
|
24
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
// resolveRoadmapFile finds <roadmapArg> (basename with or without .md) under
|
|
27
|
-
// wip/ then done/ (both flat and by_agent layouts). Throws UsageError naming the
|
|
28
|
-
// roadmap exactly as the user typed it (no .md normalization) when not found —
|
|
29
|
-
// pinned literally by docs/cli-parity.md (`## trackfw barrier`).
|
|
30
|
-
function resolveRoadmapFile(cfg, roadmapArg) {
|
|
31
|
-
const base = roadmapArg.endsWith('.md') ? roadmapArg : `${roadmapArg}.md`
|
|
32
|
-
const dirs = [...validator.resolveWIPDirs(cfg), ...validator.resolveDoneDirs(cfg)]
|
|
33
|
-
for (const dir of dirs) {
|
|
34
|
-
const candidate = path.join(config.expandPath(dir), base)
|
|
35
|
-
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
36
|
-
return { path: candidate, basename: base }
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
throw new UsageError(`roadmap "${roadmapArg}" not found in wip/ nor done/ under ${cfg.roadmapDir}`)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
43
|
-
// Wave label grammar (pinned by docs/cli-parity.md, "Wave label grammar" section)
|
|
44
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
// Scan regex (mirrors Go's waveHeadingRe): catches any "## Wave <token> " line.
|
|
47
|
-
// The trailing space is part of rule 1 — a heading whose label touches EOL is not
|
|
48
|
-
// recognised as a wave heading (neither valid nor malformed).
|
|
49
|
-
const WAVE_SCAN_RE = /^## Wave (\S+) /
|
|
50
|
-
|
|
51
|
-
// Grammar: <integer>[-<suffix>] where integer >= 0 and suffix is [a-z0-9]+ (lowercase).
|
|
52
|
-
// Valid: 0, 1, 2, 2-bis, 2-hotfix, 10-a2. 0 is the Wave 0 threat-model convention
|
|
53
|
-
// (docs/cli-parity.md § "Wave label grammar").
|
|
54
|
-
// Invalid: X, 2-BIS, -bis, 2-, 2-bis-ter.
|
|
55
|
-
const WAVE_LABEL_RE = /^(\d+)(?:-([a-z0-9]+))?$/
|
|
56
|
-
|
|
57
|
-
// isValidWaveLabel returns true iff token matches the grammar above.
|
|
58
|
-
// Exported so unit tests can assert the full table of valid/invalid examples.
|
|
59
|
-
// Shared by both guard points (the --wave flag validation and the heading
|
|
60
|
-
// pre-pass in findWave) — a single function, unlike Go's two duplicated checks.
|
|
61
|
-
function isValidWaveLabel(token) {
|
|
62
|
-
const m = WAVE_LABEL_RE.exec(String(token))
|
|
63
|
-
if (!m) return false
|
|
64
|
-
return parseInt(m[1], 10) >= 0
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
68
|
-
// Roadmap parsing rules (string-level, see docs/cli-parity.md)
|
|
69
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
// findWave locates the `## Wave <label> ` heading matching waveLabel and returns its
|
|
72
|
-
// line range [startLine, endLine) within `lines`. Performs a full pre-pass over all
|
|
73
|
-
// wave headings (mirroring Go's parseWaves): throws UsageError for the first malformed
|
|
74
|
-
// heading regardless of position — this ensures a typo like `## Wave X — ...` aborts
|
|
75
|
-
// the entire document (ADR decision 16, non-vacuity). Throws UsageError naming the
|
|
76
|
-
// wave label and the resolved roadmap basename when not found (pinned literally by
|
|
77
|
-
// docs/cli-parity.md). roadmapBasename is optional so unit tests that only exercise
|
|
78
|
-
// parsing can omit it; the CLI path always supplies it.
|
|
79
|
-
function findWave(lines, waveLabel, roadmapBasename) {
|
|
80
|
-
// Pre-pass: validate ALL wave headings and collect them.
|
|
81
|
-
// Abort immediately on the first malformed heading — this is intentional and
|
|
82
|
-
// mirrors Go's parseWaves() full-scan approach (ADR decision 16).
|
|
83
|
-
const waves = []
|
|
84
|
-
for (let i = 0; i < lines.length; i++) {
|
|
85
|
-
const attempt = WAVE_SCAN_RE.exec(lines[i])
|
|
86
|
-
if (!attempt) continue
|
|
87
|
-
const token = attempt[1]
|
|
88
|
-
if (!isValidWaveLabel(token)) {
|
|
89
|
-
throw new UsageError(`malformed wave heading at line ${i + 1}: "${token}" is not a valid wave label`)
|
|
90
|
-
}
|
|
91
|
-
let endLine = lines.length
|
|
92
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
93
|
-
if (/^## /.test(lines[j])) { endLine = j; break }
|
|
94
|
-
}
|
|
95
|
-
waves.push({ label: token, startLine: i, endLine })
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Find the requested wave by exact label match (no prefix/fuzzy matching).
|
|
99
|
-
const wave = waves.find(w => w.label === String(waveLabel))
|
|
100
|
-
if (!wave) {
|
|
101
|
-
throw new UsageError(`wave ${waveLabel} not found in roadmap "${roadmapBasename}"`)
|
|
102
|
-
}
|
|
103
|
-
return { startLine: wave.startLine, endLine: wave.endLine }
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// CRITERIA_HEADER_RE accepts both the canonical English header (ADR
|
|
107
|
-
// 2026-08-29 decision 1) and the Portuguese one, which remains accepted with
|
|
108
|
-
// no removal date (decision 2 — 99/143 roadmaps in the corpus use it,
|
|
109
|
-
// including artifacts in done/). The `^` anchor is load-bearing: an
|
|
110
|
-
// unanchored match would casar dentro de prosa ou de cerca de código, turning
|
|
111
|
-
// a quoted literal into forged acceptance evidence.
|
|
112
|
-
const CRITERIA_HEADER_RE = /^\*\*(?:Acceptance criteria|Crit[ée]rios de aceite):\*\*/
|
|
113
|
-
|
|
114
|
-
// STATUS_VOCABULARY is the closed set of first-token markers recognised as
|
|
115
|
-
// "complete" (ADR decision 3): the checkmark emoji, and the English/Portuguese
|
|
116
|
-
// words, matched after case-folding and diacritics-folding. Deliberately
|
|
117
|
-
// closed and explicit — "feito", "ok", "finalizado" are out (ADR, Alternatives
|
|
118
|
-
// Considered — "accept any non-empty status" is the rejected no-op design).
|
|
119
|
-
const STATUS_VOCABULARY = new Set(['✅', 'done', 'concluido'])
|
|
120
|
-
|
|
121
|
-
// VS16 is the single variation selector this module treats as cosmetic
|
|
122
|
-
// noise: the one emoji keyboards insert after "checkmark" to force
|
|
123
|
-
// text-style presentation (ADR 2026-08-29 decision 9, exception clause). No
|
|
124
|
-
// other variation selector or combining mark gets this treatment.
|
|
125
|
-
const VS16 = '\u{FE0F}'
|
|
126
|
-
|
|
127
|
-
// stripVS16 removes only U+FE0F occurrences from token. Deliberately
|
|
128
|
-
// narrower than "strip every variation selector" or "strip every Mn": VS16
|
|
129
|
-
// is the one documented cosmetic exception (ADR decision 9); anything else
|
|
130
|
-
// of category Mn is now a rejection, not a fold (see
|
|
131
|
-
// hasDisallowedCombiningMark).
|
|
132
|
-
function stripVS16(token) {
|
|
133
|
-
return token.split(VS16).join('')
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// hasDisallowedCombiningMark reports whether token (with VS16 already
|
|
137
|
-
// removed) contains any Unicode category Mn (Mark, Nonspacing) codepoint.
|
|
138
|
-
// ADR 2026-08-29 decision 9: after the single VS16 exception, any combining
|
|
139
|
-
// mark on the first status token is rejected outright \u2014 the ML is NOT
|
|
140
|
-
// complete \u2014 rather than folded away. A vocabulary this small exists to
|
|
141
|
-
// refuse ambiguity; silently folding combining marks reopens exactly the
|
|
142
|
-
// ambiguity it exists to close ("d<U+1DC0>one" must not read as "done").
|
|
143
|
-
//
|
|
144
|
-
// ORDER IS LOAD-BEARING: this check MUST run on the token BEFORE NFD
|
|
145
|
-
// decomposition, not after. "Conclu\u00eddo" in its authored (NFC) form has
|
|
146
|
-
// no literal Mn codepoint \u2014 the accented "\u00ed" is a single
|
|
147
|
-
// precomposed codepoint. NFD-decomposing it *produces* a trailing Mn
|
|
148
|
-
// (U+0301, COMBINING ACUTE ACCENT) that normalizeStatusToken then strips
|
|
149
|
-
// for vocabulary matching. Running this rejection check on the
|
|
150
|
-
// already-decomposed string would treat that legitimate,
|
|
151
|
-
// vocabulary-sanctioned accent the same as an injected combining mark and
|
|
152
|
-
// reject "Conclu\u00eddo" outright, breaking AC15's own positive case.
|
|
153
|
-
// Checking the raw, pre-decomposition token instead lets it through here
|
|
154
|
-
// (no literal Mn present) while still catching a combining mark authored
|
|
155
|
-
// directly onto the token, which is category Mn in either form, decomposed
|
|
156
|
-
// or not.
|
|
157
|
-
function hasDisallowedCombiningMark(token) {
|
|
158
|
-
return /\p{Mn}/u.test(token)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// normalizeStatusToken folds diacritics and lower-cases the token for
|
|
162
|
-
// vocabulary comparison. Uses the \p{Mn} Unicode property escape (General
|
|
163
|
-
// Category "Mark, Nonspacing") instead of a hand-enumerated code point range,
|
|
164
|
-
// to match Go's runes.In(unicode.Mn) and Python's unicodedata.category(ch) \u2014
|
|
165
|
-
// all three fold every combining mark that survives to this point, which,
|
|
166
|
-
// after hasDisallowedCombiningMark has already run, only happens as a
|
|
167
|
-
// byproduct of NFD-decomposing an accented Latin letter (e.g. "Conclu\u00eddo"
|
|
168
|
-
// -> "Concluido"). Callers MUST have already rejected via
|
|
169
|
-
// hasDisallowedCombiningMark before calling this \u2014 see that function's
|
|
170
|
-
// doc comment for why.
|
|
171
|
-
function normalizeStatusToken(token) {
|
|
172
|
-
return token.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// statusIsComplete implements rule 3 by FIRST TOKEN, not substring (ADR
|
|
176
|
-
// decision 3, AC8/AC9/AC14). marker is the already-trimmed remainder of the
|
|
177
|
-
// "**Status:**" line. Splitting on /\s+/ treats U+00A0 (NBSP) as a separator \u2014
|
|
178
|
-
// matching the accepted "NBSP separator" case \u2014 while a zero-width character
|
|
179
|
-
// (U+200B, not matched by \s) stays glued to the token and safely causes
|
|
180
|
-
// rejection (a usability false-negative, not a security concern).
|
|
181
|
-
//
|
|
182
|
-
// This is the fix for vault/notes/adr-status-substring-livre-falso-positivo-2026-08-01.md:
|
|
183
|
-
// `marker.includes('checkmark')` would classify "**Status:** [pending] checkmark" as
|
|
184
|
-
// complete (reproduced live against 7.3.0 \u2014 ADR decision 8). First-token
|
|
185
|
-
// comparison rejects it because the first token is the pending glyph, not the marker.
|
|
186
|
-
//
|
|
187
|
-
// ADR decision 9: VS16 is stripped first (the one cosmetic exception), then
|
|
188
|
-
// any remaining combining mark on the raw token rejects outright \u2014 see
|
|
189
|
-
// hasDisallowedCombiningMark for why this must happen before NFD.
|
|
190
|
-
function statusIsComplete(marker) {
|
|
191
|
-
const trimmed = marker.trim()
|
|
192
|
-
if (trimmed.length === 0) return false
|
|
193
|
-
const first = stripVS16(trimmed.split(/\s+/)[0])
|
|
194
|
-
if (hasDisallowedCombiningMark(first)) return false
|
|
195
|
-
return STATUS_VOCABULARY.has(normalizeStatusToken(first))
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// splitRoadmapLines is the single boundary where the raw file content becomes
|
|
199
|
-
// the array every marker regex operates on. It normalizes CRLF line endings
|
|
200
|
-
// by stripping a trailing "\r" from each line produced by splitting on
|
|
201
|
-
// "\n" — every downstream marker (ML heading, "**Status:**", acceptance
|
|
202
|
-
// header, criterion lines, "**Gates da wave:**", the fence delimiter) then
|
|
203
|
-
// sees the same content it would see for an LF-only file. It does NOT handle
|
|
204
|
-
// a lone-CR (old-Mac-style) file: splitting on "\n" alone leaves such a file
|
|
205
|
-
// as one giant line, same as internal/commands/barrier.go's splitRoadmapLines.
|
|
206
|
-
// Python's universal-newlines read does handle lone CR — a known, accepted
|
|
207
|
-
// asymmetry; issue #216 (the defect motivating this fix) is CRLF specifically.
|
|
208
|
-
//
|
|
209
|
-
// Unlike Go and Python, this runtime DOES depend on this normalization: JS
|
|
210
|
-
// regex "." excludes "\r" (it is a LineTerminator per the ECMAScript spec,
|
|
211
|
-
// unlike Go's RE2 "."), so `/^\*\*Status:\*\*(.*)$/.exec("**Status:** done\r")`
|
|
212
|
-
// returns null — a CRLF roadmap with every ML fully completed was reported as
|
|
213
|
-
// "not complete (status: missing)" (mirrors internal/commands/barrier.go
|
|
214
|
-
// splitRoadmapLines and the universal-newline read in
|
|
215
|
-
// pypi/trackfw/commands/barrier.py).
|
|
216
|
-
//
|
|
217
|
-
// Only the trailing "\r" immediately before the split point is stripped —
|
|
218
|
-
// this must never be confused with per-line indentation trimming, which
|
|
219
|
-
// ML-1B deliberately removed: leading whitespace on a marker line still fails
|
|
220
|
-
// to match (markers are anchored at column 0, untouched by this function).
|
|
221
|
-
function splitRoadmapLines(content) {
|
|
222
|
-
return content.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// detectFenceMarker inspects a whitespace-trimmed line and reports whether it
|
|
226
|
-
// opens or closes a CommonMark-style fence: a run of 3+ identical backtick
|
|
227
|
-
// (`) or tilde (~) characters at the start of the line. Returns
|
|
228
|
-
// { char, length } or null. ADR decision (ML-1B, achado 1): CommonMark
|
|
229
|
-
// defines a fence as 3+ of the SAME character (backtick or tilde), closed by
|
|
230
|
-
// a run of the same character with length >= the opening run — masking only
|
|
231
|
-
// "```" left both "~~~" fences and 4+-backtick fences (whose interior can
|
|
232
|
-
// nest a 3-backtick block) unmasked, which is the escape route a hostile
|
|
233
|
-
// roadmap would use.
|
|
234
|
-
function detectFenceMarker(trimmed) {
|
|
235
|
-
if (trimmed.length === 0) return null
|
|
236
|
-
const first = trimmed[0]
|
|
237
|
-
if (first !== '`' && first !== '~') return null
|
|
238
|
-
let i = 0
|
|
239
|
-
while (i < trimmed.length && trimmed[i] === first) i++
|
|
240
|
-
if (i < 3) return null
|
|
241
|
-
return { char: first, length: i }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// computeFenceMask returns, for each line index, whether that line lies
|
|
245
|
-
// strictly inside a fenced code block (``` ... ``` or ~~~ ... ~~~, per
|
|
246
|
-
// CommonMark: 3+ of the same fence character, closed by a run of the same
|
|
247
|
-
// character with length >= the opening run's length). A line that is itself
|
|
248
|
-
// a fence delimiter is never reported as "inside" — only the lines between an
|
|
249
|
-
// opening and a closing delimiter are masked. ADR decision 7 / AC13: ML
|
|
250
|
-
// detection, status and acceptance-header parsing must ignore documentation/
|
|
251
|
-
// examples inside a cerca — otherwise a roadmap that cites those literals (as
|
|
252
|
-
// this very roadmap, its REQ and its ADR do, repeatedly) is read as real ML
|
|
253
|
-
// content. parseGates already has its own, independent fence-matching for the
|
|
254
|
-
// "```bash ... ```" gates block and is untouched by this mask.
|
|
255
|
-
function computeFenceMask(lines) {
|
|
256
|
-
const mask = new Array(lines.length).fill(false)
|
|
257
|
-
let fenced = false
|
|
258
|
-
let fenceChar = null
|
|
259
|
-
let fenceLen = 0
|
|
260
|
-
for (let i = 0; i < lines.length; i++) {
|
|
261
|
-
const trimmed = lines[i].trim()
|
|
262
|
-
const marker = detectFenceMarker(trimmed)
|
|
263
|
-
if (!fenced) {
|
|
264
|
-
if (marker) {
|
|
265
|
-
fenced = true
|
|
266
|
-
fenceChar = marker.char
|
|
267
|
-
fenceLen = marker.length
|
|
268
|
-
continue
|
|
269
|
-
}
|
|
270
|
-
continue
|
|
271
|
-
}
|
|
272
|
-
// Currently inside a fence: only a marker of the SAME character with
|
|
273
|
-
// length >= the opening run closes it (a nested shorter/different
|
|
274
|
-
// marker stays masked as interior content) — AND, per CommonMark, a
|
|
275
|
-
// closing fence line contains NOTHING besides the fence run and
|
|
276
|
-
// (already-trimmed) surrounding whitespace: marker.length === trimmed.length.
|
|
277
|
-
// Fix for hades-tf security review (2026-08-29, achado #1 /
|
|
278
|
-
// vault/notes/barrier-fence-closing-trailing-content-bypass-2026-08-29.md):
|
|
279
|
-
// a line like "```trailing-junk" found INSIDE an open fence does not
|
|
280
|
-
// close it in real CommonMark — it stays interior content — but the
|
|
281
|
-
// prior check treated any run of length >= fenceLen as a valid close
|
|
282
|
-
// regardless of trailing text. The opening branch above is unchanged:
|
|
283
|
-
// CommonMark allows an info string after the opening run (` ```bash `).
|
|
284
|
-
if (marker && marker.char === fenceChar && marker.length >= fenceLen && marker.length === trimmed.length) {
|
|
285
|
-
fenced = false
|
|
286
|
-
continue
|
|
287
|
-
}
|
|
288
|
-
mask[i] = true
|
|
289
|
-
}
|
|
290
|
-
return mask
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// findMLs locates every `### ML-` heading inside [startLine, endLine) and returns
|
|
294
|
-
// { id, lines, fenced } for each, where `lines` is the ML body (heading excluded)
|
|
295
|
-
// and `fenced` is the fence mask aligned to `lines`. fenceMask defaults to "no
|
|
296
|
-
// fences" so existing call sites that omit it keep working (ML-1A, AC13).
|
|
297
|
-
function findMLs(lines, startLine, endLine, fenceMask = computeFenceMask(lines)) {
|
|
298
|
-
const headings = []
|
|
299
|
-
for (let i = startLine; i < endLine; i++) {
|
|
300
|
-
if (fenceMask[i]) continue
|
|
301
|
-
if (/^### ML-/.test(lines[i])) {
|
|
302
|
-
const m = /^### (\S+)/.exec(lines[i])
|
|
303
|
-
headings.push({ id: m[1], headingLine: i })
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return headings.map((h, idx) => {
|
|
307
|
-
let end = endLine
|
|
308
|
-
for (let j = h.headingLine + 1; j < endLine; j++) {
|
|
309
|
-
if (fenceMask[j]) continue
|
|
310
|
-
if (/^### /.test(lines[j]) || /^## /.test(lines[j])) { end = j; break }
|
|
311
|
-
}
|
|
312
|
-
return {
|
|
313
|
-
id: h.id,
|
|
314
|
-
lines: lines.slice(h.headingLine + 1, end),
|
|
315
|
-
fenced: fenceMask.slice(h.headingLine + 1, end),
|
|
316
|
-
}
|
|
317
|
-
})
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// mlCompletionStatus applies rule 3 — completion is by FIRST TOKEN (ADR
|
|
321
|
-
// decision 3), not substring. Returns { complete, marker } where marker is
|
|
322
|
-
// "missing" when no `**Status:**` line exists at all. Lines inside a fenced
|
|
323
|
-
// code block are ignored (ADR decision 7, AC13-a) — `fenced` is aligned to
|
|
324
|
-
// `mlLines`. Matched against the RAW line (no per-line .trim()) — an indented
|
|
325
|
-
// marker does not count (ML-1B, achado 2): Go and Python already require
|
|
326
|
-
// column 0 via `^` against the untrimmed line, and Node's prior .trim() made
|
|
327
|
-
// it the only runtime that released a wave on indented markers.
|
|
328
|
-
//
|
|
329
|
-
// Default omitted on purpose (hades-tf security review, 2026-08-29, achado
|
|
330
|
-
// #3 / hefesto-tf): a `fenced = []` default left every `fenced[i]` as
|
|
331
|
-
// `undefined` when the caller forgot the argument — indistinguishable from
|
|
332
|
-
// "nothing is fenced", silently turning OFF the fence-masking protection this
|
|
333
|
-
// very function exists to enforce. Production call sites always pass
|
|
334
|
-
// `ml.fenced` (never exploitable today), but a completion gate must fail
|
|
335
|
-
// CLOSED on a missing argument, not open. The default below marks every line
|
|
336
|
-
// as fenced (masked) instead, so an accidentally omitted argument yields
|
|
337
|
-
// "missing"/not-complete rather than "everything counts as real content".
|
|
338
|
-
function mlCompletionStatus(mlLines, fenced = mlLines.map(() => true)) {
|
|
339
|
-
for (let i = 0; i < mlLines.length; i++) {
|
|
340
|
-
if (fenced[i]) continue
|
|
341
|
-
const m = /^\*\*Status:\*\*(.*)$/.exec(mlLines[i])
|
|
342
|
-
if (m) {
|
|
343
|
-
const marker = m[1].trim()
|
|
344
|
-
return { complete: statusIsComplete(marker), marker: marker.length > 0 ? marker : 'missing' }
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
return { complete: false, marker: 'missing' }
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// mlAcceptanceEvidence applies rule 4. Returns { hasBlock, total, unmet }.
|
|
351
|
-
// Lines inside a fenced code block are ignored throughout — for the header
|
|
352
|
-
// search, for the "**" block-end boundary, and for counting criterion lines —
|
|
353
|
-
// otherwise a cerca citing the acceptance header/`- [x]` as an example would
|
|
354
|
-
// forge acceptance evidence (ADR decision 7, AC13-a). All markers are matched
|
|
355
|
-
// against the RAW line (no per-line .trim()) — an indented marker does not
|
|
356
|
-
// count (ML-1B, achado 2): Go and Python already require column 0 via `^`
|
|
357
|
-
// against the untrimmed line, and Node's prior .trim() made it the only
|
|
358
|
-
// runtime that released a wave on indented markers.
|
|
359
|
-
//
|
|
360
|
-
// Default omitted on purpose — same rationale as mlCompletionStatus above
|
|
361
|
-
// (hades-tf security review, 2026-08-29, achado #3 / hefesto-tf): fail
|
|
362
|
-
// CLOSED (every line treated as fenced/masked, so `hasBlock: false` and no
|
|
363
|
-
// forged criteria are counted) if the caller omits `fenced`, instead of
|
|
364
|
-
// silently disabling the fence mask.
|
|
365
|
-
function mlAcceptanceEvidence(mlLines, fenced = mlLines.map(() => true)) {
|
|
366
|
-
let blockStart = -1
|
|
367
|
-
for (let i = 0; i < mlLines.length; i++) {
|
|
368
|
-
if (fenced[i]) continue
|
|
369
|
-
if (CRITERIA_HEADER_RE.test(mlLines[i])) { blockStart = i; break }
|
|
370
|
-
}
|
|
371
|
-
if (blockStart === -1) return { hasBlock: false, total: 0, unmet: 0 }
|
|
372
|
-
|
|
373
|
-
let blockEnd = mlLines.length
|
|
374
|
-
for (let j = blockStart + 1; j < mlLines.length; j++) {
|
|
375
|
-
if (fenced[j]) continue
|
|
376
|
-
if (/^\*\*/.test(mlLines[j])) { blockEnd = j; break }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const criteria = []
|
|
380
|
-
for (let i = blockStart + 1; i < blockEnd; i++) {
|
|
381
|
-
if (fenced[i]) continue
|
|
382
|
-
const l = mlLines[i]
|
|
383
|
-
if (/^- \[.\]/.test(l)) criteria.push(l)
|
|
384
|
-
}
|
|
385
|
-
if (criteria.length === 0) return { hasBlock: false, total: 0, unmet: 0 }
|
|
386
|
-
|
|
387
|
-
const unmet = criteria.filter(l => /^- \[ \]/.test(l))
|
|
388
|
-
return { hasBlock: true, total: criteria.length, unmet: unmet.length }
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// GATES_HEADER_RE mirrors Go's gatesHeaderRe / Python's _GATES_HEADER_RE: a
|
|
392
|
-
// PREFIX match at column 0, not full-line equality — a header followed by
|
|
393
|
-
// trailing prose/whitespace on the same line ("**Gates da wave:** (obrigatórios)")
|
|
394
|
-
// or a CRLF-terminated line ("**Gates da wave:**\r") must still be recognised,
|
|
395
|
-
// exactly as Go/Python already do.
|
|
396
|
-
const GATES_HEADER_RE = /^\*\*Gates da wave:\*\*/
|
|
397
|
-
|
|
398
|
-
// parseGates applies rule 5. Returns { commands }. Throws UsageError naming the
|
|
399
|
-
// offending line number for an unterminated fence. The header marker is
|
|
400
|
-
// matched against the RAW line (no per-line .trim()) — an indented
|
|
401
|
-
// '**Gates da wave:**' does not count (ML-1B, achado 2): Go and Python
|
|
402
|
-
// already require column 0 for this marker.
|
|
403
|
-
function parseGates(lines, startLine, endLine) {
|
|
404
|
-
let markerLine = -1
|
|
405
|
-
for (let i = startLine; i < endLine; i++) {
|
|
406
|
-
if (GATES_HEADER_RE.test(lines[i])) { markerLine = i; break }
|
|
407
|
-
}
|
|
408
|
-
if (markerLine === -1) return { commands: [] }
|
|
409
|
-
|
|
410
|
-
let i = markerLine + 1
|
|
411
|
-
while (i < endLine && lines[i].trim() === '') i++
|
|
412
|
-
if (i >= endLine || lines[i].trim() !== '```bash') {
|
|
413
|
-
throw new UsageError(`malformed gates block at line ${markerLine + 1}: expected a \`\`\`bash fence`)
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const fenceStart = i
|
|
417
|
-
i++
|
|
418
|
-
const commands = []
|
|
419
|
-
let closed = false
|
|
420
|
-
for (; i < endLine; i++) {
|
|
421
|
-
const trimmed = lines[i].trim()
|
|
422
|
-
if (trimmed === '```') { closed = true; break }
|
|
423
|
-
if (trimmed.length === 0 || trimmed.startsWith('#')) continue
|
|
424
|
-
commands.push(trimmed)
|
|
425
|
-
}
|
|
426
|
-
if (!closed) {
|
|
427
|
-
throw new UsageError(`unterminated gates fence starting at line ${fenceStart + 1}`)
|
|
428
|
-
}
|
|
429
|
-
return { commands }
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
433
|
-
// Checks (fixed evaluation order: mls_complete, acceptance_evidence, gates, validate)
|
|
434
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
435
|
-
|
|
436
|
-
function evalMlsComplete(mls, waveLabel) {
|
|
437
|
-
const evidence = []
|
|
438
|
-
const failures = []
|
|
439
|
-
for (const ml of mls) {
|
|
440
|
-
const { complete, marker } = mlCompletionStatus(ml.lines, ml.fenced)
|
|
441
|
-
if (complete) evidence.push(`${ml.id}: ✅`)
|
|
442
|
-
else failures.push(`${ml.id}: not complete (status: ${marker})`)
|
|
443
|
-
}
|
|
444
|
-
const status = mls.length > 0 && failures.length === 0 ? 'passed' : 'blocked'
|
|
445
|
-
// Pinned literally by docs/cli-parity.md ("Wave contains zero MLs" case).
|
|
446
|
-
if (mls.length === 0) failures.push(`wave ${waveLabel}: no ML found`)
|
|
447
|
-
return { name: 'mls_complete', status, evidence, failures }
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function evalAcceptanceEvidence(mls) {
|
|
451
|
-
const evidence = []
|
|
452
|
-
const failures = []
|
|
453
|
-
for (const ml of mls) {
|
|
454
|
-
const ev = mlAcceptanceEvidence(ml.lines, ml.fenced)
|
|
455
|
-
if (!ev.hasBlock) {
|
|
456
|
-
failures.push(`${ml.id}: no acceptance block`)
|
|
457
|
-
} else if (ev.unmet > 0) {
|
|
458
|
-
failures.push(`${ml.id}: ${ev.unmet} unmet acceptance criteria`)
|
|
459
|
-
} else {
|
|
460
|
-
evidence.push(`${ml.id}: ${ev.total} criteria met`)
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
const status = failures.length === 0 ? 'passed' : 'blocked'
|
|
464
|
-
return { name: 'acceptance_evidence', status, evidence, failures }
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
468
|
-
// Roadmap trust check (AC11, AC12 — docs/cli-parity.md § Trust and --trust-local-gates)
|
|
469
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
470
|
-
|
|
471
|
-
// roadmapTrustForGates determines whether the gates declared in a roadmap can
|
|
472
|
-
// be trusted for execution without --trust-local-gates.
|
|
473
|
-
//
|
|
474
|
-
// Posture (AC1): CLOSED by default. Gates execute only when the function can
|
|
475
|
-
// PROVE that the roadmap is present in refs/remotes/origin/main byte-for-byte.
|
|
476
|
-
// Absence of proof is absence of trust — every error path returns
|
|
477
|
-
// { trusted: false } with a named failureMsg (AC2). Exactly one
|
|
478
|
-
// { trusted: true } return, at the very end, after all proofs succeed.
|
|
479
|
-
//
|
|
480
|
-
// AC3: the discriminant never parses git stderr. Steps 4 and 5 use
|
|
481
|
-
// "git rev-parse --verify" and "git cat-file -e" whose exit codes answer
|
|
482
|
-
// the questions directly. The fully-qualified ref refs/remotes/origin/main
|
|
483
|
-
// is used throughout to prevent a local branch named "origin/main" from
|
|
484
|
-
// satisfying the trust anchor.
|
|
485
|
-
//
|
|
486
|
-
// Residual (declared in docs/cli-parity.md): Windows users with
|
|
487
|
-
// core.autocrlf=true may receive "content differs" for an otherwise-identical
|
|
488
|
-
// roadmap (LF vs CRLF). The check fails closed, so the residual is safe.
|
|
489
|
-
// roadmapTrustForGates determines whether the gates declared in a roadmap can
|
|
490
|
-
// be trusted for execution without --trust-local-gates.
|
|
491
|
-
//
|
|
492
|
-
// localContentBuf (Buffer) is the content already read by the caller — the same
|
|
493
|
-
// bytes used to parse gate commands. Comparing it here ensures the proof covers
|
|
494
|
-
// exactly what executes (F1 invariant: what is proved = what executes).
|
|
495
|
-
// The only remaining route from unverified bytes to sh -c is --trust-local-gates,
|
|
496
|
-
// which requires explicit operator consent.
|
|
497
|
-
function roadmapTrustForGates(roadmapPath, localContentBuf) {
|
|
498
|
-
const roadmapDir = path.dirname(roadmapPath)
|
|
499
|
-
|
|
500
|
-
// Step 1: check if we are inside a git repository.
|
|
501
|
-
// Two distinct failure reasons are separated here (F5):
|
|
502
|
-
// revParse.error → git binary not found in PATH (spawn failure)
|
|
503
|
-
// revParse.status → git ran but not inside a git repository
|
|
504
|
-
const revParse = spawnSync('git', ['rev-parse', '--git-dir'], {
|
|
505
|
-
cwd: roadmapDir,
|
|
506
|
-
encoding: 'utf8',
|
|
507
|
-
stdio: 'pipe',
|
|
508
|
-
})
|
|
509
|
-
if (revParse.error) {
|
|
510
|
-
// Spawn failure: git binary is not installed or not in PATH.
|
|
511
|
-
return {
|
|
512
|
-
trusted: false,
|
|
513
|
-
failureMsg: 'gates not evaluated: git not found in PATH — install git to evaluate local gates',
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
if (revParse.status !== 0) {
|
|
517
|
-
return {
|
|
518
|
-
trusted: false,
|
|
519
|
-
failureMsg: 'gates not evaluated: not a git repository — pass --trust-local-gates to evaluate local gates',
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// Step 2: get the repository toplevel.
|
|
524
|
-
const topResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
525
|
-
cwd: roadmapDir,
|
|
526
|
-
encoding: 'utf8',
|
|
527
|
-
stdio: 'pipe',
|
|
528
|
-
})
|
|
529
|
-
if (topResult.status !== 0 || topResult.error) {
|
|
530
|
-
return {
|
|
531
|
-
trusted: false,
|
|
532
|
-
failureMsg: 'gates not evaluated: cannot resolve git repository root — pass --trust-local-gates to evaluate local gates',
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
const repoRoot = topResult.stdout.trim()
|
|
536
|
-
|
|
537
|
-
// Step 3: compute repo-relative path (always forward slashes for git).
|
|
538
|
-
const absRoadmap = path.resolve(roadmapPath)
|
|
539
|
-
const relPath = path.relative(repoRoot, absRoadmap).split(path.sep).join('/')
|
|
540
|
-
|
|
541
|
-
// Step 4: verify that refs/remotes/origin/main resolves to a commit (AC3 —
|
|
542
|
-
// exit code only, no stderr parsing). Failure means: no remote named origin,
|
|
543
|
-
// origin/main never fetched, or wrong default branch name. All are untrusted.
|
|
544
|
-
const verify = spawnSync(
|
|
545
|
-
'git', ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
|
546
|
-
{ cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
547
|
-
)
|
|
548
|
-
if (verify.status !== 0 || verify.error) {
|
|
549
|
-
return {
|
|
550
|
-
trusted: false,
|
|
551
|
-
failureMsg: 'gates not evaluated: origin/main ref not available — pass --trust-local-gates to evaluate local gates',
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Step 5: check whether the roadmap path exists in refs/remotes/origin/main
|
|
556
|
-
// (AC3 — "git cat-file -e" exits non-zero when the object does not exist).
|
|
557
|
-
const refPath = `refs/remotes/origin/main:${relPath}`
|
|
558
|
-
const catFile = spawnSync('git', ['cat-file', '-e', refPath], {
|
|
559
|
-
cwd: repoRoot,
|
|
560
|
-
encoding: 'utf8',
|
|
561
|
-
stdio: 'pipe',
|
|
562
|
-
})
|
|
563
|
-
if (catFile.status !== 0 || catFile.error) {
|
|
564
|
-
return {
|
|
565
|
-
trusted: false,
|
|
566
|
-
failureMsg: 'gates not evaluated: roadmap is not committed in origin/main — pass --trust-local-gates to evaluate local gates',
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Step 6: retrieve the file content from refs/remotes/origin/main.
|
|
571
|
-
// No encoding option → stdout is a Buffer for true byte-for-byte comparison (F4).
|
|
572
|
-
const show = spawnSync('git', ['show', refPath], {
|
|
573
|
-
cwd: repoRoot,
|
|
574
|
-
stdio: 'pipe',
|
|
575
|
-
})
|
|
576
|
-
if (show.status !== 0 || show.error) {
|
|
577
|
-
// The cat-file check already confirmed the object exists; this is an
|
|
578
|
-
// unexpected failure (e.g. transient I/O). Still fail closed.
|
|
579
|
-
return {
|
|
580
|
-
trusted: false,
|
|
581
|
-
failureMsg: 'gates not evaluated: cannot read roadmap from origin/main — pass --trust-local-gates to evaluate local gates',
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// Step 7: compare content byte-for-byte.
|
|
586
|
-
// localContentBuf (the parameter) is the Buffer the caller already read — it
|
|
587
|
-
// is the same slice used to parse gate commands. No second read is performed
|
|
588
|
-
// here; the proof covers exactly what executes (F1 invariant).
|
|
589
|
-
// show.stdout is also a Buffer (no encoding option above) — true binary comparison (F4).
|
|
590
|
-
//
|
|
591
|
-
// Step 5 triple note (F5 — declared unseparable): git cat-file -e exits non-zero
|
|
592
|
-
// for (a) roadmap absent from origin/main, (b) relPath wrong (e.g. macOS symlink,
|
|
593
|
-
// mitigated by WORK_PHYS in check-barrier.sh), (c) unexpected I/O error. All are
|
|
594
|
-
// equally fail-closed; separating them without additional git invocations would
|
|
595
|
-
// expand the attack surface. Declared unseparable by design.
|
|
596
|
-
if (!localContentBuf.equals(show.stdout)) {
|
|
597
|
-
return {
|
|
598
|
-
trusted: false,
|
|
599
|
-
failureMsg: 'gates not evaluated: roadmap content differs from origin/main — pass --trust-local-gates to evaluate local gates',
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// Proven: roadmap is present in refs/remotes/origin/main and byte-identical.
|
|
604
|
-
// The buffer compared is the same one parsed for gate commands.
|
|
605
|
-
return { trusted: true }
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// SH_MISSING_MSG is the pinned failure string for a `gates` check that could not
|
|
609
|
-
// be evaluated because `sh` is not on $PATH (AC3, AC4). All three runtimes (Go,
|
|
610
|
-
// Node, Python) must emit this byte-for-byte — see docs/cli-parity.md
|
|
611
|
-
// "Pinned failure strings for not_evaluated".
|
|
612
|
-
const SH_MISSING_MSG =
|
|
613
|
-
'gates not evaluated: sh not found in PATH — install a POSIX shell (e.g. Git Bash, WSL) to evaluate gates'
|
|
614
|
-
|
|
615
|
-
function evalGates(commands, cwd, trustResult = { trusted: false }) {
|
|
616
|
-
// trustResult: { trusted: true } | { trusted: false, failureMsg: string }
|
|
617
|
-
// Default is { trusted: false } — fail-closed (F2). Every call site must pass
|
|
618
|
-
// an explicit trustResult; omitting it produces not_evaluated, not gate execution.
|
|
619
|
-
if (!trustResult.trusted) {
|
|
620
|
-
// Roadmap is not trusted: do not execute gates (AC3, AC14).
|
|
621
|
-
// Report as not_evaluated — distinct from passed and blocked (AC6).
|
|
622
|
-
return {
|
|
623
|
-
name: 'gates',
|
|
624
|
-
status: 'not_evaluated',
|
|
625
|
-
commands,
|
|
626
|
-
evidence: [],
|
|
627
|
-
failures: [trustResult.failureMsg],
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
const evidence = []
|
|
631
|
-
const failures = []
|
|
632
|
-
for (const command of commands) {
|
|
633
|
-
// `sh` is invoked explicitly, as argv[0], and resolved through $PATH — NOT
|
|
634
|
-
// spawnSync's `shell: true`, which is pinned to a fixed /bin/sh. This is the
|
|
635
|
-
// same $PATH resolution Go has always used via exec.LookPath, and it is
|
|
636
|
-
// required for Windows: the Git Bash `sh.exe` is never at /bin/sh and is
|
|
637
|
-
// only reachable via $PATH.
|
|
638
|
-
const result = spawnSync('sh', ['-c', command], {
|
|
639
|
-
cwd,
|
|
640
|
-
encoding: 'utf8',
|
|
641
|
-
stdio: 'pipe',
|
|
642
|
-
})
|
|
643
|
-
if (result.error) {
|
|
644
|
-
// The process never started at all (e.g. `sh` missing from $PATH) —
|
|
645
|
-
// distinct from the gate command itself failing inside a running `sh`,
|
|
646
|
-
// which surfaces as a normal exit code (e.g. 127 for "tool not found")
|
|
647
|
-
// and never sets result.error. "Could not measure" is not "measured and
|
|
648
|
-
// failed" (AC3, AC4) — stop immediately: gates after this one were never
|
|
649
|
-
// observed, so they must not appear in evidence or failures.
|
|
650
|
-
return {
|
|
651
|
-
name: 'gates',
|
|
652
|
-
status: 'not_evaluated',
|
|
653
|
-
commands,
|
|
654
|
-
evidence: [],
|
|
655
|
-
failures: [SH_MISSING_MSG],
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
const code = result.status === null || result.status === undefined ? 1 : result.status
|
|
659
|
-
if (code === 0) evidence.push(`${command}: exit 0`)
|
|
660
|
-
else failures.push(`${command}: exit ${code}`)
|
|
661
|
-
}
|
|
662
|
-
const status = failures.length === 0 ? 'passed' : 'blocked'
|
|
663
|
-
return { name: 'gates', status, commands, evidence, failures }
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
async function evalValidate() {
|
|
667
|
-
const { violations, warnings } = await validator.validate()
|
|
668
|
-
const summary = `${violations.length} violations, ${warnings.length} warnings`
|
|
669
|
-
if (violations.length === 0) {
|
|
670
|
-
return { name: 'validate', status: 'passed', evidence: [summary], failures: [] }
|
|
671
|
-
}
|
|
672
|
-
return { name: 'validate', status: 'blocked', evidence: [], failures: [summary] }
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
676
|
-
// Result document
|
|
677
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
678
|
-
|
|
679
|
-
function isoSeconds(date) {
|
|
680
|
-
return date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function buildDoc(roadmapBasename, waveNumber, checks, startedAt, finishedAt) {
|
|
684
|
-
const status = checks.every(c => c.status === 'passed') ? 'passed' : 'blocked'
|
|
685
|
-
const failures = []
|
|
686
|
-
for (const check of checks) {
|
|
687
|
-
for (const f of check.failures) failures.push(`${check.name}: ${f}`)
|
|
688
|
-
}
|
|
689
|
-
return {
|
|
690
|
-
roadmap: roadmapBasename,
|
|
691
|
-
wave: waveNumber,
|
|
692
|
-
status,
|
|
693
|
-
started_at: isoSeconds(startedAt),
|
|
694
|
-
finished_at: isoSeconds(finishedAt),
|
|
695
|
-
checks: checks.map(c => {
|
|
696
|
-
const doc = { name: c.name, status: c.status, evidence: c.evidence, failures: c.failures }
|
|
697
|
-
if (c.name === 'gates') {
|
|
698
|
-
return { name: c.name, status: c.status, commands: c.commands, evidence: c.evidence, failures: c.failures }
|
|
699
|
-
}
|
|
700
|
-
return doc
|
|
701
|
-
}),
|
|
702
|
-
failures,
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function printTextReport(doc) {
|
|
707
|
-
console.log(`Roadmap: ${doc.roadmap}`)
|
|
708
|
-
console.log(`Wave: ${doc.wave}`)
|
|
709
|
-
console.log(`Status: ${doc.status}`)
|
|
710
|
-
console.log('')
|
|
711
|
-
for (const check of doc.checks) {
|
|
712
|
-
console.log(`[${check.status}] ${check.name}`)
|
|
713
|
-
for (const e of check.evidence) console.log(` ✓ ${e}`)
|
|
714
|
-
for (const f of check.failures) console.log(` ✗ ${f}`)
|
|
715
|
-
}
|
|
716
|
-
if (doc.failures.length > 0) {
|
|
717
|
-
console.log('')
|
|
718
|
-
console.log('Failures:')
|
|
719
|
-
for (const f of doc.failures) console.log(` • ${f}`)
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
724
|
-
// Command
|
|
725
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
726
|
-
|
|
727
|
-
async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
|
|
728
|
-
if (waveOption === undefined || waveOption === null || String(waveOption).trim() === '') {
|
|
729
|
-
throw new UsageError('--wave is required')
|
|
730
|
-
}
|
|
731
|
-
const waveLabel = String(waveOption).trim()
|
|
732
|
-
if (!isValidWaveLabel(waveLabel)) {
|
|
733
|
-
throw new UsageError(`invalid --wave "${waveOption}" — not a valid wave label`)
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const cfg = config.load()
|
|
737
|
-
const resolved = resolveRoadmapFile(cfg, roadmapArg)
|
|
738
|
-
// Read as Buffer (no encoding) so the same bytes can be passed to roadmapTrustForGates
|
|
739
|
-
// for the byte-for-byte comparison (F1 invariant: what is proved = what executes).
|
|
740
|
-
const contentBuf = fs.readFileSync(resolved.path)
|
|
741
|
-
const content = contentBuf.toString('utf8')
|
|
742
|
-
const lines = splitRoadmapLines(content)
|
|
743
|
-
|
|
744
|
-
const startedAt = new Date()
|
|
745
|
-
const wave = findWave(lines, waveLabel, resolved.basename)
|
|
746
|
-
const mls = findMLs(lines, wave.startLine, wave.endLine)
|
|
747
|
-
const gates = parseGates(lines, wave.startLine, wave.endLine)
|
|
748
|
-
|
|
749
|
-
// Determine trust for gate execution (AC11, AC12).
|
|
750
|
-
// Pass contentBuf so the trust check compares the same buffer parsed for gates.
|
|
751
|
-
const trustResult = trustLocalGates
|
|
752
|
-
? { trusted: true }
|
|
753
|
-
: roadmapTrustForGates(resolved.path, contentBuf)
|
|
754
|
-
|
|
755
|
-
const checks = []
|
|
756
|
-
checks.push(evalMlsComplete(mls, waveLabel))
|
|
757
|
-
checks.push(evalAcceptanceEvidence(mls))
|
|
758
|
-
checks.push(evalGates(gates.commands, process.cwd(), trustResult))
|
|
759
|
-
checks.push(await evalValidate())
|
|
760
|
-
const finishedAt = new Date()
|
|
761
|
-
|
|
762
|
-
const doc = buildDoc(resolved.basename, waveLabel, checks, startedAt, finishedAt)
|
|
763
|
-
|
|
764
|
-
if (jsonOutput) {
|
|
765
|
-
console.log(JSON.stringify(doc, null, 2))
|
|
766
|
-
} else {
|
|
767
|
-
printTextReport(doc)
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
return doc.status === 'passed' ? 0 : 1
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
function createBarrierCommand() {
|
|
774
|
-
const cmd = new Command('barrier')
|
|
775
|
-
cmd
|
|
776
|
-
.description(
|
|
777
|
-
'Deterministic core of the wave-release barrier: evaluates ML completion, ' +
|
|
778
|
-
'acceptance evidence, wave gates and governance validation for a single wave ' +
|
|
779
|
-
'declared in a roadmap.'
|
|
780
|
-
)
|
|
781
|
-
.argument('<roadmap>', 'Roadmap basename, with or without .md')
|
|
782
|
-
.option('--wave <label>', 'Wave label to evaluate (e.g. 1, 2-bis, 2-hotfix)')
|
|
783
|
-
.option('--json', 'Emit the result document as JSON instead of a text report')
|
|
784
|
-
.option('--trust-local-gates', 'Trust the local roadmap content for gate execution without comparing to origin/main (used by the /trackfw:barrier slash command for WIP roadmaps)')
|
|
785
|
-
.action(async (roadmapArg, options) => {
|
|
786
|
-
try {
|
|
787
|
-
const exitCode = await runBarrier(roadmapArg, options.wave, !!options.json, !!options.trustLocalGates)
|
|
788
|
-
process.exit(exitCode)
|
|
789
|
-
} catch (err) {
|
|
790
|
-
if (err instanceof UsageError) {
|
|
791
|
-
process.stderr.write(`trackfw barrier: ${err.message}\n`)
|
|
792
|
-
process.exit(2)
|
|
793
|
-
return
|
|
794
|
-
}
|
|
795
|
-
process.stderr.write(`trackfw barrier: unexpected error: ${err && err.message ? err.message : err}\n`)
|
|
796
|
-
process.exit(2)
|
|
797
|
-
}
|
|
798
|
-
})
|
|
799
|
-
|
|
800
|
-
return cmd
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
module.exports = createBarrierCommand()
|
|
804
|
-
module.exports.UsageError = UsageError
|
|
805
|
-
module.exports.isValidWaveLabel = isValidWaveLabel
|
|
806
|
-
module.exports.resolveRoadmapFile = resolveRoadmapFile
|
|
807
|
-
module.exports.findWave = findWave
|
|
808
|
-
module.exports.findMLs = findMLs
|
|
809
|
-
module.exports.mlCompletionStatus = mlCompletionStatus
|
|
810
|
-
module.exports.mlAcceptanceEvidence = mlAcceptanceEvidence
|
|
811
|
-
module.exports.parseGates = parseGates
|
|
812
|
-
module.exports.statusIsComplete = statusIsComplete
|
|
813
|
-
module.exports.computeFenceMask = computeFenceMask
|
|
814
|
-
module.exports.splitRoadmapLines = splitRoadmapLines
|
|
815
|
-
module.exports.CRITERIA_HEADER_RE = CRITERIA_HEADER_RE
|
|
816
|
-
module.exports.roadmapTrustForGates = roadmapTrustForGates
|
|
817
|
-
module.exports.evalMlsComplete = evalMlsComplete
|
|
818
|
-
module.exports.evalAcceptanceEvidence = evalAcceptanceEvidence
|
|
819
|
-
module.exports.evalGates = evalGates
|
|
820
|
-
module.exports.evalValidate = evalValidate
|
|
821
|
-
module.exports.buildDoc = buildDoc
|
|
822
|
-
module.exports.runBarrier = runBarrier
|