@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 +16 -0
- package/README.md +1 -0
- package/docs/mock.md +63 -0
- package/package.json +3 -3
- package/src/api.ts +7 -1
- package/src/cli.ts +148 -0
- package/src/dedupe.ts +24 -0
- package/src/index.ts +7 -0
- package/src/mock.ts +0 -0
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|