@vertekum/schema-builder 0.1.3 → 0.1.5

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/cli.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
- import { basename, dirname, join, relative } from 'node:path';
2
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
3
3
  import type { CommandDescriptor, CommandResult } from '@vertekum/core';
4
4
  import { assertOpenSetsAreNameSets, build } from './build';
5
5
  import { emit, isStamped } from './emit';
6
+ import { lintModule } from './lint';
6
7
  import { resolveModule } from './resolve';
7
8
 
8
9
  /**
@@ -25,20 +26,74 @@ function modulesUnder(dir: string): string[] {
25
26
  .map((f) => join(dir, f));
26
27
  }
27
28
 
28
- export function buildModule(modulePath: string): {
29
+ export function buildModule(
30
+ modulePath: string,
31
+ /** The module name the provenance stamp records; default: the basename. Pass a relative
32
+ * path when modules nest, so same-named modules stamp distinguishably. */
33
+ moduleLabel?: string,
34
+ ): {
29
35
  target: string;
30
36
  content: string;
31
37
  } {
32
38
  const resolved = resolveModule(modulePath);
33
39
  assertOpenSetsAreNameSets(resolved);
34
40
  const tree = build(resolved);
35
- const moduleFile = basename(modulePath);
41
+ const moduleFile = moduleLabel ?? basename(modulePath);
36
42
  return {
37
43
  target: join(dirname(modulePath), `${resolved.name}.json`),
38
44
  content: emit(tree, { moduleFile, ...resolved.module.meta }),
39
45
  };
40
46
  }
41
47
 
48
+ /**
49
+ * `vertekum schema lint [module]`: validate the `.dfn` sources themselves. Where `build --check`
50
+ * asks "are the artifacts current?", lint asks "is the grammar sound?" — fragments included,
51
+ * every production evaluated, findings collected instead of aborting on the first. Findings exit
52
+ * `1` through a thrown error carrying the listing (the `build --check` staleness precedent).
53
+ */
54
+ export const schemaLintCommand: CommandDescriptor = {
55
+ name: 'schema lint',
56
+ description:
57
+ 'validate .dfn vocabulary modules — fragments included — without building',
58
+ args: [
59
+ {
60
+ name: 'module',
61
+ required: false,
62
+ description: "a .dfn file; default: every module under './schemas'",
63
+ },
64
+ ],
65
+ run(ctx): CommandResult {
66
+ const { projectDir } = ctx.project as ProjectDir;
67
+ const modules = ctx.args.module
68
+ ? [join(projectDir, ctx.args.module)]
69
+ : modulesUnder(join(projectDir, 'schemas'));
70
+ if (modules.length === 0) {
71
+ return { summary: 'no .dfn modules found' };
72
+ }
73
+
74
+ const project = (file: string) =>
75
+ isAbsolute(file) ? relative(projectDir, file) : file;
76
+ const diagnostics = modules.flatMap((modulePath) =>
77
+ lintModule(modulePath).map((d) => ({ ...d, file: project(d.file) })),
78
+ );
79
+
80
+ if (diagnostics.length > 0) {
81
+ throw new Error(
82
+ [
83
+ `${diagnostics.length} problem(s) in the .dfn modules:`,
84
+ ...diagnostics.map(
85
+ (d) => ` ${d.file}:${d.line}:${d.column} ${d.message}`,
86
+ ),
87
+ ].join('\n'),
88
+ );
89
+ }
90
+ return {
91
+ summary: `${modules.length} module(s) clean`,
92
+ data: { modules: modules.map(project) },
93
+ };
94
+ },
95
+ };
96
+
42
97
  export const schemaBuildCommand: CommandDescriptor = {
43
98
  name: 'schema build',
44
99
  description: 'build .dfn vocabulary modules into JSON Schema files',
@@ -77,7 +132,10 @@ export const schemaBuildCommand: CommandDescriptor = {
77
132
  fragments.push(relative(projectDir, modulePath));
78
133
  continue;
79
134
  }
80
- const { target, content } = buildModule(modulePath);
135
+ const { target, content } = buildModule(
136
+ modulePath,
137
+ relative(projectDir, modulePath),
138
+ );
81
139
  const existing = existsSync(target)
82
140
  ? readFileSync(target, 'utf8')
83
141
  : undefined;
package/src/error.ts CHANGED
@@ -2,11 +2,17 @@
2
2
  export class DfnError extends Error {
3
3
  readonly line: number;
4
4
  readonly column: number;
5
+ /** The module the position refers to, when the thrower knows it (multi-file runs). */
6
+ readonly file?: string;
7
+ /** The bare message, without the position prefix — what a diagnostic report carries. */
8
+ readonly detail: string;
5
9
 
6
- constructor(message: string, line: number, column: number) {
7
- super(`${line}:${column} ${message}`);
10
+ constructor(message: string, line: number, column: number, file?: string) {
11
+ super(`${file ? `${file}:` : ''}${line}:${column} ${message}`);
8
12
  this.name = 'DfnError';
9
13
  this.line = line;
10
14
  this.column = column;
15
+ this.file = file;
16
+ this.detail = message;
11
17
  }
12
18
  }
package/src/lint.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { assertOpenSetsAreNameSets, build, evaluateProduction } from './build';
2
+ import { DfnError } from './error';
3
+ import { resolveModule } from './resolve';
4
+
5
+ /**
6
+ * Grammar-only validation of a `.dfn` module — the whole module, not just what a root reaches.
7
+ *
8
+ * `build --dry-run` validates incidentally: it expands from `root`, so a fragment's productions
9
+ * and a rooted module's unused ones are parsed and nothing more. Lint's contract is the source
10
+ * itself — every production evaluates (cycle-detected, references resolved, pick/omit members and
11
+ * scales checked, open sets verified name-only), and diagnostics COLLECT: one broken production
12
+ * does not hide the next one.
13
+ */
14
+
15
+ export interface DfnDiagnostic {
16
+ /** The module the position refers to — the linted file, unless the error names another. */
17
+ file: string;
18
+ line: number;
19
+ column: number;
20
+ message: string;
21
+ }
22
+
23
+ function toDiagnostic(file: string, error: unknown): DfnDiagnostic {
24
+ if (error instanceof DfnError) {
25
+ return {
26
+ file: error.file ?? file,
27
+ line: error.line,
28
+ column: error.column,
29
+ message: error.detail,
30
+ };
31
+ }
32
+ return {
33
+ file,
34
+ line: 1,
35
+ column: 1,
36
+ message: error instanceof Error ? error.message : String(error),
37
+ };
38
+ }
39
+
40
+ /** Lint one module. Returns every finding; an empty array means the grammar is sound. */
41
+ export function lintModule(path: string): DfnDiagnostic[] {
42
+ let resolved: ReturnType<typeof resolveModule>;
43
+ try {
44
+ resolved = resolveModule(path);
45
+ } catch (error) {
46
+ return [toDiagnostic(path, error)];
47
+ }
48
+
49
+ const out: DfnDiagnostic[] = [];
50
+ const collect = (walk: () => void): void => {
51
+ try {
52
+ walk();
53
+ } catch (error) {
54
+ out.push(toDiagnostic(path, error));
55
+ }
56
+ };
57
+
58
+ for (const [name, node] of resolved.module.productions) {
59
+ collect(() => {
60
+ evaluateProduction(resolved, name);
61
+ assertOpenSetsAreNameSets(resolved, node);
62
+ });
63
+ }
64
+ if (resolved.module.root) {
65
+ // `build` re-expands referenced productions, so a production's error can surface twice —
66
+ // once from its own walk, once through the root. The dedupe below keeps one.
67
+ collect(() => {
68
+ build(resolved);
69
+ assertOpenSetsAreNameSets(resolved);
70
+ });
71
+ }
72
+
73
+ const seen = new Set<string>();
74
+ return out.filter((d) => {
75
+ const key = `${d.file}|${d.line}|${d.column}|${d.message}`;
76
+ if (seen.has(key)) return false;
77
+ seen.add(key);
78
+ return true;
79
+ });
80
+ }
package/src/parser.ts CHANGED
@@ -49,7 +49,7 @@ class Parser {
49
49
  }
50
50
 
51
51
  module(): Module {
52
- const uses: string[] = [];
52
+ const uses: Array<{ spec: string; alias?: string }> = [];
53
53
  const productions = new Map<string, Node>();
54
54
  const meta: ModuleMeta = {};
55
55
  let root: Node | undefined;
@@ -59,7 +59,13 @@ class Parser {
59
59
  const token = this.peek();
60
60
  if (token.kind === 'use') {
61
61
  this.next();
62
- uses.push(this.expect('string', 'a quoted specifier').value);
62
+ const spec = this.expect('string', 'a quoted specifier').value;
63
+ let alias: string | undefined;
64
+ if (this.peek().kind === 'ident' && this.peek().value === 'as') {
65
+ this.next();
66
+ alias = this.expect('ident', 'an import alias').value;
67
+ }
68
+ uses.push({ spec, alias });
63
69
  } else if (token.kind === 'ident' && this.at(1)?.kind === 'string') {
64
70
  // `<ident> "<string>"` (no `=`) is a PRAGMA — document metadata, not a production, so
65
71
  // nothing is reserved: `title = a | b` still declares a production named title.
@@ -127,6 +133,16 @@ class Parser {
127
133
  token.column,
128
134
  );
129
135
  }
136
+ // `<scale>*` — the open-set mark after a closed `>`/`]` lands here as a stray
137
+ // statement tail; name the fix rather than complaining about the line ending.
138
+ if (this.peek().kind === 'star') {
139
+ const star = this.peek();
140
+ throw new DfnError(
141
+ "'*' marks a set open and sits inside the reference or group it opens — <name*> or [a | b *]",
142
+ star.line,
143
+ star.column,
144
+ );
145
+ }
130
146
  if (this.peek().kind !== 'eof') this.expect('newline', 'end of line');
131
147
  this.skipNewlines();
132
148
  }
@@ -168,26 +184,70 @@ class Parser {
168
184
  return { term, optional };
169
185
  }
170
186
 
187
+ /** A pick/omit list member: an identifier or a (possibly padded) number. */
188
+ private member(): string {
189
+ const token = this.next();
190
+ if (token.kind !== 'ident' && token.kind !== 'number') {
191
+ throw new DfnError(
192
+ `expected a member name, got '${token.value || token.kind}'`,
193
+ token.line,
194
+ token.column,
195
+ );
196
+ }
197
+ return token.value;
198
+ }
199
+
171
200
  private term(): Step['term'] {
172
201
  const token = this.next();
202
+ const at = { line: token.line, column: token.column };
173
203
  switch (token.kind) {
174
204
  case 'ident':
175
205
  case 'number':
176
- return { kind: 'name', value: token.value };
206
+ return { kind: 'name', value: token.value, ...at };
177
207
  case 'range': {
178
- const { min, max, step } = token.range as {
179
- min: number;
180
- max: number;
181
- step: number;
182
- };
183
- if (step === 0 || max < min) {
208
+ const payload = token.range as NonNullable<Token['range']>;
209
+ const { min, max, mode, step, quantum, pad } = payload;
210
+ if (max < min) {
211
+ throw new DfnError(
212
+ 'range needs max >= min',
213
+ token.line,
214
+ token.column,
215
+ );
216
+ }
217
+ if (mode === '/') {
218
+ if (quantum !== undefined) {
219
+ throw new DfnError(
220
+ '~quantum belongs to multiplied scales (min-max*factor~q)',
221
+ token.line,
222
+ token.column,
223
+ );
224
+ }
225
+ if (step === 0 || !Number.isInteger(step)) {
226
+ throw new DfnError(
227
+ 'a stepped range needs a whole-number step greater than zero',
228
+ token.line,
229
+ token.column,
230
+ );
231
+ }
232
+ return { kind: 'range', min, max, mode: 'stepped', step, pad, ...at };
233
+ }
234
+ if (step <= 1) {
184
235
  throw new DfnError(
185
- 'range needs max >= min and a non-zero step',
236
+ 'a multiplied range needs a factor greater than one',
186
237
  token.line,
187
238
  token.column,
188
239
  );
189
240
  }
190
- return { kind: 'range', min, max, step };
241
+ return {
242
+ kind: 'range',
243
+ min,
244
+ max,
245
+ mode: 'multiplied',
246
+ step,
247
+ quantum,
248
+ pad,
249
+ ...at,
250
+ };
191
251
  }
192
252
  case 'langle': {
193
253
  const imported = this.peek().kind === 'at';
@@ -216,10 +276,10 @@ class Parser {
216
276
  if (this.peek().kind === 'lbracket') {
217
277
  const into = negated ? omit : pick;
218
278
  this.next();
219
- into.push(this.expect('ident', 'a member name').value);
279
+ into.push(this.member());
220
280
  while (this.peek().kind === 'comma') {
221
281
  this.next();
222
- into.push(this.expect('ident', 'a member name').value);
282
+ into.push(this.member());
223
283
  }
224
284
  this.expect('rbracket', "']'");
225
285
  } else if (negated) {
@@ -241,6 +301,7 @@ class Parser {
241
301
  open,
242
302
  pick,
243
303
  omit,
304
+ ...at,
244
305
  };
245
306
  }
246
307
  case 'lbracket': {
@@ -248,8 +309,16 @@ class Parser {
248
309
  const open = this.peek().kind === 'star';
249
310
  if (open) this.next();
250
311
  this.expect('rbracket', "']'");
251
- return { kind: 'group', node, open };
312
+ return { kind: 'group', node, open, ...at };
252
313
  }
314
+ case 'star':
315
+ // The most common misplacement: `color.*` or `a | *`. The mark exists — it just
316
+ // sits inside the set it opens, so say that instead of a bare grammar complaint.
317
+ throw new DfnError(
318
+ "'*' marks a set open and sits inside the reference or group it opens — <name*> or [a | b *]",
319
+ token.line,
320
+ token.column,
321
+ );
253
322
  default:
254
323
  throw new DfnError(
255
324
  `expected a term, got '${token.value || token.kind}'`,
package/src/resolve.ts CHANGED
@@ -55,16 +55,31 @@ export function resolveModule(
55
55
  } catch {
56
56
  throw new DfnError(`cannot read ${absolute}`, 1, 1);
57
57
  }
58
- const module = parse(source);
58
+ let module: Module;
59
+ try {
60
+ module = parse(source);
61
+ } catch (error) {
62
+ // Stamp the file: a sweep parses many modules, and `13:10 …` without a file name is
63
+ // exactly the kind of message this pipeline should never emit.
64
+ if (error instanceof DfnError && !error.file) {
65
+ throw new DfnError(error.detail, error.line, error.column, absolute);
66
+ }
67
+ throw error;
68
+ }
59
69
 
60
70
  const imports = new Map<string, ResolvedModule>();
61
- for (const spec of module.uses) {
71
+ for (const { spec, alias } of module.uses) {
62
72
  const target = resolveSpecifier(spec, dirname(absolute));
63
73
  const resolved = resolveModule(target, inProgress);
64
- if (imports.has(resolved.name)) {
65
- throw new DfnError(`two imports share the name '${resolved.name}'`, 1, 1);
74
+ const key = alias ?? resolved.name;
75
+ if (imports.has(key)) {
76
+ throw new DfnError(
77
+ `two imports share the name '${key}' — alias one: use "…" as other-name`,
78
+ 1,
79
+ 1,
80
+ );
66
81
  }
67
- imports.set(resolved.name, resolved);
82
+ imports.set(key, resolved);
68
83
  }
69
84
 
70
85
  inProgress.delete(absolute);
package/src/tokenizer.ts CHANGED
@@ -31,17 +31,33 @@ export type TokenKind =
31
31
 
32
32
  export interface Token {
33
33
  kind: TokenKind;
34
- /** Source text (for ident/number/string); ranges carry min/max/step instead. */
34
+ /** Source text (for ident/number/string); ranges carry their parsed payload instead. */
35
35
  value: string;
36
- range?: { min: number; max: number; step: number };
36
+ range?: {
37
+ min: number;
38
+ max: number;
39
+ /** `/` = stepped (additive), `*` = multiplied (geometric). */
40
+ mode: '/' | '*';
41
+ step: number;
42
+ quantum?: number;
43
+ /** Pad width inferred from a leading zero on a written endpoint (`025` → 3). */
44
+ pad?: number;
45
+ };
37
46
  line: number;
38
47
  column: number;
39
48
  }
40
49
 
41
50
  const IDENT = /^[A-Za-z][A-Za-z0-9-]*/;
42
- const RANGE = /^(\d+)-(\d+)\/(\d+)/;
51
+ const RANGE = /^(\d+)-(\d+)([*/])(\d+(?:\.\d+)?)(?:~(\d+))?/;
43
52
  const NUMBER = /^\d+/;
44
53
 
54
+ /** "Write the numbers as they appear": a leading zero declares the pad width. */
55
+ function padWidth(...literals: string[]): number | undefined {
56
+ const declared = literals.filter((l) => l.length > 1 && l.startsWith('0'));
57
+ if (declared.length === 0) return undefined;
58
+ return Math.max(...declared.map((l) => l.length));
59
+ }
60
+
45
61
  export function tokenize(source: string): Token[] {
46
62
  const tokens: Token[] = [];
47
63
  const lines = source.split('\n');
@@ -85,7 +101,10 @@ export function tokenize(source: string): Token[] {
85
101
  range: {
86
102
  min: Number(range[1]),
87
103
  max: Number(range[2]),
88
- step: Number(range[3]),
104
+ mode: range[3] as '/' | '*',
105
+ step: Number(range[4]),
106
+ quantum: range[5] ? Number(range[5]) : undefined,
107
+ pad: padWidth(range[1], range[2]),
89
108
  },
90
109
  line,
91
110
  column,