@verevoir/design-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # @verevoir/design-gate
2
+
3
+ Zero-dependency deterministic verifier for a design pack: DTCG token validation,
4
+ regenerate-diff of generated value views, and value-drift checks over the concept
5
+ docs. Node built-ins only — runs anywhere with no install.
6
+
7
+ The **same code** the design capabilities emit into a produced pack (for that
8
+ pack's CI) **and** the runtime imports to enforce a capability's
9
+ `verify: design-pack` postcondition (STDIO-451) — one source, two consumers.
10
+
11
+ ## Use
12
+
13
+ ```js
14
+ import { verifyFiles } from '@verevoir/design-gate';
15
+
16
+ // files: { path -> content } (e.g. read back from the branch the model wrote to)
17
+ const { ok, findings } = verifyFiles({
18
+ 'design-language/tokens/colour.tokens.json': '…',
19
+ 'design-language/tokens/colour.tokens.md': '…',
20
+ 'design-language/colour.md': '…',
21
+ });
22
+ ```
23
+
24
+ `verifyFiles(map)` returns `{ ok, findings }`; each finding is
25
+ `{ kind, file?, where?, message }` (kinds `DTCG` / `VIEW_DRIFT` / `VALUE_DRIFT` /
26
+ `NO_TOKENS` / `PARSE`), model-actionable so an execution loop can re-prompt until
27
+ green. The full surface (`verifyPack`, `renderTokenView`, `validateDtcg`,
28
+ `tokenLiterals`, `findValueDrift`, `runGate`) and the check semantics are in
29
+ [`design/README.md`](design/README.md).
@@ -0,0 +1,68 @@
1
+ # Design gate — deterministic token/doc checks
2
+
3
+ The deterministic half of the design converter. The design capabilities
4
+ (`ingest-style-guide`, `infer-design-system-from-repo`, `generate-design-tokens`,
5
+ `generate-agent-rules`) **emit this tooling into the pack they produce** and wire
6
+ it into that pack's CI, so faithfulness is *enforced* rather than trusted to the
7
+ model's prose discipline. Zero dependencies (Node built-ins only), so a produced
8
+ pack runs it with no install.
9
+
10
+ It exists because the bar was already in the corpus — `design-system-reference-stays-true`
11
+ says "point at the token source, don't re-type values" — and a model under
12
+ context pressure re-types them anyway. The facts (values, structure) are
13
+ mechanically checkable; this is the checker.
14
+
15
+ ## Checks
16
+
17
+ - **`dtcg.mjs` — `validateDtcg(tree)`**: the token source is well-formed DTCG. A
18
+ node is a token *or* a group, not both; a dimension `$value` is a real value,
19
+ not a CSS expression; every `{alias}` resolves and none cycle; `$schema` is the
20
+ real published one (`https://tr.designtokens.org/format/`), not a fabricated URL.
21
+ - **`value-drift.mjs` — `tokenLiterals(tree)` + `findValueDrift(literals, doc)`**:
22
+ no value-free doc re-types a token value. High-signal by design — it matches
23
+ distinctive literals (hex, unit-bearing dimensions, colour functions) where an
24
+ occurrence in prose is unambiguously a re-typed value;
25
+ bare unitless numbers (font weights, type-scale steps) are owned by the
26
+ generated value views, and fenced code blocks are skipped (example code may
27
+ carry real values).
28
+ - **`generate.mjs` — `renderTokenView(tree)` + `checkView` + CLI**: renders the
29
+ value-bearing reference *from* the tokens as a `GENERATED — DO NOT EDIT`
30
+ artifact (aliases resolved to their terminal, the reference shown), and a
31
+ `--check` mode regenerates in memory and fails on any divergence — kuat's
32
+ `tokens:check`, generalised. This is what makes value tables drift-proof by
33
+ construction; the drift linter is the backstop for everything *not* generated.
34
+ - **`gate.mjs` — `runGate({ tokens, docs })`** + CLI: runs DTCG + value-drift.
35
+ Generated docs (the token views themselves) are skipped — their drift is the
36
+ regenerate-diff check's job, not this one.
37
+
38
+ ```
39
+ node tooling/design/generate.mjs <tokens.json> <out.md> [--check]
40
+ node tooling/design/gate.mjs <tokens.json> <doc-or-dir>...
41
+ ```
42
+
43
+ Both exit non-zero on any finding.
44
+
45
+ ## Run the tooling's own tests
46
+
47
+ ```
48
+ cd tooling && npm test # node --test, zero deps
49
+ ```
50
+
51
+ ## Known limits (by design)
52
+
53
+ The drift check trades a little recall for near-zero false positives — a gate that
54
+ fires on correct prose gets switched off. So non-prose spans are skipped (code,
55
+ fenced and inline; markdown link destinations; URLs; asset/file paths — all places
56
+ a value is *referenced*, not re-typed), and it deliberately does **not** catch: a
57
+ re-typed value whose whitespace/leading-zero differs from the token
58
+ (`rgb(23, 149, 212)` vs `rgb(23,149,212)`, `.5rem` vs `0.5rem`); bare unitless
59
+ numbers and percentages (owned by the generated views). The **generator +
60
+ regenerate-diff** check (`generate.mjs`) is what makes the value-bearing views
61
+ drift-proof by construction; this drift linter is the prose backstop.
62
+
63
+ ## Wired into the capabilities
64
+
65
+ The design capabilities emit this tooling **and** the `templates/design/` CI
66
+ workflow into the pack they produce, with the regenerate-diff + gate as the
67
+ capability's postcondition — a conversion isn't "done" until they pass. See
68
+ `templates/design/README.md` for the path convention and the exact commands.
@@ -0,0 +1,135 @@
1
+ // Deterministic DTCG token-file validation, Node built-ins only — so a produced
2
+ // pack runs it with no install. The checks here are the ones a faithful design
3
+ // converter must not be trusted to satisfy by prose alone: a node is a token
4
+ // XOR a group, a dimension value is a real value not a CSS expression, every
5
+ // {alias} resolves, and the $schema is the real published one.
6
+
7
+ const REF_RE = /^\{([^}]+)\}$/;
8
+
9
+ /** The one real, published DTCG format schema. A fabricated URL (e.g.
10
+ * design-tokens.org/schema.json) 404s a validator, so it is a finding. */
11
+ export const DTCG_SCHEMA = 'https://tr.designtokens.org/format/';
12
+
13
+ const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
14
+
15
+ /** Whether a string is a single DTCG alias reference like `{color.blue.500}`. */
16
+ export const isRef = (v) => typeof v === 'string' && REF_RE.test(v.trim());
17
+
18
+ /** The dotted path inside an alias reference, or null if it isn't one. */
19
+ export const refPath = (v) => (isRef(v) ? v.trim().match(REF_RE)[1] : null);
20
+
21
+ /** Child members of a node: keys not prefixed with `$` whose value is an object
22
+ * (a nested token or group). `$type`/`$value`/`$description`/`$extensions` are
23
+ * the token's own metadata, never children. */
24
+ const children = (node) =>
25
+ Object.entries(node).filter(([k, v]) => !k.startsWith('$') && isObject(v));
26
+
27
+ const looksLikeExpression = (s) => typeof s === 'string' && /[a-z]+\s*\(/i.test(s);
28
+
29
+ /** A concrete dimension value: a string carrying a number ("16px", ".5rem") or
30
+ * the DTCG object form `{ value, unit }`. */
31
+ const validDimension = (v) =>
32
+ (typeof v === 'string' && /\d/.test(v)) ||
33
+ (isObject(v) && typeof v.value === 'number' && typeof v.unit === 'string');
34
+
35
+ /** Resolve an alias to its terminal token, following chains and refusing cycles.
36
+ * Returns { ok, token, path } or { ok:false, reason }. */
37
+ function resolve(root, path, seen = new Set()) {
38
+ if (seen.has(path)) return { ok: false, reason: 'cycle' };
39
+ seen.add(path);
40
+ let node = root;
41
+ for (const seg of path.split('.')) {
42
+ if (!isObject(node) || !(seg in node)) return { ok: false, reason: 'missing' };
43
+ node = node[seg];
44
+ }
45
+ if (!isObject(node) || !('$value' in node)) return { ok: false, reason: 'not-a-token' };
46
+ if (isRef(node.$value)) return resolve(root, refPath(node.$value), seen);
47
+ return { ok: true, token: node, path };
48
+ }
49
+
50
+ /** Resolve a token `$value` to its terminal literal, following aliases. Returns
51
+ * the literal (string / number / DTCG object) or `null` when it doesn't
52
+ * resolve. A non-alias value is returned as-is. */
53
+ export function resolvedLiteral(root, value) {
54
+ if (!isRef(value)) return value;
55
+ const r = resolve(root, refPath(value));
56
+ return r.ok ? r.token.$value : null;
57
+ }
58
+
59
+ /** Validate a parsed DTCG token tree. Returns a flat list of findings
60
+ * `{ path, code, message }` — empty when the tree is well-formed. Pure and
61
+ * deterministic: the same tree always yields the same findings, in tree order. */
62
+ export function validateDtcg(root, { schema = DTCG_SCHEMA } = {}) {
63
+ const findings = [];
64
+ const at = (p) => p || '(root)';
65
+
66
+ if (isObject(root) && '$schema' in root && root.$schema !== schema) {
67
+ findings.push({
68
+ path: '$schema',
69
+ code: 'BAD_SCHEMA',
70
+ message: `$schema is "${root.$schema}", not the real DTCG schema (${schema}).`,
71
+ });
72
+ }
73
+
74
+ const walk = (node, path, inheritedType) => {
75
+ if (!isObject(node)) return;
76
+ // A group's $type is inherited by its descendant tokens (DTCG).
77
+ const type = '$type' in node ? node.$type : inheritedType;
78
+ const isToken = '$value' in node;
79
+ const kids = children(node);
80
+
81
+ if (isToken && kids.length > 0) {
82
+ findings.push({
83
+ path: at(path),
84
+ code: 'TOKEN_AND_GROUP',
85
+ message: `node is both a token ($value) and a group (has child tokens: ${kids
86
+ .map(([k]) => k)
87
+ .join(', ')}) — DTCG nodes are one or the other.`,
88
+ });
89
+ }
90
+
91
+ if (isToken) {
92
+ const value = node.$value;
93
+ if (isRef(value)) {
94
+ // An alias's type is the referent's, so it needs no $type of its own.
95
+ const r = resolve(root, refPath(value));
96
+ if (!r.ok) {
97
+ findings.push({
98
+ path: at(path),
99
+ code: r.reason === 'cycle' ? 'REF_CYCLE' : 'UNRESOLVED_REF',
100
+ message: `$value alias ${value} ${r.reason === 'cycle' ? 'is part of a reference cycle' : 'does not resolve to a token'}.`,
101
+ });
102
+ }
103
+ } else if (type === undefined) {
104
+ findings.push({
105
+ path: at(path),
106
+ code: 'TOKEN_MISSING_TYPE',
107
+ message: `token has a $value but no $type (own or inherited from a group) — DTCG requires a resolvable type.`,
108
+ });
109
+ } else if (type === 'dimension' && looksLikeExpression(value)) {
110
+ findings.push({
111
+ path: at(path),
112
+ code: 'EXPRESSION_DIMENSION',
113
+ message: `dimension $value "${value}" is a CSS expression, which no DTCG parser accepts — use a concrete value.`,
114
+ });
115
+ } else if (type === 'dimension' && !validDimension(value)) {
116
+ findings.push({
117
+ path: at(path),
118
+ code: 'BAD_DIMENSION_VALUE',
119
+ message: `dimension $value is not a concrete dimension (expected "16px" or { value, unit }).`,
120
+ });
121
+ }
122
+ } else if (kids.length === 0 && '$type' in node) {
123
+ findings.push({
124
+ path: at(path),
125
+ code: 'TOKEN_MISSING_VALUE',
126
+ message: `node has $type but no $value and no children — a token must carry a $value.`,
127
+ });
128
+ }
129
+
130
+ for (const [k, v] of kids) walk(v, path ? `${path}.${k}` : k, type);
131
+ };
132
+
133
+ walk(root, '', undefined);
134
+ return findings;
135
+ }
@@ -0,0 +1,87 @@
1
+ // The deterministic design gate the capabilities emit into a produced pack and
2
+ // wire into CI: validate the token source as DTCG, and assert no value-free doc
3
+ // re-types a token value. Zero dependencies, so the pack runs it with no install.
4
+ //
5
+ // Usage (from the pack root):
6
+ // node tooling/design/gate.mjs <tokens.json> <doc-or-dir>...
7
+ // Exits non-zero on any finding; prints each with its location.
8
+
9
+ import { readFileSync, readdirSync, lstatSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { validateDtcg } from './dtcg.mjs';
12
+ import { tokenLiterals, findValueDrift } from './value-drift.mjs';
13
+
14
+ const GENERATED_MARK = 'GENERATED FILE — DO NOT EDIT';
15
+
16
+ /** Run both deterministic checks over an already-read token source and a set of
17
+ * already-read docs. Pure: the same inputs always yield the same result, so the
18
+ * gate is reproducible. A generated doc is the token view itself and is skipped
19
+ * — its drift is owned by the regenerate-diff check, not this one. */
20
+ export function runGate({ tokens, docs = [] }) {
21
+ const dtcg = validateDtcg(tokens);
22
+ const literals = tokenLiterals(tokens);
23
+ const drift = docs
24
+ .filter(({ text }) => !text.includes(GENERATED_MARK))
25
+ .flatMap(({ path, text }) => findValueDrift(literals, text).map((f) => ({ ...f, path })));
26
+ return { ok: dtcg.length === 0 && drift.length === 0, dtcg, drift };
27
+ }
28
+
29
+ /** Collect `.md` files under a path. Symlinks are NOT followed (so a symlink
30
+ * loop can't hang the gate); node_modules/.git are skipped; an unreadable entry
31
+ * is skipped rather than thrown. */
32
+ export function markdownFilesUnder(target) {
33
+ const out = [];
34
+ const visit = (p) => {
35
+ let s;
36
+ try {
37
+ s = lstatSync(p);
38
+ } catch {
39
+ return;
40
+ }
41
+ if (s.isSymbolicLink()) return;
42
+ if (s.isDirectory()) {
43
+ if (/(^|\/)(node_modules|\.git)(\/|$)/.test(p)) return;
44
+ for (const name of readdirSync(p)) visit(join(p, name));
45
+ } else if (s.isFile() && p.endsWith('.md')) {
46
+ out.push(p);
47
+ }
48
+ };
49
+ visit(target);
50
+ return out;
51
+ }
52
+
53
+ export function main(argv) {
54
+ const [tokensPath, ...docTargets] = argv;
55
+ if (!tokensPath) {
56
+ console.error('usage: node gate.mjs <tokens.json> <doc-or-dir>...');
57
+ return 2;
58
+ }
59
+ let tokens;
60
+ try {
61
+ tokens = JSON.parse(readFileSync(tokensPath, 'utf8'));
62
+ } catch (e) {
63
+ console.error(`cannot read tokens file ${tokensPath}: ${e.message}`);
64
+ return 2;
65
+ }
66
+ // A named doc target that doesn't exist is a misconfiguration, not "no docs" —
67
+ // silently scanning nothing would turn the gate green on a CI path typo.
68
+ for (const t of docTargets) {
69
+ try {
70
+ lstatSync(t);
71
+ } catch {
72
+ console.error(`doc target not found: ${t}`);
73
+ return 2;
74
+ }
75
+ }
76
+ const docPaths = docTargets.flatMap(markdownFilesUnder);
77
+ const docs = docPaths.map((path) => ({ path, text: readFileSync(path, 'utf8') }));
78
+ const { ok, dtcg, drift } = runGate({ tokens, docs });
79
+
80
+ for (const f of dtcg) console.error(`DTCG ${f.code} at ${f.path}: ${f.message}`);
81
+ for (const f of drift)
82
+ console.error(`DRIFT ${f.path}:${f.line} re-types token value ${f.value} — point at the token instead`);
83
+ console.error(ok ? 'design gate ok' : `design gate FAILED: ${dtcg.length} DTCG + ${drift.length} drift finding(s)`);
84
+ return ok ? 0 : 1;
85
+ }
86
+
87
+ if (import.meta.url === `file://${process.argv[1]}`) process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,140 @@
1
+ // The token → value-view generator, and its regenerate-diff drift check (kuat's
2
+ // `tokens:check`, generalised to any DTCG token file). The value-bearing
3
+ // reference is a GENERATED artifact: a model authors the tokens (the source of
4
+ // truth) and value-free prose, and this renders the value tables deterministically
5
+ // — so they can never silently drift. The check regenerates in memory and asserts
6
+ // the committed file matches exactly. Zero dependencies (Node built-ins).
7
+ //
8
+ // Usage (from the pack root):
9
+ // node tooling/design/generate.mjs <tokens.json> <out.md> # write
10
+ // node tooling/design/generate.mjs <tokens.json> <out.md> --check # verify
11
+
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { resolvedLiteral, isRef } from './dtcg.mjs';
14
+
15
+ /** The marker the drift gate (value-drift.mjs) recognises to skip a generated
16
+ * doc — its faithfulness is this check's job, not the prose linter's. */
17
+ export const GENERATED_MARK = 'GENERATED FILE — DO NOT EDIT';
18
+
19
+ const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
20
+ // Escape what would corrupt a table cell: a literal pipe, and a newline (which
21
+ // would split the row). Keeps the rendered markdown well-formed and stable.
22
+ const cell = (s) => String(s).replace(/\|/g, '\\|').replace(/\r?\n/g, '\\n');
23
+
24
+ function formatValue(v) {
25
+ if (v === null || v === undefined) return '(unresolved)';
26
+ // A canonical DTCG dimension is exactly { value, unit }. Anything carrying
27
+ // extra keys is not collapsed to `<value><unit>` — that would hide a drift in
28
+ // the extra field — so it falls through to the full JSON form.
29
+ if (isObject(v) && 'value' in v && 'unit' in v && Object.keys(v).length === 2)
30
+ return `${v.value}${v.unit}`;
31
+ if (typeof v === 'string' || typeof v === 'number') return String(v);
32
+ return JSON.stringify(v);
33
+ }
34
+
35
+ /** Flatten the tree to its tokens in document order, carrying the inherited
36
+ * `$type` so a token under a typed group still reports its type. */
37
+ function collectTokens(root) {
38
+ const out = [];
39
+ const walk = (node, path, inheritedType) => {
40
+ if (!isObject(node)) return;
41
+ const type = '$type' in node ? node.$type : inheritedType;
42
+ if ('$value' in node) out.push({ path, type, value: node.$value });
43
+ for (const [k, v] of Object.entries(node))
44
+ if (!k.startsWith('$') && isObject(v)) walk(v, path ? `${path}.${k}` : k, type);
45
+ };
46
+ walk(root, '', undefined);
47
+ return out;
48
+ }
49
+
50
+ /** Render the deterministic value view for a token tree: a banner, then one
51
+ * table per top-level group listing each token's resolved value (aliases
52
+ * resolved to their terminal, with the reference shown). Pure and stable — the
53
+ * same tree always renders byte-identically, so it can self-diff. */
54
+ export function renderTokenView(root) {
55
+ const groups = new Map();
56
+ for (const t of collectTokens(root)) {
57
+ const g = t.path.split('.')[0];
58
+ if (!groups.has(g)) groups.set(g, []);
59
+ groups.get(g).push(t);
60
+ }
61
+
62
+ // The banner carries no path — the output must not depend on how it was
63
+ // invoked, or the author's spelling and CI's `./`-prefixed `find` spelling
64
+ // would disagree and a faithful view would fail --check.
65
+ const lines = [
66
+ `<!-- ${GENERATED_MARK} BY HAND.`,
67
+ ' Produced from the design tokens by tooling/design/generate.mjs.',
68
+ ' Change a value at the token source and regenerate; a drift check gates this file. -->',
69
+ '',
70
+ '# Design tokens',
71
+ ];
72
+ for (const [group, rows] of groups) {
73
+ lines.push('', `## ${group}`, '', '| Token | Type | Value | Alias |', '| --- | --- | --- | --- |');
74
+ for (const { path, type, value } of rows) {
75
+ const resolved = formatValue(resolvedLiteral(root, value));
76
+ const alias = isRef(value) ? value : '';
77
+ lines.push(`| ${cell(path)} | ${cell(type ?? '')} | ${cell(resolved)} | ${cell(alias)} |`);
78
+ }
79
+ }
80
+ return lines.join('\n') + '\n';
81
+ }
82
+
83
+ /** Compare a freshly-rendered view against the committed one. `{ ok }` when
84
+ * identical, else the first differing line and both sides. */
85
+ export function checkView(rendered, committed) {
86
+ if (rendered === committed) return { ok: true };
87
+ const a = rendered.split('\n');
88
+ const b = committed.split('\n');
89
+ const n = Math.max(a.length, b.length);
90
+ for (let i = 0; i < n; i++) {
91
+ if (a[i] !== b[i]) return { ok: false, line: i + 1, expected: a[i] ?? '(eof)', actual: b[i] ?? '(eof)' };
92
+ }
93
+ return { ok: false, line: n, expected: '', actual: '' };
94
+ }
95
+
96
+ export function main(argv) {
97
+ const check = argv.includes('--check');
98
+ const [tokensPath, outPath] = argv.filter((a) => a !== '--check');
99
+ if (!tokensPath || !outPath) {
100
+ console.error('usage: node generate.mjs <tokens.json> <out.md> [--check]');
101
+ return 2;
102
+ }
103
+ let tokens;
104
+ try {
105
+ tokens = JSON.parse(readFileSync(tokensPath, 'utf8'));
106
+ } catch (e) {
107
+ console.error(`cannot read tokens file ${tokensPath}: ${e.message}`);
108
+ return 2;
109
+ }
110
+ const rendered = renderTokenView(tokens);
111
+
112
+ if (check) {
113
+ let committed = '';
114
+ try {
115
+ committed = readFileSync(outPath, 'utf8');
116
+ } catch {
117
+ committed = '';
118
+ }
119
+ const r = checkView(rendered, committed);
120
+ if (r.ok) {
121
+ console.error(`tokens:check ok — ${outPath} matches ${tokensPath}`);
122
+ return 0;
123
+ }
124
+ console.error(`tokens:check FAILED — ${outPath} has drifted from ${tokensPath} at line ${r.line}:`);
125
+ console.error(` expected: ${r.expected}`);
126
+ console.error(` actual: ${r.actual}`);
127
+ return 1;
128
+ }
129
+
130
+ try {
131
+ writeFileSync(outPath, rendered);
132
+ } catch (e) {
133
+ console.error(`cannot write ${outPath}: ${e.message}`);
134
+ return 2;
135
+ }
136
+ console.error(`wrote ${outPath} from ${tokensPath}`);
137
+ return 0;
138
+ }
139
+
140
+ if (import.meta.url === `file://${process.argv[1]}`) process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,10 @@
1
+ // @verevoir/design-gate — the deterministic design verifier, zero-dependency.
2
+ // The same code the design capabilities emit into a produced pack (for that
3
+ // pack's CI) AND the consuming runtime imports to enforce a capability's
4
+ // `verify: design-pack` as a hard postcondition. One source, two consumers.
5
+
6
+ export { verifyFiles, verifyPack } from './verify-pack.mjs';
7
+ export { renderTokenView, checkView, GENERATED_MARK } from './generate.mjs';
8
+ export { validateDtcg, DTCG_SCHEMA, isRef, refPath, resolvedLiteral } from './dtcg.mjs';
9
+ export { tokenLiterals, findValueDrift } from './value-drift.mjs';
10
+ export { runGate } from './gate.mjs';
@@ -0,0 +1,82 @@
1
+ // The value-drift backstop: a token value must live once, in the tokens — a
2
+ // concept doc points at it, never re-types it. This asserts no value-free doc
3
+ // contains a literal token value. It is deliberately high-signal, not
4
+ // exhaustive: it matches only DISTINCTIVE literals (a hex colour, a dimension
5
+ // carrying a unit, a colour function, a percentage) where an occurrence in prose
6
+ // is unambiguously a re-typed value; bare unitless numbers (font weights, a
7
+ // type-scale step) are owned by the generated value views, not grepped here,
8
+ // because "700" in prose is all noise. Fenced code blocks are skipped — example
9
+ // code is meant to carry real values.
10
+
11
+ const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
12
+ const isRef = (v) => typeof v === 'string' && /^\{[^}]+\}$/.test(v.trim());
13
+
14
+ const HEX = /#[0-9a-fA-F]{3,8}/g;
15
+ const DIMENSION = /-?\d*\.?\d+(?:px|rem|em)/g;
16
+ const COLOR_FN = /(?:oklch|rgba?|hsla?)\([^)]*\)/gi;
17
+
18
+ /** Pull the distinctive value literals out of one token `$value` (a string, or a
19
+ * DTCG dimension object `{value,unit}`), lower-cased so matching is stable. */
20
+ function literalsOf(value) {
21
+ const out = [];
22
+ if (isObject(value) && 'value' in value && 'unit' in value) {
23
+ out.push(`${value.value}${value.unit}`.toLowerCase());
24
+ return out;
25
+ }
26
+ if (typeof value !== 'string' || isRef(value)) return out;
27
+ for (const re of [HEX, COLOR_FN, DIMENSION]) {
28
+ const m = value.match(re);
29
+ if (m) out.push(...m.map((s) => s.toLowerCase()));
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** The set of distinctive value literals a doc must not re-type. Walks the token
35
+ * tree collecting every concrete `$value` (aliases are pointers, so skipped). */
36
+ export function tokenLiterals(root) {
37
+ const set = new Set();
38
+ const walk = (node) => {
39
+ if (!isObject(node)) return;
40
+ if ('$value' in node) for (const lit of literalsOf(node.$value)) set.add(lit);
41
+ for (const [k, v] of Object.entries(node)) if (!k.startsWith('$') && isObject(v)) walk(v);
42
+ };
43
+ walk(root);
44
+ return set;
45
+ }
46
+
47
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48
+
49
+ /** Find re-typed token values in a doc's prose. `literals` is the set from
50
+ * `tokenLiterals`. Returns `{ value, line, snippet }[]` in document order;
51
+ * empty when the doc only points at values. Fenced code blocks are ignored. */
52
+ export function findValueDrift(literals, docText) {
53
+ if (literals.size === 0) return [];
54
+ // A literal preceded/followed by a hex digit or letter is part of a longer
55
+ // token (e.g. "16px" inside "216px"), so require a non-alphanumeric edge.
56
+ const alternation = [...literals].map(escape).join('|');
57
+ const re = new RegExp(`(?<![0-9a-z])(${alternation})(?![0-9a-z])`, 'gi');
58
+
59
+ const findings = [];
60
+ let inFence = false;
61
+ const lines = docText.split('\n');
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i];
64
+ if (/^\s*(```|~~~)/.test(line)) {
65
+ inFence = !inFence;
66
+ continue;
67
+ }
68
+ if (inFence) continue;
69
+ // Blank the non-prose spans before matching — a value in code, a link
70
+ // destination, a URL, or an asset/file path is referenced, not re-typed.
71
+ const blank = (m) => ' '.repeat(m.length);
72
+ const scan = line
73
+ .replace(/`[^`]*`/g, blank) // inline code
74
+ .replace(/\]\([^)]*\)/g, blank) // markdown link destination
75
+ .replace(/<?\bhttps?:\/\/[^\s>)]+>?/gi, blank) // bare / autolink URL
76
+ .replace(/[\w./-]+\.(?:svg|png|jpe?g|gif|webp|css|scss|sass|less|js|ts|json|md|html?)\b/gi, blank); // asset / file path
77
+ for (const m of scan.matchAll(re)) {
78
+ findings.push({ value: m[1].toLowerCase(), line: i + 1, snippet: line.trim() });
79
+ }
80
+ }
81
+ return findings;
82
+ }
@@ -0,0 +1,136 @@
1
+ // verify-pack — the deterministic "verify" a design capability's execution runs.
2
+ // For every token file in a produced pack it validates DTCG, regenerate-diffs the
3
+ // generated view, and value-drift-scans the reference docs, returning aggregated,
4
+ // model-actionable findings so an execution loop can re-prompt until green.
5
+ //
6
+ // Two entry points:
7
+ // verifyFiles(files) — PURE, over an in-memory { path → content } map; the
8
+ // runtime calls this on files read back from the branch the model wrote to.
9
+ // verifyPack(root) — the CLI/local wrapper: read the pack off disk into a
10
+ // file map and delegate to verifyFiles.
11
+ // Zero dependencies (Node built-ins).
12
+ // node tooling/design/verify-pack.mjs <pack-root> 0 = clean, 1 = findings, 2 = no tokens
13
+
14
+ import { readFileSync, readdirSync, lstatSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { validateDtcg } from './dtcg.mjs';
17
+ import { renderTokenView, checkView } from './generate.mjs';
18
+ import { runGate } from './gate.mjs';
19
+
20
+ const TOKEN_FILE = /(^|\/)design-language\/tokens\/[^/]+\.tokens\.json$/;
21
+
22
+ /** Drop duplicate findings — two token files sharing a literal would otherwise
23
+ * flag the same re-typed value twice; findings feed back to the model, so keep
24
+ * them noise-free. */
25
+ function result(findings) {
26
+ const seen = new Set();
27
+ const deduped = findings.filter((f) => {
28
+ const key = JSON.stringify([f.kind, f.file, f.where, f.message]);
29
+ return seen.has(key) ? false : (seen.add(key), true);
30
+ });
31
+ return { ok: deduped.length === 0, findings: deduped };
32
+ }
33
+
34
+ /** Verify a produced design pack held as an in-memory `{ path → content }` map
35
+ * (paths use `/`). A path under `design-language/tokens/` ending `.tokens.json`
36
+ * is a token source; its sibling `.tokens.md` is the generated view; the `.md`
37
+ * files under the same `design-language/` dir are the concept docs. Pure: same
38
+ * files → same findings (`{ kind, file, where?, message }`; kinds NO_TOKENS /
39
+ * PARSE / DTCG / VIEW_DRIFT / VALUE_DRIFT). */
40
+ export function verifyFiles(files) {
41
+ // Total against malformed input — this is the gate the runtime calls on files
42
+ // it reads back, so a missing/odd value degrades to a finding, never a throw.
43
+ const map =
44
+ files instanceof Map
45
+ ? files
46
+ : new Map(files && typeof files === 'object' ? Object.entries(files) : []);
47
+ const tokenPaths = [...map.keys()].filter((p) => TOKEN_FILE.test(p)).sort();
48
+ if (tokenPaths.length === 0) {
49
+ return {
50
+ ok: false,
51
+ findings: [
52
+ { kind: 'NO_TOKENS', message: 'no design-language/tokens/*.tokens.json in the pack' },
53
+ ],
54
+ };
55
+ }
56
+
57
+ const findings = [];
58
+ for (const tp of tokenPaths) {
59
+ let tokens;
60
+ try {
61
+ tokens = JSON.parse(String(map.get(tp) ?? ''));
62
+ } catch (e) {
63
+ findings.push({ kind: 'PARSE', file: tp, message: e.message });
64
+ continue;
65
+ }
66
+
67
+ for (const f of validateDtcg(tokens))
68
+ findings.push({ kind: 'DTCG', file: tp, where: f.path, message: `${f.code}: ${f.message}` });
69
+
70
+ const viewPath = tp.replace(/\.json$/, '.md');
71
+ if (!checkView(renderTokenView(tokens), String(map.get(viewPath) ?? '')).ok)
72
+ findings.push({
73
+ kind: 'VIEW_DRIFT',
74
+ file: viewPath,
75
+ message: `generated view is missing or stale — run: node tooling/design/generate.mjs ${tp} ${viewPath}`,
76
+ });
77
+
78
+ // Concept docs: the `.md` files under this token file's `design-language/` dir.
79
+ const dlPrefix = tp.replace(/tokens\/[^/]+\.tokens\.json$/, '');
80
+ const docs = [...map.entries()]
81
+ .filter(([p]) => p.endsWith('.md') && p.startsWith(dlPrefix))
82
+ .map(([path, text]) => ({ path, text: String(text ?? '') }));
83
+ for (const d of runGate({ tokens, docs }).drift)
84
+ findings.push({
85
+ kind: 'VALUE_DRIFT',
86
+ file: d.path,
87
+ where: `line ${d.line}`,
88
+ message: `re-types token value ${d.value} — point at the token instead`,
89
+ });
90
+ }
91
+ return result(findings);
92
+ }
93
+
94
+ /** Read a pack off disk into a `{ path → content }` map (paths relative to
95
+ * `root`, only `*.tokens.json` + `*.md`) and verify it. Symlinks not followed;
96
+ * node_modules/.git skipped. */
97
+ export function verifyPack(root) {
98
+ const files = new Map();
99
+ const visit = (abs, rel) => {
100
+ let s;
101
+ try {
102
+ s = lstatSync(abs);
103
+ } catch {
104
+ return;
105
+ }
106
+ if (s.isSymbolicLink()) return;
107
+ if (s.isDirectory()) {
108
+ if (/(^|\/)(node_modules|\.git)(\/|$)/.test(abs)) return;
109
+ for (const n of readdirSync(abs)) visit(join(abs, n), rel ? `${rel}/${n}` : n);
110
+ } else if (s.isFile() && (abs.endsWith('.tokens.json') || abs.endsWith('.md'))) {
111
+ try {
112
+ files.set(rel, readFileSync(abs, 'utf8'));
113
+ } catch {
114
+ // skip an unreadable file
115
+ }
116
+ }
117
+ };
118
+ visit(root, '');
119
+ return verifyFiles(files);
120
+ }
121
+
122
+ function main(argv) {
123
+ const root = argv[0];
124
+ if (!root) {
125
+ console.error('usage: node verify-pack.mjs <pack-root>');
126
+ return 2;
127
+ }
128
+ const { ok, findings } = verifyPack(root);
129
+ for (const f of findings)
130
+ console.error(`${f.kind} ${f.file ?? ''}${f.where ? ` ${f.where}` : ''}: ${f.message}`);
131
+ console.error(ok ? 'verify-pack ok' : `verify-pack FAILED: ${findings.length} finding(s)`);
132
+ if (findings.some((f) => f.kind === 'NO_TOKENS')) return 2;
133
+ return ok ? 0 : 1;
134
+ }
135
+
136
+ if (import.meta.url === `file://${process.argv[1]}`) process.exit(main(process.argv.slice(2)));
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@verevoir/design-gate",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Zero-dependency deterministic design verifier: DTCG token validation, regenerate-diff of generated views, and value-drift checks over a design pack. The same code the design capabilities emit into a produced pack (its CI) and the runtime imports to enforce a capability's `verify: design-pack` postcondition.",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/verevoir/aigency-guardrails.git",
10
+ "directory": "tooling"
11
+ },
12
+ "exports": {
13
+ ".": "./design/index.mjs"
14
+ },
15
+ "files": [
16
+ "design/index.mjs",
17
+ "design/dtcg.mjs",
18
+ "design/gate.mjs",
19
+ "design/generate.mjs",
20
+ "design/value-drift.mjs",
21
+ "design/verify-pack.mjs",
22
+ "design/README.md"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test"
26
+ }
27
+ }