@tsonic/frontend 0.0.25 → 0.0.26

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.
@@ -1,20 +1,26 @@
1
1
  /**
2
2
  * Attribute Collection Pass
3
3
  *
4
- * This pass detects marker calls like `A.on(Class).type.add(Attr)` and transforms
5
- * them into attributes attached to the corresponding IR declarations.
4
+ * This pass detects compiler-only marker calls and transforms them into IR attributes
5
+ * attached to the corresponding declarations, removing the marker statements.
6
6
  *
7
7
  * Supported patterns:
8
- * - A.on(Class).type.add(Attr) - Type-level attribute on class
9
- * - A.on(Class).type.add(Attr, arg1, arg2) - With positional arguments
10
- * - A.on(fn).type.add(Attr) - Function-level attribute
8
+ * - A.on(Class).type.add(AttrCtor, ...args) - Type attribute
9
+ * - A.on(Class).ctor.add(AttrCtor, ...args) - Constructor attribute
10
+ * - A.on(Class).method(x => x.method).add(AttrCtor) - Method attribute
11
+ * - A.on(Class).prop(x => x.prop).add(AttrCtor) - Property attribute
12
+ * - add(A.attr(AttrCtor, ...args)) - Descriptor form
13
+ * - add(descriptor) where `const descriptor = A.attr(...)`
11
14
  *
12
- * Future patterns (not yet implemented):
13
- * - A.on(Class).prop(x => x.field).add(Attr) - Property attribute
14
- * - A.on(Class).method(x => x.fn).add(Attr) - Method attribute
15
- * - A.on(Class).type.add(Attr, { Name: "x" }) - Named arguments
15
+ * Backward compatibility:
16
+ * - A.on(fn).type.add(AttrCtor, ...args) attaches to a function declaration
17
+ *
18
+ * Notes:
19
+ * - This API is compiler-only. All recognized marker statements are removed.
20
+ * - Invalid marker calls are errors (no silent drops).
16
21
  */
17
22
  import { createDiagnostic, } from "../../types/diagnostic.js";
23
+ const ATTRIBUTES_IMPORT_SPECIFIER = "@tsonic/core/attributes.js";
18
24
  /**
19
25
  * Try to extract an attribute argument from an IR expression.
20
26
  * Returns undefined if the expression is not a valid attribute argument.
@@ -51,130 +57,476 @@ const tryExtractAttributeArg = (expr) => {
51
57
  }
52
58
  return undefined;
53
59
  };
60
+ const getAttributesApiLocalNames = (module) => {
61
+ const names = new Set();
62
+ for (const imp of module.imports) {
63
+ if (imp.source !== ATTRIBUTES_IMPORT_SPECIFIER)
64
+ continue;
65
+ for (const spec of imp.specifiers) {
66
+ if (spec.kind !== "named")
67
+ continue;
68
+ if (spec.name !== "attributes")
69
+ continue;
70
+ names.add(spec.localName);
71
+ }
72
+ }
73
+ return names;
74
+ };
75
+ const isAttributesApiIdentifier = (expr, apiNames) => expr.kind === "identifier" && apiNames.has(expr.name);
76
+ const getMemberName = (expr) => {
77
+ if (expr.kind !== "memberAccess")
78
+ return undefined;
79
+ if (expr.isComputed)
80
+ return undefined;
81
+ if (typeof expr.property !== "string")
82
+ return undefined;
83
+ return expr.property;
84
+ };
85
+ const looksLikeAttributesApiUsage = (expr, apiNames) => {
86
+ switch (expr.kind) {
87
+ case "call":
88
+ return (looksLikeAttributesApiUsage(expr.callee, apiNames) ||
89
+ expr.arguments.some((arg) => arg.kind !== "spread" && looksLikeAttributesApiUsage(arg, apiNames)));
90
+ case "memberAccess":
91
+ return (looksLikeAttributesApiUsage(expr.object, apiNames) ||
92
+ (typeof expr.property === "string" &&
93
+ (expr.property === "on" || expr.property === "attr") &&
94
+ isAttributesApiIdentifier(expr.object, apiNames)));
95
+ case "arrowFunction":
96
+ return ((expr.body.kind === "blockStatement"
97
+ ? expr.body.statements.some((s) => s.kind === "expressionStatement" &&
98
+ looksLikeAttributesApiUsage(s.expression, apiNames))
99
+ : looksLikeAttributesApiUsage(expr.body, apiNames)) || false);
100
+ case "functionExpression":
101
+ return expr.body.statements.some((s) => s.kind === "expressionStatement" &&
102
+ looksLikeAttributesApiUsage(s.expression, apiNames));
103
+ case "array":
104
+ return expr.elements.some((el) => el !== undefined && el.kind !== "spread" && looksLikeAttributesApiUsage(el, apiNames));
105
+ case "object":
106
+ return expr.properties.some((p) => {
107
+ if (p.kind === "spread")
108
+ return looksLikeAttributesApiUsage(p.expression, apiNames);
109
+ if (typeof p.key !== "string")
110
+ return looksLikeAttributesApiUsage(p.key, apiNames);
111
+ return looksLikeAttributesApiUsage(p.value, apiNames);
112
+ });
113
+ default:
114
+ return false;
115
+ }
116
+ };
117
+ const parseOnCall = (expr, module, apiNames) => {
118
+ if (expr.kind !== "call")
119
+ return { kind: "notMatch" };
120
+ const call = expr;
121
+ if (call.callee.kind !== "memberAccess")
122
+ return { kind: "notMatch" };
123
+ const member = call.callee;
124
+ if (member.isComputed || typeof member.property !== "string")
125
+ return { kind: "notMatch" };
126
+ if (member.property !== "on")
127
+ return { kind: "notMatch" };
128
+ if (!isAttributesApiIdentifier(member.object, apiNames))
129
+ return { kind: "notMatch" };
130
+ if (call.arguments.length !== 1) {
131
+ return {
132
+ kind: "error",
133
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: A.on(...) expects exactly 1 argument`, createLocation(module.filePath, call.sourceSpan)),
134
+ };
135
+ }
136
+ const arg0 = call.arguments[0];
137
+ if (!arg0 || arg0.kind === "spread") {
138
+ return {
139
+ kind: "error",
140
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: A.on(...) does not accept spread arguments`, createLocation(module.filePath, call.sourceSpan)),
141
+ };
142
+ }
143
+ if (arg0.kind !== "identifier") {
144
+ return {
145
+ kind: "error",
146
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: A.on(Target) target must be an identifier`, createLocation(module.filePath, call.sourceSpan)),
147
+ };
148
+ }
149
+ return {
150
+ kind: "ok",
151
+ value: { target: arg0, sourceSpan: call.sourceSpan },
152
+ };
153
+ };
154
+ const parseSelector = (selector, module) => {
155
+ if (selector.kind !== "arrowFunction") {
156
+ return {
157
+ kind: "error",
158
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector must be an arrow function (x => x.member)`, createLocation(module.filePath, selector.sourceSpan)),
159
+ };
160
+ }
161
+ const fn = selector;
162
+ if (fn.parameters.length !== 1) {
163
+ return {
164
+ kind: "error",
165
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector must have exactly 1 parameter`, createLocation(module.filePath, fn.sourceSpan)),
166
+ };
167
+ }
168
+ const p0 = fn.parameters[0];
169
+ if (!p0 || p0.pattern.kind !== "identifierPattern") {
170
+ return {
171
+ kind: "error",
172
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector parameter must be an identifier`, createLocation(module.filePath, fn.sourceSpan)),
173
+ };
174
+ }
175
+ const paramName = p0.pattern.name;
176
+ if (fn.body.kind !== "memberAccess") {
177
+ return {
178
+ kind: "error",
179
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector body must be a member access (x => x.member)`, createLocation(module.filePath, fn.sourceSpan)),
180
+ };
181
+ }
182
+ const body = fn.body;
183
+ if (body.isComputed || typeof body.property !== "string") {
184
+ return {
185
+ kind: "error",
186
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector must access a named member (no computed access)`, createLocation(module.filePath, fn.sourceSpan)),
187
+ };
188
+ }
189
+ if (body.object.kind !== "identifier" || body.object.name !== paramName) {
190
+ return {
191
+ kind: "error",
192
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector must be of the form (x) => x.member`, createLocation(module.filePath, fn.sourceSpan)),
193
+ };
194
+ }
195
+ return { kind: "ok", value: body.property };
196
+ };
197
+ const resolveClrTypeForAttributeCtor = (ctorIdent, module) => {
198
+ if (ctorIdent.resolvedClrType)
199
+ return ctorIdent.resolvedClrType;
200
+ // Prefer CLR imports: these are the authoritative mapping for runtime type names.
201
+ for (const imp of module.imports) {
202
+ if (!imp.isClr)
203
+ continue;
204
+ if (!imp.resolvedNamespace)
205
+ continue;
206
+ for (const spec of imp.specifiers) {
207
+ if (spec.kind !== "named")
208
+ continue;
209
+ if (spec.localName !== ctorIdent.name)
210
+ continue;
211
+ return `${imp.resolvedNamespace}.${spec.name}`;
212
+ }
213
+ }
214
+ return undefined;
215
+ };
216
+ const makeAttributeType = (ctorIdent, module) => {
217
+ const resolvedClrType = resolveClrTypeForAttributeCtor(ctorIdent, module);
218
+ if (resolvedClrType) {
219
+ return {
220
+ kind: "ok",
221
+ value: {
222
+ kind: "referenceType",
223
+ name: ctorIdent.name,
224
+ resolvedClrType,
225
+ },
226
+ };
227
+ }
228
+ // Allow locally-emitted attribute types (non-ambient class declarations).
229
+ const hasLocalClass = module.body.some((s) => s.kind === "classDeclaration" && s.name === ctorIdent.name);
230
+ if (hasLocalClass) {
231
+ return { kind: "ok", value: { kind: "referenceType", name: ctorIdent.name } };
232
+ }
233
+ return {
234
+ kind: "error",
235
+ diagnostic: createDiagnostic("TSN4004", "error", `Missing CLR binding for attribute constructor '${ctorIdent.name}'. Import the attribute type from a CLR bindings module (e.g., @tsonic/dotnet) or define it as a local class.`, createLocation(module.filePath, ctorIdent.sourceSpan)),
236
+ };
237
+ };
238
+ const parseAttrDescriptorCall = (expr, module, apiNames) => {
239
+ if (expr.kind !== "call")
240
+ return { kind: "notMatch" };
241
+ const call = expr;
242
+ if (call.callee.kind !== "memberAccess")
243
+ return { kind: "notMatch" };
244
+ const member = call.callee;
245
+ if (member.isComputed || typeof member.property !== "string")
246
+ return { kind: "notMatch" };
247
+ if (member.property !== "attr")
248
+ return { kind: "notMatch" };
249
+ if (!isAttributesApiIdentifier(member.object, apiNames))
250
+ return { kind: "notMatch" };
251
+ if (call.arguments.length < 1) {
252
+ return {
253
+ kind: "error",
254
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: A.attr(AttrCtor, ...args) requires at least the attribute constructor`, createLocation(module.filePath, call.sourceSpan)),
255
+ };
256
+ }
257
+ const rawArgs = [];
258
+ for (const arg of call.arguments) {
259
+ if (!arg || arg.kind === "spread") {
260
+ return {
261
+ kind: "error",
262
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: spreads are not allowed in attributes`, createLocation(module.filePath, call.sourceSpan)),
263
+ };
264
+ }
265
+ rawArgs.push(arg);
266
+ }
267
+ const ctorExpr = rawArgs[0];
268
+ if (!ctorExpr || ctorExpr.kind !== "identifier") {
269
+ return {
270
+ kind: "error",
271
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: A.attr(...) attribute constructor must be an identifier`, createLocation(module.filePath, call.sourceSpan)),
272
+ };
273
+ }
274
+ const attributeCtor = ctorExpr;
275
+ const attributeTypeResult = makeAttributeType(attributeCtor, module);
276
+ if (attributeTypeResult.kind !== "ok") {
277
+ return attributeTypeResult;
278
+ }
279
+ const positionalArgs = [];
280
+ const namedArgs = new Map();
281
+ let sawNamed = false;
282
+ for (const arg of rawArgs.slice(1)) {
283
+ if (arg.kind === "object") {
284
+ sawNamed = true;
285
+ const obj = arg;
286
+ for (const prop of obj.properties) {
287
+ if (prop.kind === "spread") {
288
+ return {
289
+ kind: "error",
290
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: spreads are not allowed in named arguments`, createLocation(module.filePath, obj.sourceSpan)),
291
+ };
292
+ }
293
+ if (prop.kind !== "property" || typeof prop.key !== "string") {
294
+ return {
295
+ kind: "error",
296
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: named arguments must be simple { Name: value } properties`, createLocation(module.filePath, obj.sourceSpan)),
297
+ };
298
+ }
299
+ const v = tryExtractAttributeArg(prop.value);
300
+ if (!v) {
301
+ return {
302
+ kind: "error",
303
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: named argument '${prop.key}' must be a compile-time constant`, createLocation(module.filePath, prop.value.sourceSpan)),
304
+ };
305
+ }
306
+ namedArgs.set(prop.key, v);
307
+ }
308
+ continue;
309
+ }
310
+ if (sawNamed) {
311
+ return {
312
+ kind: "error",
313
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: positional arguments cannot appear after named arguments`, createLocation(module.filePath, arg.sourceSpan)),
314
+ };
315
+ }
316
+ const v = tryExtractAttributeArg(arg);
317
+ if (!v) {
318
+ return {
319
+ kind: "error",
320
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: attribute arguments must be compile-time constants (string/number/boolean/typeof/enum)`, createLocation(module.filePath, arg.sourceSpan)),
321
+ };
322
+ }
323
+ positionalArgs.push(v);
324
+ }
325
+ return {
326
+ kind: "ok",
327
+ value: {
328
+ attributeType: attributeTypeResult.value,
329
+ positionalArgs,
330
+ namedArgs,
331
+ sourceSpan: call.sourceSpan,
332
+ },
333
+ };
334
+ };
54
335
  /**
55
336
  * Try to detect if a call expression is an attribute marker pattern.
56
337
  *
57
- * Pattern: A.on(Target).type.add(Attr, ...args)
58
- * Structure:
59
- * - CallExpression (outer) - the .add(Attr, ...) call
60
- * - callee: MemberAccess with property "add"
61
- * - object: MemberAccess with property "type"
62
- * - object: CallExpression - the A.on(Target) call
63
- * - callee: MemberAccess with property "on"
64
- * - object: Identifier "A" or "attributes"
65
- * - arguments: [Target identifier]
66
- * - arguments: [Attr type, ...positional args]
338
+ * Patterns:
339
+ * - A.on(Target).type.add(...)
340
+ * - A.on(Target).ctor.add(...)
341
+ * - A.on(Target).method(selector).add(...)
342
+ * - A.on(Target).prop(selector).add(...)
67
343
  */
68
- const tryDetectAttributeMarker = (call, module) => {
69
- // Check outer call: must be a member access call like .add(...)
344
+ const tryDetectAttributeMarker = (call, module, apiNames, descriptors) => {
70
345
  if (call.callee.kind !== "memberAccess")
71
- return undefined;
346
+ return { kind: "notMatch" };
72
347
  const outerMember = call.callee;
73
- // Check that property is "add" (string, not computed)
74
348
  if (outerMember.isComputed || typeof outerMember.property !== "string") {
75
- return undefined;
349
+ return { kind: "notMatch" };
76
350
  }
77
351
  if (outerMember.property !== "add")
78
- return undefined;
79
- // Check that object is .type member access
80
- if (outerMember.object.kind !== "memberAccess")
81
- return undefined;
82
- const typeMember = outerMember.object;
83
- if (typeMember.isComputed || typeof typeMember.property !== "string") {
84
- return undefined;
352
+ return { kind: "notMatch" };
353
+ // Determine the target selector: `.type`, `.ctor`, `.method(selector)`, `.prop(selector)`
354
+ let selector;
355
+ let selectedMemberName;
356
+ let onCallExpr;
357
+ if (outerMember.object.kind === "memberAccess") {
358
+ const targetMember = outerMember.object;
359
+ const prop = getMemberName(targetMember);
360
+ if (prop !== "type" && prop !== "ctor")
361
+ return { kind: "notMatch" };
362
+ selector = prop;
363
+ onCallExpr = targetMember.object;
85
364
  }
86
- if (typeMember.property !== "type")
87
- return undefined;
88
- // Check that object of .type is A.on(Target) call
89
- if (typeMember.object.kind !== "call")
90
- return undefined;
91
- const onCall = typeMember.object;
92
- // Check that onCall.callee is A.on or attributes.on
93
- if (onCall.callee.kind !== "memberAccess")
94
- return undefined;
95
- const onMember = onCall.callee;
96
- if (onMember.isComputed || typeof onMember.property !== "string") {
97
- return undefined;
365
+ else if (outerMember.object.kind === "call") {
366
+ const selectorCall = outerMember.object;
367
+ if (selectorCall.callee.kind !== "memberAccess")
368
+ return { kind: "notMatch" };
369
+ const selectorMember = selectorCall.callee;
370
+ const prop = getMemberName(selectorMember);
371
+ if (prop !== "method" && prop !== "prop")
372
+ return { kind: "notMatch" };
373
+ selector = prop;
374
+ if (selectorCall.arguments.length !== 1) {
375
+ return {
376
+ kind: "error",
377
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: .${prop}(selector) expects exactly 1 argument`, createLocation(module.filePath, selectorCall.sourceSpan)),
378
+ };
379
+ }
380
+ const arg0 = selectorCall.arguments[0];
381
+ if (!arg0 || arg0.kind === "spread") {
382
+ return {
383
+ kind: "error",
384
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: selector cannot be a spread argument`, createLocation(module.filePath, selectorCall.sourceSpan)),
385
+ };
386
+ }
387
+ const sel = parseSelector(arg0, module);
388
+ if (sel.kind !== "ok")
389
+ return sel;
390
+ selectedMemberName = sel.value;
391
+ onCallExpr = selectorMember.object;
98
392
  }
99
- if (onMember.property !== "on")
100
- return undefined;
101
- // Check that the object of .on is "A" or "attributes"
102
- if (onMember.object.kind !== "identifier")
103
- return undefined;
104
- const apiObject = onMember.object;
105
- if (apiObject.name !== "A" && apiObject.name !== "attributes")
106
- return undefined;
107
- // Extract target from A.on(Target)
108
- if (onCall.arguments.length !== 1)
109
- return undefined;
110
- const targetArg = onCall.arguments[0];
111
- if (!targetArg || targetArg.kind === "spread")
112
- return undefined;
113
- // Target must be an identifier
114
- if (targetArg.kind !== "identifier")
115
- return undefined;
116
- const targetName = targetArg.name;
117
- // Extract attribute type from .add(Attr, ...) arguments
118
- if (call.arguments.length < 1)
119
- return undefined;
120
- const attrTypeArg = call.arguments[0];
121
- if (!attrTypeArg || attrTypeArg.kind === "spread")
122
- return undefined;
123
- // Attribute type should be an identifier referencing the attribute class
124
- if (attrTypeArg.kind !== "identifier") {
125
- // Not a simple identifier - could be a member access like System.SerializableAttribute
126
- // For now, we don't support this
127
- return undefined;
393
+ else {
394
+ return { kind: "notMatch" };
128
395
  }
129
- const attrIdent = attrTypeArg;
130
- const resolveClrFromImports = () => {
131
- // If the attribute type is imported from a CLR bindings module, reconstruct the CLR FQN
132
- // from the module import table. Identifier expressions do not always carry resolvedClrType.
133
- for (const imp of module.imports) {
134
- if (!imp.isClr)
135
- continue;
136
- if (!imp.resolvedNamespace)
137
- continue;
138
- for (const spec of imp.specifiers) {
139
- if (spec.kind !== "named")
140
- continue;
141
- if (spec.localName !== attrIdent.name)
142
- continue;
143
- return `${imp.resolvedNamespace}.${spec.name}`;
396
+ if (!onCallExpr)
397
+ return { kind: "notMatch" };
398
+ const on = parseOnCall(onCallExpr, module, apiNames);
399
+ if (on.kind !== "ok")
400
+ return on;
401
+ const targetName = on.value.target.name;
402
+ // Parse `.add(...)` arguments
403
+ if (call.arguments.length < 1) {
404
+ return {
405
+ kind: "error",
406
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: .add(...) requires at least one argument`, createLocation(module.filePath, call.sourceSpan)),
407
+ };
408
+ }
409
+ const addArgs = [];
410
+ for (const arg of call.arguments) {
411
+ if (!arg || arg.kind === "spread") {
412
+ return {
413
+ kind: "error",
414
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: spreads are not allowed in attributes`, createLocation(module.filePath, call.sourceSpan)),
415
+ };
416
+ }
417
+ addArgs.push(arg);
418
+ }
419
+ const first = addArgs[0];
420
+ if (!first) {
421
+ return {
422
+ kind: "error",
423
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: .add(...) first argument is missing`, createLocation(module.filePath, call.sourceSpan)),
424
+ };
425
+ }
426
+ // .add(A.attr(...)) inline descriptor
427
+ if (addArgs.length === 1) {
428
+ const descCall = parseAttrDescriptorCall(first, module, apiNames);
429
+ if (descCall.kind === "ok") {
430
+ return {
431
+ kind: "ok",
432
+ value: {
433
+ targetName,
434
+ targetSelector: selector,
435
+ selectedMemberName,
436
+ attributeType: descCall.value.attributeType,
437
+ positionalArgs: descCall.value.positionalArgs,
438
+ namedArgs: descCall.value.namedArgs,
439
+ sourceSpan: call.sourceSpan,
440
+ },
441
+ };
442
+ }
443
+ // .add(descriptorVar)
444
+ if (first.kind === "identifier") {
445
+ const desc = descriptors.get(first.name);
446
+ if (desc) {
447
+ return {
448
+ kind: "ok",
449
+ value: {
450
+ targetName,
451
+ targetSelector: selector,
452
+ selectedMemberName,
453
+ attributeType: desc.attributeType,
454
+ positionalArgs: desc.positionalArgs,
455
+ namedArgs: desc.namedArgs,
456
+ sourceSpan: call.sourceSpan,
457
+ },
458
+ };
144
459
  }
145
460
  }
146
- return undefined;
147
- };
148
- // Prefer resolvedClrType if present (bindings/globals). Otherwise resolve via CLR imports.
149
- // Final fallback is the identifier name (ambient declarations where name is already a CLR type).
150
- const clrType = attrIdent.resolvedClrType ?? resolveClrFromImports() ?? attrIdent.name;
151
- const attributeType = {
152
- kind: "referenceType",
153
- name: attrIdent.name,
154
- resolvedClrType: clrType,
155
- };
156
- // Extract positional arguments (skip the first which is the attribute type)
157
- const positionalArgs = call.arguments
158
- .slice(1)
159
- .filter((arg) => {
160
- if (!arg || arg.kind === "spread")
161
- return false;
162
- // Skip object literals (named arguments) for now
163
- if (arg.kind === "object")
164
- return false;
165
- return true;
166
- })
167
- .map((arg) => tryExtractAttributeArg(arg))
168
- .filter((arg) => arg !== undefined);
169
- // Determine target kind - we'll resolve this against the module later
170
- // For now, assume it could be either class or function
461
+ }
462
+ // .add(AttrCtor, ...args)
463
+ if (first.kind !== "identifier") {
464
+ return {
465
+ kind: "error",
466
+ diagnostic: createDiagnostic("TSN4005", "error", `Invalid attribute marker: .add(AttrCtor, ...args) requires attribute constructor to be an identifier`, createLocation(module.filePath, call.sourceSpan)),
467
+ };
468
+ }
469
+ const attributeTypeResult = makeAttributeType(first, module);
470
+ if (attributeTypeResult.kind !== "ok") {
471
+ return attributeTypeResult;
472
+ }
473
+ const positionalArgs = [];
474
+ const namedArgs = new Map();
475
+ let sawNamed = false;
476
+ for (const arg of addArgs.slice(1)) {
477
+ if (arg.kind === "object") {
478
+ sawNamed = true;
479
+ const obj = arg;
480
+ for (const prop of obj.properties) {
481
+ if (prop.kind === "spread") {
482
+ return {
483
+ kind: "error",
484
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: spreads are not allowed in named arguments`, createLocation(module.filePath, obj.sourceSpan)),
485
+ };
486
+ }
487
+ if (prop.kind !== "property" || typeof prop.key !== "string") {
488
+ return {
489
+ kind: "error",
490
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: named arguments must be simple { Name: value } properties`, createLocation(module.filePath, obj.sourceSpan)),
491
+ };
492
+ }
493
+ const v = tryExtractAttributeArg(prop.value);
494
+ if (!v) {
495
+ return {
496
+ kind: "error",
497
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: named argument '${prop.key}' must be a compile-time constant`, createLocation(module.filePath, prop.value.sourceSpan)),
498
+ };
499
+ }
500
+ namedArgs.set(prop.key, v);
501
+ }
502
+ continue;
503
+ }
504
+ if (sawNamed) {
505
+ return {
506
+ kind: "error",
507
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: positional arguments cannot appear after named arguments`, createLocation(module.filePath, arg.sourceSpan)),
508
+ };
509
+ }
510
+ const v = tryExtractAttributeArg(arg);
511
+ if (!v) {
512
+ return {
513
+ kind: "error",
514
+ diagnostic: createDiagnostic("TSN4006", "error", `Invalid attribute argument: attribute arguments must be compile-time constants (string/number/boolean/typeof/enum)`, createLocation(module.filePath, arg.sourceSpan)),
515
+ };
516
+ }
517
+ positionalArgs.push(v);
518
+ }
171
519
  return {
172
- targetName,
173
- targetKind: "class", // Will be refined during attachment
174
- attributeType,
175
- positionalArgs,
176
- namedArgs: new Map(),
177
- sourceSpan: call.sourceSpan,
520
+ kind: "ok",
521
+ value: {
522
+ targetName,
523
+ targetSelector: selector,
524
+ selectedMemberName,
525
+ attributeType: attributeTypeResult.value,
526
+ positionalArgs,
527
+ namedArgs,
528
+ sourceSpan: call.sourceSpan,
529
+ },
178
530
  };
179
531
  };
180
532
  /**
@@ -185,24 +537,72 @@ const createLocation = (filePath, sourceSpan) => sourceSpan ?? { file: filePath,
185
537
  * Process a single module: detect attribute markers and attach to declarations
186
538
  */
187
539
  const processModule = (module, diagnostics) => {
540
+ const apiNames = getAttributesApiLocalNames(module);
541
+ if (apiNames.size === 0) {
542
+ return module;
543
+ }
544
+ // Collect detected attribute descriptors declared as variables:
545
+ // const d = A.attr(AttrCtor, ...args)
546
+ const descriptors = new Map();
547
+ const removedStatementIndices = new Set();
548
+ module.body.forEach((stmt, i) => {
549
+ if (stmt.kind !== "variableDeclaration")
550
+ return;
551
+ const decl = stmt;
552
+ // Only handle simple, single declarator `const name = A.attr(...)`.
553
+ if (decl.declarationKind !== "const")
554
+ return;
555
+ if (decl.declarations.length !== 1)
556
+ return;
557
+ const d0 = decl.declarations[0];
558
+ if (!d0)
559
+ return;
560
+ if (d0.name.kind !== "identifierPattern")
561
+ return;
562
+ if (!d0.initializer)
563
+ return;
564
+ const parsed = parseAttrDescriptorCall(d0.initializer, module, apiNames);
565
+ if (parsed.kind === "notMatch")
566
+ return;
567
+ if (parsed.kind === "error") {
568
+ diagnostics.push(parsed.diagnostic);
569
+ removedStatementIndices.add(i);
570
+ return;
571
+ }
572
+ descriptors.set(d0.name.name, parsed.value);
573
+ removedStatementIndices.add(i);
574
+ });
188
575
  // Collect detected attribute markers
189
576
  const markers = [];
190
- const markerStatementIndices = new Set();
191
577
  // Walk statements looking for attribute markers
192
578
  module.body.forEach((stmt, i) => {
579
+ if (removedStatementIndices.has(i))
580
+ return;
193
581
  if (stmt.kind !== "expressionStatement")
194
582
  return;
195
583
  const expr = stmt.expression;
196
584
  if (expr.kind !== "call")
197
585
  return;
198
- const marker = tryDetectAttributeMarker(expr, module);
199
- if (marker) {
200
- markers.push(marker);
201
- markerStatementIndices.add(i);
586
+ const marker = tryDetectAttributeMarker(expr, module, apiNames, descriptors);
587
+ if (marker.kind === "ok") {
588
+ markers.push(marker.value);
589
+ removedStatementIndices.add(i);
590
+ return;
591
+ }
592
+ if (marker.kind === "error") {
593
+ diagnostics.push(marker.diagnostic);
594
+ removedStatementIndices.add(i);
595
+ return;
596
+ }
597
+ // If it looks like an attribute API call but doesn't match a supported marker,
598
+ // fail deterministically instead of leaving runtime-dead code in the output.
599
+ if (looksLikeAttributesApiUsage(expr, apiNames)) {
600
+ diagnostics.push(createDiagnostic("TSN4005", "error", `Invalid attribute marker call. Expected one of: A.on(X).type.add(...), A.on(X).ctor.add(...), A.on(X).method(x => x.m).add(...), A.on(X).prop(x => x.p).add(...)`, createLocation(module.filePath, expr.sourceSpan)));
601
+ removedStatementIndices.add(i);
202
602
  }
203
603
  });
204
- // If no markers found, return module unchanged
205
- if (markers.length === 0) {
604
+ // If nothing to do, return module unchanged
605
+ if (markers.length === 0 && removedStatementIndices.size === 0) {
206
606
  return module;
207
607
  }
208
608
  // Build map of declaration names to their indices
@@ -218,37 +618,89 @@ const processModule = (module, diagnostics) => {
218
618
  });
219
619
  // Build map of attributes per declaration
220
620
  const classAttributes = new Map();
621
+ const classCtorAttributes = new Map();
622
+ const classMethodAttributes = new Map();
623
+ const classPropAttributes = new Map();
221
624
  const functionAttributes = new Map();
222
625
  for (const marker of markers) {
223
626
  const classIndex = classDeclarations.get(marker.targetName);
224
627
  const funcIndex = functionDeclarations.get(marker.targetName);
225
- if (classIndex !== undefined) {
226
- // Attach to class
227
- const attrs = classAttributes.get(classIndex) ?? [];
228
- attrs.push({
229
- kind: "attribute",
230
- attributeType: marker.attributeType,
231
- positionalArgs: marker.positionalArgs,
232
- namedArgs: marker.namedArgs,
233
- });
234
- classAttributes.set(classIndex, attrs);
235
- }
236
- else if (funcIndex !== undefined) {
237
- // Attach to function
238
- const attrs = functionAttributes.get(funcIndex) ?? [];
239
- attrs.push({
240
- kind: "attribute",
241
- attributeType: marker.attributeType,
242
- positionalArgs: marker.positionalArgs,
243
- namedArgs: marker.namedArgs,
244
- });
245
- functionAttributes.set(funcIndex, attrs);
628
+ const attr = {
629
+ kind: "attribute",
630
+ attributeType: marker.attributeType,
631
+ positionalArgs: marker.positionalArgs,
632
+ namedArgs: marker.namedArgs,
633
+ };
634
+ if (marker.targetSelector === "type") {
635
+ if (classIndex !== undefined && funcIndex !== undefined) {
636
+ diagnostics.push(createDiagnostic("TSN4005", "error", `Attribute target '${marker.targetName}' is ambiguous (matches both class and function)`, createLocation(module.filePath, marker.sourceSpan)));
637
+ continue;
638
+ }
639
+ if (classIndex !== undefined) {
640
+ const attrs = classAttributes.get(classIndex) ?? [];
641
+ attrs.push(attr);
642
+ classAttributes.set(classIndex, attrs);
643
+ continue;
644
+ }
645
+ if (funcIndex !== undefined) {
646
+ const attrs = functionAttributes.get(funcIndex) ?? [];
647
+ attrs.push(attr);
648
+ functionAttributes.set(funcIndex, attrs);
649
+ continue;
650
+ }
651
+ diagnostics.push(createDiagnostic("TSN4007", "error", `Attribute target '${marker.targetName}' not found in module`, createLocation(module.filePath, marker.sourceSpan)));
652
+ continue;
246
653
  }
247
- else {
248
- // Target not found - emit warning diagnostic
249
- // This is not a hard failure since the marker may reference a declaration
250
- // in another module (cross-module attribute attachment not yet supported)
251
- diagnostics.push(createDiagnostic("TSN5002", "warning", `Attribute target '${marker.targetName}' not found in module`, createLocation(module.filePath, marker.sourceSpan)));
654
+ if (classIndex === undefined) {
655
+ diagnostics.push(createDiagnostic("TSN4007", "error", `Attribute target '${marker.targetName}' not found in module`, createLocation(module.filePath, marker.sourceSpan)));
656
+ continue;
657
+ }
658
+ const classStmt = module.body[classIndex];
659
+ if (marker.targetSelector === "ctor") {
660
+ const hasCtor = classStmt.members.some((m) => m.kind === "constructorDeclaration");
661
+ if (classStmt.isStruct && !hasCtor) {
662
+ diagnostics.push(createDiagnostic("TSN4005", "error", `Cannot apply constructor attributes to struct '${classStmt.name}' without an explicit constructor`, createLocation(module.filePath, marker.sourceSpan)));
663
+ continue;
664
+ }
665
+ const attrs = classCtorAttributes.get(classIndex) ?? [];
666
+ attrs.push(attr);
667
+ classCtorAttributes.set(classIndex, attrs);
668
+ continue;
669
+ }
670
+ if (marker.targetSelector === "method") {
671
+ const memberName = marker.selectedMemberName;
672
+ if (!memberName) {
673
+ diagnostics.push(createDiagnostic("TSN4005", "error", `Invalid attribute marker: method target missing member name`, createLocation(module.filePath, marker.sourceSpan)));
674
+ continue;
675
+ }
676
+ const hasMember = classStmt.members.some((m) => m.kind === "methodDeclaration" && m.name === memberName);
677
+ if (!hasMember) {
678
+ diagnostics.push(createDiagnostic("TSN4007", "error", `Method '${classStmt.name}.${memberName}' not found for attribute target`, createLocation(module.filePath, marker.sourceSpan)));
679
+ continue;
680
+ }
681
+ const perClass = classMethodAttributes.get(classIndex) ?? new Map();
682
+ const attrs = perClass.get(memberName) ?? [];
683
+ attrs.push(attr);
684
+ perClass.set(memberName, attrs);
685
+ classMethodAttributes.set(classIndex, perClass);
686
+ continue;
687
+ }
688
+ if (marker.targetSelector === "prop") {
689
+ const memberName = marker.selectedMemberName;
690
+ if (!memberName) {
691
+ diagnostics.push(createDiagnostic("TSN4005", "error", `Invalid attribute marker: property target missing member name`, createLocation(module.filePath, marker.sourceSpan)));
692
+ continue;
693
+ }
694
+ const hasMember = classStmt.members.some((m) => m.kind === "propertyDeclaration" && m.name === memberName);
695
+ if (!hasMember) {
696
+ diagnostics.push(createDiagnostic("TSN4007", "error", `Property '${classStmt.name}.${memberName}' not found for attribute target`, createLocation(module.filePath, marker.sourceSpan)));
697
+ continue;
698
+ }
699
+ const perClass = classPropAttributes.get(classIndex) ?? new Map();
700
+ const attrs = perClass.get(memberName) ?? [];
701
+ attrs.push(attr);
702
+ perClass.set(memberName, attrs);
703
+ classPropAttributes.set(classIndex, perClass);
252
704
  }
253
705
  }
254
706
  // Rebuild module body:
@@ -257,20 +709,62 @@ const processModule = (module, diagnostics) => {
257
709
  const newBody = [];
258
710
  module.body.forEach((stmt, i) => {
259
711
  // Skip marker statements
260
- if (markerStatementIndices.has(i))
712
+ if (removedStatementIndices.has(i))
261
713
  return;
262
- if (stmt.kind === "classDeclaration" && classAttributes.has(i)) {
714
+ if (stmt.kind === "classDeclaration") {
263
715
  // Update class with attributes
264
716
  const classStmt = stmt;
265
717
  const existingAttrs = classStmt.attributes ?? [];
266
- const newAttrs = classAttributes.get(i) ?? [];
267
- newBody.push({
718
+ const typeAttrs = classAttributes.get(i) ?? [];
719
+ const ctorAttrs = classCtorAttributes.get(i) ?? [];
720
+ const methodAttrs = classMethodAttributes.get(i);
721
+ const propAttrs = classPropAttributes.get(i);
722
+ const updatedMembers = methodAttrs || propAttrs
723
+ ? classStmt.members.map((m) => {
724
+ if (m.kind === "methodDeclaration" && methodAttrs) {
725
+ const extras = methodAttrs.get(m.name);
726
+ if (extras && extras.length > 0) {
727
+ return {
728
+ ...m,
729
+ attributes: [...(m.attributes ?? []), ...extras],
730
+ };
731
+ }
732
+ }
733
+ if (m.kind === "propertyDeclaration" && propAttrs) {
734
+ const extras = propAttrs.get(m.name);
735
+ if (extras && extras.length > 0) {
736
+ return {
737
+ ...m,
738
+ attributes: [...(m.attributes ?? []), ...extras],
739
+ };
740
+ }
741
+ }
742
+ return m;
743
+ })
744
+ : classStmt.members;
745
+ const updated = {
268
746
  ...classStmt,
269
- attributes: [...existingAttrs, ...newAttrs],
270
- });
747
+ members: updatedMembers,
748
+ attributes: typeAttrs.length > 0
749
+ ? [...existingAttrs, ...typeAttrs]
750
+ : classStmt.attributes,
751
+ ctorAttributes: ctorAttrs.length > 0
752
+ ? [...(classStmt.ctorAttributes ?? []), ...ctorAttrs]
753
+ : classStmt.ctorAttributes,
754
+ };
755
+ // Avoid allocating new nodes when there are no changes.
756
+ if (typeAttrs.length === 0 &&
757
+ ctorAttrs.length === 0 &&
758
+ !methodAttrs &&
759
+ !propAttrs) {
760
+ newBody.push(classStmt);
761
+ }
762
+ else {
763
+ newBody.push(updated);
764
+ }
765
+ return;
271
766
  }
272
- else if (stmt.kind === "functionDeclaration" &&
273
- functionAttributes.has(i)) {
767
+ if (stmt.kind === "functionDeclaration" && functionAttributes.has(i)) {
274
768
  // Update function with attributes
275
769
  const funcStmt = stmt;
276
770
  const existingAttrs = funcStmt.attributes ?? [];
@@ -279,11 +773,10 @@ const processModule = (module, diagnostics) => {
279
773
  ...funcStmt,
280
774
  attributes: [...existingAttrs, ...newAttrs],
281
775
  });
776
+ return;
282
777
  }
283
- else {
284
- // Keep statement unchanged
285
- newBody.push(stmt);
286
- }
778
+ // Keep statement unchanged
779
+ newBody.push(stmt);
287
780
  });
288
781
  return {
289
782
  ...module,