@iyulab/m3l 0.1.1 → 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/lexer.js CHANGED
@@ -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
@@ -331,9 +324,24 @@ export function parseTypeAndAttrs(rest, data) {
331
324
  skipWS();
332
325
  }
333
326
  }
334
- // Parse attributes: @name or @name(balanced_args)
327
+ // Parse attributes: @name or @name(balanced_args), with optional cascade symbols (! !!)
335
328
  const attrs = [];
336
- 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
+ }
337
345
  pos++; // skip @
338
346
  const nameStart = pos;
339
347
  while (pos < len && /\w/.test(rest[pos]))
package/dist/parser.js CHANGED
@@ -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
  }
@@ -554,20 +549,61 @@ function buildFieldNode(data, token, state) {
554
549
  const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
555
550
  field.computed = { expression: expr };
556
551
  }
557
- // Parse computed_raw: @computed_raw("expression", ...)
552
+ // Parse computed_raw: @computed_raw("expression", platform: "name")
558
553
  if (computedRawAttr && computedRawAttr.args?.[0]) {
559
- const expr = computedRawAttr.args[0].replace(/^["']|["']$/g, '');
554
+ const rawArgs = computedRawAttr.args[0];
555
+ const parts = splitComputedRawArgs(rawArgs);
556
+ const expr = parts.expression.replace(/^["']|["']$/g, '');
560
557
  field.computed = { expression: expr };
558
+ if (parts.platform) {
559
+ field.computed.platform = parts.platform;
560
+ }
561
561
  }
562
562
  return field;
563
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
+ }
564
596
  function parseAttributes(rawAttrs) {
565
597
  if (!rawAttrs)
566
598
  return [];
567
- return rawAttrs.map(a => ({
568
- name: a.name,
569
- args: a.args ? [a.args] : undefined,
570
- }));
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
+ });
571
607
  }
572
608
  function parseTypeParams(params) {
573
609
  if (!params)
@@ -697,8 +733,12 @@ function parseMetadataValue(value) {
697
733
  if (typeof value !== 'string')
698
734
  return value;
699
735
  const str = value;
700
- // 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("'"));
701
739
  const unquoted = str.replace(/^["']|["']$/g, '');
740
+ if (wasQuoted)
741
+ return unquoted;
702
742
  // Try number
703
743
  const n = Number(unquoted);
704
744
  if (!isNaN(n) && unquoted !== '')
@@ -2,7 +2,7 @@ import type { ParsedFile, M3LAST, ProjectInfo } from './types.js';
2
2
  /** AST schema version — bump major on breaking structure changes */
3
3
  export declare const AST_VERSION = "1.0";
4
4
  /** Parser package version — kept in sync with package.json */
5
- export declare const PARSER_VERSION = "0.1.1";
5
+ export declare const PARSER_VERSION = "0.1.2";
6
6
  /**
7
7
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
8
8
  * Handles: inheritance resolution, duplicate detection, reference validation.
package/dist/resolver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** AST schema version — bump major on breaking structure changes */
2
2
  export const AST_VERSION = '1.0';
3
3
  /** Parser package version — kept in sync with package.json */
4
- export const PARSER_VERSION = '0.1.1';
4
+ export const PARSER_VERSION = '0.1.2';
5
5
  /**
6
6
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
7
7
  * Handles: inheritance resolution, duplicate detection, reference validation.
@@ -131,11 +131,10 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
131
131
  // Handle @override: child fields with @override replace inherited fields
132
132
  const overrideNames = new Set();
133
133
  for (const ownField of model.fields) {
134
- const overrideIdx = ownField.attributes.findIndex(a => a.name === 'override');
135
- if (overrideIdx >= 0) {
134
+ const hasOverride = ownField.attributes.some(a => a.name === 'override');
135
+ if (hasOverride) {
136
136
  overrideNames.add(ownField.name);
137
- // Remove @override attribute from the child field
138
- ownField.attributes.splice(overrideIdx, 1);
137
+ // Preserve @override in attributes so AST consumers can detect it
139
138
  }
140
139
  }
141
140
  // Remove inherited fields that are overridden
package/dist/types.d.ts CHANGED
@@ -16,6 +16,7 @@ export type FieldKind = 'stored' | 'computed' | 'lookup' | 'rollup';
16
16
  export interface FieldAttribute {
17
17
  name: string;
18
18
  args?: (string | number | boolean)[];
19
+ cascade?: string;
19
20
  }
20
21
  /** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
21
22
  export interface CustomAttribute {
@@ -56,6 +57,7 @@ export interface FieldNode {
56
57
  };
57
58
  computed?: {
58
59
  expression: string;
60
+ platform?: string;
59
61
  };
60
62
  enum_values?: EnumValue[];
61
63
  fields?: FieldNode[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/m3l",
3
- "version": "0.1.1",
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",