@vertekum/schema-builder 0.1.4 → 0.1.6

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/docs/lint.md ADDED
@@ -0,0 +1,59 @@
1
+ # `vertekum schema lint`
2
+
3
+ Validate the `.dfn` sources themselves — fragments included, every production evaluated —
4
+ without building anything.
5
+
6
+ ```bash
7
+ # vertekum schema lint [module] [--json] [--cwd <dir>]
8
+ vertekum schema lint # every module under ./schemas
9
+ vertekum schema lint schemas/color.dfn # one module — fragment or rooted
10
+ ```
11
+
12
+ | Argument / flag | What it does |
13
+ | --------------- | ----------------------------------------------------------------------------- |
14
+ | `[module]` | A `.dfn` file, relative to the working directory. Default: every module under `./schemas`, recursively — **fragments included**. |
15
+ | `--json` | Machine-readable result. |
16
+ | `--cwd <dir>` | Project discovery starts here. |
17
+
18
+ Exit `0` when the grammar is sound, `1` with findings — one per line, positioned:
19
+
20
+ ```
21
+ 2 problem(s) in the .dfn modules:
22
+ schemas/emphasis.dfn:1:21 unknown production '<missing>'
23
+ schemas/scale.dfn:1:14 '*' marks a set open and sits inside the reference or group it opens — <name*> or [a | b *]
24
+ ```
25
+
26
+ ## What lint checks that a build does not
27
+
28
+ A build expands from `root`, so its validation reaches exactly what a root reaches.
29
+ Lint's contract is the source:
30
+
31
+ - **Fragments** — a module with no `root` never builds, but lint evaluates its
32
+ productions, so a broken denotation is caught in the file that declares it, not
33
+ months later in the first module that imports it.
34
+ - **Unused productions** — a rooted module's production that nothing references yet is
35
+ still checked.
36
+ - **Everything a build would refuse** — unknown or ambiguous references, pick/omit
37
+ members that don't exist, scale collisions, `*` on a non-name set, reference cycles —
38
+ reported the same way, at the same positions.
39
+
40
+ Findings **collect**: one broken production does not hide the next, so a module with
41
+ three mistakes is one lint run, not three.
42
+
43
+ ## Sound vs. current
44
+
45
+ `lint` and [`build --check`](./build.md#--check-the-staleness-gate) answer different
46
+ questions:
47
+
48
+ | Verb | Question | Covers |
49
+ | --------------- | --------------------------- | ------------------------------------------ |
50
+ | `schema lint` | Is the grammar sound? | Every `.dfn`, fragments and all; no artifacts touched. |
51
+ | `schema build --check` | Are the artifacts current? | Rooted modules' built `.json` vs. disk. |
52
+
53
+ CI wants both — lint fails a broken fragment the staleness gate cannot see, and the
54
+ staleness gate fails a sound grammar whose rebuild was forgotten.
55
+
56
+ ## Programmatic use
57
+
58
+ `lintModule(path)` (from `@vertekum/schema-builder/api`) returns the findings as
59
+ `{ file, line, column, message }` records — an empty array means sound.
@@ -0,0 +1,105 @@
1
+ # Modules and composition
2
+
3
+ A vocabulary rarely fits one file: denotations shared across aspects live in fragment
4
+ modules, aspects live in their own modules, and one aggregate root unions them. `use`
5
+ is the composition mechanism.
6
+
7
+ ```dfn
8
+ # schema.dfn — the aggregate
9
+ use "./color.dfn"
10
+ use "./space.dfn"
11
+
12
+ root = [<@color> | <@space>]
13
+ ```
14
+
15
+ ## `use`
16
+
17
+ `use "<specifier>"` imports a module; everything it declares becomes referenceable.
18
+
19
+ | Specifier form | Example | Resolution |
20
+ | -------------------- | ------------------------------------ | ----------------------------------------------------------- |
21
+ | Relative path | `use "./denotations/colors.dfn"` | Relative to the importing module's own location. |
22
+ | Package specifier | `use "@acme/vocabulary/color.dfn"` | The package's `exports` map first; else the path joined to the package root — so a content package need not map every `.dfn` individually. |
23
+
24
+ An import cycle is an authoring error, reported with the module that closes the loop.
25
+
26
+ ### Import keys and `as`
27
+
28
+ Each import gets a **key**: its basename without `.dfn`, or the alias given with
29
+ `as`. The key is how references address the import. Two imports landing on the same
30
+ key — two `color.dfn` files from different directories — is an error naming the fix:
31
+
32
+ ```dfn
33
+ use "./brand/color.dfn"
34
+ use "./system/color.dfn" as system-color
35
+ ```
36
+
37
+ ### Accessing an import
38
+
39
+ | Reference | Means |
40
+ | -------------------- | ---------------------------------------------------------------------------- |
41
+ | `<@key>` | the imported module's **root** — the whole subtree it grants. |
42
+ | `<@name>` | the production `name`, searched across every import; unambiguous or an error. |
43
+ | `<@key/name>` | qualified: that import's production alone — the collision resolver. |
44
+
45
+ When two imports both declare `<accent>`, the unqualified `<@accent>` refuses with the
46
+ qualified form to use. Local productions (`<name>`, no `@`) never collide with imports.
47
+
48
+ ## Fragments
49
+
50
+ A module without a `root` is a **fragment** — a library of denotations meant to be
51
+ imported, never built:
52
+
53
+ ```dfn
54
+ # denotations/emphasis.dfn
55
+ emphasis = subtle | bold
56
+ ```
57
+
58
+ The build sweep skips fragments with a notice (`… declares no root (a fragment) —
59
+ skipped`); naming one explicitly as the build argument is an error, so a module whose
60
+ `root` was mistyped cannot be silently skipped.
61
+
62
+ ## Aggregate roots
63
+
64
+ A root that unions imported roots — `root = [<@color> | <@space>]` — composes a whole
65
+ vocabulary from aspect modules. Each module owns its top-level names: the same
66
+ top-level name arriving from two different imports is a **collision**, not a merge
67
+ (`top-level 'color' comes from both 'color' and 'palette'`). To co-validate aspects as
68
+ *separate* schemas over the same files instead, give each module
69
+ [`scope "branch"`](./language.md#pragmas) and bind them side by side.
70
+
71
+ ## Nested directories
72
+
73
+ Modules may nest — `schemas/color.dfn` beside `schemas/palette/color.dfn` — and the
74
+ build preserves the structure: each built `.json` lands beside its own module. The
75
+ provenance stamp records the module's project-relative path, so same-named modules in
76
+ different directories stay distinguishable.
77
+
78
+ ## Shipping and ejecting grammar
79
+
80
+ A package can ship its `.dfn` sources alongside the built schemas. An `exports` pattern
81
+ keeps flat specifiers working over a nested layout:
82
+
83
+ ```json
84
+ {
85
+ "exports": {
86
+ "./*.json": "./lib/*.json",
87
+ "./*.dfn": "./dfn/*.dfn"
88
+ }
89
+ }
90
+ ```
91
+
92
+ Consumers then eject the grammar rather than the generated JSON:
93
+
94
+ ```bash
95
+ vertekum schema eject @acme/vocabulary/color.dfn ./schemas/color.dfn
96
+ vertekum schema build
97
+ ```
98
+
99
+ Editing an ejected module keeps changes one-line grammar edits with a rebuild, instead
100
+ of surgery on generated schema. Two things to know:
101
+
102
+ - An ejected module that `use`s **relative** imports needs them ejected alongside it
103
+ (package-specifier imports keep resolving).
104
+ - What the `schemas` config binds is the **built `.json`**, never the `.dfn` — the
105
+ grammar is authoring source; core and `check` only ever see schema files.
package/docs/scales.md ADDED
@@ -0,0 +1,88 @@
1
+ # Scale expressions
2
+
3
+ A scale expression enumerates a numeric name set at build time — `100-900/100` instead
4
+ of nine names by hand. Two modes:
5
+
6
+ | Syntax | Mode |
7
+ | ---------------------- | --------------------------------------------------------------------- |
8
+ | [`min-max/step`](#additive-scales) | **additive** — arithmetic steps |
9
+ | [`min-max*factor~quantum`](#geometric-scales) | **geometric** — a ratio, optionally quantized |
10
+
11
+ Both are inclusive at both ends, and both enumerate **names** — every step must land on
12
+ a whole number, because a token name cannot be `31.25`.
13
+
14
+ ## Additive scales
15
+
16
+ `min-max/step`: from `min`, adding `step`, up to and including `max`.
17
+
18
+ ```dfn
19
+ scale = 100-900/100 # 100 200 300 400 500 600 700 800 900
20
+ ```
21
+
22
+ Different regions of one scale are alternation:
23
+
24
+ ```dfn
25
+ scale = 0 | 025-100/25 | 150-300/50 | 400-600/100 | 800 | 1000
26
+ ```
27
+
28
+ ### Zero-padding
29
+
30
+ Write the numbers as they appear: a leading zero on a written endpoint declares the pad
31
+ width for **every** emitted name of that range.
32
+
33
+ ```dfn
34
+ scale = 025-100/25 # 025 050 075 100
35
+ ```
36
+
37
+ ## Geometric scales
38
+
39
+ `min-max*factor`: from `min`, multiplying by `factor` (fractional ratios allowed), up
40
+ to and including `max`. Raw geometric steps rarely land on whole numbers, so the mode
41
+ pairs with quantization: `~quantum` rounds each step to the nearest multiple of
42
+ `quantum`.
43
+
44
+ The canonical case — a type scale, ratio 1.25, rounded to the nearest 4:
45
+
46
+ ```dfn
47
+ type-scale = 16-64*1.25~4
48
+ ```
49
+
50
+ | Raw step | ×1.25 from | Quantized (~4) |
51
+ | --------- | ---------- | -------------- |
52
+ | 16 | — | **16** |
53
+ | 20 | 16 | **20** |
54
+ | 25 | 20 | **24** |
55
+ | 31.25 | 25 | **32** |
56
+ | 39.0625 | 31.25 | **40** |
57
+ | 48.828125 | 39.0625 | **48** |
58
+ | 61.035… | 48.828125 | **60** |
59
+
60
+ (The next raw step, 76.29…, exceeds 64 — the series ends.)
61
+
62
+ ## The semantics, precisely
63
+
64
+ These rules are fixed; the same evaluator (in `@vertekum/core`) generates the names
65
+ here and, in time, the values a token-side scale materializes — so a name can never
66
+ drift from the value it mirrors.
67
+
68
+ - **Bounds are inclusive and apply to the raw series.** A step enters while
69
+ `raw ≤ max`; the *quantized* value may stand just past a bound (a raw 62.5 with `~25`
70
+ emits 75 even when `max` is 70).
71
+ - **Compounding is raw-basis.** The ratio always applies to the exact previous raw
72
+ value, never to a rounded one — rounding influences names, not the progression.
73
+ - **Quantization is to the nearest multiple** of the quantum.
74
+ - **Every emitted step must be a whole number.** A fractional step with no quantum is a
75
+ build error (`step 22.5 is not a whole number — names are names; quantize (~) or
76
+ adjust the scale`).
77
+ - **Collisions are an error.** When two raw steps quantize onto the same name, the
78
+ build refuses, naming the colliding steps — the quantum is too coarse for the factor.
79
+ A silent dedupe would emit fewer names than the expression reads.
80
+
81
+ ## Constraints
82
+
83
+ | Rule | Error otherwise |
84
+ | ----------------------- | -------------------------------------------------- |
85
+ | `step > 0`, whole (additive) | a stepped scale needs a positive whole-number step |
86
+ | `factor > 1` (geometric)| a multiplied scale needs a factor greater than one |
87
+ | `max ≥ min` | a scale needs max >= min |
88
+ | `~` only with `*` | quantization belongs to the geometric mode |
@@ -61,7 +61,7 @@
61
61
  }
62
62
  },
63
63
  "production": {
64
- "match": "^([A-Za-z][A-Za-z0-9-]*)\\s*(=)",
64
+ "match": "^(\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*)\\s*(=)",
65
65
  "captures": {
66
66
  "1": {
67
67
  "name": "entity.name.function.production.dfn"
@@ -127,7 +127,7 @@
127
127
  },
128
128
  "patterns": [
129
129
  {
130
- "match": "([A-Za-z][A-Za-z0-9-]*)(/)([A-Za-z][A-Za-z0-9-]*)",
130
+ "match": "(\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*)(/)(\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*)",
131
131
  "captures": {
132
132
  "1": {
133
133
  "name": "entity.name.namespace.module.dfn"
@@ -145,7 +145,7 @@
145
145
  },
146
146
  {
147
147
  "name": "variable.other.reference.imported.dfn",
148
- "match": "[A-Za-z][A-Za-z0-9-]*"
148
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*"
149
149
  }
150
150
  ]
151
151
  },
@@ -171,7 +171,7 @@
171
171
  },
172
172
  {
173
173
  "name": "variable.other.reference.dfn",
174
- "match": "[A-Za-z][A-Za-z0-9-]*"
174
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*"
175
175
  }
176
176
  ]
177
177
  },
@@ -197,7 +197,7 @@
197
197
  "patterns": [
198
198
  {
199
199
  "name": "variable.parameter.member.dfn",
200
- "match": "[A-Za-z][A-Za-z0-9-]*|\\d+"
200
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*|\\d+"
201
201
  },
202
202
  {
203
203
  "name": "punctuation.separator.comma.dfn",
@@ -245,7 +245,7 @@
245
245
  },
246
246
  "name": {
247
247
  "name": "constant.language.name.dfn",
248
- "match": "[A-Za-z][A-Za-z0-9-]*"
248
+ "match": "\\d+[A-Za-z][A-Za-z0-9-]*|[A-Za-z][A-Za-z0-9-]*"
249
249
  }
250
250
  }
251
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertekum/schema-builder",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Declare token vocabularies as grammar; build them into JSON Schema files",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -19,6 +19,7 @@
19
19
  "bugs": "https://github.com/moros-oxus/vertekum/issues",
20
20
  "files": [
21
21
  "CHANGELOG.md",
22
+ "docs",
22
23
  "grammar",
23
24
  "src",
24
25
  "tsconfig.json",
package/src/api.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import type { ActivateContext } from '@vertekum/core';
2
- import { schemaBuildCommand } from './cli';
2
+ import { schemaBuildCommand, schemaLintCommand } from './cli';
3
3
  import type { schemaBuilderManifest } from './index';
4
4
 
5
- export { assertOpenSetsAreNameSets, build, type TreeNode } from './build';
6
- export { buildModule, schemaBuildCommand } from './cli';
5
+ export {
6
+ assertOpenSetsAreNameSets,
7
+ build,
8
+ evaluateProduction,
9
+ type TreeNode,
10
+ } from './build';
11
+ export { buildModule, schemaBuildCommand, schemaLintCommand } from './cli';
7
12
  export { emit, isStamped, STAMP_PREFIX, stamp } from './emit';
13
+ export { type DfnDiagnostic, lintModule } from './lint';
8
14
  export { parse } from './parser';
9
15
  export { type ResolvedModule, resolveModule } from './resolve';
10
16
 
11
- /** Headless activation: contributes the `schema build` command and nothing else. */
17
+ /** Headless activation: contributes the `schema build` and `schema lint` commands. */
12
18
  export function activate(
13
19
  ctx: ActivateContext<typeof schemaBuilderManifest>,
14
20
  ): void {
15
21
  ctx.commands.register(schemaBuildCommand);
22
+ ctx.commands.register(schemaLintCommand);
16
23
  }
package/src/ast.ts CHANGED
@@ -32,6 +32,15 @@ export interface ModuleMeta {
32
32
 
33
33
  export type Node = Alt | Path | Name | Range | Ref | Group;
34
34
 
35
+ /**
36
+ * Where a node's source text begins. Carried on every node a later phase can fail on, so a build
37
+ * or lint error names the offending line rather than a placeholder.
38
+ */
39
+ export interface Position {
40
+ line: number;
41
+ column: number;
42
+ }
43
+
35
44
  /** `a | b | c` — set union of the options' name trees. */
36
45
  export interface Alt {
37
46
  kind: 'alt';
@@ -51,7 +60,7 @@ export interface Step {
51
60
  }
52
61
 
53
62
  /** A literal name segment — an identifier or a bare number. */
54
- export interface Name {
63
+ export interface Name extends Position {
55
64
  kind: 'name';
56
65
  value: string;
57
66
  }
@@ -62,7 +71,7 @@ export interface Name {
62
71
  * nearest multiple of `quantum` (`~4`). A leading zero on a written endpoint (`025`)
63
72
  * declares the zero-pad width for every emitted name.
64
73
  */
65
- export interface Range {
74
+ export interface Range extends Position {
66
75
  kind: 'range';
67
76
  min: number;
68
77
  max: number;
@@ -78,7 +87,7 @@ export interface Range {
78
87
  * modifiers: `[a, b]` is PICK (only the listed members), `![a, b]` is OMIT (the set minus
79
88
  * them) — a modified reference is a new set.
80
89
  */
81
- export interface Ref {
90
+ export interface Ref extends Position {
82
91
  kind: 'ref';
83
92
  name: string;
84
93
  imported: boolean;
@@ -90,7 +99,7 @@ export interface Ref {
90
99
  }
91
100
 
92
101
  /** `[ … ]`; `open` records a trailing `*` before the closing bracket. */
93
- export interface Group {
102
+ export interface Group extends Position {
94
103
  kind: 'group';
95
104
  node: Node;
96
105
  open: boolean;
package/src/build.ts CHANGED
@@ -38,7 +38,11 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
38
38
  if (!ref.imported) {
39
39
  const production = scope.module.module.productions.get(ref.name);
40
40
  if (!production) {
41
- throw new DfnError(`unknown production '<${ref.name}>'`, 1, 1);
41
+ throw new DfnError(
42
+ `unknown production '<${ref.name}>'`,
43
+ ref.line,
44
+ ref.column,
45
+ );
42
46
  }
43
47
  return { node: production, scope };
44
48
  }
@@ -47,11 +51,15 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
47
51
  if (ref.from) {
48
52
  const imported = scope.module.imports.get(ref.from);
49
53
  if (!imported) {
50
- throw new DfnError(`no import named '${ref.from}'`, 1, 1);
54
+ throw new DfnError(`no import named '${ref.from}'`, ref.line, ref.column);
51
55
  }
52
56
  const production = imported.module.productions.get(ref.name);
53
57
  if (!production) {
54
- throw new DfnError(`'${ref.from}' has no production '${ref.name}'`, 1, 1);
58
+ throw new DfnError(
59
+ `'${ref.from}' has no production '${ref.name}'`,
60
+ ref.line,
61
+ ref.column,
62
+ );
55
63
  }
56
64
  return {
57
65
  node: production,
@@ -69,13 +77,17 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
69
77
  }
70
78
  }
71
79
  if (hits.length === 0) {
72
- throw new DfnError(`no import provides '<@${ref.name}>'`, 1, 1);
80
+ throw new DfnError(
81
+ `no import provides '<@${ref.name}>'`,
82
+ ref.line,
83
+ ref.column,
84
+ );
73
85
  }
74
86
  if (hits.length > 1) {
75
87
  throw new DfnError(
76
88
  `'<@${ref.name}>' is ambiguous across imports — qualify it: <@module/${ref.name}>`,
77
- 1,
78
- 1,
89
+ ref.line,
90
+ ref.column,
79
91
  );
80
92
  }
81
93
  return hits[0];
@@ -118,15 +130,15 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
118
130
  } catch (error) {
119
131
  throw new DfnError(
120
132
  error instanceof Error ? error.message : String(error),
121
- 1,
122
- 1,
133
+ node.line,
134
+ node.column,
123
135
  );
124
136
  }
125
137
  if (scale.collisions.length > 0) {
126
138
  throw new DfnError(
127
139
  `scale steps ${scale.collisions.join(', ')} quantize onto earlier names — the quantum is too coarse for the factor`,
128
- 1,
129
- 1,
140
+ node.line,
141
+ node.column,
130
142
  );
131
143
  }
132
144
  const forest = leaf();
@@ -172,7 +184,11 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
172
184
  const { node: production, scope: refScope } = target(node, scope);
173
185
  if (!node.imported) {
174
186
  if (scope.expanding.has(node.name)) {
175
- throw new DfnError(`'<${node.name}>' expands through itself`, 1, 1);
187
+ throw new DfnError(
188
+ `'<${node.name}>' expands through itself`,
189
+ node.line,
190
+ node.column,
191
+ );
176
192
  }
177
193
  scope.expanding.add(node.name);
178
194
  }
@@ -182,8 +198,8 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
182
198
  if (!forest.children.delete(omitted)) {
183
199
  throw new DfnError(
184
200
  `'<${node.name}>' has no member '${omitted}' to omit`,
185
- 1,
186
- 1,
201
+ node.line,
202
+ node.column,
187
203
  );
188
204
  }
189
205
  }
@@ -192,8 +208,8 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
192
208
  if (!forest.children.has(picked)) {
193
209
  throw new DfnError(
194
210
  `'<${node.name}>' has no member '${picked}' to pick`,
195
- 1,
196
- 1,
211
+ node.line,
212
+ node.column,
197
213
  );
198
214
  }
199
215
  }
@@ -256,8 +272,8 @@ export function build(resolved: ResolvedModule): TreeNode {
256
272
  if (owner && owner !== option.name) {
257
273
  throw new DfnError(
258
274
  `top-level '${name}' comes from both '${owner}' and '${option.name}'`,
259
- 1,
260
- 1,
275
+ option.line,
276
+ option.column,
261
277
  );
262
278
  }
263
279
  seen.set(name, option.name);
@@ -271,6 +287,24 @@ export function build(resolved: ResolvedModule): TreeNode {
271
287
  return tree;
272
288
  }
273
289
 
290
+ /**
291
+ * Expand ONE production in isolation — the lint walk's unit. `build` stays root-only; this is what
292
+ * lets a fragment's productions (and a rooted module's unused ones) be validated at all. Seeding
293
+ * `expanding` with the production's own name makes direct self-reference a cycle, same as it is
294
+ * when reached from a root.
295
+ */
296
+ export function evaluateProduction(
297
+ resolved: ResolvedModule,
298
+ name: string,
299
+ ): TreeNode {
300
+ const production = resolved.module.productions.get(name);
301
+ if (!production) {
302
+ throw new DfnError(`no production '${name}'`, 1, 1);
303
+ }
304
+ const scope: Scope = { module: resolved, expanding: new Set([name]) };
305
+ return evaluate(production, scope, leaf);
306
+ }
307
+
274
308
  /**
275
309
  * Enforce that every `*` sat on a name-only set: evaluation flags are structural, so the check is
276
310
  * on the AST — walk refs/groups marked open and verify their expansion source.
@@ -294,7 +328,11 @@ export function assertOpenSetsAreNameSets(
294
328
  return;
295
329
  case 'group':
296
330
  if (node.open && !isNameSet(node.node)) {
297
- throw new DfnError('* opens a set of names, not sub-paths', 1, 1);
331
+ throw new DfnError(
332
+ '* opens a set of names, not sub-paths',
333
+ node.line,
334
+ node.column,
335
+ );
298
336
  }
299
337
  assertOpenSetsAreNameSets(resolved, node.node, scope);
300
338
  return;
@@ -304,8 +342,8 @@ export function assertOpenSetsAreNameSets(
304
342
  if (!isNameSet(production)) {
305
343
  throw new DfnError(
306
344
  `'<${node.name}*>' opens a set, but the production is not name-only`,
307
- 1,
308
- 1,
345
+ node.line,
346
+ node.column,
309
347
  );
310
348
  }
311
349
  }
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
  /**
@@ -44,6 +45,55 @@ export function buildModule(
44
45
  };
45
46
  }
46
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
+
47
97
  export const schemaBuildCommand: CommandDescriptor = {
48
98
  name: 'schema build',
49
99
  description: 'build .dfn vocabulary modules into JSON Schema files',
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
  }