@tekyzinc/gsd-t 3.26.11 → 3.29.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/README.md +4 -0
- package/bin/context-budget-audit.cjs +17 -2
- package/bin/gsd-t-build-coverage.cjs +438 -0
- package/bin/gsd-t-ci-parity.cjs +500 -0
- package/bin/gsd-t-economics.cjs +37 -9
- package/bin/gsd-t-test-data-adapters/file-json-array.cjs +56 -0
- package/bin/gsd-t-test-data-adapters/localstorage-key-prefix.cjs +44 -0
- package/bin/gsd-t-test-data-adapters/sqlite-table-where.cjs +71 -0
- package/bin/gsd-t-test-data-ledger.cjs +290 -0
- package/bin/gsd-t-time-format.cjs +94 -0
- package/bin/gsd-t.js +30 -0
- package/bin/model-windows.cjs +99 -0
- package/bin/model-windows.test.cjs +75 -0
- package/bin/orchestrator.js +4 -1
- package/bin/runway-estimator.cjs +35 -5
- package/bin/token-budget.cjs +12 -3
- package/commands/gsd-t-complete-milestone.md +7 -3
- package/commands/gsd-t-help.md +21 -0
- package/commands/gsd-t-init.md +1 -1
- package/commands/gsd-t-verify.md +90 -0
- package/package.json +1 -1
- package/scripts/context-meter/transcript-parser.js +12 -2
- package/scripts/context-meter/transcript-parser.test.js +51 -4
- package/scripts/gsd-t-calibration-hook.js +8 -1
- package/scripts/gsd-t-context-meter.e2e.test.js +45 -6
- package/scripts/gsd-t-context-meter.js +17 -3
- package/scripts/gsd-t-context-meter.test.js +85 -0
- package/scripts/gsd-t-date-guard.js +26 -5
- package/scripts/gsd-t-design-review-server.js +3 -1
- package/templates/CLAUDE-global.md +37 -1
- package/templates/progress.md +6 -2
- package/templates/test-helpers/README.md +98 -0
- package/templates/test-helpers/test-data-fixture.ts +153 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GSD-T ci-parity (M57 D2)
|
|
6
|
+
*
|
|
7
|
+
* Reproduces a project's actual CI build locally instead of relying on
|
|
8
|
+
* potentially warm-cache local tsc/test parity.
|
|
9
|
+
*
|
|
10
|
+
* Auto-detects CI config via locked detection precedence (user decision — do
|
|
11
|
+
* NOT reorder): cloudbuild.yaml → .github/workflows/*.yml → Dockerfile RUN →
|
|
12
|
+
* package.json scripts (build, typecheck, test) → none.
|
|
13
|
+
*
|
|
14
|
+
* Clears build caches before running detected commands so stale incremental
|
|
15
|
+
* artifacts cannot mask regressions (the TimeTracking v1.10.12 SC2 failure
|
|
16
|
+
* class: ~8 noImplicitAny errors passed warm-cache local tsc, failed CI cold
|
|
17
|
+
* build).
|
|
18
|
+
*
|
|
19
|
+
* When a Dockerfile is present, runs a real `docker build` — presence is the
|
|
20
|
+
* sole trigger; no opt-in flag (locked user decision).
|
|
21
|
+
*
|
|
22
|
+
* Contract: .gsd-t/contracts/ci-parity-contract.md v1.0.0 STABLE
|
|
23
|
+
*
|
|
24
|
+
* Parse limits (no external YAML lib; minimal line/regex scanning):
|
|
25
|
+
* - cloudbuild.yaml: only reads `args:` array elements under `steps:`.
|
|
26
|
+
* Multi-line folded/block scalars are not supported. Only simple
|
|
27
|
+
* `args: [...]` one-liner arrays or `- 'val'` list forms are handled.
|
|
28
|
+
* - .github/workflows/*.yml: only reads `run:` lines from the FIRST job's
|
|
29
|
+
* steps. Multi-line pipe/fold blocks (`run: |`) capture only the first
|
|
30
|
+
* continuation line. `uses:` steps (no `run:`) are skipped.
|
|
31
|
+
* - Dockerfile: reads `RUN` lines (single-line only; backslash-continuations
|
|
32
|
+
* are joined into one command).
|
|
33
|
+
* These limits are intentional trade-offs to keep the module at zero
|
|
34
|
+
* external runtime deps.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
const child_process = require('child_process');
|
|
40
|
+
|
|
41
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
42
|
+
|
|
43
|
+
// ── Detection ─────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Parse cloudbuild.yaml steps[].args → array of command strings. */
|
|
46
|
+
function detectCloudbuild(projectDir) {
|
|
47
|
+
const f = path.join(projectDir, 'cloudbuild.yaml');
|
|
48
|
+
if (!fs.existsSync(f)) return null;
|
|
49
|
+
const lines = fs.readFileSync(f, 'utf8').split('\n');
|
|
50
|
+
const cmds = [];
|
|
51
|
+
let inArgs = false;
|
|
52
|
+
let argParts = [];
|
|
53
|
+
|
|
54
|
+
for (const raw of lines) {
|
|
55
|
+
const line = raw.trimEnd();
|
|
56
|
+
// Inline array: ` args: ['node', '-e', '...']`
|
|
57
|
+
const inlineM = line.match(/^\s+args:\s*\[(.+)\]/);
|
|
58
|
+
if (inlineM) {
|
|
59
|
+
const cmd = inlineM[1]
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((t) => t.trim().replace(/^['"]|['"]$/g, ''))
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join(' ');
|
|
64
|
+
if (cmd) cmds.push(cmd);
|
|
65
|
+
inArgs = false;
|
|
66
|
+
argParts = [];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Start of args block: ` args:`
|
|
70
|
+
if (/^\s+args:\s*$/.test(line)) {
|
|
71
|
+
inArgs = true;
|
|
72
|
+
argParts = [];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (inArgs) {
|
|
76
|
+
// List item under args
|
|
77
|
+
const itemM = line.match(/^\s+-\s+['"]?(.+?)['"]?\s*$/);
|
|
78
|
+
if (itemM) {
|
|
79
|
+
argParts.push(itemM[1]);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Not a list item → end of args block
|
|
83
|
+
if (argParts.length) {
|
|
84
|
+
cmds.push(argParts.join(' '));
|
|
85
|
+
argParts = [];
|
|
86
|
+
}
|
|
87
|
+
inArgs = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (inArgs && argParts.length) cmds.push(argParts.join(' '));
|
|
91
|
+
return cmds.length ? cmds : [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse first job's steps[].run from .github/workflows/*.yml. */
|
|
95
|
+
function detectWorkflows(projectDir) {
|
|
96
|
+
const wfDir = path.join(projectDir, '.github', 'workflows');
|
|
97
|
+
if (!fs.existsSync(wfDir)) return null;
|
|
98
|
+
const files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f));
|
|
99
|
+
if (!files.length) return null;
|
|
100
|
+
const lines = fs.readFileSync(path.join(wfDir, files[0]), 'utf8').split('\n');
|
|
101
|
+
const cmds = [];
|
|
102
|
+
let inJobs = false;
|
|
103
|
+
let inSteps = false;
|
|
104
|
+
let firstJobDone = false;
|
|
105
|
+
let jobDepth = 0;
|
|
106
|
+
let pendingRun = null;
|
|
107
|
+
|
|
108
|
+
for (const raw of lines) {
|
|
109
|
+
const line = raw.trimEnd();
|
|
110
|
+
if (/^jobs:/.test(line)) { inJobs = true; continue; }
|
|
111
|
+
if (!inJobs) continue;
|
|
112
|
+
if (firstJobDone) continue;
|
|
113
|
+
|
|
114
|
+
// Detect first-job boundary (4-space indent key)
|
|
115
|
+
if (/^ \w[\w-]*:/.test(line)) { jobDepth++; if (jobDepth > 1) { firstJobDone = true; continue; } }
|
|
116
|
+
if (/^\s+steps:/.test(line)) { inSteps = true; continue; }
|
|
117
|
+
if (!inSteps) continue;
|
|
118
|
+
|
|
119
|
+
// Inline run: ` - run: cmd`
|
|
120
|
+
const inlineRunM = line.match(/^\s+-\s+run:\s+(.+)$/);
|
|
121
|
+
if (inlineRunM) { cmds.push(inlineRunM[1].trim()); pendingRun = null; continue; }
|
|
122
|
+
// Block run marker: ` run: |` or ` run: >`
|
|
123
|
+
const blockRunM = line.match(/^\s+run:\s*[|>]?\s*$/);
|
|
124
|
+
if (blockRunM) { pendingRun = true; continue; }
|
|
125
|
+
// Inline run value: ` run: cmd`
|
|
126
|
+
const plainRunM = line.match(/^\s+run:\s+(.+)$/);
|
|
127
|
+
if (plainRunM && !blockRunM) { cmds.push(plainRunM[1].trim()); pendingRun = null; continue; }
|
|
128
|
+
// Continuation line for block run (indented more than run:)
|
|
129
|
+
if (pendingRun) {
|
|
130
|
+
const contM = line.match(/^\s{10,}(.+)$/);
|
|
131
|
+
if (contM) { cmds.push(contM[1].trim()); pendingRun = null; continue; }
|
|
132
|
+
if (line.trim() === '') continue;
|
|
133
|
+
pendingRun = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return cmds.length ? cmds : [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Parse RUN lines from a Dockerfile (backslash-continuations joined). */
|
|
140
|
+
function detectDockerfileRun(projectDir) {
|
|
141
|
+
const f = path.join(projectDir, 'Dockerfile');
|
|
142
|
+
if (!fs.existsSync(f)) return null;
|
|
143
|
+
const lines = fs.readFileSync(f, 'utf8').split('\n');
|
|
144
|
+
const cmds = [];
|
|
145
|
+
let buf = '';
|
|
146
|
+
|
|
147
|
+
for (const raw of lines) {
|
|
148
|
+
const line = raw.trimEnd();
|
|
149
|
+
if (/^RUN\s+/.test(line)) {
|
|
150
|
+
buf = line.replace(/^RUN\s+/, '').replace(/\\$/, '').trim();
|
|
151
|
+
} else if (buf && /^\s/.test(line)) {
|
|
152
|
+
buf += ' ' + line.replace(/\\$/, '').trim();
|
|
153
|
+
} else {
|
|
154
|
+
if (buf) { cmds.push(buf); buf = ''; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (buf) cmds.push(buf);
|
|
158
|
+
return cmds.length ? cmds : [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Extract package.json scripts: build, typecheck, test (in that order). */
|
|
162
|
+
function detectPackageScripts(projectDir) {
|
|
163
|
+
const f = path.join(projectDir, 'package.json');
|
|
164
|
+
if (!fs.existsSync(f)) return null;
|
|
165
|
+
let pkg;
|
|
166
|
+
try { pkg = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; }
|
|
167
|
+
const scripts = (pkg && pkg.scripts) || {};
|
|
168
|
+
const ORDER = ['build', 'typecheck', 'test'];
|
|
169
|
+
const found = ORDER.filter((k) => scripts[k]);
|
|
170
|
+
if (!found.length) return null;
|
|
171
|
+
return found.map((k) => `npm run ${k}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Detect CI source and return { source, commands }.
|
|
176
|
+
* Precedence: cloudbuild → workflows → dockerfile-run → package-scripts → none.
|
|
177
|
+
*/
|
|
178
|
+
function detectCi(projectDir) {
|
|
179
|
+
const cb = detectCloudbuild(projectDir);
|
|
180
|
+
if (cb !== null) return { source: 'cloudbuild', commands: cb };
|
|
181
|
+
|
|
182
|
+
const wf = detectWorkflows(projectDir);
|
|
183
|
+
if (wf !== null) return { source: 'workflows', commands: wf };
|
|
184
|
+
|
|
185
|
+
const df = detectDockerfileRun(projectDir);
|
|
186
|
+
if (df !== null) return { source: 'dockerfile-run', commands: df };
|
|
187
|
+
|
|
188
|
+
const ps = detectPackageScripts(projectDir);
|
|
189
|
+
if (ps !== null) return { source: 'package-scripts', commands: ps };
|
|
190
|
+
|
|
191
|
+
return { source: 'none', commands: [] };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Cache clearing ─────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
function collectFiles(dir, pred, depth, out) {
|
|
197
|
+
if (depth <= 0) return;
|
|
198
|
+
let entries;
|
|
199
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
200
|
+
for (const e of entries) {
|
|
201
|
+
const full = path.join(dir, e.name);
|
|
202
|
+
if (e.isDirectory()) {
|
|
203
|
+
if (e.name === 'node_modules') continue;
|
|
204
|
+
collectFiles(full, pred, depth - 1, out);
|
|
205
|
+
} else if (pred(e.name)) {
|
|
206
|
+
out.push(full);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Parse tsBuildInfoFile and outDir from tsconfig*.json files. */
|
|
212
|
+
function parseTsconfigArtifacts(projectDir) {
|
|
213
|
+
const paths = [];
|
|
214
|
+
let entries;
|
|
215
|
+
try { entries = fs.readdirSync(projectDir); } catch { return paths; }
|
|
216
|
+
for (const name of entries) {
|
|
217
|
+
if (!/^tsconfig.*\.json$/.test(name)) continue;
|
|
218
|
+
let cfg;
|
|
219
|
+
try { cfg = JSON.parse(fs.readFileSync(path.join(projectDir, name), 'utf8')); } catch { continue; }
|
|
220
|
+
const opts = (cfg && cfg.compilerOptions) || {};
|
|
221
|
+
if (opts.tsBuildInfoFile) {
|
|
222
|
+
paths.push(path.resolve(projectDir, opts.tsBuildInfoFile));
|
|
223
|
+
}
|
|
224
|
+
if (opts.outDir) {
|
|
225
|
+
paths.push(path.resolve(projectDir, opts.outDir));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return paths;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Containment predicate (Destructive Action Guard — LOCKED).
|
|
233
|
+
*
|
|
234
|
+
* A config-derived path may be deleted ONLY when its resolved absolute path
|
|
235
|
+
* is a STRICT DESCENDANT of projectRoot:
|
|
236
|
+
*
|
|
237
|
+
* resolved.startsWith(projectRoot + path.sep) && resolved !== projectRoot
|
|
238
|
+
*
|
|
239
|
+
* Both halves are load-bearing:
|
|
240
|
+
* - resolves OUTSIDE projectRoot (`../victim`, absolute) → REFUSE
|
|
241
|
+
* (BUG-1: `tsconfig outDir:"../victim"` force-deleted a sibling).
|
|
242
|
+
* - resolves EQUAL TO projectRoot (`.`, `./`, `src/..`, `./foo/../`)
|
|
243
|
+
* → REFUSE (BUG-8: a regression that force-deleted the entire repo —
|
|
244
|
+
* deleting the project root is never a cache-clear).
|
|
245
|
+
* - the `+ path.sep` guards the prefix-collision edge: a sibling whose
|
|
246
|
+
* name is `projectRoot + "-evil"` must NOT pass `startsWith(projectRoot)`.
|
|
247
|
+
*
|
|
248
|
+
* See memory: feedback_destructive_path_ops_containment.md.
|
|
249
|
+
*/
|
|
250
|
+
function _isSafeToDelete(targetPath, projectRoot) {
|
|
251
|
+
const resolved = path.resolve(targetPath);
|
|
252
|
+
const root = path.resolve(projectRoot);
|
|
253
|
+
return resolved !== root && resolved.startsWith(root + path.sep);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Delete a config-derived path, but ONLY if it passes the containment
|
|
258
|
+
* predicate. Refusals are recorded (never thrown, never deleted) so the
|
|
259
|
+
* caller can surface them in the envelope and still clear legitimate caches.
|
|
260
|
+
*/
|
|
261
|
+
function removePathContained(p, projectRoot, refused) {
|
|
262
|
+
if (!_isSafeToDelete(p, projectRoot)) {
|
|
263
|
+
refused.push(path.resolve(p));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
const st = fs.statSync(p);
|
|
268
|
+
if (st.isDirectory()) fs.rmSync(p, { recursive: true, force: true });
|
|
269
|
+
else fs.unlinkSync(p);
|
|
270
|
+
} catch { /* best-effort: a missing path is fine */ }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Clear build caches before running detected commands (mandatory path —
|
|
275
|
+
* runCiParity calls this unconditionally before any detected command).
|
|
276
|
+
* Returns { refusedPaths } so a containment refusal is observable.
|
|
277
|
+
*/
|
|
278
|
+
function clearBuildCaches(projectDir) {
|
|
279
|
+
const root = path.resolve(projectDir);
|
|
280
|
+
const refused = [];
|
|
281
|
+
|
|
282
|
+
// 1. *.tsbuildinfo files (constructed under projectDir, but guarded too)
|
|
283
|
+
const tsBuildInfoFiles = [];
|
|
284
|
+
collectFiles(projectDir, (name) => name.endsWith('.tsbuildinfo'), 6, tsBuildInfoFiles);
|
|
285
|
+
for (const f of tsBuildInfoFiles) removePathContained(f, root, refused);
|
|
286
|
+
|
|
287
|
+
// 2. node_modules/.cache
|
|
288
|
+
removePathContained(path.join(projectDir, 'node_modules', '.cache'), root, refused);
|
|
289
|
+
|
|
290
|
+
// 3. tsconfig-referenced outDir / tsBuildInfoFile — the dangerous,
|
|
291
|
+
// user-controlled paths. Each MUST pass _isSafeToDelete.
|
|
292
|
+
for (const p of parseTsconfigArtifacts(projectDir)) removePathContained(p, root, refused);
|
|
293
|
+
|
|
294
|
+
return { refusedPaths: refused };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Command runner ─────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/** Run a single shell command with bounded timeout. Returns {cmd, exitCode, ok, output}. */
|
|
300
|
+
function runCommand(cmd, projectDir, timeoutMs) {
|
|
301
|
+
let result;
|
|
302
|
+
try {
|
|
303
|
+
result = child_process.spawnSync('sh', ['-c', cmd], {
|
|
304
|
+
cwd: projectDir,
|
|
305
|
+
timeout: timeoutMs,
|
|
306
|
+
encoding: 'utf8',
|
|
307
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
308
|
+
});
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return { cmd, exitCode: 1, ok: false, output: String(err) };
|
|
311
|
+
}
|
|
312
|
+
const exitCode = result.status != null ? result.status : 1;
|
|
313
|
+
return { cmd, exitCode, ok: exitCode === 0, output: (result.stdout || '') + (result.stderr || '') };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ── Docker ─────────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/** Check whether the `docker` binary is available on PATH. */
|
|
319
|
+
function dockerAvailable() {
|
|
320
|
+
try {
|
|
321
|
+
const r = child_process.spawnSync('docker', ['--version'], {
|
|
322
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
323
|
+
timeout: 5000,
|
|
324
|
+
});
|
|
325
|
+
return r.status === 0;
|
|
326
|
+
} catch { return false; }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Run `docker build` in projectDir with a bounded timeout.
|
|
331
|
+
* Returns { ok, exitCode, output }.
|
|
332
|
+
*/
|
|
333
|
+
function runDockerBuild(projectDir, timeoutMs) {
|
|
334
|
+
const tag = `gsd-t-ci-parity-${Date.now()}`;
|
|
335
|
+
let result;
|
|
336
|
+
try {
|
|
337
|
+
result = child_process.spawnSync(
|
|
338
|
+
'docker', ['build', '--no-cache', '-t', tag, '.'],
|
|
339
|
+
{
|
|
340
|
+
cwd: projectDir,
|
|
341
|
+
timeout: timeoutMs,
|
|
342
|
+
encoding: 'utf8',
|
|
343
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
} catch (err) {
|
|
347
|
+
return { ok: false, exitCode: 1, output: String(err) };
|
|
348
|
+
}
|
|
349
|
+
// Best-effort cleanup of created image
|
|
350
|
+
try {
|
|
351
|
+
child_process.spawnSync('docker', ['rmi', '-f', tag], {
|
|
352
|
+
stdio: 'ignore', timeout: 10000,
|
|
353
|
+
});
|
|
354
|
+
} catch { /* ignore */ }
|
|
355
|
+
const exitCode = result.status != null ? result.status : 1;
|
|
356
|
+
return { ok: exitCode === 0, exitCode, output: (result.stdout || '') + (result.stderr || '') };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Run CI parity check for a project.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} opts
|
|
365
|
+
* @param {string} opts.projectDir
|
|
366
|
+
* @param {number} [opts.timeoutMs]
|
|
367
|
+
* @returns {{ ok: boolean, detectedSource: string, commands: Array, dockerBuilt: boolean, dockerSkippedReason?: string, note?: string }}
|
|
368
|
+
*/
|
|
369
|
+
function runCiParity(opts) {
|
|
370
|
+
const projectDir = (opts && opts.projectDir) || process.cwd();
|
|
371
|
+
const timeoutMs = (opts && opts.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
372
|
+
|
|
373
|
+
const { source, commands } = detectCi(projectDir);
|
|
374
|
+
|
|
375
|
+
// Cache-clear is on the MANDATORY path — it runs before any command AND
|
|
376
|
+
// before the no-CI/docker-only path, so a Dockerfile-only project still
|
|
377
|
+
// gets a clean cache (closes BUG-2: the prior early-return skipped it).
|
|
378
|
+
const { refusedPaths } = clearBuildCaches(projectDir);
|
|
379
|
+
|
|
380
|
+
// No CI config found — not a failure
|
|
381
|
+
if (source === 'none') {
|
|
382
|
+
const hasDockerfile = fs.existsSync(path.join(projectDir, 'Dockerfile'));
|
|
383
|
+
const dockerResult = hasDockerfile
|
|
384
|
+
? runDockerStep(projectDir, timeoutMs)
|
|
385
|
+
: { dockerBuilt: false, dockerSkippedReason: 'no-dockerfile' };
|
|
386
|
+
const env = {
|
|
387
|
+
ok: !dockerResult.dockerFailed,
|
|
388
|
+
detectedSource: 'none',
|
|
389
|
+
commands: [],
|
|
390
|
+
cacheCleared: true,
|
|
391
|
+
...dockerResult,
|
|
392
|
+
note: 'No CI config detected (no cloudbuild.yaml, .github/workflows, Dockerfile RUN lines, or package.json scripts)',
|
|
393
|
+
};
|
|
394
|
+
if (refusedPaths.length) env.refusedPaths = refusedPaths;
|
|
395
|
+
return env;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Run all detected commands
|
|
399
|
+
const commandResults = [];
|
|
400
|
+
let allOk = true;
|
|
401
|
+
for (const cmd of commands) {
|
|
402
|
+
const r = runCommand(cmd, projectDir, timeoutMs);
|
|
403
|
+
commandResults.push({ cmd: r.cmd, exitCode: r.exitCode, ok: r.ok });
|
|
404
|
+
if (!r.ok) allOk = false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Docker step (presence-triggered, not a failure if docker unavailable)
|
|
408
|
+
const hasDockerfile = fs.existsSync(path.join(projectDir, 'Dockerfile'));
|
|
409
|
+
const dockerResult = hasDockerfile
|
|
410
|
+
? runDockerStep(projectDir, timeoutMs)
|
|
411
|
+
: { dockerBuilt: false, dockerSkippedReason: 'no-dockerfile' };
|
|
412
|
+
|
|
413
|
+
if (dockerResult.dockerFailed) allOk = false;
|
|
414
|
+
|
|
415
|
+
const envelope = {
|
|
416
|
+
ok: allOk,
|
|
417
|
+
detectedSource: source,
|
|
418
|
+
commands: commandResults,
|
|
419
|
+
cacheCleared: true,
|
|
420
|
+
dockerBuilt: dockerResult.dockerBuilt,
|
|
421
|
+
};
|
|
422
|
+
if (refusedPaths.length) envelope.refusedPaths = refusedPaths;
|
|
423
|
+
if (dockerResult.dockerSkippedReason) envelope.dockerSkippedReason = dockerResult.dockerSkippedReason;
|
|
424
|
+
if (!allOk && commands.length === 0) envelope.note = 'No commands detected';
|
|
425
|
+
return envelope;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Run docker build step (T3 logic).
|
|
430
|
+
* Returns { dockerBuilt, dockerFailed?, dockerSkippedReason? }
|
|
431
|
+
*/
|
|
432
|
+
function runDockerStep(projectDir, timeoutMs) {
|
|
433
|
+
if (!dockerAvailable()) {
|
|
434
|
+
return { dockerBuilt: false, dockerSkippedReason: 'docker-unavailable', dockerFailed: false };
|
|
435
|
+
}
|
|
436
|
+
const r = runDockerBuild(projectDir, timeoutMs);
|
|
437
|
+
return {
|
|
438
|
+
dockerBuilt: r.ok,
|
|
439
|
+
dockerFailed: !r.ok,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
module.exports = { runCiParity, detectCi, clearBuildCaches, _isSafeToDelete };
|
|
444
|
+
|
|
445
|
+
// ── CLI entry ──────────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
if (require.main === module) {
|
|
448
|
+
const argv = process.argv.slice(2);
|
|
449
|
+
let jsonMode = false;
|
|
450
|
+
let projectDir = process.cwd();
|
|
451
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
452
|
+
|
|
453
|
+
for (let i = 0; i < argv.length; i++) {
|
|
454
|
+
const a = argv[i];
|
|
455
|
+
if (a === '--json') { jsonMode = true; continue; }
|
|
456
|
+
if (a === '--project-dir' && argv[i + 1]) { projectDir = argv[++i]; continue; }
|
|
457
|
+
if (a === '--timeout-ms' && argv[i + 1]) { timeoutMs = Number(argv[++i]); continue; }
|
|
458
|
+
if (a === '--help' || a === '-h') {
|
|
459
|
+
process.stdout.write(
|
|
460
|
+
'Usage: gsd-t ci-parity [--project-dir <dir>] [--timeout-ms <ms>] [--json]\n' +
|
|
461
|
+
'Exit 0 = all CI commands + docker build passed\n' +
|
|
462
|
+
'Exit 4 = failure\n' +
|
|
463
|
+
'Exit 2 = usage error\n'
|
|
464
|
+
);
|
|
465
|
+
process.exit(0);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let result;
|
|
470
|
+
try {
|
|
471
|
+
result = runCiParity({ projectDir, timeoutMs });
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (jsonMode) {
|
|
474
|
+
process.stdout.write(JSON.stringify({ ok: false, error: String(err) }) + '\n');
|
|
475
|
+
} else {
|
|
476
|
+
process.stderr.write('ci-parity error: ' + String(err) + '\n');
|
|
477
|
+
}
|
|
478
|
+
process.exit(4);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (jsonMode) {
|
|
482
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
483
|
+
} else {
|
|
484
|
+
const status = result.ok ? 'PASS' : 'FAIL';
|
|
485
|
+
process.stdout.write(`[ci-parity] ${status} detectedSource=${result.detectedSource}\n`);
|
|
486
|
+
for (const c of result.commands) {
|
|
487
|
+
process.stdout.write(` [${c.ok ? 'ok' : 'FAIL'}] ${c.cmd} (exit ${c.exitCode})\n`);
|
|
488
|
+
}
|
|
489
|
+
if (result.dockerBuilt) {
|
|
490
|
+
process.stdout.write(' [ok] docker build\n');
|
|
491
|
+
} else if (result.dockerSkippedReason) {
|
|
492
|
+
process.stdout.write(` [skip] docker build: ${result.dockerSkippedReason}\n`);
|
|
493
|
+
} else if (!result.dockerBuilt) {
|
|
494
|
+
process.stdout.write(' [FAIL] docker build\n');
|
|
495
|
+
}
|
|
496
|
+
if (result.note) process.stdout.write(` note: ${result.note}\n`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
process.exit(result.ok ? 0 : 4);
|
|
500
|
+
}
|
package/bin/gsd-t-economics.cjs
CHANGED
|
@@ -18,13 +18,40 @@ const path = require("node:path");
|
|
|
18
18
|
|
|
19
19
|
// ─── Constants ────────────────────────────────────────────────────────────
|
|
20
20
|
|
|
21
|
+
const { SAFE_DEFAULT_WINDOW } = require("./model-windows.cjs");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Effective CW ceiling in tokens. This IS the model context window, used for
|
|
25
|
+
* `tokens / ceiling` percentages and parallel-mode gate decisions. Resolved
|
|
26
|
+
* model-aware (see resolveCwCeiling) — the old hardcoded 200K was correct only
|
|
27
|
+
* for pre-4 models and overstated every CW% 5× on Opus/Sonnet (1M window),
|
|
28
|
+
* skewing parallel-mode gates. Kept as the safe fallback when no meter state.
|
|
29
|
+
* - bin/token-budget.cjs (FALLBACK_WINDOW = model-windows safe)
|
|
30
|
+
* - bin/context-meter-config.cjs (modelWindowSize default 200000, now a fallback)
|
|
31
|
+
* - bin/runway-estimator.cjs (DEFAULT_MODEL_CONTEXT_CAP = model-windows safe)
|
|
32
|
+
*/
|
|
33
|
+
const CW_CEILING_TOKENS = SAFE_DEFAULT_WINDOW;
|
|
34
|
+
const METER_STATE_REL = ".gsd-t/.context-meter-state.json";
|
|
35
|
+
const METER_STATE_STALE_MS = 5 * 60 * 1000;
|
|
36
|
+
|
|
21
37
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* - bin/runway-estimator.cjs (DEFAULT_MODEL_CONTEXT_CAP = 200000)
|
|
38
|
+
* Resolve the effective CW ceiling for a project. Prefers a fresh Context
|
|
39
|
+
* Meter reading (model-aware modelWindowSize, written by the meter hook from
|
|
40
|
+
* the running model), else the safe large default.
|
|
26
41
|
*/
|
|
27
|
-
|
|
42
|
+
function resolveCwCeiling(projectDir) {
|
|
43
|
+
try {
|
|
44
|
+
const fp = path.join(projectDir || ".", METER_STATE_REL);
|
|
45
|
+
const s = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
46
|
+
if (s && typeof s.modelWindowSize === "number" && s.modelWindowSize > 0 && s.timestamp) {
|
|
47
|
+
const age = Date.now() - Date.parse(s.timestamp);
|
|
48
|
+
if (!isNaN(age) && age >= 0 && age <= METER_STATE_STALE_MS) {
|
|
49
|
+
return s.modelWindowSize;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (_) { /* fall through */ }
|
|
53
|
+
return CW_CEILING_TOKENS;
|
|
54
|
+
}
|
|
28
55
|
|
|
29
56
|
/** Confidence tier cutoffs (exact-match row counts). */
|
|
30
57
|
const HIGH_CONFIDENCE_MIN = 5; // ≥5 exact matches
|
|
@@ -95,7 +122,7 @@ function loadCorpus(projectDir) {
|
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
const globalMedian = median(allTotals);
|
|
98
|
-
const globalPct = tokensToCwPct(globalMedian);
|
|
125
|
+
const globalPct = tokensToCwPct(globalMedian, resolveCwCeiling(projectDir));
|
|
99
126
|
|
|
100
127
|
const idx = { rows, exact, byDomain, byCommand, globalMedian, globalPct };
|
|
101
128
|
_corpusCache.set(projectDir, idx);
|
|
@@ -122,9 +149,10 @@ function median(arr) {
|
|
|
122
149
|
return a.length % 2 === 1 ? a[mid] : (a[mid - 1] + a[mid]) / 2;
|
|
123
150
|
}
|
|
124
151
|
|
|
125
|
-
function tokensToCwPct(tokens) {
|
|
152
|
+
function tokensToCwPct(tokens, ceiling) {
|
|
126
153
|
if (!Number.isFinite(tokens) || tokens <= 0) return 0;
|
|
127
|
-
|
|
154
|
+
const c = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : CW_CEILING_TOKENS;
|
|
155
|
+
return (tokens / c) * 100;
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
// ─── Event emission (best-effort) ─────────────────────────────────────────
|
|
@@ -247,7 +275,7 @@ function estimateTaskFootprint(opts) {
|
|
|
247
275
|
const corpus = loadCorpus(projectDir);
|
|
248
276
|
|
|
249
277
|
const { estimatedTokens, matchedRows, confidence } = lookupInCorpus(taskNode, corpus);
|
|
250
|
-
const estimatedCwPct = tokensToCwPct(estimatedTokens);
|
|
278
|
+
const estimatedCwPct = tokensToCwPct(estimatedTokens, resolveCwCeiling(projectDir));
|
|
251
279
|
|
|
252
280
|
const { parallelOk, split, workerCount } = decideGates(mode, estimatedCwPct, confidence);
|
|
253
281
|
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
* Atomic rewrite (write-temp + rename).
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
|
|
13
|
+
const KIND = 'file-json-array';
|
|
14
|
+
|
|
15
|
+
function purge({ store, id, taggedPrefix }) {
|
|
16
|
+
if (typeof store !== 'string' || store.length === 0) {
|
|
17
|
+
throw new Error('file-json-array: store must be a non-empty file path');
|
|
18
|
+
}
|
|
19
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
20
|
+
throw new Error('file-json-array: id must be a non-empty string');
|
|
21
|
+
}
|
|
22
|
+
if (typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(taggedPrefix)) {
|
|
23
|
+
throw new Error(`file-json-array: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!fs.existsSync(store)) {
|
|
27
|
+
return 'absent';
|
|
28
|
+
}
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = fs.readFileSync(store, 'utf8');
|
|
32
|
+
} catch (e) {
|
|
33
|
+
throw new Error(`file-json-array: read failed: ${e.message}`);
|
|
34
|
+
}
|
|
35
|
+
let arr;
|
|
36
|
+
try {
|
|
37
|
+
arr = JSON.parse(raw);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
throw new Error(`file-json-array: parse failed: ${e.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (!Array.isArray(arr)) {
|
|
42
|
+
throw new Error('file-json-array: store contents are not an array');
|
|
43
|
+
}
|
|
44
|
+
const before = arr.length;
|
|
45
|
+
const next = arr.filter((row) => !(row && typeof row === 'object' && row.id === id));
|
|
46
|
+
if (next.length === before) {
|
|
47
|
+
return 'absent';
|
|
48
|
+
}
|
|
49
|
+
// Atomic rewrite
|
|
50
|
+
const tmp = `${store}.tmp.${process.pid}.${Date.now()}`;
|
|
51
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), 'utf8');
|
|
52
|
+
fs.renameSync(tmp, store);
|
|
53
|
+
return 'purged';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { kind: KIND, purge };
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
if (typeof taggedPrefix === 'string' && taggedPrefix.length > 0 && !id.startsWith(taggedPrefix)) {
|
|
26
|
+
throw new Error(`localStorage-key-prefix: tag prefix mismatch (id="${id}", taggedPrefix="${taggedPrefix}")`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Browser-side cleanup requires a live page. If absent, treat as 'absent'.
|
|
30
|
+
if (!page || typeof page.evaluate !== 'function') {
|
|
31
|
+
return 'absent';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const key = store + id;
|
|
35
|
+
const result = await page.evaluate((k) => {
|
|
36
|
+
if (typeof window === 'undefined' || !window.localStorage) return 'absent';
|
|
37
|
+
if (window.localStorage.getItem(k) === null) return 'absent';
|
|
38
|
+
window.localStorage.removeItem(k);
|
|
39
|
+
return 'purged';
|
|
40
|
+
}, key);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { kind: KIND, purge };
|