@vertekum/schema-builder 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/src/build.ts ADDED
@@ -0,0 +1,261 @@
1
+ import type { Node, Ref } from './ast';
2
+ import { DfnError } from './error';
3
+ import type { ResolvedModule } from './resolve';
4
+
5
+ /**
6
+ * The expanded name tree a root permits. `open` marks a position whose child SET admits additions
7
+ * (every member, listed or added, takes the same tail). `denotation` names the terminal
8
+ * denotation this subtree came from — emission turns multi-use denotations into `$defs`.
9
+ */
10
+ export interface TreeNode {
11
+ children: Map<string, TreeNode>;
12
+ open: boolean;
13
+ denotation?: string;
14
+ }
15
+
16
+ const leaf = (): TreeNode => ({ children: new Map(), open: false });
17
+
18
+ function merge(into: TreeNode, from: TreeNode): void {
19
+ into.open = into.open || from.open;
20
+ if (into.denotation !== from.denotation) into.denotation = undefined;
21
+ for (const [name, child] of from.children) {
22
+ const existing = into.children.get(name);
23
+ if (existing) merge(existing, child);
24
+ else into.children.set(name, child);
25
+ }
26
+ }
27
+
28
+ /** Evaluation scope: the module whose productions and imports are in view. */
29
+ interface Scope {
30
+ module: ResolvedModule;
31
+ /** Local productions currently expanding, for reference-cycle detection. */
32
+ expanding: Set<string>;
33
+ }
34
+
35
+ /** Find what `<name>` / `<@name>` means in this scope. Imported roots go by module name. */
36
+ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
37
+ if (!ref.imported) {
38
+ const production = scope.module.module.productions.get(ref.name);
39
+ if (!production) {
40
+ throw new DfnError(`unknown production '<${ref.name}>'`, 1, 1);
41
+ }
42
+ return { node: production, scope };
43
+ }
44
+
45
+ const hits: Array<{ node: Node; scope: Scope }> = [];
46
+ for (const imported of scope.module.imports.values()) {
47
+ const importedScope: Scope = { module: imported, expanding: new Set() };
48
+ const production = imported.module.productions.get(ref.name);
49
+ if (production) hits.push({ node: production, scope: importedScope });
50
+ if (imported.name === ref.name && imported.module.root) {
51
+ hits.push({ node: imported.module.root, scope: importedScope });
52
+ }
53
+ }
54
+ if (hits.length === 0) {
55
+ throw new DfnError(`no import provides '<@${ref.name}>'`, 1, 1);
56
+ }
57
+ if (hits.length > 1) {
58
+ throw new DfnError(`'<@${ref.name}>' is ambiguous across imports`, 1, 1);
59
+ }
60
+ return hits[0];
61
+ }
62
+
63
+ /**
64
+ * Evaluate a node to the forest of names it permits, each with its subtree. `tail` is what hangs
65
+ * beneath every name this node produces — how `a.<set>.b` threads `b` under each member of the
66
+ * set, which is also what makes an open set's additions well-defined.
67
+ */
68
+ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
69
+ switch (node.kind) {
70
+ case 'name': {
71
+ const forest = leaf();
72
+ forest.children.set(node.value, tail());
73
+ return forest;
74
+ }
75
+ case 'range': {
76
+ const forest = leaf();
77
+ for (let n = node.min; n <= node.max; n += node.step) {
78
+ forest.children.set(String(n), tail());
79
+ }
80
+ return forest;
81
+ }
82
+ case 'alt': {
83
+ const forest = leaf();
84
+ for (const option of node.options) {
85
+ merge(forest, evaluate(option, scope, tail));
86
+ }
87
+ return forest;
88
+ }
89
+ case 'path': {
90
+ // Steps expand right to left: each step becomes the tail of the one before it. An
91
+ // optional step (`?`) may be SKIPPED — its tail also attaches directly, so
92
+ // `<role>.<emphasis>?.<interaction>?` grants the whole slot-collapse lattice.
93
+ let next = tail;
94
+ for (let i = node.steps.length - 1; i > 0; i--) {
95
+ const step = node.steps[i];
96
+ const after = next;
97
+ next = step.optional
98
+ ? () => {
99
+ const forest = evaluate(step.term, scope, after);
100
+ merge(forest, after());
101
+ return forest;
102
+ }
103
+ : () => evaluate(step.term, scope, after);
104
+ }
105
+ const first = node.steps[0];
106
+ const forest = evaluate(first.term, scope, next);
107
+ if (first.optional) merge(forest, next());
108
+ return forest;
109
+ }
110
+ case 'group': {
111
+ const forest = evaluate(node.node, scope, tail);
112
+ if (node.open) forest.open = true;
113
+ return forest;
114
+ }
115
+ case 'ref': {
116
+ const { node: production, scope: refScope } = target(node, scope);
117
+ if (!node.imported) {
118
+ if (scope.expanding.has(node.name)) {
119
+ throw new DfnError(`'<${node.name}>' expands through itself`, 1, 1);
120
+ }
121
+ scope.expanding.add(node.name);
122
+ }
123
+ const forest = evaluate(production, refScope, tail);
124
+ if (!node.imported) scope.expanding.delete(node.name);
125
+ for (const omitted of node.omit) {
126
+ if (!forest.children.delete(omitted)) {
127
+ throw new DfnError(
128
+ `'<${node.name}>' has no member '${omitted}' to omit`,
129
+ 1,
130
+ 1,
131
+ );
132
+ }
133
+ }
134
+ if (node.pick.length > 0) {
135
+ for (const picked of node.pick) {
136
+ if (!forest.children.has(picked)) {
137
+ throw new DfnError(
138
+ `'<${node.name}>' has no member '${picked}' to pick`,
139
+ 1,
140
+ 1,
141
+ );
142
+ }
143
+ }
144
+ const keep = new Set(node.pick);
145
+ for (const name of [...forest.children.keys()]) {
146
+ if (!keep.has(name)) forest.children.delete(name);
147
+ }
148
+ }
149
+ if (node.open) forest.open = true;
150
+ // A modified set is a different set — it never shares the source denotation's $def.
151
+ if (
152
+ node.pick.length === 0 &&
153
+ node.omit.length === 0 &&
154
+ isNameSet(production) &&
155
+ isTerminal(tail())
156
+ ) {
157
+ forest.denotation = node.name;
158
+ }
159
+ return forest;
160
+ }
161
+ }
162
+ }
163
+
164
+ /** A production of names/ranges only — the sets `*` may open and `$defs` may name. */
165
+ function isNameSet(node: Node): boolean {
166
+ switch (node.kind) {
167
+ case 'name':
168
+ case 'range':
169
+ return true;
170
+ case 'alt':
171
+ return node.options.every(isNameSet);
172
+ default:
173
+ return false;
174
+ }
175
+ }
176
+
177
+ function isTerminal(node: TreeNode): boolean {
178
+ return node.children.size === 0;
179
+ }
180
+
181
+ /** Expand a module's root into its name tree. Fragments-only modules cannot build. */
182
+ export function build(resolved: ResolvedModule): TreeNode {
183
+ const root = resolved.module.root;
184
+ if (!root) {
185
+ throw new DfnError(`${resolved.name}.dfn declares no root`, 1, 1);
186
+ }
187
+ const scope: Scope = { module: resolved, expanding: new Set() };
188
+
189
+ // An aggregate root unions imported roots; the same top-level name arriving from two different
190
+ // module refs is a collision, not a merge — each module owns its top. `[a | b]` and `a | b`
191
+ // are the same aggregate, so a root-level group unwraps first.
192
+ const aggregate = root.kind === 'group' ? root.node : root;
193
+ if (aggregate.kind === 'alt') {
194
+ const seen = new Map<string, string>();
195
+ for (const option of aggregate.options) {
196
+ if (option.kind !== 'ref' || !option.imported) continue;
197
+ const forest = evaluate(option, scope, leaf);
198
+ for (const name of forest.children.keys()) {
199
+ const owner = seen.get(name);
200
+ if (owner && owner !== option.name) {
201
+ throw new DfnError(
202
+ `top-level '${name}' comes from both '${owner}' and '${option.name}'`,
203
+ 1,
204
+ 1,
205
+ );
206
+ }
207
+ seen.set(name, option.name);
208
+ }
209
+ }
210
+ }
211
+
212
+ const tree = evaluate(root, scope, leaf);
213
+ // Open markers on the root's own set have no parent position to attach to.
214
+ tree.open = false;
215
+ return tree;
216
+ }
217
+
218
+ /**
219
+ * Enforce that every `*` sat on a name-only set: evaluation flags are structural, so the check is
220
+ * on the AST — walk refs/groups marked open and verify their expansion source.
221
+ */
222
+ export function assertOpenSetsAreNameSets(
223
+ resolved: ResolvedModule,
224
+ node: Node | undefined = resolved.module.root,
225
+ scope: Scope = { module: resolved, expanding: new Set() },
226
+ ): void {
227
+ if (!node) return;
228
+ switch (node.kind) {
229
+ case 'alt':
230
+ for (const option of node.options) {
231
+ assertOpenSetsAreNameSets(resolved, option, scope);
232
+ }
233
+ return;
234
+ case 'path':
235
+ for (const step of node.steps) {
236
+ assertOpenSetsAreNameSets(resolved, step.term, scope);
237
+ }
238
+ return;
239
+ case 'group':
240
+ if (node.open && !isNameSet(node.node)) {
241
+ throw new DfnError('* opens a set of names, not sub-paths', 1, 1);
242
+ }
243
+ assertOpenSetsAreNameSets(resolved, node.node, scope);
244
+ return;
245
+ case 'ref': {
246
+ if (node.open) {
247
+ const { node: production } = target(node, scope);
248
+ if (!isNameSet(production)) {
249
+ throw new DfnError(
250
+ `'<${node.name}*>' opens a set, but the production is not name-only`,
251
+ 1,
252
+ 1,
253
+ );
254
+ }
255
+ }
256
+ return;
257
+ }
258
+ default:
259
+ return;
260
+ }
261
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
+ import type { CommandDescriptor, CommandResult } from '@vertekum/core';
4
+ import { assertOpenSetsAreNameSets, build } from './build';
5
+ import { emit, isStamped } from './emit';
6
+ import { resolveModule } from './resolve';
7
+
8
+ /**
9
+ * `vertekum schema build [module]` (ADR-0030): expand `.dfn` modules and DECLARE the built JSON
10
+ * Schema files on the result — the runner owns writing, `--dry-run`, and `--json`. With no
11
+ * module argument, every `.dfn` under the project's `schemas/` directory builds. `--check`
12
+ * verifies the built files on disk are current instead of writing; stale files are an error
13
+ * (exit 1), which is the CI staleness gate.
14
+ */
15
+
16
+ /** The slice of the CLI's Project this command reads. */
17
+ interface ProjectDir {
18
+ projectDir: string;
19
+ }
20
+
21
+ function modulesUnder(dir: string): string[] {
22
+ if (!existsSync(dir)) return [];
23
+ return readdirSync(dir, { recursive: true, encoding: 'utf8' })
24
+ .filter((f) => f.endsWith('.dfn') && !f.includes('node_modules'))
25
+ .map((f) => join(dir, f));
26
+ }
27
+
28
+ export function buildModule(modulePath: string): {
29
+ target: string;
30
+ content: string;
31
+ } {
32
+ const resolved = resolveModule(modulePath);
33
+ assertOpenSetsAreNameSets(resolved);
34
+ const tree = build(resolved);
35
+ const moduleFile = basename(modulePath);
36
+ return {
37
+ target: join(dirname(modulePath), `${resolved.name}.json`),
38
+ content: emit(tree, { moduleFile, ...resolved.module.meta }),
39
+ };
40
+ }
41
+
42
+ export const schemaBuildCommand: CommandDescriptor = {
43
+ name: 'schema build',
44
+ description: 'build .dfn vocabulary modules into JSON Schema files',
45
+ args: [
46
+ {
47
+ name: 'module',
48
+ required: false,
49
+ description: "a .dfn file; default: every module under './schemas'",
50
+ },
51
+ ],
52
+ options: [
53
+ {
54
+ flag: '--check',
55
+ description: 'verify built files are current; write nothing',
56
+ },
57
+ ],
58
+ run(ctx): CommandResult {
59
+ const { projectDir } = ctx.project as ProjectDir;
60
+ const modules = ctx.args.module
61
+ ? [join(projectDir, ctx.args.module)]
62
+ : modulesUnder(join(projectDir, 'schemas'));
63
+ if (modules.length === 0) {
64
+ return { summary: 'no .dfn modules found', files: [] };
65
+ }
66
+
67
+ const files: Array<{ path: string; content: string }> = [];
68
+ const skipped: string[] = [];
69
+ const stale: string[] = [];
70
+
71
+ for (const modulePath of modules) {
72
+ const { target, content } = buildModule(modulePath);
73
+ const existing = existsSync(target)
74
+ ? readFileSync(target, 'utf8')
75
+ : undefined;
76
+ const targetRelative = relative(projectDir, target);
77
+
78
+ if (ctx.options.check === true) {
79
+ if (existing !== content) stale.push(targetRelative);
80
+ continue;
81
+ }
82
+ if (existing !== undefined && !isStamped(existing)) {
83
+ skipped.push(targetRelative);
84
+ continue;
85
+ }
86
+ files.push({ path: targetRelative, content });
87
+ }
88
+
89
+ if (stale.length > 0) {
90
+ throw new Error(
91
+ `stale built schemas: ${stale.join(', ')} — run \`vertekum schema build\``,
92
+ );
93
+ }
94
+ const notes = [
95
+ ctx.options.check === true
96
+ ? `${modules.length} module(s) current`
97
+ : `built ${files.length} module(s)`,
98
+ ...skipped.map((f) => `${f} has local edits (no stamp) — left as is`),
99
+ ];
100
+ return { summary: notes.join('\n'), files, data: { skipped } };
101
+ },
102
+ };
package/src/emit.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { TreeNode } from './build';
2
+
3
+ /**
4
+ * Emit a name tree as the names-and-order JSON Schema shape shipped schemas use: every position
5
+ * `type: object`, declared children under `properties`, `$`-members passing via
6
+ * `patternProperties`, and closure via `unevaluatedProperties: false` — except open positions,
7
+ * which route additions to the members' shared tail through `additionalProperties` instead.
8
+ * Terminal denotation refs used more than once become `$defs`, matching the hand-written style.
9
+ */
10
+
11
+ export const STAMP_PREFIX = 'built by @vertekum/schema-builder from';
12
+
13
+ export function stamp(moduleFile: string): string {
14
+ return `${STAMP_PREFIX} ${moduleFile} — do not hand-edit; remove this comment to take ownership`;
15
+ }
16
+
17
+ type Schema = Record<string, unknown>;
18
+
19
+ function countDenotations(node: TreeNode, counts: Map<string, number>): void {
20
+ if (node.denotation) {
21
+ counts.set(node.denotation, (counts.get(node.denotation) ?? 0) + 1);
22
+ }
23
+ for (const child of node.children.values()) countDenotations(child, counts);
24
+ }
25
+
26
+ function position(node: TreeNode, defs: Set<string>): Schema {
27
+ if (node.denotation && defs.has(node.denotation)) {
28
+ return { $ref: `#/$defs/${node.denotation}` };
29
+ }
30
+ const properties: Schema = {};
31
+ for (const [name, child] of node.children) {
32
+ properties[name] = position(child, defs);
33
+ }
34
+ const schema: Schema = {
35
+ type: 'object',
36
+ properties,
37
+ patternProperties: { '^\\$': true },
38
+ };
39
+ if (node.open) {
40
+ // Additions join the set: they take the same tail every listed member has. All members share
41
+ // it by construction (`*` is restricted to name-only sets), so the first child is the shape.
42
+ const first = node.children.values().next().value as TreeNode | undefined;
43
+ schema.additionalProperties = first
44
+ ? position(first, defs)
45
+ : { type: 'object', patternProperties: { '^\\$': true } };
46
+ } else {
47
+ schema.unevaluatedProperties = false;
48
+ }
49
+ return schema;
50
+ }
51
+
52
+ /** The $def body for a denotation node: its own position schema, never self-referential. */
53
+ function definition(node: TreeNode, defs: Set<string>): Schema {
54
+ const bare: TreeNode = { ...node, denotation: undefined };
55
+ return position(bare, defs);
56
+ }
57
+
58
+ export interface EmitOptions {
59
+ /** The module filename for the provenance stamp, e.g. `house.dfn`. */
60
+ moduleFile: string;
61
+ /** From the module's pragmas: `id "…"`, `title "…"`, `description "…"`, `scope "…"`. */
62
+ id?: string;
63
+ title?: string;
64
+ description?: string;
65
+ scope?: 'document' | 'branch';
66
+ }
67
+
68
+ export function emit(tree: TreeNode, options: EmitOptions): string {
69
+ const counts = new Map<string, number>();
70
+ countDenotations(tree, counts);
71
+ const shared = new Set(
72
+ [...counts.entries()].filter(([, n]) => n >= 2).map(([name]) => name),
73
+ );
74
+
75
+ const document: Schema = {
76
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
77
+ };
78
+ if (options.id) document.$id = options.id;
79
+ document.$comment = stamp(options.moduleFile);
80
+ if (options.title) document.title = options.title;
81
+ if (options.description) document.description = options.description;
82
+
83
+ if (shared.size > 0) {
84
+ const defs: Schema = {};
85
+ const collect = (node: TreeNode): void => {
86
+ if (
87
+ node.denotation &&
88
+ shared.has(node.denotation) &&
89
+ !(node.denotation in defs)
90
+ ) {
91
+ defs[node.denotation] = definition(node, shared);
92
+ }
93
+ for (const child of node.children.values()) collect(child);
94
+ };
95
+ collect(tree);
96
+ document.$defs = defs;
97
+ }
98
+
99
+ const root = position(tree, shared);
100
+ document.type = root.type;
101
+ document.properties = root.properties;
102
+ document.patternProperties = root.patternProperties;
103
+ // `scope "branch"`: govern only the named top-level branches — the document root stays
104
+ // unsealed so sibling vocabularies can bind over the same files. Default seals.
105
+ if (options.scope !== 'branch') {
106
+ document.unevaluatedProperties = root.unevaluatedProperties;
107
+ }
108
+
109
+ return `${JSON.stringify(document, null, 2)}\n`;
110
+ }
111
+
112
+ /** True when `content` carries the generated stamp — the file is the builder's to overwrite. */
113
+ export function isStamped(content: string): boolean {
114
+ return content.includes(STAMP_PREFIX);
115
+ }
package/src/error.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** A syntax or semantic error in a `.dfn` module, positioned for the author. */
2
+ export class DfnError extends Error {
3
+ readonly line: number;
4
+ readonly column: number;
5
+
6
+ constructor(message: string, line: number, column: number) {
7
+ super(`${line}:${column} ${message}`);
8
+ this.name = 'DfnError';
9
+ this.line = line;
10
+ this.column = column;
11
+ }
12
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { defineExtension, type ExtensionManifest } from '@vertekum/core';
2
+ import { activate } from './api';
3
+
4
+ export const schemaBuilderManifest = {
5
+ id: 'vtk.schema-builder',
6
+ name: 'Schema Builder',
7
+ description:
8
+ "Builds `.dfn` vocabulary modules — a grammar for declaring which token names may exist, and in what order — into the JSON Schema files the `schemas` config binds. Contributes the 'schema build' command. A non-view extension: no route, no ribbon entry.",
9
+ activation: ['onStartup'],
10
+ } satisfies ExtensionManifest;
11
+
12
+ /**
13
+ * Grammar in, schemas out: the vocabulary is DECLARED in `.dfn` modules and BUILT into ordinary
14
+ * schema files, which stay the only thing binding and validation ever see.
15
+ */
16
+ export const schemaBuilderExtension = defineExtension<
17
+ typeof schemaBuilderManifest
18
+ >({
19
+ manifest: schemaBuilderManifest,
20
+ activate,
21
+ });