@praxisflux/gates 0.31.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.
@@ -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 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}`);
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 = parseSourcesBlock(text);
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 pin = parseFrontmatter(text)?.verified_against;
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 = parseSourcesBlock(text);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.31.0",
3
+ "version": "0.32.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": {