pi-gauntlet 6.0.0 → 6.1.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 +12 -0
- package/README.md +1 -1
- package/agents/code-reviewer.md +1 -0
- package/agents/conformance-reviewer.md +1 -1
- package/bin/gauntlet-performance.test.mjs +4 -4
- package/bin/gauntlet-spec-index.mjs +142 -100
- package/bin/gauntlet-spec-index.test.mjs +41 -1
- package/bin/gauntlet-telemetry-seal.mjs +49 -14
- package/bin/gauntlet-telemetry-seal.test.mjs +76 -5
- package/extensions/lib/gauntlet-settings.test.ts +13 -3
- package/extensions/lib/gauntlet-settings.ts +18 -2
- package/extensions/lib/phase-tracker-helpers.test.ts +14 -0
- package/extensions/lib/phase-tracker-helpers.ts +5 -4
- package/extensions/lib/telemetry-collect.test.ts +6 -0
- package/extensions/lib/telemetry-collect.ts +4 -2
- package/extensions/lib/telemetry-diff.test.ts +52 -4
- package/extensions/lib/telemetry-diff.ts +4 -4
- package/extensions/lib/telemetry-paths.test.ts +17 -6
- package/extensions/lib/telemetry-paths.ts +7 -2
- package/extensions/lib/telemetry-ship.test.ts +9 -6
- package/extensions/lib/telemetry-ship.ts +4 -3
- package/extensions/phase-tracker.test.ts +24 -0
- package/extensions/phase-tracker.ts +10 -6
- package/extensions/telemetry.test.ts +32 -7
- package/extensions/telemetry.ts +30 -26
- package/package.json +1 -1
- package/skills/brainstorming/SKILL.md +5 -5
- package/skills/dispatching-parallel-agents/SKILL.md +1 -1
- package/skills/finishing-a-development-branch/SKILL.md +8 -4
- package/skills/gauntlet-resume/reference/reconstruction.md +1 -4
- package/skills/requesting-code-review/SKILL.md +4 -2
- package/skills/subagent-driven-development/SKILL.md +6 -6
- package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +10 -8
- package/skills/verification-before-completion/reference/conformance-check.md +2 -0
- package/skills/verification-before-completion/reference/whole-diff-minors.md +31 -0
- package/skills/writing-plans/SKILL.md +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v6.1.0 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Minor code-review findings ride along in a `FIX_FIRST` fix round and never trigger one; a `SHIP` report ends the loop whatever Minors it lists. The whole-diff review's Minors land in the closure block as `whole-diff minors:` and the finish gate offers a one-shot `fix minors` reply. Re-reviews are scoped to a `## Fix delta` block (the whole-diff review and its re-reviews stay unscoped): a new finding outside it blocks only when Critical; `TRAJECTORY:` counts Critical and Moderate only. Telemetry counts `[Critical]`/`[Moderate]` into `blocker`/`major`. (#56)
|
|
8
|
+
|
|
9
|
+
## v6.0.1 - 2026-09-30
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `flowGuards.specDirs` defaults to `["doc/specs", "docs/specs"]` and now governs telemetry spec/plan classification, the seal's plan exclusion, and the spec index; `gauntlet-telemetry-seal` selects records by their `spec:` field; `gauntlet-spec-index` ships as an esbuild bundle from `src/bins/`; `gauntlet_setting` gains the `flowGuards` key.
|
|
14
|
+
|
|
3
15
|
## v6.0.0 - 2026-09-30
|
|
4
16
|
|
|
5
17
|
### Changed
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ Pin an exact release with `npm:pi-gauntlet@X.Y.Z`. See [doc/install-internals.md
|
|
|
137
137
|
|
|
138
138
|
## Spec search index
|
|
139
139
|
|
|
140
|
-
`gauntlet-spec-index` provides lexical search over two corpora in one per-worktree cache. From a repository worktree, run `node <pi-gauntlet-package>/bin/gauntlet-spec-index.mjs --query "<text>" [--corpus specs|docs] [--limit N] [--exclude <repo-relative path>]...`; it requires Node >=24.15.0 and refreshes its FTS5 index on every query. `--corpus specs` (the default) searches `doc/specs
|
|
140
|
+
`gauntlet-spec-index` provides lexical search over two corpora in one per-worktree cache. From a repository worktree, run `node <pi-gauntlet-package>/bin/gauntlet-spec-index.mjs --query "<text>" [--corpus specs|docs] [--limit N] [--exclude <repo-relative path>]...`; it requires Node >=24.15.0 and refreshes its FTS5 index on every query. `--corpus specs` (the default) searches `<spec dir>/*.md` for each resolved `flowGuards.specDirs` entry (default `doc/specs` and `docs/specs`) at the repository root and one service level down and prints tab-separated `score`, `path`, `service`, `title`, `status`, `shipped_at`, `state`, `files`, and `snippet` columns. `--corpus docs` searches project documentation and prints `score`, `path`, `title`, and `snippet`; the snippet is the matched `##`/`###` heading line when one holds a query term, else a body fragment. Docs candidates are the tracked and untracked, non-gitignored `*.md` files (`git ls-files -co --exclude-standard`, so nested repositories and submodules are not indexed) matching an include list - default `**/doc/**/*.md`, `**/docs/**/*.md`, `README.md`, `AGENTS.md`, where a slash-free entry also matches one service level down - minus the resolved spec and plan dirs (matched as literal path components), `**/node_modules/**`, `.pi/gauntlet/**`, `.worktrees/**`, symlinks, and files whose first line is the context-draft marker; `**` never matches a dot directory, so `.pi/*.md` or `.github/*.md` is indexed only through an explicit include. A project replaces the include list with `- docs: <glob>` bullets under a `## Spec index` heading in its gauntlet overrides file (see [Project-specific overrides](#project-specific-overrides)); the exclusions are not configurable through the overrides file; the spec and plan dir exclusions follow `flowGuards.specDirs`. Both corpora pass the same confidence rule before `--limit` applies: a query term is evidence when it occurs in fewer than half the rows of the queried table (stem variants of one word count once); query tokens split on word boundaries, so `gauntlet-spec-index` searches as three words; a row is returned only when it matches at least two distinct evidence terms in the corpus's strong fields - `title` or `goal` for specs, `title` or `headings` for docs - and rows scoring below half the best live row's score are dropped (every docs row is live). A query with no real match therefore prints the header only and exits 0 - and when the queried corpus has fewer than 10 documents, also writes one stderr line naming the corpus and its size (`gauntlet-spec-index: docs corpus has 5 documents - too small for the confidence rule; no rows returned`), so a small corpus and a genuine miss stay distinguishable; a corpus of two or fewer files never returns rows. `--exclude` (repeatable) removes a path before the rule runs, so the spec under review never sets the bar. `state` is `superseded` when a `> **Superseded by:** ... - fully` banner sits in the block directly under the title and `live` otherwise; named-section banners and consumer-defined banner syntaxes both read as `live`. Live rows sort before superseded rows, then by score. The `files` column is a `;`-separated list of repo-relative paths the spec's shipped change modified and that still exist in the repository, the literal `missing` when the spec's telemetry record has no `derived.modified_files` list, or blank when there is no readable record or no recorded path remains. The cache lives at `.pi/gauntlet/index.sqlite`, and its first creation adds `/.pi/gauntlet/index.sqlite*` to Git's `info/exclude` so the database and SQLite sidecars stay out of `git status`. `/skill:brainstorming` queries the specs corpus twice - the scout at gather time from the request, and the main loop at spec-writing from the finished spec's title, goal, and headings, surfacing new `live` candidates at the review gate - and the scout queries the docs corpus once to render `Docs touched:` lines that round 2 weighs as documentation-impact candidates.
|
|
141
141
|
|
|
142
142
|
## Performance digest
|
|
143
143
|
|
package/agents/code-reviewer.md
CHANGED
|
@@ -59,6 +59,7 @@ Rules:
|
|
|
59
59
|
- Verify before praising: no "looks good" on code you did not read.
|
|
60
60
|
- Report only on code you read.
|
|
61
61
|
- Name the concrete change in every Fix; "improve error handling" is not a Fix.
|
|
62
|
+
- On a re-review (the task carries `## Previous review report (re-review trigger)`): confirm each prior finding is resolved, then review the `## Fix delta` block, reading surrounding code as needed. A new finding outside that delta is Critical, or it is `[Minor]` with the note `(outside fix delta)`. No `## Fix delta` block -> the task's whole diff is the delta. The whole-diff review and its re-reviews are never scoped.
|
|
62
63
|
|
|
63
64
|
Label every finding with a globally unique `F1..Fn` ID (no restart per severity),
|
|
64
65
|
and a `touched-files:`/`touched-resources:` pair (files a fix would edit, not only
|
|
@@ -147,7 +147,7 @@ Footer order: `Parallel-safe:` is the final line of the report.
|
|
|
147
147
|
- **Read-only. Never edit.** You audit; you do not fix.
|
|
148
148
|
- **Propose, do not dispose.** For each gap you may suggest a one-line remediation *direction*, but you do **not** decide the disposition - the orchestrator auto-applies `fix` gaps and defers `accept`/`rescope` gaps to the user at the finish gate (an `UNAUTHORIZED` row follows its `recommended` value like every other verdict). Never present a fix as a decision you made.
|
|
149
149
|
- **Evidence or it didn't happen.** Cite a real `file:line` for every DELIVERED/PARTIAL. If you cannot, downgrade the row to MISSING.
|
|
150
|
-
- **Origin quote or it isn't a gap.** Every non-UNAUTHORIZED gap's `origin` carries a locator AND a verbatim quote: `origin: <file/section, 'prompt', or 'ticket'> - "<quoted clause>"` (truncate long clauses with `[...]` as long as the fragment uniquely identifies the clause). No quotable origin clause = no gap. Do not derive implicit requirements. Do not flag wording preferences. A deviation recorded in the spec wins over an older origin value (Process step 2); report it only if unrecorded.
|
|
150
|
+
- **Origin quote or it isn't a gap.** Every non-UNAUTHORIZED gap's `origin` carries a locator AND a verbatim quote: `origin: <file/section, 'prompt', or 'ticket'> - "<quoted clause>"` (truncate long clauses with `[...]` as long as the fragment uniquely identifies the clause). No quotable origin clause = no gap. Do not derive implicit requirements. Do not flag wording preferences. A code-review finding - style, naming, comment, micro-efficiency - with no origin clause is never a gap, whatever its severity. A deviation recorded in the spec wins over an older origin value (Process step 2); report it only if unrecorded.
|
|
151
151
|
- **Spec is canonical; the prompt catches what the spec dropped; the ticket is fallback only** when no spec exists.
|
|
152
152
|
- **The spec's `## Acceptance criteria` section is origin, not ticket.** Its `in-scope`/`venue:` rows are `Rn`; its `deviates:`/`deferred:` rows are recorded drift (`recorded in spec? yes`).
|
|
153
153
|
- **Do not absorb origin drift silently** — flag every spec↔prompt/ticket disagreement.
|
|
@@ -54,8 +54,8 @@ derived:
|
|
|
54
54
|
worker: { dispatches: 3, models: [m/beta] }
|
|
55
55
|
spec-council-member: { dispatches: 4, models: [m/alpha] }
|
|
56
56
|
reviews:
|
|
57
|
-
spec-reviewer: { dispatches: 1, nonzero_exit: 0
|
|
58
|
-
code-reviewer: { dispatches: 1, nonzero_exit: 0 }
|
|
57
|
+
spec-reviewer: { dispatches: 1, nonzero_exit: 0 }
|
|
58
|
+
code-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }
|
|
59
59
|
conformance_loops: 2
|
|
60
60
|
conformance_open_gaps: 0
|
|
61
61
|
gates: { spec_rounds: 1, plan_rounds: 1, fix_round_grants: 1, task_reopens: 0, ship_option: squash }
|
|
@@ -211,8 +211,8 @@ test("malformed reviewer entries and findings objects contribute null counters",
|
|
|
211
211
|
'findings: "bad"',
|
|
212
212
|
);
|
|
213
213
|
const malformedReviewer = COMPLETE.replace(
|
|
214
|
-
"
|
|
215
|
-
'
|
|
214
|
+
"code-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }",
|
|
215
|
+
'code-reviewer: "bad"',
|
|
216
216
|
);
|
|
217
217
|
write(root, `${TDIR}/malformed-findings-object.yaml`, malformedFindings);
|
|
218
218
|
write(root, `${TDIR}/malformed-reviewer.yaml`, malformedReviewer.replace(
|
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
// src/bins/gauntlet-spec-index.mjs
|
|
3
4
|
import { readFileSync, appendFileSync, existsSync, statSync, lstatSync, readdirSync, mkdirSync, rmSync, realpathSync, openSync, readSync, closeSync } from "node:fs";
|
|
4
5
|
import { join, dirname, basename, isAbsolute, matchesGlob } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
5
7
|
import { execFileSync } from "node:child_process";
|
|
6
8
|
import process from "node:process";
|
|
7
9
|
import { parse as parseYaml } from "yaml";
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
11
|
+
// extensions/lib/gauntlet-settings.ts
|
|
12
|
+
function mergeGauntlet(preset, repo) {
|
|
13
|
+
return { ...preset ?? {}, ...repo ?? {} };
|
|
14
|
+
}
|
|
15
|
+
var nonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
|
|
16
|
+
var DEFAULT_SPEC_DIRS = ["doc/specs", "docs/specs"];
|
|
17
|
+
var normalizeDir = (d) => d.trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
18
|
+
function resolveFlowGuards(g) {
|
|
19
|
+
const fg = g.flowGuards;
|
|
20
|
+
const enforce = fg?.enforce !== false;
|
|
21
|
+
const rawDirs = fg?.specDirs;
|
|
22
|
+
const specDirs = Array.isArray(rawDirs) && rawDirs.length > 0 && rawDirs.every(nonEmptyString) ? rawDirs.map(normalizeDir) : [...DEFAULT_SPEC_DIRS];
|
|
23
|
+
return { enforce, specDirs };
|
|
24
|
+
}
|
|
25
|
+
function planDirsFor(specDirs) {
|
|
26
|
+
const out = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const dir of specDirs) {
|
|
28
|
+
const parts = dir.split("/").filter((c) => c.length > 0);
|
|
29
|
+
if (parts.length === 0) continue;
|
|
30
|
+
out.add([...parts.slice(0, -1), "plans"].join("/"));
|
|
31
|
+
}
|
|
32
|
+
return [...out];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/bins/gauntlet-spec-index.mjs
|
|
36
|
+
var SCHEMA_VERSION = 3;
|
|
37
|
+
var EVIDENCE_DF_FRACTION = 0.5;
|
|
38
|
+
var MIN_EVIDENCE_TOKENS = 2;
|
|
39
|
+
var SCORE_RATIO = 0.5;
|
|
40
|
+
var MIN_CORPUS = 10;
|
|
41
|
+
var FULLY_BANNER = /^> \*\*Superseded by:\*\* \[.*\]\(.*\) - fully$/;
|
|
42
|
+
var MIN_NODE = [24, 15, 0];
|
|
43
|
+
var DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
44
|
+
var EXCLUDE_LINE = "/.pi/gauntlet/index.sqlite*";
|
|
45
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".worktrees", "node_modules", "build"]);
|
|
46
|
+
var FENCE = /^\s*(`{3,}|~{3,})/;
|
|
47
|
+
var DOC_INCLUDES = ["**/doc/**/*.md", "**/docs/**/*.md", "README.md", "AGENTS.md"];
|
|
48
|
+
var DOC_EXCLUDE_GLOBS = ["**/node_modules/**", ".pi/gauntlet/**", ".worktrees/**"];
|
|
49
|
+
var OVERRIDES_FILES = [".pi/gauntlet-overrides.md", "gauntlet-overrides.md", "doc/gauntlet-overrides.md"];
|
|
50
|
+
var SCHEMA = `
|
|
27
51
|
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
28
52
|
CREATE TABLE IF NOT EXISTS files (corpus TEXT NOT NULL, path TEXT NOT NULL, mtime_ms INTEGER, size INTEGER, PRIMARY KEY (corpus, path));
|
|
29
53
|
CREATE VIRTUAL TABLE IF NOT EXISTS specs USING fts5(
|
|
@@ -33,40 +57,36 @@ const SCHEMA = `
|
|
|
33
57
|
path UNINDEXED, title, headings, body,
|
|
34
58
|
tokenize = 'porter unicode61');
|
|
35
59
|
`;
|
|
36
|
-
|
|
37
|
-
const usage = () => {
|
|
60
|
+
var usage = () => {
|
|
38
61
|
process.stderr.write('usage: gauntlet-spec-index --query "<text>" [--corpus specs|docs] [--limit N] [--exclude <repo-relative path>]...\n');
|
|
39
62
|
process.exit(1);
|
|
40
63
|
};
|
|
41
|
-
|
|
42
|
-
process.stderr.write(`${msg}
|
|
64
|
+
var die = (msg) => {
|
|
65
|
+
process.stderr.write(`${msg}
|
|
66
|
+
`);
|
|
43
67
|
process.exit(2);
|
|
44
68
|
};
|
|
45
|
-
|
|
46
69
|
function parseArgs(argv) {
|
|
47
|
-
let
|
|
70
|
+
let query2;
|
|
48
71
|
let corpus = "specs";
|
|
49
72
|
let limit = 10;
|
|
50
73
|
const exclude = [];
|
|
51
74
|
for (let i = 0; i < argv.length; i++) {
|
|
52
|
-
if (argv[i] === "--query" && argv[i + 1] !==
|
|
75
|
+
if (argv[i] === "--query" && argv[i + 1] !== void 0) query2 = argv[++i];
|
|
53
76
|
else if (argv[i] === "--corpus" && Object.hasOwn(CORPORA, argv[i + 1] ?? "")) corpus = argv[++i];
|
|
54
77
|
else if (argv[i] === "--limit" && /^[1-9]\d*$/.test(argv[i + 1] ?? "")) limit = Number(argv[++i]);
|
|
55
|
-
else if (argv[i] === "--exclude" && argv[i + 1] !==
|
|
78
|
+
else if (argv[i] === "--exclude" && argv[i + 1] !== void 0) exclude.push(argv[++i]);
|
|
56
79
|
else usage();
|
|
57
80
|
}
|
|
58
|
-
if (
|
|
59
|
-
return { query, corpus, limit, exclude };
|
|
81
|
+
if (query2 === void 0) usage();
|
|
82
|
+
return { query: query2, corpus, limit, exclude };
|
|
60
83
|
}
|
|
61
|
-
|
|
62
84
|
function nodeOk() {
|
|
63
85
|
const cur = process.versions.node.split(".").map(Number);
|
|
64
86
|
for (let i = 0; i < 3; i++) if (cur[i] !== MIN_NODE[i]) return cur[i] > MIN_NODE[i];
|
|
65
87
|
return true;
|
|
66
88
|
}
|
|
67
|
-
|
|
68
|
-
const git = (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
69
|
-
|
|
89
|
+
var git = (cwd, args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
70
90
|
function repoRoot() {
|
|
71
91
|
try {
|
|
72
92
|
return git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
@@ -74,10 +94,25 @@ function repoRoot() {
|
|
|
74
94
|
return die("gauntlet-spec-index: not inside a git repository");
|
|
75
95
|
}
|
|
76
96
|
}
|
|
77
|
-
|
|
97
|
+
function readLayer(file) {
|
|
98
|
+
if (!existsSync(file)) return {};
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
process.stderr.write(`warning: ${file}: ${e.message}; using {} for this layer
|
|
103
|
+
`);
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function specDirsFor(root) {
|
|
108
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
109
|
+
const merged = mergeGauntlet(readLayer(join(agentDir, "settings.json"))?.piGauntlet, readLayer(join(root, ".pi", "settings.json"))?.piGauntlet);
|
|
110
|
+
const specDirs = resolveFlowGuards(merged).specDirs;
|
|
111
|
+
return { specDirs, planDirs: planDirsFor(specDirs) };
|
|
112
|
+
}
|
|
78
113
|
function discover(root) {
|
|
79
114
|
const out = [];
|
|
80
|
-
const seen = new Set();
|
|
115
|
+
const seen = /* @__PURE__ */ new Set();
|
|
81
116
|
const collect = (relDir, service) => {
|
|
82
117
|
const abs = join(root, relDir);
|
|
83
118
|
let real;
|
|
@@ -96,18 +131,17 @@ function discover(root) {
|
|
|
96
131
|
if (st.isFile()) out.push({ path: rel, service, mtime_ms: Math.trunc(st.mtimeMs), size: st.size });
|
|
97
132
|
}
|
|
98
133
|
};
|
|
99
|
-
|
|
100
|
-
|
|
134
|
+
const { specDirs } = specDirsFor(root);
|
|
135
|
+
const svc = services(root).sort((a, b) => a.localeCompare(b));
|
|
136
|
+
for (const d of specDirs) {
|
|
137
|
+
collect(d, "root");
|
|
138
|
+
for (const s of svc) collect(`${s}/${d}`, s);
|
|
139
|
+
}
|
|
101
140
|
return out;
|
|
102
141
|
}
|
|
103
|
-
|
|
104
|
-
// Top-level directories discover() would scan; a slash-free include also matches one level under them.
|
|
105
142
|
function services(root) {
|
|
106
|
-
return readdirSync(root, { withFileTypes: true })
|
|
107
|
-
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name))
|
|
108
|
-
.map((e) => e.name);
|
|
143
|
+
return readdirSync(root, { withFileTypes: true }).filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name)).map((e) => e.name);
|
|
109
144
|
}
|
|
110
|
-
|
|
111
145
|
function includes(root) {
|
|
112
146
|
const file = OVERRIDES_FILES.map((rel) => join(root, rel)).find((abs) => existsSync(abs));
|
|
113
147
|
if (!file) return DOC_INCLUDES;
|
|
@@ -120,14 +154,16 @@ function includes(root) {
|
|
|
120
154
|
die(`gauntlet-spec-index: unreadable overrides file ${file}: ${e.message}`);
|
|
121
155
|
}
|
|
122
156
|
for (const line of text.split(/\r?\n/)) {
|
|
123
|
-
if (/^## /.test(line)) {
|
|
157
|
+
if (/^## /.test(line)) {
|
|
158
|
+
inSection = line.trim() === "## Spec index";
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
124
161
|
if (!inSection) continue;
|
|
125
162
|
const m = /^-\s+docs:\s*(.+?)\s*$/.exec(line);
|
|
126
163
|
if (m) globs.push(m[1].replace(/^[`"']+|[`"']+$/g, ""));
|
|
127
164
|
}
|
|
128
165
|
return globs.length ? globs : DOC_INCLUDES;
|
|
129
166
|
}
|
|
130
|
-
|
|
131
167
|
function firstLine(abs) {
|
|
132
168
|
const fd = openSync(abs, "r");
|
|
133
169
|
try {
|
|
@@ -138,15 +174,24 @@ function firstLine(abs) {
|
|
|
138
174
|
closeSync(fd);
|
|
139
175
|
}
|
|
140
176
|
}
|
|
141
|
-
|
|
177
|
+
function underDirs(rel, dirs) {
|
|
178
|
+
const parts = rel.split("/");
|
|
179
|
+
return dirs.some((d) => {
|
|
180
|
+
const dp = d.split("/");
|
|
181
|
+
for (let i = 0; i + dp.length < parts.length; i++) if (dp.every((c, j) => parts[i + j] === c)) return true;
|
|
182
|
+
return false;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
142
185
|
function discoverDocs(root, globs) {
|
|
186
|
+
const { specDirs, planDirs } = specDirsFor(root);
|
|
187
|
+
const skip = [...specDirs, ...planDirs];
|
|
143
188
|
const svc = services(root);
|
|
144
|
-
const patterns = globs.flatMap((g) =>
|
|
189
|
+
const patterns = globs.flatMap((g) => g.includes("/") ? [g] : [g, ...svc.map((s) => `${s}/${g}`)]);
|
|
145
190
|
const listed = execFileSync("git", ["-C", root, "ls-files", "-co", "--exclude-standard", "-z", "--", "*.md"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\0").filter(Boolean);
|
|
146
191
|
const out = [];
|
|
147
192
|
for (const rel of listed) {
|
|
148
193
|
if (!patterns.some((p) => matchesGlob(rel, p))) continue;
|
|
149
|
-
if (
|
|
194
|
+
if (DOC_EXCLUDE_GLOBS.some((p) => matchesGlob(rel, p)) || underDirs(rel, skip)) continue;
|
|
150
195
|
let st;
|
|
151
196
|
try {
|
|
152
197
|
st = lstatSync(join(root, rel));
|
|
@@ -158,35 +203,37 @@ function discoverDocs(root, globs) {
|
|
|
158
203
|
}
|
|
159
204
|
return out;
|
|
160
205
|
}
|
|
161
|
-
|
|
162
206
|
function ensureExclude(root) {
|
|
163
207
|
const rel = git(root, ["rev-parse", "--git-path", "info/exclude"]);
|
|
164
208
|
const abs = isAbsolute(rel) ? rel : join(root, rel);
|
|
165
209
|
const cur = existsSync(abs) ? readFileSync(abs, "utf8") : "";
|
|
166
210
|
if (cur.split("\n").includes(EXCLUDE_LINE)) return;
|
|
167
211
|
mkdirSync(dirname(abs), { recursive: true });
|
|
168
|
-
appendFileSync(abs, `${cur.length && !cur.endsWith("\n") ? "\n" : ""}${EXCLUDE_LINE}
|
|
212
|
+
appendFileSync(abs, `${cur.length && !cur.endsWith("\n") ? "\n" : ""}${EXCLUDE_LINE}
|
|
213
|
+
`);
|
|
169
214
|
}
|
|
170
|
-
|
|
171
215
|
async function openDb(root) {
|
|
172
216
|
const { DatabaseSync } = await import("node:sqlite");
|
|
173
217
|
const dbPath = join(root, ".pi/gauntlet/index.sqlite");
|
|
174
218
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
175
219
|
const fresh = !existsSync(dbPath);
|
|
176
220
|
const create = () => {
|
|
177
|
-
const
|
|
178
|
-
|
|
221
|
+
const db2 = new DatabaseSync(dbPath);
|
|
222
|
+
db2.exec("PRAGMA busy_timeout = 2000");
|
|
179
223
|
try {
|
|
180
|
-
|
|
224
|
+
db2.exec(SCHEMA);
|
|
181
225
|
} catch (e) {
|
|
182
226
|
if (/fts5/i.test(e.message)) die("gauntlet-spec-index: this Node's SQLite has no FTS5 module");
|
|
183
227
|
throw e;
|
|
184
228
|
}
|
|
185
|
-
|
|
186
|
-
return
|
|
229
|
+
db2.prepare("INSERT OR REPLACE INTO meta VALUES ('schema_version', ?)").run(String(SCHEMA_VERSION));
|
|
230
|
+
return db2;
|
|
187
231
|
};
|
|
188
|
-
const rebuild = (
|
|
189
|
-
try {
|
|
232
|
+
const rebuild = (db2) => {
|
|
233
|
+
try {
|
|
234
|
+
db2?.close();
|
|
235
|
+
} catch {
|
|
236
|
+
}
|
|
190
237
|
for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(dbPath + suffix, { force: true });
|
|
191
238
|
return create();
|
|
192
239
|
};
|
|
@@ -202,11 +249,13 @@ async function openDb(root) {
|
|
|
202
249
|
const missingMeta = e?.code === "ERR_SQLITE_ERROR" && e.errcode === 1 && /no such table:\s*meta/i.test(e.message);
|
|
203
250
|
const corrupt = e?.code === "ERR_SQLITE_ERROR" && (e.errcode === 11 || e.errcode === 26);
|
|
204
251
|
if (missingMeta || corrupt) return rebuild(db);
|
|
205
|
-
try {
|
|
252
|
+
try {
|
|
253
|
+
db?.close();
|
|
254
|
+
} catch {
|
|
255
|
+
}
|
|
206
256
|
throw e;
|
|
207
257
|
}
|
|
208
258
|
}
|
|
209
|
-
|
|
210
259
|
function fenceMask(lines) {
|
|
211
260
|
const mask = new Array(lines.length).fill(false);
|
|
212
261
|
let open = null;
|
|
@@ -222,7 +271,6 @@ function fenceMask(lines) {
|
|
|
222
271
|
}
|
|
223
272
|
return mask;
|
|
224
273
|
}
|
|
225
|
-
|
|
226
274
|
function extract(text, path) {
|
|
227
275
|
const lines = text.split(/\r?\n/);
|
|
228
276
|
const fenced = fenceMask(lines);
|
|
@@ -232,15 +280,16 @@ function extract(text, path) {
|
|
|
232
280
|
const headings = lines.filter((l, i) => !fenced[i] && /^##{1,2} /.test(l)).map((l) => l.replace(/^#+ /, "")).join("\n");
|
|
233
281
|
let state = "live";
|
|
234
282
|
if (h1 >= 0) {
|
|
235
|
-
// Title block: everything after the H1 up to the first H2 heading or code fence.
|
|
236
283
|
for (const l of lines.slice(h1 + 1)) {
|
|
237
284
|
if (/^## /.test(l) || FENCE.test(l)) break;
|
|
238
|
-
if (FULLY_BANNER.test(l)) {
|
|
285
|
+
if (FULLY_BANNER.test(l)) {
|
|
286
|
+
state = "superseded";
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
239
289
|
}
|
|
240
290
|
}
|
|
241
291
|
return { title, goal, headings, state };
|
|
242
292
|
}
|
|
243
|
-
|
|
244
293
|
function refresh(db, root, corpus) {
|
|
245
294
|
const { table } = corpus;
|
|
246
295
|
const found = corpus.discover(root);
|
|
@@ -252,13 +301,19 @@ function refresh(db, root, corpus) {
|
|
|
252
301
|
const putFile = db.prepare("INSERT OR REPLACE INTO files (corpus, path, mtime_ms, size) VALUES (?, ?, ?, ?)");
|
|
253
302
|
db.exec("BEGIN IMMEDIATE");
|
|
254
303
|
try {
|
|
255
|
-
for (const path of known.keys()) if (!present.has(path)) {
|
|
304
|
+
for (const path of known.keys()) if (!present.has(path)) {
|
|
305
|
+
delRow.run(path);
|
|
306
|
+
delFile.run(table, path);
|
|
307
|
+
}
|
|
256
308
|
for (const f of found) {
|
|
257
309
|
const k = known.get(f.path);
|
|
258
310
|
if (k && k.mtime_ms === f.mtime_ms && k.size === f.size) continue;
|
|
259
311
|
const text = readFileSync(join(root, f.path), "utf8");
|
|
260
312
|
delRow.run(f.path);
|
|
261
|
-
if (text.split(/\r?\n/, 1)[0] === DRAFT_MARKER) {
|
|
313
|
+
if (text.split(/\r?\n/, 1)[0] === DRAFT_MARKER) {
|
|
314
|
+
delFile.run(table, f.path);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
262
317
|
insRow.run(...corpus.row(text, f));
|
|
263
318
|
putFile.run(table, f.path, f.mtime_ms, f.size);
|
|
264
319
|
}
|
|
@@ -268,27 +323,22 @@ function refresh(db, root, corpus) {
|
|
|
268
323
|
throw e;
|
|
269
324
|
}
|
|
270
325
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const toMatch = (toks) => toks.map(quote).join(" OR ");
|
|
275
|
-
|
|
276
|
-
// Porter is not idempotent (manatee -> manate -> manat), so MATCH always receives an
|
|
277
|
-
// original token; the vocab pass only decides which tokens are the same term.
|
|
326
|
+
var tokens = (query2) => query2.split(/[^\p{L}\p{N}\p{Co}]+/u).filter((t) => t.length >= 2);
|
|
327
|
+
var quote = (t) => `"${t.replaceAll('"', '""')}"`;
|
|
328
|
+
var toMatch = (toks) => toks.map(quote).join(" OR ");
|
|
278
329
|
function representatives(db, toks) {
|
|
279
330
|
const mem = new db.constructor(":memory:");
|
|
280
331
|
try {
|
|
281
332
|
mem.exec("CREATE VIRTUAL TABLE q USING fts5(t, tokenize = 'porter unicode61'); CREATE VIRTUAL TABLE qi USING fts5vocab(q, 'instance')");
|
|
282
333
|
const ins = mem.prepare("INSERT INTO q (rowid, t) VALUES (?, ?)");
|
|
283
334
|
toks.forEach((t, i) => ins.run(i + 1, t));
|
|
284
|
-
const rep = new Map();
|
|
335
|
+
const rep = /* @__PURE__ */ new Map();
|
|
285
336
|
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]);
|
|
286
337
|
return rep;
|
|
287
338
|
} finally {
|
|
288
339
|
mem.close();
|
|
289
340
|
}
|
|
290
341
|
}
|
|
291
|
-
|
|
292
342
|
function confidenceFilter(rows, evidence) {
|
|
293
343
|
return rows.filter((r) => {
|
|
294
344
|
const hits = evidence.filter((e) => e.all.has(r.rowid)).length;
|
|
@@ -296,7 +346,6 @@ function confidenceFilter(rows, evidence) {
|
|
|
296
346
|
return hits >= MIN_EVIDENCE_TOKENS && strong >= MIN_EVIDENCE_TOKENS;
|
|
297
347
|
});
|
|
298
348
|
}
|
|
299
|
-
|
|
300
349
|
function query(db, corpus, toks, { limit, exclude }) {
|
|
301
350
|
const { table } = corpus;
|
|
302
351
|
const n = db.prepare(`SELECT count(*) AS n FROM ${table}`).get().n;
|
|
@@ -312,7 +361,7 @@ function query(db, corpus, toks, { limit, exclude }) {
|
|
|
312
361
|
if (evidence.length < MIN_EVIDENCE_TOKENS) return { rows: [], n };
|
|
313
362
|
const rows = db.prepare(
|
|
314
363
|
`SELECT rowid, ${corpus.select}, bm25(${table}, ${corpus.weights.map((w) => w.toFixed(1)).join(", ")}) AS score
|
|
315
|
-
FROM ${table} WHERE ${table} MATCH
|
|
364
|
+
FROM ${table} WHERE ${table} MATCH ?`
|
|
316
365
|
).all(toMatch(terms)).filter((r) => !exclude.includes(r.path));
|
|
317
366
|
const kept = confidenceFilter(rows, evidence);
|
|
318
367
|
const live = (r) => !corpus.hasState || r.state === "live";
|
|
@@ -321,7 +370,6 @@ function query(db, corpus, toks, { limit, exclude }) {
|
|
|
321
370
|
const cut = best ? kept.filter((r) => Math.abs(r.score) >= SCORE_RATIO * Math.abs(best.score)) : kept;
|
|
322
371
|
return { rows: cut.slice(0, limit), n };
|
|
323
372
|
}
|
|
324
|
-
|
|
325
373
|
function telemetry(root, specPath) {
|
|
326
374
|
const blank = { status: null, shipped_at: null, files: "" };
|
|
327
375
|
const p = join(root, ".pi/gauntlet/telemetry", specPath.replace(/\.md$/, ".yaml"));
|
|
@@ -330,28 +378,23 @@ function telemetry(root, specPath) {
|
|
|
330
378
|
try {
|
|
331
379
|
rec = parseYaml(readFileSync(p, "utf8"));
|
|
332
380
|
} catch {
|
|
333
|
-
process.stderr.write(`gauntlet-spec-index: warning: unreadable telemetry ${p}
|
|
381
|
+
process.stderr.write(`gauntlet-spec-index: warning: unreadable telemetry ${p}
|
|
382
|
+
`);
|
|
334
383
|
return blank;
|
|
335
384
|
}
|
|
336
385
|
if (!rec || typeof rec !== "object" || Array.isArray(rec)) return blank;
|
|
337
386
|
const mf = rec.derived?.modified_files;
|
|
338
|
-
const files = Array.isArray(mf)
|
|
339
|
-
? mf.filter((f) => typeof f === "string" && existsSync(join(root, f))).join(";")
|
|
340
|
-
: "missing";
|
|
387
|
+
const files = Array.isArray(mf) ? mf.filter((f) => typeof f === "string" && existsSync(join(root, f))).join(";") : "missing";
|
|
341
388
|
return { status: rec.status ?? null, shipped_at: rec.shipped_at ?? null, files };
|
|
342
389
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// Paths keep their spaces; only column and row delimiters are neutralised.
|
|
346
|
-
const filesCell = (v) => v.replace(/[\t\r\n]+/g, " ");
|
|
347
|
-
|
|
390
|
+
var cell = (v) => v === null || v === void 0 ? "" : String(v).replace(/\s+/g, " ").trim();
|
|
391
|
+
var filesCell = (v) => v.replace(/[\t\r\n]+/g, " ");
|
|
348
392
|
function docSnippet(r) {
|
|
349
|
-
if (!r.hsnip.includes("
|
|
350
|
-
const fragment = r.hsnip.split("\n").find((line) => line.includes("
|
|
393
|
+
if (!r.hsnip.includes("")) return r.bsnip;
|
|
394
|
+
const fragment = r.hsnip.split("\n").find((line) => line.includes("")).replace(/[\u0001\u0002]/g, "");
|
|
351
395
|
return r.headings.split("\n").find((line) => line.includes(fragment));
|
|
352
396
|
}
|
|
353
|
-
|
|
354
|
-
const CORPORA = {
|
|
397
|
+
var CORPORA = {
|
|
355
398
|
specs: {
|
|
356
399
|
table: "specs",
|
|
357
400
|
columns: ["path", "service", "title", "goal", "headings", "body", "state"],
|
|
@@ -368,7 +411,7 @@ const CORPORA = {
|
|
|
368
411
|
format: (root, r) => {
|
|
369
412
|
const t = telemetry(root, r.path);
|
|
370
413
|
return [...[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)];
|
|
371
|
-
}
|
|
414
|
+
}
|
|
372
415
|
},
|
|
373
416
|
docs: {
|
|
374
417
|
table: "docs",
|
|
@@ -377,16 +420,15 @@ const CORPORA = {
|
|
|
377
420
|
weights: [0, 10, 3, 1],
|
|
378
421
|
hasState: false,
|
|
379
422
|
header: ["score", "path", "title", "snippet"],
|
|
380
|
-
select: "path, title, headings, snippet(docs, 2, '
|
|
423
|
+
select: "path, title, headings, snippet(docs, 2, '', '', '', 12) AS hsnip, snippet(docs, 3, '', '', '...', 12) AS bsnip",
|
|
381
424
|
discover: (root) => discoverDocs(root, includes(root)),
|
|
382
425
|
row: (text, f) => {
|
|
383
426
|
const x = extract(text, f.path);
|
|
384
427
|
return [f.path, x.title, x.headings, text];
|
|
385
428
|
},
|
|
386
|
-
format: (root, r) => [r.score.toFixed(3), r.path, r.title, docSnippet(r)].map(cell)
|
|
387
|
-
}
|
|
429
|
+
format: (root, r) => [r.score.toFixed(3), r.path, r.title, docSnippet(r)].map(cell)
|
|
430
|
+
}
|
|
388
431
|
};
|
|
389
|
-
|
|
390
432
|
async function main() {
|
|
391
433
|
if (!nodeOk()) die(`gauntlet-spec-index needs Node >=24.15.0 (found ${process.versions.node})`);
|
|
392
434
|
const { query: text, corpus: name, limit, exclude } = parseArgs(process.argv.slice(2));
|
|
@@ -397,15 +439,15 @@ async function main() {
|
|
|
397
439
|
const db = await openDb(root);
|
|
398
440
|
refresh(db, root, corpus);
|
|
399
441
|
const { rows, n } = query(db, corpus, toks, { limit, exclude });
|
|
400
|
-
const out = [corpus.header.join("
|
|
401
|
-
for (const r of rows) out.push(corpus.format(root, r).join("
|
|
442
|
+
const out = [corpus.header.join(" ")];
|
|
443
|
+
for (const r of rows) out.push(corpus.format(root, r).join(" "));
|
|
402
444
|
process.stdout.write(out.join("\n") + "\n");
|
|
403
445
|
if (rows.length === 0 && n < MIN_CORPUS) {
|
|
404
|
-
process.stderr.write(`gauntlet-spec-index: ${name} corpus has ${n} documents - too small for the confidence rule; no rows returned
|
|
446
|
+
process.stderr.write(`gauntlet-spec-index: ${name} corpus has ${n} documents - too small for the confidence rule; no rows returned
|
|
447
|
+
`);
|
|
405
448
|
}
|
|
406
449
|
db.close();
|
|
407
450
|
}
|
|
408
|
-
|
|
409
451
|
main().catch((e) => {
|
|
410
452
|
if (e?.code === "ERR_SQLITE_ERROR") die(`gauntlet-spec-index: ${e.message}`);
|
|
411
453
|
throw e;
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { DatabaseSync } from "node:sqlite";
|
|
9
9
|
|
|
10
10
|
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-spec-index.mjs");
|
|
11
|
+
const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gsi-empty-agent-"));
|
|
11
12
|
const DRAFT = "# CONTEXT DRAFT - NOT A SPEC - fully replaced at spec-writing";
|
|
12
13
|
const HEADER = ["score", "path", "service", "title", "status", "shipped_at", "state", "files", "snippet"];
|
|
13
14
|
const DOCS_HEADER = ["score", "path", "title", "snippet"];
|
|
@@ -68,7 +69,7 @@ const repo = () => {
|
|
|
68
69
|
};
|
|
69
70
|
|
|
70
71
|
const run = (cwd, args) => {
|
|
71
|
-
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8" });
|
|
72
|
+
const r = spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: "utf8", env: { ...process.env, PI_CODING_AGENT_DIR: EMPTY_AGENT } });
|
|
72
73
|
const lines = r.stdout.split("\n").filter(Boolean);
|
|
73
74
|
return { status: r.status, stderr: r.stderr, stdout: r.stdout, header: lines[0]?.split("\t"), rows: lines.slice(1).map((l) => l.split("\t")) };
|
|
74
75
|
};
|
|
@@ -82,6 +83,45 @@ const withDb = (root, fn) => {
|
|
|
82
83
|
}
|
|
83
84
|
};
|
|
84
85
|
|
|
86
|
+
test("S1: a repo with only docs/specs/ is indexed under the default dirs", (t) => {
|
|
87
|
+
const root = gitRepo(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
88
|
+
write(root, "docs/specs/a.md", "# Alpha zephyr widget\n\n**Goal:** rank the widget.\n");
|
|
89
|
+
for (const n of ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]) write(root, `docs/specs/filler-${n}.md`, filler(n));
|
|
90
|
+
commit(root);
|
|
91
|
+
const r = run(root, ["--query", "zephyr widget"]);
|
|
92
|
+
assert.equal(r.status, 0, r.stderr);
|
|
93
|
+
assert.deepEqual(paths(r), ["docs/specs/a.md"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("S2: repo flowGuards.specDirs replaces the defaults for the specs corpus", (t) => {
|
|
97
|
+
const root = gitRepo(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
98
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { flowGuards: { specDirs: ["design/specs"] } } }));
|
|
99
|
+
write(root, "design/specs/a.md", "# Alpha zephyr widget\n\n**Goal:** rank the widget.\n");
|
|
100
|
+
write(root, "doc/specs/decoy.md", "# zephyr widget decoy\n");
|
|
101
|
+
write(root, "docs/specs/decoy2.md", "# zephyr widget decoy two\n");
|
|
102
|
+
for (const n of ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]) write(root, `design/specs/filler-${n}.md`, filler(n));
|
|
103
|
+
commit(root);
|
|
104
|
+
const r = run(root, ["--query", "zephyr widget"]);
|
|
105
|
+
assert.equal(r.status, 0, r.stderr);
|
|
106
|
+
assert.deepEqual(paths(r), ["design/specs/a.md"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("S3: docs corpus excludes configured spec and plan dirs by literal path components, including glob metacharacters", (t) => {
|
|
110
|
+
const root = gitRepo(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
111
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { flowGuards: { specDirs: ["docs/[draft]/specs"] } } }));
|
|
112
|
+
write(root, "docs/[draft]/specs/s.md", decoy);
|
|
113
|
+
write(root, "docs/[draft]/plans/p.md", decoy);
|
|
114
|
+
write(root, "docs/guide.md", "# Guide\n\n## Configuring the ptarmigan gannet\n\nprose\n");
|
|
115
|
+
for (let n = 1; n <= 4; n++) write(root, `docs/filler-${n}.md`, docFiller(n));
|
|
116
|
+
commit(root);
|
|
117
|
+
const r = run(root, ["--corpus", "docs", "--query", "ptarmigan gannet"]);
|
|
118
|
+
assert.equal(r.status, 0, r.stderr);
|
|
119
|
+
assert.deepEqual(paths(r), ["docs/guide.md"]);
|
|
120
|
+
const specs = run(root, ["--query", "ptarmigan gannet"]);
|
|
121
|
+
assert.equal(specs.status, 0, specs.stderr);
|
|
122
|
+
assert.ok(withDb(root, (db) => db.prepare("SELECT path FROM specs").all().map((x) => x.path)).includes("docs/[draft]/specs/s.md"));
|
|
123
|
+
});
|
|
124
|
+
|
|
85
125
|
test("1: corpus boundary, ordering, draft skip, git status clean, exclude written once", (t) => {
|
|
86
126
|
const root = repo();
|
|
87
127
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|