@vertekum/schema-builder 0.3.10 → 0.3.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @vertekum/schema-builder
2
2
 
3
+ ## 0.3.12
4
+
5
+ ### Patch Changes
6
+
7
+ - [`bcb1f46`](https://github.com/moros-oxus/vertekum/commit/bcb1f46b9491f24560d88d7a1b416bd13dd553a4) Thanks [@tschemmer](https://github.com/tschemmer)! - A configured schema that compiles but crashes during validation (a ref cycle, a validator too large for the stack) now surfaces as a `schema/invalid-schema` diagnostic naming the binding and file instead of taking every command down — so `schema build` can always regenerate a broken artifact. The builder's emit dedupe also hoists subtrees repeated as a single shared object reference, which previously escaped structural sharing entirely.
8
+ - Updated dependencies [[`bcb1f46`](https://github.com/moros-oxus/vertekum/commit/bcb1f46b9491f24560d88d7a1b416bd13dd553a4)]:
9
+ - @vertekum/core@0.3.12
10
+
11
+ ## 0.3.11
12
+
13
+ ### Patch Changes
14
+
15
+ - [`6265f42`](https://github.com/moros-oxus/vertekum/commit/6265f4285ed7b2e9a5e1dd2f6801911ddf9247d0) Thanks [@tschemmer](https://github.com/tschemmer)! - `vertekum schema mock` renders the matrix a vocabulary grants: a grouped name listing (`--style names`), a sample DTCG token file with per-type default values (`--style tokens`; types via the `mock.types` glob map, then `--type`), at `least` (every name adjacency once) or `full` (the whole matrix) coverage — and with `--break <p>`, a separate deliberately-broken sibling file that `check` must refuse. Deterministic at a fixed `--seed`; outputs land in the configured `mock.out`.
16
+ - Updated dependencies []:
17
+ - @vertekum/core@0.3.11
18
+
3
19
  ## 0.3.10
4
20
 
5
21
  ### Patch Changes
package/README.md CHANGED
@@ -62,6 +62,7 @@ root = color.<property>.<role>?.<emphasis>?.<state>?
62
62
  - [`schema build`](./docs/build.md) — the command, the sweep, the ownership stamp, the
63
63
  `--check` CI gate, programmatic use.
64
64
  - [`schema lint`](./docs/lint.md) — validate the `.dfn` sources themselves: fragments
65
+ - [`schema mock`](./docs/mock.md) — render the granted matrix: name listings, sample token files, deliberate breakage.
65
66
  included, every production, findings collected with positions; `--fix` repairs the
66
67
  mechanical ones.
67
68
  - [`schema fmt`](./docs/format.md) — canonical formatting: JS-literal blocks, indent
package/docs/mock.md ADDED
@@ -0,0 +1,63 @@
1
+ # `schema mock` — the matrix, made tangible
2
+
3
+ A vocabulary grants a space of names. `schema mock` renders that space as files you
4
+ can read, validate, and break on purpose:
5
+
6
+ ```bash
7
+ # vertekum schema mock [module] [--style names|tokens] [--coverage least|full] [--break <p>] [--type <t>] [--seed <n>]
8
+ vertekum schema mock # every module in the configured source
9
+ vertekum schema mock schemas/color.dfn # one module
10
+ vertekum schema mock --style tokens --break 0.3
11
+ ```
12
+
13
+ | Flag | Values | Default | What it does |
14
+ | --- | --- | --- | --- |
15
+ | `--style` | `names` \| `tokens` | both | `names` writes the markdown listing; `tokens` writes a sample DTCG file. |
16
+ | `--coverage` | `least` \| `full` | `least` | How much of the matrix each output holds (below). |
17
+ | `--break` | probability `0..1` | `0` | Each token independently breaks with this probability — emitted as a SEPARATE `*.broken.tokens.json`; the clean mock stays valid. |
18
+ | `--type` | a DTCG `$type` | `color` | Fallback type for mock tokens (the settings `mock.types` map wins). |
19
+ | `--seed` | integer | `1` | The breakage RNG seed — identical invocations produce identical files; vary it to vary the breaks. |
20
+
21
+ Outputs land in the configured `mock.out` (default `./mocks`), one trio per module:
22
+ `<module>.names.md` (grouped by top-level segment, with counts),
23
+ `<module>.mock.tokens.json`, and — with `--break` — `<module>.broken.tokens.json`.
24
+
25
+ ## Coverage
26
+
27
+ - **`least`** — every parent→child name adjacency appears at least once: each member
28
+ of each position is exercised somewhere, without the cross-product. The smoke-test
29
+ size.
30
+ - **`full`** — every complete token name the vocabulary grants. On a generative
31
+ module this is the whole matrix; expect thousands.
32
+
33
+ Token names are the tree's **leaves** — an interior name whose optional tail was
34
+ skipped is a group, never a token (that is what `$root` exists for).
35
+
36
+ ## Types and values
37
+
38
+ A mock token's `$type` resolves through: the settings map → `--type` → `color`.
39
+
40
+ ```ts
41
+ schemaBuilderExtension({
42
+ mock: {
43
+ out: './mocks',
44
+ types: { 'color.*': 'color', 'space.*': 'dimension' }, // name-glob → $type
45
+ },
46
+ })
47
+ ```
48
+
49
+ Values come from a fixed per-type table: color → white, dimension → `0px`,
50
+ duration → `0ms`, number → `0`, fontWeight → `400`, fontFamily → `["sans-serif"]`,
51
+ cubicBezier → `[0,0,1,1]`, strokeStyle → `"solid"`, and minimal spec objects for the
52
+ composites (border, shadow, transition, gradient, typography). An unknown type gets
53
+ the string `"mock"`.
54
+
55
+ ## Breakage
56
+
57
+ With `--break 0.5`, each token has a 50% chance of one deliberate violation — a coin
58
+ picks **name** (an ungranted `-broken` sibling, caught by the vocabulary seal) or
59
+ **value** (a type-mismatched literal, caught by the format binding; types without a
60
+ published value schema break by name instead, so every break stays catchable). Each
61
+ broken token carries `$description: "deliberately broken (name|value)"`. Copy the
62
+ broken file into a collection and watch `vertekum check` refuse it — a self-test of
63
+ the whole validation stack.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertekum/schema-builder",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Declare token vocabularies as grammar; build them into JSON Schema files",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -37,13 +37,13 @@
37
37
  "./cli": "./src/cli.ts"
38
38
  },
39
39
  "peerDependencies": {
40
- "@vertekum/core": "0.3.10"
40
+ "@vertekum/core": "0.3.12"
41
41
  },
42
42
  "dependencies": {
43
43
  "zod": "^3.25.76"
44
44
  },
45
45
  "devDependencies": {
46
- "@vertekum/core": "0.3.10",
46
+ "@vertekum/core": "0.3.12",
47
47
  "@vertekum/schema-vocabulary": "0.3.0"
48
48
  }
49
49
  }
package/src/api.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import type { ActivateContext } from '@vertekum/core';
2
- import { schemaBuildCommand, schemaFmtCommand, schemaLintCommand } from './cli';
2
+ import {
3
+ schemaBuildCommand,
4
+ schemaFmtCommand,
5
+ schemaLintCommand,
6
+ schemaMockCommand,
7
+ } from './cli';
3
8
  import type { schemaBuilderManifest } from './index';
4
9
 
5
10
  export {
@@ -33,4 +38,5 @@ export function activate(
33
38
  ctx.commands.register(schemaBuildCommand);
34
39
  ctx.commands.register(schemaLintCommand);
35
40
  ctx.commands.register(schemaFmtCommand);
41
+ ctx.commands.register(schemaMockCommand);
36
42
  }
package/src/cli.ts CHANGED
@@ -12,6 +12,16 @@ import { emit, isStamped } from './emit';
12
12
  import { DfnError } from './error';
13
13
  import { fixSource, formatSource, resolveIndent } from './format';
14
14
  import { lintModule } from './lint';
15
+ import {
16
+ breakTokens,
17
+ fullPaths,
18
+ leastPaths,
19
+ mockTokens,
20
+ renderNames,
21
+ renderTokens,
22
+ rng,
23
+ typeResolver,
24
+ } from './mock';
15
25
  import { type ResolvedModule, resolveModule } from './resolve';
16
26
 
17
27
  /**
@@ -37,6 +47,7 @@ function settingsOf(ctx: { project: unknown }): {
37
47
  out?: string;
38
48
  link: boolean;
39
49
  schemaId?: string;
50
+ mock: { out: string; types?: Record<string, string> };
40
51
  } {
41
52
  const kernel = (ctx.project as ProjectDir).kernel;
42
53
  const slice = kernel?.config.get<{
@@ -44,12 +55,17 @@ function settingsOf(ctx: { project: unknown }): {
44
55
  out?: string;
45
56
  link?: boolean;
46
57
  schemaId?: string;
58
+ mock?: { out?: string; types?: Record<string, string> };
47
59
  }>('vtk.schema-builder');
48
60
  return {
49
61
  source: slice?.source ?? './schemas',
50
62
  out: slice?.out,
51
63
  link: slice?.link === true,
52
64
  schemaId: slice?.schemaId,
65
+ mock: {
66
+ out: slice?.mock?.out ?? './mocks',
67
+ ...(slice?.mock?.types ? { types: slice.mock.types } : {}),
68
+ },
53
69
  };
54
70
  }
55
71
 
@@ -495,3 +511,135 @@ export const schemaBuildCommand: CommandDescriptor = {
495
511
  return { summary: notes.join('\n'), files, data: { skipped, fragments } };
496
512
  },
497
513
  };
514
+
515
+ /**
516
+ * `vertekum schema mock` — make the granted matrix tangible: a names listing, a usable sample
517
+ * token file, and (with `--break`) a deliberately broken sibling that `check` must catch.
518
+ * Coverage `least` exercises every parent→child name adjacency once; `full` is the whole
519
+ * matrix. Deterministic at a fixed `--seed`, so mocks are diff-stable.
520
+ */
521
+ export const schemaMockCommand: CommandDescriptor = {
522
+ name: 'schema mock',
523
+ description:
524
+ 'generate the name matrix and sample token files a vocabulary grants',
525
+ args: [
526
+ {
527
+ name: 'module',
528
+ required: false,
529
+ description:
530
+ "a .dfn file or directory; default: the configured source ('./schemas')",
531
+ },
532
+ ],
533
+ options: [
534
+ {
535
+ flag: '--style <style>',
536
+ description: "'names' (markdown) or 'tokens' (DTCG sample); default both",
537
+ },
538
+ {
539
+ flag: '--coverage <coverage>',
540
+ description:
541
+ "'least' (every name adjacency once; default) or 'full' (the whole matrix)",
542
+ },
543
+ {
544
+ flag: '--break <p>',
545
+ description:
546
+ 'probability 0..1 that each token breaks (name or value) — emitted as a separate *.broken.tokens.json',
547
+ },
548
+ {
549
+ flag: '--type <type>',
550
+ description: "fallback DTCG $type for mock tokens (default 'color')",
551
+ },
552
+ {
553
+ flag: '--seed <n>',
554
+ description: 'RNG seed for the breakage pass (default 1)',
555
+ },
556
+ ],
557
+ run(ctx): CommandResult {
558
+ const { projectDir } = ctx.project as ProjectDir;
559
+ const settings = settingsOf(ctx);
560
+ const style = (ctx.options.style as string | undefined) ?? 'both';
561
+ if (!['names', 'tokens', 'both'].includes(style)) {
562
+ throw new Error(`--style must be 'names' or 'tokens', got '${style}'`);
563
+ }
564
+ const coverage = (ctx.options.coverage as string | undefined) ?? 'least';
565
+ if (!['least', 'full'].includes(coverage)) {
566
+ throw new Error(
567
+ `--coverage must be 'least' or 'full', got '${coverage}'`,
568
+ );
569
+ }
570
+ const breakP = Number((ctx.options.break as string | undefined) ?? '0');
571
+ if (!Number.isFinite(breakP) || breakP < 0 || breakP > 1) {
572
+ throw new Error(`--break must be a probability 0..1`);
573
+ }
574
+ const seed = Number((ctx.options.seed as string | undefined) ?? '1');
575
+ const typeOf = typeResolver(
576
+ settings.mock.types,
577
+ ctx.options.type as string | undefined,
578
+ );
579
+
580
+ const { modules, explicitFile, root } = resolveModules(
581
+ projectDir,
582
+ ctx.args.module,
583
+ settings.source,
584
+ );
585
+ if (modules.length === 0) {
586
+ return { summary: 'no .dfn modules found', files: [] };
587
+ }
588
+
589
+ const files: Array<{ path: string; content: string }> = [];
590
+ const skipped: string[] = [];
591
+ let names = 0;
592
+ let tokensOut = 0;
593
+
594
+ for (const modulePath of modules) {
595
+ const resolved = resolveModule(modulePath);
596
+ if (natureOf(resolved) === 'inline' || !resolved.module.root) {
597
+ // Inline modules never emit; a rootless fragment has no matrix of its own. In a sweep
598
+ // both are notices; an explicit fragment is worth an error.
599
+ if (explicitFile) {
600
+ throw new Error(
601
+ `${relative(projectDir, modulePath)} declares no root — nothing to mock`,
602
+ );
603
+ }
604
+ skipped.push(relative(projectDir, modulePath));
605
+ continue;
606
+ }
607
+ const tree = build(resolved);
608
+ const paths = coverage === 'full' ? fullPaths(tree) : leastPaths(tree);
609
+ const rel = relative(root, modulePath).replace(/\.dfn$/, '');
610
+ const base = join(settings.mock.out, rel);
611
+ const moduleName = rel.split('/').pop() as string;
612
+
613
+ if (style !== 'tokens') {
614
+ files.push({
615
+ path: `${base}.names.md`,
616
+ content: renderNames(moduleName, coverage, paths),
617
+ });
618
+ names += paths.length;
619
+ }
620
+ if (style !== 'names') {
621
+ const tokens = mockTokens(paths, typeOf);
622
+ files.push({
623
+ path: `${base}.mock.tokens.json`,
624
+ content: renderTokens(tokens),
625
+ });
626
+ tokensOut += tokens.length;
627
+ if (breakP > 0) {
628
+ const broken = breakTokens(tokens, breakP, rng(seed));
629
+ files.push({
630
+ path: `${base}.broken.tokens.json`,
631
+ content: renderTokens(broken),
632
+ });
633
+ }
634
+ }
635
+ }
636
+
637
+ const parts = [
638
+ `mocked ${files.length} file(s) (${coverage})`,
639
+ style !== 'tokens' ? `${names} name(s)` : null,
640
+ style !== 'names' ? `${tokensOut} token(s)` : null,
641
+ skipped.length > 0 ? `skipped ${skipped.join(', ')}` : null,
642
+ ].filter(Boolean);
643
+ return { summary: parts.join(' — '), files };
644
+ },
645
+ };
package/src/dedupe.ts CHANGED
@@ -84,11 +84,35 @@ function visitChildren(
84
84
  }
85
85
  }
86
86
 
87
+ /**
88
+ * Break object-identity sharing: a subtree reachable from two schema positions as the SAME
89
+ * reference serializes twice but signs once (the memo is identity-keyed), so it would land in
90
+ * a bucket of one and never hoist. Copying the re-encountered node makes every position a
91
+ * distinct object, after which occurrence counting is simply object counting.
92
+ */
93
+ function unshare(document: Json): void {
94
+ const seen = new Set<Json>();
95
+ const walk = (node: Json): void => {
96
+ visitChildren(node, (child, replace) => {
97
+ if (seen.has(child)) {
98
+ const copy = JSON.parse(JSON.stringify(child)) as Json;
99
+ replace(copy);
100
+ walk(copy);
101
+ return;
102
+ }
103
+ seen.add(child);
104
+ walk(child);
105
+ });
106
+ };
107
+ walk(document);
108
+ }
109
+
87
110
  /**
88
111
  * Hoist repeated subtrees of `document` into its `$defs`, in place. Iterates to a fixpoint:
89
112
  * hoisting inner duplicates makes outer subtrees identical, which the next round catches.
90
113
  */
91
114
  export function dedupeSubtrees(document: Json): void {
115
+ unshare(document);
92
116
  const defs = (): Json => {
93
117
  if (!document.$defs || typeof document.$defs !== 'object') {
94
118
  document.$defs = {};
package/src/index.ts CHANGED
@@ -24,6 +24,13 @@ export const SchemaBuilderSettings = z.object({
24
24
  * A file's `id` pragma wins. Unset: no `$id` unless the pragma supplies one.
25
25
  */
26
26
  schemaId: z.string().optional(),
27
+ /** `schema mock` output: where mocks land, and a name-glob → DTCG `$type` map. */
28
+ mock: z
29
+ .object({
30
+ out: z.string().default('./mocks'),
31
+ types: z.record(z.string()).optional(),
32
+ })
33
+ .default({}),
27
34
  });
28
35
 
29
36
  export const schemaBuilderManifest = {
package/src/mock.ts ADDED
Binary file