@tekyzinc/gsd-t 3.26.11 → 3.29.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -0
  3. package/bin/context-budget-audit.cjs +17 -2
  4. package/bin/gsd-t-build-coverage.cjs +438 -0
  5. package/bin/gsd-t-ci-parity.cjs +500 -0
  6. package/bin/gsd-t-economics.cjs +37 -9
  7. package/bin/gsd-t-test-data-adapters/file-json-array.cjs +56 -0
  8. package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +44 -0
  9. package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +71 -0
  10. package/bin/gsd-t-test-data-ledger.cjs +290 -0
  11. package/bin/gsd-t-time-format.cjs +94 -0
  12. package/bin/gsd-t.js +30 -0
  13. package/bin/model-windows.cjs +99 -0
  14. package/bin/model-windows.test.cjs +75 -0
  15. package/bin/orchestrator.js +4 -1
  16. package/bin/runway-estimator.cjs +35 -5
  17. package/bin/token-budget.cjs +12 -3
  18. package/commands/gsd-t-complete-milestone.md +7 -3
  19. package/commands/gsd-t-help.md +21 -0
  20. package/commands/gsd-t-init.md +1 -1
  21. package/commands/gsd-t-verify.md +90 -0
  22. package/package.json +1 -1
  23. package/scripts/context-meter/transcript-parser.js +12 -2
  24. package/scripts/context-meter/transcript-parser.test.js +51 -4
  25. package/scripts/gsd-t-calibration-hook.js +8 -1
  26. package/scripts/gsd-t-context-meter.e2e.test.js +45 -6
  27. package/scripts/gsd-t-context-meter.js +17 -3
  28. package/scripts/gsd-t-context-meter.test.js +85 -0
  29. package/scripts/gsd-t-date-guard.js +26 -5
  30. package/scripts/gsd-t-design-review-server.js +3 -1
  31. package/templates/CLAUDE-global.md +37 -1
  32. package/templates/progress.md +6 -2
  33. package/templates/test-helpers/README.md +98 -0
  34. package/templates/test-helpers/test-data-fixture.ts +153 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,157 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [3.29.10] - 2026-05-27 10:09 PDT
6
+
7
+ ### Changed — Timestamp precision in progress.md (forward-only)
8
+
9
+ Origin: GSD-T-Board (and humans reading progress.md mid-workday) need
10
+ timestamp precision finer than a day. Many GSD-T phases run multiple
11
+ times per day; date-only entries collapse the timeline. The Decision Log
12
+ already used `YYYY-MM-DD HH:MM:`; this release extends that precision to
13
+ the visible "Completed" / "Date" cells and frontmatter.
14
+
15
+ - **`commands/gsd-t-complete-milestone.md`** — Completed Milestones table
16
+ rows now write the "Completed" cell as `YYYY-MM-DD HH:MM TZ` (e.g.
17
+ `2026-05-27 10:09 PDT`). Milestone archive `**Completed**:` line uses
18
+ the same format. The progress.md `## Date:` line is bumped on
19
+ milestone completion.
20
+ - **`commands/gsd-t-init.md`** — initial `## Date:` line in seed
21
+ progress.md uses the new format.
22
+ - **`templates/progress.md`** — Session Log "Date" cell + `## Date:`
23
+ frontmatter use the new format. Inline comments document the
24
+ forward-only convention (readers MUST accept both old date-only and
25
+ new date+time+TZ rows).
26
+ - **`bin/gsd-t-time-format.cjs`** (new) — shared helpers:
27
+ - `localIsoWithOffset()` → `YYYY-MM-DDTHH:MM:SS±HH:MM` (replaces
28
+ `new Date().toISOString()` for `archive-meta.json::completedAt`)
29
+ - `localTimestampForProgress()` → `YYYY-MM-DD HH:MM TZ`
30
+ - **`bin/orchestrator.js` + `scripts/gsd-t-design-review-server.js`** —
31
+ `completedAt` JSON fields now emit local-offset ISO instead of UTC `Z`,
32
+ via `localIsoWithOffset()`.
33
+ - **`scripts/gsd-t-date-guard.js`** (PreToolUse hook):
34
+ - `stamped-iso` pattern extended to accept optional TZ abbreviation,
35
+ numeric offset (`±HH:MM` / `±HHMM`), or `Z` after the `HH:MM`.
36
+ - New `progress-table-cell` pattern validates `| YYYY-MM-DD HH:MM TZ |`
37
+ in table cells against ±5 min live-clock drift.
38
+ - **`templates/CLAUDE-global.md` + `~/.claude/CLAUDE.md`** — Live Clock
39
+ Rule documents the new format requirements.
40
+
41
+ ### Forward-only — NOT a migration
42
+
43
+ Pre-3.29.10 rows in existing `progress.md` files (date-only `YYYY-MM-DD`)
44
+ **stay as-is**. No rewrite. The format change applies only to entries
45
+ written from this version forward. Readers (status, dashboard,
46
+ GSD-T-Board) handle both formats — the change is back-compat by design.
47
+
48
+ ### Falsifiable verification
49
+
50
+ - 9 new unit tests in `test/m59-time-format.test.js` covering the
51
+ helper + both date-guard regexes + writer→guard round-trip.
52
+ - Full suite: **2658/2658 pass** (baseline 2649 + 9 new, **zero
53
+ regressions**).
54
+ - Date-guard regex tests confirm: ✅ `Date: 2024-05-27 10:15 PDT`,
55
+ ✅ `Date: 2024-05-27T10:15:00-07:00`, ✅ `| 2024-05-27 10:15 PDT |`
56
+ in table cells; ✅ pre-existing `| 2024-05-27 |` date-only cells
57
+ remain valid (not flagged).
58
+
59
+ **Versioning**: minor bump 3.28.10 → **3.29.10** (additive capability,
60
+ no breaking reader changes — every consumer already handled the
61
+ opaque-string case).
62
+
63
+ ## [3.28.10] - 2026-05-27
64
+
65
+ ### Added — M58 Test Data Cleanup Gate
66
+
67
+ Origin: GSD-T-Board v0.1.10 ran `gsd-t-verify`, the Playwright suite
68
+ passed, the milestone was tagged VERIFIED — and 2442 `E2E_TEST_*` /
69
+ `E2E_DRAG_*` ideas stayed live in the production data store. Root cause:
70
+ GSD-T had no convention for tracking test data inserted during Verify and
71
+ no purge step after the suite completes.
72
+
73
+ - **`gsd-t test-data`** (`bin/gsd-t-test-data-ledger.cjs`) — append-only
74
+ JSONL ledger at `.gsd-t/test-data-ledger.jsonl` recording every test
75
+ insert as `{runId, kind, store, id, taggedPrefix, insertedAt}`. Public
76
+ API: `appendInsert`, `listInserts`, `purgeRunInserts`, `registerAdapter`.
77
+ CLI: `gsd-t test-data --list [--run <id>]` / `gsd-t test-data --purge
78
+ --run <id> [--dry-run]`. Exit 0 on success, 4 on adapter errors.
79
+ - **Three built-in adapters** (`bin/gsd-t-test-data-adapters/`):
80
+ `localStorage-key-prefix` (Playwright page.evaluate-based), `file-json-array`
81
+ (atomic write-temp + rename), `sqlite-table-where` (parameterized DELETE
82
+ with tagged-prefix LIKE guard; dynamic `better-sqlite3` require). Every
83
+ adapter refuses to delete a record whose id doesn't start with the
84
+ ledger row's `taggedPrefix` — defense in depth.
85
+ - **`withTestData()` Playwright fixture**
86
+ (`templates/test-helpers/test-data-fixture.ts`) — opt-in fixture exposing
87
+ `testData.tag(prefix)` and `testData.register({...})`. Tagging convention:
88
+ `{PREFIX}_{verifyRunId}_{counter}`. Reads `process.env.GSD_T_VERIFY_RUN_ID`
89
+ set by `gsd-t-verify`. Optional `purgePerTest` opt-in for long suites.
90
+ - **`gsd-t-verify` Step 4.5** (new, FAIL-blocking) — runs
91
+ `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"` after the E2E
92
+ suite, before VERDICT. Any adapter error fails the gate (block-promotion
93
+ semantics, equivalent to a failing CI-Parity Gate). Verify report line:
94
+ `Test Data Cleanup: PASS — purged=N skipped=M errors=E` or `FAIL`.
95
+ - **Contracts** — `test-data-ledger-contract.md` v1.0.0 STABLE +
96
+ `test-data-tagging-contract.md` v1.0.0 STABLE.
97
+
98
+ **Falsifiable SC results** (all PASS):
99
+ - SC1 ledger records 5 inserts from synthetic Playwright fixture ✅
100
+ - SC2 `purgeRunInserts({runId})` removes those 5, reports `purged.length===5` ✅
101
+ - SC3 verify FAILs when ledger entries can't be purged (planted adapter throw) ✅
102
+ - SC4 successful E2E purges cleanly → verify report `purged=5 skipped=0 errors=0` ✅
103
+ - SC5 zero regressions on `npm test` ✅
104
+ - SC6 Red Team GRUDGING PASS ≥5 broken patches caught ✅
105
+ - SC7 doc-ripple complete (verify.md + CLAUDE-global.md + README + help + 2 contracts) ✅
106
+
107
+ **Versioning**: minor bump 3.27.10 → **3.28.10** (new feature, additive).
108
+
109
+ ## [3.27.10] - 2026-05-19
110
+
111
+ ### Added — M57 CI-Parity Verify Gate
112
+
113
+ Origin: a TimeTracking v1.10.12 post-mortem — `gsd-t-verify` reported
114
+ VERIFIED, the milestone was tagged + pushed, but Cloud Build failed (a new
115
+ top-level `hooks/` dir was committed but never added to the Dockerfile
116
+ `COPY` directives, and `noImplicitAny` regressions passed a warm-cache
117
+ local `tsc` but failed CI's cold build). A cross-project survey found 7/18
118
+ registered projects have CI surfaces — systemic, not project-specific.
119
+
120
+ - **`gsd-t build-coverage`** (`bin/gsd-t-build-coverage.cjs`) — detects new
121
+ top-level paths in a milestone commit range not referenced by a real CI
122
+ build input. Coverage is decided by **structurally parsing** CI files
123
+ (Dockerfile COPY/ADD source args incl. relative `--from=`; cloudbuild
124
+ `args`-positional; workflow `run`-positional via a block-scalar-aware
125
+ YAML walker) — never substring-matching raw config text. `node_modules`
126
+ never counts.
127
+ - **`gsd-t ci-parity`** (`bin/gsd-t-ci-parity.cjs`) — reproduces the
128
+ project's actual CI build locally (auto-detect cloudbuild → workflows →
129
+ Dockerfile RUN → package scripts), clears build caches, auto-runs the
130
+ real `docker build` when a Dockerfile is present. `clearBuildCaches`
131
+ routes every config-derived delete through a containment predicate
132
+ (`resolved.startsWith(root+sep) && resolved!==root`) — refuses any path
133
+ resolving outside OR equal-to projectRoot (a Destructive Action Guard
134
+ requirement).
135
+ - Both wired into `gsd-t-verify` Step 2.6 as **FAIL-blocking** checks
136
+ (never warning-only); either failing blocks complete-milestone.
137
+
138
+ ### Process note — re-plan after Red Team non-convergence
139
+
140
+ The first M57 design (substring-match build-coverage + unguarded
141
+ cache-clear) FAILED Red Team across **5 non-converging cycles** (BUG-4/6/
142
+ 9/9b: each fix spawned a new false-negative variant; plus 5 CRITICAL
143
+ Destructive-Action-Guard violations in the cache-clearer). The autonomous
144
+ chain was halted (Prime Rule stop #2), the design re-planned, and rebuilt
145
+ in-session after the detached fan-out harness false-completed twice
146
+ (contracts flipped STABLE while the code was never rewritten). The
147
+ corrected structural design converged on the first attempt: all 7 frozen
148
+ falsification-corpus variants flagged, containment predicate holds, full
149
+ suite 2587 pass / 0 fail. Lessons captured in memory
150
+ (`feedback_coverage_check_structural_not_substring`,
151
+ `feedback_destructive_path_ops_containment`,
152
+ `feedback_detached_fanout_false_completion`). Pair-flagged with backlog
153
+ #25 (gsd-t-bench): the bench eval set must include a synthetic regression
154
+ of this incident when it ships.
155
+
5
156
  ## [3.26.11] - 2026-05-11
6
157
 
7
158
  ### Changed — Effort estimates in GSD-T-native units
package/README.md CHANGED
@@ -119,6 +119,10 @@ 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)
124
+ gsd-t test-data --list [--run ID] [--json] # M58: list test-data ledger entries
125
+ gsd-t test-data --purge --run ID [--dry-run] [--json] # M58: purge tagged test data after Verify (Step 4.5)
122
126
  ```
123
127
 
124
128
  `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 } };