@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 +29 -0
- package/design/README.md +68 -0
- package/design/dtcg.mjs +135 -0
- package/design/gate.mjs +87 -0
- package/design/generate.mjs +140 -0
- package/design/index.mjs +10 -0
- package/design/value-drift.mjs +82 -0
- package/design/verify-pack.mjs +136 -0
- package/package.json +27 -0
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).
|
package/design/README.md
ADDED
|
@@ -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.
|
package/design/dtcg.mjs
ADDED
|
@@ -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
|
+
}
|
package/design/gate.mjs
ADDED
|
@@ -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)));
|
package/design/index.mjs
ADDED
|
@@ -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
|
+
}
|