@iyulab/m3l 0.1.2 → 0.1.4

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
@@ -1,6 +1,10 @@
1
1
  # @iyulab/m3l
2
2
 
3
- M3L (Meta Model Markup Language) parser and CLI — parse `.m3l.md` files into a structured JSON AST.
3
+ [![npm](https://img.shields.io/npm/v/@iyulab/m3l)](https://www.npmjs.com/package/@iyulab/m3l)
4
+ [![TypeScript CI](https://github.com/iyulab/m3l/actions/workflows/parser-publish.yml/badge.svg)](https://github.com/iyulab/m3l/actions/workflows/parser-publish.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](../../LICENSE)
6
+
7
+ M3L (Meta Model Markup Language) parser and CLI — parse `.m3l.md` / `.m3l` files into a structured JSON AST.
4
8
 
5
9
  M3L is a Markdown-based data modeling language. You write data models in readable Markdown, and this parser converts them into a machine-processable AST with full validation.
6
10
 
@@ -107,6 +111,7 @@ npx m3l validate ./models --strict --format json
107
111
  | `## Name ::enum` | Enum definition |
108
112
  | `## Name ::interface` | Interface definition |
109
113
  | `## Name ::view` | Derived view |
114
+ | `## @name ::attribute` | Attribute registry entry |
110
115
  | `- field: type` | Field definition |
111
116
  | `- field: type?` | Nullable field |
112
117
  | `- field: type[]` | Array field |
@@ -133,6 +138,7 @@ interface M3LAST {
133
138
  enums: EnumNode[];
134
139
  interfaces: ModelNode[];
135
140
  views: ModelNode[];
141
+ attributeRegistry: AttributeRegistryEntry[];
136
142
  errors: Diagnostic[];
137
143
  warnings: Diagnostic[];
138
144
  }
@@ -146,10 +152,10 @@ interface FieldNode {
146
152
  label?: string;
147
153
  type?: string;
148
154
  params?: (string | number)[];
149
- generic_params?: string[]; // map<K,V> ["K", "V"]
155
+ generic_params?: string[]; // map<K,V> -> ["K", "V"]
150
156
  nullable: boolean;
151
157
  array: boolean;
152
- arrayItemNullable: boolean; // string?[] true
158
+ arrayItemNullable: boolean; // string?[] -> true
153
159
  kind: 'stored' | 'computed' | 'lookup' | 'rollup';
154
160
  default_value?: string;
155
161
  description?: string;
@@ -163,17 +169,61 @@ interface FieldNode {
163
169
  loc: SourceLocation;
164
170
  }
165
171
 
166
- interface ModelNode {
172
+ interface FieldAttribute {
173
+ name: string;
174
+ args?: (string | number | boolean)[];
175
+ cascade?: string;
176
+ isStandard?: boolean; // true for M3L standard attributes
177
+ isRegistered?: boolean; // true for attributes in the registry
178
+ }
179
+
180
+ interface CustomAttribute {
181
+ content: string; // e.g. "MaxLength(100)"
182
+ raw: string; // e.g. "[MaxLength(100)]"
183
+ parsed?: { // structured parse result
184
+ name: string;
185
+ arguments: (string | number | boolean)[];
186
+ };
187
+ }
188
+
189
+ interface AttributeRegistryEntry {
167
190
  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
- // ...
191
+ description?: string;
192
+ target: ('field' | 'model')[];
193
+ type: string;
194
+ range?: [number, number];
195
+ required: boolean;
196
+ defaultValue?: string | number | boolean;
174
197
  }
175
198
  ```
176
199
 
200
+ ## Attribute Registry
201
+
202
+ Define custom attributes with validation metadata using `::attribute`:
203
+
204
+ ```markdown
205
+ ## @pii ::attribute
206
+ > Personal identifiable information marker
207
+ - target: [field]
208
+ - type: boolean
209
+ - default: false
210
+
211
+ ## @audit_level ::attribute
212
+ > Audit compliance level
213
+ - target: [field, model]
214
+ - type: integer
215
+ - range: [1, 5]
216
+ - default: 1
217
+ ```
218
+
219
+ Attributes are classified into 3 tiers:
220
+
221
+ | Tier | `isStandard` | `isRegistered` | Example |
222
+ |------|-------------|----------------|---------|
223
+ | Standard | `true` | — | `@primary`, `@unique`, `@reference` |
224
+ | Registered | — | `true` | `@pii`, `@audit_level` (defined via `::attribute`) |
225
+ | Unregistered | — | — | `@some_unknown_attr` |
226
+
177
227
  ## Validation
178
228
 
179
229
  The validator checks for semantic errors and style warnings:
@@ -184,7 +234,9 @@ The validator checks for semantic errors and style warnings:
184
234
  | M3L-E001 | Rollup FK field missing `@reference` |
185
235
  | M3L-E002 | Lookup FK field missing `@reference` |
186
236
  | M3L-E004 | View references non-existent model |
237
+ | M3L-E005 | Duplicate model/enum name |
187
238
  | M3L-E006 | Duplicate field name within model |
239
+ | M3L-E007 | Unresolved parent in inheritance |
188
240
 
189
241
  **Warnings (--strict):**
190
242
  | Code | Description |
@@ -221,6 +273,10 @@ Parse an M3L content string into an AST.
221
273
 
222
274
  Parse and validate M3L files. Options: `{ strict?: boolean }`.
223
275
 
276
+ ### `STANDARD_ATTRIBUTES: Set<string>`
277
+
278
+ The set of 27 M3L standard attribute names (e.g. `primary`, `unique`, `reference`, `computed`, ...).
279
+
224
280
  ### Lower-level API
225
281
 
226
282
  ```typescript
@@ -238,7 +294,7 @@ This TypeScript parser and the [C# parser](../csharp/) produce equivalent AST st
238
294
 
239
295
  | | TypeScript | C# |
240
296
  |---|---|---|
241
- | Package | `@iyulab/m3l` | `Iyulab.M3L` |
297
+ | Package | `@iyulab/m3l` | `M3LParser` |
242
298
  | Runtime | Node.js 20+ | .NET 8.0+ |
243
299
  | AST Version | 1.0 | 1.0 |
244
300
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { lex } from './lexer.js';
2
- export { parseTokens, parseString as parseFileString } from './parser.js';
2
+ export { parseTokens, parseString as parseFileString, STANDARD_ATTRIBUTES } from './parser.js';
3
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';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { lex } from './lexer.js';
2
- export { parseTokens, parseString as parseFileString } from './parser.js';
2
+ export { parseTokens, parseString as parseFileString, STANDARD_ATTRIBUTES } from './parser.js';
3
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';
@@ -15,7 +15,7 @@ export async function parse(inputPath) {
15
15
  const resolved = resolvePath(inputPath);
16
16
  const files = await readM3LFiles(resolved);
17
17
  if (files.length === 0) {
18
- throw new Error(`No .m3l.md files found at: ${inputPath}`);
18
+ throw new Error(`No .m3l.md or .m3l files found at: ${inputPath}`);
19
19
  }
20
20
  const parsedFiles = files.map(f => parseFileContent(f.content, f.path));
21
21
  let projectInfo;
package/dist/lexer.js CHANGED
@@ -3,11 +3,11 @@ const RE_H1 = /^# (.+)$/;
3
3
  const RE_H2 = /^## (.+)$/;
4
4
  const RE_H3 = /^### (.+)$/;
5
5
  const RE_HR = /^-{3,}$/;
6
- const RE_BLOCKQUOTE = /^> (.+)$/;
6
+ const RE_BLOCKQUOTE = /^\s*> (.+)$/;
7
7
  const RE_LIST_ITEM = /^(\s*)- (.+)$/;
8
8
  const RE_BLANK = /^\s*$/;
9
9
  // H2 sub-patterns
10
- const RE_TYPE_INDICATOR = /^([\w][\w.]*(?:\([^)]*\))?)\s*::(\w+)(.*)$/;
10
+ 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*(.+))?$/;
@@ -135,7 +135,7 @@ export function lex(content, file) {
135
135
  return tokens;
136
136
  }
137
137
  function tokenizeH2(content, raw, line) {
138
- // Check for type indicator: ## Name ::enum, ::interface, ::view
138
+ // Check for type indicator: ## Name ::enum, ::interface, ::view, ::attribute
139
139
  const typeMatch = content.match(RE_TYPE_INDICATOR);
140
140
  if (typeMatch) {
141
141
  const namepart = typeMatch[1];
@@ -143,6 +143,14 @@ function tokenizeH2(content, raw, line) {
143
143
  const rest = typeMatch[3]?.trim() || '';
144
144
  const { name, label } = parseNameLabel(namepart);
145
145
  const data = { name, label };
146
+ // Parse inheritance: ::enum : Base1, Base2
147
+ const inheritMatch = rest.match(/^:\s*(.+?)(?:\s+@|\s*"|\s*$)/);
148
+ if (inheritMatch) {
149
+ data.inherits = inheritMatch[1].split(',').map(s => s.trim()).filter(Boolean);
150
+ }
151
+ else {
152
+ data.inherits = [];
153
+ }
146
154
  if (typeIndicator === 'view') {
147
155
  data.materialized = rest.includes('@materialized');
148
156
  }
@@ -151,7 +159,8 @@ function tokenizeH2(content, raw, line) {
151
159
  if (descMatch) {
152
160
  data.description = descMatch[1];
153
161
  }
154
- return { type: typeIndicator, raw, line, indent: 0, data };
162
+ const tokenType = typeIndicator === 'attribute' ? 'attribute_def' : typeIndicator;
163
+ return { type: tokenType, raw, line, indent: 0, data };
155
164
  }
156
165
  // Regular model: ## Name : Parent1, Parent2
157
166
  const modelMatch = content.match(RE_MODEL_DEF);
package/dist/parser.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import type { Token, ParsedFile } from './types.js';
2
+ /**
3
+ * Standard M3L attribute catalog.
4
+ * These are the officially defined attributes in the M3L specification.
5
+ */
6
+ export declare const STANDARD_ATTRIBUTES: Set<string>;
2
7
  /**
3
8
  * Parse M3L content string into a ParsedFile AST.
4
9
  */
package/dist/parser.js CHANGED
@@ -1,4 +1,22 @@
1
1
  import { lex, parseTypeAndAttrs } from './lexer.js';
2
+ /**
3
+ * Standard M3L attribute catalog.
4
+ * These are the officially defined attributes in the M3L specification.
5
+ */
6
+ export const STANDARD_ATTRIBUTES = new Set([
7
+ // Field constraints
8
+ 'primary', 'unique', 'required', 'index', 'generated', 'immutable',
9
+ // References / relations
10
+ 'reference', 'fk', 'relation', 'on_update', 'on_delete',
11
+ // Search / display
12
+ 'searchable', 'description', 'visibility',
13
+ // Validation
14
+ 'min', 'max', 'validate', 'not_null',
15
+ // Derived fields
16
+ 'computed', 'computed_raw', 'lookup', 'rollup', 'from', 'persisted',
17
+ // Model-level
18
+ 'public', 'private', 'materialized', 'meta', 'behavior', 'override', 'default_attribute',
19
+ ]);
2
20
  /**
3
21
  * Parse M3L content string into a ParsedFile AST.
4
22
  */
@@ -21,6 +39,8 @@ export function parseTokens(tokens, file) {
21
39
  enums: [],
22
40
  interfaces: [],
23
41
  views: [],
42
+ attributeRegistry: [],
43
+ currentAttrDef: null,
24
44
  sourceDirectivesDone: false,
25
45
  };
26
46
  for (const token of tokens) {
@@ -35,6 +55,7 @@ export function parseTokens(tokens, file) {
35
55
  enums: state.enums,
36
56
  interfaces: state.interfaces,
37
57
  views: state.views,
58
+ attributeRegistry: state.attributeRegistry,
38
59
  };
39
60
  }
40
61
  function processToken(token, state) {
@@ -52,6 +73,9 @@ function processToken(token, state) {
52
73
  case 'view':
53
74
  handleViewStart(token, state);
54
75
  break;
76
+ case 'attribute_def':
77
+ handleAttributeDefStart(token, state);
78
+ break;
55
79
  case 'section':
56
80
  handleSection(token, state);
57
81
  break;
@@ -115,6 +139,7 @@ function handleEnumStart(token, state) {
115
139
  type: 'enum',
116
140
  source: state.file,
117
141
  line: token.line,
142
+ inherits: data.inherits || [],
118
143
  description: data.description,
119
144
  values: [],
120
145
  loc: { file: state.file, line: token.line, col: 1 },
@@ -181,6 +206,18 @@ function handleSection(token, state) {
181
206
  }
182
207
  }
183
208
  function handleField(token, state) {
209
+ // Handle attribute definition fields (- target: [field, model])
210
+ if (state.currentAttrDef) {
211
+ const data = token.data;
212
+ const name = data.name;
213
+ const raw = token.raw.trim().replace(/^-\s*/, '');
214
+ const colonIdx = raw.indexOf(':');
215
+ if (colonIdx >= 0) {
216
+ const value = raw.substring(colonIdx + 1).trim();
217
+ state.currentAttrDef.fields.set(name, value);
218
+ }
219
+ return;
220
+ }
184
221
  if (!state.currentElement)
185
222
  return;
186
223
  const data = token.data;
@@ -229,11 +266,12 @@ function handleDirective(data, model, token, state) {
229
266
  if (!attrs || attrs.length === 0)
230
267
  return;
231
268
  const attr = attrs[0];
232
- if (attr.name === 'index') {
269
+ if (attr.name === 'index' || attr.name === 'unique') {
233
270
  model.sections.indexes.push({
234
271
  type: 'directive',
235
272
  raw: data.raw_content,
236
273
  args: attr.args,
274
+ unique: attr.name === 'unique',
237
275
  loc: { file: state.file, line: token.line, col: 1 },
238
276
  });
239
277
  }
@@ -322,6 +360,7 @@ function handleSectionItem(data, model, token, state) {
322
360
  raw: token.raw.trim().replace(/^- /, ''),
323
361
  loc,
324
362
  });
363
+ state.lastField = { name: token.raw.trim().replace(/^- /, '') };
325
364
  return;
326
365
  }
327
366
  // Metadata section
@@ -384,6 +423,16 @@ function handleNestedItem(token, state) {
384
423
  }
385
424
  return;
386
425
  }
426
+ // Nested items under relation in Relations section
427
+ if (state.currentSection === 'Relations' && state.lastField) {
428
+ const lastRelation = model.sections.relations[model.sections.relations.length - 1];
429
+ if (lastRelation && typeof lastRelation === 'object') {
430
+ if (key) {
431
+ lastRelation[key] = parseNestedValue(value || '');
432
+ }
433
+ }
434
+ return;
435
+ }
387
436
  // Nested items under a field
388
437
  if (state.lastField) {
389
438
  const field = state.lastField;
@@ -455,9 +504,25 @@ function handleNestedItem(token, state) {
455
504
  }
456
505
  }
457
506
  function handleBlockquote(token, state) {
507
+ // Handle attribute definition description
508
+ if (state.currentAttrDef) {
509
+ const text = token.data.text;
510
+ state.currentAttrDef.description = text;
511
+ return;
512
+ }
458
513
  if (!state.currentElement)
459
514
  return;
460
515
  const text = token.data.text;
516
+ // Field-level blockquote: apply to lastField if available (not for enums)
517
+ if (state.lastField && !isEnumNode(state.currentElement)) {
518
+ if (state.lastField.description) {
519
+ state.lastField.description += '\n' + text;
520
+ }
521
+ else {
522
+ state.lastField.description = text;
523
+ }
524
+ return;
525
+ }
461
526
  if (state.currentElement.description) {
462
527
  state.currentElement.description += '\n' + text;
463
528
  }
@@ -478,6 +543,8 @@ function handleText(token, state) {
478
543
  }
479
544
  }
480
545
  function finalizeElement(state) {
546
+ // Finalize pending attribute definition
547
+ finalizeAttrDef(state);
481
548
  if (!state.currentElement)
482
549
  return;
483
550
  if (isEnumNode(state.currentElement)) {
@@ -502,6 +569,70 @@ function finalizeElement(state) {
502
569
  state.currentKind = 'stored';
503
570
  state.lastField = null;
504
571
  }
572
+ function handleAttributeDefStart(token, state) {
573
+ finalizeElement(state);
574
+ const data = token.data || {};
575
+ const name = (data.name || '').replace(/^@/, '');
576
+ state.currentAttrDef = {
577
+ name,
578
+ description: data.description,
579
+ fields: new Map(),
580
+ };
581
+ state.currentElement = null;
582
+ }
583
+ function finalizeAttrDef(state) {
584
+ if (!state.currentAttrDef)
585
+ return;
586
+ const def = state.currentAttrDef;
587
+ const fields = def.fields;
588
+ const targetRaw = fields.get('target');
589
+ const target = [];
590
+ if (targetRaw) {
591
+ const cleaned = targetRaw.replace(/^\[|\]$/g, '').split(',').map(s => s.trim());
592
+ for (const t of cleaned) {
593
+ if (t === 'field' || t === 'model')
594
+ target.push(t);
595
+ }
596
+ }
597
+ const rangeRaw = fields.get('range');
598
+ let range;
599
+ if (rangeRaw) {
600
+ const nums = rangeRaw.replace(/^\[|\]$/g, '').split(',').map(s => Number(s.trim()));
601
+ if (nums.length === 2 && !isNaN(nums[0]) && !isNaN(nums[1])) {
602
+ range = [nums[0], nums[1]];
603
+ }
604
+ }
605
+ const requiredRaw = fields.get('required');
606
+ const required = requiredRaw === 'true' || requiredRaw === true;
607
+ const defaultRaw = fields.get('default');
608
+ let defaultValue;
609
+ if (defaultRaw !== undefined) {
610
+ if (defaultRaw === 'true')
611
+ defaultValue = true;
612
+ else if (defaultRaw === 'false')
613
+ defaultValue = false;
614
+ else if (typeof defaultRaw === 'string' && !isNaN(Number(defaultRaw)))
615
+ defaultValue = Number(defaultRaw);
616
+ else if (typeof defaultRaw === 'string')
617
+ defaultValue = defaultRaw;
618
+ else if (typeof defaultRaw === 'number' || typeof defaultRaw === 'boolean')
619
+ defaultValue = defaultRaw;
620
+ }
621
+ const entry = {
622
+ name: def.name,
623
+ target: target.length > 0 ? target : ['field'],
624
+ type: fields.get('type') || 'boolean',
625
+ required,
626
+ };
627
+ if (def.description)
628
+ entry.description = def.description;
629
+ if (range)
630
+ entry.range = range;
631
+ if (defaultValue !== undefined)
632
+ entry.defaultValue = defaultValue;
633
+ state.attributeRegistry.push(entry);
634
+ state.currentAttrDef = null;
635
+ }
505
636
  // --- Helpers ---
506
637
  function buildFieldNode(data, token, state) {
507
638
  const attrs = parseAttributes(data.attributes);
@@ -559,6 +690,10 @@ function buildFieldNode(data, token, state) {
559
690
  field.computed.platform = parts.platform;
560
691
  }
561
692
  }
693
+ // Inline comment as field description
694
+ if (!field.description && data.comment) {
695
+ field.description = data.comment;
696
+ }
562
697
  return field;
563
698
  }
564
699
  /**
@@ -602,6 +737,8 @@ function parseAttributes(rawAttrs) {
602
737
  attr.args = [a.args];
603
738
  if (a.cascade)
604
739
  attr.cascade = a.cascade;
740
+ if (STANDARD_ATTRIBUTES.has(a.name))
741
+ attr.isStandard = true;
605
742
  return attr;
606
743
  });
607
744
  }
@@ -798,9 +935,57 @@ function parseCustomAttributes(rawAttrs) {
798
935
  return rawAttrs.map(raw => {
799
936
  // raw is "[MaxLength(100)]" — strip brackets to get content
800
937
  const content = raw.replace(/^\[|\]$/g, '');
801
- return { content, raw };
938
+ const attr = { content, raw };
939
+ // Try to parse "Name(arg1, arg2)" or "Name" pattern
940
+ const match = content.match(/^([A-Za-z_][\w.]*)(?:\((.+)\))?$/);
941
+ if (match) {
942
+ const name = match[1];
943
+ const argsStr = match[2];
944
+ const args = [];
945
+ if (argsStr) {
946
+ for (const part of splitBalanced(argsStr)) {
947
+ const trimmed = part.trim();
948
+ args.push(parseArgValue(trimmed));
949
+ }
950
+ }
951
+ attr.parsed = { name, arguments: args };
952
+ }
953
+ return attr;
802
954
  });
803
955
  }
956
+ /** Split a string by commas, respecting balanced parentheses */
957
+ function splitBalanced(s) {
958
+ const parts = [];
959
+ let depth = 0;
960
+ let start = 0;
961
+ for (let i = 0; i < s.length; i++) {
962
+ if (s[i] === '(')
963
+ depth++;
964
+ else if (s[i] === ')')
965
+ depth--;
966
+ else if (s[i] === ',' && depth === 0) {
967
+ parts.push(s.substring(start, i));
968
+ start = i + 1;
969
+ }
970
+ }
971
+ parts.push(s.substring(start));
972
+ return parts;
973
+ }
974
+ /** Parse a single argument value: numbers, booleans, or strings */
975
+ function parseArgValue(s) {
976
+ if (s === 'true')
977
+ return true;
978
+ if (s === 'false')
979
+ return false;
980
+ const n = Number(s);
981
+ if (!isNaN(n) && s.length > 0)
982
+ return n;
983
+ // Strip surrounding quotes if present
984
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
985
+ return s.slice(1, -1);
986
+ }
987
+ return s;
988
+ }
804
989
  function isEnumNode(el) {
805
990
  return el.type === 'enum' && 'values' in el;
806
991
  }
package/dist/reader.d.ts CHANGED
@@ -4,7 +4,7 @@ export interface M3LFile {
4
4
  }
5
5
  /**
6
6
  * Read M3L files from a path (file or directory).
7
- * If path is a directory, scans for **\/*.m3l.md files.
7
+ * If path is a directory, scans for **\/*.m3l.md and **\/*.m3l files.
8
8
  * If an m3l.config.yaml exists in the directory, uses its sources patterns.
9
9
  */
10
10
  export declare function readM3LFiles(inputPath: string): Promise<M3LFile[]>;
package/dist/reader.js CHANGED
@@ -3,7 +3,7 @@ import { join, resolve as resolvePath } from 'path';
3
3
  import fg from 'fast-glob';
4
4
  /**
5
5
  * Read M3L files from a path (file or directory).
6
- * If path is a directory, scans for **\/*.m3l.md files.
6
+ * If path is a directory, scans for **\/*.m3l.md and **\/*.m3l files.
7
7
  * If an m3l.config.yaml exists in the directory, uses its sources patterns.
8
8
  */
9
9
  export async function readM3LFiles(inputPath) {
@@ -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.2";
5
+ export declare const PARSER_VERSION = "0.1.4";
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.2';
4
+ export const PARSER_VERSION = '0.1.4';
5
5
  /**
6
6
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
7
7
  * Handles: inheritance resolution, duplicate detection, reference validation.
@@ -14,6 +14,7 @@ export function resolve(files, project) {
14
14
  const allEnums = [];
15
15
  const allInterfaces = [];
16
16
  const allViews = [];
17
+ const allAttrRegistry = [];
17
18
  const sources = [];
18
19
  for (const file of files) {
19
20
  sources.push(file.source);
@@ -21,6 +22,8 @@ export function resolve(files, project) {
21
22
  allEnums.push(...file.enums);
22
23
  allInterfaces.push(...file.interfaces);
23
24
  allViews.push(...file.views);
25
+ if (file.attributeRegistry)
26
+ allAttrRegistry.push(...file.attributeRegistry);
24
27
  }
25
28
  // Build name maps
26
29
  const modelMap = new Map();
@@ -54,6 +57,29 @@ export function resolve(files, project) {
54
57
  for (const model of [...allModels, ...allViews]) {
55
58
  checkDuplicateFields(model, errors);
56
59
  }
60
+ // Tag isRegistered on attributes matching the registry
61
+ if (allAttrRegistry.length > 0) {
62
+ const registeredNames = new Set(allAttrRegistry.map(r => r.name));
63
+ const tagAttrs = (attrs) => {
64
+ for (const a of attrs) {
65
+ if (registeredNames.has(a.name)) {
66
+ a.isRegistered = true;
67
+ }
68
+ }
69
+ };
70
+ const tagModel = (m) => {
71
+ tagAttrs(m.attributes);
72
+ for (const f of m.fields) {
73
+ tagAttrs(f.attributes);
74
+ }
75
+ };
76
+ for (const m of allModels)
77
+ tagModel(m);
78
+ for (const v of allViews)
79
+ tagModel(v);
80
+ for (const i of allInterfaces)
81
+ tagModel(i);
82
+ }
57
83
  // Detect namespace from first file if available
58
84
  const projectInfo = project || {};
59
85
  if (!projectInfo.name) {
@@ -70,6 +96,7 @@ export function resolve(files, project) {
70
96
  enums: allEnums,
71
97
  interfaces: allInterfaces,
72
98
  views: allViews,
99
+ attributeRegistry: allAttrRegistry,
73
100
  errors,
74
101
  warnings,
75
102
  };
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ export interface SourceLocation {
4
4
  line: number;
5
5
  col: number;
6
6
  }
7
- export type TokenType = 'namespace' | 'model' | 'enum' | 'interface' | 'view' | 'section' | 'field' | 'nested_item' | 'blockquote' | 'horizontal_rule' | 'blank' | 'text';
7
+ export type TokenType = 'namespace' | 'model' | 'enum' | 'interface' | 'view' | 'attribute_def' | 'section' | 'field' | 'nested_item' | 'blockquote' | 'horizontal_rule' | 'blank' | 'text';
8
8
  export interface Token {
9
9
  type: TokenType;
10
10
  raw: string;
@@ -17,6 +17,10 @@ export interface FieldAttribute {
17
17
  name: string;
18
18
  args?: (string | number | boolean)[];
19
19
  cascade?: string;
20
+ /** Whether this is a standard M3L attribute (from the official catalog) */
21
+ isStandard?: boolean;
22
+ /** Whether this attribute is registered in an Attribute Registry (::attribute definition) */
23
+ isRegistered?: boolean;
20
24
  }
21
25
  /** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
22
26
  export interface CustomAttribute {
@@ -24,6 +28,11 @@ export interface CustomAttribute {
24
28
  content: string;
25
29
  /** Original text including brackets, e.g., "[MaxLength(100)]" */
26
30
  raw: string;
31
+ /** Parsed structure — name and arguments extracted from the content */
32
+ parsed?: {
33
+ name: string;
34
+ arguments: (string | number | boolean)[];
35
+ };
27
36
  }
28
37
  export interface EnumValue {
29
38
  name: string;
@@ -104,6 +113,7 @@ export interface EnumNode {
104
113
  type: 'enum';
105
114
  source: string;
106
115
  line: number;
116
+ inherits: string[];
107
117
  description?: string;
108
118
  values: EnumValue[];
109
119
  loc: SourceLocation;
@@ -127,6 +137,23 @@ export interface ParsedFile {
127
137
  enums: EnumNode[];
128
138
  interfaces: ModelNode[];
129
139
  views: ModelNode[];
140
+ attributeRegistry: AttributeRegistryEntry[];
141
+ }
142
+ export interface AttributeRegistryEntry {
143
+ /** Attribute name (without @) */
144
+ name: string;
145
+ /** Description */
146
+ description?: string;
147
+ /** Valid targets: 'field', 'model' */
148
+ target: ('field' | 'model')[];
149
+ /** Value type: 'boolean', 'integer', 'string', etc. */
150
+ type: string;
151
+ /** Valid range for numeric types */
152
+ range?: [number, number];
153
+ /** Whether the attribute is required */
154
+ required: boolean;
155
+ /** Default value */
156
+ defaultValue?: string | number | boolean;
130
157
  }
131
158
  export interface M3LAST {
132
159
  /** Parser package version (semver) */
@@ -139,6 +166,8 @@ export interface M3LAST {
139
166
  enums: EnumNode[];
140
167
  interfaces: ModelNode[];
141
168
  views: ModelNode[];
169
+ /** Attribute registry entries parsed from ::attribute definitions */
170
+ attributeRegistry: AttributeRegistryEntry[];
142
171
  errors: Diagnostic[];
143
172
  warnings: Diagnostic[];
144
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/m3l",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",