@tekyzinc/gsd-t 3.27.10 → 3.29.11
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 +134 -0
- package/README.md +2 -0
- package/bin/gsd-t-test-data-adapters/file-json-array.cjs +80 -0
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +49 -0
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +86 -0
- package/bin/gsd-t-test-data-ledger.cjs +291 -0
- package/bin/gsd-t-time-format.cjs +94 -0
- package/bin/gsd-t.js +9 -0
- package/bin/orchestrator.js +4 -1
- package/commands/gsd-t-complete-milestone.md +7 -3
- package/commands/gsd-t-help.md +7 -0
- package/commands/gsd-t-init.md +1 -1
- package/commands/gsd-t-verify.md +44 -0
- package/package.json +1 -1
- package/scripts/gsd-t-date-guard.js +26 -5
- package/scripts/gsd-t-design-review-server.js +3 -1
- package/templates/CLAUDE-global.md +31 -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,140 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [3.29.11] - 2026-05-29 09:57 PDT
|
|
6
|
+
|
|
7
|
+
### Fixed — CRITICAL test-data adapter data-destruction bug (M60)
|
|
8
|
+
|
|
9
|
+
Found by a native-Workflow Red Team bake-off (Opus 4.8, perspective-diverse
|
|
10
|
+
adversarial review) while evaluating GSD-T against native Claude Code 4.8
|
|
11
|
+
capability. M58's own in-session Red Team had passed this as "6/6 defended"
|
|
12
|
+
— the claim was wrong.
|
|
13
|
+
|
|
14
|
+
- **Bug**: `bin/gsd-t-test-data-adapters/file-json-array.cjs` and
|
|
15
|
+
`localstorage-key-prefix.cjs` guarded with
|
|
16
|
+
`typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(...)`.
|
|
17
|
+
An empty/undefined `taggedPrefix` short-circuits the whole condition, so
|
|
18
|
+
**no guard runs** — `gsd-t test-data --purge` would delete untagged
|
|
19
|
+
production records and report `errors:0` (gate GREEN). No adapter enforced
|
|
20
|
+
`projectDir` containment on the `store` path, making it a write-anywhere
|
|
21
|
+
delete primitive. Reproduced live before fixing.
|
|
22
|
+
- **Fix** (additive refusal only — removes capability, never adds destructive
|
|
23
|
+
behavior): both adapters now hard-refuse empty/undefined `taggedPrefix`
|
|
24
|
+
(matching the already-correct `sqlite-table-where` adapter); `file-json-array`
|
|
25
|
+
and `sqlite-table-where` enforce the containment predicate
|
|
26
|
+
`resolved.startsWith(root + sep) && resolved !== root` when `projectDir` is
|
|
27
|
+
supplied; `purgeRunInserts` threads `projectDir` into every adapter call.
|
|
28
|
+
- **Tests**: new `test/m60-redteam-regressions.test.js` (10 tests, one per
|
|
29
|
+
finding + happy-path + back-compat). M58 suite 44/44 unchanged.
|
|
30
|
+
- **Contract**: `test-data-tagging-contract.md` → v1.1.0 STABLE (empty-prefix
|
|
31
|
+
refusal + path containment now normative).
|
|
32
|
+
- Backward-compatible: the happy path (non-empty prefix, in-project path, or
|
|
33
|
+
no `projectDir`) is unchanged.
|
|
34
|
+
|
|
35
|
+
## [3.29.10] - 2026-05-27 10:09 PDT
|
|
36
|
+
|
|
37
|
+
### Changed — Timestamp precision in progress.md (forward-only)
|
|
38
|
+
|
|
39
|
+
Origin: GSD-T-Board (and humans reading progress.md mid-workday) need
|
|
40
|
+
timestamp precision finer than a day. Many GSD-T phases run multiple
|
|
41
|
+
times per day; date-only entries collapse the timeline. The Decision Log
|
|
42
|
+
already used `YYYY-MM-DD HH:MM:`; this release extends that precision to
|
|
43
|
+
the visible "Completed" / "Date" cells and frontmatter.
|
|
44
|
+
|
|
45
|
+
- **`commands/gsd-t-complete-milestone.md`** — Completed Milestones table
|
|
46
|
+
rows now write the "Completed" cell as `YYYY-MM-DD HH:MM TZ` (e.g.
|
|
47
|
+
`2026-05-27 10:09 PDT`). Milestone archive `**Completed**:` line uses
|
|
48
|
+
the same format. The progress.md `## Date:` line is bumped on
|
|
49
|
+
milestone completion.
|
|
50
|
+
- **`commands/gsd-t-init.md`** — initial `## Date:` line in seed
|
|
51
|
+
progress.md uses the new format.
|
|
52
|
+
- **`templates/progress.md`** — Session Log "Date" cell + `## Date:`
|
|
53
|
+
frontmatter use the new format. Inline comments document the
|
|
54
|
+
forward-only convention (readers MUST accept both old date-only and
|
|
55
|
+
new date+time+TZ rows).
|
|
56
|
+
- **`bin/gsd-t-time-format.cjs`** (new) — shared helpers:
|
|
57
|
+
- `localIsoWithOffset()` → `YYYY-MM-DDTHH:MM:SS±HH:MM` (replaces
|
|
58
|
+
`new Date().toISOString()` for `archive-meta.json::completedAt`)
|
|
59
|
+
- `localTimestampForProgress()` → `YYYY-MM-DD HH:MM TZ`
|
|
60
|
+
- **`bin/orchestrator.js` + `scripts/gsd-t-design-review-server.js`** —
|
|
61
|
+
`completedAt` JSON fields now emit local-offset ISO instead of UTC `Z`,
|
|
62
|
+
via `localIsoWithOffset()`.
|
|
63
|
+
- **`scripts/gsd-t-date-guard.js`** (PreToolUse hook):
|
|
64
|
+
- `stamped-iso` pattern extended to accept optional TZ abbreviation,
|
|
65
|
+
numeric offset (`±HH:MM` / `±HHMM`), or `Z` after the `HH:MM`.
|
|
66
|
+
- New `progress-table-cell` pattern validates `| YYYY-MM-DD HH:MM TZ |`
|
|
67
|
+
in table cells against ±5 min live-clock drift.
|
|
68
|
+
- **`templates/CLAUDE-global.md` + `~/.claude/CLAUDE.md`** — Live Clock
|
|
69
|
+
Rule documents the new format requirements.
|
|
70
|
+
|
|
71
|
+
### Forward-only — NOT a migration
|
|
72
|
+
|
|
73
|
+
Pre-3.29.10 rows in existing `progress.md` files (date-only `YYYY-MM-DD`)
|
|
74
|
+
**stay as-is**. No rewrite. The format change applies only to entries
|
|
75
|
+
written from this version forward. Readers (status, dashboard,
|
|
76
|
+
GSD-T-Board) handle both formats — the change is back-compat by design.
|
|
77
|
+
|
|
78
|
+
### Falsifiable verification
|
|
79
|
+
|
|
80
|
+
- 9 new unit tests in `test/m59-time-format.test.js` covering the
|
|
81
|
+
helper + both date-guard regexes + writer→guard round-trip.
|
|
82
|
+
- Full suite: **2658/2658 pass** (baseline 2649 + 9 new, **zero
|
|
83
|
+
regressions**).
|
|
84
|
+
- Date-guard regex tests confirm: ✅ `Date: 2024-05-27 10:15 PDT`,
|
|
85
|
+
✅ `Date: 2024-05-27T10:15:00-07:00`, ✅ `| 2024-05-27 10:15 PDT |`
|
|
86
|
+
in table cells; ✅ pre-existing `| 2024-05-27 |` date-only cells
|
|
87
|
+
remain valid (not flagged).
|
|
88
|
+
|
|
89
|
+
**Versioning**: minor bump 3.28.10 → **3.29.10** (additive capability,
|
|
90
|
+
no breaking reader changes — every consumer already handled the
|
|
91
|
+
opaque-string case).
|
|
92
|
+
|
|
93
|
+
## [3.28.10] - 2026-05-27
|
|
94
|
+
|
|
95
|
+
### Added — M58 Test Data Cleanup Gate
|
|
96
|
+
|
|
97
|
+
Origin: GSD-T-Board v0.1.10 ran `gsd-t-verify`, the Playwright suite
|
|
98
|
+
passed, the milestone was tagged VERIFIED — and 2442 `E2E_TEST_*` /
|
|
99
|
+
`E2E_DRAG_*` ideas stayed live in the production data store. Root cause:
|
|
100
|
+
GSD-T had no convention for tracking test data inserted during Verify and
|
|
101
|
+
no purge step after the suite completes.
|
|
102
|
+
|
|
103
|
+
- **`gsd-t test-data`** (`bin/gsd-t-test-data-ledger.cjs`) — append-only
|
|
104
|
+
JSONL ledger at `.gsd-t/test-data-ledger.jsonl` recording every test
|
|
105
|
+
insert as `{runId, kind, store, id, taggedPrefix, insertedAt}`. Public
|
|
106
|
+
API: `appendInsert`, `listInserts`, `purgeRunInserts`, `registerAdapter`.
|
|
107
|
+
CLI: `gsd-t test-data --list [--run <id>]` / `gsd-t test-data --purge
|
|
108
|
+
--run <id> [--dry-run]`. Exit 0 on success, 4 on adapter errors.
|
|
109
|
+
- **Three built-in adapters** (`bin/gsd-t-test-data-adapters/`):
|
|
110
|
+
`localStorage-key-prefix` (Playwright page.evaluate-based), `file-json-array`
|
|
111
|
+
(atomic write-temp + rename), `sqlite-table-where` (parameterized DELETE
|
|
112
|
+
with tagged-prefix LIKE guard; dynamic `better-sqlite3` require). Every
|
|
113
|
+
adapter refuses to delete a record whose id doesn't start with the
|
|
114
|
+
ledger row's `taggedPrefix` — defense in depth.
|
|
115
|
+
- **`withTestData()` Playwright fixture**
|
|
116
|
+
(`templates/test-helpers/test-data-fixture.ts`) — opt-in fixture exposing
|
|
117
|
+
`testData.tag(prefix)` and `testData.register({...})`. Tagging convention:
|
|
118
|
+
`{PREFIX}_{verifyRunId}_{counter}`. Reads `process.env.GSD_T_VERIFY_RUN_ID`
|
|
119
|
+
set by `gsd-t-verify`. Optional `purgePerTest` opt-in for long suites.
|
|
120
|
+
- **`gsd-t-verify` Step 4.5** (new, FAIL-blocking) — runs
|
|
121
|
+
`gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"` after the E2E
|
|
122
|
+
suite, before VERDICT. Any adapter error fails the gate (block-promotion
|
|
123
|
+
semantics, equivalent to a failing CI-Parity Gate). Verify report line:
|
|
124
|
+
`Test Data Cleanup: PASS — purged=N skipped=M errors=E` or `FAIL`.
|
|
125
|
+
- **Contracts** — `test-data-ledger-contract.md` v1.0.0 STABLE +
|
|
126
|
+
`test-data-tagging-contract.md` v1.0.0 STABLE.
|
|
127
|
+
|
|
128
|
+
**Falsifiable SC results** (all PASS):
|
|
129
|
+
- SC1 ledger records 5 inserts from synthetic Playwright fixture ✅
|
|
130
|
+
- SC2 `purgeRunInserts({runId})` removes those 5, reports `purged.length===5` ✅
|
|
131
|
+
- SC3 verify FAILs when ledger entries can't be purged (planted adapter throw) ✅
|
|
132
|
+
- SC4 successful E2E purges cleanly → verify report `purged=5 skipped=0 errors=0` ✅
|
|
133
|
+
- SC5 zero regressions on `npm test` ✅
|
|
134
|
+
- SC6 Red Team GRUDGING PASS ≥5 broken patches caught ✅
|
|
135
|
+
- SC7 doc-ripple complete (verify.md + CLAUDE-global.md + README + help + 2 contracts) ✅
|
|
136
|
+
|
|
137
|
+
**Versioning**: minor bump 3.27.10 → **3.28.10** (new feature, additive).
|
|
138
|
+
|
|
5
139
|
## [3.27.10] - 2026-05-19
|
|
6
140
|
|
|
7
141
|
### Added — M57 CI-Parity Verify Gate
|
package/README.md
CHANGED
|
@@ -121,6 +121,8 @@ gsd-t verify-gate --skip-track1 --json # Diagnostic: Track 2 on
|
|
|
121
121
|
gsd-t verify-gate --max-concurrency 4 --json # Override D3-map default
|
|
122
122
|
gsd-t build-coverage --json # M57: new top-level paths must be a real CI build input (structural parse)
|
|
123
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)
|
|
124
126
|
```
|
|
125
127
|
|
|
126
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.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter: file-json-array
|
|
3
|
+
*
|
|
4
|
+
* Purges a record from a JSON file containing an array.
|
|
5
|
+
* `store` is the file path; `id` is the value of the `id` field on the matching row.
|
|
6
|
+
*
|
|
7
|
+
* Refuses to delete a record whose `id` does not start with `taggedPrefix`.
|
|
8
|
+
* `taggedPrefix` is REQUIRED and non-empty — an empty/omitted prefix would
|
|
9
|
+
* disable the guard entirely (Red Team CRITICAL, M60). Atomic rewrite
|
|
10
|
+
* (write-temp + rename).
|
|
11
|
+
*
|
|
12
|
+
* When `projectDir` is supplied, `store` MUST resolve inside it — the adapter
|
|
13
|
+
* refuses paths outside-AND-equal-to projectDir (containment predicate from
|
|
14
|
+
* feedback_destructive_path_ops_containment). Prevents a tampered ledger from
|
|
15
|
+
* becoming a write-anywhere delete primitive.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
const KIND = 'file-json-array';
|
|
21
|
+
|
|
22
|
+
function assertContained(store, projectDir) {
|
|
23
|
+
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
|
24
|
+
return; // no projectDir supplied — containment not enforceable, caller's choice
|
|
25
|
+
}
|
|
26
|
+
const root = path.resolve(projectDir);
|
|
27
|
+
const resolved = path.resolve(store);
|
|
28
|
+
if (!(resolved.startsWith(root + path.sep) && resolved !== root)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`file-json-array: store path "${store}" resolves outside projectDir "${projectDir}" — refused (containment guard)`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function purge({ store, id, taggedPrefix, projectDir }) {
|
|
36
|
+
if (typeof store !== 'string' || store.length === 0) {
|
|
37
|
+
throw new Error('file-json-array: store must be a non-empty file path');
|
|
38
|
+
}
|
|
39
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
40
|
+
throw new Error('file-json-array: id must be a non-empty string');
|
|
41
|
+
}
|
|
42
|
+
if (typeof taggedPrefix !== 'string' || taggedPrefix.length === 0) {
|
|
43
|
+
throw new Error('file-json-array: taggedPrefix is required and must be non-empty (guard cannot be disabled)');
|
|
44
|
+
}
|
|
45
|
+
if (!id.startsWith(taggedPrefix)) {
|
|
46
|
+
throw new Error(`file-json-array: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
|
|
47
|
+
}
|
|
48
|
+
assertContained(store, projectDir);
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync(store)) {
|
|
51
|
+
return 'absent';
|
|
52
|
+
}
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = fs.readFileSync(store, 'utf8');
|
|
56
|
+
} catch (e) {
|
|
57
|
+
throw new Error(`file-json-array: read failed: ${e.message}`);
|
|
58
|
+
}
|
|
59
|
+
let arr;
|
|
60
|
+
try {
|
|
61
|
+
arr = JSON.parse(raw);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
throw new Error(`file-json-array: parse failed: ${e.message}`);
|
|
64
|
+
}
|
|
65
|
+
if (!Array.isArray(arr)) {
|
|
66
|
+
throw new Error('file-json-array: store contents are not an array');
|
|
67
|
+
}
|
|
68
|
+
const before = arr.length;
|
|
69
|
+
const next = arr.filter((row) => !(row && typeof row === 'object' && row.id === id));
|
|
70
|
+
if (next.length === before) {
|
|
71
|
+
return 'absent';
|
|
72
|
+
}
|
|
73
|
+
// Atomic rewrite
|
|
74
|
+
const tmp = `${store}.tmp.${process.pid}.${Date.now()}`;
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8');
|
|
76
|
+
fs.renameSync(tmp, store);
|
|
77
|
+
return 'purged';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { kind: KIND, purge };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter: localStorage-key-prefix
|
|
3
|
+
*
|
|
4
|
+
* Purges a key from browser localStorage. Designed to be called from
|
|
5
|
+
* Playwright host side via `page.evaluate`.
|
|
6
|
+
*
|
|
7
|
+
* Caller passes `page` via `purge({ page, store, id, taggedPrefix })`.
|
|
8
|
+
* `store` is the key prefix; `id` is the suffix. Final key = store + id.
|
|
9
|
+
*
|
|
10
|
+
* When `page` is omitted, the adapter returns 'absent' rather than throwing
|
|
11
|
+
* — this lets ledger.purgeRunInserts run cleanly when no live browser is
|
|
12
|
+
* present (e.g., after Playwright tears down). Verify-step semantics: if
|
|
13
|
+
* Playwright is gone, the data is gone too (unless persisted server-side,
|
|
14
|
+
* which other adapters handle).
|
|
15
|
+
*/
|
|
16
|
+
const KIND = 'localStorage-key-prefix';
|
|
17
|
+
|
|
18
|
+
async function purge({ page, store, id, taggedPrefix }) {
|
|
19
|
+
if (typeof store !== 'string' || store.length === 0) {
|
|
20
|
+
throw new Error('localStorage-key-prefix: store must be a non-empty key prefix');
|
|
21
|
+
}
|
|
22
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
23
|
+
throw new Error('localStorage-key-prefix: id must be a non-empty string');
|
|
24
|
+
}
|
|
25
|
+
// taggedPrefix is REQUIRED and non-empty — an empty/omitted prefix would
|
|
26
|
+
// disable the guard entirely (Red Team CRITICAL, M60).
|
|
27
|
+
if (typeof taggedPrefix !== 'string' || taggedPrefix.length === 0) {
|
|
28
|
+
throw new Error('localStorage-key-prefix: taggedPrefix is required and must be non-empty (guard cannot be disabled)');
|
|
29
|
+
}
|
|
30
|
+
if (!id.startsWith(taggedPrefix)) {
|
|
31
|
+
throw new Error(`localStorage-key-prefix: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Browser-side cleanup requires a live page. If absent, treat as 'absent'.
|
|
35
|
+
if (!page || typeof page.evaluate !== 'function') {
|
|
36
|
+
return 'absent';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const key = store + id;
|
|
40
|
+
const result = await page.evaluate((k) => {
|
|
41
|
+
if (typeof window === 'undefined' || !window.localStorage) return 'absent';
|
|
42
|
+
if (window.localStorage.getItem(k) === null) return 'absent';
|
|
43
|
+
window.localStorage.removeItem(k);
|
|
44
|
+
return 'purged';
|
|
45
|
+
}, key);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { kind: KIND, purge };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter: sqlite-table-where
|
|
3
|
+
*
|
|
4
|
+
* Purges a row from a SQLite table by ID, with a tagged-prefix LIKE guard.
|
|
5
|
+
* `store` is `dbPath|table|idColumn` (three pipe-separated segments).
|
|
6
|
+
*
|
|
7
|
+
* `better-sqlite3` is dynamically required at adapter-use time — adapter
|
|
8
|
+
* still loads when the module isn't installed. Tests self-skip in that case.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const KIND = 'sqlite-table-where';
|
|
14
|
+
|
|
15
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
function assertContained(dbPath, projectDir) {
|
|
18
|
+
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
|
19
|
+
return; // no projectDir supplied — containment not enforceable, caller's choice
|
|
20
|
+
}
|
|
21
|
+
const root = path.resolve(projectDir);
|
|
22
|
+
const resolved = path.resolve(dbPath);
|
|
23
|
+
if (!(resolved.startsWith(root + path.sep) && resolved !== root)) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`sqlite-table-where: dbPath "${dbPath}" resolves outside projectDir "${projectDir}" — refused (containment guard)`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseStore(store) {
|
|
31
|
+
if (typeof store !== 'string') {
|
|
32
|
+
throw new Error('sqlite-table-where: store must be "dbPath|table|idColumn"');
|
|
33
|
+
}
|
|
34
|
+
const parts = store.split('|');
|
|
35
|
+
if (parts.length !== 3) {
|
|
36
|
+
throw new Error('sqlite-table-where: store must be "dbPath|table|idColumn"');
|
|
37
|
+
}
|
|
38
|
+
const [dbPath, table, idColumn] = parts.map((s) => s.trim());
|
|
39
|
+
if (!dbPath || !table || !idColumn) {
|
|
40
|
+
throw new Error('sqlite-table-where: empty segment in store');
|
|
41
|
+
}
|
|
42
|
+
if (!IDENT_RE.test(table)) {
|
|
43
|
+
throw new Error(`sqlite-table-where: invalid table identifier "${table}"`);
|
|
44
|
+
}
|
|
45
|
+
if (!IDENT_RE.test(idColumn)) {
|
|
46
|
+
throw new Error(`sqlite-table-where: invalid idColumn identifier "${idColumn}"`);
|
|
47
|
+
}
|
|
48
|
+
return { dbPath, table, idColumn };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function purge({ store, id, taggedPrefix, projectDir }) {
|
|
52
|
+
const { dbPath, table, idColumn } = parseStore(store);
|
|
53
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
54
|
+
throw new Error('sqlite-table-where: id must be a non-empty string');
|
|
55
|
+
}
|
|
56
|
+
if (typeof taggedPrefix !== 'string' || taggedPrefix.length === 0) {
|
|
57
|
+
throw new Error('sqlite-table-where: taggedPrefix is required for SQL safety');
|
|
58
|
+
}
|
|
59
|
+
if (!id.startsWith(taggedPrefix)) {
|
|
60
|
+
throw new Error(`sqlite-table-where: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
|
|
61
|
+
}
|
|
62
|
+
assertContained(dbPath, projectDir);
|
|
63
|
+
if (!fs.existsSync(dbPath)) {
|
|
64
|
+
return 'absent';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let Database;
|
|
68
|
+
try {
|
|
69
|
+
Database = require('better-sqlite3');
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new Error('sqlite-table-where: better-sqlite3 not installed; cannot purge');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const db = new Database(dbPath);
|
|
75
|
+
try {
|
|
76
|
+
// Identifiers are validated against IDENT_RE; values use bind parameters.
|
|
77
|
+
const sql = `DELETE FROM "${table}" WHERE "${idColumn}" = ? AND "${idColumn}" LIKE ?`;
|
|
78
|
+
const stmt = db.prepare(sql);
|
|
79
|
+
const info = stmt.run(id, taggedPrefix + '%');
|
|
80
|
+
return info.changes > 0 ? 'purged' : 'absent';
|
|
81
|
+
} finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { kind: KIND, purge };
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gsd-t-test-data-ledger — M58 D1
|
|
4
|
+
*
|
|
5
|
+
* Append-only JSONL ledger tracking test data inserted during a Verify run,
|
|
6
|
+
* plus a purge engine that removes those records from the underlying store
|
|
7
|
+
* after the suite completes.
|
|
8
|
+
*
|
|
9
|
+
* Contract: .gsd-t/contracts/test-data-ledger-contract.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const LEDGER_RELPATH = path.join('.gsd-t', 'test-data-ledger.jsonl');
|
|
16
|
+
|
|
17
|
+
// ─── Adapter registry ─────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const adapters = new Map();
|
|
20
|
+
|
|
21
|
+
function registerAdapter(kind, adapter) {
|
|
22
|
+
if (typeof kind !== 'string' || kind.length === 0) {
|
|
23
|
+
throw new Error('registerAdapter: kind must be a non-empty string');
|
|
24
|
+
}
|
|
25
|
+
if (!adapter || typeof adapter.purge !== 'function') {
|
|
26
|
+
throw new Error('registerAdapter: adapter must export a purge(...) function');
|
|
27
|
+
}
|
|
28
|
+
adapters.set(kind, adapter);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Built-in adapters auto-register on module load.
|
|
32
|
+
registerAdapter('file-json-array', require('./gsd-t-test-data-adapters/file-json-array.cjs'));
|
|
33
|
+
registerAdapter('localStorage-key-prefix', require('./gsd-t-test-data-adapters/localstorage-key-prefix.cjs'));
|
|
34
|
+
registerAdapter('sqlite-table-where', require('./gsd-t-test-data-adapters/sqlite-table-where.cjs'));
|
|
35
|
+
|
|
36
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function ledgerPathFor(projectDir) {
|
|
39
|
+
return path.join(projectDir, LEDGER_RELPATH);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function appendInsert({ projectDir, runId, kind, store, id, taggedPrefix, insertedAt }) {
|
|
43
|
+
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
|
44
|
+
throw new Error('appendInsert: projectDir is required');
|
|
45
|
+
}
|
|
46
|
+
if (typeof runId !== 'string' || runId.length === 0) {
|
|
47
|
+
throw new Error('appendInsert: runId is required');
|
|
48
|
+
}
|
|
49
|
+
if (typeof kind !== 'string' || kind.length === 0) {
|
|
50
|
+
throw new Error('appendInsert: kind is required');
|
|
51
|
+
}
|
|
52
|
+
if (typeof store !== 'string' || store.length === 0) {
|
|
53
|
+
throw new Error('appendInsert: store is required');
|
|
54
|
+
}
|
|
55
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
56
|
+
throw new Error('appendInsert: id is required');
|
|
57
|
+
}
|
|
58
|
+
const finalTaggedPrefix = typeof taggedPrefix === 'string' && taggedPrefix.length > 0
|
|
59
|
+
? taggedPrefix
|
|
60
|
+
: 'E2E_';
|
|
61
|
+
if (!id.startsWith(finalTaggedPrefix)) {
|
|
62
|
+
throw new Error(`appendInsert: id "${id}" does not start with taggedPrefix "${finalTaggedPrefix}"`);
|
|
63
|
+
}
|
|
64
|
+
const finalInsertedAt = typeof insertedAt === 'string' && insertedAt.length > 0
|
|
65
|
+
? insertedAt
|
|
66
|
+
: new Date().toISOString();
|
|
67
|
+
|
|
68
|
+
const row = {
|
|
69
|
+
runId,
|
|
70
|
+
kind,
|
|
71
|
+
store,
|
|
72
|
+
id,
|
|
73
|
+
taggedPrefix: finalTaggedPrefix,
|
|
74
|
+
insertedAt: finalInsertedAt,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const ledgerPath = ledgerPathFor(projectDir);
|
|
78
|
+
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
79
|
+
fs.appendFileSync(ledgerPath, JSON.stringify(row) + '\n', 'utf8');
|
|
80
|
+
return { ok: true, ledgerPath };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function listInserts({ projectDir, runId }) {
|
|
84
|
+
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
|
85
|
+
throw new Error('listInserts: projectDir is required');
|
|
86
|
+
}
|
|
87
|
+
const ledgerPath = ledgerPathFor(projectDir);
|
|
88
|
+
if (!fs.existsSync(ledgerPath)) return [];
|
|
89
|
+
const raw = fs.readFileSync(ledgerPath, 'utf8');
|
|
90
|
+
const rows = [];
|
|
91
|
+
for (const line of raw.split('\n')) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed) continue;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(trimmed);
|
|
96
|
+
if (runId && parsed.runId !== runId) continue;
|
|
97
|
+
rows.push(parsed);
|
|
98
|
+
} catch {
|
|
99
|
+
// skip malformed lines (audit-trail is permissive)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function purgeRunInserts({ projectDir, runId, dryRun }) {
|
|
106
|
+
if (typeof projectDir !== 'string' || projectDir.length === 0) {
|
|
107
|
+
throw new Error('purgeRunInserts: projectDir is required');
|
|
108
|
+
}
|
|
109
|
+
if (typeof runId !== 'string' || runId.length === 0) {
|
|
110
|
+
throw new Error('purgeRunInserts: runId is required');
|
|
111
|
+
}
|
|
112
|
+
const rows = listInserts({ projectDir, runId });
|
|
113
|
+
const purged = [];
|
|
114
|
+
const skipped = [];
|
|
115
|
+
const errors = [];
|
|
116
|
+
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
if (dryRun === true) {
|
|
119
|
+
purged.push(row); // dry-run treats every targeted row as 'would be purged'
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const adapter = adapters.get(row.kind);
|
|
123
|
+
if (!adapter) {
|
|
124
|
+
errors.push({ record: row, message: `no adapter registered for kind "${row.kind}"` });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const result = await adapter.purge({
|
|
129
|
+
store: row.store,
|
|
130
|
+
id: row.id,
|
|
131
|
+
taggedPrefix: row.taggedPrefix,
|
|
132
|
+
projectDir, // thread through so path adapters can enforce containment (M60)
|
|
133
|
+
});
|
|
134
|
+
if (result === 'purged') {
|
|
135
|
+
purged.push(row);
|
|
136
|
+
} else if (result === 'absent') {
|
|
137
|
+
skipped.push(row);
|
|
138
|
+
} else {
|
|
139
|
+
errors.push({ record: row, message: `adapter returned unexpected value "${String(result)}"` });
|
|
140
|
+
}
|
|
141
|
+
} catch (e) {
|
|
142
|
+
errors.push({ record: row, message: e && e.message ? e.message : String(e) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { purged, skipped, errors };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── CLI ──────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const COLOR = {
|
|
152
|
+
reset: '\x1b[0m',
|
|
153
|
+
bold: '\x1b[1m',
|
|
154
|
+
red: '\x1b[31m',
|
|
155
|
+
green: '\x1b[32m',
|
|
156
|
+
yellow: '\x1b[33m',
|
|
157
|
+
blue: '\x1b[34m',
|
|
158
|
+
dim: '\x1b[2m',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function parseArgs(argv) {
|
|
162
|
+
const opts = {
|
|
163
|
+
mode: null, // 'list' | 'purge'
|
|
164
|
+
runId: null,
|
|
165
|
+
dryRun: false,
|
|
166
|
+
json: false,
|
|
167
|
+
projectDir: process.cwd(),
|
|
168
|
+
};
|
|
169
|
+
for (let i = 0; i < argv.length; i++) {
|
|
170
|
+
const a = argv[i];
|
|
171
|
+
if (a === '--list') opts.mode = 'list';
|
|
172
|
+
else if (a === '--purge') opts.mode = 'purge';
|
|
173
|
+
else if (a === '--dry-run') opts.dryRun = true;
|
|
174
|
+
else if (a === '--json') opts.json = true;
|
|
175
|
+
else if (a === '--run' || a === '--run-id') {
|
|
176
|
+
opts.runId = argv[++i] || null;
|
|
177
|
+
} else if (a === '--project') {
|
|
178
|
+
opts.projectDir = argv[++i] || process.cwd();
|
|
179
|
+
} else if (a === '-h' || a === '--help') {
|
|
180
|
+
opts.mode = 'help';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return opts;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function printHelp() {
|
|
187
|
+
process.stdout.write(`Usage: gsd-t test-data --list [--run <id>] [--json]
|
|
188
|
+
gsd-t test-data --purge --run <id> [--dry-run] [--json]
|
|
189
|
+
|
|
190
|
+
Options:
|
|
191
|
+
--list List ledger entries (optionally filtered by --run)
|
|
192
|
+
--purge Purge ledger entries for a given --run
|
|
193
|
+
--run <id> Verify run id (e.g., verify-m58-20260527T091800Z)
|
|
194
|
+
--dry-run With --purge: report what would be purged without calling adapters
|
|
195
|
+
--json Emit JSON envelope instead of pretty output
|
|
196
|
+
--project <dir> Project directory (defaults to CWD)
|
|
197
|
+
-h, --help Show this help
|
|
198
|
+
|
|
199
|
+
Exit codes:
|
|
200
|
+
0 success
|
|
201
|
+
4 one or more adapter errors (purge mode)
|
|
202
|
+
64 CLI argument error
|
|
203
|
+
`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function main(argv) {
|
|
207
|
+
const opts = parseArgs(argv);
|
|
208
|
+
if (opts.mode === 'help' || !opts.mode) {
|
|
209
|
+
printHelp();
|
|
210
|
+
return opts.mode === 'help' ? 0 : 64;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (opts.mode === 'list') {
|
|
214
|
+
const rows = listInserts({ projectDir: opts.projectDir, runId: opts.runId });
|
|
215
|
+
if (opts.json) {
|
|
216
|
+
process.stdout.write(JSON.stringify({ ok: true, rows }) + '\n');
|
|
217
|
+
} else {
|
|
218
|
+
if (rows.length === 0) {
|
|
219
|
+
process.stdout.write(`${COLOR.dim}No ledger entries${opts.runId ? ` for run "${opts.runId}"` : ''}.${COLOR.reset}\n`);
|
|
220
|
+
} else {
|
|
221
|
+
process.stdout.write(`${COLOR.bold}Test data ledger${opts.runId ? ` — run ${opts.runId}` : ''}${COLOR.reset}\n`);
|
|
222
|
+
for (const r of rows) {
|
|
223
|
+
process.stdout.write(` ${COLOR.blue}${r.kind}${COLOR.reset} ${r.id} ${COLOR.dim}(${r.store})${COLOR.reset}\n`);
|
|
224
|
+
}
|
|
225
|
+
process.stdout.write(`\n${COLOR.bold}Total:${COLOR.reset} ${rows.length}\n`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (opts.mode === 'purge') {
|
|
232
|
+
if (!opts.runId) {
|
|
233
|
+
process.stderr.write('gsd-t test-data --purge requires --run <id>\n');
|
|
234
|
+
return 64;
|
|
235
|
+
}
|
|
236
|
+
const envelope = await purgeRunInserts({
|
|
237
|
+
projectDir: opts.projectDir,
|
|
238
|
+
runId: opts.runId,
|
|
239
|
+
dryRun: opts.dryRun,
|
|
240
|
+
});
|
|
241
|
+
if (opts.json) {
|
|
242
|
+
process.stdout.write(JSON.stringify({
|
|
243
|
+
ok: envelope.errors.length === 0,
|
|
244
|
+
runId: opts.runId,
|
|
245
|
+
dryRun: !!opts.dryRun,
|
|
246
|
+
purged: envelope.purged.length,
|
|
247
|
+
skipped: envelope.skipped.length,
|
|
248
|
+
errors: envelope.errors,
|
|
249
|
+
}) + '\n');
|
|
250
|
+
} else {
|
|
251
|
+
const tag = opts.dryRun ? '[DRY RUN] ' : '';
|
|
252
|
+
process.stdout.write(`${COLOR.bold}${tag}Purge run ${opts.runId}${COLOR.reset}\n`);
|
|
253
|
+
process.stdout.write(` ${COLOR.green}purged:${COLOR.reset} ${envelope.purged.length}\n`);
|
|
254
|
+
process.stdout.write(` ${COLOR.yellow}skipped:${COLOR.reset} ${envelope.skipped.length}\n`);
|
|
255
|
+
process.stdout.write(` ${COLOR.red}errors:${COLOR.reset} ${envelope.errors.length}\n`);
|
|
256
|
+
if (envelope.errors.length > 0) {
|
|
257
|
+
process.stdout.write(`\n${COLOR.red}Errors:${COLOR.reset}\n`);
|
|
258
|
+
for (const e of envelope.errors.slice(0, 5)) {
|
|
259
|
+
process.stdout.write(` - ${e.record.id} (${e.record.kind}): ${e.message}\n`);
|
|
260
|
+
}
|
|
261
|
+
if (envelope.errors.length > 5) {
|
|
262
|
+
process.stdout.write(` … and ${envelope.errors.length - 5} more\n`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return envelope.errors.length === 0 ? 0 : 4;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
printHelp();
|
|
270
|
+
return 64;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
appendInsert,
|
|
275
|
+
listInserts,
|
|
276
|
+
purgeRunInserts,
|
|
277
|
+
registerAdapter,
|
|
278
|
+
main,
|
|
279
|
+
ledgerPathFor,
|
|
280
|
+
LEDGER_RELPATH,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
if (require.main === module) {
|
|
284
|
+
main(process.argv.slice(2)).then(
|
|
285
|
+
(code) => process.exit(code),
|
|
286
|
+
(err) => {
|
|
287
|
+
process.stderr.write(`gsd-t test-data: ${err && err.message ? err.message : String(err)}\n`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gsd-t-time-format — M59
|
|
3
|
+
*
|
|
4
|
+
* Shared helpers for the v3.29.10 timestamp-precision format.
|
|
5
|
+
*
|
|
6
|
+
* Exports:
|
|
7
|
+
* localIsoWithOffset([date]) → "YYYY-MM-DDTHH:MM:SS±HH:MM" (local offset)
|
|
8
|
+
* localTimestampForProgress([date]) → "YYYY-MM-DD HH:MM TZ" (human-readable, for progress.md fields)
|
|
9
|
+
*
|
|
10
|
+
* Both helpers source the current time from `new Date()` by default. The
|
|
11
|
+
* `[GSD-T NOW]` UserPromptSubmit signal feeds the system clock, so these
|
|
12
|
+
* are correct in any GSD-T spawn.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TZ_ABBR_FALLBACK = 'UTC';
|
|
16
|
+
|
|
17
|
+
function pad2(n) {
|
|
18
|
+
const s = String(n);
|
|
19
|
+
return s.length < 2 ? `0${s}` : s;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ISO 8601 timestamp with local timezone offset (NOT UTC `Z`).
|
|
24
|
+
*
|
|
25
|
+
* Example: `2026-05-27T10:15:30-07:00` (PDT, summer)
|
|
26
|
+
* `2026-12-15T10:15:30-08:00` (PST, winter)
|
|
27
|
+
*/
|
|
28
|
+
function localIsoWithOffset(date) {
|
|
29
|
+
const d = date instanceof Date ? date : new Date();
|
|
30
|
+
const yyyy = d.getFullYear();
|
|
31
|
+
const mm = pad2(d.getMonth() + 1);
|
|
32
|
+
const dd = pad2(d.getDate());
|
|
33
|
+
const hh = pad2(d.getHours());
|
|
34
|
+
const mi = pad2(d.getMinutes());
|
|
35
|
+
const ss = pad2(d.getSeconds());
|
|
36
|
+
|
|
37
|
+
const offsetMinTotal = -d.getTimezoneOffset(); // east of UTC is positive
|
|
38
|
+
const sign = offsetMinTotal >= 0 ? '+' : '-';
|
|
39
|
+
const offsetAbs = Math.abs(offsetMinTotal);
|
|
40
|
+
const offH = pad2(Math.floor(offsetAbs / 60));
|
|
41
|
+
const offM = pad2(offsetAbs % 60);
|
|
42
|
+
|
|
43
|
+
return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}${sign}${offH}:${offM}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a short human-readable TZ abbreviation (e.g., "PDT", "PST").
|
|
48
|
+
* Uses Intl.DateTimeFormat short timezone where available; falls back to
|
|
49
|
+
* the numeric offset string if the platform doesn't provide one.
|
|
50
|
+
*/
|
|
51
|
+
function shortTzAbbr(date) {
|
|
52
|
+
const d = date instanceof Date ? date : new Date();
|
|
53
|
+
try {
|
|
54
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
55
|
+
timeZoneName: 'short',
|
|
56
|
+
}).formatToParts(d);
|
|
57
|
+
const tzPart = parts.find((p) => p.type === 'timeZoneName');
|
|
58
|
+
if (tzPart && tzPart.value) return tzPart.value;
|
|
59
|
+
} catch {
|
|
60
|
+
/* fall through */
|
|
61
|
+
}
|
|
62
|
+
// Fallback — numeric offset like "GMT-07:00"
|
|
63
|
+
const offsetMin = -d.getTimezoneOffset();
|
|
64
|
+
const sign = offsetMin >= 0 ? '+' : '-';
|
|
65
|
+
const abs = Math.abs(offsetMin);
|
|
66
|
+
return `GMT${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Human-readable timestamp for progress.md visible fields:
|
|
71
|
+
* "YYYY-MM-DD HH:MM TZ"
|
|
72
|
+
*
|
|
73
|
+
* Example: `2026-05-27 10:15 PDT`
|
|
74
|
+
*
|
|
75
|
+
* This is the M59 format for:
|
|
76
|
+
* - `## Date:` line in progress.md frontmatter
|
|
77
|
+
* - "Completed" cell in the Completed Milestones table
|
|
78
|
+
* - "Date" cell in the Session Log table
|
|
79
|
+
*/
|
|
80
|
+
function localTimestampForProgress(date) {
|
|
81
|
+
const d = date instanceof Date ? date : new Date();
|
|
82
|
+
const yyyy = d.getFullYear();
|
|
83
|
+
const mm = pad2(d.getMonth() + 1);
|
|
84
|
+
const dd = pad2(d.getDate());
|
|
85
|
+
const hh = pad2(d.getHours());
|
|
86
|
+
const mi = pad2(d.getMinutes());
|
|
87
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${mi} ${shortTzAbbr(d) || TZ_ABBR_FALLBACK}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
localIsoWithOffset,
|
|
92
|
+
localTimestampForProgress,
|
|
93
|
+
shortTzAbbr,
|
|
94
|
+
};
|
package/bin/gsd-t.js
CHANGED
|
@@ -4580,6 +4580,15 @@ if (require.main === module) {
|
|
|
4580
4580
|
});
|
|
4581
4581
|
process.exit(res.status == null ? 1 : res.status);
|
|
4582
4582
|
}
|
|
4583
|
+
case "test-data": {
|
|
4584
|
+
// M58 D1 — `gsd-t test-data --list|--purge` thin dispatcher.
|
|
4585
|
+
const { spawnSync } = require("child_process");
|
|
4586
|
+
const js = path.join(__dirname, "gsd-t-test-data-ledger.cjs");
|
|
4587
|
+
const res = spawnSync(process.execPath, [js, ...args.slice(1)], {
|
|
4588
|
+
stdio: "inherit",
|
|
4589
|
+
});
|
|
4590
|
+
process.exit(res.status == null ? 1 : res.status);
|
|
4591
|
+
}
|
|
4583
4592
|
case "stream-feed": {
|
|
4584
4593
|
doStreamFeed(args.slice(1));
|
|
4585
4594
|
break;
|
package/bin/orchestrator.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
const fs = require("fs");
|
|
27
27
|
const path = require("path");
|
|
28
28
|
const { execFileSync, execFile, spawn: cpSpawn } = require("child_process");
|
|
29
|
+
const { localIsoWithOffset } = require(path.join(__dirname, "gsd-t-time-format.cjs"));
|
|
29
30
|
|
|
30
31
|
// ─── ANSI Colors ────────────────────────────────────────────────────────────
|
|
31
32
|
|
|
@@ -1275,11 +1276,13 @@ ${BOLD}Phases:${RESET} ${this.wf.phases.join(" → ")}
|
|
|
1275
1276
|
}
|
|
1276
1277
|
|
|
1277
1278
|
// 6f. Record phase completion
|
|
1279
|
+
// M59 (v3.29.10): completedAt is local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`)
|
|
1280
|
+
// rather than UTC `Z` — matches the human-readable progress.md fields.
|
|
1278
1281
|
state.phaseResults[phase] = {
|
|
1279
1282
|
completed: true,
|
|
1280
1283
|
builtPaths,
|
|
1281
1284
|
reviewCycles: reviewCycle + 1,
|
|
1282
|
-
completedAt:
|
|
1285
|
+
completedAt: localIsoWithOffset(),
|
|
1283
1286
|
};
|
|
1284
1287
|
state.completedPhases.push(phase);
|
|
1285
1288
|
this.clearQueue(projectDir);
|
|
@@ -336,7 +336,7 @@ Create `summary.md`:
|
|
|
336
336
|
```markdown
|
|
337
337
|
# Milestone Complete: {name}
|
|
338
338
|
|
|
339
|
-
**Completed**: {
|
|
339
|
+
**Completed**: {YYYY-MM-DD HH:MM TZ}
|
|
340
340
|
**Duration**: {start date} → {end date}
|
|
341
341
|
**Status**: {VERIFIED | FORCED}
|
|
342
342
|
|
|
@@ -419,14 +419,18 @@ Steps to apply the trim:
|
|
|
419
419
|
# GSD-T Progress
|
|
420
420
|
|
|
421
421
|
## Version: {new version}
|
|
422
|
+
## Status: ACTIVE
|
|
423
|
+
## Date: {YYYY-MM-DD HH:MM TZ — source from live `[GSD-T NOW]`}
|
|
422
424
|
## Current Milestone
|
|
423
425
|
None — ready for next milestone
|
|
424
426
|
|
|
425
427
|
## Completed Milestones
|
|
426
428
|
| Milestone | Version | Completed | Tag |
|
|
427
429
|
|-----------|---------|-----------|-----|
|
|
428
|
-
| {name} | {version} | {
|
|
429
|
-
| {previous} | {version} | {
|
|
430
|
+
| {name} | {version} | {YYYY-MM-DD HH:MM TZ} | v{version} |
|
|
431
|
+
| {previous} | {version} | {YYYY-MM-DD HH:MM TZ — keep existing rows as-is; forward-only format} | v{version} |
|
|
432
|
+
<!-- M59 (v3.29.10): "Completed" cells are written `YYYY-MM-DD HH:MM TZ` from this version forward. Pre-3.29.10 rows that read `YYYY-MM-DD` stay as-is (forward-only — never rewrite). Readers MUST accept both. -->
|
|
433
|
+
|
|
430
434
|
|
|
431
435
|
## Decision Log
|
|
432
436
|
|
package/commands/gsd-t-help.md
CHANGED
|
@@ -469,6 +469,13 @@ Use these when user asks for help on a specific command:
|
|
|
469
469
|
- **Use when**: Final pre-merge gate. Catches the TimeTracking v1.10.12 class (noImplicitAny regressions passed a warm-cache local tsc but failed CI's cold build).
|
|
470
470
|
- **CLI**: `gsd-t ci-parity [--project-dir PATH] [--timeout-ms MS] [--json]`. Exit 0/4/2.
|
|
471
471
|
|
|
472
|
+
### test-data (M58)
|
|
473
|
+
- **Summary**: Append-only test-data ledger + purge engine. Tests register inserts via the `withTestData()` Playwright fixture; `gsd-t-verify` Step 4.5 purges them by adapter before VERDICT. Three built-in adapters: `localStorage-key-prefix`, `file-json-array`, `sqlite-table-where`. Each adapter refuses to delete records whose id does not start with the ledger row's `taggedPrefix` (defense in depth).
|
|
474
|
+
- **Auto-invoked**: Yes — by `gsd-t-verify` Step 4.5 (FAIL-blocking, never warning-only)
|
|
475
|
+
- **Files**: `bin/gsd-t-test-data-ledger.cjs`, `bin/gsd-t-test-data-adapters/*.cjs`, `templates/test-helpers/test-data-fixture.ts`
|
|
476
|
+
- **Use when**: Test data hygiene. Catches the GSD-T-Board class (2442 orphaned `E2E_TEST_*` / `E2E_DRAG_*` ideas left in the production data store after a passing Verify run).
|
|
477
|
+
- **CLI**: `gsd-t test-data --list [--run <id>] [--json]` / `gsd-t test-data --purge --run <id> [--dry-run] [--json] [--project <dir>]`. Exit 0 on success, 4 on adapter errors, 64 on usage error.
|
|
478
|
+
|
|
472
479
|
## Unknown Command
|
|
473
480
|
|
|
474
481
|
If user asks for help on unrecognized command:
|
package/commands/gsd-t-init.md
CHANGED
|
@@ -180,7 +180,7 @@ Create `.gsd-t/progress.md`:
|
|
|
180
180
|
## Project: {name from CLAUDE.md or $ARGUMENTS}
|
|
181
181
|
## Version: {detected version, or 0.1.00}
|
|
182
182
|
## Status: INITIALIZED
|
|
183
|
-
## Date: {today}
|
|
183
|
+
## Date: {today YYYY-MM-DD HH:MM TZ — source from the live `[GSD-T NOW]` signal; never date-only}
|
|
184
184
|
|
|
185
185
|
## Milestones
|
|
186
186
|
| # | Milestone | Status | Domains |
|
package/commands/gsd-t-verify.md
CHANGED
|
@@ -303,6 +303,49 @@ const { captureSpawn } = require('./bin/gsd-t-token-capture.cjs');
|
|
|
303
303
|
`captureSpawn` parses `result.usage` and writes the row to `.gsd-t/token-log.md` under the canonical header. Tokens column renders as `in=N out=N cr=N cc=N $X.XX` or `—`, never `N/A`. Collect all reports, synthesize, create remediation plan.
|
|
304
304
|
```
|
|
305
305
|
|
|
306
|
+
<!-- M58: Test Data Cleanup Gate -->
|
|
307
|
+
## Step 4.5: Test Data Cleanup Gate (MANDATORY — FAIL-blocking, never warning-only)
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
node scripts/gsd-t-watch-state.js advance --agent-id "$GSD_T_AGENT_ID" --parent-id "${GSD_T_PARENT_AGENT_ID:-null}" --command gsd-t-verify --step 4 --step-label ".5: Test Data Cleanup Gate" 2>/dev/null || true
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Origin: GSD-T-Board v0.1.10 Verify ran the Playwright suite, the suite
|
|
314
|
+
passed, the milestone was tagged VERIFIED — and 2442 `E2E_TEST_*` /
|
|
315
|
+
`E2E_DRAG_*` ideas stayed live in the production data store. The gate
|
|
316
|
+
exists to catch that class: any test data registered during Verify via the
|
|
317
|
+
`withTestData()` Playwright fixture (or by direct calls to
|
|
318
|
+
`appendInsert(...)`) MUST be purged before VERDICT.
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
# Verify-run id — set at Step 1; if not, derive from milestone + UTC.
|
|
322
|
+
: "${GSD_T_VERIFY_RUN_ID:=verify-${MILESTONE:-current}-$(date -u +%Y%m%dT%H%M%SZ)}"
|
|
323
|
+
export GSD_T_VERIFY_RUN_ID
|
|
324
|
+
|
|
325
|
+
# Purge anything the fixture (or appendInsert) registered during this run.
|
|
326
|
+
gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID" --json > /tmp/gsd-t-test-data-purge.json
|
|
327
|
+
TD_EXIT=$?
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
- `test-data --purge` exit **0** (`errors:[]`) → record
|
|
331
|
+
`Test data: purged=<N> skipped=<M>` in the verify report. Gate passes.
|
|
332
|
+
- exit **4** (`errors.length > 0`) → verify FAIL. Append the first 5
|
|
333
|
+
`errors[].message` values to the verify report and surface the count of
|
|
334
|
+
remaining records. The fix is either (a) repair the adapter / store
|
|
335
|
+
configuration so purge succeeds, or (b) update the test to use the
|
|
336
|
+
fixture so the ledger has accurate entries.
|
|
337
|
+
|
|
338
|
+
The gate runs AFTER the E2E suite (Step 4) so any tests that inserted
|
|
339
|
+
test data via `withTestData(...)` have already populated the ledger.
|
|
340
|
+
It runs BEFORE Step 5 (verify report) so the purge counts can land in the
|
|
341
|
+
report. Tests that bypass the fixture and leave un-tagged data will not
|
|
342
|
+
be caught here — they're caught at the project's next dataset audit.
|
|
343
|
+
|
|
344
|
+
Pure-deterministic CLI check (no LLM). Contract:
|
|
345
|
+
`.gsd-t/contracts/test-data-ledger-contract.md` v1.0.0 STABLE.
|
|
346
|
+
Tagging: `.gsd-t/contracts/test-data-tagging-contract.md` v1.0.0 STABLE.
|
|
347
|
+
<!-- /M58: Test Data Cleanup Gate -->
|
|
348
|
+
|
|
306
349
|
## Step 5: Compile Verification Report
|
|
307
350
|
|
|
308
351
|
```bash
|
|
@@ -322,6 +365,7 @@ Create or update `.gsd-t/verify-report.md`:
|
|
|
322
365
|
- Code Quality: {PASS/WARN/FAIL} — {N} issues found
|
|
323
366
|
- Unit Tests: {PASS/WARN/FAIL} — {N}/{total} passing
|
|
324
367
|
- E2E Tests: {PASS/WARN/FAIL} — {N}/{total} specs passing
|
|
368
|
+
- Test Data Cleanup: {PASS/FAIL} — purged={N} skipped={M} errors={E}
|
|
325
369
|
- Security: {PASS/WARN/FAIL} — {N} findings
|
|
326
370
|
- Integration: {PASS/WARN/FAIL}
|
|
327
371
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.11",
|
|
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",
|
|
@@ -89,18 +89,25 @@ const FRESH_STAMP_PATTERNS = [
|
|
|
89
89
|
];
|
|
90
90
|
|
|
91
91
|
// Generic ISO date — only validated when surrounded by strong "freshly stamping now"
|
|
92
|
-
// context (e.g., right after labels like
|
|
93
|
-
//
|
|
92
|
+
// context (e.g., right after labels like the canonical frontmatter / metadata
|
|
93
|
+
// keys captured below).
|
|
94
|
+
// Two arms: (a) date+time (validated against full +/-DRIFT_MINUTES window),
|
|
94
95
|
// (b) date-only (validated as same-calendar-day-as-now, time-of-day ignored).
|
|
96
|
+
//
|
|
97
|
+
// M59 (v3.29.10): time portion may carry an optional trailing TZ token —
|
|
98
|
+
// either a short abbreviation (PDT/PST/UTC/...), a numeric offset
|
|
99
|
+
// (+/-HH:MM or +/-HHMM), or Z. The TZ is matched but not used for drift
|
|
100
|
+
// math — drift is computed against the local clock, which already has the
|
|
101
|
+
// live offset.
|
|
95
102
|
const STAMPED_ISO_PATTERN = {
|
|
96
103
|
name: "stamped-iso",
|
|
97
|
-
regex: /\b(?:Date|Today|Stamped|Updated|Created|Generated|Now|Timestamp|At)\s*[:=]\s*(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/gi,
|
|
104
|
+
regex: /\b(?:Date|Today|Stamped|Updated|Created|Generated|Now|Timestamp|At)\s*[:=]\s*(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::\d{2})?(?:\s+[A-Z]{2,5}|[+\-]\d{2}:?\d{2}|Z)?)?/gi,
|
|
98
105
|
extract: (m) => {
|
|
99
106
|
const hasTime = m[4] !== undefined;
|
|
100
107
|
return {
|
|
101
108
|
stamped: new Date(
|
|
102
109
|
Number(m[1]), Number(m[2]) - 1, Number(m[3]),
|
|
103
|
-
hasTime ? Number(m[4]) : 12, // Date-only
|
|
110
|
+
hasTime ? Number(m[4]) : 12, // Date-only -> noon, neutralizes timezone-edge false positives
|
|
104
111
|
hasTime ? Number(m[5]) : 0,
|
|
105
112
|
0
|
|
106
113
|
),
|
|
@@ -109,6 +116,20 @@ const STAMPED_ISO_PATTERN = {
|
|
|
109
116
|
},
|
|
110
117
|
};
|
|
111
118
|
|
|
119
|
+
// M59 (v3.29.10): table cells in progress.md's "Completed Milestones" and
|
|
120
|
+
// "Session Log" tables now carry `YYYY-MM-DD HH:MM TZ`. We validate them
|
|
121
|
+
// against +/-DRIFT_MINUTES (treat as a fresh stamp). Date-only cells in
|
|
122
|
+
// pre-3.29.10 rows remain valid and are NOT flagged - those are historical
|
|
123
|
+
// (forward-only rule), so this regex requires the HH:MM portion to fire.
|
|
124
|
+
const PROGRESS_TABLE_CELL_PATTERN = {
|
|
125
|
+
name: "progress-table-cell",
|
|
126
|
+
regex: /\|\s*(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?:\s+[A-Z]{2,5})?\s*\|/g,
|
|
127
|
+
extract: (m) => new Date(
|
|
128
|
+
Number(m[1]), Number(m[2]) - 1, Number(m[3]),
|
|
129
|
+
Number(m[4]), Number(m[5]), 0
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
|
|
112
133
|
function isAllowlisted(filePath) {
|
|
113
134
|
if (!filePath) return false;
|
|
114
135
|
return ALLOWLIST_PATTERNS.some((re) => re.test(filePath));
|
|
@@ -119,7 +140,7 @@ function findStaleTimestamps(content, now, oldContent) {
|
|
|
119
140
|
const findings = [];
|
|
120
141
|
const oldText = typeof oldContent === "string" ? oldContent : "";
|
|
121
142
|
|
|
122
|
-
const allPatterns = [...FRESH_STAMP_PATTERNS, STAMPED_ISO_PATTERN];
|
|
143
|
+
const allPatterns = [...FRESH_STAMP_PATTERNS, STAMPED_ISO_PATTERN, PROGRESS_TABLE_CELL_PATTERN];
|
|
123
144
|
|
|
124
145
|
for (const pattern of allPatterns) {
|
|
125
146
|
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
|
|
@@ -13,6 +13,7 @@ const http = require("http");
|
|
|
13
13
|
const { spawn } = require("child_process");
|
|
14
14
|
const fs = require("fs");
|
|
15
15
|
const path = require("path");
|
|
16
|
+
const { localIsoWithOffset } = require(path.join(__dirname, "..", "bin", "gsd-t-time-format.cjs"));
|
|
16
17
|
const url = require("url");
|
|
17
18
|
|
|
18
19
|
// ── CLI args ──────────────────────────────────────────────────────────
|
|
@@ -422,7 +423,8 @@ function writeFeedback(items) {
|
|
|
422
423
|
fs.writeFileSync(
|
|
423
424
|
path.join(REVIEW_DIR, "review-complete.json"),
|
|
424
425
|
JSON.stringify({
|
|
425
|
-
|
|
426
|
+
// M59 (v3.29.10): local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`) rather than UTC `Z`.
|
|
427
|
+
completedAt: localIsoWithOffset(),
|
|
426
428
|
phase: readStatus().phase,
|
|
427
429
|
items: items.map(i => ({ id: i.id, verdict: i.verdict })),
|
|
428
430
|
}, null, 2)
|
|
@@ -167,11 +167,13 @@ Whenever you write a date or timestamp to any file — decision log entries in `
|
|
|
167
167
|
2. If absent, run `node -e "console.log(new Date().toISOString())"` via Bash before writing.
|
|
168
168
|
|
|
169
169
|
**Enforcement**: a PreToolUse hook (`scripts/gsd-t-date-guard.js`) blocks Write/Edit calls whose content contains timestamps drifting more than ±5 minutes from the live system clock. The guard:
|
|
170
|
-
- Validates decision-log entries (`- YYYY-MM-DD HH:MM:`), filename timestamps (`continue-here-YYYY-MM-DDTHHMMSS`), banners (`Day: Mon DD, YYYY HH:MM`),
|
|
170
|
+
- Validates decision-log entries (`- YYYY-MM-DD HH:MM:`), filename timestamps (`continue-here-YYYY-MM-DDTHHMMSS`), banners (`Day: Mon DD, YYYY HH:MM`), labeled stamps (`Date:`, `Updated:`, `Created:`, etc., with optional TZ abbr / numeric offset / `Z`), and **progress.md table cells carrying `YYYY-MM-DD HH:MM TZ`** (M59, v3.29.10+ — Completed Milestones + Session Log).
|
|
171
171
|
- For Edit, ignores timestamps that appear in BOTH `old_string` and `new_string` (pre-existing context, not new writes).
|
|
172
172
|
- Allowlists machine-written paths (`.gsd-t/events/`, `.gsd-t/transcripts/`, `.gsd-t/metrics/`, `.git/`, `node_modules/`, archives, log files).
|
|
173
173
|
- Fails open on internal error — broken tool calls would be worse than drift.
|
|
174
174
|
|
|
175
|
+
**Timestamp precision in progress.md (M59, v3.29.10+)**: the `## Date:` frontmatter line, the "Completed" cell of the Completed Milestones table, and the "Date" cell of the Session Log table MUST be written as `YYYY-MM-DD HH:MM TZ` (e.g. `2026-05-27 10:15 PDT`). This is **forward-only** — pre-3.29.10 rows that read date-only (`YYYY-MM-DD`) stay as-is. Readers (status, dashboard, GSD-T-Board) MUST accept both. `archive-meta.json::completedAt` is local-offset ISO (`YYYY-MM-DDTHH:MM:SS±HH:MM`) — use `localIsoWithOffset()` from `bin/gsd-t-time-format.cjs`, not `new Date().toISOString()` (which produces UTC `Z`).
|
|
176
|
+
|
|
175
177
|
If the guard blocks your write, do NOT bypass it. Re-read `[GSD-T NOW]`, regenerate the timestamp, retry.
|
|
176
178
|
|
|
177
179
|
## Conversation vs. Work
|
|
@@ -260,6 +262,34 @@ Every Playwright assertion must verify one of:
|
|
|
260
262
|
|
|
261
263
|
**If a test would pass on an empty HTML page with the correct element IDs and no JavaScript, it is not a functional test.** Rewrite it.
|
|
262
264
|
|
|
265
|
+
### Test Data Cleanup (MANDATORY — M58)
|
|
266
|
+
|
|
267
|
+
**Tests that insert data into a project's stores MUST register those inserts with the GSD-T test-data ledger so Verify can purge them.** Tests that leave orphaned `E2E_*` records in production data violate this rule.
|
|
268
|
+
|
|
269
|
+
The supported mechanism is the `withTestData()` Playwright fixture:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { test as base } from '@playwright/test';
|
|
273
|
+
import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
|
|
274
|
+
|
|
275
|
+
export const test = base.extend(withTestData());
|
|
276
|
+
|
|
277
|
+
test('drag idea creates new column', async ({ page, testData }) => {
|
|
278
|
+
const id = testData.tag('E2E_DRAG'); // → "E2E_DRAG_{runId}_{counter}"
|
|
279
|
+
await testData.register({
|
|
280
|
+
kind: 'localStorage-key-prefix',
|
|
281
|
+
store: 'gsd-t-board:idea:',
|
|
282
|
+
id,
|
|
283
|
+
taggedPrefix: 'E2E_',
|
|
284
|
+
});
|
|
285
|
+
// … UI interactions that insert a row keyed by `${store}${id}` …
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Three built-in adapters: `localStorage-key-prefix`, `file-json-array`, `sqlite-table-where`. Extend via `registerAdapter(kind, adapter)`. Each adapter refuses to delete a record whose id does not start with the ledger row's `taggedPrefix` (defense in depth — see `.gsd-t/contracts/test-data-tagging-contract.md`).
|
|
290
|
+
|
|
291
|
+
After the E2E suite, `gsd-t-verify` Step 4.5 runs `gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"`. If any adapter throws or refuses, verify FAILs the gate (block-promotion semantics — equivalent to a failing CI-Parity Gate). Contract: `.gsd-t/contracts/test-data-ledger-contract.md` v1.0.0 STABLE.
|
|
292
|
+
|
|
263
293
|
## QA Agent (Mandatory)
|
|
264
294
|
|
|
265
295
|
Every code-producing/validating phase MUST run QA. QA writes ZERO feature code — it generates, runs, and gap-reports tests. Failure (or any shallow E2E test) blocks phase completion.
|
package/templates/progress.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## Project: {Project Name}
|
|
4
4
|
## Version: 0.1.0
|
|
5
5
|
## Status: READY
|
|
6
|
-
## Date: {
|
|
6
|
+
## Date: {YYYY-MM-DD HH:MM TZ}
|
|
7
7
|
|
|
8
8
|
## Current Milestone
|
|
9
9
|
None — ready for next milestone
|
|
@@ -11,6 +11,8 @@ None — ready for next milestone
|
|
|
11
11
|
## Completed Milestones
|
|
12
12
|
| Milestone | Version | Completed | Tag |
|
|
13
13
|
|-----------|---------|-----------|-----|
|
|
14
|
+
<!-- M59: "Completed" cell format is `YYYY-MM-DD HH:MM TZ` for entries written ≥ v3.29.10; older rows may be `YYYY-MM-DD`. Readers MUST accept both. -->
|
|
15
|
+
|
|
14
16
|
|
|
15
17
|
## Domains
|
|
16
18
|
| Domain | Status | Tasks | Completed |
|
|
@@ -37,4 +39,6 @@ None — ready for next milestone
|
|
|
37
39
|
## Session Log
|
|
38
40
|
| Date | Session | What was accomplished |
|
|
39
41
|
|------|---------|----------------------|
|
|
40
|
-
| {
|
|
42
|
+
| {YYYY-MM-DD HH:MM TZ} | 1 | Project initialized |
|
|
43
|
+
<!-- M59: "Date" cell format is `YYYY-MM-DD HH:MM TZ` for entries written ≥ v3.29.10; older rows may be `YYYY-MM-DD`. Readers MUST accept both. -->
|
|
44
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# GSD-T Test Helpers
|
|
2
|
+
|
|
3
|
+
Helpers that test suites can import to keep test data out of production stores.
|
|
4
|
+
|
|
5
|
+
## `test-data-fixture.ts`
|
|
6
|
+
|
|
7
|
+
Playwright fixture (`withTestData`) that auto-registers test inserts with the
|
|
8
|
+
GSD-T test-data ledger. After Playwright finishes, the verify-final-step
|
|
9
|
+
(`gsd-t-verify` Step 4.5) sweeps the ledger and purges every registered row.
|
|
10
|
+
|
|
11
|
+
### Install
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// playwright.config.ts (no changes — fixture is plugged in per-spec or via a base test)
|
|
15
|
+
|
|
16
|
+
// test/_base.ts
|
|
17
|
+
import { test as base } from '@playwright/test';
|
|
18
|
+
import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
|
|
19
|
+
|
|
20
|
+
export const test = base.extend(withTestData());
|
|
21
|
+
export { expect } from '@playwright/test';
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Use
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { test } from './_base';
|
|
28
|
+
|
|
29
|
+
test('drag idea creates new column', async ({ page, testData }) => {
|
|
30
|
+
const id = testData.tag('E2E_DRAG');
|
|
31
|
+
await testData.register({
|
|
32
|
+
kind: 'localStorage-key-prefix',
|
|
33
|
+
store: 'gsd-t-board:idea:',
|
|
34
|
+
id,
|
|
35
|
+
taggedPrefix: 'E2E_',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ... interact with the UI; the app inserts a row keyed by `gsd-t-board:idea:${id}` ...
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Tagging Convention
|
|
43
|
+
|
|
44
|
+
All IDs that flow through `testData.register()` MUST start with a recognized
|
|
45
|
+
prefix. Defaults to `E2E_`. Projects can declare additional prefixes in
|
|
46
|
+
`.gsd-t/test-data-config.json`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{ "taggedPrefixes": ["E2E_", "FIXTURE_", "INTEGRATION_"] }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`testData.tag(prefix)` composes IDs of the form
|
|
53
|
+
`{PREFIX}_{verifyRunId}_{counter}` — e.g.
|
|
54
|
+
`E2E_DRAG_verify-m58-20260527T091800Z_3`.
|
|
55
|
+
|
|
56
|
+
### How purge happens
|
|
57
|
+
|
|
58
|
+
1. Each call to `testData.register(...)` appends a JSONL row to
|
|
59
|
+
`.gsd-t/test-data-ledger.jsonl`.
|
|
60
|
+
2. After Playwright runs, `gsd-t-verify` Step 4.5 executes
|
|
61
|
+
`gsd-t test-data --purge --run "$GSD_T_VERIFY_RUN_ID"`.
|
|
62
|
+
3. The ledger is read; each row is dispatched to its `kind`'s adapter; the
|
|
63
|
+
adapter removes the record from the store (or reports `absent` /
|
|
64
|
+
structured `error`).
|
|
65
|
+
4. If any row produces an error, verify FAILs the Test Data Cleanup Gate
|
|
66
|
+
(block-promotion semantics — equivalent to a failing CI-Parity Gate).
|
|
67
|
+
|
|
68
|
+
### Opt-in per-test purge
|
|
69
|
+
|
|
70
|
+
For long suites where you want to clean up after every test, pass
|
|
71
|
+
`purgePerTest: true`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
export const test = base.extend(withTestData({ purgePerTest: true }));
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This invokes `purgeRunInserts` in the fixture's `afterEach` instead of
|
|
78
|
+
deferring to the verify-final-step. Most suites should leave it off — the
|
|
79
|
+
verify step is the canonical sweep and avoids per-test overhead.
|
|
80
|
+
|
|
81
|
+
### Verify-run id
|
|
82
|
+
|
|
83
|
+
The fixture reads `process.env.GSD_T_VERIFY_RUN_ID` and uses it as the
|
|
84
|
+
ledger `runId`. `gsd-t-verify` sets this at the top of the verify run as
|
|
85
|
+
`verify-${MILESTONE}-$(date -u +%Y%m%dT%H%M%SZ)`. For local development
|
|
86
|
+
runs (where the env var is absent), the fixture falls back to
|
|
87
|
+
`local-${randomUUID()}` so the ledger is still coherent.
|
|
88
|
+
|
|
89
|
+
### What this does NOT do
|
|
90
|
+
|
|
91
|
+
- It does NOT enforce that every test uses the fixture — the gate works by
|
|
92
|
+
finding orphans, not by lint. A test that bypasses the fixture and inserts
|
|
93
|
+
data the gate doesn't know about will leave that data behind.
|
|
94
|
+
- It does NOT clean up data inserted before the suite started — that's a
|
|
95
|
+
pre-existing data hygiene concern.
|
|
96
|
+
- It does NOT roll back database transactions. Use it for additive inserts
|
|
97
|
+
(rows, keys, files); for transactional cleanup, use your store's native
|
|
98
|
+
rollback.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD-T M58 — Playwright Test Data fixture
|
|
3
|
+
*
|
|
4
|
+
* Auto-registers test data inserts with the GSD-T ledger so the Test Data
|
|
5
|
+
* Cleanup Gate (gsd-t-verify Step 4.5) can purge them after the suite.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
*
|
|
9
|
+
* import { test as base } from '@playwright/test';
|
|
10
|
+
* import { withTestData } from '@tekyzinc/gsd-t/templates/test-helpers/test-data-fixture';
|
|
11
|
+
*
|
|
12
|
+
* export const test = base.extend(withTestData());
|
|
13
|
+
*
|
|
14
|
+
* test('drag idea creates new column', async ({ page, testData }) => {
|
|
15
|
+
* const id = testData.tag('E2E_DRAG');
|
|
16
|
+
* await testData.register({
|
|
17
|
+
* kind: 'localStorage-key-prefix',
|
|
18
|
+
* store: 'gsd-t-board:idea:',
|
|
19
|
+
* id,
|
|
20
|
+
* taggedPrefix: 'E2E_',
|
|
21
|
+
* });
|
|
22
|
+
* // ... UI interactions that insert {key: 'gsd-t-board:idea:' + id} ...
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* Tagging convention: `{PREFIX}_{verifyRunId}_{counter}`.
|
|
26
|
+
*
|
|
27
|
+
* Run id comes from `process.env.GSD_T_VERIFY_RUN_ID` (set by gsd-t-verify).
|
|
28
|
+
* If absent, the fixture falls back to a per-process UUID so local runs still
|
|
29
|
+
* write a coherent ledger.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { randomUUID } from 'node:crypto';
|
|
33
|
+
import * as path from 'node:path';
|
|
34
|
+
|
|
35
|
+
// Resolve the ledger module path at runtime so this template file does not
|
|
36
|
+
// require build-time linkage to the published package.
|
|
37
|
+
function resolveLedger(): {
|
|
38
|
+
appendInsert: (row: LedgerRow) => { ok: boolean; ledgerPath: string };
|
|
39
|
+
} {
|
|
40
|
+
// Caller can override via env (used by synthetic suites under test/fixtures/m58-d2/).
|
|
41
|
+
const override = process.env.GSD_T_LEDGER_MODULE_PATH;
|
|
42
|
+
const modulePath = override
|
|
43
|
+
? path.resolve(override)
|
|
44
|
+
: require.resolve('@tekyzinc/gsd-t/bin/gsd-t-test-data-ledger.cjs');
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
46
|
+
return require(modulePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type AdapterKind = 'localStorage-key-prefix' | 'file-json-array' | 'sqlite-table-where' | string;
|
|
50
|
+
|
|
51
|
+
interface LedgerRow {
|
|
52
|
+
projectDir: string;
|
|
53
|
+
runId: string;
|
|
54
|
+
kind: AdapterKind;
|
|
55
|
+
store: string;
|
|
56
|
+
id: string;
|
|
57
|
+
taggedPrefix?: string;
|
|
58
|
+
insertedAt?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TestDataHandle {
|
|
62
|
+
/**
|
|
63
|
+
* Compose a tagged identifier of the form `{PREFIX}_{runId}_{counter}`.
|
|
64
|
+
* Default prefix is `E2E_`.
|
|
65
|
+
*/
|
|
66
|
+
tag(prefix?: string): string;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Record an insert in the ledger. Must be called before / immediately
|
|
70
|
+
* after the test inserts the row in its store so the verify-final-step
|
|
71
|
+
* can purge it.
|
|
72
|
+
*/
|
|
73
|
+
register(opts: {
|
|
74
|
+
kind: AdapterKind;
|
|
75
|
+
store: string;
|
|
76
|
+
id: string;
|
|
77
|
+
taggedPrefix?: string;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The runId this fixture is writing under (read-only).
|
|
82
|
+
*/
|
|
83
|
+
readonly runId: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WithTestDataOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Project directory (defaults to process.cwd()).
|
|
89
|
+
*/
|
|
90
|
+
projectDir?: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Default tag prefix when `tag()` is called without one (defaults to `E2E_`).
|
|
94
|
+
*/
|
|
95
|
+
defaultPrefix?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Opt-in: per-test purge in `test.afterEach`. Default false — the canonical
|
|
99
|
+
* purge point is gsd-t-verify Step 4.5 (`gsd-t test-data --purge --run`).
|
|
100
|
+
*/
|
|
101
|
+
purgePerTest?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function withTestData(opts: WithTestDataOptions = {}) {
|
|
105
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
106
|
+
const defaultPrefix = opts.defaultPrefix || 'E2E_';
|
|
107
|
+
const runId = process.env.GSD_T_VERIFY_RUN_ID || `local-${randomUUID()}`;
|
|
108
|
+
|
|
109
|
+
// Playwright fixture factory shape: { testData: [async ({}, use) => {...}, { scope: 'test' }] }
|
|
110
|
+
return {
|
|
111
|
+
testData: [
|
|
112
|
+
async ({}, use: (handle: TestDataHandle) => Promise<void>) => {
|
|
113
|
+
let counter = 0;
|
|
114
|
+
const ledger = resolveLedger();
|
|
115
|
+
const handle: TestDataHandle = {
|
|
116
|
+
runId,
|
|
117
|
+
tag(prefix?: string) {
|
|
118
|
+
const p = prefix || defaultPrefix;
|
|
119
|
+
counter += 1;
|
|
120
|
+
// Normalise prefix to end with '_' so composed IDs match the
|
|
121
|
+
// taggedPrefix the adapter will guard against.
|
|
122
|
+
const base = p.endsWith('_') ? p : `${p}_`;
|
|
123
|
+
return `${base}${runId}_${counter}`;
|
|
124
|
+
},
|
|
125
|
+
async register({ kind, store, id, taggedPrefix }) {
|
|
126
|
+
const prefix = taggedPrefix || defaultPrefix;
|
|
127
|
+
ledger.appendInsert({
|
|
128
|
+
projectDir,
|
|
129
|
+
runId,
|
|
130
|
+
kind,
|
|
131
|
+
store,
|
|
132
|
+
id,
|
|
133
|
+
taggedPrefix: prefix,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
await use(handle);
|
|
138
|
+
// Per-test purge is opt-in; canonical sweep is the verify-final-step.
|
|
139
|
+
if (opts.purgePerTest) {
|
|
140
|
+
// Lazy-load purgeRunInserts only when opted in.
|
|
141
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
142
|
+
const ledgerMod = require(
|
|
143
|
+
process.env.GSD_T_LEDGER_MODULE_PATH
|
|
144
|
+
? path.resolve(process.env.GSD_T_LEDGER_MODULE_PATH)
|
|
145
|
+
: '@tekyzinc/gsd-t/bin/gsd-t-test-data-ledger.cjs',
|
|
146
|
+
);
|
|
147
|
+
await ledgerMod.purgeRunInserts({ projectDir, runId });
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{ scope: 'test' },
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
}
|