@tekyzinc/gsd-t 3.26.11 → 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 CHANGED
@@ -2,6 +2,53 @@
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
+
5
52
  ## [3.26.11] - 2026-05-11
6
53
 
7
54
  ### Changed — Effort estimates in GSD-T-native units
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
- const CONTEXT_WINDOW = 200_000; // claude-opus-4-6 default
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 } };