@praxisflux/gates 0.30.0 → 0.32.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/README.md +2 -1
- package/codebase-to-course/lib/gate-runner.mjs +5 -1
- package/grounding-wiki/gates/capsules.mjs +32 -4
- package/grounding-wiki/gates/freshness.mjs +25 -3
- package/grounding-wiki/lib/gate-runner.mjs +5 -1
- package/lib/gate-runner.mjs +5 -1
- package/package.json +1 -1
- package/scripts/run-gates.mjs +29 -10
- package/spec-bridge/lib/gate-runner.mjs +5 -1
package/README.md
CHANGED
|
@@ -73,7 +73,8 @@ codes, same failure lines.
|
|
|
73
73
|
|
|
74
74
|
## The contract
|
|
75
75
|
|
|
76
|
-
Gate names, inputs, and exit codes (0 all pass · 1 any gate failed
|
|
76
|
+
Gate names, inputs, and exit codes (0 all pass · 1 any gate failed — a gate that crashes
|
|
77
|
+
while running counts as failed · 2 usage error) are
|
|
77
78
|
praxisflux's versioned consumer interface, released and semver-bumped like everything else
|
|
78
79
|
(`docs/releasing.md`); each failure line names its fix. You can also invoke the runner
|
|
79
80
|
directly from any praxisflux checkout:
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// A gate that resolves no roots is a no-op (this isn't its kind of project). `check` problems
|
|
11
11
|
// block the stop (exit 2); optional `warn` notices are surfaced on stderr but never block (exit 0)
|
|
12
12
|
// — for freshness reminders and the like that shouldn't refuse to let the model finish.
|
|
13
|
+
// A gate that CRASHES — in resolveRoots or in check — surfaces as a blocking problem naming
|
|
14
|
+
// the gate: a crash swallowed silently would be permanent non-enforcement wearing a green exit.
|
|
13
15
|
// `ctx` is { sessionId, input }: the invoking session's identity (hook input `session_id`,
|
|
14
16
|
// falling back to $CLAUDE_CODE_SESSION_ID) plus the raw hook input — gates that scope state
|
|
15
17
|
// to its owning session (e.g. reorient run records) key off it; every existing gate ignores it.
|
|
@@ -43,7 +45,9 @@ export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
|
|
|
43
45
|
const warnings = [];
|
|
44
46
|
for (const gate of gates) {
|
|
45
47
|
let roots = [];
|
|
46
|
-
try { roots = gate.resolveRoots(start, ctx) || []; } catch {
|
|
48
|
+
try { roots = gate.resolveRoots(start, ctx) || []; } catch (e) {
|
|
49
|
+
problems.push(`[${gate.name || "gate"}] resolveRoots crashed on ${start}: ${e.message}`);
|
|
50
|
+
}
|
|
47
51
|
for (const root of roots) {
|
|
48
52
|
try { problems.push(...(gate.check(root, ctx) || [])); } catch (e) {
|
|
49
53
|
problems.push(`[${gate.name || "gate"}] crashed on ${root}: ${e.message}`);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// renders through this module — so the gate's regenerate-and-compare check and the
|
|
7
7
|
// regeneration command can never drift apart.
|
|
8
8
|
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
9
|
-
import { join, isAbsolute, basename, dirname } from "node:path";
|
|
9
|
+
import { join, isAbsolute, basename, dirname, relative, sep } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
12
|
import { parseFrontmatter, extractWikilinks } from "../lib/markdown.mjs";
|
|
@@ -23,6 +23,17 @@ function corpusPath(repoRoot, corpusDir) {
|
|
|
23
23
|
return isAbsolute(corpusDir) ? corpusDir : join(repoRoot, corpusDir);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The one canonical spelling of a corpus dir: repo-relative, forward slashes, no trailing
|
|
28
|
+
* slash — whatever spelling the caller used (absolute path, trailing slash, `./` prefix).
|
|
29
|
+
* CAPSULES.md embeds corpusDir in its header, and the freshness gate re-renders and
|
|
30
|
+
* byte-compares; both sides normalizing here makes regenerate-and-compare invariant to how
|
|
31
|
+
* the generator happened to be invoked.
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeCorpusDir(repoRoot, corpusDir = "docs/wiki") {
|
|
34
|
+
return relative(repoRoot, corpusPath(repoRoot, corpusDir)).split(sep).join("/");
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
/** Every note file in the corpus dir — INDEX.md and the generated CAPSULES.md are not notes. */
|
|
27
38
|
export function noteFiles(dir) {
|
|
28
39
|
return readdirSync(dir)
|
|
@@ -55,6 +66,7 @@ export function indexLineTarget(line) {
|
|
|
55
66
|
* corpus state (and commit) → byte-identical output. Read-only — scripts/capsules.mjs writes.
|
|
56
67
|
*/
|
|
57
68
|
export function renderCapsules(repoRoot, corpusDir = "docs/wiki", { commit } = {}) {
|
|
69
|
+
corpusDir = normalizeCorpusDir(repoRoot, corpusDir); // header embeds it — one spelling only
|
|
58
70
|
const dir = corpusPath(repoRoot, corpusDir);
|
|
59
71
|
const indexPath = join(dir, "INDEX.md");
|
|
60
72
|
if (!existsSync(indexPath)) throw new Error(`not a corpus: ${indexPath} missing`);
|
|
@@ -107,6 +119,7 @@ export function renderCapsules(repoRoot, corpusDir = "docs/wiki", { commit } = {
|
|
|
107
119
|
* validateFreshness already fails them as not-a-corpus-note.
|
|
108
120
|
*/
|
|
109
121
|
export function checkCapsuleTier(repoRoot, corpusDir = "docs/wiki") {
|
|
122
|
+
corpusDir = normalizeCorpusDir(repoRoot, corpusDir); // match renderCapsules' spelling
|
|
110
123
|
const dir = corpusPath(repoRoot, corpusDir);
|
|
111
124
|
if (!existsSync(join(dir, "INDEX.md"))) return { adopted: false, fails: [], warns: [] };
|
|
112
125
|
const adopted = existsSync(join(dir, "CAPSULES.md"));
|
|
@@ -142,10 +155,25 @@ export function checkCapsuleTier(repoRoot, corpusDir = "docs/wiki") {
|
|
|
142
155
|
const regen = `node ${capsulesScript} ${repoRoot} ${corpusDir}`;
|
|
143
156
|
const existing = readFileSync(join(dir, "CAPSULES.md"), "utf8");
|
|
144
157
|
const m = /corpus commit ([0-9a-f]{40})/.exec(existing);
|
|
145
|
-
if (!m)
|
|
158
|
+
if (!m) {
|
|
146
159
|
fails.push(`${corpusDir}/CAPSULES.md: header names no corpus commit — hand-edited or foreign; regenerate: ${regen}`);
|
|
147
|
-
else
|
|
148
|
-
|
|
160
|
+
} else {
|
|
161
|
+
const rendered = renderCapsules(repoRoot, corpusDir, { commit: m[1] });
|
|
162
|
+
// Pre-normalization headers embed corpusDir as the generator was invoked (absolute
|
|
163
|
+
// path, trailing slash). If the ONLY difference is that spelling, the content is
|
|
164
|
+
// current — that deserves regeneration guidance, not a hand-edit accusation.
|
|
165
|
+
const cmd = /^( node \$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/capsules\.mjs <repo-root> )(.*)$/m.exec(existing);
|
|
166
|
+
const respelled = cmd && cmd[2] !== corpusDir
|
|
167
|
+
? existing.replace(cmd[0], () => cmd[1] + corpusDir)
|
|
168
|
+
: existing;
|
|
169
|
+
if (rendered === existing) {
|
|
170
|
+
// current, canonical header — nothing to say
|
|
171
|
+
} else if (rendered === respelled) {
|
|
172
|
+
warns.push(`${corpusDir}/CAPSULES.md: header embeds a pre-normalization corpusDir spelling (${cmd[2]}); content is current — regenerate to refresh the header: ${regen}`);
|
|
173
|
+
} else {
|
|
174
|
+
fails.push(`${corpusDir}/CAPSULES.md: stale — regenerate-and-compare mismatch (a description or INDEX.md changed after generation, or the file was hand-edited); regenerate: ${regen}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
149
177
|
}
|
|
150
178
|
|
|
151
179
|
return { adopted, fails, warns };
|
|
@@ -34,6 +34,17 @@ export function parseSourcesBlock(text) {
|
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A note's `sources:` paths — both sanctioned spellings, one truth: inline `sources: [a, b]`
|
|
39
|
+
* arrays come from lib/markdown.mjs `parseFrontmatter` (already parsed on `fm`), YAML block
|
|
40
|
+
* lists from parseSourcesBlock above. Aligning on parseFrontmatter for the inline form keeps
|
|
41
|
+
* this from growing a second frontmatter dialect.
|
|
42
|
+
*/
|
|
43
|
+
export function noteSources(text, fm = parseFrontmatter(text)) {
|
|
44
|
+
if (Array.isArray(fm?.sources)) return fm.sources;
|
|
45
|
+
return parseSourcesBlock(text);
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
/**
|
|
38
49
|
* Check every note in a corpus for staleness against the repo's git history.
|
|
39
50
|
* corpusDir is relative to repoRoot unless absolute. Returns { fails, warns, checked };
|
|
@@ -69,10 +80,14 @@ export function validateFreshness(repoRoot, corpusDir = "docs/wiki") {
|
|
|
69
80
|
continue;
|
|
70
81
|
}
|
|
71
82
|
|
|
72
|
-
const sources =
|
|
83
|
+
const sources = noteSources(text, fm);
|
|
73
84
|
if (sources.length === 0) {
|
|
74
85
|
warns.push(`${rel}: no sources listed — staleness is unverifiable`);
|
|
75
86
|
} else {
|
|
87
|
+
// A source path absent from the working tree is vanished proof, not freshness: git log
|
|
88
|
+
// over a nonexistent pathspec is silently empty, which would report FRESH forever.
|
|
89
|
+
for (const s of sources.filter((s) => !existsSync(join(repoRoot, s))))
|
|
90
|
+
fails.push(`${rel}: source missing from the working tree: ${s} — renamed, deleted, or a typo; fix the note's sources`);
|
|
76
91
|
let changed = "";
|
|
77
92
|
try {
|
|
78
93
|
changed = git(repoRoot, ["log", "--oneline", `${pin}..HEAD`, "--", ...sources]);
|
|
@@ -143,12 +158,19 @@ export function planFreshness(repoRoot, corpusDir = "docs/wiki") {
|
|
|
143
158
|
for (const file of noteFiles(dir)) {
|
|
144
159
|
const rel = `${corpusDir}/${file}`;
|
|
145
160
|
const text = readFileSync(join(dir, file), "utf8");
|
|
146
|
-
const
|
|
161
|
+
const fm = parseFrontmatter(text);
|
|
162
|
+
const pin = fm?.verified_against;
|
|
147
163
|
if (!pin) { problems.push(`${rel}: no verified_against pin`); continue; }
|
|
148
164
|
try { git(repoRoot, ["cat-file", "-e", `${pin}^{commit}`]); }
|
|
149
165
|
catch { problems.push(`${rel}: pin ${pin} is not a known commit`); continue; }
|
|
150
|
-
const sources =
|
|
166
|
+
const sources = noteSources(text, fm);
|
|
151
167
|
if (!sources.length) continue; // unverifiable — the freshness gate already warns
|
|
168
|
+
const missing = sources.filter((s) => !existsSync(join(repoRoot, s)));
|
|
169
|
+
if (missing.length) {
|
|
170
|
+
// The freshness gate blocks on these; plan doesn't paper over them with a re-pin.
|
|
171
|
+
problems.push(`${rel}: source missing from the working tree: ${missing.join(", ")} — fix the note's sources first`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
152
174
|
|
|
153
175
|
const log = git(repoRoot, ["log", "--oneline", `${pin}..HEAD`, "--", ...sources]);
|
|
154
176
|
if (!log) continue; // fresh
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// A gate that resolves no roots is a no-op (this isn't its kind of project). `check` problems
|
|
11
11
|
// block the stop (exit 2); optional `warn` notices are surfaced on stderr but never block (exit 0)
|
|
12
12
|
// — for freshness reminders and the like that shouldn't refuse to let the model finish.
|
|
13
|
+
// A gate that CRASHES — in resolveRoots or in check — surfaces as a blocking problem naming
|
|
14
|
+
// the gate: a crash swallowed silently would be permanent non-enforcement wearing a green exit.
|
|
13
15
|
// `ctx` is { sessionId, input }: the invoking session's identity (hook input `session_id`,
|
|
14
16
|
// falling back to $CLAUDE_CODE_SESSION_ID) plus the raw hook input — gates that scope state
|
|
15
17
|
// to its owning session (e.g. reorient run records) key off it; every existing gate ignores it.
|
|
@@ -43,7 +45,9 @@ export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
|
|
|
43
45
|
const warnings = [];
|
|
44
46
|
for (const gate of gates) {
|
|
45
47
|
let roots = [];
|
|
46
|
-
try { roots = gate.resolveRoots(start, ctx) || []; } catch {
|
|
48
|
+
try { roots = gate.resolveRoots(start, ctx) || []; } catch (e) {
|
|
49
|
+
problems.push(`[${gate.name || "gate"}] resolveRoots crashed on ${start}: ${e.message}`);
|
|
50
|
+
}
|
|
47
51
|
for (const root of roots) {
|
|
48
52
|
try { problems.push(...(gate.check(root, ctx) || [])); } catch (e) {
|
|
49
53
|
problems.push(`[${gate.name || "gate"}] crashed on ${root}: ${e.message}`);
|
package/lib/gate-runner.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// A gate that resolves no roots is a no-op (this isn't its kind of project). `check` problems
|
|
11
11
|
// block the stop (exit 2); optional `warn` notices are surfaced on stderr but never block (exit 0)
|
|
12
12
|
// — for freshness reminders and the like that shouldn't refuse to let the model finish.
|
|
13
|
+
// A gate that CRASHES — in resolveRoots or in check — surfaces as a blocking problem naming
|
|
14
|
+
// the gate: a crash swallowed silently would be permanent non-enforcement wearing a green exit.
|
|
13
15
|
// `ctx` is { sessionId, input }: the invoking session's identity (hook input `session_id`,
|
|
14
16
|
// falling back to $CLAUDE_CODE_SESSION_ID) plus the raw hook input — gates that scope state
|
|
15
17
|
// to its owning session (e.g. reorient run records) key off it; every existing gate ignores it.
|
|
@@ -43,7 +45,9 @@ export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
|
|
|
43
45
|
const warnings = [];
|
|
44
46
|
for (const gate of gates) {
|
|
45
47
|
let roots = [];
|
|
46
|
-
try { roots = gate.resolveRoots(start, ctx) || []; } catch {
|
|
48
|
+
try { roots = gate.resolveRoots(start, ctx) || []; } catch (e) {
|
|
49
|
+
problems.push(`[${gate.name || "gate"}] resolveRoots crashed on ${start}: ${e.message}`);
|
|
50
|
+
}
|
|
47
51
|
for (const root of roots) {
|
|
48
52
|
try { problems.push(...(gate.check(root, ctx) || [])); } catch (e) {
|
|
49
53
|
problems.push(`[${gate.name || "gate"}] crashed on ${root}: ${e.message}`);
|
package/package.json
CHANGED
package/scripts/run-gates.mjs
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// [--wiki-dir docs/wiki] [--course-dir docs/course]
|
|
8
8
|
//
|
|
9
9
|
// Gate names, options, and exit codes are praxisflux's versioned consumer contract
|
|
10
|
-
// (docs/consuming-gates.md): exit 0 when every gate passes, 1 when any gate fails
|
|
11
|
-
// usage error (unknown gate, missing --gates).
|
|
12
|
-
// file ships as the @praxisflux/gates npm bin
|
|
10
|
+
// (docs/consuming-gates.md): exit 0 when every gate passes, 1 when any gate fails — including
|
|
11
|
+
// a gate that crashes while running — and 2 on a usage error (unknown gate, missing --gates).
|
|
12
|
+
// Each failure line names its fix. This same file ships as the @praxisflux/gates npm bin
|
|
13
|
+
// (scripts/build-npm.mjs carves the package).
|
|
13
14
|
import { join, resolve } from "node:path";
|
|
14
15
|
import { spawnSync } from "node:child_process";
|
|
15
16
|
import { runAsCli } from "../lib/cli.mjs";
|
|
@@ -46,14 +47,29 @@ export const GATES = {
|
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
export function
|
|
50
|
+
/** Validate a requested gate list. Throws on an unknown or empty list — a misspelled gate
|
|
51
|
+
* must fail the build loudly, never skip silently. These throws are the ONLY usage errors
|
|
52
|
+
* (exit 2); anything that goes wrong after validation is a gate result, not usage. */
|
|
53
|
+
export function validateGateNames(names) {
|
|
53
54
|
if (!names.length) throw new Error(`no gates requested — pass --gates with any of: ${Object.keys(GATES).join(", ")}`);
|
|
54
55
|
for (const n of names)
|
|
55
56
|
if (!GATES[n]) throw new Error(`unknown gate "${n}" — valid gates: ${Object.keys(GATES).join(", ")}`);
|
|
56
|
-
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Run the named gates against opts.root. Returns [{gate, problems, warnings, ok}].
|
|
60
|
+
* Throws on an unknown or empty gate list (validateGateNames). An exception thrown WHILE a
|
|
61
|
+
* gate runs is a gate failure, never a usage error: it becomes a problem on that gate's
|
|
62
|
+
* result naming the gate and the error, so the CLI exits 1 — the exit code CI consumers
|
|
63
|
+
* branch on for "a gate did not pass" (docs/consuming-gates.md). */
|
|
64
|
+
export function runGates(names, opts) {
|
|
65
|
+
validateGateNames(names);
|
|
66
|
+
return names.map((gate) => {
|
|
67
|
+
try {
|
|
68
|
+
return { gate, ...GATES[gate](opts) };
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { gate, problems: [`gate "${gate}" crashed while running: ${e.message}`], warnings: [], ok: "" };
|
|
71
|
+
}
|
|
72
|
+
});
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
if (runAsCli(import.meta.url)) {
|
|
@@ -69,13 +85,16 @@ if (runAsCli(import.meta.url)) {
|
|
|
69
85
|
};
|
|
70
86
|
const names = opt("gates", "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
71
87
|
|
|
72
|
-
|
|
88
|
+
// Usage validation alone lives inside the exit-2 try/catch; gate execution happens
|
|
89
|
+
// outside it, so an exception thrown while a gate runs can never masquerade as usage —
|
|
90
|
+
// runGates converts it into that gate's failure result and the run exits 1.
|
|
73
91
|
try {
|
|
74
|
-
|
|
92
|
+
validateGateNames(names);
|
|
75
93
|
} catch (e) {
|
|
76
94
|
console.error(`usage error: ${e.message}`);
|
|
77
95
|
process.exit(2);
|
|
78
96
|
}
|
|
97
|
+
const results = runGates(names, opts);
|
|
79
98
|
let failed = 0;
|
|
80
99
|
for (const { gate, problems, warnings, ok } of results) {
|
|
81
100
|
for (const w of warnings) console.log(`[${gate}] warn: ${w}`);
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// A gate that resolves no roots is a no-op (this isn't its kind of project). `check` problems
|
|
11
11
|
// block the stop (exit 2); optional `warn` notices are surfaced on stderr but never block (exit 0)
|
|
12
12
|
// — for freshness reminders and the like that shouldn't refuse to let the model finish.
|
|
13
|
+
// A gate that CRASHES — in resolveRoots or in check — surfaces as a blocking problem naming
|
|
14
|
+
// the gate: a crash swallowed silently would be permanent non-enforcement wearing a green exit.
|
|
13
15
|
// `ctx` is { sessionId, input }: the invoking session's identity (hook input `session_id`,
|
|
14
16
|
// falling back to $CLAUDE_CODE_SESSION_ID) plus the raw hook input — gates that scope state
|
|
15
17
|
// to its owning session (e.g. reorient run records) key off it; every existing gate ignores it.
|
|
@@ -43,7 +45,9 @@ export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
|
|
|
43
45
|
const warnings = [];
|
|
44
46
|
for (const gate of gates) {
|
|
45
47
|
let roots = [];
|
|
46
|
-
try { roots = gate.resolveRoots(start, ctx) || []; } catch {
|
|
48
|
+
try { roots = gate.resolveRoots(start, ctx) || []; } catch (e) {
|
|
49
|
+
problems.push(`[${gate.name || "gate"}] resolveRoots crashed on ${start}: ${e.message}`);
|
|
50
|
+
}
|
|
47
51
|
for (const root of roots) {
|
|
48
52
|
try { problems.push(...(gate.check(root, ctx) || [])); } catch (e) {
|
|
49
53
|
problems.push(`[${gate.name || "gate"}] crashed on ${root}: ${e.message}`);
|