@verevoir/design-gate 0.1.0 → 0.2.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 +14 -6
- package/design/build-artefacts.mjs +109 -0
- package/design/dtcg.d.mts +19 -0
- package/design/index.mjs +2 -0
- package/design/token-completeness.mjs +272 -0
- package/design/value-drift.mjs +31 -10
- package/design/verify-pack.mjs +6 -3
- package/package.json +22 -5
- package/surface/surface.d.ts +1039 -0
- package/surface/surface.js +272 -0
package/README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# @verevoir/design-gate
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
3
|
+
The design substrate, in two layers:
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
- **The gate** (`.` → [`design/`](design/README.md)) — a zero-dependency
|
|
6
|
+
deterministic verifier for a design pack: DTCG token validation, regenerate-diff
|
|
7
|
+
of generated value views, and value-drift checks over the concept docs. Node
|
|
8
|
+
built-ins only — runs anywhere with no install. The **same code** the design
|
|
9
|
+
capabilities emit into a produced pack (for that pack's CI) **and** the runtime
|
|
10
|
+
imports to enforce a capability's `verify: design-pack` postcondition (STDIO-451)
|
|
11
|
+
— one source, two consumers.
|
|
12
|
+
- **The surface** (`./surface` → [`surface/surface.ts`](surface/surface.ts)) — the
|
|
13
|
+
typed `DesignSurface` contract (the design interlingua) with its `validateSurface`
|
|
14
|
+
gate and `merge`/`synthesise`. Its token layer is validated by delegating to the
|
|
15
|
+
gate's `validateDtcg`, so the tokens are the token-layer slice of one surface
|
|
16
|
+
rather than a separate DTCG pipeline (STDIO-524). This layer is TypeScript and
|
|
17
|
+
depends on `zod`; the gate stays zero-dependency and is what ships into packs.
|
|
10
18
|
|
|
11
19
|
## Use
|
|
12
20
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Deterministic build-artefact presence check. Given a list of file paths (e.g.
|
|
2
|
+
// the output tree of a produced design pack or a PR's changed files), flags any
|
|
3
|
+
// path that sits inside a build-output, dependency-cache, or generated directory
|
|
4
|
+
// that must never enter version control — enforcing `track-only-intended-files`.
|
|
5
|
+
//
|
|
6
|
+
// Scope is deliberately limited to artefact *directory* conventions. These are
|
|
7
|
+
// stable across languages (node_modules/, dist/, target/, obj/, __pycache__/…)
|
|
8
|
+
// and rarely false-positive, so they earn a deterministic check. We do NOT
|
|
9
|
+
// enumerate compiled file *extensions*: that list can't be completed (the next
|
|
10
|
+
// stack ships output we never listed — a silent miss), and a design pack
|
|
11
|
+
// legitimately commits binaries (fonts, icon files, images), so matching by
|
|
12
|
+
// extension is both incomplete and occasionally wrong. Detecting whether a
|
|
13
|
+
// stray committed file is an artefact across stacks we've never seen is a
|
|
14
|
+
// judgment, not a regex — it belongs to the antagonistic reviewer's rubric,
|
|
15
|
+
// where example-quality already lives.
|
|
16
|
+
//
|
|
17
|
+
// Zero dependencies (Node built-ins only). Pure: same paths → same findings.
|
|
18
|
+
|
|
19
|
+
// Segment-level patterns: any path component (between slashes) that matches is
|
|
20
|
+
// an artefact directory. Checked case-insensitively so "Node_Modules" etc. also
|
|
21
|
+
// flag. Anchored to a segment boundary so "distribution/" does not trigger on
|
|
22
|
+
// "dist/".
|
|
23
|
+
const ARTEFACT_DIRS = [
|
|
24
|
+
/^node_modules$/i,
|
|
25
|
+
/^\.npm$/i,
|
|
26
|
+
/^dist$/i,
|
|
27
|
+
/^build$/i,
|
|
28
|
+
/^out$/i,
|
|
29
|
+
/^\.next$/i,
|
|
30
|
+
/^\.nuxt$/i,
|
|
31
|
+
/^\.svelte-kit$/i,
|
|
32
|
+
/^target$/i,
|
|
33
|
+
/^bin$/i,
|
|
34
|
+
/^obj$/i,
|
|
35
|
+
/^__pycache__$/i,
|
|
36
|
+
/^\.mypy_cache$/i,
|
|
37
|
+
/^\.pytest_cache$/i,
|
|
38
|
+
/^\.ruff_cache$/i,
|
|
39
|
+
/^\.gradle$/i,
|
|
40
|
+
/^\.nuget$/i,
|
|
41
|
+
/^packages$/i, // NuGet packages restore dir
|
|
42
|
+
/^vendor$/i,
|
|
43
|
+
/^\.cache$/i,
|
|
44
|
+
/^coverage$/i,
|
|
45
|
+
/^\.coverage$/i,
|
|
46
|
+
/^htmlcov$/i,
|
|
47
|
+
/^\.turbo$/i,
|
|
48
|
+
/^\.parcel-cache$/i,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Normalise a path to forward-slash segments, filtering empty parts produced by
|
|
52
|
+
* leading slashes or doubled separators. */
|
|
53
|
+
function segments(p) {
|
|
54
|
+
return p.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check a list of file paths for committed build artefacts. Returns a flat list
|
|
59
|
+
* of findings `{ code, path, message }` — one per offending path — empty when
|
|
60
|
+
* no artefacts are detected.
|
|
61
|
+
*
|
|
62
|
+
* Only artefact *directories* are flagged (see the module header for why file
|
|
63
|
+
* extensions are deliberately out of scope).
|
|
64
|
+
*
|
|
65
|
+
* @param {string[]} paths - File paths to inspect (relative or absolute; both
|
|
66
|
+
* `/`-separated and `\`-separated are handled).
|
|
67
|
+
* @returns {{ code: string, path: string, message: string }[]}
|
|
68
|
+
*/
|
|
69
|
+
export function checkBuildArtefacts(paths) {
|
|
70
|
+
if (!Array.isArray(paths)) return [];
|
|
71
|
+
|
|
72
|
+
const findings = [];
|
|
73
|
+
|
|
74
|
+
for (const rawPath of paths) {
|
|
75
|
+
if (typeof rawPath !== 'string') continue;
|
|
76
|
+
|
|
77
|
+
const segs = segments(rawPath);
|
|
78
|
+
if (segs.length === 0) continue;
|
|
79
|
+
|
|
80
|
+
// If ANY segment is an artefact directory, the path is flagged regardless of
|
|
81
|
+
// what follows it — a file deep inside node_modules/ is as bad as
|
|
82
|
+
// node_modules/ itself. The final segment is included so a path that ends on
|
|
83
|
+
// the directory name (no trailing slash, e.g. "dist") is caught too.
|
|
84
|
+
const artefactSeg = segs.find((seg) => ARTEFACT_DIRS.some((re) => re.test(seg)));
|
|
85
|
+
if (artefactSeg === undefined) continue;
|
|
86
|
+
|
|
87
|
+
const isLeaf = artefactSeg === segs[segs.length - 1];
|
|
88
|
+
findings.push({
|
|
89
|
+
code: 'COMMITTED_BUILD_ARTEFACT',
|
|
90
|
+
path: rawPath,
|
|
91
|
+
message: isLeaf
|
|
92
|
+
? `path "${rawPath}" is a build/dependency directory that must not be committed — add it to .gitignore and remove it from the index.`
|
|
93
|
+
: `path "${rawPath}" is inside a build/dependency directory ("${artefactSeg}/") that must not be committed — add it to .gitignore and remove it from the index.`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Aggregate helper: run the artefact check and return `{ ok, findings }`.
|
|
102
|
+
*
|
|
103
|
+
* @param {string[]} paths - File paths to inspect.
|
|
104
|
+
* @returns {{ ok: boolean, findings: { code: string, path: string, message: string }[] }}
|
|
105
|
+
*/
|
|
106
|
+
export function verifyBuildArtefacts(paths) {
|
|
107
|
+
const findings = checkBuildArtefacts(paths);
|
|
108
|
+
return { ok: findings.length === 0, findings };
|
|
109
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Types for the zero-dependency gate module `dtcg.mjs`, hand-written because the
|
|
2
|
+
// gate ships as plain ESM with no build. Only the surface's consumers are typed
|
|
3
|
+
// here; the runtime is `dtcg.mjs`.
|
|
4
|
+
|
|
5
|
+
/** A single DTCG validation finding, in tree order. */
|
|
6
|
+
export interface DtcgFinding {
|
|
7
|
+
path: string;
|
|
8
|
+
code: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Validate a parsed DTCG token tree; returns a flat list of findings (empty when
|
|
13
|
+
* well-formed). Pure and deterministic. */
|
|
14
|
+
export function validateDtcg(root: unknown, opts?: { schema?: string }): DtcgFinding[];
|
|
15
|
+
|
|
16
|
+
export const DTCG_SCHEMA: string;
|
|
17
|
+
export function isRef(v: unknown): boolean;
|
|
18
|
+
export function refPath(v: unknown): string | null;
|
|
19
|
+
export function resolvedLiteral(root: unknown, value: unknown): unknown;
|
package/design/index.mjs
CHANGED
|
@@ -8,3 +8,5 @@ export { renderTokenView, checkView, GENERATED_MARK } from './generate.mjs';
|
|
|
8
8
|
export { validateDtcg, DTCG_SCHEMA, isRef, refPath, resolvedLiteral } from './dtcg.mjs';
|
|
9
9
|
export { tokenLiterals, findValueDrift } from './value-drift.mjs';
|
|
10
10
|
export { runGate } from './gate.mjs';
|
|
11
|
+
export { checkTokenCompleteness, verifyTokenCompleteness } from './token-completeness.mjs';
|
|
12
|
+
export { checkBuildArtefacts, verifyBuildArtefacts } from './build-artefacts.mjs';
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Deterministic token-system completeness check. Detects colour-only or partial
|
|
2
|
+
// token systems — where the semantic/alias layer, typography, spacing, or
|
|
3
|
+
// borders/radii are absent — so a capability execution that produces only a
|
|
4
|
+
// colour palette is surfaced as incomplete rather than silently accepted as done.
|
|
5
|
+
//
|
|
6
|
+
// It also checks description coverage — that groups and the semantic/role layer
|
|
7
|
+
// carry a $description so the file is legible without external documentation —
|
|
8
|
+
// while exempting raw primitive scale steps, which would otherwise false-positive
|
|
9
|
+
// on every legitimate 50→950 ramp.
|
|
10
|
+
//
|
|
11
|
+
// Explicitly out of scope: example quality (server vs. real usage vs. toy
|
|
12
|
+
// snippets), whether inline provenance is genuinely the source form, and whether
|
|
13
|
+
// the file is leaf-inline rather than sprawled — those are prose-rubric judgments
|
|
14
|
+
// for an antagonistic reviewer, not deterministic structural checks. Only
|
|
15
|
+
// structural completeness and description coverage are checked here.
|
|
16
|
+
//
|
|
17
|
+
// Usage: import checkTokenCompleteness / verifyTokenCompleteness or
|
|
18
|
+
// checkDescriptionCoverage / verifyDescriptionCoverage; pure and zero-dependency
|
|
19
|
+
// (Node built-ins only).
|
|
20
|
+
|
|
21
|
+
const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
22
|
+
const isRef = (v) => typeof v === 'string' && /^\{[^}]+\}$/.test(v.trim());
|
|
23
|
+
|
|
24
|
+
// Group-name patterns for each dimension, matched case-insensitively against
|
|
25
|
+
// top-level key names. Patterns lean broad: a valid token file will use names
|
|
26
|
+
// like "color"/"colour", "spacing"/"space"/"gap", "typography"/"type"/"font",
|
|
27
|
+
// "border"/"radius"/"radii". A file that uses none of these names for its groups
|
|
28
|
+
// is unlikely to be a full system — the false-positive risk is low.
|
|
29
|
+
const COLOUR_NAMES = /^colou?r(?:s)?$|^palette$|^brand$/i;
|
|
30
|
+
const TYPOGRAPHY_NAMES = /^typograph|^type$|^font|^text$/i;
|
|
31
|
+
const SPACING_NAMES = /^spacing$|^space$|^gap$|^size$|^scale$/i;
|
|
32
|
+
const BORDER_NAMES = /^border|^radius$|^radii$|^corner$/i;
|
|
33
|
+
|
|
34
|
+
/** A node is a non-scalar group: an object carrying child keys that are not DTCG
|
|
35
|
+
* metadata (not `$`-prefixed). A token group OR a composite DTCG group qualifies. */
|
|
36
|
+
const hasChildren = (node) =>
|
|
37
|
+
isObject(node) && Object.keys(node).some((k) => !k.startsWith('$') && isObject(node[k]));
|
|
38
|
+
|
|
39
|
+
/** Collect all token $value strings in the tree (aliases and literals alike).
|
|
40
|
+
* Used to detect whether any alias/semantic layer exists anywhere in the tree
|
|
41
|
+
* — not just at the top level. */
|
|
42
|
+
function collectValues(node, out = []) {
|
|
43
|
+
if (!isObject(node)) return out;
|
|
44
|
+
if ('$value' in node) out.push(node.$value);
|
|
45
|
+
for (const [k, v] of Object.entries(node)) if (!k.startsWith('$')) collectValues(v, out);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the tree contains at least one alias/reference token — a token whose
|
|
50
|
+
* `$value` is `{path.to.another.token}`. The presence of such a token indicates
|
|
51
|
+
* a semantic or role layer exists (palette tokens are typically raw values;
|
|
52
|
+
* role/alias tokens reference them). */
|
|
53
|
+
function hasAliasLayer(root) {
|
|
54
|
+
return collectValues(root).some(isRef);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether the top-level structure contains at least one non-empty group whose
|
|
58
|
+
* name matches the given pattern. `hasValue` is true when at least one token
|
|
59
|
+
* (any token, not just a raw-value one) exists under that group — the group
|
|
60
|
+
* must be non-trivially populated, not just an empty shell. */
|
|
61
|
+
function hasNonEmptyGroup(root, pattern) {
|
|
62
|
+
return Object.entries(root)
|
|
63
|
+
.filter(([k]) => !k.startsWith('$') && pattern.test(k))
|
|
64
|
+
.some(([, v]) => hasChildren(v));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether the tree contains any tokens at all — used to distinguish a genuinely
|
|
68
|
+
* empty file (no findings emitted; invalid DTCG is the DTCG gate's concern) from
|
|
69
|
+
* a partial system. An empty tree cannot meaningfully be called colour-only. */
|
|
70
|
+
function hasAnyToken(node) {
|
|
71
|
+
if (!isObject(node)) return false;
|
|
72
|
+
if ('$value' in node) return true;
|
|
73
|
+
return Object.entries(node).some(([k, v]) => !k.startsWith('$') && hasAnyToken(v));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether the tree contains any non-colour tokens (any group not matching colour
|
|
77
|
+
* names that has at least one token). Used for the COLOUR_ONLY detection: if the
|
|
78
|
+
* only populated groups are colour groups, the system is colour-only. */
|
|
79
|
+
function hasNonColourTokens(root) {
|
|
80
|
+
return Object.entries(root)
|
|
81
|
+
.filter(([k]) => !k.startsWith('$') && !COLOUR_NAMES.test(k))
|
|
82
|
+
.some(([, v]) => hasAnyToken(v));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check a parsed DTCG token tree for system completeness. Returns a flat list of
|
|
87
|
+
* findings `{ code, message }` — empty when the system looks complete.
|
|
88
|
+
*
|
|
89
|
+
* Findings emitted:
|
|
90
|
+
* COLOUR_ONLY — only colour groups are present; all other dimensions absent
|
|
91
|
+
* MISSING_SEMANTIC_LAYER — no alias/reference token found anywhere; role tokens absent
|
|
92
|
+
* MISSING_TYPOGRAPHY — no typography/type/font group present or non-empty
|
|
93
|
+
* MISSING_SPACING — no spacing/space/gap group present or non-empty
|
|
94
|
+
* MISSING_BORDER_RADIUS — no border/radius group present or non-empty
|
|
95
|
+
*
|
|
96
|
+
* The check is structural and heuristic — it detects clearly absent dimensions
|
|
97
|
+
* by name-matching top-level groups and inspecting $value strings. It will not
|
|
98
|
+
* false-positive a legitimately complete file that uses standard DTCG group names,
|
|
99
|
+
* and will not flag component-level tokens as required (they are optional per the
|
|
100
|
+
* done-well bar stated in the capability). A colour-only tree may produce multiple
|
|
101
|
+
* findings (one COLOUR_ONLY plus the individual MISSING_* findings); callers may
|
|
102
|
+
* use COLOUR_ONLY as the primary signal and suppress or include the rest.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} root - A parsed DTCG token object (the result of JSON.parse on
|
|
105
|
+
* a *.tokens.json file). Non-objects, null, and arrays are treated as empty and
|
|
106
|
+
* return no findings (DTCG validation is the validateDtcg gate's concern).
|
|
107
|
+
* @returns {{ code: string, message: string }[]}
|
|
108
|
+
*/
|
|
109
|
+
export function checkTokenCompleteness(root) {
|
|
110
|
+
if (!isObject(root)) return [];
|
|
111
|
+
if (!hasAnyToken(root)) return [];
|
|
112
|
+
|
|
113
|
+
const findings = [];
|
|
114
|
+
|
|
115
|
+
// COLOUR_ONLY: tokens exist but none outside colour groups.
|
|
116
|
+
if (!hasNonColourTokens(root)) {
|
|
117
|
+
findings.push({
|
|
118
|
+
code: 'COLOUR_ONLY',
|
|
119
|
+
message:
|
|
120
|
+
'token file contains only colour tokens — a full system requires a semantic/alias layer, typography, spacing, and borders/radii.',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MISSING_SEMANTIC_LAYER: no alias reference found anywhere in the tree.
|
|
125
|
+
if (!hasAliasLayer(root)) {
|
|
126
|
+
findings.push({
|
|
127
|
+
code: 'MISSING_SEMANTIC_LAYER',
|
|
128
|
+
message:
|
|
129
|
+
'no alias/reference tokens found — a full system needs a semantic layer of role tokens (e.g. {color.brand.primary}) that reference palette values.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// MISSING_TYPOGRAPHY: no typography/type/font group.
|
|
134
|
+
if (!hasNonEmptyGroup(root, TYPOGRAPHY_NAMES)) {
|
|
135
|
+
findings.push({
|
|
136
|
+
code: 'MISSING_TYPOGRAPHY',
|
|
137
|
+
message:
|
|
138
|
+
'no typography group found (expected a top-level "typography", "type", or "font" group) — font families, sizes, weights, and line-heights must be tokenised.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MISSING_SPACING: no spacing/space/gap group.
|
|
143
|
+
if (!hasNonEmptyGroup(root, SPACING_NAMES)) {
|
|
144
|
+
findings.push({
|
|
145
|
+
code: 'MISSING_SPACING',
|
|
146
|
+
message:
|
|
147
|
+
'no spacing group found (expected a top-level "spacing", "space", "gap", or "scale" group) — spacing and layout sizing must be tokenised.',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// MISSING_BORDER_RADIUS: no border/radius group.
|
|
152
|
+
if (!hasNonEmptyGroup(root, BORDER_NAMES)) {
|
|
153
|
+
findings.push({
|
|
154
|
+
code: 'MISSING_BORDER_RADIUS',
|
|
155
|
+
message:
|
|
156
|
+
'no border/radius group found (expected a top-level "border", "radius", "radii", or "corner" group) — border widths and radii must be tokenised.',
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return findings;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Aggregate helper: run the completeness check and return `{ ok, findings }`.
|
|
165
|
+
* `ok` is true only when there are no findings.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} root - A parsed DTCG token object.
|
|
168
|
+
* @returns {{ ok: boolean, findings: { code: string, message: string }[] }}
|
|
169
|
+
*/
|
|
170
|
+
export function verifyTokenCompleteness(root) {
|
|
171
|
+
const findings = checkTokenCompleteness(root);
|
|
172
|
+
return { ok: findings.length === 0, findings };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Description coverage
|
|
177
|
+
//
|
|
178
|
+
// A token file is read far more often than it is written. The readability bar
|
|
179
|
+
// (see generate-design-tokens) is that groups and the semantic/role layer carry
|
|
180
|
+
// a $description so a consumer learns the system from the file, not a wiki. Raw
|
|
181
|
+
// primitive scale steps (a 50→950 colour ramp, an 8px/16px spacing scale) are
|
|
182
|
+
// deliberately exempt: requiring a description on each step would false-positive
|
|
183
|
+
// on every legitimate scale, and the group's own description already says what
|
|
184
|
+
// the scale is and how to choose within it.
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
/** A leaf token: a node carrying a `$value`. */
|
|
188
|
+
const isToken = (node) => isObject(node) && '$value' in node;
|
|
189
|
+
|
|
190
|
+
/** A semantic/alias token: a leaf whose `$value` references another token
|
|
191
|
+
* (`{path.to.token}`). These are the consumption surface a consumer reaches for,
|
|
192
|
+
* so each must say what it is for — unlike a raw primitive whose meaning is its
|
|
193
|
+
* position in a scale. */
|
|
194
|
+
const isAliasToken = (node) => isToken(node) && isRef(node.$value);
|
|
195
|
+
|
|
196
|
+
/** A group is a raw primitive scale when every direct token child holds a raw
|
|
197
|
+
* (non-alias) value — e.g. `blue: { 50: {…}, 100: {…} }` or `space: { 1: {…} }`.
|
|
198
|
+
* Such a group's steps are exempt from per-step descriptions; the group itself
|
|
199
|
+
* still carries one. A group with no direct token children (only sub-groups) is
|
|
200
|
+
* not a scale — it is a sectioning group and is held to the group bar. */
|
|
201
|
+
function isRawScaleGroup(node) {
|
|
202
|
+
const tokenChildren = Object.entries(node)
|
|
203
|
+
.filter(([k]) => !k.startsWith('$'))
|
|
204
|
+
.map(([, v]) => v)
|
|
205
|
+
.filter(isToken);
|
|
206
|
+
return tokenChildren.length > 0 && tokenChildren.every((t) => !isRef(t.$value));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Walk the tree, collecting the human-readable path of every node that should
|
|
210
|
+
* carry a `$description` but does not: every group, and every semantic/alias
|
|
211
|
+
* token. Raw primitive leaf tokens are never required to carry one. */
|
|
212
|
+
function collectMissingDescriptions(node, path, out) {
|
|
213
|
+
if (!isObject(node)) return;
|
|
214
|
+
|
|
215
|
+
if (isToken(node)) {
|
|
216
|
+
if (isAliasToken(node) && typeof node.$description !== 'string') {
|
|
217
|
+
out.push({ path, kind: 'semantic token' });
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (hasChildren(node)) {
|
|
223
|
+
if (typeof node.$description !== 'string' && !isRawScaleGroup(node)) {
|
|
224
|
+
out.push({ path, kind: 'group' });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const [k, v] of Object.entries(node)) {
|
|
229
|
+
if (k.startsWith('$')) continue;
|
|
230
|
+
collectMissingDescriptions(v, path ? `${path}.${k}` : k, out);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check a parsed DTCG token tree for description coverage. Returns a flat list of
|
|
236
|
+
* findings `{ code, message }` — empty when every group and semantic/alias token
|
|
237
|
+
* carries a `$description`. Raw primitive scale steps are not required to.
|
|
238
|
+
*
|
|
239
|
+
* Findings emitted:
|
|
240
|
+
* MISSING_DESCRIPTIONS — one finding listing the groups and/or semantic tokens
|
|
241
|
+
* that lack a `$description`.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} root - A parsed DTCG token object.
|
|
244
|
+
* @returns {{ code: string, message: string }[]}
|
|
245
|
+
*/
|
|
246
|
+
export function checkDescriptionCoverage(root) {
|
|
247
|
+
if (!isObject(root)) return [];
|
|
248
|
+
if (!hasAnyToken(root)) return [];
|
|
249
|
+
|
|
250
|
+
const missing = [];
|
|
251
|
+
collectMissingDescriptions(root, '', missing);
|
|
252
|
+
if (missing.length === 0) return [];
|
|
253
|
+
|
|
254
|
+
const where = missing.map((m) => `${m.path} (${m.kind})`).join(', ');
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
code: 'MISSING_DESCRIPTIONS',
|
|
258
|
+
message: `${missing.length} group(s) and/or semantic token(s) lack a $description — a consumer must reach for external docs to know their purpose: ${where}.`,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Aggregate helper: run the description-coverage check and return `{ ok, findings }`.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} root - A parsed DTCG token object.
|
|
267
|
+
* @returns {{ ok: boolean, findings: { code: string, message: string }[] }}
|
|
268
|
+
*/
|
|
269
|
+
export function verifyDescriptionCoverage(root) {
|
|
270
|
+
const findings = checkDescriptionCoverage(root);
|
|
271
|
+
return { ok: findings.length === 0, findings };
|
|
272
|
+
}
|
package/design/value-drift.mjs
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// concept doc points at it, never re-types it. This asserts no value-free doc
|
|
3
3
|
// contains a literal token value. It is deliberately high-signal, not
|
|
4
4
|
// exhaustive: it matches only DISTINCTIVE literals (a hex colour, a dimension
|
|
5
|
-
// carrying a unit, a colour function, a
|
|
6
|
-
// is unambiguously a re-typed value; bare unitless numbers (font
|
|
7
|
-
// type-scale step) are owned by the generated value views, not
|
|
8
|
-
// because "700" in prose is all noise. Fenced code blocks are
|
|
9
|
-
// code is meant to carry real values.
|
|
5
|
+
// carrying a unit, a colour function, a font-family stack) where an occurrence
|
|
6
|
+
// in prose is unambiguously a re-typed value; bare unitless numbers (font
|
|
7
|
+
// weights, a type-scale step) are owned by the generated value views, not
|
|
8
|
+
// grepped here, because "700" in prose is all noise. Fenced code blocks are
|
|
9
|
+
// skipped — example code is meant to carry real values.
|
|
10
10
|
|
|
11
11
|
const isObject = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
12
12
|
const isRef = (v) => typeof v === 'string' && /^\{[^}]+\}$/.test(v.trim());
|
|
@@ -31,16 +31,37 @@ function literalsOf(value) {
|
|
|
31
31
|
return out;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Extract a font-family literal for a DTCG `fontFamily` token `$value`. Only
|
|
35
|
+
* array values (full font stacks like `["Inter", "system-ui", "sans-serif"]`)
|
|
36
|
+
* are collected — their comma-separated form is distinctive enough in prose that
|
|
37
|
+
* a match is unambiguously a re-typed value. A single-family string ("Inter") is
|
|
38
|
+
* deliberately skipped: a bare font name is too common in ordinary prose to flag
|
|
39
|
+
* safely. Returns the lowercase comma-separated stack, or null if not flaggable. */
|
|
40
|
+
function fontFamilyLiteral(value) {
|
|
41
|
+
if (Array.isArray(value) && value.length > 0)
|
|
42
|
+
return value.map((s) => String(s)).join(', ').toLowerCase();
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/** 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).
|
|
47
|
+
* tree collecting every concrete `$value` (aliases are pointers, so skipped).
|
|
48
|
+
* Carries the inherited `$type` so font-family stacks under a typed group are
|
|
49
|
+
* also captured. */
|
|
36
50
|
export function tokenLiterals(root) {
|
|
37
51
|
const set = new Set();
|
|
38
|
-
const walk = (node) => {
|
|
52
|
+
const walk = (node, inheritedType) => {
|
|
39
53
|
if (!isObject(node)) return;
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
const type = '$type' in node ? node.$type : inheritedType;
|
|
55
|
+
if ('$value' in node) {
|
|
56
|
+
for (const lit of literalsOf(node.$value)) set.add(lit);
|
|
57
|
+
if (type === 'fontFamily') {
|
|
58
|
+
const lit = fontFamilyLiteral(node.$value);
|
|
59
|
+
if (lit) set.add(lit);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const [k, v] of Object.entries(node)) if (!k.startsWith('$') && isObject(v)) walk(v, type);
|
|
42
63
|
};
|
|
43
|
-
walk(root);
|
|
64
|
+
walk(root, undefined);
|
|
44
65
|
return set;
|
|
45
66
|
}
|
|
46
67
|
|
package/design/verify-pack.mjs
CHANGED
|
@@ -75,10 +75,13 @@ export function verifyFiles(files) {
|
|
|
75
75
|
message: `generated view is missing or stale — run: node tooling/design/generate.mjs ${tp} ${viewPath}`,
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
// Concept docs: the `.md` files under
|
|
79
|
-
|
|
78
|
+
// Concept docs: the `.md` files under the reference-library root (the parent
|
|
79
|
+
// of `design-language/`). Strip `design-language/tokens/<name>.tokens.json`
|
|
80
|
+
// to reach that root so sibling dirs (`brand/`, `content/`, `accessibility/`,
|
|
81
|
+
// `media-types/`, …) are scanned alongside `design-language/` itself.
|
|
82
|
+
const refRoot = tp.replace(/design-language\/tokens\/[^/]+\.tokens\.json$/, '');
|
|
80
83
|
const docs = [...map.entries()]
|
|
81
|
-
.filter(([p]) => p.endsWith('.md') && p.startsWith(
|
|
84
|
+
.filter(([p]) => p.endsWith('.md') && p.startsWith(refRoot))
|
|
82
85
|
.map(([path, text]) => ({ path, text: String(text ?? '') }));
|
|
83
86
|
for (const d of runGate({ tokens, docs }).drift)
|
|
84
87
|
findings.push({
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@verevoir/design-gate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "The design substrate: a zero-dependency deterministic design gate (DTCG token validation, regenerate-diff of generated views, value-drift checks) that ships into a produced pack's CI, plus the typed DesignSurface contract (`./surface`) the tooling reasons over. The gate is the same code the design capabilities emit into a pack and the runtime imports to enforce a capability's `verify: design-pack` postcondition.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -10,18 +10,35 @@
|
|
|
10
10
|
"directory": "tooling"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
|
-
".": "./design/index.mjs"
|
|
13
|
+
".": "./design/index.mjs",
|
|
14
|
+
"./surface": {
|
|
15
|
+
"types": "./surface/surface.d.ts",
|
|
16
|
+
"import": "./surface/surface.js"
|
|
17
|
+
}
|
|
14
18
|
},
|
|
15
19
|
"files": [
|
|
16
20
|
"design/index.mjs",
|
|
17
21
|
"design/dtcg.mjs",
|
|
22
|
+
"design/dtcg.d.mts",
|
|
18
23
|
"design/gate.mjs",
|
|
19
24
|
"design/generate.mjs",
|
|
20
25
|
"design/value-drift.mjs",
|
|
21
26
|
"design/verify-pack.mjs",
|
|
22
|
-
"design/
|
|
27
|
+
"design/token-completeness.mjs",
|
|
28
|
+
"design/build-artefacts.mjs",
|
|
29
|
+
"design/README.md",
|
|
30
|
+
"surface/surface.js",
|
|
31
|
+
"surface/surface.d.ts"
|
|
23
32
|
],
|
|
24
33
|
"scripts": {
|
|
25
|
-
"
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"test": "npm run build && node --test",
|
|
36
|
+
"prepublishOnly": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"zod": "^3.23.8"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"typescript": "^5.5.4"
|
|
26
43
|
}
|
|
27
44
|
}
|