@iyulab/m3l 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -64,7 +64,7 @@ npx m3l validate ./models --strict --format json
64
64
  - bio(Biography): text?
65
65
  - birth_date: date?
66
66
 
67
- # Rollup
67
+ ### Rollup
68
68
  - book_count: integer @rollup(BookAuthor.author_id, count)
69
69
 
70
70
  > Stores information about book authors.
@@ -82,10 +82,10 @@ npx m3l validate ./models --strict --format json
82
82
  - borrowed: "Borrowed"
83
83
  - publisher_id: identifier @fk(Publisher.id)
84
84
 
85
- # Lookup
85
+ ### Lookup
86
86
  - publisher_name: string @lookup(publisher_id.name)
87
87
 
88
- # Computed
88
+ ### Computed
89
89
  - is_available: boolean @computed("status = 'available' AND quantity > 0")
90
90
 
91
91
  ## OverdueLoans ::view @materialized
@@ -110,9 +110,11 @@ npx m3l validate ./models --strict --format json
110
110
  | `- field: type` | Field definition |
111
111
  | `- field: type?` | Nullable field |
112
112
  | `- field: type[]` | Array field |
113
+ | `- field: type?[]` | Array of nullable items |
113
114
  | `- field: type = val` | Field with default value |
114
115
  | `@attr` / `@attr(args)` | Attribute (constraint, index, etc.) |
115
- | `# Lookup` / `# Rollup` / `# Computed` | Kind section for derived fields |
116
+ | `` `[FrameworkAttr]` `` | Custom framework attribute |
117
+ | `### Lookup` / `### Rollup` / `### Computed` | Kind section for derived fields |
116
118
  | `### Section` | Named section (Indexes, Relations, Metadata, etc.) |
117
119
  | `> text` | Model/element description |
118
120
  | `"text"` | Inline description on field |
@@ -123,18 +125,54 @@ The parser produces an `M3LAST` object:
123
125
 
124
126
  ```typescript
125
127
  interface M3LAST {
128
+ parserVersion: string; // Parser package version (semver)
129
+ astVersion: string; // AST schema version
126
130
  project: { name?: string; version?: string };
127
- sources: string[]; // parsed file paths
128
- models: ModelNode[]; // models and interfaces
129
- enums: EnumNode[]; // enum definitions
130
- interfaces: ModelNode[]; // interface definitions
131
- views: ModelNode[]; // derived views
132
- errors: Diagnostic[]; // parse/resolve errors
133
- warnings: Diagnostic[]; // validation warnings
131
+ sources: string[];
132
+ models: ModelNode[];
133
+ enums: EnumNode[];
134
+ interfaces: ModelNode[];
135
+ views: ModelNode[];
136
+ errors: Diagnostic[];
137
+ warnings: Diagnostic[];
134
138
  }
135
139
  ```
136
140
 
137
- Each `ModelNode` contains fields, sections (indexes, relations, metadata), inheritance info, and source locations for error reporting.
141
+ ### Key AST types
142
+
143
+ ```typescript
144
+ interface FieldNode {
145
+ name: string;
146
+ label?: string;
147
+ type?: string;
148
+ params?: (string | number)[];
149
+ generic_params?: string[]; // map<K,V> → ["K", "V"]
150
+ nullable: boolean;
151
+ array: boolean;
152
+ arrayItemNullable: boolean; // string?[] → true
153
+ kind: 'stored' | 'computed' | 'lookup' | 'rollup';
154
+ default_value?: string;
155
+ description?: string;
156
+ attributes: FieldAttribute[];
157
+ framework_attrs?: CustomAttribute[];
158
+ lookup?: { path: string };
159
+ rollup?: { target: string; fk: string; aggregate: string; field?: string; where?: string };
160
+ computed?: { expression: string };
161
+ enum_values?: EnumValue[];
162
+ fields?: FieldNode[]; // sub-fields for object type
163
+ loc: SourceLocation;
164
+ }
165
+
166
+ interface ModelNode {
167
+ name: string;
168
+ type: 'model' | 'enum' | 'interface' | 'view';
169
+ inherits: string[];
170
+ attributes: FieldAttribute[]; // model-level attributes (@public, etc.)
171
+ fields: FieldNode[];
172
+ sections: { indexes; relations; behaviors; metadata; [key: string]: unknown };
173
+ // ...
174
+ }
175
+ ```
138
176
 
139
177
  ## Validation
140
178
 
@@ -143,20 +181,17 @@ The validator checks for semantic errors and style warnings:
143
181
  **Errors:**
144
182
  | Code | Description |
145
183
  |------|-------------|
146
- | E001 | Rollup FK field missing `@reference` |
147
- | E002 | Lookup FK field missing `@reference` |
148
- | E004 | View references non-existent model |
149
- | E005 | Duplicate model/enum name |
150
- | E006 | Duplicate field name within model |
151
- | E007 | Unresolved parent in inheritance |
184
+ | M3L-E001 | Rollup FK field missing `@reference` |
185
+ | M3L-E002 | Lookup FK field missing `@reference` |
186
+ | M3L-E004 | View references non-existent model |
187
+ | M3L-E006 | Duplicate field name within model |
152
188
 
153
189
  **Warnings (--strict):**
154
190
  | Code | Description |
155
191
  |------|-------------|
156
- | W001 | Model has no fields |
157
- | W002 | Model has no description |
158
- | W003 | Field missing label |
159
- | W004 | Enum has no values |
192
+ | M3L-W001 | Field line exceeds 80 characters |
193
+ | M3L-W002 | Object nesting exceeds 3 levels |
194
+ | M3L-W004 | Lookup chain exceeds 3 hops |
160
195
 
161
196
  ## Multi-file Projects
162
197
 
@@ -197,6 +232,18 @@ const ast = resolve([parsed]);
197
232
  const diagnostics = validate(ast, { strict: true });
198
233
  ```
199
234
 
235
+ ## Compatibility
236
+
237
+ This TypeScript parser and the [C# parser](../csharp/) produce equivalent AST structures. Both share the same conformance test suite.
238
+
239
+ | | TypeScript | C# |
240
+ |---|---|---|
241
+ | Package | `@iyulab/m3l` | `Iyulab.M3L` |
242
+ | Runtime | Node.js 20+ | .NET 8.0+ |
243
+ | AST Version | 1.0 | 1.0 |
244
+
245
+ Version is managed centrally via the root `VERSION` file.
246
+
200
247
  ## License
201
248
 
202
249
  MIT
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { lex } from './lexer.js';
2
2
  export { parseTokens, parseString as parseFileString } from './parser.js';
3
- export { resolve } from './resolver.js';
3
+ export { resolve, AST_VERSION, PARSER_VERSION } from './resolver.js';
4
4
  export { validate } from './validator.js';
5
5
  export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
6
6
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { lex } from './lexer.js';
2
2
  export { parseTokens, parseString as parseFileString } from './parser.js';
3
- export { resolve } from './resolver.js';
3
+ export { resolve, AST_VERSION, PARSER_VERSION } from './resolver.js';
4
4
  export { validate } from './validator.js';
5
5
  export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
6
6
  import { readM3LFiles, readProjectConfig } from './reader.js';
package/dist/lexer.d.ts CHANGED
@@ -3,3 +3,4 @@ import type { Token } from './types.js';
3
3
  * Tokenize M3L markdown content into a sequence of tokens.
4
4
  */
5
5
  export declare function lex(content: string, file: string): Token[];
6
+ export declare function parseTypeAndAttrs(rest: string, data: Record<string, unknown>): void;
package/dist/lexer.js CHANGED
@@ -11,7 +11,7 @@ const RE_TYPE_INDICATOR = /^([\w][\w.]*(?:\([^)]*\))?)\s*::(\w+)(.*)$/;
11
11
  const RE_MODEL_DEF = /^([\w][\w.]*(?:\([^)]*\))?)\s*(?::\s*(.+?))?(\s+@.+)?$/;
12
12
  // Field line patterns
13
13
  const RE_FIELD_NAME = /^([\w]+)(?:\(([^)]*)\))?\s*(?::\s*(.+))?$/;
14
- const RE_TYPE_PART = /^([\w]+)(?:\(([^)]*)\))?(\?)?(\[\])?/;
14
+ const RE_TYPE_PART = /^([\w]+)(?:<([^>]+)>)?(?:\(([^)]*)\))?(\?)?(\[\])?(\?)?/;
15
15
  const RE_FRAMEWORK_ATTR = /`\[([^\]]+)\]`/g;
16
16
  const RE_INLINE_COMMENT = /\s+#\s+(.+)$/;
17
17
  // Known kind-context H1 headers
@@ -35,15 +35,20 @@ export function lex(content, file) {
35
35
  tokens.push({ type: 'horizontal_rule', raw, line: lineNum, indent: 0 });
36
36
  continue;
37
37
  }
38
- // H3 — Section header
38
+ // H3 — Section header (including kind sections: ### Lookup, ### Rollup, ### Computed)
39
39
  const h3Match = raw.match(RE_H3);
40
40
  if (h3Match) {
41
+ const h3Name = h3Match[1].trim();
42
+ const data = { name: h3Name };
43
+ if (KIND_SECTIONS.has(h3Name)) {
44
+ data.kind_section = true;
45
+ }
41
46
  tokens.push({
42
47
  type: 'section',
43
48
  raw,
44
49
  line: lineNum,
45
50
  indent: 0,
46
- data: { name: h3Match[1].trim() },
51
+ data,
47
52
  });
48
53
  continue;
49
54
  }
@@ -54,29 +59,17 @@ export function lex(content, file) {
54
59
  tokens.push(tokenizeH2(h2Content, raw, lineNum));
55
60
  continue;
56
61
  }
57
- // H1 — Namespace or kind-section context
62
+ // H1 — Namespace / Document title only
58
63
  const h1Match = raw.match(RE_H1);
59
64
  if (h1Match) {
60
65
  const h1Content = h1Match[1].trim();
61
- // Check if this is a kind section (# Lookup, # Rollup, # Computed)
62
- if (KIND_SECTIONS.has(h1Content)) {
63
- tokens.push({
64
- type: 'section',
65
- raw,
66
- line: lineNum,
67
- indent: 0,
68
- data: { name: h1Content, kind_section: true },
69
- });
70
- }
71
- else {
72
- tokens.push({
73
- type: 'namespace',
74
- raw,
75
- line: lineNum,
76
- indent: 0,
77
- data: parseNamespace(h1Content),
78
- });
79
- }
66
+ tokens.push({
67
+ type: 'namespace',
68
+ raw,
69
+ line: lineNum,
70
+ indent: 0,
71
+ data: parseNamespace(h1Content),
72
+ });
80
73
  continue;
81
74
  }
82
75
  // Blockquote
@@ -264,7 +257,7 @@ function parseFieldLine(content) {
264
257
  parseTypeAndAttrs(rest, data);
265
258
  return data;
266
259
  }
267
- function parseTypeAndAttrs(rest, data) {
260
+ export function parseTypeAndAttrs(rest, data) {
268
261
  let pos = 0;
269
262
  const len = rest.length;
270
263
  const skipWS = () => { while (pos < len && rest[pos] === ' ')
@@ -277,15 +270,29 @@ function parseTypeAndAttrs(rest, data) {
277
270
  return;
278
271
  }
279
272
  }
280
- // Parse type: word(params)?[]?
273
+ // Parse type: word<generics>?(params)?[]??
281
274
  const typeMatch = rest.match(RE_TYPE_PART);
282
275
  if (typeMatch) {
283
276
  data.type_name = typeMatch[1];
277
+ // Group 2: generic params from <K,V>
284
278
  if (typeMatch[2]) {
285
- data.type_params = typeMatch[2].split(',').map(s => s.trim());
279
+ data.type_generic_params = typeMatch[2].split(',').map(s => s.trim());
280
+ }
281
+ // Group 3: size/type params from (params)
282
+ if (typeMatch[3]) {
283
+ data.type_params = typeMatch[3].split(',').map(s => s.trim());
284
+ }
285
+ // Group 5: array
286
+ data.array = typeMatch[5] === '[]';
287
+ // Group 4: ? before [] = element nullable; Group 6: ? after [] = container nullable
288
+ if (data.array) {
289
+ data.nullable = typeMatch[6] === '?';
290
+ data.arrayItemNullable = typeMatch[4] === '?';
291
+ }
292
+ else {
293
+ data.nullable = typeMatch[4] === '?' || typeMatch[6] === '?';
294
+ data.arrayItemNullable = false;
286
295
  }
287
- data.nullable = typeMatch[3] === '?';
288
- data.array = typeMatch[4] === '[]';
289
296
  pos = typeMatch[0].length;
290
297
  skipWS();
291
298
  }
@@ -317,9 +324,24 @@ function parseTypeAndAttrs(rest, data) {
317
324
  skipWS();
318
325
  }
319
326
  }
320
- // Parse attributes: @name or @name(balanced_args)
327
+ // Parse attributes: @name or @name(balanced_args), with optional cascade symbols (! !!)
321
328
  const attrs = [];
322
- while (pos < len && rest[pos] === '@') {
329
+ while (pos < len && (rest[pos] === '@' || rest[pos] === '!' || rest[pos] === '?')) {
330
+ // Cascade symbols: !, !!, ? — attach to the previous attribute
331
+ if (rest[pos] === '!' || rest[pos] === '?') {
332
+ let symbol = rest[pos];
333
+ pos++;
334
+ if (symbol === '!' && pos < len && rest[pos] === '!') {
335
+ symbol = '!!';
336
+ pos++;
337
+ }
338
+ // Attach cascade to last attribute
339
+ if (attrs.length > 0) {
340
+ attrs[attrs.length - 1].cascade = symbol;
341
+ }
342
+ skipWS();
343
+ continue;
344
+ }
323
345
  pos++; // skip @
324
346
  const nameStart = pos;
325
347
  while (pos < len && /\w/.test(rest[pos]))
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { lex } from './lexer.js';
1
+ import { lex, parseTypeAndAttrs } from './lexer.js';
2
2
  /**
3
3
  * Parse M3L content string into a ParsedFile AST.
4
4
  */
@@ -75,11 +75,6 @@ function processToken(token, state) {
75
75
  }
76
76
  function handleNamespace(token, state) {
77
77
  const data = token.data;
78
- // Check if this is a kind section (# Lookup, # Rollup, etc.)
79
- if (data.kind_section) {
80
- handleSection(token, state);
81
- return;
82
- }
83
78
  if (!state.currentElement) {
84
79
  state.namespace = data.name;
85
80
  }
@@ -87,6 +82,7 @@ function handleNamespace(token, state) {
87
82
  function handleModelStart(token, state) {
88
83
  finalizeElement(state);
89
84
  const data = token.data;
85
+ const modelAttrs = parseAttributes(data.attributes);
90
86
  const model = {
91
87
  name: data.name,
92
88
  label: data.label,
@@ -94,6 +90,7 @@ function handleModelStart(token, state) {
94
90
  source: state.file,
95
91
  line: token.line,
96
92
  inherits: data.inherits || [],
93
+ attributes: modelAttrs,
97
94
  fields: [],
98
95
  sections: {
99
96
  indexes: [],
@@ -137,6 +134,7 @@ function handleViewStart(token, state) {
137
134
  source: state.file,
138
135
  line: token.line,
139
136
  inherits: [],
137
+ attributes: [],
140
138
  materialized: data.materialized || false,
141
139
  fields: [],
142
140
  sections: {
@@ -248,15 +246,25 @@ function handleDirective(data, model, token, state) {
248
246
  });
249
247
  }
250
248
  else {
251
- // Generic directive
252
- const sectionName = attr.name;
253
- if (!model.sections[sectionName]) {
254
- model.sections[sectionName] = [];
249
+ // Generic directive — normalize singular form
250
+ let sectionName = attr.name;
251
+ if (sectionName === 'behavior')
252
+ sectionName = 'behaviors';
253
+ if (sectionName === 'behaviors') {
254
+ model.sections.behaviors.push({
255
+ raw: data.raw_content,
256
+ args: attr.args,
257
+ });
258
+ }
259
+ else {
260
+ if (!model.sections[sectionName]) {
261
+ model.sections[sectionName] = [];
262
+ }
263
+ model.sections[sectionName].push({
264
+ raw: data.raw_content,
265
+ args: attr.args,
266
+ });
255
267
  }
256
- model.sections[sectionName].push({
257
- raw: data.raw_content,
258
- args: attr.args,
259
- });
260
268
  }
261
269
  }
262
270
  function handleSectionItem(data, model, token, state) {
@@ -332,10 +340,17 @@ function handleSectionItem(data, model, token, state) {
332
340
  });
333
341
  return;
334
342
  }
335
- // Generic section — treat as fields
336
- const field = buildFieldNode(data, token, state);
337
- model.fields.push(field);
338
- state.lastField = field;
343
+ // Generic section — store as section items, NOT as fields
344
+ if (!model.sections[section]) {
345
+ model.sections[section] = [];
346
+ }
347
+ model.sections[section].push({
348
+ name: data.name,
349
+ raw: token.raw.trim(),
350
+ value: data.type_name || data.description || data.raw_value,
351
+ loc,
352
+ });
353
+ state.lastField = null;
339
354
  }
340
355
  function handleNestedItem(token, state) {
341
356
  if (!state.currentElement)
@@ -402,6 +417,29 @@ function handleNestedItem(token, state) {
402
417
  });
403
418
  return;
404
419
  }
420
+ // Sub-field for object/nested type
421
+ if (key && value) {
422
+ // Walk up to find the nearest object-type ancestor for this indent level
423
+ const parentField = field;
424
+ if (parentField.type === 'object') {
425
+ if (!parentField.fields)
426
+ parentField.fields = [];
427
+ // Re-parse value as type and attributes
428
+ const subData = { name: key };
429
+ parseTypeAndAttrs(value, subData);
430
+ // Only treat as sub-field if a type was extracted
431
+ if (subData.type_name) {
432
+ const subField = buildFieldNode(subData, token, state);
433
+ parentField.fields.push(subField);
434
+ // If the sub-field is also an object, set it as lastField for deeper nesting
435
+ if (subField.type === 'object') {
436
+ state.lastField = subField;
437
+ }
438
+ // Otherwise keep lastField pointing to the parent object for siblings
439
+ return;
440
+ }
441
+ }
442
+ }
405
443
  // Extended format field attributes
406
444
  if (key) {
407
445
  applyExtendedAttribute(field, key, value || '');
@@ -472,6 +510,7 @@ function buildFieldNode(data, token, state) {
472
510
  const lookupAttr = attrs.find(a => a.name === 'lookup');
473
511
  const rollupAttr = attrs.find(a => a.name === 'rollup');
474
512
  const computedAttr = attrs.find(a => a.name === 'computed');
513
+ const computedRawAttr = attrs.find(a => a.name === 'computed_raw');
475
514
  const fromAttr = attrs.find(a => a.name === 'from');
476
515
  if (lookupAttr)
477
516
  kind = 'lookup';
@@ -479,18 +518,22 @@ function buildFieldNode(data, token, state) {
479
518
  kind = 'rollup';
480
519
  else if (computedAttr)
481
520
  kind = 'computed';
521
+ else if (computedRawAttr)
522
+ kind = 'computed';
482
523
  const field = {
483
524
  name: data.name,
484
525
  label: data.label,
485
526
  type: data.type_name,
486
527
  params: parseTypeParams(data.type_params),
528
+ generic_params: data.type_generic_params,
487
529
  nullable: data.nullable || false,
488
530
  array: data.array || false,
531
+ arrayItemNullable: data.arrayItemNullable || false,
489
532
  kind,
490
533
  default_value: data.default_value,
491
534
  description: data.description,
492
535
  attributes: attrs,
493
- framework_attrs: data.framework_attrs,
536
+ framework_attrs: parseCustomAttributes(data.framework_attrs),
494
537
  loc: { file: state.file, line: token.line, col: 1 },
495
538
  };
496
539
  // Parse lookup
@@ -506,15 +549,61 @@ function buildFieldNode(data, token, state) {
506
549
  const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
507
550
  field.computed = { expression: expr };
508
551
  }
552
+ // Parse computed_raw: @computed_raw("expression", platform: "name")
553
+ if (computedRawAttr && computedRawAttr.args?.[0]) {
554
+ const rawArgs = computedRawAttr.args[0];
555
+ const parts = splitComputedRawArgs(rawArgs);
556
+ const expr = parts.expression.replace(/^["']|["']$/g, '');
557
+ field.computed = { expression: expr };
558
+ if (parts.platform) {
559
+ field.computed.platform = parts.platform;
560
+ }
561
+ }
509
562
  return field;
510
563
  }
564
+ /**
565
+ * Split @computed_raw args: "expr", platform: "name"
566
+ * Returns the expression (first positional arg) and optional named params.
567
+ */
568
+ function splitComputedRawArgs(raw) {
569
+ // Find the first quoted string as the expression
570
+ const quoteChar = raw[0];
571
+ if (quoteChar === '"' || quoteChar === "'") {
572
+ // Find matching closing quote (not escaped)
573
+ let i = 1;
574
+ while (i < raw.length) {
575
+ if (raw[i] === '\\') {
576
+ i += 2;
577
+ continue;
578
+ }
579
+ if (raw[i] === quoteChar)
580
+ break;
581
+ i++;
582
+ }
583
+ const expression = raw.slice(0, i + 1); // includes quotes
584
+ const remainder = raw.slice(i + 1).trim();
585
+ // Parse named params from remainder: , platform: "sqlserver"
586
+ let platform;
587
+ const platformMatch = remainder.match(/platform\s*:\s*["']([^"']+)["']/);
588
+ if (platformMatch) {
589
+ platform = platformMatch[1];
590
+ }
591
+ return { expression, platform };
592
+ }
593
+ // No quotes — treat entire string as expression
594
+ return { expression: raw };
595
+ }
511
596
  function parseAttributes(rawAttrs) {
512
597
  if (!rawAttrs)
513
598
  return [];
514
- return rawAttrs.map(a => ({
515
- name: a.name,
516
- args: a.args ? [a.args] : undefined,
517
- }));
599
+ return rawAttrs.map(a => {
600
+ const attr = { name: a.name };
601
+ if (a.args)
602
+ attr.args = [a.args];
603
+ if (a.cascade)
604
+ attr.cascade = a.cascade;
605
+ return attr;
606
+ });
518
607
  }
519
608
  function parseTypeParams(params) {
520
609
  if (!params)
@@ -644,8 +733,12 @@ function parseMetadataValue(value) {
644
733
  if (typeof value !== 'string')
645
734
  return value;
646
735
  const str = value;
647
- // Remove surrounding quotes
736
+ // If explicitly quoted, preserve as string (don't coerce "1.0" to number)
737
+ const wasQuoted = (str.startsWith('"') && str.endsWith('"')) ||
738
+ (str.startsWith("'") && str.endsWith("'"));
648
739
  const unquoted = str.replace(/^["']|["']$/g, '');
740
+ if (wasQuoted)
741
+ return unquoted;
649
742
  // Try number
650
743
  const n = Number(unquoted);
651
744
  if (!isNaN(n) && unquoted !== '')
@@ -699,6 +792,15 @@ function applyExtendedAttribute(field, key, value) {
699
792
  break;
700
793
  }
701
794
  }
795
+ function parseCustomAttributes(rawAttrs) {
796
+ if (!rawAttrs || rawAttrs.length === 0)
797
+ return undefined;
798
+ return rawAttrs.map(raw => {
799
+ // raw is "[MaxLength(100)]" — strip brackets to get content
800
+ const content = raw.replace(/^\[|\]$/g, '');
801
+ return { content, raw };
802
+ });
803
+ }
702
804
  function isEnumNode(el) {
703
805
  return el.type === 'enum' && 'values' in el;
704
806
  }
@@ -1,4 +1,8 @@
1
1
  import type { ParsedFile, M3LAST, ProjectInfo } from './types.js';
2
+ /** AST schema version — bump major on breaking structure changes */
3
+ export declare const AST_VERSION = "1.0";
4
+ /** Parser package version — kept in sync with package.json */
5
+ export declare const PARSER_VERSION = "0.1.2";
2
6
  /**
3
7
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
4
8
  * Handles: inheritance resolution, duplicate detection, reference validation.
package/dist/resolver.js CHANGED
@@ -1,3 +1,7 @@
1
+ /** AST schema version — bump major on breaking structure changes */
2
+ export const AST_VERSION = '1.0';
3
+ /** Parser package version — kept in sync with package.json */
4
+ export const PARSER_VERSION = '0.1.2';
1
5
  /**
2
6
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
3
7
  * Handles: inheritance resolution, duplicate detection, reference validation.
@@ -58,6 +62,8 @@ export function resolve(files, project) {
58
62
  projectInfo.name = ns;
59
63
  }
60
64
  return {
65
+ parserVersion: PARSER_VERSION,
66
+ astVersion: AST_VERSION,
61
67
  project: projectInfo,
62
68
  sources,
63
69
  models: allModels,
@@ -72,7 +78,7 @@ function checkDuplicate(name, kind, item, map, allMap, errors) {
72
78
  const existing = allMap.get(name);
73
79
  if (existing) {
74
80
  errors.push({
75
- code: 'E005',
81
+ code: 'M3L-E005',
76
82
  severity: 'error',
77
83
  file: item.source,
78
84
  line: item.line,
@@ -95,7 +101,7 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
95
101
  if (!parent) {
96
102
  if (!allNamedMap.has(name)) {
97
103
  errors.push({
98
- code: 'E007',
104
+ code: 'M3L-E007',
99
105
  severity: 'error',
100
106
  file: fromModel.source,
101
107
  line: fromModel.line,
@@ -122,9 +128,20 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
122
128
  for (const parentName of model.inherits) {
123
129
  collectFields(parentName, model);
124
130
  }
131
+ // Handle @override: child fields with @override replace inherited fields
132
+ const overrideNames = new Set();
133
+ for (const ownField of model.fields) {
134
+ const hasOverride = ownField.attributes.some(a => a.name === 'override');
135
+ if (hasOverride) {
136
+ overrideNames.add(ownField.name);
137
+ // Preserve @override in attributes so AST consumers can detect it
138
+ }
139
+ }
140
+ // Remove inherited fields that are overridden
141
+ const filteredInherited = inheritedFields.filter(f => !overrideNames.has(f.name));
125
142
  // Prepend inherited fields before model's own fields
126
- if (inheritedFields.length > 0) {
127
- model.fields = [...inheritedFields, ...model.fields];
143
+ if (filteredInherited.length > 0) {
144
+ model.fields = [...filteredInherited, ...model.fields];
128
145
  }
129
146
  }
130
147
  function checkDuplicateFields(model, errors) {
@@ -133,7 +150,7 @@ function checkDuplicateFields(model, errors) {
133
150
  const existing = seen.get(field.name);
134
151
  if (existing) {
135
152
  errors.push({
136
- code: 'E005',
153
+ code: 'M3L-E005',
137
154
  severity: 'error',
138
155
  file: field.loc.file,
139
156
  line: field.loc.line,
package/dist/types.d.ts CHANGED
@@ -15,7 +15,15 @@ export interface Token {
15
15
  export type FieldKind = 'stored' | 'computed' | 'lookup' | 'rollup';
16
16
  export interface FieldAttribute {
17
17
  name: string;
18
- args?: unknown[];
18
+ args?: (string | number | boolean)[];
19
+ cascade?: string;
20
+ }
21
+ /** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
22
+ export interface CustomAttribute {
23
+ /** Content inside brackets, e.g., "MaxLength(100)" for `[MaxLength(100)]` */
24
+ content: string;
25
+ /** Original text including brackets, e.g., "[MaxLength(100)]" */
26
+ raw: string;
19
27
  }
20
28
  export interface EnumValue {
21
29
  name: string;
@@ -28,13 +36,15 @@ export interface FieldNode {
28
36
  label?: string;
29
37
  type?: string;
30
38
  params?: (string | number)[];
39
+ generic_params?: string[];
31
40
  nullable: boolean;
32
41
  array: boolean;
42
+ arrayItemNullable: boolean;
33
43
  kind: FieldKind;
34
44
  default_value?: string;
35
45
  description?: string;
36
46
  attributes: FieldAttribute[];
37
- framework_attrs?: string[];
47
+ framework_attrs?: CustomAttribute[];
38
48
  lookup?: {
39
49
  path: string;
40
50
  };
@@ -47,6 +57,7 @@ export interface FieldNode {
47
57
  };
48
58
  computed?: {
49
59
  expression: string;
60
+ platform?: string;
50
61
  };
51
62
  enum_values?: EnumValue[];
52
63
  fields?: FieldNode[];
@@ -60,6 +71,7 @@ export interface ModelNode {
60
71
  line: number;
61
72
  inherits: string[];
62
73
  description?: string;
74
+ attributes: FieldAttribute[];
63
75
  fields: FieldNode[];
64
76
  sections: {
65
77
  indexes: unknown[];
@@ -117,6 +129,10 @@ export interface ParsedFile {
117
129
  views: ModelNode[];
118
130
  }
119
131
  export interface M3LAST {
132
+ /** Parser package version (semver) */
133
+ parserVersion: string;
134
+ /** AST schema version — incremented on breaking AST structure changes */
135
+ astVersion: string;
120
136
  project: ProjectInfo;
121
137
  sources: string[];
122
138
  models: ModelNode[];
package/dist/validator.js CHANGED
@@ -9,7 +9,7 @@ export function validate(ast, options = {}) {
9
9
  for (const m of allModels) {
10
10
  modelMap.set(m.name, m);
11
11
  }
12
- // E001: @rollup FK missing @reference
12
+ // M3L-E001: @rollup FK missing @reference
13
13
  for (const model of allModels) {
14
14
  for (const field of model.fields) {
15
15
  if (field.kind === 'rollup' && field.rollup) {
@@ -17,7 +17,7 @@ export function validate(ast, options = {}) {
17
17
  }
18
18
  }
19
19
  }
20
- // E002: @lookup path FK missing @reference
20
+ // M3L-E002: @lookup path FK missing @reference
21
21
  for (const model of allModels) {
22
22
  for (const field of model.fields) {
23
23
  if (field.kind === 'lookup' && field.lookup) {
@@ -25,12 +25,12 @@ export function validate(ast, options = {}) {
25
25
  }
26
26
  }
27
27
  }
28
- // E004: View @from references model not found
28
+ // M3L-E004: View @from references model not found
29
29
  for (const view of ast.views) {
30
30
  if (view.source_def?.from) {
31
31
  if (!modelMap.has(view.source_def.from)) {
32
32
  errors.push({
33
- code: 'E004',
33
+ code: 'M3L-E004',
34
34
  severity: 'error',
35
35
  file: view.source,
36
36
  line: view.line,
@@ -40,13 +40,13 @@ export function validate(ast, options = {}) {
40
40
  }
41
41
  }
42
42
  }
43
- // E005: Duplicate field names (already checked in resolver, but re-check for safety)
43
+ // M3L-E006: Duplicate field names (already checked in resolver, but re-check for safety)
44
44
  for (const model of allModels) {
45
45
  const seen = new Set();
46
46
  for (const field of model.fields) {
47
47
  if (seen.has(field.name)) {
48
48
  errors.push({
49
- code: 'E005',
49
+ code: 'M3L-E006',
50
50
  severity: 'error',
51
51
  file: field.loc.file,
52
52
  line: field.loc.line,
@@ -61,16 +61,16 @@ export function validate(ast, options = {}) {
61
61
  if (options.strict) {
62
62
  for (const model of allModels) {
63
63
  for (const field of model.fields) {
64
- // W001: Field line length > 80 chars
64
+ // M3L-W001: Field line length > 80 chars
65
65
  // We check the source loc raw length — approximate using field attributes count
66
66
  checkFieldLineLength(field, model, warnings);
67
- // W003: Framework attrs without backtick (already processed in lexer, skip)
68
- // W004: Lookup chain > 3 hops
67
+ // M3L-W003: Framework attrs without backtick (already processed in lexer, skip)
68
+ // M3L-W004: Lookup chain > 3 hops
69
69
  if (field.kind === 'lookup' && field.lookup) {
70
70
  const hops = field.lookup.path.split('.').length;
71
71
  if (hops > 3) {
72
72
  warnings.push({
73
- code: 'W004',
73
+ code: 'M3L-W004',
74
74
  severity: 'warning',
75
75
  file: field.loc.file,
76
76
  line: field.loc.line,
@@ -80,10 +80,10 @@ export function validate(ast, options = {}) {
80
80
  }
81
81
  }
82
82
  }
83
- // W002: Object nesting > 3 levels
83
+ // M3L-W002: Object nesting > 3 levels
84
84
  checkNestingDepth(model.fields, 1, model, warnings);
85
85
  }
86
- // W006: Inline enum missing values: key
86
+ // M3L-W006: Inline enum missing values: key
87
87
  for (const model of allModels) {
88
88
  for (const field of model.fields) {
89
89
  if (field.type === 'enum' && field.enum_values && field.enum_values.length > 0) {
@@ -102,7 +102,7 @@ function validateRollupReference(field, model, modelMap, errors) {
102
102
  const rollup = field.rollup;
103
103
  const targetModel = modelMap.get(rollup.target);
104
104
  if (!targetModel) {
105
- // Target model doesn't exist — this is E007 (already caught in resolver)
105
+ // Target model doesn't exist — this is M3L-E007 (already caught in resolver)
106
106
  return;
107
107
  }
108
108
  // Check that the FK field in the target model has @reference or @fk
@@ -114,7 +114,7 @@ function validateRollupReference(field, model, modelMap, errors) {
114
114
  const hasReference = fkField.attributes.some(a => a.name === 'reference' || a.name === 'fk');
115
115
  if (!hasReference) {
116
116
  errors.push({
117
- code: 'E001',
117
+ code: 'M3L-E001',
118
118
  severity: 'error',
119
119
  file: field.loc.file,
120
120
  line: field.loc.line,
@@ -136,7 +136,7 @@ function validateLookupReference(field, model, modelMap, errors) {
136
136
  const hasReference = fkField.attributes.some(a => a.name === 'reference' || a.name === 'fk');
137
137
  if (!hasReference) {
138
138
  errors.push({
139
- code: 'E002',
139
+ code: 'M3L-E002',
140
140
  severity: 'error',
141
141
  file: field.loc.file,
142
142
  line: field.loc.line,
@@ -168,7 +168,7 @@ function checkFieldLineLength(field, model, warnings) {
168
168
  len += 3 + field.description.length;
169
169
  if (len > 80) {
170
170
  warnings.push({
171
- code: 'W001',
171
+ code: 'M3L-W001',
172
172
  severity: 'warning',
173
173
  file: field.loc.file,
174
174
  line: field.loc.line,
@@ -182,7 +182,7 @@ function checkNestingDepth(fields, depth, model, warnings) {
182
182
  if (field.fields && field.fields.length > 0) {
183
183
  if (depth >= 3) {
184
184
  warnings.push({
185
- code: 'W002',
185
+ code: 'M3L-W002',
186
186
  severity: 'warning',
187
187
  file: field.loc.file,
188
188
  line: field.loc.line,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/m3l",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "M3L parser and CLI tool — parse .m3l.md files into JSON AST",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",