@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/codebase-to-course/gates/cli.mjs +20 -0
- package/codebase-to-course/gates/course.mjs +58 -0
- package/codebase-to-course/lib/README.md +8 -0
- package/codebase-to-course/lib/cli.mjs +23 -0
- package/codebase-to-course/lib/dates.mjs +11 -0
- package/codebase-to-course/lib/gate-runner.mjs +69 -0
- package/codebase-to-course/lib/handoff.mjs +75 -0
- package/codebase-to-course/lib/html/base.html +129 -0
- package/codebase-to-course/lib/installer.mjs +52 -0
- package/codebase-to-course/lib/lifecycle.mjs +67 -0
- package/codebase-to-course/lib/markdown.mjs +81 -0
- package/codebase-to-course/lib/project-root.mjs +52 -0
- package/codebase-to-course/lib/selfcontained.mjs +37 -0
- package/codebase-to-course/lib/spec-derive.mjs +114 -0
- package/codebase-to-course/lib/template.mjs +11 -0
- package/codebase-to-course/lib/toolkit/README.md +45 -0
- package/codebase-to-course/lib/toolkit/code-translation.md +50 -0
- package/codebase-to-course/lib/toolkit/diagrams.md +57 -0
- package/codebase-to-course/lib/toolkit/pedagogy.md +39 -0
- package/codebase-to-course/lib/toolkit/quiz-patterns.md +65 -0
- package/codebase-to-course/lib/toolkit/svg-diagrams.md +59 -0
- package/codebase-to-course/lib/toolkit/tooltip.md +83 -0
- package/codebase-to-course/skills/codebase-to-course/references/validate.mjs +301 -0
- package/grounding-wiki/gates/cli.mjs +26 -0
- package/grounding-wiki/gates/freshness.mjs +92 -0
- package/grounding-wiki/lib/README.md +8 -0
- package/grounding-wiki/lib/cli.mjs +23 -0
- package/grounding-wiki/lib/dates.mjs +11 -0
- package/grounding-wiki/lib/gate-runner.mjs +69 -0
- package/grounding-wiki/lib/handoff.mjs +75 -0
- package/grounding-wiki/lib/html/base.html +129 -0
- package/grounding-wiki/lib/installer.mjs +52 -0
- package/grounding-wiki/lib/lifecycle.mjs +67 -0
- package/grounding-wiki/lib/markdown.mjs +81 -0
- package/grounding-wiki/lib/project-root.mjs +52 -0
- package/grounding-wiki/lib/selfcontained.mjs +37 -0
- package/grounding-wiki/lib/spec-derive.mjs +114 -0
- package/grounding-wiki/lib/template.mjs +11 -0
- package/grounding-wiki/lib/toolkit/README.md +45 -0
- package/grounding-wiki/lib/toolkit/code-translation.md +50 -0
- package/grounding-wiki/lib/toolkit/diagrams.md +57 -0
- package/grounding-wiki/lib/toolkit/pedagogy.md +39 -0
- package/grounding-wiki/lib/toolkit/quiz-patterns.md +65 -0
- package/grounding-wiki/lib/toolkit/svg-diagrams.md +59 -0
- package/grounding-wiki/lib/toolkit/tooltip.md +83 -0
- package/lib/README.md +8 -0
- package/lib/cli.mjs +23 -0
- package/lib/dates.mjs +11 -0
- package/lib/gate-runner.mjs +69 -0
- package/lib/handoff.mjs +75 -0
- package/lib/html/base.html +129 -0
- package/lib/installer.mjs +52 -0
- package/lib/lifecycle.mjs +67 -0
- package/lib/markdown.mjs +81 -0
- package/lib/project-root.mjs +52 -0
- package/lib/selfcontained.mjs +37 -0
- package/lib/spec-derive.mjs +114 -0
- package/lib/template.mjs +11 -0
- package/lib/toolkit/README.md +45 -0
- package/lib/toolkit/code-translation.md +50 -0
- package/lib/toolkit/diagrams.md +57 -0
- package/lib/toolkit/pedagogy.md +39 -0
- package/lib/toolkit/quiz-patterns.md +65 -0
- package/lib/toolkit/svg-diagrams.md +59 -0
- package/lib/toolkit/tooltip.md +83 -0
- package/package.json +17 -0
- package/scripts/run-gates.mjs +91 -0
- package/spec-bridge/gates/bridge.mjs +130 -0
- package/spec-bridge/gates/cli.mjs +37 -0
- package/spec-bridge/lib/README.md +8 -0
- package/spec-bridge/lib/cli.mjs +23 -0
- package/spec-bridge/lib/dates.mjs +11 -0
- package/spec-bridge/lib/gate-runner.mjs +69 -0
- package/spec-bridge/lib/handoff.mjs +75 -0
- package/spec-bridge/lib/html/base.html +129 -0
- package/spec-bridge/lib/installer.mjs +52 -0
- package/spec-bridge/lib/lifecycle.mjs +67 -0
- package/spec-bridge/lib/markdown.mjs +81 -0
- package/spec-bridge/lib/project-root.mjs +52 -0
- package/spec-bridge/lib/selfcontained.mjs +37 -0
- package/spec-bridge/lib/spec-derive.mjs +114 -0
- package/spec-bridge/lib/template.mjs +11 -0
- package/spec-bridge/lib/toolkit/README.md +45 -0
- package/spec-bridge/lib/toolkit/code-translation.md +50 -0
- package/spec-bridge/lib/toolkit/diagrams.md +57 -0
- package/spec-bridge/lib/toolkit/pedagogy.md +39 -0
- package/spec-bridge/lib/toolkit/quiz-patterns.md +65 -0
- package/spec-bridge/lib/toolkit/svg-diagrams.md +59 -0
- 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(/</g, "<")
|
|
64
|
+
.replace(/>/g, ">")
|
|
65
|
+
.replace(/"/g, '"')
|
|
66
|
+
.replace(/'/g, "'")
|
|
67
|
+
.replace(/ /g, " ")
|
|
68
|
+
.replace(/&/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
|
+
}
|