pi-gauntlet 5.20.0 → 5.22.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 +20 -0
- package/README.md +3 -3
- package/bin/gauntlet-performance.mjs +233 -115
- package/bin/gauntlet-performance.test.mjs +100 -0
- package/bin/gauntlet-spec-index.mjs +83 -22
- package/bin/gauntlet-spec-index.test.mjs +181 -61
- package/bin/gauntlet-telemetry-seal.mjs +6 -2
- package/bin/gauntlet-telemetry-seal.test.mjs +10 -0
- package/extensions/lib/telemetry-council.test.ts +91 -0
- package/extensions/lib/telemetry-council.ts +121 -0
- package/extensions/lib/telemetry-record.test.ts +26 -0
- package/extensions/lib/telemetry-record.ts +8 -3
- package/extensions/telemetry.test.ts +90 -0
- package/extensions/telemetry.ts +45 -3
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +22 -6
- package/skills/brainstorming/gatherer.md +6 -3
- package/skills/gauntlet-performance/SKILL.md +3 -2
- package/skills/roasting-the-spec/SKILL.md +4 -4
|
@@ -7,22 +7,28 @@ import { execFileSync } from "node:child_process";
|
|
|
7
7
|
import process from "node:process";
|
|
8
8
|
import { parse as parseYaml } from "yaml";
|
|
9
9
|
|
|
10
|
-
const SCHEMA_VERSION =
|
|
10
|
+
const SCHEMA_VERSION = 2;
|
|
11
|
+
const EVIDENCE_DF_FRACTION = 0.5;
|
|
12
|
+
const MIN_EVIDENCE_TOKENS = 2;
|
|
13
|
+
const SCORE_RATIO = 0.5;
|
|
14
|
+
// Default banner grammar of skills/brainstorming/reference/superseding.md; a named-section
|
|
15
|
+
// scope or anything after `- fully` keeps the spec live.
|
|
16
|
+
const FULLY_BANNER = /^> \*\*Superseded by:\*\* \[.*\]\(.*\) - fully$/;
|
|
11
17
|
const MIN_NODE = [24, 15, 0];
|
|
12
18
|
const DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
13
19
|
const EXCLUDE_LINE = "/.pi/gauntlet/index.sqlite*";
|
|
14
20
|
const SKIP_DIRS = new Set([".worktrees", "node_modules", "build"]);
|
|
15
|
-
const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "files", "snippet"];
|
|
21
|
+
const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "state", "files", "snippet"];
|
|
16
22
|
const SCHEMA = `
|
|
17
23
|
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
18
24
|
CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, mtime_ms INTEGER, size INTEGER);
|
|
19
25
|
CREATE VIRTUAL TABLE IF NOT EXISTS specs USING fts5(
|
|
20
|
-
path UNINDEXED, service UNINDEXED, title, goal, headings, body,
|
|
26
|
+
path UNINDEXED, service UNINDEXED, title, goal, headings, body, state UNINDEXED,
|
|
21
27
|
tokenize = 'porter unicode61');
|
|
22
28
|
`;
|
|
23
29
|
|
|
24
30
|
const usage = () => {
|
|
25
|
-
process.stderr.write('usage: gauntlet-spec-index --query "<text>" [--limit N]
|
|
31
|
+
process.stderr.write('usage: gauntlet-spec-index --query "<text>" [--limit N] [--exclude <repo-relative path>]...\n');
|
|
26
32
|
process.exit(1);
|
|
27
33
|
};
|
|
28
34
|
const die = (msg) => {
|
|
@@ -33,13 +39,15 @@ const die = (msg) => {
|
|
|
33
39
|
function parseArgs(argv) {
|
|
34
40
|
let query;
|
|
35
41
|
let limit = 10;
|
|
42
|
+
const exclude = [];
|
|
36
43
|
for (let i = 0; i < argv.length; i++) {
|
|
37
44
|
if (argv[i] === "--query" && argv[i + 1] !== undefined) query = argv[++i];
|
|
38
45
|
else if (argv[i] === "--limit" && /^[1-9]\d*$/.test(argv[i + 1] ?? "")) limit = Number(argv[++i]);
|
|
46
|
+
else if (argv[i] === "--exclude" && argv[i + 1] !== undefined) exclude.push(argv[++i]);
|
|
39
47
|
else usage();
|
|
40
48
|
}
|
|
41
49
|
if (query === undefined) usage();
|
|
42
|
-
return { query, limit };
|
|
50
|
+
return { query, limit, exclude };
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
function nodeOk() {
|
|
@@ -139,10 +147,19 @@ async function openDb(root) {
|
|
|
139
147
|
|
|
140
148
|
function extract(text, path) {
|
|
141
149
|
const lines = text.split(/\r?\n/);
|
|
142
|
-
const
|
|
150
|
+
const h1 = lines.findIndex((l) => l.startsWith("# "));
|
|
151
|
+
const title = (h1 >= 0 ? lines[h1].slice(2).trim() : "") || basename(path, ".md");
|
|
143
152
|
const goal = lines.find((l) => l.startsWith("**Goal:**"))?.slice("**Goal:**".length).trim() ?? "";
|
|
144
153
|
const headings = lines.filter((l) => /^##{1,2} /.test(l)).map((l) => l.replace(/^#+ /, "")).join("\n");
|
|
145
|
-
|
|
154
|
+
let state = "live";
|
|
155
|
+
if (h1 >= 0) {
|
|
156
|
+
// Title block: everything after the H1 up to the first H2 heading or code fence.
|
|
157
|
+
for (const l of lines.slice(h1 + 1)) {
|
|
158
|
+
if (/^## /.test(l) || /^\s*(`{3,}|~{3,})/.test(l)) break;
|
|
159
|
+
if (FULLY_BANNER.test(l)) { state = "superseded"; break; }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { title, goal, headings, state };
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
function refresh(db, root, corpus) {
|
|
@@ -150,7 +167,7 @@ function refresh(db, root, corpus) {
|
|
|
150
167
|
const present = new Set(corpus.map((f) => f.path));
|
|
151
168
|
const delSpec = db.prepare("DELETE FROM specs WHERE path = ?");
|
|
152
169
|
const delFile = db.prepare("DELETE FROM files WHERE path = ?");
|
|
153
|
-
const insSpec = db.prepare("INSERT INTO specs (path, service, title, goal, headings, body) VALUES (?, ?, ?, ?, ?, ?)");
|
|
170
|
+
const insSpec = db.prepare("INSERT INTO specs (path, service, title, goal, headings, body, state) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
154
171
|
const putFile = db.prepare("INSERT OR REPLACE INTO files (path, mtime_ms, size) VALUES (?, ?, ?)");
|
|
155
172
|
db.exec("BEGIN IMMEDIATE");
|
|
156
173
|
try {
|
|
@@ -162,7 +179,7 @@ function refresh(db, root, corpus) {
|
|
|
162
179
|
delSpec.run(f.path);
|
|
163
180
|
if (text.split(/\r?\n/, 1)[0] === DRAFT_MARKER) { delFile.run(f.path); continue; }
|
|
164
181
|
const x = extract(text, f.path);
|
|
165
|
-
insSpec.run(f.path, f.service, x.title, x.goal, x.headings, text);
|
|
182
|
+
insSpec.run(f.path, f.service, x.title, x.goal, x.headings, text, x.state);
|
|
166
183
|
putFile.run(f.path, f.mtime_ms, f.size);
|
|
167
184
|
}
|
|
168
185
|
db.exec("COMMIT");
|
|
@@ -172,8 +189,58 @@ function refresh(db, root, corpus) {
|
|
|
172
189
|
}
|
|
173
190
|
}
|
|
174
191
|
|
|
175
|
-
const
|
|
176
|
-
|
|
192
|
+
const tokens = (query) => query.split(/[^\p{L}\p{N}\p{Co}]+/u).filter((t) => t.length >= 2);
|
|
193
|
+
const quote = (t) => `"${t.replaceAll('"', '""')}"`;
|
|
194
|
+
const toMatch = (toks) => toks.map(quote).join(" OR ");
|
|
195
|
+
|
|
196
|
+
// Porter is not idempotent (manatee -> manate -> manat), so MATCH always receives an
|
|
197
|
+
// original token; the vocab pass only decides which tokens are the same term.
|
|
198
|
+
function representatives(db, toks) {
|
|
199
|
+
const mem = new db.constructor(":memory:");
|
|
200
|
+
try {
|
|
201
|
+
mem.exec("CREATE VIRTUAL TABLE q USING fts5(t, tokenize = 'porter unicode61'); CREATE VIRTUAL TABLE qi USING fts5vocab(q, 'instance')");
|
|
202
|
+
const ins = mem.prepare("INSERT INTO q (rowid, t) VALUES (?, ?)");
|
|
203
|
+
toks.forEach((t, i) => ins.run(i + 1, t));
|
|
204
|
+
const rep = new Map();
|
|
205
|
+
for (const r of mem.prepare("SELECT term, doc FROM qi ORDER BY doc, offset").all()) if (!rep.has(r.term)) rep.set(r.term, toks[r.doc - 1]);
|
|
206
|
+
return rep;
|
|
207
|
+
} finally {
|
|
208
|
+
mem.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function confidenceFilter(rows, evidence) {
|
|
213
|
+
return rows.filter((r) => {
|
|
214
|
+
const hits = evidence.filter((e) => e.all.has(r.rowid)).length;
|
|
215
|
+
const titleGoal = evidence.filter((e) => e.titleGoal.has(r.rowid)).length;
|
|
216
|
+
return hits >= MIN_EVIDENCE_TOKENS && titleGoal >= MIN_EVIDENCE_TOKENS;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function query(db, toks, { limit, exclude }) {
|
|
221
|
+
const n = db.prepare("SELECT count(*) AS n FROM specs").get().n;
|
|
222
|
+
const rowids = (match) => new Set(db.prepare("SELECT rowid FROM specs WHERE specs MATCH ?").all(match).map((r) => r.rowid));
|
|
223
|
+
const reps = representatives(db, toks);
|
|
224
|
+
const terms = [...new Set(reps.values())];
|
|
225
|
+
const evidence = [];
|
|
226
|
+
for (const tok of terms) {
|
|
227
|
+
const all = rowids(quote(tok));
|
|
228
|
+
if (all.size === 0 || all.size >= n * EVIDENCE_DF_FRACTION) continue;
|
|
229
|
+
evidence.push({ all, titleGoal: rowids(`title:${quote(tok)} OR goal:${quote(tok)}`) });
|
|
230
|
+
}
|
|
231
|
+
if (evidence.length < MIN_EVIDENCE_TOKENS) return [];
|
|
232
|
+
const rows = db.prepare(
|
|
233
|
+
`SELECT rowid, path, service, title, state,
|
|
234
|
+
bm25(specs, 0, 0, 10.0, 5.0, 2.0, 1.0, 0) AS score,
|
|
235
|
+
snippet(specs, 5, '', '', '...', 12) AS snippet
|
|
236
|
+
FROM specs WHERE specs MATCH ?`,
|
|
237
|
+
).all(toMatch(terms)).filter((r) => !exclude.includes(r.path));
|
|
238
|
+
const kept = confidenceFilter(rows, evidence);
|
|
239
|
+
kept.sort((a, b) => (a.state !== "live") - (b.state !== "live") || a.score - b.score);
|
|
240
|
+
const best = kept.find((r) => r.state === "live");
|
|
241
|
+
const cut = best ? kept.filter((r) => Math.abs(r.score) >= SCORE_RATIO * Math.abs(best.score)) : kept;
|
|
242
|
+
return cut.slice(0, limit);
|
|
243
|
+
}
|
|
177
244
|
|
|
178
245
|
function telemetry(root, specPath) {
|
|
179
246
|
const blank = { status: null, shipped_at: null, files: "" };
|
|
@@ -200,23 +267,17 @@ const filesCell = (v) => v.replace(/[\t\r\n]+/g, " ");
|
|
|
200
267
|
|
|
201
268
|
async function main() {
|
|
202
269
|
if (!nodeOk()) die(`gauntlet-spec-index needs Node >=24.15.0 (found ${process.versions.node})`);
|
|
203
|
-
const { query, limit } = parseArgs(process.argv.slice(2));
|
|
204
|
-
const
|
|
205
|
-
if (!
|
|
270
|
+
const { query: text, limit, exclude } = parseArgs(process.argv.slice(2));
|
|
271
|
+
const toks = tokens(text);
|
|
272
|
+
if (!toks.length) usage();
|
|
206
273
|
const root = repoRoot();
|
|
207
274
|
const db = await openDb(root);
|
|
208
275
|
refresh(db, root, discover(root));
|
|
209
|
-
const rows = db
|
|
210
|
-
`SELECT path, service, title,
|
|
211
|
-
bm25(specs, 0, 0, 10.0, 5.0, 2.0, 1.0) AS score,
|
|
212
|
-
snippet(specs, 5, '', '', '...', 12) AS snippet
|
|
213
|
-
FROM specs WHERE specs MATCH ?
|
|
214
|
-
ORDER BY score LIMIT ?`,
|
|
215
|
-
).all(match, limit);
|
|
276
|
+
const rows = query(db, toks, { limit, exclude });
|
|
216
277
|
const out = [HEADER.join("\t")];
|
|
217
278
|
for (const r of rows) {
|
|
218
279
|
const t = telemetry(root, r.path);
|
|
219
|
-
out.push([...[r.score.toFixed(3), r.path, r.service, r.title, t.status, t.shipped_at].map(cell), filesCell(t.files), cell(r.snippet)].join("\t"));
|
|
280
|
+
out.push([...[r.score.toFixed(3), r.path, r.service, r.title, t.status, t.shipped_at, r.state].map(cell), filesCell(t.files), cell(r.snippet)].join("\t"));
|
|
220
281
|
}
|
|
221
282
|
process.stdout.write(out.join("\n") + "\n");
|
|
222
283
|
db.close();
|
|
@@ -9,23 +9,35 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
9
9
|
|
|
10
10
|
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-spec-index.mjs");
|
|
11
11
|
const DRAFT = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
12
|
+
const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "state", "files", "snippet"];
|
|
12
13
|
|
|
13
14
|
const write = (root, rel, text) => {
|
|
14
15
|
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
15
16
|
writeFileSync(join(root, rel), text);
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
+
const gitRepo = () => {
|
|
19
20
|
const root = mkdtempSync(join(tmpdir(), "gsi-"));
|
|
20
21
|
spawnSync("git", ["init", "-q"], { cwd: root });
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
write(root, "build/doc/specs/decoy2.md", "# zephyr decoy two\n");
|
|
25
|
-
write(root, "apps/svc/doc/specs/decoy3.md", "# zephyr decoy three\n");
|
|
26
|
-
write(root, "doc/specs/draft.md", `${DRAFT}\n\nzephyr zephyr zephyr\n`);
|
|
22
|
+
return root;
|
|
23
|
+
};
|
|
24
|
+
const commit = (root) => {
|
|
27
25
|
spawnSync("git", ["add", "-A"], { cwd: root });
|
|
28
26
|
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "fixture"], { cwd: root });
|
|
27
|
+
};
|
|
28
|
+
// Fillers keep the corpus large enough that two-document probe terms stay below N/2.
|
|
29
|
+
const filler = (n) => `# Filler ${n}\n\n**Goal:** filler goal ${n}.\n\n## Design\n\nnothing of note here\n`;
|
|
30
|
+
|
|
31
|
+
const repo = () => {
|
|
32
|
+
const root = gitRepo();
|
|
33
|
+
write(root, "doc/specs/a.md", "# Alpha zephyr widget\n\n**Goal:** rank the widget.\n\n## Design\n\nbody text\n");
|
|
34
|
+
write(root, "svc-a/doc/specs/b.md", "# Beta service\n\n**Goal:** zephyr widget for the beta service.\n\nDeep in the body a zephyr appears.\n");
|
|
35
|
+
for (const n of ["one", "two", "three", "four"]) write(root, `doc/specs/filler-${n}.md`, filler(n));
|
|
36
|
+
write(root, ".worktrees/x/doc/specs/decoy1.md", "# zephyr widget decoy one\n");
|
|
37
|
+
write(root, "build/doc/specs/decoy2.md", "# zephyr widget decoy two\n");
|
|
38
|
+
write(root, "apps/svc/doc/specs/decoy3.md", "# zephyr widget decoy three\n");
|
|
39
|
+
write(root, "doc/specs/draft.md", `${DRAFT}\n\nzephyr widget zephyr widget\n`);
|
|
40
|
+
commit(root);
|
|
29
41
|
return root;
|
|
30
42
|
};
|
|
31
43
|
|
|
@@ -47,41 +59,39 @@ const withDb = (root, fn) => {
|
|
|
47
59
|
test("1: corpus boundary, ordering, draft skip, git status clean, exclude written once", (t) => {
|
|
48
60
|
const root = repo();
|
|
49
61
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
50
|
-
const r1 = run(root, ["--query", "zephyr"]);
|
|
62
|
+
const r1 = run(root, ["--query", "zephyr widget"]);
|
|
51
63
|
assert.equal(r1.status, 0, r1.stderr);
|
|
52
64
|
assert.deepEqual(paths(r1), ["doc/specs/a.md", "svc-a/doc/specs/b.md"]);
|
|
53
65
|
assert.deepEqual(r1.rows.map((r) => r[2]), ["root", "svc-a"]);
|
|
54
66
|
const status = spawnSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).stdout;
|
|
55
67
|
assert.equal(status, "");
|
|
56
|
-
run(root, ["--query", "zephyr"]);
|
|
68
|
+
run(root, ["--query", "zephyr widget"]);
|
|
57
69
|
const exclude = readFileSync(join(root, ".git/info/exclude"), "utf8");
|
|
58
70
|
assert.equal(exclude.split("\n").filter((l) => l === "/.pi/gauntlet/index.sqlite*").length, 1);
|
|
59
71
|
});
|
|
60
72
|
|
|
61
|
-
test("2: title outranks
|
|
73
|
+
test("2: title outranks goal; --limit 1", (t) => {
|
|
62
74
|
const root = repo();
|
|
63
75
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
64
|
-
|
|
65
|
-
assert.deepEqual(paths(run(root, ["--query", "zephyr", "--limit", "1"])), ["doc/specs/a.md"]);
|
|
66
|
-
assert.equal(run(root, ["--query", "quokka"]).rows.length, 10);
|
|
76
|
+
assert.deepEqual(paths(run(root, ["--query", "zephyr widget", "--limit", "1"])), ["doc/specs/a.md"]);
|
|
67
77
|
});
|
|
68
78
|
|
|
69
79
|
test("3: incremental refresh updates only the edited row; delete removes rows", (t) => {
|
|
70
80
|
const root = repo();
|
|
71
81
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
72
|
-
run(root, ["--query", "zephyr"]);
|
|
82
|
+
run(root, ["--query", "zephyr widget"]);
|
|
73
83
|
const before = withDb(root, (database) => Object.fromEntries(database.prepare("SELECT path, mtime_ms, size FROM files").all().map((r) => [r.path, `${r.mtime_ms}:${r.size}`])));
|
|
74
84
|
const b = join(root, "svc-a/doc/specs/b.md");
|
|
75
|
-
writeFileSync(b,
|
|
85
|
+
writeFileSync(b, "# Beta service wombat kudu\n\n**Goal:** zephyr widget for the beta service.\n\nDeep in the body a zephyr appears.\n");
|
|
76
86
|
utimesSync(b, new Date(), new Date(Date.now() + 5000));
|
|
77
|
-
assert.deepEqual(paths(run(root, ["--query", "wombat"])), ["svc-a/doc/specs/b.md"]);
|
|
87
|
+
assert.deepEqual(paths(run(root, ["--query", "wombat kudu"])), ["svc-a/doc/specs/b.md"]);
|
|
78
88
|
const after = withDb(root, (database) => Object.fromEntries(database.prepare("SELECT path, mtime_ms, size FROM files").all().map((r) => [r.path, `${r.mtime_ms}:${r.size}`])));
|
|
79
89
|
assert.equal(after["doc/specs/a.md"], before["doc/specs/a.md"]);
|
|
80
90
|
assert.notEqual(after["svc-a/doc/specs/b.md"], before["svc-a/doc/specs/b.md"]);
|
|
81
91
|
const count = (root, sql, path) => withDb(root, (database) => Object.values(database.prepare(sql).get(path))[0]);
|
|
82
92
|
assert.equal(count(root, "SELECT count(*) FROM specs WHERE path = ?", "svc-a/doc/specs/b.md"), 1);
|
|
83
93
|
rmSync(b);
|
|
84
|
-
run(root, ["--query", "zephyr"]);
|
|
94
|
+
run(root, ["--query", "zephyr widget"]);
|
|
85
95
|
assert.equal(count(root, "SELECT count(*) FROM specs WHERE path = ?", "svc-a/doc/specs/b.md"), 0);
|
|
86
96
|
assert.equal(count(root, "SELECT count(*) FROM files WHERE path = ?", "svc-a/doc/specs/b.md"), 0);
|
|
87
97
|
});
|
|
@@ -89,71 +99,70 @@ test("3: incremental refresh updates only the edited row; delete removes rows",
|
|
|
89
99
|
test("4: schema_version mismatch rebuilds the db", (t) => {
|
|
90
100
|
const root = repo();
|
|
91
101
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
92
|
-
run(root, ["--query", "zephyr"]);
|
|
102
|
+
run(root, ["--query", "zephyr widget"]);
|
|
93
103
|
withDb(root, (database) => database.prepare("UPDATE meta SET value = '999' WHERE key = 'schema_version'").run());
|
|
94
|
-
const r = run(root, ["--query", "zephyr"]);
|
|
104
|
+
const r = run(root, ["--query", "zephyr widget"]);
|
|
95
105
|
assert.equal(r.status, 0, r.stderr);
|
|
96
106
|
assert.equal(paths(r).length, 2);
|
|
97
|
-
assert.equal(withDb(root, (database) => database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value), "
|
|
107
|
+
assert.equal(withDb(root, (database) => database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value), "2");
|
|
98
108
|
});
|
|
99
109
|
|
|
100
110
|
test("5: a non-SQLite database is rebuilt", (t) => {
|
|
101
111
|
const root = repo();
|
|
102
112
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
103
113
|
write(root, ".pi/gauntlet/index.sqlite", "not a sqlite database");
|
|
104
|
-
const r = run(root, ["--query", "zephyr"]);
|
|
114
|
+
const r = run(root, ["--query", "zephyr widget"]);
|
|
105
115
|
assert.equal(r.status, 0, r.stderr);
|
|
106
116
|
assert.deepEqual(paths(r), ["doc/specs/a.md", "svc-a/doc/specs/b.md"]);
|
|
107
|
-
assert.equal(withDb(root, (database) => database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value), "
|
|
117
|
+
assert.equal(withDb(root, (database) => database.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get().value), "2");
|
|
108
118
|
});
|
|
109
119
|
|
|
110
120
|
test("6: telemetry join is output-only and tolerant", (t) => {
|
|
111
121
|
const root = repo();
|
|
112
122
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
113
|
-
run(root, ["--query", "zephyr"]);
|
|
123
|
+
run(root, ["--query", "zephyr widget"]);
|
|
114
124
|
const filesBefore = withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all()));
|
|
115
125
|
write(root, "x", "x\n");
|
|
116
126
|
write(root, "y", "y\n");
|
|
117
127
|
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: shipped\nshipped_at: 2026-09-17T10:00:00Z\nderived:\n modified_files:\n - x\n - y\n");
|
|
118
|
-
let r = run(root, ["--query", "zephyr"]);
|
|
119
|
-
assert.deepEqual(r.rows[0].slice(4,
|
|
120
|
-
assert.equal(r.rows[0].length,
|
|
121
|
-
assert.equal(r.rows[1][
|
|
128
|
+
let r = run(root, ["--query", "zephyr widget"]);
|
|
129
|
+
assert.deepEqual(r.rows[0].slice(4, 8), ["shipped", "2026-09-17T10:00:00Z", "live", "x;y"]);
|
|
130
|
+
assert.equal(r.rows[0].length, 9);
|
|
131
|
+
assert.equal(r.rows[1][7], "");
|
|
122
132
|
assert.equal(withDb(root, (database) => JSON.stringify(database.prepare("SELECT * FROM files ORDER BY path").all())), filesBefore);
|
|
123
133
|
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: in_progress\nshipped_at: 2026-09-18T10:00:00Z\n");
|
|
124
|
-
r = run(root, ["--query", "zephyr"]);
|
|
125
|
-
assert.deepEqual(r.rows[0].slice(4,
|
|
134
|
+
r = run(root, ["--query", "zephyr widget"]);
|
|
135
|
+
assert.deepEqual(r.rows[0].slice(4, 8), ["in_progress", "2026-09-18T10:00:00Z", "live", "missing"]);
|
|
126
136
|
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: [unclosed\n");
|
|
127
|
-
r = run(root, ["--query", "zephyr"]);
|
|
137
|
+
r = run(root, ["--query", "zephyr widget"]);
|
|
128
138
|
assert.equal(r.status, 0);
|
|
129
|
-
assert.deepEqual(r.rows[0].slice(4,
|
|
139
|
+
assert.deepEqual(r.rows[0].slice(4, 8), ["", "", "live", ""]);
|
|
130
140
|
assert.match(r.stderr, /a\.yaml/);
|
|
131
141
|
});
|
|
132
142
|
|
|
133
143
|
test("6b: files cell is the AC fixture - present paths kept, gone paths dropped, no list is missing", (t) => {
|
|
134
|
-
const root =
|
|
144
|
+
const root = gitRepo();
|
|
135
145
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
136
|
-
|
|
137
|
-
write(root, "doc/specs/
|
|
138
|
-
write(root,
|
|
146
|
+
write(root, "doc/specs/a.md", "# Alpha quokka wallaby\n\n**Goal:** quokka.\n");
|
|
147
|
+
write(root, "doc/specs/b.md", "# Beta quokka wallaby\n\n**Goal:** quokka too.\n");
|
|
148
|
+
for (const n of ["one", "two", "three"]) write(root, `doc/specs/filler-${n}.md`, filler(n));
|
|
139
149
|
write(root, "src/x.ts", "export {};\n");
|
|
140
150
|
write(root, ".pi/gauntlet/telemetry/doc/specs/a.yaml", "status: shipped\nderived:\n modified_files:\n - src/x.ts\n - src/gone.ts\n");
|
|
141
151
|
write(root, ".pi/gauntlet/telemetry/doc/specs/b.yaml", "status: shipped\n");
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const r = run(root, ["--query", "quokka"]);
|
|
152
|
+
commit(root);
|
|
153
|
+
const r = run(root, ["--query", "quokka wallaby"]);
|
|
145
154
|
assert.equal(r.status, 0, r.stderr);
|
|
146
|
-
const cells = Object.fromEntries(r.rows.map((row) => [row[1], row[
|
|
155
|
+
const cells = Object.fromEntries(r.rows.map((row) => [row[1], row[7]]));
|
|
147
156
|
assert.equal(cells["doc/specs/a.md"], "src/x.ts");
|
|
148
157
|
assert.equal(cells["doc/specs/b.md"], "missing");
|
|
149
|
-
for (const row of r.rows) assert.equal(row.length,
|
|
158
|
+
for (const row of r.rows) assert.equal(row.length, 9);
|
|
150
159
|
});
|
|
151
160
|
|
|
152
161
|
test("6c: files cell edge cases - order, spaces kept, all gone, non-array, null record", (t) => {
|
|
153
162
|
const root = repo();
|
|
154
163
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
155
164
|
const yaml = ".pi/gauntlet/telemetry/doc/specs/a.yaml";
|
|
156
|
-
const files = () => run(root, ["--query", "zephyr"]).rows[0][
|
|
165
|
+
const files = () => run(root, ["--query", "zephyr widget"]).rows[0][7];
|
|
157
166
|
write(root, "b.js", "");
|
|
158
167
|
write(root, "a.js", "");
|
|
159
168
|
write(root, "sp aced.txt", "");
|
|
@@ -162,7 +171,7 @@ test("6c: files cell edge cases - order, spaces kept, all gone, non-array, null
|
|
|
162
171
|
write(root, "tab\there.js", "");
|
|
163
172
|
write(root, yaml, 'status: shipped\nderived:\n modified_files:\n - a.js\n - "tab\\there.js"\n');
|
|
164
173
|
assert.equal(files(), "a.js;tab here.js");
|
|
165
|
-
assert.equal(run(root, ["--query", "zephyr"]).rows[0].length,
|
|
174
|
+
assert.equal(run(root, ["--query", "zephyr widget"]).rows[0].length, 9);
|
|
166
175
|
write(root, yaml, "status: shipped\nderived:\n modified_files:\n - 'sp aced.txt'\n - 7\n");
|
|
167
176
|
assert.equal(files(), "sp aced.txt");
|
|
168
177
|
write(root, yaml, "status: shipped\nderived:\n modified_files:\n - nope1\n - nope2\n");
|
|
@@ -178,45 +187,156 @@ test("6c: files cell edge cases - order, spaces kept, all gone, non-array, null
|
|
|
178
187
|
test("7: query sanitising tolerates embedded quotes", (t) => {
|
|
179
188
|
const root = repo();
|
|
180
189
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
181
|
-
write(root, "doc/specs/q.md", '# Quotes\n\
|
|
182
|
-
let r = run(root, ["--query", 'foo"bar']);
|
|
190
|
+
write(root, "doc/specs/q.md", '# Quotes foo"bar ocelot\n\n**Goal:** "plain" ocelot words\n');
|
|
191
|
+
let r = run(root, ["--query", 'foo"bar ocelot']);
|
|
183
192
|
assert.equal(r.status, 0, r.stderr);
|
|
184
193
|
assert.ok(paths(r).includes("doc/specs/q.md"));
|
|
185
|
-
r = run(root, ["--query", '"plain"']);
|
|
194
|
+
r = run(root, ["--query", '"plain" ocelot']);
|
|
186
195
|
assert.equal(r.status, 0, r.stderr);
|
|
187
196
|
assert.ok(paths(r).includes("doc/specs/q.md"));
|
|
188
197
|
});
|
|
189
198
|
|
|
190
|
-
test("8: one line per hit,
|
|
199
|
+
test("8: one line per hit, nine tab-separated fields, whitespace collapsed", (t) => {
|
|
191
200
|
const root = repo();
|
|
192
201
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
193
|
-
write(root, "doc/specs/t.md", "# Tab\tin\ttitle narwhal\n\nbody narwhal\nline two\twith tab narwhal\r\nmore\n");
|
|
194
|
-
const r = run(root, ["--query", "narwhal"]);
|
|
195
|
-
assert.
|
|
202
|
+
write(root, "doc/specs/t.md", "# Tab\tin\ttitle narwhal manatee\n\n**Goal:** narwhal manatee.\n\nbody narwhal\nline two\twith tab narwhal\r\nmore\n");
|
|
203
|
+
const r = run(root, ["--query", "narwhal manatee"]);
|
|
204
|
+
assert.deepEqual(r.header, HEADER);
|
|
196
205
|
assert.equal(r.rows.length, 1);
|
|
197
|
-
assert.equal(r.rows[0].length,
|
|
198
|
-
assert.equal(r.rows[0][3], "Tab in title narwhal");
|
|
206
|
+
assert.equal(r.rows[0].length, 9);
|
|
207
|
+
assert.equal(r.rows[0][3], "Tab in title narwhal manatee");
|
|
199
208
|
});
|
|
200
209
|
|
|
201
|
-
test("9:
|
|
210
|
+
test("9: state is superseded only for a `- fully` banner in the title block", (t) => {
|
|
202
211
|
const root = repo();
|
|
203
212
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
213
|
+
const banner = "> **Superseded by:** [doc/specs/a.md](./a.md) - fully";
|
|
214
|
+
write(root, "doc/specs/old-fully.md", `# Old kestrel marmot\n\n${banner}\n\n**Goal:** kestrel marmot.\n`);
|
|
215
|
+
write(root, "doc/specs/old-trailing.md", `# Old trailing\n\n${banner} \n`);
|
|
216
|
+
write(root, "doc/specs/blank-title.md", "# \n\nbody\n");
|
|
217
|
+
write(root, "doc/specs/old-prose.md", `# Old two\n\n> Historical note kept for context.\n${banner}\n`);
|
|
218
|
+
write(root, "doc/specs/old-section.md", '# Old three\n\n> **Superseded by:** [doc/specs/a.md](./a.md) - "Design" section only\n');
|
|
219
|
+
write(root, "doc/specs/old-semicolon.md", `# Old four\n\n${banner}; see also b\n`);
|
|
220
|
+
write(root, "doc/specs/old-fenced.md", `# Old five\n\n\`\`\`markdown\n${banner}\n\`\`\`\n`);
|
|
221
|
+
write(root, "doc/specs/old-h2.md", `# Old six\n\n## History\n\n${banner}\n`);
|
|
222
|
+
write(root, "doc/specs/old-h3.md", `# Old seven\n\n### Detail\n\n${banner}\n`);
|
|
223
|
+
const r = run(root, ["--query", "kestrel marmot"]);
|
|
224
|
+
assert.equal(r.status, 0, r.stderr);
|
|
225
|
+
assert.deepEqual(r.header, HEADER);
|
|
226
|
+
assert.equal(r.header[6], "state");
|
|
227
|
+
assert.deepEqual(paths(r), ["doc/specs/old-fully.md"]);
|
|
228
|
+
assert.equal(r.rows[0][6], "superseded");
|
|
229
|
+
const states = withDb(root, (database) => Object.fromEntries(database.prepare("SELECT path, state FROM specs WHERE path LIKE 'doc/specs/old-%'").all().map((row) => [row.path, row.state])));
|
|
230
|
+
assert.deepEqual(states, {
|
|
231
|
+
"doc/specs/old-fully.md": "superseded",
|
|
232
|
+
"doc/specs/old-trailing.md": "live",
|
|
233
|
+
"doc/specs/old-prose.md": "superseded",
|
|
234
|
+
"doc/specs/old-section.md": "live",
|
|
235
|
+
"doc/specs/old-semicolon.md": "live",
|
|
236
|
+
"doc/specs/old-fenced.md": "live",
|
|
237
|
+
"doc/specs/old-h2.md": "live",
|
|
238
|
+
"doc/specs/old-h3.md": "superseded",
|
|
239
|
+
});
|
|
240
|
+
assert.equal(withDb(root, (database) => database.prepare("SELECT state FROM specs WHERE path = 'doc/specs/a.md'").get().state), "live");
|
|
241
|
+
assert.equal(withDb(root, (database) => database.prepare("SELECT title FROM specs WHERE path = 'doc/specs/blank-title.md'").get().title), "blank-title");
|
|
209
242
|
});
|
|
210
243
|
|
|
211
244
|
test("10: usage errors exit 1, environment errors exit 2", (t) => {
|
|
212
245
|
const root = repo();
|
|
213
246
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
214
247
|
assert.equal(run(root, []).status, 1);
|
|
215
|
-
assert.equal(run(root, ["--query", "zephyr", "--limit", "0"]).status, 1);
|
|
216
|
-
assert.equal(run(root, ["--query", "zephyr", "--limit", "x"]).status, 1);
|
|
217
|
-
assert.equal(run(root, ["--query", "zephyr", "--json"]).status, 1);
|
|
248
|
+
assert.equal(run(root, ["--query", "zephyr widget", "--limit", "0"]).status, 1);
|
|
249
|
+
assert.equal(run(root, ["--query", "zephyr widget", "--limit", "x"]).status, 1);
|
|
250
|
+
assert.equal(run(root, ["--query", "zephyr widget", "--json"]).status, 1);
|
|
251
|
+
assert.equal(run(root, ["--query", "zephyr widget", "--exclude"]).status, 1);
|
|
218
252
|
assert.equal(run(root, ["--query", "a"]).status, 1);
|
|
219
253
|
const bare = mkdtempSync(join(tmpdir(), "gsi-bare-"));
|
|
220
254
|
t.after(() => rmSync(bare, { recursive: true, force: true }));
|
|
221
|
-
assert.equal(run(bare, ["--query", "zephyr"]).status, 2);
|
|
255
|
+
assert.equal(run(bare, ["--query", "zephyr widget"]).status, 2);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("11: AC1 fixture - exactly the strong row; no-match and all-common queries return header only", (t) => {
|
|
259
|
+
const root = gitRepo();
|
|
260
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
261
|
+
const common = (n) => `# Plain spec ${n}\n\n**Goal:** plain goal ${n}.\n\nThe body mentions the common shared vocabulary.\n`;
|
|
262
|
+
write(root, "doc/specs/strong.md", "# Strong heron ibis\n\n**Goal:** heron ibis jackal.\n\nbody\n");
|
|
263
|
+
for (let n = 1; n <= 5; n++) write(root, `doc/specs/p${n}.md`, common(n));
|
|
264
|
+
commit(root);
|
|
265
|
+
const strong = run(root, ["--query", "heron ibis jackal common", "--limit", "10"]);
|
|
266
|
+
assert.equal(strong.status, 0, strong.stderr);
|
|
267
|
+
assert.deepEqual(paths(strong), ["doc/specs/strong.md"]);
|
|
268
|
+
for (const q of ["yeti unicorn", "common shared"]) {
|
|
269
|
+
const r = run(root, ["--query", q]);
|
|
270
|
+
assert.equal(r.status, 0, r.stderr);
|
|
271
|
+
assert.deepEqual(r.header, HEADER);
|
|
272
|
+
assert.deepEqual(r.rows, []);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("12: evidence identity - stem variants of one word are one term", (t) => {
|
|
277
|
+
const root = repo();
|
|
278
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
279
|
+
write(root, "doc/specs/cfg.md", "# Configuration lemur index\n\n**Goal:** configure the lemur index.\n\nbody\n");
|
|
280
|
+
for (const q of ["configuration configuration", "Configuration configure", "index indexing"]) {
|
|
281
|
+
const r = run(root, ["--query", q]);
|
|
282
|
+
assert.equal(r.status, 0, r.stderr);
|
|
283
|
+
assert.deepEqual(r.rows, [], q);
|
|
284
|
+
}
|
|
285
|
+
assert.deepEqual(paths(run(root, ["--query", "configuration lemur"])), ["doc/specs/cfg.md"]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("13: ratio - a row far below the best live score is dropped; an equal pair is kept", (t) => {
|
|
289
|
+
const root = repo();
|
|
290
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
291
|
+
write(root, "doc/specs/r1.md", "# Otter pangolin vole yak emu dingo\n\n**Goal:** otter pangolin vole yak emu dingo.\n\nbody\n");
|
|
292
|
+
write(root, "doc/specs/r2.md", "# Otter pangolin service\n\n**Goal:** otter pangolin.\n\nbody\n");
|
|
293
|
+
write(root, "doc/specs/weak-super.md", "# Otter pangolin service archive\n\n> **Superseded by:** [doc/specs/r1.md](./r1.md) - fully\n\n**Goal:** otter pangolin.\n\nbody\n");
|
|
294
|
+
write(root, "doc/specs/e1.md", "# Quail raccoon\n\n**Goal:** quail raccoon.\n\nbody\n");
|
|
295
|
+
write(root, "doc/specs/e2.md", "# Quail raccoon\n\n**Goal:** quail raccoon.\n\nbody\n");
|
|
296
|
+
const r = run(root, ["--query", "otter pangolin vole yak emu dingo"]);
|
|
297
|
+
assert.deepEqual(paths(r), ["doc/specs/r1.md"]);
|
|
298
|
+
const pair = run(root, ["--query", "quail raccoon"]);
|
|
299
|
+
assert.deepEqual(paths(pair).sort(), ["doc/specs/e1.md", "doc/specs/e2.md"]);
|
|
300
|
+
assert.equal(pair.rows[0][0], pair.rows[1][0]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("14: superseded rows sort after live rows and never set the ratio bar", (t) => {
|
|
304
|
+
const root = repo();
|
|
305
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
306
|
+
write(root, "doc/specs/old.md", "# Otter pangolin vole yak emu dingo\n\n> **Superseded by:** [doc/specs/new.md](./new.md) - fully\n\n**Goal:** otter pangolin vole yak emu dingo.\n");
|
|
307
|
+
write(root, "doc/specs/new.md", "# Otter pangolin service\n\n**Goal:** otter pangolin.\n\nbody\n");
|
|
308
|
+
const r = run(root, ["--query", "otter pangolin vole yak emu dingo"]);
|
|
309
|
+
assert.deepEqual(paths(r), ["doc/specs/new.md", "doc/specs/old.md"]);
|
|
310
|
+
assert.deepEqual(r.rows.map((row) => row[6]), ["live", "superseded"]);
|
|
311
|
+
assert.ok(Math.abs(Number(r.rows[1][0])) > Math.abs(Number(r.rows[0][0])));
|
|
312
|
+
assert.deepEqual(paths(run(root, ["--query", "otter pangolin vole yak emu dingo", "--limit", "1"])), ["doc/specs/new.md"]);
|
|
313
|
+
assert.deepEqual(paths(run(root, ["--query", "vole yak"])), ["doc/specs/old.md"]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("15: --exclude removes a path before the filter so it never sets the bar", (t) => {
|
|
317
|
+
const root = repo();
|
|
318
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
319
|
+
write(root, "doc/specs/self.md", "# Sloth tapir vole yak emu dingo\n\n**Goal:** sloth tapir vole yak emu dingo.\n\nbody\n");
|
|
320
|
+
write(root, "doc/specs/pred.md", "# Sloth tapir service\n\n**Goal:** sloth tapir.\n\nbody\n");
|
|
321
|
+
const q = ["--query", "sloth tapir vole yak emu dingo"];
|
|
322
|
+
assert.deepEqual(paths(run(root, q)), ["doc/specs/self.md"]);
|
|
323
|
+
assert.deepEqual(paths(run(root, [...q, "--exclude", "doc/specs/self.md"])), ["doc/specs/pred.md"]);
|
|
324
|
+
assert.deepEqual(paths(run(root, [...q, "--exclude", "doc/specs/nope.md"])), ["doc/specs/self.md"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("16: punctuation splits into distinct evidence terms and query order does not change rows", (t) => {
|
|
328
|
+
const root = gitRepo();
|
|
329
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
330
|
+
write(root, "doc/specs/cfg.md", "# Configuration lemur index\n\n**Goal:** configure the lemur index.\n\nbody\n");
|
|
331
|
+
write(root, "doc/specs/cf.md", "# Config file lynx\n\n**Goal:** config file.\n\nbody\n");
|
|
332
|
+
for (let n = 1; n <= 5; n++) write(root, `doc/specs/p${n}.md`, `# Plain spec ${n}\n\n**Goal:** plain goal ${n}.\n\nthe plain body\n`);
|
|
333
|
+
commit(root);
|
|
334
|
+
const common = run(root, ["--query", "the-lemur"]);
|
|
335
|
+
assert.equal(common.status, 0, common.stderr);
|
|
336
|
+
assert.deepEqual(common.header, HEADER);
|
|
337
|
+
assert.deepEqual(common.rows, []);
|
|
338
|
+
const distinct = run(root, ["--query", "configuration-lemur"]);
|
|
339
|
+
assert.equal(distinct.status, 0, distinct.stderr);
|
|
340
|
+
assert.deepEqual(paths(distinct), ["doc/specs/cfg.md"]);
|
|
341
|
+
assert.deepEqual(paths(run(root, ["--query", "config config-file"])), paths(run(root, ["--query", "config-file config"])));
|
|
222
342
|
});
|
|
@@ -168,7 +168,7 @@ function derive(rec, now) {
|
|
|
168
168
|
}
|
|
169
169
|
for (const k of Object.keys(acc.phases)) phases[k] = compact({ ...phases[k] ?? {}, ...acc.phases[k] });
|
|
170
170
|
const live = liveShipEvent(rec.events);
|
|
171
|
-
return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : void 0, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped });
|
|
171
|
+
return compact({ duration_s: seconds(rec.created_at, rec.shipped_at ?? rec.abandoned_at ?? now), phases, personas: acc.personas, reviews: Object.keys(acc.reviews).length ? acc.reviews : void 0, conformance_loops: acc.conformance_loops, conformance_open_gaps: acc.conformance_open_gaps, gates: compact({ ...acc.gates, ship_option: live?.option }), plan: rec.derived.plan, tests: rec.derived.tests, amendments: acc.amendments, spec_edits_after_ship: acc.spec_edits_after_ship, diff: rec.derived.diff, modified_files: rec.derived.modified_files, council: rec.derived.council, spec_writes: acc.spec_writes, events_dropped: acc.events_dropped });
|
|
172
172
|
}
|
|
173
173
|
var stripUndefined = (v) => {
|
|
174
174
|
if (Array.isArray(v)) return v.map(stripUndefined);
|
|
@@ -202,7 +202,11 @@ function serializeRecord(rec) {
|
|
|
202
202
|
const body = stripUndefined({ ...head, derived, accumulators });
|
|
203
203
|
const doc = new Document(body);
|
|
204
204
|
const derivedNode = doc.get("derived", true);
|
|
205
|
-
if (isMap(derivedNode))
|
|
205
|
+
if (isMap(derivedNode)) {
|
|
206
|
+
flowLeafChildren(derivedNode, /* @__PURE__ */ new Set(["modified_files", "council"]));
|
|
207
|
+
const council = derivedNode.get("council", true);
|
|
208
|
+
if (isMap(council)) flowLeafChildren(council);
|
|
209
|
+
}
|
|
206
210
|
const accumulatorNode = doc.get("accumulators", true);
|
|
207
211
|
if (isMap(accumulatorNode)) {
|
|
208
212
|
for (const session of accumulatorNode.items) if (isMap(session.value)) flowLeafChildren(session.value);
|
|
@@ -100,6 +100,16 @@ test("seals an untracked in_progress record: stamp, ship event, diff, re-derived
|
|
|
100
100
|
assert.equal(porcelain(root), "");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("seal preserves derived.council through the shipped stamp", (t) => {
|
|
104
|
+
const council = { chair: { model: "p/chair:medium", dispatches: 1, clusters: 0, members_reported: 0 }, members: {} };
|
|
105
|
+
const root = repo({ record: RECORD.replace(" spec_writes: {}", ` council: ${JSON.stringify(council)}\n spec_writes: {}`) }); cleanup(t, root);
|
|
106
|
+
const r = run(root, ["--spec", SPEC]);
|
|
107
|
+
assert.equal(r.status, 0, r.stderr);
|
|
108
|
+
assert.equal(record(root).status, "shipped");
|
|
109
|
+
assert.deepEqual(record(root).derived.council, council);
|
|
110
|
+
assert.deepEqual(parseYaml(out(root, ["show", `HEAD:${REC}`])).derived.council, council);
|
|
111
|
+
});
|
|
112
|
+
|
|
103
113
|
test("--spec accepts an absolute path within the checkout", (t) => {
|
|
104
114
|
const root = repo(); cleanup(t, root);
|
|
105
115
|
const r = run(root, ["--spec", join(realpathSync(root), SPEC)]);
|