@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 CHANGED
@@ -1,12 +1,20 @@
1
1
  # @verevoir/design-gate
2
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.
3
+ The design substrate, in two layers:
6
4
 
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.
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
+ }
@@ -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 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.
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
- 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);
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
 
@@ -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 this token file's `design-language/` dir.
79
- const dlPrefix = tp.replace(/tokens\/[^/]+\.tokens\.json$/, '');
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(dlPrefix))
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.1.0",
3
+ "version": "0.2.0",
4
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.",
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/README.md"
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
- "test": "node --test"
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
  }