@ontrails/warden 1.0.0-beta.23 → 1.0.0-beta.29

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +113 -0
  2. package/README.md +35 -1
  3. package/package.json +10 -9
  4. package/src/ast.ts +109 -0
  5. package/src/cli.ts +44 -4
  6. package/src/command.ts +24 -34
  7. package/src/drift.ts +24 -8
  8. package/src/index.ts +9 -0
  9. package/src/project-rules.ts +290 -0
  10. package/src/resolve.ts +8 -7
  11. package/src/rules/ast.ts +837 -6
  12. package/src/rules/cli-command-route-coherence.ts +177 -0
  13. package/src/rules/composes-declarations.ts +210 -77
  14. package/src/rules/context-no-surface-types.ts +15 -12
  15. package/src/rules/contour-exists.ts +7 -7
  16. package/src/rules/dead-internal-trail.ts +11 -4
  17. package/src/rules/dead-public-trail.ts +258 -0
  18. package/src/rules/duplicate-public-contract.ts +91 -0
  19. package/src/rules/error-mapping-completeness.ts +5 -3
  20. package/src/rules/example-valid.ts +6 -12
  21. package/src/rules/fires-declarations.ts +41 -64
  22. package/src/rules/implementation-returns-result.ts +208 -76
  23. package/src/rules/incomplete-crud.ts +20 -19
  24. package/src/rules/index.ts +15 -0
  25. package/src/rules/layer-field-name-drift.ts +12 -6
  26. package/src/rules/library-projection-coherence.ts +100 -0
  27. package/src/rules/metadata.ts +102 -1
  28. package/src/rules/no-destructured-compose.ts +16 -14
  29. package/src/rules/no-native-error-result.ts +15 -8
  30. package/src/rules/no-redundant-result-error-wrap.ts +82 -29
  31. package/src/rules/no-sync-result-assumption.ts +70 -68
  32. package/src/rules/no-top-level-surface.ts +46 -64
  33. package/src/rules/on-references-exist.ts +1 -1
  34. package/src/rules/owner-projection-parity.ts +10 -13
  35. package/src/rules/public-export-example-coverage.ts +29 -20
  36. package/src/rules/public-internal-deep-imports.ts +2 -2
  37. package/src/rules/read-intent-fires.ts +8 -8
  38. package/src/rules/registry-names.ts +10 -0
  39. package/src/rules/resource-declarations.ts +20 -31
  40. package/src/rules/resource-exists.ts +2 -2
  41. package/src/rules/resource-id-grammar.ts +2 -2
  42. package/src/rules/resource-mock-coverage.ts +2 -2
  43. package/src/rules/static-resource-accessor-preference.ts +26 -34
  44. package/src/rules/surface-facet-coherence.ts +21 -29
  45. package/src/rules/trail-fork-coaching.ts +616 -0
  46. package/src/rules/trail-versioning-source.ts +56 -78
  47. package/src/rules/types.ts +2 -0
  48. package/src/rules/unreachable-detour-shadowing.ts +16 -21
  49. package/src/rules/valid-describe-refs.ts +14 -12
  50. package/src/rules/warden-export-symmetry.ts +42 -35
  51. package/src/rules/warden-rules-use-ast.ts +168 -50
  52. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  53. package/src/trails/dead-public-trail.trail.ts +31 -0
  54. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  55. package/src/trails/error-mapping-completeness.trail.ts +1 -0
  56. package/src/trails/index.ts +5 -0
  57. package/src/trails/library-projection-coherence.trail.ts +43 -0
  58. package/src/trails/schema.ts +4 -0
  59. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  60. package/src/trails/warden-rules-use-ast.trail.ts +19 -0
  61. package/src/trails/wrap-rule.ts +1 -0
@@ -0,0 +1,177 @@
1
+ import { deriveTrailCliCommandProjection } from '@ontrails/core';
2
+ import type { CliCommandRoute, Topo } from '@ontrails/core';
3
+ import type { TopoGraph } from '@ontrails/topographer';
4
+
5
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
6
+
7
+ const RULE_NAME = 'cli-command-route-coherence';
8
+ const TOPO_FILE = '<topo>';
9
+
10
+ interface RouteClaim {
11
+ readonly kind: CliCommandRoute['kind'];
12
+ readonly path: readonly string[];
13
+ readonly source: CliCommandRoute['source'];
14
+ readonly trailId: string;
15
+ }
16
+
17
+ const routeKey = (path: readonly string[]): string => path.join('\0');
18
+
19
+ const renderPath = (path: readonly string[]): string => path.join(' ');
20
+
21
+ const claimLabel = (claim: RouteClaim): string =>
22
+ `${claim.kind} route for trail "${claim.trailId}" (${claim.source})`;
23
+
24
+ const buildProjectionDiagnostic = (
25
+ trailId: string,
26
+ error: unknown
27
+ ): WardenDiagnostic => ({
28
+ filePath: TOPO_FILE,
29
+ line: 1,
30
+ message: `CLI command route projection for trail "${trailId}" is invalid: ${
31
+ error instanceof Error ? error.message : String(error)
32
+ }`,
33
+ rule: RULE_NAME,
34
+ severity: 'error',
35
+ });
36
+
37
+ const buildCollisionDiagnostic = (
38
+ path: readonly string[],
39
+ claims: readonly RouteClaim[]
40
+ ): WardenDiagnostic => ({
41
+ filePath: TOPO_FILE,
42
+ line: 1,
43
+ message: `CLI command route collision on "${renderPath(path)}": ${claims
44
+ .toSorted((a, b) => a.trailId.localeCompare(b.trailId))
45
+ .map(claimLabel)
46
+ .join(
47
+ ', '
48
+ )}. Rename or remove one CLI alias so every accepted command path normalizes into exactly one trail contract.`,
49
+ rule: RULE_NAME,
50
+ severity: 'error',
51
+ });
52
+
53
+ const buildGraphTargetDiagnostic = (
54
+ entryId: string,
55
+ route: CliCommandRoute,
56
+ knownTrailIds: ReadonlySet<string>
57
+ ): WardenDiagnostic => ({
58
+ filePath: TOPO_FILE,
59
+ line: 1,
60
+ message: knownTrailIds.has(route.target)
61
+ ? `Serialized CLI command route "${renderPath(route.path)}" is stored under trail "${entryId}" but targets "${route.target}". Route facts must stay attached to their owning trail.`
62
+ : `Serialized CLI command route "${renderPath(route.path)}" targets unknown trail "${route.target}". Surface-owned aliases must target existing trail IDs.`,
63
+ rule: RULE_NAME,
64
+ severity: 'error',
65
+ });
66
+
67
+ const collectTopoClaims = (
68
+ topo: Topo
69
+ ): {
70
+ readonly claims: readonly RouteClaim[];
71
+ readonly diagnostics: readonly WardenDiagnostic[];
72
+ } => {
73
+ const claims: RouteClaim[] = [];
74
+ const diagnostics: WardenDiagnostic[] = [];
75
+
76
+ for (const trail of topo.list()) {
77
+ try {
78
+ const projection = deriveTrailCliCommandProjection(trail);
79
+ for (const route of projection.routes) {
80
+ claims.push({
81
+ kind: route.kind,
82
+ path: route.path,
83
+ source: route.source,
84
+ trailId: trail.id,
85
+ });
86
+ }
87
+ } catch (error: unknown) {
88
+ diagnostics.push(buildProjectionDiagnostic(trail.id, error));
89
+ }
90
+ }
91
+
92
+ return { claims, diagnostics };
93
+ };
94
+
95
+ const collectGraphClaims = (
96
+ graph: TopoGraph | undefined
97
+ ): readonly RouteClaim[] => {
98
+ if (graph === undefined) {
99
+ return [];
100
+ }
101
+
102
+ const claims: RouteClaim[] = [];
103
+ for (const entry of graph.entries) {
104
+ if (entry.kind !== 'trail') {
105
+ continue;
106
+ }
107
+ for (const route of entry.cli?.routes ?? []) {
108
+ claims.push({
109
+ kind: route.kind,
110
+ path: route.path,
111
+ source: route.source,
112
+ trailId: entry.id,
113
+ });
114
+ }
115
+ }
116
+ return claims;
117
+ };
118
+
119
+ const collectCollisionDiagnostics = (
120
+ claims: readonly RouteClaim[]
121
+ ): readonly WardenDiagnostic[] => {
122
+ const claimsByRoute = new Map<string, RouteClaim[]>();
123
+ for (const claim of claims) {
124
+ const key = routeKey(claim.path);
125
+ const current = claimsByRoute.get(key) ?? [];
126
+ current.push(claim);
127
+ claimsByRoute.set(key, current);
128
+ }
129
+
130
+ return [...claimsByRoute.values()]
131
+ .filter((grouped) => grouped.length > 1)
132
+ .map((grouped) =>
133
+ buildCollisionDiagnostic(grouped[0]?.path ?? [], grouped)
134
+ );
135
+ };
136
+
137
+ const collectGraphTargetDiagnostics = (
138
+ graph: TopoGraph | undefined,
139
+ knownTrailIds: ReadonlySet<string>
140
+ ): readonly WardenDiagnostic[] => {
141
+ if (graph === undefined) {
142
+ return [];
143
+ }
144
+
145
+ const diagnostics: WardenDiagnostic[] = [];
146
+ for (const entry of graph.entries) {
147
+ if (entry.kind !== 'trail') {
148
+ continue;
149
+ }
150
+ for (const route of entry.cli?.routes ?? []) {
151
+ if (route.target !== entry.id || !knownTrailIds.has(route.target)) {
152
+ diagnostics.push(
153
+ buildGraphTargetDiagnostic(entry.id, route, knownTrailIds)
154
+ );
155
+ }
156
+ }
157
+ }
158
+ return diagnostics;
159
+ };
160
+
161
+ export const cliCommandRouteCoherence: TopoAwareWardenRule = {
162
+ checkTopo(topo, context) {
163
+ const { claims, diagnostics } = collectTopoClaims(topo);
164
+ const knownTrailIds = new Set(topo.trails.keys());
165
+ const routeClaims =
166
+ context?.graph === undefined ? claims : collectGraphClaims(context.graph);
167
+ return [
168
+ ...diagnostics,
169
+ ...collectCollisionDiagnostics(routeClaims),
170
+ ...collectGraphTargetDiagnostics(context?.graph, knownTrailIds),
171
+ ];
172
+ },
173
+ description:
174
+ 'Ensure CLI command routes and aliases resolve to one coherent trail contract.',
175
+ name: RULE_NAME,
176
+ severity: 'error',
177
+ };
@@ -7,12 +7,31 @@
7
7
  */
8
8
 
9
9
  import {
10
- findConfigProperty,
11
10
  findBlazeBodies,
11
+ findConfigProperty,
12
12
  findTrailDefinitions,
13
+ getNodeArguments,
14
+ getNodeBodyNode,
15
+ getNodeBodyStatements,
16
+ getNodeCallee,
17
+ getNodeDeclaration,
18
+ getNodeDeclarations,
19
+ getNodeId,
20
+ getNodeInit,
21
+ getNodeKey,
22
+ getNodeKind,
23
+ getNodeLeft,
24
+ getNodeName,
25
+ getNodeObject,
26
+ getNodeParams,
27
+ getNodeProperties,
28
+ getNodeProperty,
29
+ getNodeValue,
30
+ getNodeValueNode,
31
+ isShadowed,
13
32
  offsetToLine,
14
33
  parse,
15
- walk,
34
+ walkWithScopes,
16
35
  } from './ast.js';
17
36
  import type { AstNode } from './ast.js';
18
37
  import { isTestFile } from './scan.js';
@@ -27,7 +46,7 @@ const identifierName = (node: AstNode | undefined): string | null => {
27
46
  if (node?.type !== 'Identifier') {
28
47
  return null;
29
48
  }
30
- return (node as unknown as { name?: string }).name ?? null;
49
+ return getNodeName(node) ?? null;
31
50
  };
32
51
 
33
52
  // ---------------------------------------------------------------------------
@@ -40,14 +59,14 @@ const isStringLiteral = (node: AstNode): boolean => {
40
59
  return true;
41
60
  }
42
61
  if (node.type === 'Literal') {
43
- return typeof (node as unknown as { value?: unknown }).value === 'string';
62
+ return typeof getNodeValue(node) === 'string';
44
63
  }
45
64
  return false;
46
65
  };
47
66
 
48
67
  /** Extract the string value from a string literal node. */
49
68
  const getStringValue = (node: AstNode): string | null => {
50
- const val = (node as unknown as { value?: unknown }).value;
69
+ const val = getNodeValue(node);
51
70
  return typeof val === 'string' ? val : null;
52
71
  };
53
72
 
@@ -194,12 +213,8 @@ const extractMemberPair = (
194
213
  return null;
195
214
  }
196
215
 
197
- const objName = identifierName(
198
- (callee as unknown as { object?: AstNode }).object
199
- );
200
- const propName = identifierName(
201
- (callee as unknown as { property?: AstNode }).property
202
- );
216
+ const objName = identifierName(getNodeObject(callee));
217
+ const propName = identifierName(getNodeProperty(callee));
203
218
 
204
219
  return objName && propName ? { objName, propName } : null;
205
220
  };
@@ -219,7 +234,7 @@ const extractContextParamName = (blazeBody: AstNode): string | null => {
219
234
  }
220
235
  const [, param] = params;
221
236
  if (param?.type === 'AssignmentPattern') {
222
- const { left } = param as unknown as { left?: AstNode };
237
+ const left = getNodeLeft(param);
223
238
  return identifierName(left);
224
239
  }
225
240
  return identifierName(param);
@@ -230,10 +245,8 @@ const extractComposeLocalName = (prop: AstNode): string | null => {
230
245
  if (prop.type !== 'Property') {
231
246
  return null;
232
247
  }
233
- const { key, value } = prop as unknown as {
234
- readonly key?: AstNode;
235
- readonly value?: AstNode;
236
- };
248
+ const key = getNodeKey(prop);
249
+ const value = getNodeValueNode(prop);
237
250
  const keyName = identifierName(key);
238
251
  if (keyName !== 'compose') {
239
252
  return null;
@@ -246,9 +259,7 @@ const collectComposeNamesFromPattern = (
246
259
  pattern: AstNode,
247
260
  names: Set<string>
248
261
  ): void => {
249
- const { properties } = pattern as unknown as {
250
- readonly properties?: readonly AstNode[];
251
- };
262
+ const properties = getNodeProperties(pattern);
252
263
  for (const prop of properties ?? []) {
253
264
  const localName = extractComposeLocalName(prop);
254
265
  if (localName) {
@@ -436,10 +447,8 @@ const getCtxDestructurePattern = (
436
447
  if (node.type !== 'VariableDeclarator') {
437
448
  return null;
438
449
  }
439
- const { id, init } = node as unknown as {
440
- readonly id?: AstNode;
441
- readonly init?: AstNode;
442
- };
450
+ const id = getNodeId(node);
451
+ const init = getNodeInit(node);
443
452
  if (!id || id.type !== 'ObjectPattern' || !init) {
444
453
  return null;
445
454
  }
@@ -448,11 +457,14 @@ const getCtxDestructurePattern = (
448
457
  };
449
458
 
450
459
  const getTopLevelStatements = (body: AstNode): readonly AstNode[] => {
451
- const blockBody = (body as unknown as { body?: AstNode }).body;
460
+ if (body.type === 'BlockStatement') {
461
+ return getNodeBodyStatements(body);
462
+ }
463
+ const blockBody = getNodeBodyNode(body);
452
464
  if (!blockBody || blockBody.type !== 'BlockStatement') {
453
465
  return [];
454
466
  }
455
- return (blockBody as unknown as { body?: readonly AstNode[] }).body ?? [];
467
+ return getNodeBodyStatements(blockBody);
456
468
  };
457
469
 
458
470
  const collectComposeNamesFromDeclaration = (
@@ -463,13 +475,11 @@ const collectComposeNamesFromDeclaration = (
463
475
  if (stmt.type !== 'VariableDeclaration') {
464
476
  return;
465
477
  }
466
- const { kind } = stmt as unknown as { readonly kind?: string };
478
+ const kind = getNodeKind(stmt);
467
479
  if (kind !== 'const') {
468
480
  return;
469
481
  }
470
- const declarations =
471
- (stmt as unknown as { readonly declarations?: readonly AstNode[] })
472
- .declarations ?? [];
482
+ const declarations = getNodeDeclarations(stmt);
473
483
  for (const decl of declarations) {
474
484
  const pattern = getCtxDestructurePattern(decl, ctxNames);
475
485
  if (pattern) {
@@ -489,6 +499,109 @@ const collectDestructuredComposeNames = (
489
499
  return names;
490
500
  };
491
501
 
502
+ interface ComposeHelper {
503
+ readonly body: AstNode;
504
+ readonly params: readonly AstNode[];
505
+ }
506
+
507
+ const isFunctionLikeNode = (
508
+ node: AstNode | null | undefined
509
+ ): node is AstNode =>
510
+ node !== null &&
511
+ node !== undefined &&
512
+ (node.type === 'ArrowFunctionExpression' ||
513
+ node.type === 'FunctionDeclaration' ||
514
+ node.type === 'FunctionExpression');
515
+
516
+ const helperFromFunctionNode = (
517
+ node: AstNode | null | undefined
518
+ ): ComposeHelper | null => {
519
+ if (!isFunctionLikeNode(node)) {
520
+ return null;
521
+ }
522
+ const body = getNodeBodyNode(node);
523
+ return body ? { body, params: getNodeParams(node) } : null;
524
+ };
525
+
526
+ const addFunctionHelper = (
527
+ node: AstNode,
528
+ helpers: Map<string, ComposeHelper>
529
+ ): void => {
530
+ if (node.type !== 'FunctionDeclaration') {
531
+ return;
532
+ }
533
+ const name = identifierName(getNodeId(node));
534
+ const helper = helperFromFunctionNode(node);
535
+ if (name && helper) {
536
+ helpers.set(name, helper);
537
+ }
538
+ };
539
+
540
+ const addVariableHelpers = (
541
+ node: AstNode,
542
+ helpers: Map<string, ComposeHelper>
543
+ ): void => {
544
+ if (node.type !== 'VariableDeclaration' || getNodeKind(node) !== 'const') {
545
+ return;
546
+ }
547
+ for (const declaration of getNodeDeclarations(node)) {
548
+ const name = identifierName(getNodeId(declaration));
549
+ const helper = helperFromFunctionNode(getNodeInit(declaration));
550
+ if (name && helper) {
551
+ helpers.set(name, helper);
552
+ }
553
+ }
554
+ };
555
+
556
+ const unwrapTopLevelDeclaration = (node: AstNode): AstNode =>
557
+ node.type === 'ExportNamedDeclaration'
558
+ ? (getNodeDeclaration(node) ?? node)
559
+ : node;
560
+
561
+ const collectComposeHelpers = (
562
+ ast: AstNode
563
+ ): ReadonlyMap<string, ComposeHelper> => {
564
+ const helpers = new Map<string, ComposeHelper>();
565
+ for (const statement of getNodeBodyStatements(ast)) {
566
+ const declaration = unwrapTopLevelDeclaration(statement);
567
+ addFunctionHelper(declaration, helpers);
568
+ addVariableHelpers(declaration, helpers);
569
+ }
570
+ return helpers;
571
+ };
572
+
573
+ const collectHelperCtxNames = (
574
+ call: AstNode,
575
+ helper: ComposeHelper,
576
+ ctxNames: ReadonlySet<string>
577
+ ): ReadonlySet<string> => {
578
+ const names = new Set<string>();
579
+ const args = getNodeArguments(call);
580
+ for (const [index, param] of helper.params.entries()) {
581
+ const argName = identifierName(args[index]);
582
+ const paramName = identifierName(param);
583
+ if (argName && paramName && ctxNames.has(argName)) {
584
+ names.add(paramName);
585
+ }
586
+ }
587
+ return names;
588
+ };
589
+
590
+ const findHelperCall = (
591
+ node: AstNode | null | undefined,
592
+ helpers: ReadonlyMap<string, ComposeHelper>
593
+ ): { readonly helper: ComposeHelper; readonly name: string } | null => {
594
+ if (node?.type !== 'CallExpression') {
595
+ return null;
596
+ }
597
+ const calleeName = identifierName(getNodeCallee(node));
598
+ if (!calleeName) {
599
+ return null;
600
+ }
601
+ const helper = helpers.get(calleeName);
602
+ return helper ? { helper, name: calleeName } : null;
603
+ };
604
+
492
605
  interface CalledComposes {
493
606
  /** Statically resolved trail IDs from string literal arguments. */
494
607
  readonly ids: ReadonlySet<string>;
@@ -504,31 +617,67 @@ interface CalledComposes {
504
617
  const collectComposeCallsFromBody = (
505
618
  body: AstNode,
506
619
  ids: Set<string>,
507
- sourceCode: string
620
+ sourceCode: string,
621
+ helpers: ReadonlyMap<string, ComposeHelper>,
622
+ inheritedCtxNames?: ReadonlySet<string>,
623
+ visitedHelpers = new Set<string>()
508
624
  ): boolean => {
509
- const ctxNames = buildCtxNames(body);
625
+ const ctxNames = inheritedCtxNames ?? buildCtxNames(body);
510
626
  const composeLocalNames = collectDestructuredComposeNames(body, ctxNames);
627
+ const helperNames = new Set(helpers.keys());
511
628
  let foundUnresolved = false;
512
629
 
513
- walk(body, (node) => {
514
- const extracted = extractComposeCall(
515
- node,
516
- ctxNames,
517
- composeLocalNames,
518
- sourceCode
519
- );
520
- if (!extracted) {
521
- return;
522
- }
523
-
524
- if (extracted.hasUnresolved) {
525
- foundUnresolved = true;
526
- }
527
-
528
- for (const id of extracted.ids) {
529
- ids.add(id);
530
- }
531
- });
630
+ walkWithScopes(
631
+ body,
632
+ (node, scopeStack) => {
633
+ const extracted = extractComposeCall(
634
+ node,
635
+ ctxNames,
636
+ composeLocalNames,
637
+ sourceCode
638
+ );
639
+ if (extracted) {
640
+ if (extracted.hasUnresolved) {
641
+ foundUnresolved = true;
642
+ }
643
+
644
+ for (const id of extracted.ids) {
645
+ ids.add(id);
646
+ }
647
+ }
648
+
649
+ const helperCall = findHelperCall(node, helpers);
650
+ if (
651
+ !helperCall ||
652
+ visitedHelpers.has(helperCall.name) ||
653
+ isShadowed(helperCall.name, scopeStack)
654
+ ) {
655
+ return;
656
+ }
657
+ const helperCtxNames = collectHelperCtxNames(
658
+ node,
659
+ helperCall.helper,
660
+ ctxNames
661
+ );
662
+ if (helperCtxNames.size === 0) {
663
+ return;
664
+ }
665
+ visitedHelpers.add(helperCall.name);
666
+ if (
667
+ collectComposeCallsFromBody(
668
+ helperCall.helper.body,
669
+ ids,
670
+ sourceCode,
671
+ helpers,
672
+ helperCtxNames,
673
+ visitedHelpers
674
+ )
675
+ ) {
676
+ foundUnresolved = true;
677
+ }
678
+ },
679
+ { initialScopes: [helperNames] }
680
+ );
532
681
 
533
682
  return foundUnresolved;
534
683
  };
@@ -536,13 +685,15 @@ const collectComposeCallsFromBody = (
536
685
  /** Walk blaze bodies and collect all statically resolvable ctx.compose() trail IDs. */
537
686
  const extractCalledComposes = (
538
687
  config: AstNode,
688
+ ast: AstNode,
539
689
  sourceCode: string
540
690
  ): CalledComposes => {
541
691
  const ids = new Set<string>();
542
692
  let hasUnresolved = false;
693
+ const helpers = collectComposeHelpers(ast);
543
694
 
544
695
  for (const body of findBlazeBodies(config)) {
545
- if (collectComposeCallsFromBody(body, ids, sourceCode)) {
696
+ if (collectComposeCallsFromBody(body, ids, sourceCode, helpers)) {
546
697
  hasUnresolved = true;
547
698
  }
548
699
  }
@@ -558,16 +709,13 @@ const buildUndeclaredDiagnostic = (
558
709
  trailId: string,
559
710
  composedId: string,
560
711
  filePath: string,
561
- line: number,
562
- softened = false
712
+ line: number
563
713
  ): WardenDiagnostic => ({
564
714
  filePath,
565
715
  line,
566
- message: softened
567
- ? `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes (may be declared via trail object references). Add the string id to composes, or use the same trail object form in both composes and ctx.compose(...).`
568
- : `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes. Add it to the trail composes array: composes: ['${composedId}', ...].`,
716
+ message: `Trail "${trailId}": ctx.compose('${composedId}') called but '${composedId}' is not declared in composes. Add it to the trail composes array: composes: ['${composedId}', ...].`,
569
717
  rule: 'composes-declarations',
570
- severity: softened ? 'warn' : 'error',
718
+ severity: 'error',
571
719
  });
572
720
 
573
721
  const buildUnusedDiagnostic = (
@@ -595,20 +743,13 @@ const reportUndeclared = (
595
743
  trailId: string;
596
744
  filePath: string;
597
745
  line: number;
598
- softened?: boolean;
599
746
  },
600
747
  diagnostics: WardenDiagnostic[]
601
748
  ): void => {
602
749
  for (const id of called) {
603
750
  if (!declared.has(id)) {
604
751
  diagnostics.push(
605
- buildUndeclaredDiagnostic(
606
- ctx.trailId,
607
- id,
608
- ctx.filePath,
609
- ctx.line,
610
- ctx.softened
611
- )
752
+ buildUndeclaredDiagnostic(ctx.trailId, id, ctx.filePath, ctx.line)
612
753
  );
613
754
  }
614
755
  }
@@ -632,12 +773,13 @@ const reportUnused = (
632
773
 
633
774
  const checkTrailDefinition = (
634
775
  def: { id: string; config: AstNode; start: number },
776
+ ast: AstNode,
635
777
  filePath: string,
636
778
  sourceCode: string,
637
779
  diagnostics: WardenDiagnostic[]
638
780
  ): void => {
639
781
  const declared = extractDeclaredComposes(def.config, sourceCode);
640
- const called = extractCalledComposes(def.config, sourceCode);
782
+ const called = extractCalledComposes(def.config, ast, sourceCode);
641
783
 
642
784
  if (
643
785
  declared.ids.size === 0 &&
@@ -651,16 +793,7 @@ const checkTrailDefinition = (
651
793
  const line = offsetToLine(sourceCode, def.start);
652
794
  const ctx = { filePath, line, trailId: def.id };
653
795
 
654
- // When the declared array contains trail object references we can't resolve,
655
- // downgrade "undeclared" diagnostics from error to warn. The developer still
656
- // sees genuinely undeclared calls, but we can't statically prove the call
657
- // isn't covered by a trail object entry the runtime will normalize.
658
- reportUndeclared(
659
- called.ids,
660
- declared.ids,
661
- { ...ctx, softened: declared.hasUnresolved },
662
- diagnostics
663
- );
796
+ reportUndeclared(called.ids, declared.ids, ctx, diagnostics);
664
797
 
665
798
  // When all ctx.compose() calls are statically resolved, report unused
666
799
  // declarations. When some calls use trail object references (unresolved),
@@ -692,7 +825,7 @@ export const composesDeclarations: WardenRule = {
692
825
  const diagnostics: WardenDiagnostic[] = [];
693
826
 
694
827
  for (const def of findTrailDefinitions(ast)) {
695
- checkTrailDefinition(def, filePath, sourceCode, diagnostics);
828
+ checkTrailDefinition(def, ast, filePath, sourceCode, diagnostics);
696
829
  }
697
830
 
698
831
  return diagnostics;
@@ -5,7 +5,16 @@
5
5
  * imports in comments or strings.
6
6
  */
7
7
 
8
- import { offsetToLine, parse, walk } from './ast.js';
8
+ import {
9
+ getNodeCallee,
10
+ getNodeComputed,
11
+ getNodeName,
12
+ getNodeProperty,
13
+ offsetToLine,
14
+ parse,
15
+ walk,
16
+ } from './ast.js';
17
+ import type { AstNode } from './ast.js';
9
18
  import type { WardenDiagnostic, WardenRule } from './types.js';
10
19
 
11
20
  const SURFACE_MODULES = new Set([
@@ -29,12 +38,6 @@ const SURFACE_TYPE_NAMES = new Set([
29
38
  'ServerResponse',
30
39
  ]);
31
40
 
32
- interface AstNode {
33
- readonly type: string;
34
- readonly start: number;
35
- readonly [key: string]: unknown;
36
- }
37
-
38
41
  interface ImportSpecifier {
39
42
  readonly local?: { readonly name?: string };
40
43
  readonly imported?: { readonly name?: string };
@@ -44,7 +47,7 @@ const isBareTrailCallee = (callee: AstNode): boolean => {
44
47
  if (callee.type !== 'Identifier') {
45
48
  return false;
46
49
  }
47
- return (callee as unknown as { name?: string }).name === 'trail';
50
+ return getNodeName(callee) === 'trail';
48
51
  };
49
52
 
50
53
  const isNamespacedTrailCallee = (callee: AstNode): boolean => {
@@ -57,14 +60,14 @@ const isNamespacedTrailCallee = (callee: AstNode): boolean => {
57
60
  // Skip computed access like `ns[trail]()` — the bracketed expression may
58
61
  // resolve to any runtime value, not the `trail` primitive, even when it
59
62
  // happens to be an identifier literally named `trail`.
60
- if ((callee as unknown as { computed?: boolean }).computed === true) {
63
+ if (getNodeComputed(callee) === true) {
61
64
  return false;
62
65
  }
63
- const prop = (callee as unknown as { property?: AstNode }).property;
66
+ const prop = getNodeProperty(callee);
64
67
  if (prop?.type !== 'Identifier') {
65
68
  return false;
66
69
  }
67
- return (prop as unknown as { name?: string }).name === 'trail';
70
+ return getNodeName(prop) === 'trail';
68
71
  };
69
72
 
70
73
  /**
@@ -90,7 +93,7 @@ const hasTrailCall = (ast: AstNode): boolean => {
90
93
  if (found || node.type !== 'CallExpression') {
91
94
  return;
92
95
  }
93
- const { callee } = node as unknown as { callee?: AstNode };
96
+ const callee = getNodeCallee(node);
94
97
  if (!callee) {
95
98
  return;
96
99
  }