@praxisflux/gates 0.14.0 → 0.16.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.
@@ -0,0 +1,152 @@
1
+ // capsules.mjs — the capsule tier of a code-grounded corpus (docs/corpus-spec.md v2,
2
+ // "The capsule tier and CAPSULES.md" + "Note size budget and summary-style splits").
3
+ //
4
+ // Read-only (gates/ contract): renderCapsules computes what CAPSULES.md must contain,
5
+ // checkCapsuleTier enforces the v2 budgets. The one writer is scripts/capsules.mjs, which
6
+ // renders through this module — so the gate's regenerate-and-compare check and the
7
+ // regeneration command can never drift apart.
8
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
9
+ import { join, isAbsolute, basename, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { execFileSync } from "node:child_process";
12
+ import { parseFrontmatter, extractWikilinks } from "../lib/markdown.mjs";
13
+
14
+ /** v2 budgets — producers' write-time caps, enforced once a corpus adopts the capsule tier. */
15
+ export const CAPSULE_BUDGET = 500; // chars, a note's description:
16
+ export const NOTE_BODY_BUDGET = 8000; // chars, everything below the frontmatter
17
+
18
+ function git(repoRoot, args) {
19
+ return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
20
+ }
21
+
22
+ function corpusPath(repoRoot, corpusDir) {
23
+ return isAbsolute(corpusDir) ? corpusDir : join(repoRoot, corpusDir);
24
+ }
25
+
26
+ /** Every note file in the corpus dir — INDEX.md and the generated CAPSULES.md are not notes. */
27
+ export function noteFiles(dir) {
28
+ return readdirSync(dir)
29
+ .filter((f) => f.endsWith(".md") && f !== "INDEX.md" && f !== "CAPSULES.md")
30
+ .sort();
31
+ }
32
+
33
+ /** The note body: everything below the frontmatter block (the text v2's 8,000-char cap measures). */
34
+ export function noteBody(text) {
35
+ const t = (text || "").replace(/^/, "").replace(/^\s+/, "");
36
+ const m = /^---\s*\n[\s\S]*?\n---[^\S\n]*\n?/.exec(t);
37
+ return m ? t.slice(m[0].length) : t;
38
+ }
39
+
40
+ /** The first sibling-note reference on an INDEX.md list line: a [[wikilink]] target or a
41
+ * markdown link to <name>.md. Null for prose/heading lines. */
42
+ export function indexLineTarget(line) {
43
+ if (!/^\s*[-*]\s/.test(line)) return null;
44
+ const wiki = extractWikilinks(line);
45
+ if (wiki.length) return wiki[0];
46
+ const md = /\[[^\]]*\]\(([^)#\s]+)\.md\)/.exec(line);
47
+ return md ? basename(md[1]) : null;
48
+ }
49
+
50
+ /**
51
+ * Render the CAPSULES.md content for a corpus: a header naming the generator and the corpus
52
+ * commit it was generated at, then — in INDEX.md order — each note's INDEX line followed by
53
+ * its capsule (its `description:`). Notes on disk but missing from INDEX.md follow, sorted by
54
+ * name, so the rollup always covers the whole corpus. Deterministic and idempotent: same
55
+ * corpus state (and commit) → byte-identical output. Read-only — scripts/capsules.mjs writes.
56
+ */
57
+ export function renderCapsules(repoRoot, corpusDir = "docs/wiki", { commit } = {}) {
58
+ const dir = corpusPath(repoRoot, corpusDir);
59
+ const indexPath = join(dir, "INDEX.md");
60
+ if (!existsSync(indexPath)) throw new Error(`not a corpus: ${indexPath} missing`);
61
+ const at = commit || git(repoRoot, ["rev-parse", "HEAD"]);
62
+
63
+ const capsules = new Map(); // note name -> description (its capsule)
64
+ for (const file of noteFiles(dir)) {
65
+ const fm = parseFrontmatter(readFileSync(join(dir, file), "utf8"));
66
+ if (fm) capsules.set(basename(file, ".md"), String(fm.description ?? ""));
67
+ }
68
+
69
+ const entries = [];
70
+ const seen = new Set();
71
+ for (const line of readFileSync(indexPath, "utf8").split("\n")) {
72
+ const name = indexLineTarget(line);
73
+ if (!name || !capsules.has(name) || seen.has(name)) continue; // reserved names stay INDEX-only
74
+ seen.add(name);
75
+ entries.push(`${line.trimEnd()}\n ${capsules.get(name)}`);
76
+ }
77
+ for (const name of [...capsules.keys()].sort()) {
78
+ if (!seen.has(name)) entries.push(`- [[${name}]] (not in INDEX.md)\n ${capsules.get(name)}`);
79
+ }
80
+
81
+ const header = [
82
+ "# Capsules — the corpus rollup",
83
+ "",
84
+ `Generated by grounding-wiki \`scripts/capsules.mjs\` at corpus commit ${at}.`,
85
+ 'Derived state (docs/corpus-spec.md, "The capsule tier and CAPSULES.md") — do not edit by',
86
+ "hand; regenerate whenever any note's `description:` changes:",
87
+ "",
88
+ // eslint-disable-next-line no-template-curly-in-string
89
+ " node ${CLAUDE_PLUGIN_ROOT}/scripts/capsules.mjs <repo-root> " + corpusDir,
90
+ ].join("\n");
91
+ return `${header}\n\n${entries.join("\n\n")}\n`;
92
+ }
93
+
94
+ /**
95
+ * Enforce the v2 token-economy budgets over a corpus. Adoption is signalled by CAPSULES.md
96
+ * existing (the artifact v2 defines — no new contract surface):
97
+ *
98
+ * - Adopted: description over 500 chars → FAIL; body over 8,000 chars → FAIL pointing at the
99
+ * summary-style split rule, downgraded to WARN when the note's frontmatter carries
100
+ * `size_budget_exempt: <reason>`; CAPSULES.md stale or hand-edited (regenerate-and-compare
101
+ * against this module's render, at the commit the header names) → FAIL naming the
102
+ * regeneration command.
103
+ * - Unadopted (no CAPSULES.md): the same budget checks surface as WARN-only notices —
104
+ * visibility during adoption, nothing blocks.
105
+ *
106
+ * Returns { adopted, fails, warns }. Notes without frontmatter are skipped here —
107
+ * validateFreshness already fails them as not-a-corpus-note.
108
+ */
109
+ export function checkCapsuleTier(repoRoot, corpusDir = "docs/wiki") {
110
+ const dir = corpusPath(repoRoot, corpusDir);
111
+ if (!existsSync(join(dir, "INDEX.md"))) return { adopted: false, fails: [], warns: [] };
112
+ const adopted = existsSync(join(dir, "CAPSULES.md"));
113
+ const fails = [];
114
+ const warns = [];
115
+ const over = (msg, { downgrade = false } = {}) => {
116
+ if (adopted && !downgrade) fails.push(msg);
117
+ else if (adopted) warns.push(msg);
118
+ else warns.push(`v2 budget (warn-only until this corpus adopts CAPSULES.md): ${msg}`);
119
+ };
120
+
121
+ for (const file of noteFiles(dir)) {
122
+ const rel = `${corpusDir}/${file}`;
123
+ const text = readFileSync(join(dir, file), "utf8");
124
+ const fm = parseFrontmatter(text);
125
+ if (!fm) continue;
126
+
127
+ const desc = String(fm.description ?? "");
128
+ if (desc.length > CAPSULE_BUDGET)
129
+ over(`${rel}: description capsule is ${desc.length} chars — ${desc.length - CAPSULE_BUDGET} over the ${CAPSULE_BUDGET}-char capsule budget; rewrite it for routing (docs/corpus-spec.md, "The capsule tier and CAPSULES.md")`);
130
+
131
+ const body = noteBody(text);
132
+ if (body.length > NOTE_BODY_BUDGET) {
133
+ if (fm.size_budget_exempt)
134
+ over(`${rel}: body is ${body.length} chars, over the ${NOTE_BODY_BUDGET}-char note budget — size_budget_exempt: ${fm.size_budget_exempt}`, { downgrade: true });
135
+ else
136
+ over(`${rel}: body is ${body.length} chars — ${body.length - NOTE_BODY_BUDGET} over the ${NOTE_BODY_BUDGET}-char note budget; split summary-style, or add size_budget_exempt: <reason> if nothing is splittable (docs/corpus-spec.md, "Note size budget and summary-style splits")`);
137
+ }
138
+ }
139
+
140
+ if (adopted) {
141
+ const capsulesScript = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "capsules.mjs");
142
+ const regen = `node ${capsulesScript} ${repoRoot} ${corpusDir}`;
143
+ const existing = readFileSync(join(dir, "CAPSULES.md"), "utf8");
144
+ const m = /corpus commit ([0-9a-f]{40})/.exec(existing);
145
+ if (!m)
146
+ fails.push(`${corpusDir}/CAPSULES.md: header names no corpus commit — hand-edited or foreign; regenerate: ${regen}`);
147
+ else if (renderCapsules(repoRoot, corpusDir, { commit: m[1] }) !== existing)
148
+ 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}`);
149
+ }
150
+
151
+ return { adopted, fails, warns };
152
+ }
@@ -2,11 +2,14 @@
2
2
  //
3
3
  // A note is STALE when any path in its `sources:` frontmatter changed after its
4
4
  // `verified_against:` pin. Verified with git plumbing against the repo the corpus lives in.
5
+ // Also enforces the v2 token-economy budgets (capsule tier + note size) via
6
+ // ./capsules.mjs — hard once the corpus adopts CAPSULES.md, warn-only before.
5
7
  // Never writes to disk (gates/ contract).
6
- import { readdirSync, readFileSync, existsSync } from "node:fs";
8
+ import { readFileSync, existsSync } from "node:fs";
7
9
  import { join, isAbsolute, basename } from "node:path";
8
10
  import { execFileSync } from "node:child_process";
9
11
  import { parseFrontmatter, stripCode, extractWikilinks } from "../lib/markdown.mjs";
12
+ import { checkCapsuleTier, noteFiles } from "./capsules.mjs";
10
13
 
11
14
  function git(repoRoot, args) {
12
15
  return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
@@ -45,7 +48,7 @@ export function validateFreshness(repoRoot, corpusDir = "docs/wiki") {
45
48
  return { fails: [`not a corpus: ${join(dir, "INDEX.md")} missing`], warns, checked: 0 };
46
49
  }
47
50
 
48
- const files = readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "INDEX.md").sort();
51
+ const files = noteFiles(dir); // INDEX.md and the generated CAPSULES.md are not notes
49
52
  const names = new Set(files.map((f) => basename(f, ".md")));
50
53
  let checked = 0;
51
54
 
@@ -88,6 +91,12 @@ export function validateFreshness(repoRoot, corpusDir = "docs/wiki") {
88
91
  }
89
92
  }
90
93
 
94
+ // v2 token-economy budgets (capsule ≤500 chars, body ≤8,000 chars, CAPSULES.md currency):
95
+ // FAILs once the corpus has adopted CAPSULES.md, WARN-only notices before.
96
+ const tier = checkCapsuleTier(repoRoot, corpusDir);
97
+ fails.push(...tier.fails);
98
+ warns.push(...tier.warns);
99
+
91
100
  return { fails, warns, checked };
92
101
  }
93
102
 
@@ -131,7 +140,7 @@ export function planFreshness(repoRoot, corpusDir = "docs/wiki") {
131
140
  if (!existsSync(join(dir, "INDEX.md"))) return { head: "", entries, problems: [`not a corpus: ${join(dir, "INDEX.md")} missing`] };
132
141
  const head = git(repoRoot, ["rev-parse", "HEAD"]);
133
142
 
134
- for (const file of readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "INDEX.md").sort()) {
143
+ for (const file of noteFiles(dir)) {
135
144
  const rel = `${corpusDir}/${file}`;
136
145
  const text = readFileSync(join(dir, file), "utf8");
137
146
  const pin = parseFrontmatter(text)?.verified_against;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {