@tekyzinc/gsd-t 3.26.10 → 3.27.10
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 +64 -0
- package/README.md +2 -0
- package/bin/context-budget-audit.cjs +17 -2
- package/bin/gsd-t-build-coverage.cjs +438 -0
- package/bin/gsd-t-ci-parity.cjs +500 -0
- package/bin/gsd-t-economics.cjs +37 -9
- package/bin/gsd-t.js +21 -0
- package/bin/model-windows.cjs +99 -0
- package/bin/model-windows.test.cjs +75 -0
- package/bin/runway-estimator.cjs +35 -5
- package/bin/token-budget.cjs +12 -3
- package/commands/gsd-t-help.md +14 -0
- package/commands/gsd-t-milestone.md +21 -5
- package/commands/gsd-t-promote-debt.md +1 -1
- package/commands/gsd-t-scan.md +6 -6
- package/commands/gsd-t-verify.md +46 -0
- package/package.json +1 -1
- package/scripts/context-meter/transcript-parser.js +12 -2
- package/scripts/context-meter/transcript-parser.test.js +51 -4
- package/scripts/gsd-t-calibration-hook.js +8 -1
- package/scripts/gsd-t-context-meter.e2e.test.js +45 -6
- package/scripts/gsd-t-context-meter.js +17 -3
- package/scripts/gsd-t-context-meter.test.js +85 -0
- package/templates/CLAUDE-global.md +30 -0
- package/templates/stacks/design-to-code.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [3.27.10] - 2026-05-19
|
|
6
|
+
|
|
7
|
+
### Added — M57 CI-Parity Verify Gate
|
|
8
|
+
|
|
9
|
+
Origin: a TimeTracking v1.10.12 post-mortem — `gsd-t-verify` reported
|
|
10
|
+
VERIFIED, the milestone was tagged + pushed, but Cloud Build failed (a new
|
|
11
|
+
top-level `hooks/` dir was committed but never added to the Dockerfile
|
|
12
|
+
`COPY` directives, and `noImplicitAny` regressions passed a warm-cache
|
|
13
|
+
local `tsc` but failed CI's cold build). A cross-project survey found 7/18
|
|
14
|
+
registered projects have CI surfaces — systemic, not project-specific.
|
|
15
|
+
|
|
16
|
+
- **`gsd-t build-coverage`** (`bin/gsd-t-build-coverage.cjs`) — detects new
|
|
17
|
+
top-level paths in a milestone commit range not referenced by a real CI
|
|
18
|
+
build input. Coverage is decided by **structurally parsing** CI files
|
|
19
|
+
(Dockerfile COPY/ADD source args incl. relative `--from=`; cloudbuild
|
|
20
|
+
`args`-positional; workflow `run`-positional via a block-scalar-aware
|
|
21
|
+
YAML walker) — never substring-matching raw config text. `node_modules`
|
|
22
|
+
never counts.
|
|
23
|
+
- **`gsd-t ci-parity`** (`bin/gsd-t-ci-parity.cjs`) — reproduces the
|
|
24
|
+
project's actual CI build locally (auto-detect cloudbuild → workflows →
|
|
25
|
+
Dockerfile RUN → package scripts), clears build caches, auto-runs the
|
|
26
|
+
real `docker build` when a Dockerfile is present. `clearBuildCaches`
|
|
27
|
+
routes every config-derived delete through a containment predicate
|
|
28
|
+
(`resolved.startsWith(root+sep) && resolved!==root`) — refuses any path
|
|
29
|
+
resolving outside OR equal-to projectRoot (a Destructive Action Guard
|
|
30
|
+
requirement).
|
|
31
|
+
- Both wired into `gsd-t-verify` Step 2.6 as **FAIL-blocking** checks
|
|
32
|
+
(never warning-only); either failing blocks complete-milestone.
|
|
33
|
+
|
|
34
|
+
### Process note — re-plan after Red Team non-convergence
|
|
35
|
+
|
|
36
|
+
The first M57 design (substring-match build-coverage + unguarded
|
|
37
|
+
cache-clear) FAILED Red Team across **5 non-converging cycles** (BUG-4/6/
|
|
38
|
+
9/9b: each fix spawned a new false-negative variant; plus 5 CRITICAL
|
|
39
|
+
Destructive-Action-Guard violations in the cache-clearer). The autonomous
|
|
40
|
+
chain was halted (Prime Rule stop #2), the design re-planned, and rebuilt
|
|
41
|
+
in-session after the detached fan-out harness false-completed twice
|
|
42
|
+
(contracts flipped STABLE while the code was never rewritten). The
|
|
43
|
+
corrected structural design converged on the first attempt: all 7 frozen
|
|
44
|
+
falsification-corpus variants flagged, containment predicate holds, full
|
|
45
|
+
suite 2587 pass / 0 fail. Lessons captured in memory
|
|
46
|
+
(`feedback_coverage_check_structural_not_substring`,
|
|
47
|
+
`feedback_destructive_path_ops_containment`,
|
|
48
|
+
`feedback_detached_fanout_false_completion`). Pair-flagged with backlog
|
|
49
|
+
#25 (gsd-t-bench): the bench eval set must include a synthetic regression
|
|
50
|
+
of this incident when it ships.
|
|
51
|
+
|
|
52
|
+
## [3.26.11] - 2026-05-11
|
|
53
|
+
|
|
54
|
+
### Changed — Effort estimates in GSD-T-native units
|
|
55
|
+
|
|
56
|
+
Promoted `feedback_no_human_hour_estimates.md` from per-user memory to canonical global rule. Replaced all 7 `Estimated effort: {assessment}` placeholders that were silently producing developer-hour / dev-day / sprint output with explicit GSD-T-unit prompts.
|
|
57
|
+
|
|
58
|
+
- `~/.claude/CLAUDE.md` + `templates/CLAUDE-global.md`: new MANDATORY section "Effort Estimates — GSD-T-Native Units" with the unit table (domain count, wave count, parallel-domain count, spawn count, token-spend range, rate-limit-window count) + acceptable-machine-time-references carve-out (5 min cache TTL, 14 day staleness, etc.) so the rule doesn't break legitimate system-timeout language.
|
|
59
|
+
- `commands/gsd-t-milestone.md` Step 4: Pre-Partition Assessment now requires GSD-T units, presents the unit table inline.
|
|
60
|
+
- `commands/gsd-t-scan.md`: 5 `Estimated effort: {assessment}` placeholders replaced with `Estimated scope: {N domains}/{N waves}/$X-Y token-spend` + memory reference.
|
|
61
|
+
- `commands/gsd-t-promote-debt.md`: same swap.
|
|
62
|
+
- `commands/gsd-t-scan.md`: "Dependency Update Sprint" → "Dependency Update".
|
|
63
|
+
- `templates/stacks/design-to-code.md`: "estimate effort" → "scope in GSD-T units".
|
|
64
|
+
|
|
65
|
+
Rationale: human-hour estimates ("30-min task", "2-3 day window") create false mental models for GSD-T workflows where the worker is Claude, not a human team. Token-spend, parallel-domain count, and rate-limit-window count are the actually-predictive units.
|
|
66
|
+
|
|
67
|
+
Tests: 2547/2547 (unchanged — doc-only change).
|
|
68
|
+
|
|
5
69
|
## [3.26.10] - 2026-05-09
|
|
6
70
|
|
|
7
71
|
### Added — M56: Verify-Gate CLI Fan-Out + Upper-Stage Briefs
|
package/README.md
CHANGED
|
@@ -119,6 +119,8 @@ gsd-t brief --kind execute --domain X --spawn-id Y # ≤2,500-token JSON sn
|
|
|
119
119
|
gsd-t verify-gate --json # Two-track gate: D1 preflight + D2 parallel CLIs
|
|
120
120
|
gsd-t verify-gate --skip-track1 --json # Diagnostic: Track 2 only
|
|
121
121
|
gsd-t verify-gate --max-concurrency 4 --json # Override D3-map default
|
|
122
|
+
gsd-t build-coverage --json # M57: new top-level paths must be a real CI build input (structural parse)
|
|
123
|
+
gsd-t ci-parity --json # M57: reproduce the project's actual CI build locally (auto docker build)
|
|
122
124
|
```
|
|
123
125
|
|
|
124
126
|
`gsd-t parallel` consumes the M44 task-graph (D1) and applies three pre-spawn gates (D4 depgraph validation → D5 file-disjointness → D6 economics) followed by mode-aware headroom/split math. Extends — does not replace — the M40 orchestrator. Contract: `.gsd-t/contracts/wave-join-contract.md` v1.1.0.
|
|
@@ -9,15 +9,29 @@
|
|
|
9
9
|
// node bin/context-budget-audit.js --json # JSON output for tooling
|
|
10
10
|
// node bin/context-budget-audit.js --top 20 # top N largest files
|
|
11
11
|
// node bin/context-budget-audit.js --threshold 5000 # flag files above N tokens
|
|
12
|
+
// node bin/context-budget-audit.js --model claude-opus-4-7 # size % to a model's window (default: 1M)
|
|
12
13
|
|
|
13
14
|
const fs = require('fs');
|
|
14
15
|
const path = require('path');
|
|
15
16
|
const os = require('os');
|
|
16
17
|
|
|
18
|
+
const { windowForModel, SAFE_DEFAULT_WINDOW } = require('./model-windows.cjs');
|
|
19
|
+
|
|
17
20
|
// Token estimation: GPT/Claude tokenizers average ~4 chars/token for English+code.
|
|
18
21
|
// This is a fast deterministic estimate, not a true tokenizer call. Within ~10%.
|
|
19
22
|
const CHARS_PER_TOKEN = 4;
|
|
20
|
-
|
|
23
|
+
|
|
24
|
+
// Context window for the "% of window" math. This is a static-analysis CLI with
|
|
25
|
+
// no live transcript, so the model is resolved from --model / GSD_T_MODEL when
|
|
26
|
+
// provided, else the model-windows safe LARGE default (1M). A bare 200K literal
|
|
27
|
+
// (the old value) overstated every percentage 5× for Opus/Sonnet sessions.
|
|
28
|
+
let CONTEXT_WINDOW = (() => {
|
|
29
|
+
const fromEnv = typeof process.env.GSD_T_MODEL === 'string' ? process.env.GSD_T_MODEL : '';
|
|
30
|
+
const argIdx = process.argv.indexOf('--model');
|
|
31
|
+
const fromArg = argIdx !== -1 ? process.argv[argIdx + 1] : '';
|
|
32
|
+
const modelId = fromArg || fromEnv;
|
|
33
|
+
return modelId ? windowForModel(modelId) : SAFE_DEFAULT_WINDOW;
|
|
34
|
+
})();
|
|
21
35
|
|
|
22
36
|
function estimateTokens(bytes) {
|
|
23
37
|
return Math.round(bytes / CHARS_PER_TOKEN);
|
|
@@ -414,8 +428,9 @@ function main() {
|
|
|
414
428
|
else if (a === '--threshold') opts.threshold = parseInt(args[++i], 10);
|
|
415
429
|
else if (a === '--project') opts.projectDir = path.resolve(args[++i]);
|
|
416
430
|
else if (a === '--global') opts.globalDir = path.resolve(args[++i]);
|
|
431
|
+
else if (a === '--model') i++; // consumed at init for window sizing; skip value
|
|
417
432
|
else if (a === '--help' || a === '-h') {
|
|
418
|
-
console.log('Usage: node bin/context-budget-audit.js [--json] [--top N] [--threshold N]');
|
|
433
|
+
console.log('Usage: node bin/context-budget-audit.js [--json] [--top N] [--threshold N] [--model <claude-model-id>]');
|
|
419
434
|
process.exit(0);
|
|
420
435
|
}
|
|
421
436
|
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GSD-T build-coverage (M57 D1) — STRUCTURAL CI parsing.
|
|
6
|
+
*
|
|
7
|
+
* Detects new top-level paths added in a milestone commit range that no CI
|
|
8
|
+
* build artifact references — the TimeTracking v1.10.12 failure class (new
|
|
9
|
+
* `hooks/` dir committed, absent from Dockerfile COPY, shipped broken while
|
|
10
|
+
* local verify reported VERIFIED).
|
|
11
|
+
*
|
|
12
|
+
* DESIGN MANDATE (re-plan 2026-05-19, after the substring design failed Red
|
|
13
|
+
* Team across 5 non-converging cycles — BUG-4/6/9/9b):
|
|
14
|
+
* Coverage is decided by STRUCTURALLY PARSING the CI files — a path
|
|
15
|
+
* contributes coverage ONLY when it appears in a build-input position:
|
|
16
|
+
* - Dockerfile: a COPY/ADD source argument (incl. `--from=` SOURCE,
|
|
17
|
+
* relative AND absolute); NOT in RUN/CMD/comment/ENV.
|
|
18
|
+
* - cloudbuild.yaml: a value inside a `steps[].args` list; NOT in a
|
|
19
|
+
* `#` comment, a step `id:`/`name:`, or an `env:` block.
|
|
20
|
+
* - .github/workflows/*.yml: a token inside a `jobs.<job>.steps[].run`
|
|
21
|
+
* command or a `working-directory:` value; NOT in a step/job/workflow
|
|
22
|
+
* `name:` (plain, quoted, OR multi-line `|`/`>` block/folded scalar),
|
|
23
|
+
* NOT in a `#` comment.
|
|
24
|
+
* A path whose first segment is `node_modules` NEVER contributes coverage.
|
|
25
|
+
* There is NO code path that does `configText.includes(seg)` or regex-greps
|
|
26
|
+
* raw config text for the segment name. (See memory:
|
|
27
|
+
* feedback_coverage_check_structural_not_substring.md)
|
|
28
|
+
*
|
|
29
|
+
* Exports: checkBuildCoverage({ projectDir, baseRef, headRef })
|
|
30
|
+
* CLI: node bin/gsd-t-build-coverage.cjs [--json] [--base REF] [--head REF]
|
|
31
|
+
*
|
|
32
|
+
* Contract: .gsd-t/contracts/cli-build-coverage-contract.md v1.1.0 STABLE.
|
|
33
|
+
*
|
|
34
|
+
* Exit codes (CLI):
|
|
35
|
+
* 0 — ok:true (all new paths covered, OR no CI artifacts found)
|
|
36
|
+
* 4 — ok:false (≥1 new top-level path uncovered)
|
|
37
|
+
* 2 — usage error (bad refs, not a git repo, detached HEAD)
|
|
38
|
+
*
|
|
39
|
+
* Hard rules: zero external runtime deps (Node built-ins only); functions
|
|
40
|
+
* small; never throw out of checkBuildCoverage (usage errors surface as a
|
|
41
|
+
* thrown UsageError caught by the CLI entry).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const fs = require('fs');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
const { execSync } = require('child_process');
|
|
47
|
+
|
|
48
|
+
class UsageError extends Error {
|
|
49
|
+
constructor(message) { super(message); this.name = 'UsageError'; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Git helpers
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function gitDiffNames(projectDir, baseRef, headRef) {
|
|
57
|
+
const raw = execSync(`git diff --name-only ${baseRef}..${headRef}`, {
|
|
58
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
});
|
|
60
|
+
return raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveRefs(projectDir, baseRef, headRef) {
|
|
64
|
+
execSync('git rev-parse --git-dir', {
|
|
65
|
+
cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
66
|
+
});
|
|
67
|
+
const base = baseRef || 'HEAD~1';
|
|
68
|
+
const head = headRef || 'HEAD';
|
|
69
|
+
if (base === head) throw new UsageError(`baseRef and headRef are identical: ${base}`);
|
|
70
|
+
return { base, head };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** First path segment of a posix-ish path, or '' if none/`.`. */
|
|
74
|
+
function topSegment(p) {
|
|
75
|
+
const cleaned = String(p).replace(/^\.\//, '').replace(/^\/+/, '');
|
|
76
|
+
const seg = cleaned.split('/')[0];
|
|
77
|
+
return (!seg || seg === '.' || seg === '..') ? '' : seg;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function collapseToTopLevel(filePaths) {
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const p of filePaths) {
|
|
83
|
+
const seg = topSegment(p);
|
|
84
|
+
if (seg) seen.add(seg);
|
|
85
|
+
}
|
|
86
|
+
return Array.from(seen).sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Map a build-input path reference to the workspace top-level segment it
|
|
91
|
+
* covers, or '' if it covers nothing in the workspace.
|
|
92
|
+
* - absolute paths (`/app/dist`) reference the *image* fs, not the
|
|
93
|
+
* workspace → no workspace coverage.
|
|
94
|
+
* - `node_modules/...` never contributes coverage (BUG-7).
|
|
95
|
+
*/
|
|
96
|
+
function coverageSegment(ref) {
|
|
97
|
+
const r = String(ref).trim();
|
|
98
|
+
if (!r || r.startsWith('/')) return ''; // absolute = image fs
|
|
99
|
+
const seg = topSegment(r);
|
|
100
|
+
if (!seg || seg === 'node_modules') return ''; // BUG-7 hard rule
|
|
101
|
+
return seg;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Dockerfile — structural, line-oriented
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** Split a COPY/ADD argument string into tokens, dropping flags. */
|
|
109
|
+
function copyArgTokens(argStr) {
|
|
110
|
+
return argStr.trim().split(/\s+/).filter(t => t && !t.startsWith('--'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse Dockerfile structurally. Only COPY/ADD instructions contribute.
|
|
115
|
+
* Returns { coversAll, segments: Set }.
|
|
116
|
+
* - `COPY . .` / `ADD . .` → coversAll.
|
|
117
|
+
* - `COPY src/ ./src/` → source `src/` covers `src`.
|
|
118
|
+
* - `COPY --from=stage <src> <dest>` → `<src>` IS a build input; a
|
|
119
|
+
* relative src (`dist/`) covers `dist`; an absolute src (`/app/dist`)
|
|
120
|
+
* references the build-stage image fs → covers nothing in workspace.
|
|
121
|
+
* - flags (`--from=`, `--chown=`, `--chmod=`) are not path tokens.
|
|
122
|
+
* - a path in RUN/CMD/ENV/comment is NOT a build input.
|
|
123
|
+
*/
|
|
124
|
+
function parseDockerfile(filePath) {
|
|
125
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
126
|
+
const segments = new Set();
|
|
127
|
+
let coversAll = false;
|
|
128
|
+
for (const raw of lines) {
|
|
129
|
+
const line = raw.replace(/\t/g, ' ').trim();
|
|
130
|
+
if (!line || line.startsWith('#')) continue;
|
|
131
|
+
const m = line.match(/^(COPY|ADD)\s+(.+)$/i);
|
|
132
|
+
if (!m) continue; // RUN/CMD/ENV/FROM → ignore
|
|
133
|
+
const tokens = copyArgTokens(m[2]);
|
|
134
|
+
if (tokens.length < 2) continue; // need ≥1 src + 1 dest
|
|
135
|
+
const sources = tokens.slice(0, tokens.length - 1); // last = dest
|
|
136
|
+
for (const src of sources) {
|
|
137
|
+
if (src === '.') { coversAll = true; continue; }
|
|
138
|
+
const seg = coverageSegment(src);
|
|
139
|
+
if (seg) segments.add(seg);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { coversAll, segments };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Minimal YAML structure walker (no YAML lib — zero-dep invariant)
|
|
147
|
+
//
|
|
148
|
+
// Not a general YAML parser. A line-state machine that yields, for each
|
|
149
|
+
// physical line, enough context to know whether a path token on that line
|
|
150
|
+
// sits in a build-input position. It tracks:
|
|
151
|
+
// - comment stripping (only outside quotes; YAML `#` must be preceded by
|
|
152
|
+
// whitespace or start-of-content to be a comment)
|
|
153
|
+
// - the current mapping key on a line (`key:` ...)
|
|
154
|
+
// - block/folded scalar regions (`key: |` / `key: >`) and their indent,
|
|
155
|
+
// so continuation lines are attributed to the OWNING key (this is the
|
|
156
|
+
// BUG-9/9b fix: a `name: |` continuation is name-prose, never a build
|
|
157
|
+
// input)
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
function stripYamlComment(line) {
|
|
161
|
+
// Remove a trailing `#...` comment when the # is at col 0 or preceded by
|
|
162
|
+
// whitespace and not inside a quote. Cheap quote tracking is sufficient
|
|
163
|
+
// for CI YAML (no `#` inside our path tokens).
|
|
164
|
+
let inS = false, inD = false;
|
|
165
|
+
for (let i = 0; i < line.length; i++) {
|
|
166
|
+
const c = line[i];
|
|
167
|
+
if (c === "'" && !inD) inS = !inS;
|
|
168
|
+
else if (c === '"' && !inS) inD = !inD;
|
|
169
|
+
else if (c === '#' && !inS && !inD && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
170
|
+
return line.slice(0, i);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return line;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function indentOf(line) {
|
|
177
|
+
const m = line.match(/^(\s*)/);
|
|
178
|
+
return m ? m[1].length : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Walk a workflow/cloudbuild YAML and invoke cb(kind, value) for every
|
|
183
|
+
* build-input-bearing token region, where kind is 'run' | 'arg' | 'workdir'.
|
|
184
|
+
* Lines inside a block/folded scalar owned by a non-build key (`name:`,
|
|
185
|
+
* `id:`, `env:`, …) are NEVER emitted.
|
|
186
|
+
*/
|
|
187
|
+
function walkYamlBuildInputs(content, cb) {
|
|
188
|
+
const lines = content.split('\n');
|
|
189
|
+
// Active block-scalar state: { ownerKey, indent } — continuation lines with
|
|
190
|
+
// indent > indent belong to ownerKey and are skipped unless ownerKey is a
|
|
191
|
+
// build key (run). We only treat `run:` block scalars as build input.
|
|
192
|
+
let block = null;
|
|
193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
194
|
+
const rawLine = lines[i];
|
|
195
|
+
if (block) {
|
|
196
|
+
const ind = indentOf(rawLine);
|
|
197
|
+
const isBlank = rawLine.trim() === '';
|
|
198
|
+
if (isBlank || ind > block.indent) {
|
|
199
|
+
if (block.ownerKey === 'run' && !isBlank) cb('run', rawLine);
|
|
200
|
+
continue; // consumed by the block
|
|
201
|
+
}
|
|
202
|
+
block = null; // dedent → block ended
|
|
203
|
+
}
|
|
204
|
+
const line = stripYamlComment(rawLine);
|
|
205
|
+
if (line.trim() === '') continue;
|
|
206
|
+
|
|
207
|
+
// `- key: value` or `key: value` (list-item dash optional)
|
|
208
|
+
const km = line.match(/^(\s*)(?:-\s+)?([A-Za-z_][\w-]*)\s*:(.*)$/);
|
|
209
|
+
if (km) {
|
|
210
|
+
const key = km[2];
|
|
211
|
+
const rest = km[3].trim();
|
|
212
|
+
const keyIndent = km[1].length;
|
|
213
|
+
// Block/folded scalar opener: `key: |` `key: >` (+ chomping/indent)
|
|
214
|
+
if (/^[|>][+-]?\d*\s*$/.test(rest)) {
|
|
215
|
+
block = { ownerKey: key, indent: keyIndent };
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (key === 'run' && rest) cb('run', rest);
|
|
219
|
+
else if (key === 'working-directory' && rest) cb('workdir', rest);
|
|
220
|
+
// `name:`, `id:`, `uses:`, `env:`, `if:`, job/workflow keys → NOT
|
|
221
|
+
// build inputs; their inline value is intentionally ignored.
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Inside an `args:` sequence: `- 'token'` items. We only honor this when
|
|
226
|
+
// the nearest enclosing key was `args`. Track that with a light scan:
|
|
227
|
+
// a line `args:` opens arg-mode until dedent past its indent.
|
|
228
|
+
// Implemented below via argMode.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* cloudbuild.yaml: collect path tokens that are VALUES inside a `steps[].args`
|
|
234
|
+
* list. Recognizes:
|
|
235
|
+
* args: ['build', '-t', 'x', '.'] (flow sequence)
|
|
236
|
+
* args:
|
|
237
|
+
* - 'build'
|
|
238
|
+
* - '.' (block sequence)
|
|
239
|
+
* A `.` arg is the docker *build context*, not a workspace COPY — it does NOT
|
|
240
|
+
* imply coversAll for build-coverage (the Dockerfile is the authority for
|
|
241
|
+
* what gets copied). We still record explicit path-looking args so an
|
|
242
|
+
* artifacts/copy-style step can contribute, but never from comments/`name:`.
|
|
243
|
+
*/
|
|
244
|
+
function parseCloudBuild(filePath) {
|
|
245
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
246
|
+
const segments = new Set();
|
|
247
|
+
let argMode = null; // { indent }
|
|
248
|
+
for (const rawLine of lines) {
|
|
249
|
+
const line = stripYamlComment(rawLine);
|
|
250
|
+
if (line.trim() === '') continue;
|
|
251
|
+
const ind = indentOf(line);
|
|
252
|
+
if (argMode && ind <= argMode.indent) argMode = null;
|
|
253
|
+
|
|
254
|
+
const flow = line.match(/^\s*(?:-\s+)?args\s*:\s*\[(.*)\]\s*$/);
|
|
255
|
+
if (flow) {
|
|
256
|
+
for (const tok of flow[1].split(',')) {
|
|
257
|
+
const v = tok.trim().replace(/^['"]|['"]$/g, '');
|
|
258
|
+
const seg = coverageSegment(v);
|
|
259
|
+
if (seg) segments.add(seg);
|
|
260
|
+
}
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (/^\s*(?:-\s+)?args\s*:\s*$/.test(line)) { argMode = { indent: ind }; continue; }
|
|
264
|
+
if (argMode) {
|
|
265
|
+
const item = line.match(/^\s*-\s+(.*)$/);
|
|
266
|
+
if (item) {
|
|
267
|
+
const v = item[1].trim().replace(/^['"]|['"]$/g, '');
|
|
268
|
+
const seg = coverageSegment(v);
|
|
269
|
+
if (seg) segments.add(seg);
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { segments };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Extract workspace path segments from a shell command string (a `run:`
|
|
279
|
+
* value or `working-directory:`). Token-splits and maps each token through
|
|
280
|
+
* coverageSegment (which already drops absolute + node_modules). Flags and
|
|
281
|
+
* option-args (`-r`, `--foo`) are ignored.
|
|
282
|
+
*/
|
|
283
|
+
function segmentsFromCommand(cmd) {
|
|
284
|
+
const out = [];
|
|
285
|
+
for (const tok of String(cmd).split(/\s+/)) {
|
|
286
|
+
const t = tok.replace(/^['"]|['"]$/g, '');
|
|
287
|
+
if (!t || t.startsWith('-')) continue;
|
|
288
|
+
if (!t.includes('/')) continue; // bare words aren't paths
|
|
289
|
+
const seg = coverageSegment(t);
|
|
290
|
+
if (seg) out.push(seg);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** .github/workflows/*.yml: collect segments from `run:` + `working-directory:` only. */
|
|
296
|
+
function parseWorkflows(workflowDir) {
|
|
297
|
+
const segments = new Set();
|
|
298
|
+
let files;
|
|
299
|
+
try { files = fs.readdirSync(workflowDir); } catch { return { segments }; }
|
|
300
|
+
for (const f of files) {
|
|
301
|
+
if (!/\.ya?ml$/.test(f)) continue;
|
|
302
|
+
const content = fs.readFileSync(path.join(workflowDir, f), 'utf8');
|
|
303
|
+
walkYamlBuildInputs(content, (kind, value) => {
|
|
304
|
+
if (kind === 'run') for (const s of segmentsFromCommand(value)) segments.add(s);
|
|
305
|
+
else if (kind === 'workdir') {
|
|
306
|
+
const seg = coverageSegment(value.replace(/^['"]|['"]$/g, ''));
|
|
307
|
+
if (seg) segments.add(seg);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return { segments };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// CI artifact detection
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
function detectCIArtifacts(projectDir) {
|
|
319
|
+
const artifacts = [];
|
|
320
|
+
let coversAll = false;
|
|
321
|
+
const covered = new Set();
|
|
322
|
+
|
|
323
|
+
const dockerfilePath = path.join(projectDir, 'Dockerfile');
|
|
324
|
+
if (fs.existsSync(dockerfilePath)) {
|
|
325
|
+
artifacts.push('Dockerfile');
|
|
326
|
+
const r = parseDockerfile(dockerfilePath);
|
|
327
|
+
if (r.coversAll) coversAll = true;
|
|
328
|
+
for (const s of r.segments) covered.add(s);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const cloudbuildPath = path.join(projectDir, 'cloudbuild.yaml');
|
|
332
|
+
if (fs.existsSync(cloudbuildPath)) {
|
|
333
|
+
artifacts.push('cloudbuild.yaml');
|
|
334
|
+
for (const s of parseCloudBuild(cloudbuildPath).segments) covered.add(s);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const workflowDir = path.join(projectDir, '.github', 'workflows');
|
|
338
|
+
if (fs.existsSync(workflowDir)) {
|
|
339
|
+
const wf = parseWorkflows(workflowDir);
|
|
340
|
+
if (wf.segments.size > 0 ||
|
|
341
|
+
(fs.existsSync(workflowDir) && fs.readdirSync(workflowDir).some(f => /\.ya?ml$/.test(f)))) {
|
|
342
|
+
artifacts.push('.github/workflows');
|
|
343
|
+
}
|
|
344
|
+
for (const s of wf.segments) covered.add(s);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { artifacts, coversAll, covered };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Main API
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {object} opts
|
|
356
|
+
* @param {string} opts.projectDir
|
|
357
|
+
* @param {string} [opts.baseRef]
|
|
358
|
+
* @param {string} [opts.headRef]
|
|
359
|
+
* @param {string[]} [opts._newPaths] - test seam: supply diff list directly
|
|
360
|
+
* @returns {{ ok, missing, checkedAgainst, newPaths, note? }}
|
|
361
|
+
*/
|
|
362
|
+
function checkBuildCoverage({ projectDir, baseRef, headRef, _newPaths }) {
|
|
363
|
+
const { base, head } = resolveRefs(projectDir, baseRef, headRef);
|
|
364
|
+
|
|
365
|
+
const newPaths = _newPaths !== undefined
|
|
366
|
+
? collapseToTopLevel(_newPaths)
|
|
367
|
+
: collapseToTopLevel(gitDiffNames(projectDir, base, head));
|
|
368
|
+
|
|
369
|
+
if (newPaths.length === 0) {
|
|
370
|
+
return { ok: true, missing: [], checkedAgainst: [], newPaths: [], note: 'empty diff' };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const { artifacts, coversAll, covered } = detectCIArtifacts(projectDir);
|
|
374
|
+
|
|
375
|
+
if (artifacts.length === 0) {
|
|
376
|
+
return { ok: true, missing: [], checkedAgainst: [], newPaths, note: 'no CI artifacts detected' };
|
|
377
|
+
}
|
|
378
|
+
if (coversAll) {
|
|
379
|
+
return { ok: true, missing: [], checkedAgainst: artifacts, newPaths };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// node_modules is never a "new path" worth gating, and never coverage.
|
|
383
|
+
const gated = newPaths.filter(p => p !== 'node_modules');
|
|
384
|
+
const missing = gated.filter(p => !covered.has(p));
|
|
385
|
+
|
|
386
|
+
return { ok: missing.length === 0, missing, checkedAgainst: artifacts, newPaths };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// CLI entry
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
function parseArgv(argv) {
|
|
394
|
+
const opts = { json: false, base: undefined, head: undefined, projectDir: process.cwd() };
|
|
395
|
+
for (let i = 0; i < argv.length; i++) {
|
|
396
|
+
const a = argv[i];
|
|
397
|
+
if (a === '--json') opts.json = true;
|
|
398
|
+
else if (a === '--base') opts.base = argv[++i];
|
|
399
|
+
else if (a === '--head') opts.head = argv[++i];
|
|
400
|
+
else if (a === '--project-dir') opts.projectDir = argv[++i];
|
|
401
|
+
else if (a === '-h' || a === '--help') {
|
|
402
|
+
process.stdout.write([
|
|
403
|
+
'Usage: gsd-t build-coverage [--json] [--base REF] [--head REF] [--project-dir PATH]',
|
|
404
|
+
'',
|
|
405
|
+
'Exit codes:',
|
|
406
|
+
' 0 ok:true — all new top-level paths covered, or no CI artifacts found.',
|
|
407
|
+
' 4 ok:false — ≥1 new top-level path not covered by any CI build input.',
|
|
408
|
+
' 2 usage error (bad refs, not a git repo).',
|
|
409
|
+
'',
|
|
410
|
+
].join('\n'));
|
|
411
|
+
process.exit(0);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return opts;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (require.main === module) {
|
|
418
|
+
const opts = parseArgv(process.argv.slice(2));
|
|
419
|
+
let result;
|
|
420
|
+
try {
|
|
421
|
+
result = checkBuildCoverage({
|
|
422
|
+
projectDir: opts.projectDir, baseRef: opts.base, headRef: opts.head,
|
|
423
|
+
});
|
|
424
|
+
} catch (e) {
|
|
425
|
+
process.stderr.write(`build-coverage: ${e && e.message ? e.message : String(e)}\n`);
|
|
426
|
+
process.exit(2);
|
|
427
|
+
}
|
|
428
|
+
if (opts.json) {
|
|
429
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
430
|
+
} else if (result.ok) {
|
|
431
|
+
process.stdout.write(`OK: all new top-level paths covered${result.note ? ` (${result.note})` : ''}\n`);
|
|
432
|
+
} else {
|
|
433
|
+
process.stdout.write(`FAIL: uncovered paths: ${result.missing.join(', ')}\n`);
|
|
434
|
+
}
|
|
435
|
+
process.exit(result.ok ? 0 : 4);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
module.exports = { checkBuildCoverage, _internal: { parseDockerfile, parseCloudBuild, parseWorkflows, coverageSegment, stripYamlComment } };
|