@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.
- package/CHANGELOG.md +151 -0
- package/README.md +4 -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-test-data-adapters/file-json-array.cjs +56 -0
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +44 -0
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +71 -0
- package/bin/gsd-t-test-data-ledger.cjs +290 -0
- package/bin/gsd-t-time-format.cjs +94 -0
- package/bin/gsd-t.js +30 -0
- package/bin/model-windows.cjs +99 -0
- package/bin/model-windows.test.cjs +75 -0
- package/bin/orchestrator.js +4 -1
- package/bin/runway-estimator.cjs +35 -5
- package/bin/token-budget.cjs +12 -3
- package/commands/gsd-t-complete-milestone.md +7 -3
- package/commands/gsd-t-help.md +21 -0
- package/commands/gsd-t-init.md +1 -1
- package/commands/gsd-t-verify.md +90 -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/scripts/gsd-t-date-guard.js +26 -5
- package/scripts/gsd-t-design-review-server.js +3 -1
- package/templates/CLAUDE-global.md +37 -1
- package/templates/progress.md +6 -2
- package/templates/test-helpers/README.md +98 -0
- 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
|
-
|
|
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 } };
|