@praxisflux/gates 0.6.3 → 0.6.5
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.
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// cli.mjs — command-line entry to the grounding-wiki gates.
|
|
3
3
|
// node cli.mjs freshness <repo-root> [corpus-dir] (corpus-dir defaults to docs/wiki)
|
|
4
|
-
|
|
4
|
+
// node cli.mjs plan <repo-root> [corpus-dir] computed reconciliation for stale
|
|
5
|
+
// notes: RE-PIN-ONLY entries print
|
|
6
|
+
// runnable repin.mjs commands, NEEDS-
|
|
7
|
+
// REVIEW entries print a work order.
|
|
8
|
+
// Fresh corpus prints nothing.
|
|
9
|
+
// Read-only — plan prints, the
|
|
10
|
+
// wiki-update skill executes.
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { validateFreshness, planFreshness } from "./freshness.mjs";
|
|
5
14
|
|
|
6
15
|
function report({ fails = [], warns = [], checked = 0 }, okMsg) {
|
|
7
16
|
for (const w of warns) console.log(`warn: ${w}`);
|
|
@@ -20,7 +29,25 @@ if (cmd === "freshness") {
|
|
|
20
29
|
if (!root) { console.error("usage: cli.mjs freshness <repo-root> [corpus-dir]"); process.exit(2); }
|
|
21
30
|
const r = validateFreshness(root, corpusDir || "docs/wiki");
|
|
22
31
|
report(r, "OK: %n note(s) fresh against their pinned sources.");
|
|
32
|
+
} else if (cmd === "plan") {
|
|
33
|
+
const [root, corpusDir] = rest;
|
|
34
|
+
if (!root) { console.error("usage: cli.mjs plan <repo-root> [corpus-dir]"); process.exit(2); }
|
|
35
|
+
const { head, entries, problems } = planFreshness(root, corpusDir || "docs/wiki");
|
|
36
|
+
if (problems.length) {
|
|
37
|
+
for (const p of problems) console.error(`# problem: ${p} — fix before planning`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const repinScript = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "repin.mjs");
|
|
41
|
+
const summary = (e) => e.files.map((f) => `${f.path} (+${f.plus}/-${f.minus})`).join(", ");
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (e.cls === "REPIN") {
|
|
44
|
+
console.log(`# RE-PIN-ONLY ${e.note} — ${e.reason} (${e.commits} commit(s): ${summary(e)})`);
|
|
45
|
+
console.log(`node ${repinScript} ${e.absPath} ${head}`);
|
|
46
|
+
} else {
|
|
47
|
+
console.log(`# NEEDS-REVIEW ${e.note} — pin ${e.pin.slice(0, 12)}, ${e.commits} commit(s); ${e.reason}; changed: ${summary(e)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
23
50
|
} else {
|
|
24
|
-
console.error("usage: cli.mjs freshness <repo-root> [corpus-dir]");
|
|
51
|
+
console.error("usage: cli.mjs freshness <repo-root> [corpus-dir] | plan <repo-root> [corpus-dir]");
|
|
25
52
|
process.exit(2);
|
|
26
53
|
}
|
|
@@ -90,3 +90,69 @@ export function validateFreshness(repoRoot, corpusDir = "docs/wiki") {
|
|
|
90
90
|
|
|
91
91
|
return { fails, warns, checked };
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/* ── plan: classify stale notes into computed re-pins vs review work ───── */
|
|
95
|
+
|
|
96
|
+
// The ONE provably-safe diff class. A changed line is a lockstep version stamp when it is a
|
|
97
|
+
// version key ("version": "1.2.3" / version: 1.2.3) or an npm-style pin (@scope/name@1.2.3).
|
|
98
|
+
const STAMP_LINE = /(["']?version["']?\s*[:=]\s*["']?\d+\.\d+\.\d+)|(@[\w.-]+\/[\w.-]+@\d+\.\d+\.\d+)/;
|
|
99
|
+
const SEMVER = /\d+\.\d+\.\d+/;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure classifier. `changedLines` are the +/- content lines of the diff over a note's
|
|
103
|
+
* sources since its pin; `noteBody` is the note's full text. RE-PIN-ONLY iff every changed
|
|
104
|
+
* line is a version stamp AND the note quotes no semver literal anywhere (a note that names
|
|
105
|
+
* versions may claim the very number that just changed). Anything else — including an empty
|
|
106
|
+
* diff we couldn't read — defaults to NEEDS-REVIEW: a pin is a verification claim, and the
|
|
107
|
+
* planner must never make one it can't prove.
|
|
108
|
+
*/
|
|
109
|
+
export function classifyNote(changedLines, noteBody) {
|
|
110
|
+
if (!changedLines.length) return { cls: "REVIEW", reason: "diff could not be read — verify by hand" };
|
|
111
|
+
if (!changedLines.every((l) => STAMP_LINE.test(l)))
|
|
112
|
+
return { cls: "REVIEW", reason: "sources changed beyond version stamps — re-verify the prose against the diff" };
|
|
113
|
+
// Raw body, deliberately: notes quote versions in backticks (`0.6.3`), which stripCode
|
|
114
|
+
// would hide — and hiding them would flip the classification in the UNSAFE direction.
|
|
115
|
+
if (SEMVER.test(String(noteBody ?? "")))
|
|
116
|
+
return { cls: "REVIEW", reason: "stamp-only diff, but the note quotes version literals — update them, then re-pin" };
|
|
117
|
+
return { cls: "REPIN", reason: "version stamps only, and the note quotes no versions" };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Plan the reconciliation of a stale corpus: for each stale note, the diff summary since its
|
|
122
|
+
* pin and a classification. Read-only (gates/ contract) — the CLI prints, the wiki-update
|
|
123
|
+
* skill executes. Returns { head, entries, problems }; a fresh corpus yields entries: [].
|
|
124
|
+
* Notes that fail structurally (no pin, unknown pin) land in `problems` — plan doesn't
|
|
125
|
+
* paper over what the freshness gate would reject.
|
|
126
|
+
*/
|
|
127
|
+
export function planFreshness(repoRoot, corpusDir = "docs/wiki") {
|
|
128
|
+
const entries = [];
|
|
129
|
+
const problems = [];
|
|
130
|
+
const dir = isAbsolute(corpusDir) ? corpusDir : join(repoRoot, corpusDir);
|
|
131
|
+
if (!existsSync(join(dir, "INDEX.md"))) return { head: "", entries, problems: [`not a corpus: ${join(dir, "INDEX.md")} missing`] };
|
|
132
|
+
const head = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
133
|
+
|
|
134
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "INDEX.md").sort()) {
|
|
135
|
+
const rel = `${corpusDir}/${file}`;
|
|
136
|
+
const text = readFileSync(join(dir, file), "utf8");
|
|
137
|
+
const pin = parseFrontmatter(text)?.verified_against;
|
|
138
|
+
if (!pin) { problems.push(`${rel}: no verified_against pin`); continue; }
|
|
139
|
+
try { git(repoRoot, ["cat-file", "-e", `${pin}^{commit}`]); }
|
|
140
|
+
catch { problems.push(`${rel}: pin ${pin} is not a known commit`); continue; }
|
|
141
|
+
const sources = parseSourcesBlock(text);
|
|
142
|
+
if (!sources.length) continue; // unverifiable — the freshness gate already warns
|
|
143
|
+
|
|
144
|
+
const log = git(repoRoot, ["log", "--oneline", `${pin}..HEAD`, "--", ...sources]);
|
|
145
|
+
if (!log) continue; // fresh
|
|
146
|
+
const commits = log.split("\n").length;
|
|
147
|
+
const files = git(repoRoot, ["diff", "--numstat", `${pin}..HEAD`, "--", ...sources])
|
|
148
|
+
.split("\n").filter(Boolean)
|
|
149
|
+
.map((l) => { const [plus, minus, path] = l.split("\t"); return { path, plus: +plus || 0, minus: +minus || 0 }; });
|
|
150
|
+
const changedLines = git(repoRoot, ["diff", "-U0", `${pin}..HEAD`, "--", ...sources])
|
|
151
|
+
.split("\n")
|
|
152
|
+
.filter((l) => (/^[+-]/.test(l) && !/^(\+\+\+|---)/.test(l)))
|
|
153
|
+
.map((l) => l.slice(1))
|
|
154
|
+
.filter((l) => l.trim() !== "");
|
|
155
|
+
entries.push({ note: rel, absPath: join(dir, file), pin, head, commits, files, ...classifyNote(changedLines, text) });
|
|
156
|
+
}
|
|
157
|
+
return { head, entries, problems };
|
|
158
|
+
}
|
package/package.json
CHANGED
|
@@ -119,6 +119,25 @@ export function checkBridge(root) {
|
|
|
119
119
|
warnings.push(
|
|
120
120
|
`[spec-bridge] ${task.id} is "${task.status}" but ${task.specDir} already derives "${derived.status}" — run the spec-bridge sync skill to catch the board up.`
|
|
121
121
|
);
|
|
122
|
+
} else if (
|
|
123
|
+
// Strict-mode near-miss: the status is honest ("ok"), every checkbox is checked, and
|
|
124
|
+
// the ONLY thing between this task and Done-eligible is the analysis requirement.
|
|
125
|
+
// Without this notice the state is silent — Done is out of reach and nothing says so
|
|
126
|
+
// until someone thinks to run `state`. Warn (the warn channel), never block.
|
|
127
|
+
v === "ok" &&
|
|
128
|
+
derived.analysis?.required &&
|
|
129
|
+
derived.status !== STATUS.DONE_ELIGIBLE &&
|
|
130
|
+
derived.tasksTotal > 0 &&
|
|
131
|
+
derived.tasksDone === derived.tasksTotal &&
|
|
132
|
+
existsSync(join(root, task.specDir, "plan.md"))
|
|
133
|
+
) {
|
|
134
|
+
const a = derived.analysis;
|
|
135
|
+
warnings.push(
|
|
136
|
+
`[spec-bridge] ${task.id}: all ${derived.tasksTotal} spec tasks checked; Done blocked by strict mode: ` +
|
|
137
|
+
(!a.present
|
|
138
|
+
? "analysis.md missing — run /speckit.analyze and save its report as " + `${task.specDir}/analysis.md`
|
|
139
|
+
: `unresolved CRITICAL finding(s) in analysis.md: ${a.criticals.join(" | ")}`)
|
|
140
|
+
);
|
|
122
141
|
}
|
|
123
142
|
}
|
|
124
143
|
return { links, problems, warnings };
|