@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/CHANGELOG.md +16 -0
- package/README.md +37 -87
- package/docs/build.md +81 -0
- package/docs/emission.md +129 -0
- package/docs/expressions.md +150 -0
- package/docs/language.md +95 -0
- package/docs/lint.md +59 -0
- package/docs/modules.md +105 -0
- package/docs/scales.md +88 -0
- package/grammar/dfn.tmLanguage.json +1 -1
- package/package.json +5 -4
- package/src/api.ts +11 -4
- package/src/ast.ts +25 -7
- package/src/build.ts +94 -20
- package/src/cli.ts +62 -4
- package/src/error.ts +8 -2
- package/src/lint.ts +80 -0
- package/src/parser.ts +83 -14
- package/src/resolve.ts +20 -5
- package/src/tokenizer.ts +23 -4
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.
|
package/docs/modules.md
ADDED
|
@@ -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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vertekum/schema-builder",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
@@ -36,13 +37,13 @@
|
|
|
36
37
|
"./cli": "./src/cli.ts"
|
|
37
38
|
},
|
|
38
39
|
"peerDependencies": {
|
|
39
|
-
"@vertekum/core": "0.1.
|
|
40
|
+
"@vertekum/core": "0.1.2"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"zod": "^3.25.76"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@vertekum/
|
|
46
|
-
"@vertekum/
|
|
46
|
+
"@vertekum/core": "0.1.2",
|
|
47
|
+
"@vertekum/schema-vocabulary": "0.1.0"
|
|
47
48
|
}
|
|
48
49
|
}
|
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 {
|
|
6
|
-
|
|
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`
|
|
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
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
/** A parsed module: imports, named productions, and the reserved `root` (absent in fragments). */
|
|
9
9
|
export interface Module {
|
|
10
|
-
/** `use "<specifier>"` statements, in order
|
|
11
|
-
uses: string
|
|
10
|
+
/** `use "<specifier>" [as <alias>]` statements, in order. */
|
|
11
|
+
uses: Array<{ spec: string; alias?: string }>;
|
|
12
12
|
/** Productions by name. `root` is never stored here. */
|
|
13
13
|
productions: Map<string, Node>;
|
|
14
14
|
/** The reserved `root` production, when declared. */
|
|
@@ -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,17 +60,26 @@ 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
|
}
|
|
58
67
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
68
|
+
/**
|
|
69
|
+
* A numeric name scale, enumerated at build time. `min-max/step` is additive;
|
|
70
|
+
* `min-max*factor` is geometric (fractional factors allowed), optionally quantized to the
|
|
71
|
+
* nearest multiple of `quantum` (`~4`). A leading zero on a written endpoint (`025`)
|
|
72
|
+
* declares the zero-pad width for every emitted name.
|
|
73
|
+
*/
|
|
74
|
+
export interface Range extends Position {
|
|
61
75
|
kind: 'range';
|
|
62
76
|
min: number;
|
|
63
77
|
max: number;
|
|
78
|
+
mode: 'stepped' | 'multiplied';
|
|
79
|
+
/** The additive step, or the geometric factor. */
|
|
64
80
|
step: number;
|
|
81
|
+
quantum?: number;
|
|
82
|
+
pad?: number;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/**
|
|
@@ -69,7 +87,7 @@ export interface Range {
|
|
|
69
87
|
* modifiers: `[a, b]` is PICK (only the listed members), `![a, b]` is OMIT (the set minus
|
|
70
88
|
* them) — a modified reference is a new set.
|
|
71
89
|
*/
|
|
72
|
-
export interface Ref {
|
|
90
|
+
export interface Ref extends Position {
|
|
73
91
|
kind: 'ref';
|
|
74
92
|
name: string;
|
|
75
93
|
imported: boolean;
|
|
@@ -81,7 +99,7 @@ export interface Ref {
|
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
/** `[ … ]`; `open` records a trailing `*` before the closing bracket. */
|
|
84
|
-
export interface Group {
|
|
102
|
+
export interface Group extends Position {
|
|
85
103
|
kind: 'group';
|
|
86
104
|
node: Node;
|
|
87
105
|
open: boolean;
|
package/src/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evaluateScale } from '@vertekum/core';
|
|
1
2
|
import type { Node, Ref } from './ast';
|
|
2
3
|
import { DfnError } from './error';
|
|
3
4
|
import type { ResolvedModule } from './resolve';
|
|
@@ -37,7 +38,11 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
|
|
|
37
38
|
if (!ref.imported) {
|
|
38
39
|
const production = scope.module.module.productions.get(ref.name);
|
|
39
40
|
if (!production) {
|
|
40
|
-
throw new DfnError(
|
|
41
|
+
throw new DfnError(
|
|
42
|
+
`unknown production '<${ref.name}>'`,
|
|
43
|
+
ref.line,
|
|
44
|
+
ref.column,
|
|
45
|
+
);
|
|
41
46
|
}
|
|
42
47
|
return { node: production, scope };
|
|
43
48
|
}
|
|
@@ -46,11 +51,15 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
|
|
|
46
51
|
if (ref.from) {
|
|
47
52
|
const imported = scope.module.imports.get(ref.from);
|
|
48
53
|
if (!imported) {
|
|
49
|
-
throw new DfnError(`no import named '${ref.from}'`,
|
|
54
|
+
throw new DfnError(`no import named '${ref.from}'`, ref.line, ref.column);
|
|
50
55
|
}
|
|
51
56
|
const production = imported.module.productions.get(ref.name);
|
|
52
57
|
if (!production) {
|
|
53
|
-
throw new DfnError(
|
|
58
|
+
throw new DfnError(
|
|
59
|
+
`'${ref.from}' has no production '${ref.name}'`,
|
|
60
|
+
ref.line,
|
|
61
|
+
ref.column,
|
|
62
|
+
);
|
|
54
63
|
}
|
|
55
64
|
return {
|
|
56
65
|
node: production,
|
|
@@ -59,22 +68,26 @@ function target(ref: Ref, scope: Scope): { node: Node; scope: Scope } {
|
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
const hits: Array<{ node: Node; scope: Scope }> = [];
|
|
62
|
-
for (const imported of scope.module.imports
|
|
71
|
+
for (const [key, imported] of scope.module.imports) {
|
|
63
72
|
const importedScope: Scope = { module: imported, expanding: new Set() };
|
|
64
73
|
const production = imported.module.productions.get(ref.name);
|
|
65
74
|
if (production) hits.push({ node: production, scope: importedScope });
|
|
66
|
-
if (
|
|
75
|
+
if (key === ref.name && imported.module.root) {
|
|
67
76
|
hits.push({ node: imported.module.root, scope: importedScope });
|
|
68
77
|
}
|
|
69
78
|
}
|
|
70
79
|
if (hits.length === 0) {
|
|
71
|
-
throw new DfnError(
|
|
80
|
+
throw new DfnError(
|
|
81
|
+
`no import provides '<@${ref.name}>'`,
|
|
82
|
+
ref.line,
|
|
83
|
+
ref.column,
|
|
84
|
+
);
|
|
72
85
|
}
|
|
73
86
|
if (hits.length > 1) {
|
|
74
87
|
throw new DfnError(
|
|
75
88
|
`'<@${ref.name}>' is ambiguous across imports — qualify it: <@module/${ref.name}>`,
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
ref.line,
|
|
90
|
+
ref.column,
|
|
78
91
|
);
|
|
79
92
|
}
|
|
80
93
|
return hits[0];
|
|
@@ -93,9 +106,44 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
|
|
|
93
106
|
return forest;
|
|
94
107
|
}
|
|
95
108
|
case 'range': {
|
|
109
|
+
// One authority for scale names (and, later, token values): core's evaluateScale.
|
|
110
|
+
let scale: ReturnType<typeof evaluateScale>;
|
|
111
|
+
try {
|
|
112
|
+
scale = evaluateScale(
|
|
113
|
+
node.mode === 'stepped'
|
|
114
|
+
? {
|
|
115
|
+
kind: 'stepped',
|
|
116
|
+
min: node.min,
|
|
117
|
+
max: node.max,
|
|
118
|
+
step: node.step,
|
|
119
|
+
pad: node.pad,
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
kind: 'multiplied',
|
|
123
|
+
min: node.min,
|
|
124
|
+
max: node.max,
|
|
125
|
+
factor: node.step,
|
|
126
|
+
quantum: node.quantum,
|
|
127
|
+
pad: node.pad,
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new DfnError(
|
|
132
|
+
error instanceof Error ? error.message : String(error),
|
|
133
|
+
node.line,
|
|
134
|
+
node.column,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (scale.collisions.length > 0) {
|
|
138
|
+
throw new DfnError(
|
|
139
|
+
`scale steps ${scale.collisions.join(', ')} quantize onto earlier names — the quantum is too coarse for the factor`,
|
|
140
|
+
node.line,
|
|
141
|
+
node.column,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
96
144
|
const forest = leaf();
|
|
97
|
-
for (
|
|
98
|
-
forest.children.set(
|
|
145
|
+
for (const name of scale.names) {
|
|
146
|
+
forest.children.set(name, tail());
|
|
99
147
|
}
|
|
100
148
|
return forest;
|
|
101
149
|
}
|
|
@@ -136,7 +184,11 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
|
|
|
136
184
|
const { node: production, scope: refScope } = target(node, scope);
|
|
137
185
|
if (!node.imported) {
|
|
138
186
|
if (scope.expanding.has(node.name)) {
|
|
139
|
-
throw new DfnError(
|
|
187
|
+
throw new DfnError(
|
|
188
|
+
`'<${node.name}>' expands through itself`,
|
|
189
|
+
node.line,
|
|
190
|
+
node.column,
|
|
191
|
+
);
|
|
140
192
|
}
|
|
141
193
|
scope.expanding.add(node.name);
|
|
142
194
|
}
|
|
@@ -146,8 +198,8 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
|
|
|
146
198
|
if (!forest.children.delete(omitted)) {
|
|
147
199
|
throw new DfnError(
|
|
148
200
|
`'<${node.name}>' has no member '${omitted}' to omit`,
|
|
149
|
-
|
|
150
|
-
|
|
201
|
+
node.line,
|
|
202
|
+
node.column,
|
|
151
203
|
);
|
|
152
204
|
}
|
|
153
205
|
}
|
|
@@ -156,8 +208,8 @@ function evaluate(node: Node, scope: Scope, tail: () => TreeNode): TreeNode {
|
|
|
156
208
|
if (!forest.children.has(picked)) {
|
|
157
209
|
throw new DfnError(
|
|
158
210
|
`'<${node.name}>' has no member '${picked}' to pick`,
|
|
159
|
-
|
|
160
|
-
|
|
211
|
+
node.line,
|
|
212
|
+
node.column,
|
|
161
213
|
);
|
|
162
214
|
}
|
|
163
215
|
}
|
|
@@ -220,8 +272,8 @@ export function build(resolved: ResolvedModule): TreeNode {
|
|
|
220
272
|
if (owner && owner !== option.name) {
|
|
221
273
|
throw new DfnError(
|
|
222
274
|
`top-level '${name}' comes from both '${owner}' and '${option.name}'`,
|
|
223
|
-
|
|
224
|
-
|
|
275
|
+
option.line,
|
|
276
|
+
option.column,
|
|
225
277
|
);
|
|
226
278
|
}
|
|
227
279
|
seen.set(name, option.name);
|
|
@@ -235,6 +287,24 @@ export function build(resolved: ResolvedModule): TreeNode {
|
|
|
235
287
|
return tree;
|
|
236
288
|
}
|
|
237
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
|
+
|
|
238
308
|
/**
|
|
239
309
|
* Enforce that every `*` sat on a name-only set: evaluation flags are structural, so the check is
|
|
240
310
|
* on the AST — walk refs/groups marked open and verify their expansion source.
|
|
@@ -258,7 +328,11 @@ export function assertOpenSetsAreNameSets(
|
|
|
258
328
|
return;
|
|
259
329
|
case 'group':
|
|
260
330
|
if (node.open && !isNameSet(node.node)) {
|
|
261
|
-
throw new DfnError(
|
|
331
|
+
throw new DfnError(
|
|
332
|
+
'* opens a set of names, not sub-paths',
|
|
333
|
+
node.line,
|
|
334
|
+
node.column,
|
|
335
|
+
);
|
|
262
336
|
}
|
|
263
337
|
assertOpenSetsAreNameSets(resolved, node.node, scope);
|
|
264
338
|
return;
|
|
@@ -268,8 +342,8 @@ export function assertOpenSetsAreNameSets(
|
|
|
268
342
|
if (!isNameSet(production)) {
|
|
269
343
|
throw new DfnError(
|
|
270
344
|
`'<${node.name}*>' opens a set, but the production is not name-only`,
|
|
271
|
-
|
|
272
|
-
|
|
345
|
+
node.line,
|
|
346
|
+
node.column,
|
|
273
347
|
);
|
|
274
348
|
}
|
|
275
349
|
}
|