@praxisflux/gates 0.5.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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/codebase-to-course/gates/cli.mjs +20 -0
  4. package/codebase-to-course/gates/course.mjs +58 -0
  5. package/codebase-to-course/lib/README.md +8 -0
  6. package/codebase-to-course/lib/cli.mjs +23 -0
  7. package/codebase-to-course/lib/dates.mjs +11 -0
  8. package/codebase-to-course/lib/gate-runner.mjs +69 -0
  9. package/codebase-to-course/lib/handoff.mjs +75 -0
  10. package/codebase-to-course/lib/html/base.html +129 -0
  11. package/codebase-to-course/lib/installer.mjs +52 -0
  12. package/codebase-to-course/lib/lifecycle.mjs +67 -0
  13. package/codebase-to-course/lib/markdown.mjs +81 -0
  14. package/codebase-to-course/lib/project-root.mjs +52 -0
  15. package/codebase-to-course/lib/selfcontained.mjs +37 -0
  16. package/codebase-to-course/lib/spec-derive.mjs +114 -0
  17. package/codebase-to-course/lib/template.mjs +11 -0
  18. package/codebase-to-course/lib/toolkit/README.md +45 -0
  19. package/codebase-to-course/lib/toolkit/code-translation.md +50 -0
  20. package/codebase-to-course/lib/toolkit/diagrams.md +57 -0
  21. package/codebase-to-course/lib/toolkit/pedagogy.md +39 -0
  22. package/codebase-to-course/lib/toolkit/quiz-patterns.md +65 -0
  23. package/codebase-to-course/lib/toolkit/svg-diagrams.md +59 -0
  24. package/codebase-to-course/lib/toolkit/tooltip.md +83 -0
  25. package/codebase-to-course/skills/codebase-to-course/references/validate.mjs +301 -0
  26. package/grounding-wiki/gates/cli.mjs +26 -0
  27. package/grounding-wiki/gates/freshness.mjs +92 -0
  28. package/grounding-wiki/lib/README.md +8 -0
  29. package/grounding-wiki/lib/cli.mjs +23 -0
  30. package/grounding-wiki/lib/dates.mjs +11 -0
  31. package/grounding-wiki/lib/gate-runner.mjs +69 -0
  32. package/grounding-wiki/lib/handoff.mjs +75 -0
  33. package/grounding-wiki/lib/html/base.html +129 -0
  34. package/grounding-wiki/lib/installer.mjs +52 -0
  35. package/grounding-wiki/lib/lifecycle.mjs +67 -0
  36. package/grounding-wiki/lib/markdown.mjs +81 -0
  37. package/grounding-wiki/lib/project-root.mjs +52 -0
  38. package/grounding-wiki/lib/selfcontained.mjs +37 -0
  39. package/grounding-wiki/lib/spec-derive.mjs +114 -0
  40. package/grounding-wiki/lib/template.mjs +11 -0
  41. package/grounding-wiki/lib/toolkit/README.md +45 -0
  42. package/grounding-wiki/lib/toolkit/code-translation.md +50 -0
  43. package/grounding-wiki/lib/toolkit/diagrams.md +57 -0
  44. package/grounding-wiki/lib/toolkit/pedagogy.md +39 -0
  45. package/grounding-wiki/lib/toolkit/quiz-patterns.md +65 -0
  46. package/grounding-wiki/lib/toolkit/svg-diagrams.md +59 -0
  47. package/grounding-wiki/lib/toolkit/tooltip.md +83 -0
  48. package/lib/README.md +8 -0
  49. package/lib/cli.mjs +23 -0
  50. package/lib/dates.mjs +11 -0
  51. package/lib/gate-runner.mjs +69 -0
  52. package/lib/handoff.mjs +75 -0
  53. package/lib/html/base.html +129 -0
  54. package/lib/installer.mjs +52 -0
  55. package/lib/lifecycle.mjs +67 -0
  56. package/lib/markdown.mjs +81 -0
  57. package/lib/project-root.mjs +52 -0
  58. package/lib/selfcontained.mjs +37 -0
  59. package/lib/spec-derive.mjs +114 -0
  60. package/lib/template.mjs +11 -0
  61. package/lib/toolkit/README.md +45 -0
  62. package/lib/toolkit/code-translation.md +50 -0
  63. package/lib/toolkit/diagrams.md +57 -0
  64. package/lib/toolkit/pedagogy.md +39 -0
  65. package/lib/toolkit/quiz-patterns.md +65 -0
  66. package/lib/toolkit/svg-diagrams.md +59 -0
  67. package/lib/toolkit/tooltip.md +83 -0
  68. package/package.json +17 -0
  69. package/scripts/run-gates.mjs +91 -0
  70. package/spec-bridge/gates/bridge.mjs +130 -0
  71. package/spec-bridge/gates/cli.mjs +37 -0
  72. package/spec-bridge/lib/README.md +8 -0
  73. package/spec-bridge/lib/cli.mjs +23 -0
  74. package/spec-bridge/lib/dates.mjs +11 -0
  75. package/spec-bridge/lib/gate-runner.mjs +69 -0
  76. package/spec-bridge/lib/handoff.mjs +75 -0
  77. package/spec-bridge/lib/html/base.html +129 -0
  78. package/spec-bridge/lib/installer.mjs +52 -0
  79. package/spec-bridge/lib/lifecycle.mjs +67 -0
  80. package/spec-bridge/lib/markdown.mjs +81 -0
  81. package/spec-bridge/lib/project-root.mjs +52 -0
  82. package/spec-bridge/lib/selfcontained.mjs +37 -0
  83. package/spec-bridge/lib/spec-derive.mjs +114 -0
  84. package/spec-bridge/lib/template.mjs +11 -0
  85. package/spec-bridge/lib/toolkit/README.md +45 -0
  86. package/spec-bridge/lib/toolkit/code-translation.md +50 -0
  87. package/spec-bridge/lib/toolkit/diagrams.md +57 -0
  88. package/spec-bridge/lib/toolkit/pedagogy.md +39 -0
  89. package/spec-bridge/lib/toolkit/quiz-patterns.md +65 -0
  90. package/spec-bridge/lib/toolkit/svg-diagrams.md +59 -0
  91. package/spec-bridge/lib/toolkit/tooltip.md +83 -0
@@ -0,0 +1,83 @@
1
+ # Jargon glossary tooltip
2
+
3
+ Gloss every technical term where it appears, so the reader never has to leave the page (or
4
+ ask) to decode jargon. The definition rides a `data-tip` attribute; a popover shows it on
5
+ hover (desktop) or tap (touch). This is the single most valuable accessibility feature for
6
+ non-technical audiences — codebase-to-course's content rules call it "No Term Left Behind."
7
+
8
+ **Graceful degradation:** if this module isn't available, gloss terms in parentheses on
9
+ first use — `the manifest (the file that declares what the extension may do)` — and move on.
10
+
11
+ ## The contract
12
+
13
+ Any element — HTML *or* SVG — may carry a `data-tip` attribute with a 1–2 sentence,
14
+ everyday-language definition:
15
+
16
+ ```html
17
+ <span data-tip="A service worker is a background script that runs independently of the
18
+ page — a behind-the-scenes assistant that's on even when you're not looking.">service
19
+ worker</span>
20
+ ```
21
+
22
+ - Give the reader a **visible affordance** so glossed terms are discoverable: a dashed
23
+ underline, or a small `?` badge (educate's decks use the badge — see the deck template's
24
+ sample slide).
25
+ - Keep definitions to 1–2 sentences, everyday language; a metaphor helps.
26
+ - Gloss on **first use per screen/slide**, not every repetition.
27
+
28
+ ## The snippet
29
+
30
+ Self-contained, and written against the **shared token names** (`--ink`, `--bg`) so it
31
+ inherits the consuming plugin's palette in both light and dark themes (the bubble is
32
+ inverted-contrast: dark-on-light pages get a dark bubble and vice versa). Popover text size
33
+ follows `--tip-size` (default `1rem`) — decks set it larger. The popover is appended to
34
+ `document.body` with `position: fixed`, so it is **never clipped** by an ancestor's
35
+ `overflow: hidden`, and it hides when printing.
36
+
37
+ Consumers embed the marked regions verbatim; `scripts/sync-shared.mjs` re-stamps them (drift
38
+ fails the test suite). Hand-edit only here.
39
+
40
+ ```css
41
+ /* praxis:tooltip-css:start */
42
+ [data-tip]{cursor:help}
43
+ .tip-pop{position:fixed;z-index:1000;max-width:min(420px,80vw);background:var(--ink);color:var(--bg);
44
+ font-size:var(--tip-size,1rem);line-height:1.4;padding:.65em .9em;border-radius:10px;
45
+ pointer-events:none;box-shadow:0 8px 24px rgba(0,0,0,.28);opacity:0;transform:translateY(4px);
46
+ transition:opacity .12s ease,transform .12s ease}
47
+ .tip-pop.show{opacity:1;transform:translateY(0)}
48
+ @media print{ .tip-pop{display:none} }
49
+ /* praxis:tooltip-css:end */
50
+ ```
51
+
52
+ ```js
53
+ // praxis:tooltip-js:start
54
+ // One shared popover: follows the cursor on hover, toggles on click/tap (touch), clamps to
55
+ // the viewport, and closes on tap-away. Works for HTML and SVG [data-tip] elements alike.
56
+ (function(){
57
+ const pop=document.createElement('div'); pop.className='tip-pop'; document.body.appendChild(pop);
58
+ let shownFor=null;
59
+ const place=(x,y)=>{ const pad=14, r=pop.getBoundingClientRect();
60
+ let px=x+pad, py=y+pad;
61
+ if(px+r.width>innerWidth-8) px=Math.max(8,x-r.width-pad);
62
+ if(py+r.height>innerHeight-8) py=Math.max(8,y-r.height-pad);
63
+ pop.style.left=px+'px'; pop.style.top=py+'px'; };
64
+ const show=(t,x,y)=>{ pop.textContent=t.getAttribute('data-tip'); pop.classList.add('show'); shownFor=t; place(x,y); };
65
+ const hide=()=>{ pop.classList.remove('show'); shownFor=null; };
66
+ document.addEventListener('mouseover',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]'); if(t) show(t,e.clientX,e.clientY); });
67
+ document.addEventListener('mousemove',e=>{ if(shownFor) place(e.clientX,e.clientY); });
68
+ document.addEventListener('mouseout',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]'); if(t && !t.contains(e.relatedTarget)) hide(); });
69
+ document.addEventListener('click',e=>{ const t=e.target.closest&&e.target.closest('[data-tip]');
70
+ if(t){ e.stopPropagation(); (shownFor===t)?hide():show(t,e.clientX,e.clientY); } else hide(); });
71
+ })();
72
+ // praxis:tooltip-js:end
73
+ ```
74
+
75
+ ## Where each plugin gets it
76
+
77
+ - **educate decks** — the deck template carries the stamped regions; nothing to author.
78
+ - **research briefings** — copy both regions into the page (alongside the base.html blocks).
79
+ - **codebase-to-course pages** — courses do NOT use this snippet: the prebuilt assets
80
+ (`references/styles.css` + `references/main.js`, copied verbatim into every course) ship
81
+ the same pattern as a course-native engine with the `.term` + `data-definition` contract
82
+ and a dashed-underline affordance. Author course HTML against that contract and never
83
+ inline tooltip CSS/JS there (see the skill's `interactive-elements.md`).
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ /* codebase-to-course chrome v2 — inline translation engine (comments-on-top)
3
+ *
4
+ * validate.mjs — translation-block validator.
5
+ * Copy this file verbatim into the course output directory; build.sh runs it
6
+ * before assembling index.html. It is self-contained (Node stdlib only).
7
+ *
8
+ * Two contracts are enforced on every .translation-block:
9
+ *
10
+ * 1. PAIRING — exactly one .tl note per .code-line. The inline engine pairs
11
+ * them positionally, so one missing note silently misaligns every note
12
+ * after it. A count mismatch is always an authoring bug.
13
+ *
14
+ * 2. BALANCE — the block's code, read as text (tags stripped, HTML entities
15
+ * decoded, string literals and comments removed), must have balanced
16
+ * ()/[]/{} brackets. A code excerpt that stops mid-structure reads as
17
+ * broken to anyone who knows code. Trim excerpts from WITHIN instead:
18
+ * replace the skipped middle with a `// …` comment code-line (with its
19
+ * own paired .tl note) and keep every closing bracket.
20
+ *
21
+ * Usage:
22
+ * node validate.mjs modules/*.html check; exit 1 on violations
23
+ * node validate.mjs --fix modules/*.html also auto-close fixable blocks
24
+ *
25
+ * --fix handles the mechanical half only: when a block's sole problem is
26
+ * unclosed brackets, it appends an elision comment code-line plus a closer
27
+ * code-line (and two paired .tl notes) so pairing and balance both hold.
28
+ * Stray closers, mismatched pairs, and unterminated strings still need a
29
+ * human/author pass — the excerpt itself is cut wrong.
30
+ *
31
+ * Opt-out: <div class="translation-block" data-validate="off"> skips a block.
32
+ * Reserve it for deliberately fragmentary pseudo-code.
33
+ */
34
+ import { readFileSync, writeFileSync, existsSync, realpathSync } from "node:fs";
35
+ import { resolve, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ /** The chrome generation this validator ships with. Bump ONLY when the
39
+ * rendering contract changes (what authored markup means on screen), and
40
+ * bump the stamp in every chrome file together — see docs/skill-patterns.md
41
+ * "Versioned course chrome". v1 is the retired side-by-side renderer, which
42
+ * predates stamping (no header = v1). */
43
+ export const CHROME_VERSION = 2;
44
+
45
+ const OPEN = { "(": ")", "[": "]", "{": "}" };
46
+ const CLOSE = { ")": "(", "]": "[", "}": "{" };
47
+
48
+ /* ── HTML micro-parsing (no dependencies) ─────────────────────── */
49
+
50
+ function hasClassToken(attrs, token) {
51
+ const m = /class\s*=\s*"([^"]*)"/.exec(attrs) || /class\s*=\s*'([^']*)'/.exec(attrs);
52
+ return !!m && m[1].split(/\s+/).includes(token);
53
+ }
54
+
55
+ function stripTags(html) {
56
+ return html.replace(/<[^>]*>/g, "");
57
+ }
58
+
59
+ function decodeEntities(s) {
60
+ return s
61
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n))
62
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
63
+ .replace(/&lt;/g, "<")
64
+ .replace(/&gt;/g, ">")
65
+ .replace(/&quot;/g, '"')
66
+ .replace(/&apos;/g, "'")
67
+ .replace(/&nbsp;/g, " ")
68
+ .replace(/&amp;/g, "&");
69
+ }
70
+
71
+ function lineOf(html, index) {
72
+ let n = 1;
73
+ for (let i = 0; i < index; i++) if (html[i] === "\n") n++;
74
+ return n;
75
+ }
76
+
77
+ /** All elements of `tagName` whose class list contains `classToken`,
78
+ * with nesting of the same tag handled. Returns
79
+ * { start, end, innerStart, innerEnd, inner } (end = after the close tag). */
80
+ function findElements(html, tagName, classToken) {
81
+ const out = [];
82
+ const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*)>/g;
83
+ let m, open = null, depth = 0;
84
+ while ((m = re.exec(html))) {
85
+ const [, slash, name, attrs] = m;
86
+ if (name.toLowerCase() !== tagName) continue;
87
+ const selfClosing = /\/\s*$/.test(attrs);
88
+ if (!slash) {
89
+ if (open === null) {
90
+ if (hasClassToken(attrs, classToken)) {
91
+ if (selfClosing) out.push({ start: m.index, end: re.lastIndex, innerStart: re.lastIndex, innerEnd: re.lastIndex, inner: "" });
92
+ else { open = { start: m.index, innerStart: re.lastIndex }; depth = 1; }
93
+ }
94
+ } else if (!selfClosing) depth++;
95
+ } else if (open !== null && --depth === 0) {
96
+ out.push({ start: open.start, end: re.lastIndex, innerStart: open.innerStart, innerEnd: m.index, inner: html.slice(open.innerStart, m.index) });
97
+ open = null;
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** Open tags (any element) whose class list contains `classToken` — exact
104
+ * token match, so `tl` does not match `tl-inline`. */
105
+ function openTagsWithClass(html, classToken) {
106
+ const out = [];
107
+ const re = /<([a-zA-Z][\w-]*)([^>]*)>/g;
108
+ let m;
109
+ while ((m = re.exec(html))) if (hasClassToken(m[2], classToken)) out.push({ start: m.index, end: re.lastIndex });
110
+ return out;
111
+ }
112
+
113
+ /** Each .translation-block open tag, its attrs, and its chunk extent (to the
114
+ * next block or end of input — blocks never nest, and the tracked classes
115
+ * only occur inside blocks, so chunk counting is exact). */
116
+ function findBlocks(html) {
117
+ const opens = [];
118
+ const re = /<div([^>]*)>/g;
119
+ let m;
120
+ while ((m = re.exec(html))) if (hasClassToken(m[1], "translation-block")) opens.push({ start: m.index, attrs: m[1] });
121
+ return opens.map((o, i) => ({ ...o, end: i + 1 < opens.length ? opens[i + 1].start : html.length, n: i + 1 }));
122
+ }
123
+
124
+ /* ── bracket balance over code text ───────────────────────────── */
125
+
126
+ /** Scan decoded code text with a small language-agnostic lexer: skips
127
+ * '…'/"…" strings (single-line), `…` template literals (multi-line),
128
+ * //-line, ⧸*…*⧸-block, and whitespace-then-# line comments, then tracks
129
+ * bracket depth. Returns { errs, unclosed, fixable }. */
130
+ export function scanBalance(text) {
131
+ const errs = [];
132
+ const stack = [];
133
+ let line = 1, state = "code", hard = false;
134
+ for (let i = 0; i < text.length; i++) {
135
+ const c = text[i], next = text[i + 1];
136
+ if (c === "\n") {
137
+ line++;
138
+ // sq/dq strings and line comments end at the newline (a string left
139
+ // open by a bad cut must not swallow the rest of the block)
140
+ if (state === "sq" || state === "dq" || state === "line") state = "code";
141
+ continue;
142
+ }
143
+ switch (state) {
144
+ case "sq": if (c === "\\") i++; else if (c === "'") state = "code"; break;
145
+ case "dq": if (c === "\\") i++; else if (c === '"') state = "code"; break;
146
+ case "bt": if (c === "\\") i++; else if (c === "`") state = "code"; break;
147
+ case "block": if (c === "*" && next === "/") { i++; state = "code"; } break;
148
+ case "line": break;
149
+ default:
150
+ if (c === "'") state = "sq";
151
+ else if (c === '"') state = "dq";
152
+ else if (c === "`") state = "bt";
153
+ else if (c === "/" && next === "/") { state = "line"; i++; }
154
+ else if (c === "/" && next === "*") { state = "block"; i++; }
155
+ // `#` comments only when followed by whitespace or `!` (shebang) —
156
+ // keeps CSS hex colors, #include, #region reading as code
157
+ else if (c === "#" && (next === undefined || next === " " || next === "\t" || next === "!")) state = "line";
158
+ else if (OPEN[c]) stack.push({ ch: c, line });
159
+ else if (CLOSE[c]) {
160
+ const top = stack.pop();
161
+ if (!top) { errs.push(`stray '${c}' on code line ${line} — nothing is open for it to close`); hard = true; }
162
+ else if (OPEN[top.ch] !== c) { errs.push(`'${top.ch}' opened on code line ${top.line} is closed by '${c}' on line ${line}`); hard = true; }
163
+ }
164
+ }
165
+ }
166
+ if (state === "bt") { errs.push("unterminated template literal — the excerpt ends mid-string"); hard = true; }
167
+ for (const o of stack) errs.push(`'${o.ch}' opened on code line ${o.line} is never closed`);
168
+ return { errs, unclosed: stack.map((o) => o.ch), fixable: !hard && stack.length > 0 };
169
+ }
170
+
171
+ /* ── the check ────────────────────────────────────────────────── */
172
+
173
+ /** Validate every translation block in an HTML string (a module file or a
174
+ * built index.html). Returns { fails, blocks, details }; `fails` is
175
+ * human-readable, `details` feeds --fix. */
176
+ export function checkTranslationBlocks(html, source = "html") {
177
+ const fails = [];
178
+ const details = [];
179
+ const blocks = findBlocks(html);
180
+ for (const b of blocks) {
181
+ const id = `${source}: translation block ${b.n} (line ${lineOf(html, b.start)})`;
182
+ if (/data-validate\s*=\s*["']off["']/.test(b.attrs)) { details.push({ ...b, skipped: true }); continue; }
183
+ const chunk = html.slice(b.start, b.end);
184
+ const codeEls = findElements(chunk, "span", "code-line");
185
+ const tls = openTagsWithClass(chunk, "tl");
186
+ if (!codeEls.length) fails.push(`${id}: no .code-line spans found`);
187
+ if (codeEls.length !== tls.length)
188
+ fails.push(`${id}: ${codeEls.length} .code-line vs ${tls.length} .tl — the inline engine pairs positionally, so every note after the first gap sits on the wrong code line. Author exactly one .tl per .code-line, in order.`);
189
+ const codeText = codeEls.map((e) => decodeEntities(stripTags(e.inner))).join("\n");
190
+ const bal = scanBalance(codeText);
191
+ for (const e of bal.errs) fails.push(`${id}: ${e}`);
192
+ details.push({ ...b, codeEls, tls, bal });
193
+ }
194
+ return { fails, blocks: blocks.length, details };
195
+ }
196
+
197
+ /* ── --fix: mechanical auto-close ─────────────────────────────── */
198
+
199
+ const ELIDE_NOTE_1 = "The middle of this code is trimmed for the lesson — the shape above is what matters.";
200
+ const ELIDE_NOTE_2 = "…and everything that was opened gets closed.";
201
+
202
+ /** Auto-close every fixable block in an HTML string. Returns
203
+ * { html, fixed, unfixable } — `unfixable` lists block numbers whose
204
+ * problems need an author (stray/mismatched closers, unterminated strings,
205
+ * pairing gaps in the middle of the block). */
206
+ export function fixTranslationBlocks(html, source = "html") {
207
+ const { details } = checkTranslationBlocks(html, source);
208
+ let fixed = 0;
209
+ const unfixable = [];
210
+ // last-to-first so earlier offsets stay valid across insertions
211
+ for (const b of [...details].reverse()) {
212
+ if (b.skipped || !b.bal || !b.bal.errs.length) continue;
213
+ if (!b.bal.fixable) { unfixable.push(b.n); continue; }
214
+ const chunk = html.slice(b.start, b.end);
215
+ const lastCode = b.codeEls[b.codeEls.length - 1];
216
+ const linesDiv = findElements(chunk, "div", "translation-lines")[0];
217
+ if (!lastCode || !linesDiv) { unfixable.push(b.n); continue; }
218
+ const closers = b.bal.unclosed.slice().reverse().map((ch) => OPEN[ch]).join("");
219
+ const codeIns =
220
+ `\n<span class="code-line"><span class="code-comment">// … rest of this excerpt elided …</span></span>` +
221
+ `\n<span class="code-line">${closers}</span>`;
222
+ const noteIns =
223
+ ` <p class="tl">${ELIDE_NOTE_1}</p>\n` +
224
+ ` <p class="tl">${ELIDE_NOTE_2}</p>\n `;
225
+ const edits = [
226
+ { at: b.start + linesDiv.innerEnd, text: noteIns },
227
+ { at: b.start + lastCode.end, text: codeIns },
228
+ ].sort((a, z) => z.at - a.at);
229
+ for (const e of edits) html = html.slice(0, e.at) + e.text + html.slice(e.at);
230
+ fixed++;
231
+ }
232
+ return { html, fixed, unfixable };
233
+ }
234
+
235
+ /* ── chrome version consistency ───────────────────────────────── */
236
+
237
+ const STAMP = /chrome v(\d+)/;
238
+
239
+ /** Check that a course dir's vendored chrome matches this validator's
240
+ * generation. Unstamped chrome is v1 (pre-inline side-by-side) — the drift
241
+ * the stamp exists to catch; mixed stamps mean a partial upgrade. */
242
+ export function checkChrome(courseDir) {
243
+ const fails = [];
244
+ for (const f of ["styles.css", "main.js"]) {
245
+ const p = join(courseDir, f);
246
+ if (!existsSync(p)) { fails.push(`${f}: missing from ${courseDir}`); continue; }
247
+ const m = STAMP.exec(readFileSync(p, "utf8").slice(0, 600));
248
+ if (!m) fails.push(`${f} has no chrome version stamp — that's v1, the retired side-by-side renderer. Upgrade: copy the plugin references/ files over this course dir and rebuild (gotchas.md "Stale Chrome").`);
249
+ else if (+m[1] !== CHROME_VERSION) fails.push(`${f} is chrome v${m[1]} but this validator is v${CHROME_VERSION} — mixed chrome. Re-copy ALL reference files together, then rebuild.`);
250
+ }
251
+ return fails;
252
+ }
253
+
254
+ /* ── CLI ──────────────────────────────────────────────────────── */
255
+
256
+ function main() {
257
+ const args = process.argv.slice(2);
258
+ const fix = args.includes("--fix");
259
+ const files = [];
260
+ let chromeDir = null;
261
+ for (let i = 0; i < args.length; i++) {
262
+ if (args[i] === "--fix") continue;
263
+ else if (args[i] === "--chrome-dir") chromeDir = args[++i];
264
+ else files.push(args[i]);
265
+ }
266
+ if (!files.length && !chromeDir) {
267
+ console.error("usage: node validate.mjs [--fix] [--chrome-dir <course-dir>] <module.html>...");
268
+ process.exit(2);
269
+ }
270
+ let allFails = chromeDir ? checkChrome(chromeDir) : [];
271
+ for (const file of files) {
272
+ let html = readFileSync(file, "utf8");
273
+ if (fix) {
274
+ const r = fixTranslationBlocks(html, file);
275
+ if (r.fixed) {
276
+ writeFileSync(file, r.html);
277
+ html = r.html;
278
+ console.log(`fixed: ${file} — auto-closed ${r.fixed} block(s)`);
279
+ }
280
+ for (const n of r.unfixable)
281
+ console.log(`unfixable: ${file} block ${n} — needs an author pass (see failures below)`);
282
+ }
283
+ allFails = allFails.concat(checkTranslationBlocks(html, file).fails);
284
+ }
285
+ if (allFails.length) {
286
+ console.log(`\nTRANSLATION-BLOCK VALIDATION FAILED (${allFails.length} issue(s)):`);
287
+ for (const f of allFails) console.log(` - ${f}`);
288
+ console.log(
289
+ "\nhint: trim excerpts from WITHIN — replace a skipped middle with a `// …` comment" +
290
+ "\ncode-line (plus its paired .tl note) and keep every closing bracket. `--fix`" +
291
+ '\nappends the closers mechanically; data-validate="off" on a block opts it out.'
292
+ );
293
+ process.exit(1);
294
+ }
295
+ console.log(`OK: translation blocks valid across ${files.length} file(s).`);
296
+ }
297
+
298
+ // Realpath both sides: import.meta.url is symlink-resolved but argv[1] is as typed, so a
299
+ // naive comparison through a symlinked path silently skips main() — never validating.
300
+ if (process.argv[1] && existsSync(process.argv[1]) &&
301
+ realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]))) main();
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // cli.mjs — command-line entry to the grounding-wiki gates.
3
+ // node cli.mjs freshness <repo-root> [corpus-dir] (corpus-dir defaults to docs/wiki)
4
+ import { validateFreshness } from "./freshness.mjs";
5
+
6
+ function report({ fails = [], warns = [], checked = 0 }, okMsg) {
7
+ for (const w of warns) console.log(`warn: ${w}`);
8
+ if (fails.length) {
9
+ console.log(`\nGATE FAILED (${fails.length} issue(s)):`);
10
+ for (const f of fails) console.log(` - ${f}`);
11
+ process.exit(1);
12
+ }
13
+ console.log(okMsg.replace("%n", String(checked)));
14
+ process.exit(0);
15
+ }
16
+
17
+ const [cmd, ...rest] = process.argv.slice(2);
18
+ if (cmd === "freshness") {
19
+ const [root, corpusDir] = rest;
20
+ if (!root) { console.error("usage: cli.mjs freshness <repo-root> [corpus-dir]"); process.exit(2); }
21
+ const r = validateFreshness(root, corpusDir || "docs/wiki");
22
+ report(r, "OK: %n note(s) fresh against their pinned sources.");
23
+ } else {
24
+ console.error("usage: cli.mjs freshness <repo-root> [corpus-dir]");
25
+ process.exit(2);
26
+ }
@@ -0,0 +1,92 @@
1
+ // freshness.mjs — read-only staleness check for a code-grounded corpus (docs/corpus-spec.md).
2
+ //
3
+ // A note is STALE when any path in its `sources:` frontmatter changed after its
4
+ // `verified_against:` pin. Verified with git plumbing against the repo the corpus lives in.
5
+ // Never writes to disk (gates/ contract).
6
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
7
+ import { join, isAbsolute, basename } from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import { parseFrontmatter, stripCode, extractWikilinks } from "../lib/markdown.mjs";
10
+
11
+ function git(repoRoot, args) {
12
+ return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
13
+ }
14
+
15
+ /**
16
+ * Parse a `sources:` block list from raw frontmatter text. lib/markdown.mjs handles only
17
+ * inline `[a, b]` arrays; corpus notes use YAML block lists for readability.
18
+ */
19
+ export function parseSourcesBlock(text) {
20
+ const m = /^---\s*\n([\s\S]*?)\n---/.exec((text || "").replace(/^/, "").replace(/^\s+/, ""));
21
+ if (!m) return [];
22
+ const out = [];
23
+ let inBlock = false;
24
+ for (const line of m[1].split("\n")) {
25
+ if (/^sources:\s*$/.test(line)) { inBlock = true; continue; }
26
+ if (!inBlock) continue;
27
+ const item = /^\s+-\s+(.+)$/.exec(line);
28
+ if (item) { out.push(item[1].trim()); continue; }
29
+ if (!/^\s/.test(line)) inBlock = false;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * Check every note in a corpus for staleness against the repo's git history.
36
+ * corpusDir is relative to repoRoot unless absolute. Returns { fails, warns, checked };
37
+ * fails non-empty ⇒ the corpus is stale (or malformed) and the gate should block.
38
+ */
39
+ export function validateFreshness(repoRoot, corpusDir = "docs/wiki") {
40
+ const fails = [];
41
+ const warns = [];
42
+ const dir = isAbsolute(corpusDir) ? corpusDir : join(repoRoot, corpusDir);
43
+
44
+ if (!existsSync(join(dir, "INDEX.md"))) {
45
+ return { fails: [`not a corpus: ${join(dir, "INDEX.md")} missing`], warns, checked: 0 };
46
+ }
47
+
48
+ const files = readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "INDEX.md").sort();
49
+ const names = new Set(files.map((f) => basename(f, ".md")));
50
+ let checked = 0;
51
+
52
+ for (const file of files) {
53
+ const rel = `${corpusDir}/${file}`;
54
+ const text = readFileSync(join(dir, file), "utf8");
55
+ const fm = parseFrontmatter(text);
56
+ checked++;
57
+
58
+ if (!fm) { fails.push(`${rel}: no frontmatter — not a corpus note`); continue; }
59
+
60
+ const pin = fm.verified_against;
61
+ if (!pin) { fails.push(`${rel}: no verified_against pin`); continue; }
62
+ try {
63
+ git(repoRoot, ["cat-file", "-e", `${pin}^{commit}`]);
64
+ } catch {
65
+ fails.push(`${rel}: pin ${pin} is not a known commit`);
66
+ continue;
67
+ }
68
+
69
+ const sources = parseSourcesBlock(text);
70
+ if (sources.length === 0) {
71
+ warns.push(`${rel}: no sources listed — staleness is unverifiable`);
72
+ } else {
73
+ let changed = "";
74
+ try {
75
+ changed = git(repoRoot, ["log", "--oneline", `${pin}..HEAD`, "--", ...sources]);
76
+ } catch (e) {
77
+ fails.push(`${rel}: git log failed for its sources (${String(e).split("\n")[0]})`);
78
+ continue;
79
+ }
80
+ if (changed) {
81
+ const lines = changed.split("\n");
82
+ fails.push(`${rel}: STALE — sources changed since ${pin.slice(0, 12)} (${lines.length} commit(s), e.g. ${lines[0]})`);
83
+ }
84
+ }
85
+
86
+ for (const link of extractWikilinks(stripCode(text))) {
87
+ if (!names.has(link)) warns.push(`${rel}: [[${link}]] does not resolve to a sibling note`);
88
+ }
89
+ }
90
+
91
+ return { fails, warns, checked };
92
+ }
@@ -0,0 +1,8 @@
1
+ # lib — the shared Node chassis
2
+
3
+ Zero-dependency Node modules shared by every plugin, **vendored into each plugin at build time**
4
+ (a shipped `.plugin` is self-contained; `${CLAUDE_PLUGIN_ROOT}/lib/…` resolves at runtime).
5
+
6
+ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harness) · `markdown`
7
+ · `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
8
+ `installer` · `dates` · `template`.
@@ -0,0 +1,23 @@
1
+ // cli.mjs — the run-as-CLI guard for dual-use modules (importable library + executable
2
+ // script).
3
+ //
4
+ // The naive `import.meta.url === `file://${process.argv[1]}`` comparison breaks through
5
+ // symlinks: Node resolves import.meta.url to the module's physical path, while argv[1] stays
6
+ // as typed, so a script invoked via a symlinked checkout (~/projects -> Claude/Code) compares
7
+ // unequal and silently runs none of its CLI body — for a gate runner that means exit 0 having
8
+ // checked nothing, the exact silent skip the gates doctrine forbids. Realpath both sides so
9
+ // the comparison is invocation-path-independent.
10
+ import { realpathSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ /** True when the module at `moduleUrl` (pass import.meta.url) is the script Node was asked
14
+ * to run, regardless of symlinks on either side. False when imported as a module, or when
15
+ * there is no entry script (`node -e`, REPL) or argv[1] is a runner's virtual entry. */
16
+ export function runAsCli(moduleUrl) {
17
+ if (!process.argv[1]) return false;
18
+ try {
19
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(process.argv[1]);
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
@@ -0,0 +1,11 @@
1
+ // dates.mjs — ISO date helpers shared across the suite.
2
+
3
+ /** Today's date (or `date`'s) as an ISO `YYYY-MM-DD` string. */
4
+ export function today(date = new Date()) {
5
+ return date.toISOString().slice(0, 10);
6
+ }
7
+
8
+ /** Rewrite an `updated:` frontmatter line to today's date. No-op if none present. */
9
+ export function bumpUpdated(text, date = new Date()) {
10
+ return (text || "").replace(/^(updated:\s*).*$/m, `$1${today(date)}`);
11
+ }
@@ -0,0 +1,69 @@
1
+ // gate-runner.mjs — the shared Stop-hook harness.
2
+ //
3
+ // Each plugin ships a Stop hook whose command is a one-line Node entry that calls runStopHook
4
+ // with that plugin's gates. When several plugins are installed, each fires its own hook — so
5
+ // "run every applicable gate" happens naturally across plugins; within one plugin the runner
6
+ // runs that plugin's gates additively over every root they resolve.
7
+ //
8
+ // A gate is: { name, resolveRoots(startDir) -> string[], check(root) -> string[] (problems),
9
+ // warn?(root) -> string[] (non-blocking notices) }.
10
+ // A gate that resolves no roots is a no-op (this isn't its kind of project). `check` problems
11
+ // block the stop (exit 2); optional `warn` notices are surfaced on stderr but never block (exit 0)
12
+ // — for freshness reminders and the like that shouldn't refuse to let the model finish.
13
+ //
14
+ // Contract (Claude Code Stop hook): stdin is JSON with { stop_hook_active, cwd, … };
15
+ // exit 0 = allow the model to stop; exit 2 = block, and stderr becomes the message it sees.
16
+
17
+ /** Read all of stdin as a string (empty string on a TTY, so it's safe to run by hand). */
18
+ export function readStdin() {
19
+ return new Promise((resolve) => {
20
+ if (process.stdin.isTTY) return resolve("");
21
+ let data = "";
22
+ process.stdin.setEncoding("utf8");
23
+ process.stdin.on("data", (c) => (data += c));
24
+ process.stdin.on("end", () => resolve(data));
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Pure core: given the parsed hook input and the gates, return { block, message }.
30
+ * Separated from process I/O so it is unit-testable.
31
+ */
32
+ export function evaluate(input, gates, { cwd = process.cwd() } = {}) {
33
+ if (input && input.stop_hook_active === true) return { block: false, message: "", warnings: "" };
34
+ const start = process.env.CLAUDE_PROJECT_DIR || (input && input.cwd) || cwd;
35
+ const problems = [];
36
+ const warnings = [];
37
+ for (const gate of gates) {
38
+ let roots = [];
39
+ try { roots = gate.resolveRoots(start) || []; } catch { roots = []; }
40
+ for (const root of roots) {
41
+ try { problems.push(...(gate.check(root) || [])); } catch (e) {
42
+ problems.push(`[${gate.name || "gate"}] crashed on ${root}: ${e.message}`);
43
+ }
44
+ if (typeof gate.warn === "function") {
45
+ try { warnings.push(...(gate.warn(root) || [])); } catch { /* warnings are best-effort */ }
46
+ }
47
+ }
48
+ }
49
+ return { block: problems.length > 0, message: problems.join("\n"), warnings: warnings.join("\n") };
50
+ }
51
+
52
+ /**
53
+ * Full harness: read stdin, evaluate the gates, exit 0 (allow) or 2 (block, message on stderr).
54
+ * Blocking problems win; otherwise any non-blocking warnings are written to stderr and we still
55
+ * allow the stop (exit 0).
56
+ */
57
+ export async function runStopHook({ gates, exit = process.exit } = {}) {
58
+ const raw = await readStdin();
59
+ let input = {};
60
+ try { input = JSON.parse(raw || "{}"); } catch { input = {}; }
61
+ const { block, message, warnings } = evaluate(input, gates);
62
+ if (block) {
63
+ process.stderr.write([message, warnings].filter(Boolean).join("\n") + "\n");
64
+ exit(2);
65
+ } else {
66
+ if (warnings) process.stderr.write(warnings + "\n");
67
+ exit(0);
68
+ }
69
+ }