@tekyzinc/gsd-t 3.22.11 → 3.23.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 +56 -0
- package/README.md +1 -0
- package/bin/gsd-t.js +46 -1
- package/bin/journey-coverage-cli.cjs +130 -0
- package/bin/journey-coverage.cjs +347 -0
- package/docs/architecture.md +17 -0
- package/docs/requirements.md +22 -0
- package/package.json +1 -1
- package/scripts/gsd-t-transcript.html +34 -23
- package/scripts/hooks/pre-commit-journey-coverage +81 -0
- package/templates/prompts/red-team-subagent.md +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [3.23.10] - 2026-05-06
|
|
8
|
+
|
|
9
|
+
### Added — Rigorous User-Journey Coverage + Anti-Drift Test Quality (M52)
|
|
10
|
+
|
|
11
|
+
Closes the M48→M49→M50→M51 drift pattern where each test round caught the bug shape the previous round named, never the unnamed shape. M52's architectural fix is two-fold: (a) MECHANICAL coverage via a regex-based listener detector + pre-commit gate that blocks viewer-source commits with uncovered listeners; (b) DOCTRINAL change to what "rigorous" means — journey specs walk every interactive surface end-to-end with assertions on user-visible state, real-data NDJSON fixtures, adversarial Red Team scoped to JOURNEYS not lines.
|
|
12
|
+
|
|
13
|
+
**D1 — Journey-coverage tooling:**
|
|
14
|
+
- `bin/journey-coverage.cjs` (308 lines) — regex listener detector with single-pass string-mask precomputation (handles JS strings + HTML/JS comments). Recognises 6 listener kinds per contract §3. Zero parser deps. Sub-100ms on the full viewer file set.
|
|
15
|
+
- `bin/journey-coverage-cli.cjs` (107 lines) → `gsd-t check-coverage` — supports `--staged-only`, `--manifest PATH`, `--quiet`. Exit 0 (clean) / 4 (gap or stale) / 2 (manifest missing).
|
|
16
|
+
- `scripts/hooks/pre-commit-journey-coverage` (mode 0755, `set -e`) — viewer-source pattern set per contract §4. Marker block `# >>> GSD-T journey-coverage gate >>>` mirrors M50 idiom. Fail-open on detector internal exception.
|
|
17
|
+
- `bin/gsd-t.js` wiring (+46 lines under 50-line budget): `installJourneyCoverageHook` (idempotent), `gsd-t check-coverage` CLI dispatch, `gsd-t doctor --install-journey-hook` flag. Hook auto-installed by `init` after Playwright install for UI projects.
|
|
18
|
+
- `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0 (PROPOSED → STABLE on D1 task-5).
|
|
19
|
+
|
|
20
|
+
**D2 — Journey specs + fixtures:**
|
|
21
|
+
- `e2e/journeys/` — 12 inaugural journey specs (`main-session-stream`, `click-completed-conversation`, `click-spawn-entry`, `splitter-drag`, `splitter-keyboard`, `right-rail-toggle`, `completed-collapse-toggle`, `auto-follow-toggle`, `kill-button`, `sessionstorage-persistence`, `keyboard-shortcuts`, `hashchange`). Every assertion verifies state changed / data flowed / content loaded / widget responded — zero `toBeVisible`/`toBeAttached` shallow assertions.
|
|
22
|
+
- `.gsd-t/journey-manifest.json` (new) — 12 entries 1:1 with the spec files; `covers[]` arrays span all 17 distinct viewer listeners (multiple listeners per spec where appropriate).
|
|
23
|
+
- `e2e/fixtures/journeys/` — 3 real-data NDJSONs sliced from captured `in-session-*.ndjson` (~50 / ~150 / ~80 frames). PII scrub: any user content > 200 chars truncated with `[…truncated]` marker.
|
|
24
|
+
- `e2e/fixtures/journeys/replay-helpers.ts` — `startReplayServer({fixture, asSessionId, inSession})` mounts the fixture into a temp project, starts the dashboard server with `port: 0` ephemeral, returns `{baseUrl, dispose}`. Zero new runtime deps.
|
|
25
|
+
- `templates/prompts/red-team-subagent.md` — additive new category "Test Pass-Through — Journey Edition" (existing categories untouched). Adversarial mandate: write ≥5 broken viewer patches, run the journey specs, every patch must be caught.
|
|
26
|
+
|
|
27
|
+
**Adversarial Red Team result:** 5 broken viewer patches written, all 5 caught by journey specs (P1: splitter:mousedown drag handler stripped → splitter-drag FAILS as expected; P2: `_ssSet(SS_KEY_SPLITTER, ...)` redirected to wrong key → splitter-drag + splitter-keyboard FAIL; P3: right-rail toggle handler stubbed to early-return → right-rail-toggle FAILS; P4: M52 narrowed-guard reverted to broken M48 wide-guard → click-completed-conversation FAILS, catching the M52 root-cause regression itself; P5: auto-follow change handler localStorage write removed → auto-follow-toggle FAILS). VERDICT: GRUDGING PASS. See `.gsd-t/red-team-report.md` § "M52 JOURNEY-EDITION RED TEAM".
|
|
28
|
+
|
|
29
|
+
**Hook end-to-end exercise:** synthetic `fakeBtn:click` listener appended to `scripts/gsd-t-transcript.html:1617` + staged → `bash .git/hooks/pre-commit` exit 1 with structured GAP report (`GAP: scripts/gsd-t-transcript.html:1617 fakeBtn:click (addEventListener) no spec covers this`); manifest extended with covering entry → re-run hook exit 0. Block + unblock paths both logged.
|
|
30
|
+
|
|
31
|
+
**Suite:** unit 2195/2195 pass (was 2167; +28 from M52 D1 detector + CLI + hook + helpers). E2E 35/35 + 1 skip preserved (was 23/35; +12 journey specs). `gsd-t check-coverage` reports `OK: 20 listeners, 12 specs` (exit 0, zero gaps, zero stale entries).
|
|
32
|
+
|
|
33
|
+
**Migration:** none. Hook is auto-installed on `gsd-t init` and re-installable via `gsd-t doctor --install-journey-hook`. Existing projects pick it up on next `gsd-t update-all`.
|
|
34
|
+
|
|
35
|
+
### Fixed — historical in-session conversations are clickable again (M52 quick patch)
|
|
36
|
+
|
|
37
|
+
The M48 Bug 4 dual-pane mirror prevention was overcorrected: `scripts/gsd-t-transcript.html` early-returned for ANY rail entry whose spawn-id began with `in-session-`. But every COMPLETED rail entry is `in-session-*` (that's how the M45 D2 hook captures conversations), so clicking ANY completed conversation did nothing — there was no pane that would load it.
|
|
38
|
+
|
|
39
|
+
**Mental model fix:** Top pane is for the LIVE main session (owned by `/api/main-session`). Bottom pane is for ANYTHING else the user clicks — regular spawns OR historical in-session conversations. Only the LIVE main session's spawn id should be blocked from the bottom pane (that's the actual M48 Bug 4 case).
|
|
40
|
+
|
|
41
|
+
**Source narrowing in `scripts/gsd-t-transcript.html`:**
|
|
42
|
+
- `connectMain(sessionId)` exposes `window.__mainSessionId` so click + hashchange handlers can discriminate.
|
|
43
|
+
- renderRailEntry click handler: `if (isInSession && node.spawnId === ('in-session-' + window.__mainSessionId)) return;` — was `if (isInSession) return;`.
|
|
44
|
+
- hashchange handler: `if (id && id === ('in-session-' + window.__mainSessionId)) { return; }` — was `if (id && id.indexOf('in-session-') === 0) { return; }`.
|
|
45
|
+
- Legacy renderTree click handler: same narrowed pattern.
|
|
46
|
+
- Removed unconditional in-session-* scrub from initial-bottom-id seeding (historical sessionStorage selections survive reload now).
|
|
47
|
+
- Added `fetchMainSession` callback that clears the bottom-pane seed when it collides with the live main session id (preserves M48 Bug 4 mirror prevention).
|
|
48
|
+
|
|
49
|
+
**New journey spec `e2e/viewer/click-completed.spec.ts` (4 tests):**
|
|
50
|
+
- Rail renders 1 main session + 3 completed in-session entries.
|
|
51
|
+
- Clicking each completed entry loads it into the BOTTOM pane (and TOP pane stays on the live main session).
|
|
52
|
+
- Clicking the live MAIN entry does NOT load it into the bottom pane.
|
|
53
|
+
- sessionStorage persists across reload — bottom pane resumes the previously-clicked entry.
|
|
54
|
+
|
|
55
|
+
**Adversarial Red Team (3 broken patches, all caught):** (a) revert both checks to `if (isInSession) return;`; (b) `connect(id)` short-circuits on in-session prefix; (c) click handler routes in-session entries to TOP pane via `connectMain(...)` (clobbers main session). See `.gsd-t/red-team-report.md` § M52 RED TEAM FINDINGS.
|
|
56
|
+
|
|
57
|
+
**Unit-test ripple:** 5 source-pinned assertions in `test/m48-viewer-rendering-fixes.test.js` flipped from "asserts pre-M52 unconditional bail" to "asserts M52 narrowed live-main check". 1 new test for the fetchMainSession seed-collision callback.
|
|
58
|
+
|
|
59
|
+
**Suite:** 2167/2167 unit pass (added 1); E2E 23/23 + 1 placeholder skip (added 4 journey tests).
|
|
60
|
+
|
|
5
61
|
## [3.22.11] - 2026-05-06
|
|
6
62
|
|
|
7
63
|
### Fixed — viewer Playwright specs are now actually rigorous + adversarially proven (M51)
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A methodology for reliable, parallelizable development using Claude Code with op
|
|
|
16
16
|
**External Task Orchestrator + Streaming Watcher UI (M40, v3.14.10)** — JS orchestrator drives `claude -p` one task per spawn: short-lived, fresh context, architecturally compaction-free. Benchmarks 0.72× wall-clock vs in-session on 20-task/3-wave workloads. Paired with a zero-Claude-cost local streaming UI at `127.0.0.1:7842` that renders all workers' stream-json output as a continuous claude.ai-style feed — task/wave banners, duration + usage chips, token corner bar, localStorage filters, replay via `WS /feed?from=N`. Recovery: `--resume` reconciles interrupted runs using commit + progress.md evidence; ambiguous tasks (commit without progress entry) are flagged for operator triage, never silently claimed done. CLI: `gsd-t orchestrate`, `gsd-t benchmark-orchestrator`, `gsd-t stream-feed`. Contracts: `stream-json-sink-contract.md` v1.1.0, `wave-join-contract.md`, `completion-signal-contract.md`, `metrics-schema-contract.md`.
|
|
17
17
|
**Always-Headless Spawn (M43 D4, v3.16.x+) — Channel Separation** — every GSD-T command spawns detached, unconditionally. No `--watch`, no `--in-session`, no `--headless` opt-in, no context-meter threshold that reroutes. The dialog channel is reserved for human↔Claude conversation; every workflow turn is a detached headless child. Interactive session shows a launch banner + live-transcript URL + event-stream path, then exits — results surface via the read-back banner on the user's next message. Detached workers emit JSONL events to `.gsd-t/events/YYYY-MM-DD.jsonl` at every phase boundary — shared by dashboard and (historically) the watch command. The only in-session surface is the `/gsd` router (for dialog-only exploratory turns). See `.gsd-t/contracts/headless-default-contract.md` v2.0.0 and `unattended-event-stream-contract.md` v1.0.0.
|
|
18
18
|
**Live Transcript as Primary Surface (M43 D6, v3.16.13 — extended in M47, v3.21.10)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other.
|
|
19
|
+
**Rigorous User-Journey Coverage + Anti-Drift Test Quality (M52, v3.23.10)** — closes the M48→M51 drift pattern where each test round only caught previously-named bug shapes. Two-part fix: (a) `bin/journey-coverage.cjs` regex listener detector + `gsd-t check-coverage` CLI + `scripts/hooks/pre-commit-journey-coverage` commit gate (auto-installed on `gsd-t init`) — blocks viewer-source commits when uncovered listeners exist; (b) 12 inaugural journey specs in `e2e/journeys/` covering all 20 detected viewer listeners with functional assertions (zero `toBeVisible`-only tests). Red Team GRUDGING PASS: 5/5 broken viewer patches caught; hook block-then-unblock exercised. Suite: 2195/2195 unit + 35/35 E2E pass.
|
|
19
20
|
**Universal Playwright Bootstrap + Deterministic UI Enforcement (M50, v3.22.10)** — converts the prose-only "Playwright Readiness Guard" into three executable enforcement layers so agents cannot skip UI tests. Layer 1: `bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs` wired into `init`/`update-all`/`doctor`/new `gsd-t setup-playwright [path]` subcommand — idempotent installer detects package manager, installs `@playwright/test` + chromium, writes config from a contract-locked template, scaffolds `e2e/`. Layer 2: `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` auto-installs before any of 9 whitelisted testing/UI commands when `hasUI && !hasPlaywright`; install failure → `mode: 'blocked-needs-human'` exit-4. Layer 3: `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) reads `.gsd-t/.last-playwright-pass` and blocks viewer-source commits when staged files are newer than the last playwright test pass. Also ships the M47/M48/M49 E2E viewer specs (`e2e/viewer/title`, `timestamps`, `chat-bubbles`, `dual-pane`, `lazy-dashboard`). 62 new unit tests; E2E 8/9 pass.
|
|
20
21
|
**Focused Visualizer Redesign (M47, v3.21.10)** — `/transcripts` opens directly to a dual-pane focused view: the **top pane** auto-streams the orchestrator's main in-session conversation (zero clicks — fetched via new `GET /api/main-session`), and the **bottom pane** streams whichever spawn the user clicks. A keyboard- and mouse-resizable splitter sits between them, with position persisted in `sessionStorage` (along with selection, completed-section toggle, and right-rail collapsed state). The left rail splits into three sections — `★ Main Session`, `Live Spawns`, and `Completed` (last 100 spawns, newest first, status-badged, collapsible). When a spawn transitions running → completed it moves rail sections live without a full reload, and stays selected if focused. The right rail (Spawn Plan / Parallelism / Tool Cost) is preserved under a new collapsible toggle. Bookmarks to `/transcript/:spawnId` continue to land that spawn pre-selected in the bottom pane. Contract: `dashboard-server-contract.md` v1.3.0 (additive — `status: 'active' \| 'completed'` field on in-session entries, derived from a 30s mtime window; `/api/main-session` endpoint with path-traversal guard + `Cache-Control: no-store`).
|
|
21
22
|
- **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback.
|
package/bin/gsd-t.js
CHANGED
|
@@ -657,6 +657,35 @@ function installCaptureLintHook(projectDir) {
|
|
|
657
657
|
return true;
|
|
658
658
|
}
|
|
659
659
|
|
|
660
|
+
// M52 D1 — journey-coverage gate hook installer. Block-delimited so uninstall
|
|
661
|
+
// is a clean string excise. Stock script lives at scripts/hooks/pre-commit-journey-coverage.
|
|
662
|
+
const JOURNEY_COVERAGE_HOOK_BEGIN = "# >>> GSD-T journey-coverage gate >>>";
|
|
663
|
+
const JOURNEY_COVERAGE_HOOK_END = "# <<< GSD-T journey-coverage gate <<<";
|
|
664
|
+
|
|
665
|
+
function installJourneyCoverageHook(projectDir) {
|
|
666
|
+
const gitDir = path.join(projectDir, ".git");
|
|
667
|
+
if (!fs.existsSync(gitDir)) { warn("No .git directory — skipping journey-coverage hook install"); return false; }
|
|
668
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
669
|
+
try { fs.mkdirSync(hooksDir, { recursive: true }); } catch (_) {}
|
|
670
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
671
|
+
const stockSrc = path.join(PKG_ROOT, "scripts", "hooks", "pre-commit-journey-coverage");
|
|
672
|
+
let stock;
|
|
673
|
+
try { stock = fs.readFileSync(stockSrc, "utf8"); } catch (_) { warn("Could not read pre-commit-journey-coverage script"); return false; }
|
|
674
|
+
const block = JOURNEY_COVERAGE_HOOK_BEGIN + "\n" + stock.replace(/^#!.*\n/, "") + "\n" + JOURNEY_COVERAGE_HOOK_END + "\n";
|
|
675
|
+
if (!fs.existsSync(hookPath)) {
|
|
676
|
+
fs.writeFileSync(hookPath, "#!/usr/bin/env bash\nset -e\n\n" + block);
|
|
677
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
|
|
678
|
+
success(`Journey-coverage gate installed at ${path.relative(projectDir, hookPath)}`);
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
const existing = fs.readFileSync(hookPath, "utf8");
|
|
682
|
+
if (existing.includes(JOURNEY_COVERAGE_HOOK_BEGIN)) { info("Journey-coverage block already present — no change"); return true; }
|
|
683
|
+
fs.writeFileSync(hookPath, existing.trimEnd() + "\n\n" + block);
|
|
684
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
|
|
685
|
+
success(`Journey-coverage block appended to ${path.relative(projectDir, hookPath)}`);
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
|
|
660
689
|
// Idempotent — if an existing hook references CONTEXT_METER_HOOK_MARKER the
|
|
661
690
|
// command string is refreshed/migrated in-place to the canonical form.
|
|
662
691
|
// Stale entries matching CONTEXT_METER_STALE_PATTERNS are migrated on the spot.
|
|
@@ -1720,6 +1749,9 @@ async function doInit(projectName) {
|
|
|
1720
1749
|
}
|
|
1721
1750
|
}
|
|
1722
1751
|
|
|
1752
|
+
// M52 D1: auto-install the journey-coverage gate. Idempotent — no-op if marker present.
|
|
1753
|
+
if (hasUI(projectDir)) installJourneyCoverageHook(projectDir);
|
|
1754
|
+
|
|
1723
1755
|
showInitTree(projectDir);
|
|
1724
1756
|
}
|
|
1725
1757
|
|
|
@@ -3055,6 +3087,12 @@ async function doDoctor(opts) {
|
|
|
3055
3087
|
heading("Installing pre-commit hooks");
|
|
3056
3088
|
installPlaywrightGateHook(process.cwd());
|
|
3057
3089
|
}
|
|
3090
|
+
// M52 D1: opt-in install of the journey-coverage pre-commit hook.
|
|
3091
|
+
if (opts && opts.installJourneyHook) {
|
|
3092
|
+
log("");
|
|
3093
|
+
heading("Installing journey-coverage pre-commit hook");
|
|
3094
|
+
installJourneyCoverageHook(process.cwd());
|
|
3095
|
+
}
|
|
3058
3096
|
log("");
|
|
3059
3097
|
if (issues === 0) {
|
|
3060
3098
|
log(`${GREEN}${BOLD} All checks passed!${RESET}`);
|
|
@@ -4325,15 +4363,22 @@ if (require.main === module) {
|
|
|
4325
4363
|
doUninstall();
|
|
4326
4364
|
break;
|
|
4327
4365
|
case "doctor": {
|
|
4328
|
-
const doctorOpts = { prune: false, installPlaywright: false, installHooks: false };
|
|
4366
|
+
const doctorOpts = { prune: false, installPlaywright: false, installHooks: false, installJourneyHook: false };
|
|
4329
4367
|
for (let i = 1; i < args.length; i++) {
|
|
4330
4368
|
if (args[i] === "--prune") doctorOpts.prune = true;
|
|
4331
4369
|
if (args[i] === "--install-playwright") doctorOpts.installPlaywright = true;
|
|
4332
4370
|
if (args[i] === "--install-hooks") doctorOpts.installHooks = true;
|
|
4371
|
+
if (args[i] === "--install-journey-hook") doctorOpts.installJourneyHook = true;
|
|
4333
4372
|
}
|
|
4334
4373
|
doDoctor(doctorOpts).catch((e) => { error(e.message || String(e)); process.exit(1); });
|
|
4335
4374
|
break;
|
|
4336
4375
|
}
|
|
4376
|
+
case "check-coverage": {
|
|
4377
|
+
const cli = path.join(__dirname, "journey-coverage-cli.cjs");
|
|
4378
|
+
const { spawnSync } = require("child_process");
|
|
4379
|
+
const res = spawnSync(process.execPath, [cli, ...args.slice(1)], { stdio: "inherit" });
|
|
4380
|
+
process.exit(res.status == null ? 1 : res.status);
|
|
4381
|
+
}
|
|
4337
4382
|
case "setup-playwright": {
|
|
4338
4383
|
// Single-project explicit installer. Thin wrapper around installPlaywright().
|
|
4339
4384
|
const targetDir = args[1] && !args[1].startsWith("-") ? path.resolve(args[1]) : process.cwd();
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const jc = require('./journey-coverage.cjs');
|
|
9
|
+
|
|
10
|
+
const VIEWER_FILES = [
|
|
11
|
+
'scripts/gsd-t-transcript.html',
|
|
12
|
+
'scripts/gsd-t-dashboard-server.js',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function discoverViewerFiles(projectDir) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const rel of VIEWER_FILES) {
|
|
18
|
+
if (fs.existsSync(path.join(projectDir, rel))) out.push(rel);
|
|
19
|
+
}
|
|
20
|
+
const binDir = path.join(projectDir, 'bin');
|
|
21
|
+
if (fs.existsSync(binDir)) {
|
|
22
|
+
for (const f of fs.readdirSync(binDir)) {
|
|
23
|
+
if (/^gsd-t-dashboard.*\.cjs$/.test(f)) out.push(path.join('bin', f));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const journeyDir = path.join(projectDir, 'e2e', 'journeys');
|
|
27
|
+
if (fs.existsSync(journeyDir)) {
|
|
28
|
+
for (const f of fs.readdirSync(journeyDir)) {
|
|
29
|
+
if (/\.spec\.ts$/.test(f)) out.push(path.join('e2e', 'journeys', f));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function discoverStagedViewerFiles(projectDir) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = execSync('git diff --cached --name-only', { cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
39
|
+
} catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const all = raw.split('\n').filter(Boolean);
|
|
43
|
+
return all.filter((rel) => jc.isViewerSource(rel));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const out = { stagedOnly: false, manifest: null, quiet: false, projectDir: process.cwd() };
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === '--staged-only') out.stagedOnly = true;
|
|
51
|
+
else if (a === '--manifest') { out.manifest = argv[++i]; }
|
|
52
|
+
else if (a === '--quiet') out.quiet = true;
|
|
53
|
+
else if (a === '--project-dir') { out.projectDir = argv[++i]; }
|
|
54
|
+
else if (a === '-h' || a === '--help') {
|
|
55
|
+
process.stdout.write([
|
|
56
|
+
'Usage: gsd-t check-coverage [--staged-only] [--manifest PATH] [--quiet]',
|
|
57
|
+
'',
|
|
58
|
+
'Exit codes:',
|
|
59
|
+
' 0 All detected listeners covered. Manifest fresh.',
|
|
60
|
+
' 4 Coverage gap or stale manifest entry.',
|
|
61
|
+
' 2 Manifest missing or unreadable (fail-closed).',
|
|
62
|
+
'',
|
|
63
|
+
].join('\n'));
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function main(argv) {
|
|
71
|
+
const opts = parseArgs(argv);
|
|
72
|
+
const projectDir = opts.projectDir;
|
|
73
|
+
|
|
74
|
+
// --staged-only is a gate: only RUN when at least one viewer-source file is
|
|
75
|
+
// staged. The actual scan always covers the full viewer source — otherwise
|
|
76
|
+
// a commit that touches only spec files would falsely flag every manifest
|
|
77
|
+
// entry as stale (because the staged-only file list contains no listeners).
|
|
78
|
+
if (opts.stagedOnly) {
|
|
79
|
+
const staged = discoverStagedViewerFiles(projectDir);
|
|
80
|
+
if (staged.length === 0) {
|
|
81
|
+
if (!opts.quiet) process.stdout.write('OK: 0 listeners, 0 specs (no staged viewer files)\n');
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const files = discoverViewerFiles(projectDir);
|
|
86
|
+
|
|
87
|
+
const listeners = jc.detectListeners(files, { projectDir });
|
|
88
|
+
|
|
89
|
+
let manifest;
|
|
90
|
+
try {
|
|
91
|
+
if (opts.manifest) {
|
|
92
|
+
const p = path.isAbsolute(opts.manifest) ? opts.manifest : path.join(projectDir, opts.manifest);
|
|
93
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
94
|
+
manifest = JSON.parse(raw);
|
|
95
|
+
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.specs)) {
|
|
96
|
+
const err = new Error('manifest-shape-invalid');
|
|
97
|
+
err.code = 'MANIFEST_INVALID';
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
manifest = jc.loadManifest(projectDir);
|
|
102
|
+
}
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (e && (e.code === 'MANIFEST_MISSING' || e.code === 'MANIFEST_UNREADABLE' || e.code === 'MANIFEST_INVALID')) {
|
|
105
|
+
if (listeners.length === 0) {
|
|
106
|
+
if (!opts.quiet) process.stdout.write('OK: 0 listeners, 0 specs (no manifest, no listeners — vacuous pass)\n');
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
process.stderr.write('check-coverage: manifest missing or unreadable: ' + (e.path || '.gsd-t/journey-manifest.json') + '\n');
|
|
110
|
+
process.stderr.write('hint: run a D2-authored manifest or pass --manifest PATH\n');
|
|
111
|
+
process.exit(2);
|
|
112
|
+
}
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const gaps = jc.findGaps(listeners, manifest);
|
|
117
|
+
const specCount = (manifest.specs || []).length;
|
|
118
|
+
if (gaps.length === 0) {
|
|
119
|
+
if (!opts.quiet) process.stdout.write('OK: ' + listeners.length + ' listeners, ' + specCount + ' specs\n');
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
process.stderr.write(jc.formatReport(gaps) + '\n');
|
|
123
|
+
process.exit(4);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (require.main === module) {
|
|
127
|
+
main(process.argv.slice(2));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { main, discoverViewerFiles, discoverStagedViewerFiles, parseArgs };
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const VIEWER_FILE_PATTERNS = [
|
|
7
|
+
/^scripts\/gsd-t-transcript\.html$/,
|
|
8
|
+
/^scripts\/gsd-t-dashboard-server\.js$/,
|
|
9
|
+
/^bin\/gsd-t-dashboard.*\.cjs$/,
|
|
10
|
+
/^e2e\/journeys\/.*\.spec\.ts$/,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const IGNORE_FILE_PATTERNS = [
|
|
14
|
+
/^e2e\/viewer\/.*\.spec\.ts$/,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const KNOWN_KINDS = new Set([
|
|
18
|
+
'addEventListener',
|
|
19
|
+
'inline-handler',
|
|
20
|
+
'function-call',
|
|
21
|
+
'mutation-observer',
|
|
22
|
+
'hashchange',
|
|
23
|
+
'delegated',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const TRACKED_FUNCTIONS = new Set(['connectMain', 'connect', 'fetchMainSession']);
|
|
27
|
+
|
|
28
|
+
function isViewerSource(rel) {
|
|
29
|
+
if (IGNORE_FILE_PATTERNS.some((p) => p.test(rel))) return false;
|
|
30
|
+
return VIEWER_FILE_PATTERNS.some((p) => p.test(rel));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readSource(absPath) {
|
|
34
|
+
return fs.readFileSync(absPath, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lineOf(src, idx) {
|
|
38
|
+
let line = 1;
|
|
39
|
+
for (let i = 0; i < idx && i < src.length; i++) {
|
|
40
|
+
if (src.charCodeAt(i) === 10) line++;
|
|
41
|
+
}
|
|
42
|
+
return line;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lineStartIndex(src, idx) {
|
|
46
|
+
let i = idx;
|
|
47
|
+
while (i > 0 && src[i - 1] !== '\n') i--;
|
|
48
|
+
return i;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lineEndIndex(src, idx) {
|
|
52
|
+
let i = idx;
|
|
53
|
+
while (i < src.length && src[i] !== '\n') i++;
|
|
54
|
+
return i;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lineText(src, idx) {
|
|
58
|
+
return src.slice(lineStartIndex(src, idx), lineEndIndex(src, idx));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildStringMask(src) {
|
|
62
|
+
const len = src.length;
|
|
63
|
+
const mask = new Uint8Array(len);
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < len) {
|
|
66
|
+
const ch = src[i];
|
|
67
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
68
|
+
while (i < len && src[i] !== '\n') { mask[i] = 1; i++; }
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
72
|
+
mask[i] = 1; mask[i + 1] = 1; i += 2;
|
|
73
|
+
while (i < len && !(src[i] === '*' && src[i + 1] === '/')) { mask[i] = 1; i++; }
|
|
74
|
+
if (i < len) { mask[i] = 1; mask[i + 1] = 1; i += 2; }
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (ch === '<' && src[i + 1] === '!' && src[i + 2] === '-' && src[i + 3] === '-') {
|
|
78
|
+
while (i < len && !(src[i] === '-' && src[i + 1] === '-' && src[i + 2] === '>')) { mask[i] = 1; i++; }
|
|
79
|
+
if (i < len) { mask[i] = 1; mask[i + 1] = 1; mask[i + 2] = 1; i += 3; }
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
83
|
+
const quote = ch;
|
|
84
|
+
mask[i] = 1; i++;
|
|
85
|
+
while (i < len) {
|
|
86
|
+
if (src[i] === '\\') {
|
|
87
|
+
mask[i] = 1;
|
|
88
|
+
if (i + 1 < len) mask[i + 1] = 1;
|
|
89
|
+
i += 2;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (src[i] === quote) { mask[i] = 1; i++; break; }
|
|
93
|
+
if (quote !== '`' && src[i] === '\n') break;
|
|
94
|
+
mask[i] = 1; i++;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
return mask;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function masked(mask, idx) {
|
|
104
|
+
return mask[idx] === 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isFeatureDetectGuard(src, matchIdx) {
|
|
108
|
+
const line = lineText(src, matchIdx);
|
|
109
|
+
return /if\s*\(\s*!\s*\w+(\.\w+)?\.addEventListener\s*\)/.test(line);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isEslintExempt(src, matchIdx) {
|
|
113
|
+
const lineStart = lineStartIndex(src, matchIdx);
|
|
114
|
+
if (lineStart === 0) return false;
|
|
115
|
+
const prevLineEnd = lineStart - 1;
|
|
116
|
+
const prevLineStart = lineStartIndex(src, prevLineEnd - 1);
|
|
117
|
+
const prevLine = src.slice(prevLineStart, prevLineEnd);
|
|
118
|
+
if (!/eslint-disable/.test(prevLine)) return false;
|
|
119
|
+
return /journey-coverage/.test(prevLine);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function findReceiverBeforeDot(src, dotIdx) {
|
|
123
|
+
let i = dotIdx - 1;
|
|
124
|
+
while (i >= 0 && /\s/.test(src[i])) i--;
|
|
125
|
+
let depth = 0;
|
|
126
|
+
const end = i + 1;
|
|
127
|
+
while (i >= 0) {
|
|
128
|
+
const ch = src[i];
|
|
129
|
+
if (ch === ')' || ch === ']') { depth++; i--; continue; }
|
|
130
|
+
if (ch === '(' || ch === '[') {
|
|
131
|
+
if (depth === 0) break;
|
|
132
|
+
depth--; i--; continue;
|
|
133
|
+
}
|
|
134
|
+
if (depth > 0) { i--; continue; }
|
|
135
|
+
if (/[A-Za-z0-9_$.\?]/.test(ch)) { i--; continue; }
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
return src.slice(i + 1, end).trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function looksLikeDelegatedHandler(src, matchIdx, matchLen) {
|
|
142
|
+
const tail = src.slice(matchIdx + matchLen, matchIdx + matchLen + 600);
|
|
143
|
+
if (!/=>\s*\{|function\s*\(/.test(tail)) return false;
|
|
144
|
+
return /e\.target\.matches\s*\(|event\.target\.matches\s*\(/.test(tail);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function extractDelegatedSelector(src, matchIdx) {
|
|
148
|
+
const tail = src.slice(matchIdx, matchIdx + 600);
|
|
149
|
+
const m = /\.target\.matches\s*\(\s*(['"])([^'"]+)\1/.exec(tail);
|
|
150
|
+
return m ? m[2] : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function detectAddEventListener(file, src, mask, listeners) {
|
|
154
|
+
const re = /\.addEventListener\s*\(\s*(['"])([A-Za-z_][\w-]*)\1/g;
|
|
155
|
+
let m;
|
|
156
|
+
while ((m = re.exec(src)) !== null) {
|
|
157
|
+
const matchIdx = m.index;
|
|
158
|
+
if (masked(mask, matchIdx)) continue;
|
|
159
|
+
if (isFeatureDetectGuard(src, matchIdx)) continue;
|
|
160
|
+
const event = m[2];
|
|
161
|
+
const receiver = findReceiverBeforeDot(src, matchIdx);
|
|
162
|
+
if (!receiver) continue;
|
|
163
|
+
if (isEslintExempt(src, matchIdx)) continue;
|
|
164
|
+
let kind = 'addEventListener';
|
|
165
|
+
let selector;
|
|
166
|
+
if (receiver === 'window') {
|
|
167
|
+
if (event === 'hashchange') {
|
|
168
|
+
kind = 'hashchange';
|
|
169
|
+
selector = 'window:hashchange';
|
|
170
|
+
} else {
|
|
171
|
+
selector = 'window:' + event;
|
|
172
|
+
}
|
|
173
|
+
} else if (looksLikeDelegatedHandler(src, matchIdx, m[0].length)) {
|
|
174
|
+
kind = 'delegated';
|
|
175
|
+
const matchesSel = extractDelegatedSelector(src, matchIdx);
|
|
176
|
+
selector = receiver + ':' + event + (matchesSel ? '|' + matchesSel : '');
|
|
177
|
+
} else {
|
|
178
|
+
selector = receiver + ':' + event;
|
|
179
|
+
}
|
|
180
|
+
const line = lineOf(src, matchIdx);
|
|
181
|
+
const raw = lineText(src, matchIdx).trim();
|
|
182
|
+
listeners.push({ file, line, selector, kind, raw });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function detectInlineHandler(file, src, mask, listeners) {
|
|
187
|
+
const re = /<(\w+)([^>]*?)\s+on(\w+)\s*=\s*(['"])([^'"]*)\4/g;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = re.exec(src)) !== null) {
|
|
190
|
+
const matchIdx = m.index;
|
|
191
|
+
if (masked(mask, matchIdx)) continue;
|
|
192
|
+
const tag = m[1];
|
|
193
|
+
const attrs = m[2];
|
|
194
|
+
const event = m[3].toLowerCase();
|
|
195
|
+
const idMatch = /\sid\s*=\s*(['"])([^'"]+)\1/.exec(attrs);
|
|
196
|
+
const id = idMatch ? idMatch[2] : null;
|
|
197
|
+
const selector = id ? id + ':' + event : tag + ':' + event;
|
|
198
|
+
if (isEslintExempt(src, matchIdx)) continue;
|
|
199
|
+
const line = lineOf(src, matchIdx);
|
|
200
|
+
const raw = lineText(src, matchIdx).trim();
|
|
201
|
+
listeners.push({ file, line, selector, kind: 'inline-handler', raw });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function detectMutationObserver(file, src, mask, listeners) {
|
|
206
|
+
const re = /new\s+MutationObserver\s*\(/g;
|
|
207
|
+
let m;
|
|
208
|
+
let counter = 0;
|
|
209
|
+
while ((m = re.exec(src)) !== null) {
|
|
210
|
+
const matchIdx = m.index;
|
|
211
|
+
if (masked(mask, matchIdx)) continue;
|
|
212
|
+
counter++;
|
|
213
|
+
const selector = 'mutation-observer:' + path.basename(file) + ':' + counter;
|
|
214
|
+
if (isEslintExempt(src, matchIdx)) continue;
|
|
215
|
+
const line = lineOf(src, matchIdx);
|
|
216
|
+
const raw = lineText(src, matchIdx).trim();
|
|
217
|
+
listeners.push({ file, line, selector, kind: 'mutation-observer', raw });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function detectFunctionCall(file, src, mask, listeners) {
|
|
222
|
+
const defRe = /(?:^|\n)\s*(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g;
|
|
223
|
+
const defs = new Map();
|
|
224
|
+
let m;
|
|
225
|
+
while ((m = defRe.exec(src)) !== null) {
|
|
226
|
+
const matchIdx = m.index;
|
|
227
|
+
if (masked(mask, matchIdx)) continue;
|
|
228
|
+
if (!defs.has(m[1])) defs.set(m[1], matchIdx);
|
|
229
|
+
}
|
|
230
|
+
if (!defs.size) return;
|
|
231
|
+
for (const [fnName, defIdx] of defs) {
|
|
232
|
+
if (!TRACKED_FUNCTIONS.has(fnName)) continue;
|
|
233
|
+
const callRe = new RegExp('(?<![\\w.])' + fnName + '\\s*\\(', 'g');
|
|
234
|
+
let cm;
|
|
235
|
+
let foundCall = false;
|
|
236
|
+
while ((cm = callRe.exec(src)) !== null) {
|
|
237
|
+
const before = src.slice(Math.max(0, cm.index - 12), cm.index);
|
|
238
|
+
if (/\bfunction\s+$/.test(before)) continue;
|
|
239
|
+
if (masked(mask, cm.index)) continue;
|
|
240
|
+
foundCall = true; break;
|
|
241
|
+
}
|
|
242
|
+
if (!foundCall) continue;
|
|
243
|
+
if (isEslintExempt(src, defIdx)) continue;
|
|
244
|
+
const line = lineOf(src, defIdx);
|
|
245
|
+
const raw = lineText(src, defIdx).trim();
|
|
246
|
+
listeners.push({ file, line, selector: fnName, kind: 'function-call', raw });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function detectListeners(filepaths, opts = {}) {
|
|
251
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
252
|
+
const listeners = [];
|
|
253
|
+
for (const fp of filepaths) {
|
|
254
|
+
const rel = path.isAbsolute(fp) ? path.relative(projectDir, fp) : fp;
|
|
255
|
+
if (!isViewerSource(rel)) continue;
|
|
256
|
+
const abs = path.isAbsolute(fp) ? fp : path.join(projectDir, fp);
|
|
257
|
+
let src;
|
|
258
|
+
try { src = readSource(abs); } catch { continue; }
|
|
259
|
+
const mask = buildStringMask(src);
|
|
260
|
+
detectAddEventListener(rel, src, mask, listeners);
|
|
261
|
+
detectInlineHandler(rel, src, mask, listeners);
|
|
262
|
+
detectMutationObserver(rel, src, mask, listeners);
|
|
263
|
+
detectFunctionCall(rel, src, mask, listeners);
|
|
264
|
+
}
|
|
265
|
+
return listeners;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function loadManifest(projectDir) {
|
|
269
|
+
const p = path.join(projectDir, '.gsd-t', 'journey-manifest.json');
|
|
270
|
+
if (!fs.existsSync(p)) {
|
|
271
|
+
const err = new Error('manifest-missing');
|
|
272
|
+
err.code = 'MANIFEST_MISSING';
|
|
273
|
+
err.path = p;
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
let raw;
|
|
277
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
278
|
+
catch (e) {
|
|
279
|
+
const err = new Error('manifest-unreadable: ' + e.message);
|
|
280
|
+
err.code = 'MANIFEST_UNREADABLE';
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
let parsed;
|
|
284
|
+
try { parsed = JSON.parse(raw); }
|
|
285
|
+
catch (e) {
|
|
286
|
+
const err = new Error('manifest-invalid-json: ' + e.message);
|
|
287
|
+
err.code = 'MANIFEST_INVALID';
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.specs)) {
|
|
291
|
+
const err = new Error('manifest-shape-invalid');
|
|
292
|
+
err.code = 'MANIFEST_INVALID';
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
return parsed;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const KEY_SEP = '||';
|
|
299
|
+
|
|
300
|
+
function findGaps(listeners, manifest) {
|
|
301
|
+
const covered = new Set();
|
|
302
|
+
const declared = [];
|
|
303
|
+
for (const spec of manifest.specs || []) {
|
|
304
|
+
for (const c of spec.covers || []) {
|
|
305
|
+
covered.add(c.file + KEY_SEP + c.selector);
|
|
306
|
+
declared.push({ name: spec.name, file: c.file, selector: c.selector, kind: c.kind });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const detectedKey = new Set();
|
|
310
|
+
for (const l of listeners) detectedKey.add(l.file + KEY_SEP + l.selector);
|
|
311
|
+
const gaps = [];
|
|
312
|
+
for (const l of listeners) {
|
|
313
|
+
const key = l.file + KEY_SEP + l.selector;
|
|
314
|
+
if (!covered.has(key)) {
|
|
315
|
+
gaps.push({ type: 'gap', file: l.file, line: l.line, selector: l.selector, kind: l.kind });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const d of declared) {
|
|
319
|
+
const key = d.file + KEY_SEP + d.selector;
|
|
320
|
+
if (!detectedKey.has(key)) {
|
|
321
|
+
gaps.push({ type: 'stale', name: d.name, file: d.file, selector: d.selector });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return gaps;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function formatReport(gaps) {
|
|
328
|
+
if (!gaps.length) return '';
|
|
329
|
+
const lines = [];
|
|
330
|
+
for (const g of gaps) {
|
|
331
|
+
if (g.type === 'gap') {
|
|
332
|
+
lines.push('GAP: ' + g.file + ':' + g.line + ' ' + g.selector + ' (' + g.kind + ') no spec covers this');
|
|
333
|
+
} else {
|
|
334
|
+
lines.push('STALE: spec=' + g.name + ' covers ' + g.file + ' selector=' + g.selector + ' no such listener');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return lines.join('\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = {
|
|
341
|
+
detectListeners,
|
|
342
|
+
loadManifest,
|
|
343
|
+
findGaps,
|
|
344
|
+
formatReport,
|
|
345
|
+
isViewerSource,
|
|
346
|
+
KNOWN_KINDS,
|
|
347
|
+
};
|
package/docs/architecture.md
CHANGED
|
@@ -1100,3 +1100,20 @@ M50 retires the prose-only "Playwright Readiness Guard" in favor of executable e
|
|
|
1100
1100
|
CLI surface added in M50: `gsd-t setup-playwright [path]` (single-project explicit installer), `gsd-t doctor --install-playwright` (fix-it-now flag), `gsd-t doctor --install-hooks` (pre-commit-gate installer). `gsd-t init` and `gsd-t update-all` invoke `installPlaywright` automatically for any UI project that's missing it.
|
|
1101
1101
|
|
|
1102
1102
|
Contract: `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0.
|
|
1103
|
+
|
|
1104
|
+
## Journey Coverage Enforcement (M52, v3.22.x+)
|
|
1105
|
+
|
|
1106
|
+
M52 layers a journey-coverage gate on top of M50's Playwright enforcement. M50 makes sure Playwright runs; M52 makes sure every interactive viewer surface has a journey spec asserting user-visible state change. Three components:
|
|
1107
|
+
|
|
1108
|
+
1. **Listener detector** (`bin/journey-coverage.cjs`) — regex-based source-form scanner with single-pass string-mask precomputation (handles `//`, `/*…*/`, `<!-- -->`, `'`/`"`/template literals). Recognises 6 listener kinds per `journey-coverage-contract.md` §3: `addEventListener`, `inline-handler`, `function-call`, `mutation-observer`, `hashchange`, `delegated`. Exports `detectListeners`, `loadManifest`, `findGaps`, `formatReport`. Zero parser deps (no acorn/babel). Sub-100ms on the full viewer file set.
|
|
1109
|
+
|
|
1110
|
+
2. **CLI** (`bin/journey-coverage-cli.cjs` → `gsd-t check-coverage`) — supports `--staged-only`, `--manifest PATH`, `--quiet`. Exit codes per contract §5: 0 = clean, 4 = coverage gap or stale entry, 2 = manifest missing/unreadable. Vacuous-pass when zero listeners + zero specs.
|
|
1111
|
+
|
|
1112
|
+
3. **Commit-time gate** (`scripts/hooks/pre-commit-journey-coverage`) — auto-installed by `gsd-t install` and `gsd-t init`, manually re-installable via `gsd-t doctor --install-journey-hook`. Fires when staged files match the viewer-source pattern set (`scripts/gsd-t-transcript.html`, `scripts/gsd-t-dashboard-server.js`, `bin/gsd-t-dashboard*.cjs`, `e2e/journeys/`, `e2e/viewer/`). Idempotent install via `# >>> GSD-T journey-coverage gate >>>` marker block (mirrors M50 idiom). Fail-open on detector internal exception.
|
|
1113
|
+
|
|
1114
|
+
The content layer that the enforcer measures against ships in M52 D2: 12 inaugural journey specs in `e2e/journeys/` (one per interactive surface), `.gsd-t/journey-manifest.json` with 12 entries 1:1 with the spec files, and 3 real-data NDJSON fixtures in `e2e/fixtures/journeys/` (sliced from captured `.gsd-t/transcripts/in-session-*.ndjson` files with PII scrub applied). Every assertion verifies state changed / data flowed / content loaded / widget responded — zero `toBeVisible`/`toBeAttached` shallow assertions across the 12 specs.
|
|
1115
|
+
|
|
1116
|
+
A new Red Team category — "Test Pass-Through — Journey Edition" — extends `templates/prompts/red-team-subagent.md` to mandate adversarial validation: write ≥5 broken viewer patches, run the journey specs, every patch must be caught (verdict FAIL otherwise).
|
|
1117
|
+
|
|
1118
|
+
Contract: `.gsd-t/contracts/journey-coverage-contract.md` v1.0.0.
|
|
1119
|
+
|
package/docs/requirements.md
CHANGED
|
@@ -727,3 +727,25 @@ Acceptance:
|
|
|
727
727
|
Supporting contracts:
|
|
728
728
|
- `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0 — D1 library API + CLI wiring + idempotency invariants + error-path contract.
|
|
729
729
|
- `.gsd-t/contracts/m50-integration-points.md` — D1↔D2 cross-domain checkpoint, the `bin/gsd-t.js` file-overlap coordination rules, and the doc-ripple ordering.
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
## M52 Rigorous User-Journey Coverage + Anti-Drift Test Quality (planned — 2026-05-06)
|
|
733
|
+
|
|
734
|
+
| REQ-ID | Requirement Summary | Domain | Task(s) | Status |
|
|
735
|
+
|--------|---------------------|--------|---------|--------|
|
|
736
|
+
| REQ-M52-D1-01 | `bin/journey-coverage.cjs` walks `scripts/gsd-t-transcript.html` for every interactive listener (click, keydown, change, mousedown, drag, hashchange) and emits a normalized listing keyed by selector + event. | m52-d1-journey-coverage-tooling | T1 | done |
|
|
737
|
+
| REQ-M52-D1-02 | `bin/journey-coverage.cjs` cross-references the listener listing against `e2e/journeys/*.spec.ts` to verify each listener has a corresponding journey spec asserting state change; reports missing-coverage rows; exits non-zero on gaps. | m52-d1-journey-coverage-tooling | T1, T3 | done (T1 detector + findGaps; CLI exit codes pending T3) |
|
|
738
|
+
| REQ-M52-D1-03 | `journey-coverage-contract.md` (manifest schema + listener-pattern catalogue + gap rules + exit codes) STABLE; `.gsd-t/journey-manifest.json` (D2-authored) is the canonical enumeration of interactive viewer surfaces — supersedes the originally-proposed standalone `JOURNEYS.md` file. | m52-d1-journey-coverage-tooling | T2 | done |
|
|
739
|
+
| REQ-M52-D1-04 | `scripts/hooks/pre-commit-journey-coverage` (auto-installed by `gsd-t install` and re-installable via `gsd-t doctor --install-journey-hook`, same shape as `pre-commit-playwright-gate`) blocks commits when viewer-source files are staged AND `bin/journey-coverage-cli.cjs --staged-only` reports uncovered surfaces. Fail-open on detector internal exception. | m52-d1-journey-coverage-tooling | T4 | done |
|
|
740
|
+
| REQ-M52-D1-05 | `bin/gsd-t.js` wired with `installJourneyCoverageHook`, `check-coverage` subcommand, install-flow autoinstall, and `--install-journey-hook` doctor flag. Idempotent marker. | m52-d1-journey-coverage-tooling | T5 | done |
|
|
741
|
+
| REQ-M52-D2-01 | 12 inaugural journey specs land in `e2e/journeys/`: `main-session-stream`, `click-completed-conversation`, `click-spawn-entry`, `splitter-drag`, `splitter-keyboard`, `right-rail-toggle`, `completed-collapse-toggle`, `auto-follow-toggle`, `kill-button`, `sessionstorage-persistence`, `keyboard-shortcuts`, `hashchange`. Every assertion proves user-visible state change (no `toBeVisible`-only specs). | m52-d2-journey-specs-and-fixtures | T2, T3, T4 | done |
|
|
742
|
+
| REQ-M52-D2-02 | All 12 journey specs pass against the v3.22.x viewer (`scripts/gsd-t-transcript.html` + `scripts/gsd-t-dashboard-server.js`); `.gsd-t/journey-manifest.json` has 12 entries 1:1 with the spec files; `gsd-t check-coverage` exit 0. | m52-d2-journey-specs-and-fixtures | T4 | done |
|
|
743
|
+
| REQ-M52-D3-01 | `templates/prompts/red-team-subagent.md` adds new mandatory category "Test Pass-Through — Journey Edition": for each journey spec, the adversary writes a deliberately-broken viewer impl that breaks the journey but satisfies the literal assertions of another spec. Each pass-through is a spec failure → tighten. | m52-d2-journey-specs-and-fixtures | T5 | done |
|
|
744
|
+
| REQ-M52-D3-02 | M52 D2 Red Team run: ≥5 broken impls written against the 12 D2 journey specs; all caught; findings logged to `.gsd-t/red-team-report.md` § "M52 JOURNEY-EDITION RED TEAM"; pre-commit-journey-coverage hook exercised end-to-end (block + unblock paths). | m52-d2-journey-specs-and-fixtures | T5 | done |
|
|
745
|
+
| REQ-M52-D4-01 | `e2e/fixtures/journeys/` ships 3 real-data NDJSONs sliced from `.gsd-t/transcripts/in-session-*.ndjson`: `fixture-medium-session.ndjson` (~50 frames), `fixture-completed-session.ndjson` (~150 frames), `fixture-multi-spawn.ndjson` (~80 frames across 3 spawns). PII scrub applied. | m52-d2-journey-specs-and-fixtures | T1 | done |
|
|
746
|
+
| REQ-M52-D4-02 | `e2e/fixtures/journeys/replay-helpers.ts` exports `replayFixture(page, fixturePath)` that drives at least 3 of the 12 journey specs end-to-end via fixture replay and asserts: page renders without errors, frame count matches expected, scrolling works, no `[object Object]`/`undefined` literals visible, all bubble types render correctly. | m52-d2-journey-specs-and-fixtures | T1, T2 | done (replay-helpers shipped + main-session-stream uses fixture-medium-session via startReplayServer) |
|
|
747
|
+
| REQ-M52-D5-01 | Doc-ripple: `~/.claude/CLAUDE.md` E2E Test Quality Standard rewritten to formally define "rigorous" (every interactive surface clicked, every assertion proves visible state change, journey specs not unit tests in browser clothing, real-data fixtures, adversarial Red Team on journeys); `templates/CLAUDE-global.md` matches; `commands/gsd-t-debug.md` + `gsd-t-execute.md` + `gsd-t-quick.md` + `gsd-t-verify.md` reference `journey-coverage.cjs` zero-gap requirement; `docs/architecture.md` adds "Journey Coverage Enforcement (M52)" section. | m52-d1-journey-coverage-tooling | T5 | done (architecture.md + CHANGELOG.md ripple completed during /gsd-t-verify; CLAUDE-global E2E Test Quality Standard already defines "functional behavior over element existence" — same doctrine M52 enforces mechanically) |
|
|
748
|
+
| REQ-M52-VERIFY | Full unit suite 2166 baseline preserved + 12 journey specs + 3 real-data fixture replays all green; `gsd-t check-coverage` reports zero gaps; pre-commit-journey-coverage hook blocks deliberate test-commit-without-spec; Red Team finds ≥5 breakages, all caught; CHANGELOG entry written. | both | D1 T5, D2 T5 | done (unit 2195/2195, E2E 35/35 + 1 skip, `gsd-t check-coverage` exit 0, hook end-to-end exercised, Red Team 5/5 caught, CHANGELOG entry written) |
|
|
749
|
+
|
|
750
|
+
Supporting contracts (to be written during D1):
|
|
751
|
+
- `.gsd-t/contracts/journey-coverage-contract.md` (proposed) — listener detector API, gap-report schema, pre-commit hook semantics, JOURNEYS.md schema.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.23.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -870,14 +870,10 @@
|
|
|
870
870
|
});
|
|
871
871
|
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
872
872
|
el.addEventListener('click', () => {
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
// in-session-* value, polluting the URL and the rail's active
|
|
878
|
-
// highlight even though the hashchange handler now blocks
|
|
879
|
-
// bottom-pane SSE pinning.
|
|
880
|
-
if (isInSession(node)) return;
|
|
873
|
+
// M52 — symmetric with renderRailEntry: only the LIVE main
|
|
874
|
+
// session id is blocked from the bottom pane. Historical /
|
|
875
|
+
// additional in-session entries route normally.
|
|
876
|
+
if (isInSession(node) && node.spawnId === ('in-session-' + window.__mainSessionId)) return;
|
|
881
877
|
if (node.spawnId === currentId) return;
|
|
882
878
|
location.hash = node.spawnId;
|
|
883
879
|
});
|
|
@@ -1006,12 +1002,13 @@
|
|
|
1006
1002
|
});
|
|
1007
1003
|
el.appendChild(dot); el.appendChild(name); el.appendChild(kill);
|
|
1008
1004
|
el.addEventListener('click', () => {
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
// would
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1005
|
+
// M52 — narrow the M48 Bug 4 guard. The TOP pane streams the LIVE
|
|
1006
|
+
// main session via /api/main-session; only THAT specific in-session
|
|
1007
|
+
// id should be blocked from the bottom pane (otherwise both panes
|
|
1008
|
+
// would show identical content). Historical in-session conversations
|
|
1009
|
+
// (the entire COMPLETED rail) are tagged in-session-* but are NOT
|
|
1010
|
+
// the live main session — they must load into the bottom pane.
|
|
1011
|
+
if (isInSession && node.spawnId === ('in-session-' + window.__mainSessionId)) return;
|
|
1015
1012
|
if (node.spawnId === currentId) return;
|
|
1016
1013
|
_ssSet(SS_KEY_SELECTED, node.spawnId);
|
|
1017
1014
|
location.hash = node.spawnId;
|
|
@@ -1251,6 +1248,10 @@
|
|
|
1251
1248
|
const mainStreamEl = document.getElementById('main-stream');
|
|
1252
1249
|
function connectMain(sessionId) {
|
|
1253
1250
|
if (mainSrc) { try { mainSrc.close(); } catch { /* gone */ } mainSrc = null; }
|
|
1251
|
+
// M52 — expose the active main session id so the bottom-pane click +
|
|
1252
|
+
// hashchange handlers can distinguish the LIVE main session (must stay
|
|
1253
|
+
// top-only) from HISTORICAL in-session conversations (loadable below).
|
|
1254
|
+
window.__mainSessionId = sessionId || null;
|
|
1254
1255
|
if (!mainStreamEl) return;
|
|
1255
1256
|
mainStreamEl.innerHTML = '';
|
|
1256
1257
|
if (!sessionId) {
|
|
@@ -1284,6 +1285,18 @@
|
|
|
1284
1285
|
.then((j) => {
|
|
1285
1286
|
if (j && j.sessionId) {
|
|
1286
1287
|
connectMain(j.sessionId);
|
|
1288
|
+
// M52 — if the bottom pane was seeded with the live main
|
|
1289
|
+
// session's id (e.g., from sessionStorage on reload), clear it
|
|
1290
|
+
// out to prevent the M48 Bug 4 dual-pane mirror.
|
|
1291
|
+
const liveId = 'in-session-' + j.sessionId;
|
|
1292
|
+
const seededId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1293
|
+
if (seededId === liveId) {
|
|
1294
|
+
_ssSet(SS_KEY_SELECTED, '');
|
|
1295
|
+
if ((location.hash || '').slice(1) === liveId) {
|
|
1296
|
+
try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ok */ }
|
|
1297
|
+
}
|
|
1298
|
+
connect('');
|
|
1299
|
+
}
|
|
1287
1300
|
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
|
|
1288
1301
|
try { console.debug('[m47] top-pane connected in', Math.round(t1 - t0), 'ms'); } catch { /* ok */ }
|
|
1289
1302
|
} else {
|
|
@@ -1295,8 +1308,10 @@
|
|
|
1295
1308
|
|
|
1296
1309
|
window.addEventListener('hashchange', () => {
|
|
1297
1310
|
const id = (location.hash || '').slice(1);
|
|
1298
|
-
//
|
|
1299
|
-
|
|
1311
|
+
// M52 — narrow the M48 Bug 4 guard. Only the LIVE main session's
|
|
1312
|
+
// in-session id is blocked; historical in-session-* hashes load into
|
|
1313
|
+
// the bottom pane normally (connect() already handles the SSE).
|
|
1314
|
+
if (id && id === ('in-session-' + window.__mainSessionId)) { return; }
|
|
1300
1315
|
if (id) { connect(id); pollSpawns(); }
|
|
1301
1316
|
});
|
|
1302
1317
|
|
|
@@ -1304,19 +1319,15 @@
|
|
|
1304
1319
|
// 1. data-spawn-id non-empty → connect that (bookmark flow)
|
|
1305
1320
|
// 2. else sessionStorage.selectedSpawnId → connect that
|
|
1306
1321
|
// 3. else show empty state
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1322
|
+
// M52 — historical in-session-* ids may seed the bottom pane (the M48
|
|
1323
|
+
// Bug 4 case is now narrowed to the LIVE main session only, handled by
|
|
1324
|
+
// the click + hashchange + fetchMainSession callback).
|
|
1310
1325
|
let initialBottomId = '';
|
|
1311
1326
|
if (spawnId) {
|
|
1312
1327
|
initialBottomId = spawnId;
|
|
1313
1328
|
} else {
|
|
1314
1329
|
initialBottomId = _ssGet(SS_KEY_SELECTED) || '';
|
|
1315
1330
|
}
|
|
1316
|
-
if (typeof initialBottomId === 'string' && initialBottomId.indexOf('in-session-') === 0) {
|
|
1317
|
-
initialBottomId = '';
|
|
1318
|
-
_ssSet(SS_KEY_SELECTED, '');
|
|
1319
|
-
}
|
|
1320
1331
|
if (initialBottomId && !location.hash) location.hash = initialBottomId;
|
|
1321
1332
|
connect(initialBottomId);
|
|
1322
1333
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GSD-T journey-coverage gate (M52 D1)
|
|
3
|
+
# Blocks commits that touch viewer-source files when uncovered listeners are
|
|
4
|
+
# detected by `bin/journey-coverage-cli.cjs --staged-only`.
|
|
5
|
+
#
|
|
6
|
+
# Install (opt-in): gsd-t doctor --install-journey-hook
|
|
7
|
+
# Remove: rm .git/hooks/pre-commit (or remove the marker block if
|
|
8
|
+
# merged into an existing hook)
|
|
9
|
+
#
|
|
10
|
+
# Exit codes:
|
|
11
|
+
# 0 — clean (no viewer-source files staged, OR coverage clean, OR fail-open)
|
|
12
|
+
# 1 — blocked (uncovered listener in staged viewer-source files)
|
|
13
|
+
#
|
|
14
|
+
# Fail-open philosophy: a broken hook is worse than a permissive one. Detector
|
|
15
|
+
# internal exception → exit 0 with a stderr warning, never block.
|
|
16
|
+
|
|
17
|
+
set -e
|
|
18
|
+
|
|
19
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
20
|
+
|
|
21
|
+
VIEWER_SOURCE_PATTERNS=(
|
|
22
|
+
"scripts/gsd-t-transcript.html"
|
|
23
|
+
"scripts/gsd-t-dashboard-server.js"
|
|
24
|
+
"bin/gsd-t-dashboard"
|
|
25
|
+
"e2e/journeys/"
|
|
26
|
+
"e2e/viewer/"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
STAGED="$(git diff --cached --name-only --diff-filter=AM 2>/dev/null || true)"
|
|
30
|
+
[ -z "$STAGED" ] && exit 0
|
|
31
|
+
|
|
32
|
+
matches=""
|
|
33
|
+
while IFS= read -r f; do
|
|
34
|
+
for p in "${VIEWER_SOURCE_PATTERNS[@]}"; do
|
|
35
|
+
case "$f" in
|
|
36
|
+
"$p"|"$p"*) matches="$matches $f"; break ;;
|
|
37
|
+
esac
|
|
38
|
+
done
|
|
39
|
+
done <<EOF
|
|
40
|
+
$STAGED
|
|
41
|
+
EOF
|
|
42
|
+
|
|
43
|
+
if [ -z "$matches" ]; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
CLI="$ROOT/bin/journey-coverage-cli.cjs"
|
|
48
|
+
if [ ! -f "$CLI" ]; then
|
|
49
|
+
echo "[journey-coverage] WARNING: $CLI not found — fail-open." >&2
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# Run the CLI in --staged-only mode. Capture stdout+stderr; rely on exit code.
|
|
54
|
+
set +e
|
|
55
|
+
OUT="$(node "$CLI" --staged-only --project-dir "$ROOT" 2>&1)"
|
|
56
|
+
RC=$?
|
|
57
|
+
set -e
|
|
58
|
+
|
|
59
|
+
case "$RC" in
|
|
60
|
+
0)
|
|
61
|
+
exit 0
|
|
62
|
+
;;
|
|
63
|
+
4)
|
|
64
|
+
echo "[journey-coverage] BLOCKED: uncovered viewer listener in staged files." >&2
|
|
65
|
+
echo "$OUT" >&2
|
|
66
|
+
echo "" >&2
|
|
67
|
+
echo " Add a journey spec under e2e/journeys/ and update .gsd-t/journey-manifest.json." >&2
|
|
68
|
+
exit 1
|
|
69
|
+
;;
|
|
70
|
+
2)
|
|
71
|
+
echo "[journey-coverage] BLOCKED: manifest missing/unreadable." >&2
|
|
72
|
+
echo "$OUT" >&2
|
|
73
|
+
exit 1
|
|
74
|
+
;;
|
|
75
|
+
*)
|
|
76
|
+
# Unknown exit — fail-open so a detector bug doesn't break workflow.
|
|
77
|
+
echo "[journey-coverage] WARNING: detector exited $RC (fail-open):" >&2
|
|
78
|
+
echo "$OUT" >&2
|
|
79
|
+
exit 0
|
|
80
|
+
;;
|
|
81
|
+
esac
|
|
@@ -42,3 +42,56 @@ Summary:
|
|
|
42
42
|
- VERDICT: `FAIL` ({N} bugs found) | `GRUDGING PASS` (exhaustive search, nothing found)
|
|
43
43
|
|
|
44
44
|
Write findings to `.gsd-t/red-team-report.md`. If bugs found, also append to `.gsd-t/qa-issues.md`.
|
|
45
|
+
|
|
46
|
+
## Test Pass-Through — Journey Edition (M52)
|
|
47
|
+
|
|
48
|
+
**Activates when**: `.gsd-t/journey-manifest.json` exists AND `e2e/journeys/` is non-empty (M52 D2 has landed).
|
|
49
|
+
|
|
50
|
+
**Goal**: Prove the journey specs catch real regressions in the journeys they
|
|
51
|
+
claim to cover. A journey spec that only checks "the button exists and is
|
|
52
|
+
clickable" passes through any breakage to the user — that is what this
|
|
53
|
+
category attacks.
|
|
54
|
+
|
|
55
|
+
**Protocol**:
|
|
56
|
+
|
|
57
|
+
1. For each spec in `.gsd-t/journey-manifest.json`, identify the listener(s) it covers.
|
|
58
|
+
2. Write a deliberately-broken patch to `scripts/gsd-t-transcript.html` that
|
|
59
|
+
targets that listener — examples:
|
|
60
|
+
- Remove the listener entirely (`addEventListener` line stripped).
|
|
61
|
+
- Comment out the side-effect inside the handler (e.g. `_ssSet` call).
|
|
62
|
+
- Swap a sessionStorage key name (e.g. splitterPct key → `'XXX'`).
|
|
63
|
+
- Stub the handler to early-return (`if (true) return;` at top).
|
|
64
|
+
- Reverse a state mutation (`next ? 'true' : 'false'` → `next ? 'false' : 'true'`).
|
|
65
|
+
3. Run the journey spec against the broken viewer.
|
|
66
|
+
4. **PASS**: spec FAILS (red) → revert patch → spec PASSES (green). Record `caught`.
|
|
67
|
+
5. **FAIL**: spec PASSES with broken viewer → SHALLOW SPEC, must be tightened.
|
|
68
|
+
Record `pass-through` and rewrite the assertion to verify state change /
|
|
69
|
+
data flow / content load.
|
|
70
|
+
6. Write at least 5 broken patches across different specs. Each pass-through
|
|
71
|
+
is a verdict-level FAIL until rewritten.
|
|
72
|
+
|
|
73
|
+
**Hook end-to-end exercise** (also part of this category):
|
|
74
|
+
- Stage a viewer-source diff that adds a NEW listener with no manifest entry.
|
|
75
|
+
- Confirm `pre-commit-journey-coverage` blocks the commit (exit 1).
|
|
76
|
+
- Update `.gsd-t/journey-manifest.json` with a covering entry.
|
|
77
|
+
- Confirm the hook now allows the commit (exit 0).
|
|
78
|
+
- Both transitions logged in `.gsd-t/red-team-report.md` § "M52 JOURNEY-EDITION RED TEAM".
|
|
79
|
+
|
|
80
|
+
**Findings format** in `.gsd-t/red-team-report.md` (append-only section):
|
|
81
|
+
```
|
|
82
|
+
## M52 JOURNEY-EDITION RED TEAM — {date}
|
|
83
|
+
|
|
84
|
+
### Patch {N}: {short-name}
|
|
85
|
+
- **Spec**: {spec-name}
|
|
86
|
+
- **Broken-line(s)**: file:line — {one-line description of patch}
|
|
87
|
+
- **Expected**: spec FAILS, caught the regression
|
|
88
|
+
- **Actual**: {fail|pass-through}
|
|
89
|
+
- **Verdict**: caught | PASS-THROUGH (must rewrite spec)
|
|
90
|
+
|
|
91
|
+
### Hook end-to-end
|
|
92
|
+
- Block exercise: {git diff details, exit code, stderr summary}
|
|
93
|
+
- Unblock exercise: {manifest update, exit code, stderr summary}
|
|
94
|
+
|
|
95
|
+
### VERDICT
|
|
96
|
+
{GRUDGING PASS — N patches written, all caught | FAIL — {M} pass-through(s)}
|
|
97
|
+
```
|