pi-gauntlet 5.18.7 → 5.20.0
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 +16 -0
- package/README.md +5 -2
- package/bin/gauntlet-telemetry-seal.mjs +346 -0
- package/bin/gauntlet-telemetry-seal.test.mjs +271 -0
- package/extensions/lib/telemetry-diff.test.ts +108 -0
- package/extensions/lib/telemetry-diff.ts +61 -0
- package/extensions/lib/telemetry-paths.test.ts +11 -35
- package/extensions/lib/telemetry-paths.ts +13 -42
- package/extensions/lib/telemetry-record.test.ts +0 -6
- package/extensions/lib/telemetry-ship.test.ts +1 -5
- package/extensions/lib/telemetry-ship.ts +1 -4
- package/extensions/telemetry.test.ts +243 -135
- package/extensions/telemetry.ts +148 -201
- package/package.json +2 -2
- package/skills/brainstorming/SKILL.md +1 -1
- package/skills/finishing-a-development-branch/SKILL.md +19 -16
- package/skills/gatekeep-pr/reference/assessment.md +0 -13
- package/skills/gatekeep-pr/reference/decision-menu.md +1 -2
- package/skills/gatekeep-pr/reference/post-selection-loop.md +3 -3
- package/skills/gauntlet-performance/SKILL.md +1 -1
- package/skills/gauntlet-resume/SKILL.md +46 -32
- package/skills/gauntlet-resume/reference/brief-contract.md +12 -8
- package/skills/gauntlet-resume/reference/reconstruction.md +10 -3
- package/skills/gauntlet-resume/reference/seed.md +47 -0
- package/bin/gauntlet-telemetry-salvage.mjs +0 -273
- package/bin/gauntlet-telemetry-salvage.test.mjs +0 -466
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync, realpathSync, symlinkSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
|
|
10
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-telemetry-seal.mjs");
|
|
11
|
+
const SPEC = "doc/specs/x.md";
|
|
12
|
+
const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
|
|
13
|
+
const RECORD = [
|
|
14
|
+
"schema: 1",
|
|
15
|
+
"spec: doc/specs/x.md",
|
|
16
|
+
"run_id: r-1",
|
|
17
|
+
"status: in_progress",
|
|
18
|
+
"created_at: 2026-09-17T16:00:00Z",
|
|
19
|
+
"sessions: [s1]",
|
|
20
|
+
"derived:",
|
|
21
|
+
" duration_s: 60",
|
|
22
|
+
" phases: {}",
|
|
23
|
+
" personas: {}",
|
|
24
|
+
" conformance_loops: 0",
|
|
25
|
+
" gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }",
|
|
26
|
+
" amendments: 0",
|
|
27
|
+
" spec_edits_after_ship: 0",
|
|
28
|
+
" spec_writes: {}",
|
|
29
|
+
" events_dropped: 0",
|
|
30
|
+
"accumulators: {}",
|
|
31
|
+
"events:",
|
|
32
|
+
" - { ts: 2026-09-17T16:00:00Z, session: s1, phase: brainstorm, kind: phase, action: start, name: brainstorm }",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n");
|
|
35
|
+
const SEALED = RECORD.replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:00:00Z");
|
|
36
|
+
|
|
37
|
+
const git = (cwd, args, env = {}) =>
|
|
38
|
+
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], { cwd, encoding: "utf8", env: { ...process.env, ...env } });
|
|
39
|
+
const out = (cwd, args) => git(cwd, args).stdout.trim();
|
|
40
|
+
const write = (root, rel, text) => {
|
|
41
|
+
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
42
|
+
writeFileSync(join(root, rel), text);
|
|
43
|
+
};
|
|
44
|
+
const commit = (root, msg, ...paths) => {
|
|
45
|
+
git(root, ["add", "-f", "--", ...paths]);
|
|
46
|
+
git(root, ["commit", "-q", "-m", msg, "--", ...paths]);
|
|
47
|
+
return out(root, ["rev-parse", "HEAD"]);
|
|
48
|
+
};
|
|
49
|
+
// main: README only. feat: spec + src/a.ts; the record is untracked unless a test commits it.
|
|
50
|
+
const repo = (options = {}) => {
|
|
51
|
+
const record = Object.hasOwn(options, "record") ? options.record : RECORD;
|
|
52
|
+
const root = mkdtempSync(join(tmpdir(), "gts-"));
|
|
53
|
+
git(root, ["init", "-q", "-b", "main"]);
|
|
54
|
+
write(root, "README.md", "# fixture\n");
|
|
55
|
+
commit(root, "init", "README.md");
|
|
56
|
+
git(root, ["checkout", "-q", "-b", "feat"]);
|
|
57
|
+
write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
|
|
58
|
+
write(root, "src/a.ts", "export const a = 1;\n");
|
|
59
|
+
commit(root, "Add spec and code", SPEC, "src/a.ts");
|
|
60
|
+
if (record !== undefined) write(root, REC, record);
|
|
61
|
+
return root;
|
|
62
|
+
};
|
|
63
|
+
const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gts-empty-agent-"));
|
|
64
|
+
process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
|
|
65
|
+
const IDENTITY = { GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
|
66
|
+
const invoke = (args, env = {}) => {
|
|
67
|
+
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env: { ...process.env, ...IDENTITY, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env } });
|
|
68
|
+
return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
|
|
69
|
+
};
|
|
70
|
+
const run = (root, args = [], env = {}) => invoke(["--worktree", root, "--option", "squash", "--base", "main", ...args], env);
|
|
71
|
+
const head = (root) => out(root, ["rev-parse", "HEAD"]);
|
|
72
|
+
const commitCount = (root) => Number(out(root, ["rev-list", "--count", "HEAD"]));
|
|
73
|
+
const headSubject = (root) => out(root, ["log", "-1", "--format=%s"]);
|
|
74
|
+
const headFiles = (root) => out(root, ["show", "--name-only", "--format=", "HEAD"]).split("\n").filter(Boolean);
|
|
75
|
+
const porcelain = (root) => out(root, ["status", "--porcelain", "--untracked-files=all"]);
|
|
76
|
+
const record = (root) => parseYaml(readFileSync(join(root, REC), "utf8"));
|
|
77
|
+
const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
|
|
78
|
+
|
|
79
|
+
test("seals an untracked in_progress record: stamp, ship event, diff, re-derived duration, one telemetry: commit touching only the record", (t) => {
|
|
80
|
+
const root = repo(); cleanup(t, root);
|
|
81
|
+
const n = commitCount(root);
|
|
82
|
+
const r = run(root, ["--spec", SPEC]);
|
|
83
|
+
assert.equal(r.status, 0, r.stderr);
|
|
84
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
85
|
+
const rec = record(root);
|
|
86
|
+
assert.equal(rec.status, "shipped");
|
|
87
|
+
assert.match(rec.shipped_at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
|
88
|
+
assert.equal(rec.derived.gates.ship_option, "squash");
|
|
89
|
+
assert.equal(typeof rec.derived.gates.ship_option, "string");
|
|
90
|
+
assert.equal(rec.events.at(-1).kind, "ship");
|
|
91
|
+
assert.equal(rec.events.at(-1).option, "squash");
|
|
92
|
+
assert.deepEqual(rec.derived.modified_files, ["src/a.ts"]);
|
|
93
|
+
assert.equal(rec.derived.diff.commits, 1, "the seal commit is excluded from the count");
|
|
94
|
+
assert.equal(rec.derived.diff.buckets.code.files, 1);
|
|
95
|
+
// created_at is 2026-09-17; sealing now re-derives duration_s from created_at to shipped_at, far above the recorded 60.
|
|
96
|
+
assert.ok(rec.derived.duration_s > 60, `duration re-derived at seal time, got ${rec.derived.duration_s}`);
|
|
97
|
+
assert.equal(commitCount(root), n + 1);
|
|
98
|
+
assert.equal(headSubject(root), `telemetry: ${SPEC}`);
|
|
99
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
100
|
+
assert.equal(porcelain(root), "");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("--spec accepts an absolute path within the checkout", (t) => {
|
|
104
|
+
const root = repo(); cleanup(t, root);
|
|
105
|
+
const r = run(root, ["--spec", join(realpathSync(root), SPEC)]);
|
|
106
|
+
assert.equal(r.status, 0, r.stderr);
|
|
107
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("--spec absolute through a symlinked prefix still resolves to the repo-relative record", (t) => {
|
|
111
|
+
const root = repo();
|
|
112
|
+
const linkDir = mkdtempSync(join(tmpdir(), "gts-link-"));
|
|
113
|
+
cleanup(t, root, linkDir);
|
|
114
|
+
const link = join(linkDir, "checkout");
|
|
115
|
+
symlinkSync(root, link);
|
|
116
|
+
const r = run(root, ["--spec", join(link, SPEC)]);
|
|
117
|
+
assert.equal(r.status, 0, r.stderr);
|
|
118
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("--spec accepts a dot-relative path", (t) => {
|
|
122
|
+
const root = repo(); cleanup(t, root);
|
|
123
|
+
const r = run(root, ["--spec", `./${SPEC}`]);
|
|
124
|
+
assert.equal(r.status, 0, r.stderr);
|
|
125
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("--option pr is recorded; without --spec the candidates are the existing records of changed specs", (t) => {
|
|
129
|
+
const root = repo(); cleanup(t, root);
|
|
130
|
+
const r = invoke(["--worktree", root, "--option", "pr", "--base", "main"]);
|
|
131
|
+
assert.equal(r.status, 0, r.stderr);
|
|
132
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
133
|
+
assert.equal(record(root).derived.gates.ship_option, "pr");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("already sealed: a shipped, tracked, clean record is left alone", (t) => {
|
|
137
|
+
const root = repo({ record: SEALED }); cleanup(t, root);
|
|
138
|
+
commit(root, "telemetry: doc/specs/x.md", REC);
|
|
139
|
+
const before = head(root);
|
|
140
|
+
const r = run(root, ["--spec", SPEC]);
|
|
141
|
+
assert.equal(r.status, 0, r.stderr);
|
|
142
|
+
assert.deepEqual(r.lines, [`already sealed ${REC}`]);
|
|
143
|
+
assert.equal(head(root), before);
|
|
144
|
+
assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("an abandoned record is already sealed even when untracked", (t) => {
|
|
148
|
+
const root = repo({ record: RECORD.replace("status: in_progress", "status: abandoned") }); cleanup(t, root);
|
|
149
|
+
const before = head(root);
|
|
150
|
+
const r = run(root, ["--spec", SPEC]);
|
|
151
|
+
assert.equal(r.status, 0, r.stderr);
|
|
152
|
+
assert.deepEqual(r.lines, [`already sealed ${REC}`]);
|
|
153
|
+
assert.equal(head(root), before);
|
|
154
|
+
assert.equal(record(root).status, "abandoned");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a shipped but untracked record (previous commit failed) is committed as is on retry", (t) => {
|
|
158
|
+
const root = repo({ record: SEALED }); cleanup(t, root);
|
|
159
|
+
const n = commitCount(root);
|
|
160
|
+
const r = run(root, ["--spec", SPEC]);
|
|
161
|
+
assert.equal(r.status, 0, r.stderr);
|
|
162
|
+
assert.deepEqual(r.lines, [`sealed ${REC}`]);
|
|
163
|
+
assert.equal(commitCount(root), n + 1);
|
|
164
|
+
assert.equal(record(root).shipped_at, "2026-09-17T18:00:00Z", "bytes committed as they were");
|
|
165
|
+
assert.equal(porcelain(root), "");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("--spec with no record -> no record at <path>, exit 2, no commit", (t) => {
|
|
169
|
+
const root = repo({ record: undefined }); cleanup(t, root);
|
|
170
|
+
const n = commitCount(root);
|
|
171
|
+
const r = run(root, ["--spec", SPEC]);
|
|
172
|
+
assert.equal(r.status, 2);
|
|
173
|
+
assert.equal(r.stderr, `no record at ${REC}`);
|
|
174
|
+
assert.equal(commitCount(root), n);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("unparseable record -> parser message, exit 2, file untouched", (t) => {
|
|
178
|
+
const root = repo({ record: "schema: 1\nspec: doc/specs/x.md\n" }); cleanup(t, root);
|
|
179
|
+
const r = run(root, ["--spec", SPEC]);
|
|
180
|
+
assert.equal(r.status, 2);
|
|
181
|
+
assert.match(r.stderr, new RegExp(`^unparseable record ${REC.replace(/\./g, "\\.")}`));
|
|
182
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), "schema: 1\nspec: doc/specs/x.md\n");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("no telemetry run: a changed spec without a record is not a candidate (supersession edit)", (t) => {
|
|
186
|
+
const root = repo({ record: undefined }); cleanup(t, root);
|
|
187
|
+
const r = run(root);
|
|
188
|
+
assert.equal(r.status, 0, r.stderr);
|
|
189
|
+
assert.deepEqual(r.lines, ["no telemetry run"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("telemetry disabled -> exit 0, nothing touched", (t) => {
|
|
193
|
+
const root = repo(); cleanup(t, root);
|
|
194
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
|
|
195
|
+
const r = run(root, ["--spec", SPEC]);
|
|
196
|
+
assert.equal(r.status, 0, r.stderr);
|
|
197
|
+
assert.deepEqual(r.lines, ["telemetry disabled"]);
|
|
198
|
+
assert.equal(record(root).status, "in_progress");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("gitignored telemetry dir is staged with -f", (t) => {
|
|
202
|
+
const root = repo(); cleanup(t, root);
|
|
203
|
+
write(root, ".gitignore", ".pi/gauntlet/telemetry/\n");
|
|
204
|
+
commit(root, "ignore telemetry", ".gitignore");
|
|
205
|
+
const r = run(root, ["--spec", SPEC]);
|
|
206
|
+
assert.equal(r.status, 0, r.stderr);
|
|
207
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("commit failure (rejecting pre-commit hook) restores the pre-seal bytes, unstages, exits 1", (t) => {
|
|
211
|
+
const root = repo(); cleanup(t, root);
|
|
212
|
+
const hook = join(root, ".git/hooks/pre-commit");
|
|
213
|
+
writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
|
|
214
|
+
chmodSync(hook, 0o755);
|
|
215
|
+
const n = commitCount(root);
|
|
216
|
+
const r = run(root, ["--spec", SPEC]);
|
|
217
|
+
assert.equal(r.status, 1);
|
|
218
|
+
assert.match(r.stderr, /rejected/);
|
|
219
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD, "pre-seal bytes restored");
|
|
220
|
+
assert.equal(out(root, ["diff", "--cached", "--name-only"]), "", "nothing left staged");
|
|
221
|
+
assert.equal(commitCount(root), n);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("commit failure restores a previously tracked record and clears the index", (t) => {
|
|
225
|
+
const root = repo(); cleanup(t, root);
|
|
226
|
+
commit(root, "Track ongoing telemetry", REC);
|
|
227
|
+
const hook = join(root, ".git/hooks/pre-commit");
|
|
228
|
+
writeFileSync(hook, "#!/bin/sh\necho rejected >&2\nexit 1\n");
|
|
229
|
+
chmodSync(hook, 0o755);
|
|
230
|
+
const n = commitCount(root);
|
|
231
|
+
const r = run(root, ["--spec", SPEC]);
|
|
232
|
+
assert.equal(r.status, 1);
|
|
233
|
+
assert.match(r.stderr, /rejected/);
|
|
234
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), RECORD);
|
|
235
|
+
assert.equal(out(root, ["diff", "--cached", "--name-only"]), "");
|
|
236
|
+
assert.equal(commitCount(root), n);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("usage and environment errors exit 1", (t) => {
|
|
240
|
+
const root = repo(); cleanup(t, root);
|
|
241
|
+
const noArgs = invoke([]);
|
|
242
|
+
assert.equal(noArgs.status, 1);
|
|
243
|
+
assert.match(noArgs.stderr, /^usage: gauntlet-telemetry-seal --worktree <abs path> --option <pr\|squash> --base <ref> \[--spec <spec path inside the checkout>\] \[--dir <telemetry dir>\]/);
|
|
244
|
+
assert.equal(invoke(["--worktree", root, "--option", "rebase", "--base", "main"]).status, 1);
|
|
245
|
+
assert.equal(invoke(["--worktree", root, "--option", "squash"]).status, 1, "--base is required");
|
|
246
|
+
const noBase = invoke(["--worktree", root, "--option", "squash", "--base", "nope"]);
|
|
247
|
+
assert.equal(noBase.status, 1);
|
|
248
|
+
assert.equal(noBase.stderr, "no base ref nope");
|
|
249
|
+
for (const spec of ["/tmp/elsewhere/doc/specs/x.md", "README.md"]) {
|
|
250
|
+
const invalid = run(root, ["--spec", spec]);
|
|
251
|
+
assert.equal(invalid.status, 1);
|
|
252
|
+
assert.match(invalid.stderr, /^usage:/);
|
|
253
|
+
}
|
|
254
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { enabled: false } } }));
|
|
255
|
+
const disabledInvalid = run(root, ["--spec", "/tmp/elsewhere/doc/specs/x.md"]);
|
|
256
|
+
assert.equal(disabledInvalid.status, 1);
|
|
257
|
+
assert.match(disabledInvalid.stderr, /^usage:/);
|
|
258
|
+
const notGit = mkdtempSync(join(tmpdir(), "gts-nogit-")); cleanup(t, notGit);
|
|
259
|
+
const ng = invoke(["--worktree", notGit, "--option", "squash", "--base", "main"]);
|
|
260
|
+
assert.equal(ng.status, 1);
|
|
261
|
+
assert.equal(ng.stderr, "not a git checkout");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("--dir overrides the telemetry dir", (t) => {
|
|
265
|
+
const root = repo({ record: undefined }); cleanup(t, root);
|
|
266
|
+
write(root, "tele/doc/specs/x.yaml", RECORD);
|
|
267
|
+
const r = run(root, ["--spec", SPEC, "--dir", "tele"]);
|
|
268
|
+
assert.equal(r.status, 0, r.stderr);
|
|
269
|
+
assert.deepEqual(r.lines, ["sealed tele/doc/specs/x.yaml"]);
|
|
270
|
+
assert.deepEqual(headFiles(root), ["tele/doc/specs/x.yaml"]);
|
|
271
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { DEFAULT_TELEMETRY_BUCKETS } from "./gauntlet-settings.ts";
|
|
4
|
+
import { JJ_MAINLINE, aggregateNumstat, computeGitDiff, computeJjDiff, type RunResult } from "./telemetry-diff.ts";
|
|
5
|
+
|
|
6
|
+
const ok = (stdout: string): RunResult => ({ code: 0, stdout, stderr: "" });
|
|
7
|
+
const DIR = ".pi/gauntlet/telemetry";
|
|
8
|
+
|
|
9
|
+
test("aggregateNumstat sums per bucket over the given file set; binary rows count 0 lines", () => {
|
|
10
|
+
const numstat = ["10\t2\textensions/telemetry.ts", "5\t0\textensions/lib/telemetry-paths.test.ts", "-\t-\timg.png", "3\t3\tdoc/specs/a.md", "1\t1\t{old => new}/x.ts"].join("\n");
|
|
11
|
+
const files = new Set(["extensions/telemetry.ts", "extensions/lib/telemetry-paths.test.ts", "img.png", "new/x.ts"]);
|
|
12
|
+
assert.deepEqual(aggregateNumstat(numstat, files, DEFAULT_TELEMETRY_BUCKETS), {
|
|
13
|
+
code: { files: 3, insertions: 11, deletions: 3 },
|
|
14
|
+
test: { files: 1, insertions: 5, deletions: 0 },
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("computeGitDiff: merge-base, name-only minus spec/plan/dir, numstat buckets, telemetry-free commit count", async () => {
|
|
19
|
+
const calls: string[][] = [];
|
|
20
|
+
const git = (args: string[]) => {
|
|
21
|
+
calls.push(args);
|
|
22
|
+
if (args[0] === "merge-base") return ok("abc123\n");
|
|
23
|
+
if (args[0] === "diff" && args.includes("--name-only")) return ok("extensions/telemetry.ts\nextensions/telemetry.test.ts\ndoc/specs/a.md\ndoc/plans/a.md\n.pi/gauntlet/telemetry/doc/specs/a.yaml\nREADME.md\n");
|
|
24
|
+
if (args[0] === "diff" && args.includes("--numstat")) return ok("100\t5\textensions/telemetry.ts\n40\t0\textensions/telemetry.test.ts\n9\t9\tdoc/specs/a.md\n2\t1\tREADME.md\n");
|
|
25
|
+
if (args[0] === "rev-list") return ok("9\n");
|
|
26
|
+
return { code: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
|
|
27
|
+
};
|
|
28
|
+
const out = await computeGitDiff({ git, cwd: "/repo", spec: "doc/specs/a.md", dir: DIR, buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
|
|
29
|
+
assert.deepEqual(out.modified_files, ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
|
|
30
|
+
assert.deepEqual(out.diff, { base: "abc123", commits: 9, buckets: { code: { files: 1, insertions: 100, deletions: 5 }, test: { files: 1, insertions: 40, deletions: 0 }, docs: { files: 1, insertions: 2, deletions: 1 } } });
|
|
31
|
+
assert.equal(out.warning, undefined);
|
|
32
|
+
assert.deepEqual(calls[0], ["merge-base", "HEAD", "main"]);
|
|
33
|
+
assert.deepEqual(calls.at(-1), ["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", "abc123..HEAD"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("computeGitDiff: merge-base failure -> warning, both fields absent", async () => {
|
|
37
|
+
const out = await computeGitDiff({ git: () => ({ code: 128, stdout: "", stderr: "fatal: no merge base\nmore" }), cwd: "/repo", spec: "doc/specs/a.md", dir: DIR, buckets: DEFAULT_TELEMETRY_BUCKETS, base: "main" });
|
|
38
|
+
assert.deepEqual(out, { warning: "diff omitted: merge-base failed: fatal: no merge base" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const JJ_PATCH = [
|
|
42
|
+
"diff --git a/src/a.ts b/src/a.ts",
|
|
43
|
+
"index 1111111..2222222 100644",
|
|
44
|
+
"--- a/src/a.ts",
|
|
45
|
+
"+++ b/src/a.ts",
|
|
46
|
+
"@@ -1,4 +1,12 @@",
|
|
47
|
+
...Array.from({ length: 10 }, (_, i) => `+added ${i}`),
|
|
48
|
+
"-removed 1",
|
|
49
|
+
"-removed 2",
|
|
50
|
+
" context",
|
|
51
|
+
"diff --git a/test/a.test.ts b/test/a.test.ts",
|
|
52
|
+
"new file mode 100644",
|
|
53
|
+
"index 0000000..3333333",
|
|
54
|
+
"--- /dev/null",
|
|
55
|
+
"+++ b/test/a.test.ts",
|
|
56
|
+
"@@ -0,0 +1,5 @@",
|
|
57
|
+
...Array.from({ length: 5 }, (_, i) => `+t ${i}`),
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
|
|
61
|
+
const jjStub = (over?: (args: string[]) => RunResult | undefined) => {
|
|
62
|
+
const calls: string[][] = [];
|
|
63
|
+
const jj = async (args: string[]): Promise<RunResult> => {
|
|
64
|
+
calls.push(args);
|
|
65
|
+
const forced = over?.(args);
|
|
66
|
+
if (forced) return forced;
|
|
67
|
+
if (args.includes("--count")) return ok(args.some((a) => a.includes(`files(~glob:"${DIR}/**")`)) ? "2\n" : "3\n");
|
|
68
|
+
if (args.includes("log")) return ok("jjbase0001\n");
|
|
69
|
+
if (args.includes("diff")) return ok(JJ_PATCH);
|
|
70
|
+
return { code: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
|
|
71
|
+
};
|
|
72
|
+
return { jj, calls };
|
|
73
|
+
};
|
|
74
|
+
const runJj = (over?: (args: string[]) => RunResult | undefined) => {
|
|
75
|
+
const s = jjStub(over);
|
|
76
|
+
return computeJjDiff({ jj: s.jj, cwd: "/ws", spec: "doc/specs/a.md", dir: DIR, buckets: DEFAULT_TELEMETRY_BUCKETS }).then((out) => ({ out, calls: s.calls }));
|
|
77
|
+
};
|
|
78
|
+
const isBaseLog = (args: string[]) => args.includes("log") && !args.includes("--count");
|
|
79
|
+
|
|
80
|
+
test("computeJjDiff: pinned flags, mainline revset, telemetry-only revisions excluded from the count", async () => {
|
|
81
|
+
const { out, calls } = await runJj();
|
|
82
|
+
assert.deepEqual(out.modified_files, ["src/a.ts", "test/a.test.ts"]);
|
|
83
|
+
assert.deepEqual(out.diff, { base: "jjbase0001", commits: 2, buckets: { code: { files: 1, insertions: 10, deletions: 2 }, test: { files: 1, insertions: 5, deletions: 0 } } });
|
|
84
|
+
assert.equal(out.warning, undefined);
|
|
85
|
+
assert.equal(calls.length, 3);
|
|
86
|
+
assert.ok(calls.every((a) => a.includes("--color=never")));
|
|
87
|
+
assert.deepEqual(calls[1].slice(0, 4), ["--color=never", "--config", "diff.git.show-path-prefix=true", "diff"]);
|
|
88
|
+
assert.ok(calls[0].some((a) => a.includes(`fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`)));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("computeJjDiff: empty mainline -> jj-named warning", async () => {
|
|
92
|
+
const { out } = await runJj((args) => (isBaseLog(args) ? ok("") : undefined));
|
|
93
|
+
assert.deepEqual(out, { warning: "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("computeJjDiff: nonzero exit -> first stderr line; empty stderr -> unknown error", async () => {
|
|
97
|
+
const a = await runJj((args) => (args.includes("diff") ? { code: 1, stdout: "", stderr: "boom\nmore" } : undefined));
|
|
98
|
+
assert.deepEqual(a.out, { warning: "diff omitted: jj diff failed: boom" });
|
|
99
|
+
const b = await runJj((args) => (isBaseLog(args) ? { code: 1, stdout: "", stderr: "" } : undefined));
|
|
100
|
+
assert.deepEqual(b.out, { warning: "diff omitted: jj log failed: unknown error" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("computeJjDiff: unparseable patch and unparseable count", async () => {
|
|
104
|
+
const a = await runJj((args) => (args.includes("diff") ? ok("not a patch") : undefined));
|
|
105
|
+
assert.deepEqual(a.out, { warning: "diff omitted: jj diff unparseable" });
|
|
106
|
+
const b = await runJj((args) => (args.includes("--count") ? ok("many\n") : undefined));
|
|
107
|
+
assert.deepEqual(b.out, { warning: "diff omitted: jj log --count unparseable: many" });
|
|
108
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { classifyBucket, numstatPath, parsePatchNumstat, type BucketStat } from "./telemetry-paths.ts";
|
|
2
|
+
import type { DiffSummary } from "./telemetry-record.ts";
|
|
3
|
+
import { modifiedFilesFrom } from "./telemetry-ship.ts";
|
|
4
|
+
|
|
5
|
+
export interface RunResult { code: number; stdout: string; stderr: string }
|
|
6
|
+
export type Runner = (args: string[], cwd: string) => RunResult | Promise<RunResult>;
|
|
7
|
+
export type Buckets = [string, string[]][];
|
|
8
|
+
// warning set => both fields absent; the caller records the warning as an event.
|
|
9
|
+
export interface DiffOutcome { diff?: DiffSummary; modified_files?: string[]; warning?: string }
|
|
10
|
+
|
|
11
|
+
export function aggregateNumstat(numstat: string, files: Set<string>, buckets: Buckets): Record<string, BucketStat> {
|
|
12
|
+
const out: Record<string, BucketStat> = {};
|
|
13
|
+
for (const line of numstat.split("\n")) {
|
|
14
|
+
if (!line.trim()) continue;
|
|
15
|
+
const [ins, del, ...rest] = line.split("\t");
|
|
16
|
+
const path = numstatPath(rest.join("\t"));
|
|
17
|
+
if (!files.has(path)) continue;
|
|
18
|
+
const bucket = classifyBucket(path, buckets);
|
|
19
|
+
const stat = (out[bucket] ??= { files: 0, insertions: 0, deletions: 0 });
|
|
20
|
+
stat.files += 1;
|
|
21
|
+
stat.insertions += ins === "-" ? 0 : Number(ins) || 0;
|
|
22
|
+
stat.deletions += del === "-" ? 0 : Number(del) || 0;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
|
|
28
|
+
const firstLine = (s: string): string => s.trim().split("\n")[0];
|
|
29
|
+
|
|
30
|
+
export async function computeJjDiff(o: { jj: Runner; cwd: string; spec: string; dir: string; buckets: Buckets }): Promise<DiffOutcome> {
|
|
31
|
+
const failed = (sub: string, r: RunResult): DiffOutcome => ({ warning: `diff omitted: jj ${sub} failed: ${firstLine(r.stderr) || "unknown error"}` });
|
|
32
|
+
// Without the ::M guard an empty mainline resolves to @ and counts the full history.
|
|
33
|
+
const baseR = await o.jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], o.cwd);
|
|
34
|
+
if (baseR.code !== 0) return failed("log", baseR);
|
|
35
|
+
const base = firstLine(baseR.stdout).trim();
|
|
36
|
+
if (!base) return { warning: "diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)" };
|
|
37
|
+
const patch = await o.jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], o.cwd);
|
|
38
|
+
if (patch.code !== 0) return failed("diff", patch);
|
|
39
|
+
const rows = parsePatchNumstat(patch.stdout);
|
|
40
|
+
if (!rows) return { warning: "diff omitted: jj diff unparseable" };
|
|
41
|
+
const dir = o.dir.replace(/\/+$/, "");
|
|
42
|
+
// Exclude record-only @ and other telemetry-only revisions from the count.
|
|
43
|
+
const count = await o.jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], o.cwd);
|
|
44
|
+
if (count.code !== 0) return failed("log", count);
|
|
45
|
+
const n = count.stdout.trim();
|
|
46
|
+
if (!/^\d+$/.test(n)) return { warning: `diff omitted: jj log --count unparseable: ${n}` };
|
|
47
|
+
const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), o.spec, o.dir);
|
|
48
|
+
const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
|
|
49
|
+
return { modified_files: files, diff: { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), o.buckets) } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function computeGitDiff(o: { git: Runner; cwd: string; spec: string; dir: string; buckets: Buckets; base: string }): Promise<DiffOutcome> {
|
|
53
|
+
const mb = await o.git(["merge-base", "HEAD", o.base], o.cwd);
|
|
54
|
+
if (mb.code !== 0 || !mb.stdout.trim()) return { warning: `diff omitted: merge-base failed: ${firstLine(mb.stderr)}` };
|
|
55
|
+
const base = mb.stdout.trim();
|
|
56
|
+
const names = await o.git(["diff", "--name-only", `${base}...HEAD`], o.cwd);
|
|
57
|
+
const files = modifiedFilesFrom(names.stdout, o.spec, o.dir);
|
|
58
|
+
const numstat = await o.git(["diff", "--numstat", `${base}...HEAD`], o.cwd);
|
|
59
|
+
const count = await o.git(["rev-list", "--count", "--invert-grep", "--grep=^telemetry: ", `${base}..HEAD`], o.cwd);
|
|
60
|
+
return { modified_files: files, diff: { base, commits: Number(count.stdout.trim()) || 0, buckets: aggregateNumstat(numstat.stdout, new Set(files), o.buckets) } };
|
|
61
|
+
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import {
|
|
4
|
-
aggregateNumstat,
|
|
5
4
|
classifyBucket,
|
|
6
5
|
isPlanPath,
|
|
7
6
|
isSpecPath,
|
|
8
7
|
isSupersededByBanner,
|
|
9
|
-
matchDiscardStatement,
|
|
10
8
|
matchShipStatement,
|
|
11
9
|
matchTestStatement,
|
|
12
10
|
parsePatchNumstat,
|
|
@@ -44,30 +42,6 @@ test("planSpecHeader extracts the **Spec:** path", () => {
|
|
|
44
42
|
assert.equal(planSpecHeader("no header"), undefined);
|
|
45
43
|
});
|
|
46
44
|
|
|
47
|
-
test("matchShipStatement needs a statement start (STMT_START)", () => {
|
|
48
|
-
assert.deepEqual(matchShipStatement("git merge --squash gh-33 && git commit"), { option: "squash", statement: "git merge --squash gh-33" });
|
|
49
|
-
assert.deepEqual(matchShipStatement("cd x; git push -u origin HEAD"), { option: "pr", statement: "git push -u origin HEAD" });
|
|
50
|
-
assert.equal(matchShipStatement('gh pr create --fill')?.option, "pr");
|
|
51
|
-
assert.equal(matchShipStatement('rg "git push" skills/'), undefined);
|
|
52
|
-
assert.equal(matchShipStatement("echo git push"), undefined);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("matchDiscardStatement matches worktree remove and branch -D", () => {
|
|
56
|
-
assert.equal(matchDiscardStatement("git worktree remove .worktrees/x"), "git worktree remove .worktrees/x");
|
|
57
|
-
assert.equal(matchDiscardStatement("cd .. && git branch -D gh-33"), "git branch -D gh-33");
|
|
58
|
-
assert.equal(matchDiscardStatement("git branch -d gh-33"), undefined);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test("matchShipStatement/matchDiscardStatement accept git -C <path> global flags", () => {
|
|
62
|
-
assert.deepEqual(matchShipStatement("git -C /p merge --squash f"), { option: "squash", statement: "git -C /p merge --squash f" });
|
|
63
|
-
assert.equal(matchShipStatement("git -C /p push -u origin HEAD")?.option, "pr");
|
|
64
|
-
assert.equal(matchShipStatement("git -c user.name=t -C /p merge --squash f")?.option, "squash");
|
|
65
|
-
assert.equal(matchShipStatement("git -C /p log"), undefined);
|
|
66
|
-
assert.equal(matchDiscardStatement("git -C /p worktree remove x"), "git -C /p worktree remove x");
|
|
67
|
-
assert.equal(matchDiscardStatement("git -C /p branch -D f"), "git -C /p branch -D f");
|
|
68
|
-
assert.equal(matchDiscardStatement("git -C /p branch -d f"), undefined);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
45
|
test("matchTestStatement returns the first statement matching a test fragment", () => {
|
|
72
46
|
assert.equal(matchTestStatement("cd repo && npm test -- --grep x", DEFAULT_TEST_COMMANDS), "npm test -- --grep x");
|
|
73
47
|
assert.equal(matchTestStatement("make test-smoke", DEFAULT_TEST_COMMANDS), undefined);
|
|
@@ -87,15 +61,6 @@ test("classifyBucket: first match wins, else code", () => {
|
|
|
87
61
|
assert.equal(classifyBucket("doc/specs/a.md", [["spec", ["doc/specs/**"]], ["docs", ["**/*.md"]]]), "spec");
|
|
88
62
|
});
|
|
89
63
|
|
|
90
|
-
test("aggregateNumstat sums per bucket over the given file set; binary rows count 0 lines", () => {
|
|
91
|
-
const numstat = ["10\t2\textensions/telemetry.ts", "5\t0\textensions/lib/telemetry-paths.test.ts", "-\t-\timg.png", "3\t3\tdoc/specs/a.md", "1\t1\t{old => new}/x.ts"].join("\n");
|
|
92
|
-
const files = new Set(["extensions/telemetry.ts", "extensions/lib/telemetry-paths.test.ts", "img.png", "new/x.ts"]);
|
|
93
|
-
assert.deepEqual(aggregateNumstat(numstat, files, DEFAULT_TELEMETRY_BUCKETS), {
|
|
94
|
-
code: { files: 3, insertions: 11, deletions: 3 },
|
|
95
|
-
test: { files: 1, insertions: 5, deletions: 0 },
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
64
|
const patch = (...blocks: string[][]) => blocks.map((b) => b.join("\n")).join("\n") + "\n";
|
|
100
65
|
|
|
101
66
|
test("parsePatchNumstat counts hunk lines and paths with spaces", () => {
|
|
@@ -149,3 +114,14 @@ test("isSupersededByBanner detects the predecessor banner and its successor labe
|
|
|
149
114
|
assert.equal(isSupersededByBanner("> **Superseded by:** [doc/specs/other.md](./other.md)", "doc/specs/new.md"), false);
|
|
150
115
|
assert.equal(isSupersededByBanner("plain text", "doc/specs/new.md"), false);
|
|
151
116
|
});
|
|
117
|
+
|
|
118
|
+
test("matchShipStatement needs a statement start (STMT_START) and accepts git global flags", () => {
|
|
119
|
+
assert.deepEqual(matchShipStatement("git merge --squash gh-33 && git commit"), { option: "squash", statement: "git merge --squash gh-33" });
|
|
120
|
+
assert.deepEqual(matchShipStatement("cd x; git push -u origin HEAD"), { option: "pr", statement: "git push -u origin HEAD" });
|
|
121
|
+
assert.equal(matchShipStatement("gh pr create --fill")?.option, "pr");
|
|
122
|
+
assert.equal(matchShipStatement('rg "git push" skills/'), undefined);
|
|
123
|
+
assert.equal(matchShipStatement("echo git push"), undefined);
|
|
124
|
+
assert.deepEqual(matchShipStatement("git -C /p merge --squash f"), { option: "squash", statement: "git -C /p merge --squash f" });
|
|
125
|
+
assert.equal(matchShipStatement("git -c user.name=t -C /p merge --squash f")?.option, "squash");
|
|
126
|
+
assert.equal(matchShipStatement("git -C /p log"), undefined);
|
|
127
|
+
});
|
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
// Pure helpers for the telemetry extension (#33): record path mapping, pi-cwd
|
|
2
|
-
// path resolution, ship/
|
|
3
|
-
//
|
|
4
|
-
// Ship detection runs on a `bash` `tool_call` while `ship` is in progress and matches
|
|
5
|
-
// the first statement against
|
|
2
|
+
// path resolution, ship/test command matchers, diff buckets, spec banners.
|
|
3
|
+
// Ship detection matches the first statement against
|
|
6
4
|
// STMT_START + git <global flags> (merge --squash | push) | gh pr create
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// with STMT_START from phase-tracker-helpers.ts:39.
|
|
5
|
+
// with STMT_START from phase-tracker-helpers.ts.
|
|
6
|
+
//
|
|
10
7
|
// Default bucket globs (DEFAULT_TELEMETRY_BUCKETS): test = **/test/**, **/tests/**,
|
|
11
8
|
// **/__tests__/**, **/*.test.*, **/*.spec.*, **/*_test.*; docs = **/*.md;
|
|
12
9
|
// config = **/*.json, **/*.yaml, **/*.yml, **/*.toml, **/*.lock, **/*-lock.*; else code.
|
|
13
10
|
|
|
14
11
|
import { isAbsolute, matchesGlob, relative, resolve } from "node:path";
|
|
15
|
-
import { STMT_START } from "./phase-tracker-helpers.ts";
|
|
16
12
|
import { buildTestCmdRegex } from "./gauntlet-settings.ts";
|
|
13
|
+
import { STMT_START } from "./phase-tracker-helpers.ts";
|
|
17
14
|
|
|
18
15
|
// ---- paths -------------------------------------------------------------------
|
|
19
16
|
|
|
@@ -40,34 +37,24 @@ export const planSpecHeader = (planBody: string): string | undefined => SPEC_HEA
|
|
|
40
37
|
|
|
41
38
|
// ---- command matchers ----------------------------------------------------------
|
|
42
39
|
|
|
40
|
+
const STATEMENT_END = /\n|;|&&|\|\||\|/;
|
|
41
|
+
|
|
42
|
+
export const truncateCommand = (s: string): string => (s.length > 120 ? s.slice(0, 120) : s);
|
|
43
|
+
export type ShipOption = "squash" | "pr";
|
|
44
|
+
|
|
43
45
|
// `git <global flags> <subcommand>`: same flags-span grammar as parseGitCommand in
|
|
44
46
|
// phase-tracker-helpers.ts, so `git -C <worktree> push` from the primary checkout counts.
|
|
45
47
|
const GIT_FLAGS = "git\\s+(?:-\\S+(?:\\s+\\S+)?\\s+)*";
|
|
46
48
|
const SHIP_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:merge\\s+--squash|push)(?=\\s|$)|gh\\s+pr\\s+create)");
|
|
47
|
-
const DISCARD_RE = new RegExp(STMT_START + "(" + GIT_FLAGS + "(?:worktree\\s+remove|branch\\s+-D)(?=\\s|$))");
|
|
48
49
|
const SQUASH_RE = new RegExp("^" + GIT_FLAGS + "merge\\s+--squash");
|
|
49
|
-
const STATEMENT_END = /\n|;|&&|\|\||\|/;
|
|
50
|
-
|
|
51
|
-
export const truncateCommand = (s: string): string => (s.length > 120 ? s.slice(0, 120) : s);
|
|
52
|
-
|
|
53
|
-
// The statement that begins at the regex's group-1 match, up to the next separator.
|
|
54
|
-
function statementAt(command: string, re: RegExp): string | undefined {
|
|
55
|
-
const m = re.exec(command);
|
|
56
|
-
if (!m) return undefined;
|
|
57
|
-
const start = m.index + m[0].indexOf(m[1]);
|
|
58
|
-
return command.slice(start).split(STATEMENT_END)[0].trim();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export type ShipOption = "squash" | "pr";
|
|
62
50
|
|
|
63
51
|
export function matchShipStatement(command: string): { option: ShipOption; statement: string } | undefined {
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
52
|
+
const m = SHIP_RE.exec(command);
|
|
53
|
+
if (!m) return undefined;
|
|
54
|
+
const statement = command.slice(m.index + m[0].indexOf(m[1])).split(STATEMENT_END)[0].trim();
|
|
66
55
|
return { option: SQUASH_RE.test(statement) ? "squash" : "pr", statement };
|
|
67
56
|
}
|
|
68
57
|
|
|
69
|
-
export const matchDiscardStatement = (command: string): string | undefined => statementAt(command, DISCARD_RE);
|
|
70
|
-
|
|
71
58
|
// First statement matching one of the resolved verifyBeforeShip.testCommands
|
|
72
59
|
// fragments, using the same \b-anchored construction as verify-before-ship.ts.
|
|
73
60
|
export function matchTestStatement(command: string, testCommands: string[]): string | undefined {
|
|
@@ -100,22 +87,6 @@ export function numstatPath(raw: string): string {
|
|
|
100
87
|
return arrow >= 0 ? braced.slice(arrow + 4) : braced;
|
|
101
88
|
}
|
|
102
89
|
|
|
103
|
-
export function aggregateNumstat(numstat: string, files: Set<string>, buckets: [string, string[]][]): Record<string, BucketStat> {
|
|
104
|
-
const out: Record<string, BucketStat> = {};
|
|
105
|
-
for (const line of numstat.split("\n")) {
|
|
106
|
-
if (!line.trim()) continue;
|
|
107
|
-
const [ins, del, ...rest] = line.split("\t");
|
|
108
|
-
const path = numstatPath(rest.join("\t"));
|
|
109
|
-
if (!files.has(path)) continue;
|
|
110
|
-
const bucket = classifyBucket(path, buckets);
|
|
111
|
-
const stat = (out[bucket] ??= { files: 0, insertions: 0, deletions: 0 });
|
|
112
|
-
stat.files += 1;
|
|
113
|
-
stat.insertions += ins === "-" ? 0 : Number(ins) || 0;
|
|
114
|
-
stat.deletions += del === "-" ? 0 : Number(del) || 0;
|
|
115
|
-
}
|
|
116
|
-
return out;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
90
|
export interface PatchRow {
|
|
120
91
|
added: number;
|
|
121
92
|
removed: number;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { STMT_START } from "./phase-tracker-helpers.ts";
|
|
4
3
|
import {
|
|
5
4
|
capEvents, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes,
|
|
6
5
|
newRecord, parseRecord, serializeRecord, type Accumulators, type TelemetryEvent,
|
|
7
6
|
} from "./telemetry-record.ts";
|
|
8
|
-
import { matchShipStatement } from "./telemetry-paths.ts";
|
|
9
7
|
|
|
10
8
|
const ev = (over: Partial<TelemetryEvent> & { kind: string }): TelemetryEvent =>
|
|
11
9
|
({ ts: "2026-09-17T10:00:00Z", session: "s1", phase: "unphased", ...over }) as TelemetryEvent;
|
|
@@ -167,7 +165,3 @@ test("parseRecord drops malformed accumulator blocks and fills missing fields",
|
|
|
167
165
|
assert.equal(derive(parsed, "2026-09-17T10:01:00Z").amendments, 2);
|
|
168
166
|
});
|
|
169
167
|
|
|
170
|
-
test("STMT_START-anchored matcher is the one used for ship detection", () => {
|
|
171
|
-
assert.ok(new RegExp(STMT_START + "git\\s+push").test("cd x && git push"));
|
|
172
|
-
assert.equal(matchShipStatement('rg "git push"'), undefined);
|
|
173
|
-
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import { guardReason, modifiedFilesFrom } from "./telemetry-ship.ts";
|
|
4
4
|
|
|
5
5
|
test("guardReason names spec, timestamp and record path", () => {
|
|
6
6
|
assert.equal(
|
|
@@ -17,7 +17,3 @@ test("modifiedFilesFrom drops the spec, doc/plans/**, and the telemetry dir; sor
|
|
|
17
17
|
const namesWithWhitespace = " lead.ts\ntrail.ts \nREADME.md\n";
|
|
18
18
|
assert.deepEqual(modifiedFilesFrom(namesWithWhitespace, "doc/specs/a.md", "t/"), [" lead.ts", "README.md", "trail.ts "]);
|
|
19
19
|
});
|
|
20
|
-
|
|
21
|
-
test("BASE_REFS order is origin/HEAD, main, master", () => {
|
|
22
|
-
assert.deepEqual(BASE_REFS, ["origin/HEAD", "main", "master"]);
|
|
23
|
-
});
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
// Pure ship-time helpers for the telemetry extension
|
|
2
|
-
// base-ref candidates, guard block reason.
|
|
3
|
-
|
|
4
|
-
export const BASE_REFS = ["origin/HEAD", "main", "master"] as const;
|
|
1
|
+
// Pure ship-time helpers for the telemetry extension: diff file filter and guard block reason.
|
|
5
2
|
|
|
6
3
|
export const guardReason = (spec: string, shippedAt: string, recordRel: string): string =>
|
|
7
4
|
`spec ${spec} shipped at ${shippedAt} (record ${recordRel}). Write a new date-slugged spec that supersedes it instead of reusing this file.`;
|