@vertekum/schema-builder 0.1.7 → 0.2.1

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,10 +1,18 @@
1
- import { existsSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, relative } from 'node:path';
3
3
  import type { CommandDescriptor, CommandResult } from '@vertekum/core';
4
- import { assertOpenSetsAreNameSets, build } from './build';
4
+ import {
5
+ assertOpenSetsAreNameSets,
6
+ build,
7
+ evaluateProduction,
8
+ type PatternRef,
9
+ type TreeNode,
10
+ } from './build';
5
11
  import { emit, isStamped } from './emit';
12
+ import { DfnError } from './error';
13
+ import { fixSource, formatSource, resolveIndent } from './format';
6
14
  import { lintModule } from './lint';
7
- import { resolveModule } from './resolve';
15
+ import { type ResolvedModule, resolveModule } from './resolve';
8
16
 
9
17
  /**
10
18
  * `vertekum schema build [module]` (ADR-0030): expand `.dfn` modules and DECLARE the built JSON
@@ -17,6 +25,32 @@ import { resolveModule } from './resolve';
17
25
  /** The slice of the CLI's Project this command reads. */
18
26
  interface ProjectDir {
19
27
  projectDir: string;
28
+ /** The consumer's `format.indent`, when set — fmt honors it (JSON writes already do). */
29
+ indent?: string | number;
30
+ /** The kernel's config store — where this extension's own settings slice lives. */
31
+ kernel?: { config: { get<T>(id: string): T } };
32
+ }
33
+
34
+ /** The extension's configured input/output pair, defaults included. */
35
+ function settingsOf(ctx: { project: unknown }): {
36
+ source: string;
37
+ out?: string;
38
+ link: boolean;
39
+ schemaId?: string;
40
+ } {
41
+ const kernel = (ctx.project as ProjectDir).kernel;
42
+ const slice = kernel?.config.get<{
43
+ source?: string;
44
+ out?: string;
45
+ link?: boolean;
46
+ schemaId?: string;
47
+ }>('vtk.schema-builder');
48
+ return {
49
+ source: slice?.source ?? './schemas',
50
+ out: slice?.out,
51
+ link: slice?.link === true,
52
+ schemaId: slice?.schemaId,
53
+ };
20
54
  }
21
55
 
22
56
  function modulesUnder(dir: string): string[] {
@@ -26,22 +60,125 @@ function modulesUnder(dir: string): string[] {
26
60
  .map((f) => join(dir, f));
27
61
  }
28
62
 
29
- export function buildModule(
30
- modulePath: string,
63
+ /**
64
+ * What a command's `[module]` argument means: nothing → the default sweep (`./schemas`);
65
+ * a directory → sweep it; a file → that module alone (`explicitFile` — build's rootless
66
+ * error applies only there). A path that exists as neither is an error up front.
67
+ */
68
+ function resolveModules(
69
+ projectDir: string,
70
+ arg: string | undefined,
71
+ sourceDefault: string,
72
+ ): { modules: string[]; explicitFile: boolean; root: string } {
73
+ if (!arg) {
74
+ const root = join(projectDir, sourceDefault);
75
+ return { modules: modulesUnder(root), explicitFile: false, root };
76
+ }
77
+ const target = join(projectDir, arg);
78
+ if (!existsSync(target)) {
79
+ throw new Error(`no such file or directory: ${arg}`);
80
+ }
81
+ if (statSync(target).isDirectory()) {
82
+ return { modules: modulesUnder(target), explicitFile: false, root: target };
83
+ }
84
+ return { modules: [target], explicitFile: true, root: dirname(target) };
85
+ }
86
+
87
+ /** A module's NATURE: the `scope` pragma, defaulted by root presence. */
88
+ export function natureOf(
89
+ resolved: ResolvedModule,
90
+ ): 'document' | 'def' | 'inline' {
91
+ return (
92
+ resolved.module.meta.scope ?? (resolved.module.root ? 'document' : 'def')
93
+ );
94
+ }
95
+
96
+ export interface BuildModuleOptions {
31
97
  /** The module name the provenance stamp records; default: the basename. Pass a relative
32
98
  * path when modules nest, so same-named modules stamp distinguishably. */
33
- moduleLabel?: string,
99
+ label?: string;
100
+ /** Where the artifact lands; default: beside the module. */
101
+ targetDir?: string;
102
+ /** Derived `$id` for this artifact (configured base + path); the `id` pragma wins. */
103
+ schemaId?: string;
104
+ /** Linked emission: resolve a module to the artifact path a cross-file `$ref` should use. */
105
+ linkResolve?: (module: ResolvedModule) => string | undefined;
106
+ }
107
+
108
+ export function buildModule(
109
+ modulePath: string,
110
+ options: BuildModuleOptions = {},
34
111
  ): {
35
112
  target: string;
36
113
  content: string;
37
114
  } {
38
115
  const resolved = resolveModule(modulePath);
39
116
  assertOpenSetsAreNameSets(resolved);
40
- const tree = build(resolved);
41
- const moduleFile = moduleLabel ?? basename(modulePath);
117
+ const nature = natureOf(resolved);
118
+ if (nature === 'inline') {
119
+ throw new DfnError(
120
+ `${resolved.name}.dfn is scope "inline" — it is never emitted`,
121
+ 1,
122
+ 1,
123
+ resolved.path,
124
+ );
125
+ }
126
+ if (nature === 'document' && !resolved.module.root) {
127
+ throw new DfnError(
128
+ `scope "document" requires a root — ${resolved.name}.dfn declares none`,
129
+ 1,
130
+ 1,
131
+ resolved.path,
132
+ );
133
+ }
134
+
135
+ const tree = resolved.module.root ? build(resolved) : undefined;
136
+
137
+ // Public patterns in declaration order, bare-evaluated; plus a memoized provider for
138
+ // emission's deep-checks (covering cross-module patterns and def-scope roots).
139
+ const patterns = new Map<string, TreeNode>();
140
+ for (const name of resolved.module.productions.keys()) {
141
+ if (resolved.module.private.has(name)) continue;
142
+ patterns.set(name, evaluateProduction(resolved, name));
143
+ }
144
+ const bareMemo = new Map<string, TreeNode | undefined>();
145
+ const bare = (ref: PatternRef): TreeNode | undefined => {
146
+ const key = ref.module
147
+ ? `@${ref.module.path}/${ref.root ? '/root' : ref.name}`
148
+ : ref.name;
149
+ if (!bareMemo.has(key)) {
150
+ let value: TreeNode | undefined;
151
+ if (!ref.module) value = patterns.get(ref.name);
152
+ else if (ref.root) value = build(ref.module);
153
+ else value = evaluateProduction(ref.module, ref.name);
154
+ bareMemo.set(key, value);
155
+ }
156
+ return bareMemo.get(key);
157
+ };
158
+
159
+ const moduleFile = options.label ?? basename(modulePath);
160
+ const meta = resolved.module.meta;
42
161
  return {
43
- target: join(dirname(modulePath), `${resolved.name}.json`),
44
- content: emit(tree, { moduleFile, ...resolved.module.meta }),
162
+ target: join(
163
+ options.targetDir ?? dirname(modulePath),
164
+ `${resolved.name}.json`,
165
+ ),
166
+ content: emit(tree, {
167
+ moduleFile,
168
+ id: meta.id,
169
+ schemaId: options.schemaId,
170
+ title: meta.title,
171
+ description: meta.description,
172
+ scopeKind: nature,
173
+ // A def file is pattern-natured: its body stays unsealed unless the author seals it,
174
+ // so consumers can whole-file-compose it. A document validates alone: sealed.
175
+ sealed: meta.sealed ?? nature === 'document',
176
+ patterns,
177
+ rootDefName:
178
+ nature === 'def' && resolved.module.root ? 'root' : undefined,
179
+ bare,
180
+ linkResolve: options.linkResolve,
181
+ }),
45
182
  };
46
183
  }
47
184
 
@@ -59,41 +196,169 @@ export const schemaLintCommand: CommandDescriptor = {
59
196
  {
60
197
  name: 'module',
61
198
  required: false,
62
- description: "a .dfn file; default: every module under './schemas'",
199
+ description:
200
+ "a .dfn file or directory; default: every module under './schemas'",
201
+ },
202
+ ],
203
+ options: [
204
+ {
205
+ flag: '--fix',
206
+ description: "apply mechanical repairs (a misplaced '*') before linting",
63
207
  },
64
208
  ],
65
209
  run(ctx): CommandResult {
66
210
  const { projectDir } = ctx.project as ProjectDir;
67
- const modules = ctx.args.module
68
- ? [join(projectDir, ctx.args.module)]
69
- : modulesUnder(join(projectDir, 'schemas'));
211
+ const { modules } = resolveModules(
212
+ projectDir,
213
+ ctx.args.module,
214
+ settingsOf(ctx).source,
215
+ );
70
216
  if (modules.length === 0) {
71
217
  return { summary: 'no .dfn modules found' };
72
218
  }
73
219
 
74
220
  const project = (file: string) =>
75
221
  isAbsolute(file) ? relative(projectDir, file) : file;
76
- const diagnostics = modules.flatMap((modulePath) =>
77
- lintModule(modulePath).map((d) => ({ ...d, file: project(d.file) })),
222
+
223
+ // --fix first: repaired content is linted IN MEMORY (the runner owns writing), so the
224
+ // remaining diagnostics describe the state the fix would leave on disk.
225
+ const files: Array<{ path: string; content: string }> = [];
226
+ const fixed: string[] = [];
227
+ const sources = new Map<string, string>();
228
+ if (ctx.options.fix === true) {
229
+ for (const modulePath of modules) {
230
+ if (!existsSync(modulePath)) continue;
231
+ const source = readFileSync(modulePath, 'utf8');
232
+ const result = fixSource(source);
233
+ if (result.fixes.length === 0) continue;
234
+ sources.set(modulePath, result.content);
235
+ files.push({ path: project(modulePath), content: result.content });
236
+ for (const fix of result.fixes) {
237
+ fixed.push(
238
+ `${project(modulePath)}:${fix.line}:${fix.column} ${fix.message}`,
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ const found = modules.flatMap((modulePath) =>
245
+ lintModule(modulePath, sources).map((d) => ({
246
+ ...d,
247
+ file: project(d.file),
248
+ })),
78
249
  );
250
+ const warningLine = (d: (typeof found)[number]) =>
251
+ ` ${d.file}:${d.line}:${d.column} warning: ${d.message}`;
252
+ const warnings = found.filter((d) => d.severity === 'warning');
253
+ const diagnostics = found.filter((d) => d.severity !== 'warning');
79
254
 
80
255
  if (diagnostics.length > 0) {
81
256
  throw new Error(
82
257
  [
258
+ ...(fixed.length > 0
259
+ ? [`fixed ${fixed.length}:`, ...fixed.map((f) => ` ${f}`)]
260
+ : []),
83
261
  `${diagnostics.length} problem(s) in the .dfn modules:`,
84
262
  ...diagnostics.map(
85
263
  (d) => ` ${d.file}:${d.line}:${d.column} ${d.message}`,
86
264
  ),
265
+ ...warnings.map(warningLine),
87
266
  ].join('\n'),
88
267
  );
89
268
  }
269
+ const notes = [
270
+ ...(fixed.length > 0
271
+ ? [`fixed ${fixed.length} problem(s):`, ...fixed.map((f) => ` ${f}`)]
272
+ : []),
273
+ ...warnings.map(warningLine),
274
+ `${modules.length} module(s) clean${warnings.length > 0 ? `, ${warnings.length} warning(s)` : ''}`,
275
+ ];
90
276
  return {
91
- summary: `${modules.length} module(s) clean`,
92
- data: { modules: modules.map(project) },
277
+ summary: notes.join('\n'),
278
+ files,
279
+ data: { modules: modules.map(project), fixed, warnings },
93
280
  };
94
281
  },
95
282
  };
96
283
 
284
+ /**
285
+ * `vertekum schema fmt [module]`: canonical formatting for the `.dfn` sources — block indent
286
+ * from `format.indent`/.editorconfig, canonical spacing, hygiene. A module whose grammar cannot
287
+ * be lexed is skipped with a notice (broken grammar is lint's report — fmt never rewrites what
288
+ * it cannot read). `--check` verifies instead of writing: the CI gate.
289
+ */
290
+ export const schemaFmtCommand: CommandDescriptor = {
291
+ name: 'schema fmt',
292
+ description: 'format .dfn vocabulary modules canonically',
293
+ args: [
294
+ {
295
+ name: 'module',
296
+ required: false,
297
+ description:
298
+ "a .dfn file or directory; default: every module under './schemas'",
299
+ },
300
+ ],
301
+ options: [
302
+ {
303
+ flag: '--check',
304
+ description: 'verify formatting; write nothing',
305
+ },
306
+ ],
307
+ run(ctx): CommandResult {
308
+ const { projectDir, indent } = ctx.project as ProjectDir;
309
+ const { modules } = resolveModules(
310
+ projectDir,
311
+ ctx.args.module,
312
+ settingsOf(ctx).source,
313
+ );
314
+ if (modules.length === 0) {
315
+ return { summary: 'no .dfn modules found', files: [] };
316
+ }
317
+
318
+ const project = (file: string) =>
319
+ isAbsolute(file) ? relative(projectDir, file) : file;
320
+ const files: Array<{ path: string; content: string }> = [];
321
+ const skipped: string[] = [];
322
+ const unformatted: string[] = [];
323
+
324
+ for (const modulePath of modules) {
325
+ const source = readFileSync(modulePath, 'utf8');
326
+ let formatted: string;
327
+ try {
328
+ formatted = formatSource(source, {
329
+ indent: resolveIndent(modulePath, indent),
330
+ });
331
+ } catch (error) {
332
+ skipped.push(
333
+ `${project(modulePath)} does not lex (${error instanceof Error ? error.message : String(error)}) — run \`vertekum schema lint\``,
334
+ );
335
+ continue;
336
+ }
337
+ if (formatted === source) continue;
338
+ if (ctx.options.check === true) {
339
+ unformatted.push(project(modulePath));
340
+ continue;
341
+ }
342
+ files.push({ path: project(modulePath), content: formatted });
343
+ }
344
+
345
+ if (unformatted.length > 0) {
346
+ throw new Error(
347
+ `unformatted .dfn modules: ${unformatted.join(', ')} — run \`vertekum schema fmt\``,
348
+ );
349
+ }
350
+ const notes = [
351
+ ctx.options.check === true
352
+ ? `${modules.length - skipped.length} module(s) formatted`
353
+ : files.length > 0
354
+ ? `formatted ${files.length} module(s)`
355
+ : `${modules.length - skipped.length} module(s) already formatted`,
356
+ ...skipped,
357
+ ];
358
+ return { summary: notes.join('\n'), files, data: { skipped } };
359
+ },
360
+ };
361
+
97
362
  export const schemaBuildCommand: CommandDescriptor = {
98
363
  name: 'schema build',
99
364
  description: 'build .dfn vocabulary modules into JSON Schema files',
@@ -101,7 +366,14 @@ export const schemaBuildCommand: CommandDescriptor = {
101
366
  {
102
367
  name: 'module',
103
368
  required: false,
104
- description: "a .dfn file; default: every module under './schemas'",
369
+ description:
370
+ "a .dfn file or directory; default: the configured source ('./schemas')",
371
+ },
372
+ {
373
+ name: 'out',
374
+ required: false,
375
+ description:
376
+ 'output directory for this run; default: the configured out, else beside each module',
105
377
  },
106
378
  ],
107
379
  options: [
@@ -112,30 +384,86 @@ export const schemaBuildCommand: CommandDescriptor = {
112
384
  ],
113
385
  run(ctx): CommandResult {
114
386
  const { projectDir } = ctx.project as ProjectDir;
115
- const modules = ctx.args.module
116
- ? [join(projectDir, ctx.args.module)]
117
- : modulesUnder(join(projectDir, 'schemas'));
387
+ const settings = settingsOf(ctx);
388
+ const { modules, explicitFile, root } = resolveModules(
389
+ projectDir,
390
+ ctx.args.module,
391
+ settings.source,
392
+ );
118
393
  if (modules.length === 0) {
119
394
  return { summary: 'no .dfn modules found', files: [] };
120
395
  }
121
396
 
397
+ // Where a module's artifact lands: the positional `[out]` pairs with THIS invocation's
398
+ // module root; the configured `out` pairs with the configured `source`; a module outside
399
+ // its pair's root builds beside itself — input and output are a pair, not a redirect.
400
+ const outArg = ctx.args.out as string | undefined;
401
+ const outDir = outArg
402
+ ? join(projectDir, outArg)
403
+ : settings.out
404
+ ? join(projectDir, settings.out)
405
+ : undefined;
406
+ const pairRoot = outArg ? root : join(projectDir, settings.source);
407
+ const targetDirFor = (modulePath: string): string | undefined => {
408
+ if (!outDir) return undefined;
409
+ if (outArg && explicitFile) return outDir;
410
+ const within = relative(pairRoot, dirname(modulePath));
411
+ if (within.startsWith('..') || isAbsolute(within)) return undefined;
412
+ return join(outDir, within);
413
+ };
414
+
122
415
  const files: Array<{ path: string; content: string }> = [];
123
416
  const skipped: string[] = [];
124
417
  const fragments: string[] = [];
125
418
  const stale: string[] = [];
126
419
 
127
420
  for (const modulePath of modules) {
128
- // A rootless module is a FRAGMENT — imports for other modules. The sweep skips it;
421
+ // A `scope "inline"` module is never emitted — the sweep skips it with a notice;
129
422
  // naming one explicitly stays an error (buildModule throws), since silence there
130
- // would hide a typo'd `root`.
131
- if (!ctx.args.module && !resolveModule(modulePath).module.root) {
132
- fragments.push(relative(projectDir, modulePath));
133
- continue;
423
+ // would hide the wrong pragma.
424
+ if (!explicitFile) {
425
+ const resolved = resolveModule(modulePath);
426
+ const nature =
427
+ resolved.module.meta.scope ??
428
+ (resolved.module.root ? 'document' : 'def');
429
+ if (nature === 'inline') {
430
+ fragments.push(relative(projectDir, modulePath));
431
+ continue;
432
+ }
134
433
  }
135
- const { target, content } = buildModule(
136
- modulePath,
137
- relative(projectDir, modulePath),
138
- );
434
+ const parentTargetDir = targetDirFor(modulePath) ?? dirname(modulePath);
435
+ // Linked mode: a child links only when THIS project builds it — its artifact must be
436
+ // computable through the same source/out mapping. Package modules inline.
437
+ const linkResolve = settings.link
438
+ ? (child: ResolvedModule): string | undefined => {
439
+ const within = relative(projectDir, child.path);
440
+ if (within.startsWith('..') || within.includes('node_modules')) {
441
+ return undefined;
442
+ }
443
+ const childDir = targetDirFor(child.path) ?? dirname(child.path);
444
+ const ref = relative(
445
+ parentTargetDir,
446
+ join(childDir, `${child.name}.json`),
447
+ );
448
+ return ref.startsWith('.') ? ref : `./${ref}`;
449
+ }
450
+ : undefined;
451
+ const derivedId = settings.schemaId
452
+ ? settings.schemaId.replace(/\/?$/, '/') +
453
+ relative(
454
+ outDir ?? join(projectDir, settings.source),
455
+ join(
456
+ parentTargetDir,
457
+ `${basename(modulePath).replace(/\.dfn$/, '')}.json`,
458
+ ),
459
+ )
460
+ : undefined;
461
+ const { target, content } = buildModule(modulePath, {
462
+ label: relative(projectDir, modulePath),
463
+ targetDir: targetDirFor(modulePath),
464
+ schemaId: derivedId,
465
+ linkResolve,
466
+ });
139
467
  const existing = existsSync(target)
140
468
  ? readFileSync(target, 'utf8')
141
469
  : undefined;
@@ -161,7 +489,7 @@ export const schemaBuildCommand: CommandDescriptor = {
161
489
  ctx.options.check === true
162
490
  ? `${modules.length - fragments.length} module(s) current`
163
491
  : `built ${files.length} module(s)`,
164
- ...fragments.map((f) => `${f} declares no root (a fragment) — skipped`),
492
+ ...fragments.map((f) => `${f} is scope "inline" — skipped`),
165
493
  ...skipped.map((f) => `${f} has local edits (no stamp) — left as is`),
166
494
  ];
167
495
  return { summary: notes.join('\n'), files, data: { skipped, fragments } };