@telorun/analyzer 1.3.0 → 1.5.0

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.
@@ -8,6 +8,21 @@ export interface StaticAnalyzerOptions {
8
8
  export declare class StaticAnalyzer {
9
9
  private readonly celEnv;
10
10
  constructor(options?: StaticAnalyzerOptions);
11
+ /**
12
+ * Run static analysis over a flattened manifest list.
13
+ *
14
+ * **Contract**: every non-system manifest (anything outside `Telo.Definition`,
15
+ * `Telo.Abstract`) must carry `metadata.source` (non-empty string) and
16
+ * `metadata.sourceLine` (number). The dedup that backs
17
+ * `DUPLICATE_RESOURCE_NAME` reads those fields to tell a pipeline echo
18
+ * apart from a genuine collision, and downstream diagnostic positioning
19
+ * depends on them too. Real callers stamp positions already (the `Loader`,
20
+ * `flattenForAnalyzer`, the telo-editor's `emitDocsFor`, the VSCode
21
+ * extension). Programmatic callers — tests, ad-hoc scripts — should pass
22
+ * their inputs through `withSyntheticPositions(...)` before calling
23
+ * `analyze()`. A missing position throws a clear error rather than
24
+ * silently producing wrong diagnostics.
25
+ */
11
26
  analyze(manifests: ResourceManifest[], options?: AnalysisOptions, registry?: AnalysisRegistry): AnalysisDiagnostic[];
12
27
  analyzeErrors(manifests: ResourceManifest[], options?: AnalysisOptions, registry?: AnalysisRegistry): AnalysisDiagnostic[];
13
28
  normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[];
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAe9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA+c/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IA6dvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAexF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAGL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAgB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAgf/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IA8dvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CAAC,SAAS,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,gBAAgB,GAAG,gBAAgB,EAAE;IAexF,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
package/dist/analyzer.js CHANGED
@@ -7,6 +7,7 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
7
7
  import { computeSuggestKind } from "./kind-suggest.js";
8
8
  import { isModuleKind } from "./module-kinds.js";
9
9
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
10
+ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
10
11
  import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
11
12
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
12
13
  import { celTypeSatisfiesJsonSchema, substituteCelFields, validateAgainstSchema, } from "./schema-compat.js";
@@ -17,6 +18,38 @@ import { validateProviderCoherence } from "./validate-provider-coherence.js";
17
18
  import { validateReferences } from "./validate-references.js";
18
19
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
19
20
  const SELF_PREFIX = "Self.";
21
+ /**
22
+ * `StaticAnalyzer.analyze()` requires `metadata.source` (non-empty) and
23
+ * `metadata.sourceLine` (number) on every non-system manifest — see the
24
+ * JSDoc on `analyze()`. Production callers stamp these via the `Loader` /
25
+ * `flattenForAnalyzer` / `emitDocsFor` paths; programmatic callers (tests,
26
+ * scripts) should pre-process inputs with `withSyntheticPositions(...)`.
27
+ * Surfacing the violation here turns silent dedup misbehaviour into a
28
+ * loud, actionable error.
29
+ */
30
+ function assertManifestPositions(manifests) {
31
+ for (let i = 0; i < manifests.length; i++) {
32
+ const m = manifests[i];
33
+ if (REF_VALIDATION_SKIP_KINDS.has(m.kind))
34
+ continue;
35
+ const meta = m.metadata;
36
+ const okSource = typeof meta?.source === "string" && meta.source.length > 0;
37
+ const okLine = typeof meta?.sourceLine === "number";
38
+ if (okSource && okLine)
39
+ continue;
40
+ const label = `${m.kind}/${m.metadata?.name ?? "(unnamed)"}`;
41
+ const missing = [
42
+ !okSource ? "metadata.source" : null,
43
+ !okLine ? "metadata.sourceLine" : null,
44
+ ]
45
+ .filter(Boolean)
46
+ .join(" and ");
47
+ throw new Error(`StaticAnalyzer.analyze(): manifest #${i} (${label}) is missing ${missing}. ` +
48
+ `Real callers stamp positions automatically; programmatic callers ` +
49
+ `(tests, ad-hoc scripts) should pass inputs through ` +
50
+ `\`withSyntheticPositions(manifests)\` before calling analyze().`);
51
+ }
52
+ }
20
53
  /** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
21
54
  * to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
22
55
  * the magic alias for "this library's own module" — and other prefixes
@@ -376,7 +409,23 @@ export class StaticAnalyzer {
376
409
  constructor(options = {}) {
377
410
  this.celEnv = buildCelEnvironment(options.celHandlers);
378
411
  }
412
+ /**
413
+ * Run static analysis over a flattened manifest list.
414
+ *
415
+ * **Contract**: every non-system manifest (anything outside `Telo.Definition`,
416
+ * `Telo.Abstract`) must carry `metadata.source` (non-empty string) and
417
+ * `metadata.sourceLine` (number). The dedup that backs
418
+ * `DUPLICATE_RESOURCE_NAME` reads those fields to tell a pipeline echo
419
+ * apart from a genuine collision, and downstream diagnostic positioning
420
+ * depends on them too. Real callers stamp positions already (the `Loader`,
421
+ * `flattenForAnalyzer`, the telo-editor's `emitDocsFor`, the VSCode
422
+ * extension). Programmatic callers — tests, ad-hoc scripts — should pass
423
+ * their inputs through `withSyntheticPositions(...)` before calling
424
+ * `analyze()`. A missing position throws a clear error rather than
425
+ * silently producing wrong diagnostics.
426
+ */
379
427
  analyze(manifests, options, registry) {
428
+ assertManifestPositions(manifests);
380
429
  const diagnostics = [];
381
430
  // Use pre-seeded registries from the provided AnalysisRegistry, or create fresh ones.
382
431
  // New aliases/definitions found in the manifests are accumulated into the provided instance
@@ -1 +1 @@
1
- {"version":3,"file":"definition-registry.d.ts","sourceRoot":"","sources":["../src/definition-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AAGlC,+EAA+E;AAC/E,qBAAa,kBAAkB;;IAK7B;;sFAEkF;IAClF,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IAEzD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyC;IAC9D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwC;IAClE,mEAAmE;IACnE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA+B;IAC1D;6DACyD;IACzD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD;;0EAEsE;IACtE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAEhE,QAAQ,CAAC,UAAU,EAAE,kBAAkB,GAAG,IAAI;IAiC9C,OAAO,CAAC,aAAa;IASrB;;;yFAGqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IAgB1E;4EACwE;IACxE,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMnE;wFACoF;IACpF,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,EAAE;IAWtE,OAAO,CAAC,iBAAiB;IAczB;;;;;;;;4FAQwF;IACxF,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAUhD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIrD,+FAA+F;IAC/F,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS;IAIxD,gGAAgG;IAChG,kBAAkB,CAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,GACvD,iBAAiB,GAAG,SAAS;IAOhC;;;;;;;;qFAQiF;IACjF,2BAA2B,CACzB,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,aAAa,EACtB,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC1C,iBAAiB,GAAG,SAAS;IAuBhC,OAAO,CAAC,uBAAuB;IA+B/B;;qEAEiE;IACjE,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,EAAE;IAgBxD,KAAK,IAAI,MAAM,EAAE;CAGlB"}
1
+ {"version":3,"file":"definition-registry.d.ts","sourceRoot":"","sources":["../src/definition-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,0BAA0B,CAAC;AAGlC,+EAA+E;AAC/E,qBAAa,kBAAkB;;IAK7B;;sFAEkF;IAClF,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IAEzD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAyC;IAC9D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAwC;IAClE,mEAAmE;IACnE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA+B;IAC1D;6DACyD;IACzD,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;IACzD;;0EAEsE;IACtE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA6B;IAEhE,QAAQ,CAAC,UAAU,EAAE,kBAAkB,GAAG,IAAI;IAiC9C,OAAO,CAAC,aAAa;IASrB;;;yFAGqF;IACrF,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI;IA+B1E;4EACwE;IACxE,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAMnE;wFACoF;IACpF,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,EAAE;IAWtE,OAAO,CAAC,iBAAiB;IAczB;;;;;;;;4FAQwF;IACxF,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IAUhD,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIrD,+FAA+F;IAC/F,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS;IAIxD,gGAAgG;IAChG,kBAAkB,CAChB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;KAAE,GACvD,iBAAiB,GAAG,SAAS;IAOhC;;;;;;;;qFAQiF;IACjF,2BAA2B,CACzB,QAAQ,EAAE,gBAAgB,EAC1B,OAAO,EAAE,aAAa,EACtB,eAAe,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,GAC1C,iBAAiB,GAAG,SAAS;IAuBhC,OAAO,CAAC,uBAAuB;IA+B/B;;qEAEiE;IACjE,YAAY,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,EAAE;IAgBxD,KAAK,IAAI,MAAM,EAAE;CAGlB"}
@@ -70,6 +70,22 @@ export class DefinitionRegistry {
70
70
  * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
71
71
  * @param moduleName The module's metadata.name (e.g. "pipeline", "http-server"). */
72
72
  registerModuleIdentity(namespace, moduleName) {
73
+ // The "telo" identity is reserved for the Telo built-in module and gets
74
+ // populated automatically when a Telo.Abstract definition registers (see
75
+ // `register` below). A user app / library without a namespace must NOT
76
+ // claim it — silently overwriting the built-in entry breaks every
77
+ // x-telo-ref that resolves through "telo#…". Concretely, the
78
+ // `Http.Api.routes[].handler` slot in the http-server schema carries
79
+ // `x-telo-ref: "telo#Invocable"`. If the entry application is, say,
80
+ // `Telo.Application/HelloApi` (no namespace), this method previously
81
+ // overwrote `"telo" → "Telo"` with `"telo" → "HelloApi"`. The handler's
82
+ // ref then resolved to a nonexistent `HelloApi.Invocable`, the
83
+ // kind-mismatch check inside `validate-references.ts` short-circuited
84
+ // on partial context, and the analyzer reported zero issues for a
85
+ // manifest that explodes at runtime. Skip non-Telo no-namespace modules;
86
+ // they have no x-telo-ref identity to declare anyway.
87
+ if (!namespace && moduleName !== "Telo")
88
+ return;
73
89
  const identity = namespace ? `${namespace}/${moduleName}` : "telo";
74
90
  this.identityMap.set(identity, moduleName);
75
91
  this.reverseIdentityMap.set(moduleName, identity);
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { buildDocumentPositions, buildLineOffsets, buildPositionIndex, documentL
12
12
  export type { DocumentPosition } from "./position-metadata.js";
13
13
  export { HttpSource } from "./sources/http-source.js";
14
14
  export { RegistrySource } from "./sources/registry-source.js";
15
+ export { withSyntheticPositions } from "./with-synthetic-positions.js";
15
16
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
16
17
  export type { AnalysisDiagnostic, AnalysisOptions, LoaderInitOptions, LoadOptions, ManifestSource, Position, PositionIndex, Range } from "./types.js";
17
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AACpF,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -8,4 +8,5 @@ export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.j
8
8
  export { buildDocumentPositions, buildLineOffsets, buildPositionIndex, documentLineOffsets, } from "./position-metadata.js";
9
9
  export { HttpSource } from "./sources/http-source.js";
10
10
  export { RegistrySource } from "./sources/registry-source.js";
11
+ export { withSyntheticPositions } from "./with-synthetic-positions.js";
11
12
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
@@ -1 +1 @@
1
- {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CA2WtB"}
1
+ {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA4C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAsbtB"}
@@ -60,14 +60,93 @@ export function validateReferences(resources, context) {
60
60
  const aliasesByModule = context.aliasesByModule;
61
61
  if (!aliases || !registry)
62
62
  return diagnostics;
63
- // Build outer resource lookup by name for resolution check.
64
- // Exclude system kinds (Telo.Definition) they are type blueprints, not instances,
65
- // and their names (e.g. "Server", "Job") would shadow user-defined resource instances.
66
- const byName = new Map();
63
+ // Build outer resource lookup by name for resolution check, collecting
64
+ // every entry per name so we can surface name collisions as diagnostics
65
+ // (the kernel's resource registry shares one namespace across all
66
+ // non-system kinds e.g. `Telo.Application HelloApi` and `Http.Api
67
+ // HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
68
+ // statically removes a class of "everything analyzes clean, then the
69
+ // kernel refuses to start" surprises.)
70
+ //
71
+ // Telo.Import is excluded from the duplicate check on top of the
72
+ // SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
73
+ // identity (aliases live in a separate namespace from resources, and
74
+ // colliding aliases vs. resource names is benign — the alias is only
75
+ // ever read as a kind prefix).
76
+ // Group manifests by name to detect collisions. Two subtleties:
77
+ //
78
+ // 1. Some analyzer hosts emit the SAME physical document twice through
79
+ // their pipeline — e.g. the telo-editor's `toAnalysisManifests` walks
80
+ // each workspace module's documents independently, and a file
81
+ // reachable from two angles (entry module + `include:` partial)
82
+ // shows up twice. The fingerprint includes `sourceLine` so identical
83
+ // docs (same kind, name, source, AND source line) collapse to one,
84
+ // while two textually-separate documents in the same file (different
85
+ // source lines) keep separate fingerprints and trip the diagnostic.
86
+ // 2. The diagnostic carries a precomputed `range` pointing at the
87
+ // duplicate's source line — editor hosts that resolve diagnostic
88
+ // positions via a `${file}::${kind}::${name}` lookup would otherwise
89
+ // collide on duplicates (Map.set overwrites) and place the squiggle
90
+ // ambiguously. The explicit `range` short-circuits that lookup.
91
+ // Dedup pipeline echoes — the same physical document emitted twice
92
+ // through an analyzer host's pipeline. Keyed on (kind, name, source,
93
+ // sourceLine), so two textually-distinct docs in the same file (same
94
+ // source, different sourceLine) keep separate fingerprints and still
95
+ // trip the diagnostic. `analyze()` enforces that every non-system
96
+ // manifest carries both positional fields — no defensive guard needed.
97
+ const byNameAll = new Map();
98
+ const seen = new Set();
67
99
  for (const r of resources) {
68
- if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind))
69
- byName.set(r.metadata.name, r);
100
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
101
+ continue;
102
+ const name = r.metadata.name;
103
+ // `analyze()` guarantees both fields are present on non-system manifests.
104
+ const meta = r.metadata;
105
+ const fingerprint = `${r.kind} ${name} ${meta.source} ${meta.sourceLine}`;
106
+ if (seen.has(fingerprint))
107
+ continue;
108
+ seen.add(fingerprint);
109
+ const existing = byNameAll.get(name);
110
+ if (existing)
111
+ existing.push(r);
112
+ else
113
+ byNameAll.set(name, [r]);
114
+ }
115
+ for (const [name, list] of byNameAll) {
116
+ if (list.length <= 1)
117
+ continue;
118
+ const [first, ...rest] = list;
119
+ const firstLabel = `${first.kind}/${name}`;
120
+ for (const dup of rest) {
121
+ const dupMeta = dup.metadata;
122
+ const range = typeof dupMeta?.sourceLine === "number"
123
+ ? {
124
+ start: { line: dupMeta.sourceLine, character: 0 },
125
+ end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
126
+ }
127
+ : undefined;
128
+ diagnostics.push({
129
+ severity: DiagnosticSeverity.Error,
130
+ code: "DUPLICATE_RESOURCE_NAME",
131
+ source: SOURCE,
132
+ message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
133
+ ...(range ? { range } : {}),
134
+ data: {
135
+ resource: { kind: dup.kind, name },
136
+ filePath: dupMeta?.source,
137
+ path: "metadata.name",
138
+ },
139
+ });
140
+ }
70
141
  }
142
+ // Single-resource map for the resolution / scope lookups below — when a
143
+ // collision exists, falling back to the first occurrence keeps the rest
144
+ // of the pass behaving the same as before the duplicate diagnostic was
145
+ // added (resolution still finds *something*; the duplicate diagnostic
146
+ // is what surfaces the underlying problem to the user).
147
+ const byName = new Map();
148
+ for (const [name, list] of byNameAll)
149
+ byName.set(name, list[0]);
71
150
  for (const r of resources) {
72
151
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind))
73
152
  continue;
@@ -0,0 +1,28 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ /**
3
+ * Stamp `metadata.source` and `metadata.sourceLine` on every non-system
4
+ * manifest that lacks them, returning a new array with cloned `metadata`
5
+ * objects for the affected entries.
6
+ *
7
+ * `StaticAnalyzer.analyze()` requires position info on every non-system
8
+ * manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
9
+ * `(source, sourceLine)` to distinguish pipeline echoes from real
10
+ * collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
11
+ * the telo-editor's `emitDocsFor`, the VSCode extension — all stamp
12
+ * positions already. This helper is the escape hatch for **programmatic
13
+ * callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
14
+ * literals without going through a loader: it gives every otherwise-naked
15
+ * manifest a synthetic, deterministic position so the analyzer's
16
+ * invariant holds without each test having to spell positions out.
17
+ *
18
+ * The synthetic source defaults to `"<programmatic>"` — override via
19
+ * `source` when a stable, recognisable label helps diagnostic output.
20
+ * Each unstamped manifest gets a unique `sourceLine` (1-based array
21
+ * index) so two real duplicates supplied without positions retain
22
+ * distinct fingerprints and still trip `DUPLICATE_RESOURCE_NAME`.
23
+ *
24
+ * Manifests that already carry `metadata.source` and `metadata.sourceLine`
25
+ * pass through unchanged.
26
+ */
27
+ export declare function withSyntheticPositions(manifests: ResourceManifest[], source?: string): ResourceManifest[];
28
+ //# sourceMappingURL=with-synthetic-positions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"with-synthetic-positions.d.ts","sourceRoot":"","sources":["../src/with-synthetic-positions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,MAAM,GAAE,MAAyB,GAChC,gBAAgB,EAAE,CAgBpB"}
@@ -0,0 +1,45 @@
1
+ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
2
+ /**
3
+ * Stamp `metadata.source` and `metadata.sourceLine` on every non-system
4
+ * manifest that lacks them, returning a new array with cloned `metadata`
5
+ * objects for the affected entries.
6
+ *
7
+ * `StaticAnalyzer.analyze()` requires position info on every non-system
8
+ * manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
9
+ * `(source, sourceLine)` to distinguish pipeline echoes from real
10
+ * collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
11
+ * the telo-editor's `emitDocsFor`, the VSCode extension — all stamp
12
+ * positions already. This helper is the escape hatch for **programmatic
13
+ * callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
14
+ * literals without going through a loader: it gives every otherwise-naked
15
+ * manifest a synthetic, deterministic position so the analyzer's
16
+ * invariant holds without each test having to spell positions out.
17
+ *
18
+ * The synthetic source defaults to `"<programmatic>"` — override via
19
+ * `source` when a stable, recognisable label helps diagnostic output.
20
+ * Each unstamped manifest gets a unique `sourceLine` (1-based array
21
+ * index) so two real duplicates supplied without positions retain
22
+ * distinct fingerprints and still trip `DUPLICATE_RESOURCE_NAME`.
23
+ *
24
+ * Manifests that already carry `metadata.source` and `metadata.sourceLine`
25
+ * pass through unchanged.
26
+ */
27
+ export function withSyntheticPositions(manifests, source = "<programmatic>") {
28
+ return manifests.map((m, i) => {
29
+ if (REF_VALIDATION_SKIP_KINDS.has(m.kind))
30
+ return m;
31
+ const meta = m.metadata;
32
+ const hasSource = typeof meta?.source === "string" && meta.source.length > 0;
33
+ const hasLine = typeof meta?.sourceLine === "number";
34
+ if (hasSource && hasLine)
35
+ return m;
36
+ return {
37
+ ...m,
38
+ metadata: {
39
+ ...m.metadata,
40
+ source: hasSource ? meta.source : source,
41
+ sourceLine: hasLine ? meta.sourceLine : i,
42
+ },
43
+ };
44
+ });
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
package/src/analyzer.ts CHANGED
@@ -14,6 +14,7 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
14
14
  import { computeSuggestKind } from "./kind-suggest.js";
15
15
  import { isModuleKind } from "./module-kinds.js";
16
16
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
17
+ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
17
18
  import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
18
19
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
19
20
  import {
@@ -36,6 +37,39 @@ import { validateThrowsCoverage } from "./validate-throws-coverage.js";
36
37
 
37
38
  const SELF_PREFIX = "Self.";
38
39
 
40
+ /**
41
+ * `StaticAnalyzer.analyze()` requires `metadata.source` (non-empty) and
42
+ * `metadata.sourceLine` (number) on every non-system manifest — see the
43
+ * JSDoc on `analyze()`. Production callers stamp these via the `Loader` /
44
+ * `flattenForAnalyzer` / `emitDocsFor` paths; programmatic callers (tests,
45
+ * scripts) should pre-process inputs with `withSyntheticPositions(...)`.
46
+ * Surfacing the violation here turns silent dedup misbehaviour into a
47
+ * loud, actionable error.
48
+ */
49
+ function assertManifestPositions(manifests: ResourceManifest[]): void {
50
+ for (let i = 0; i < manifests.length; i++) {
51
+ const m = manifests[i];
52
+ if (REF_VALIDATION_SKIP_KINDS.has(m.kind)) continue;
53
+ const meta = m.metadata as { source?: string; sourceLine?: number } | undefined;
54
+ const okSource = typeof meta?.source === "string" && meta.source.length > 0;
55
+ const okLine = typeof meta?.sourceLine === "number";
56
+ if (okSource && okLine) continue;
57
+ const label = `${m.kind}/${m.metadata?.name ?? "(unnamed)"}`;
58
+ const missing = [
59
+ !okSource ? "metadata.source" : null,
60
+ !okLine ? "metadata.sourceLine" : null,
61
+ ]
62
+ .filter(Boolean)
63
+ .join(" and ");
64
+ throw new Error(
65
+ `StaticAnalyzer.analyze(): manifest #${i} (${label}) is missing ${missing}. ` +
66
+ `Real callers stamp positions automatically; programmatic callers ` +
67
+ `(tests, ad-hoc scripts) should pass inputs through ` +
68
+ `\`withSyntheticPositions(manifests)\` before calling analyze().`,
69
+ );
70
+ }
71
+ }
72
+
39
73
  /** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
40
74
  * to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
41
75
  * the magic alias for "this library's own module" — and other prefixes
@@ -496,11 +530,27 @@ export class StaticAnalyzer {
496
530
  this.celEnv = buildCelEnvironment(options.celHandlers);
497
531
  }
498
532
 
533
+ /**
534
+ * Run static analysis over a flattened manifest list.
535
+ *
536
+ * **Contract**: every non-system manifest (anything outside `Telo.Definition`,
537
+ * `Telo.Abstract`) must carry `metadata.source` (non-empty string) and
538
+ * `metadata.sourceLine` (number). The dedup that backs
539
+ * `DUPLICATE_RESOURCE_NAME` reads those fields to tell a pipeline echo
540
+ * apart from a genuine collision, and downstream diagnostic positioning
541
+ * depends on them too. Real callers stamp positions already (the `Loader`,
542
+ * `flattenForAnalyzer`, the telo-editor's `emitDocsFor`, the VSCode
543
+ * extension). Programmatic callers — tests, ad-hoc scripts — should pass
544
+ * their inputs through `withSyntheticPositions(...)` before calling
545
+ * `analyze()`. A missing position throws a clear error rather than
546
+ * silently producing wrong diagnostics.
547
+ */
499
548
  analyze(
500
549
  manifests: ResourceManifest[],
501
550
  options?: AnalysisOptions,
502
551
  registry?: AnalysisRegistry,
503
552
  ): AnalysisDiagnostic[] {
553
+ assertManifestPositions(manifests);
504
554
  const diagnostics: AnalysisDiagnostic[] = [];
505
555
 
506
556
  // Use pre-seeded registries from the provided AnalysisRegistry, or create fresh ones.
@@ -80,6 +80,21 @@ export class DefinitionRegistry {
80
80
  * @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
81
81
  * @param moduleName The module's metadata.name (e.g. "pipeline", "http-server"). */
82
82
  registerModuleIdentity(namespace: string | null, moduleName: string): void {
83
+ // The "telo" identity is reserved for the Telo built-in module and gets
84
+ // populated automatically when a Telo.Abstract definition registers (see
85
+ // `register` below). A user app / library without a namespace must NOT
86
+ // claim it — silently overwriting the built-in entry breaks every
87
+ // x-telo-ref that resolves through "telo#…". Concretely, the
88
+ // `Http.Api.routes[].handler` slot in the http-server schema carries
89
+ // `x-telo-ref: "telo#Invocable"`. If the entry application is, say,
90
+ // `Telo.Application/HelloApi` (no namespace), this method previously
91
+ // overwrote `"telo" → "Telo"` with `"telo" → "HelloApi"`. The handler's
92
+ // ref then resolved to a nonexistent `HelloApi.Invocable`, the
93
+ // kind-mismatch check inside `validate-references.ts` short-circuited
94
+ // on partial context, and the analyzer reported zero issues for a
95
+ // manifest that explodes at runtime. Skip non-Telo no-namespace modules;
96
+ // they have no x-telo-ref identity to declare anyway.
97
+ if (!namespace && moduleName !== "Telo") return;
83
98
  const identity = namespace ? `${namespace}/${moduleName}` : "telo";
84
99
  this.identityMap.set(identity, moduleName);
85
100
  this.reverseIdentityMap.set(moduleName, identity);
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export {
24
24
  export type { DocumentPosition } from "./position-metadata.js";
25
25
  export { HttpSource } from "./sources/http-source.js";
26
26
  export { RegistrySource } from "./sources/registry-source.js";
27
+ export { withSyntheticPositions } from "./with-synthetic-positions.js";
27
28
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
28
29
  export type {
29
30
  AnalysisDiagnostic,
@@ -72,13 +72,88 @@ export function validateReferences(
72
72
  const aliasesByModule = context.aliasesByModule;
73
73
  if (!aliases || !registry) return diagnostics;
74
74
 
75
- // Build outer resource lookup by name for resolution check.
76
- // Exclude system kinds (Telo.Definition) they are type blueprints, not instances,
77
- // and their names (e.g. "Server", "Job") would shadow user-defined resource instances.
78
- const byName = new Map<string, ResourceManifest>();
75
+ // Build outer resource lookup by name for resolution check, collecting
76
+ // every entry per name so we can surface name collisions as diagnostics
77
+ // (the kernel's resource registry shares one namespace across all
78
+ // non-system kinds e.g. `Telo.Application HelloApi` and `Http.Api
79
+ // HelloApi` collide at boot with `ERR_DUPLICATE_RESOURCE`. Catching it
80
+ // statically removes a class of "everything analyzes clean, then the
81
+ // kernel refuses to start" surprises.)
82
+ //
83
+ // Telo.Import is excluded from the duplicate check on top of the
84
+ // SYSTEM_KINDS skip: its `metadata.name` is an alias, not a resource
85
+ // identity (aliases live in a separate namespace from resources, and
86
+ // colliding aliases vs. resource names is benign — the alias is only
87
+ // ever read as a kind prefix).
88
+ // Group manifests by name to detect collisions. Two subtleties:
89
+ //
90
+ // 1. Some analyzer hosts emit the SAME physical document twice through
91
+ // their pipeline — e.g. the telo-editor's `toAnalysisManifests` walks
92
+ // each workspace module's documents independently, and a file
93
+ // reachable from two angles (entry module + `include:` partial)
94
+ // shows up twice. The fingerprint includes `sourceLine` so identical
95
+ // docs (same kind, name, source, AND source line) collapse to one,
96
+ // while two textually-separate documents in the same file (different
97
+ // source lines) keep separate fingerprints and trip the diagnostic.
98
+ // 2. The diagnostic carries a precomputed `range` pointing at the
99
+ // duplicate's source line — editor hosts that resolve diagnostic
100
+ // positions via a `${file}::${kind}::${name}` lookup would otherwise
101
+ // collide on duplicates (Map.set overwrites) and place the squiggle
102
+ // ambiguously. The explicit `range` short-circuits that lookup.
103
+ // Dedup pipeline echoes — the same physical document emitted twice
104
+ // through an analyzer host's pipeline. Keyed on (kind, name, source,
105
+ // sourceLine), so two textually-distinct docs in the same file (same
106
+ // source, different sourceLine) keep separate fingerprints and still
107
+ // trip the diagnostic. `analyze()` enforces that every non-system
108
+ // manifest carries both positional fields — no defensive guard needed.
109
+ const byNameAll = new Map<string, ResourceManifest[]>();
110
+ const seen = new Set<string>();
79
111
  for (const r of resources) {
80
- if (r.metadata?.name && !SYSTEM_KINDS.has(r.kind)) byName.set(r.metadata.name as string, r);
112
+ if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import") continue;
113
+ const name = r.metadata.name as string;
114
+ // `analyze()` guarantees both fields are present on non-system manifests.
115
+ const meta = r.metadata as unknown as { source: string; sourceLine: number };
116
+ const fingerprint = `${r.kind} ${name} ${meta.source} ${meta.sourceLine}`;
117
+ if (seen.has(fingerprint)) continue;
118
+ seen.add(fingerprint);
119
+ const existing = byNameAll.get(name);
120
+ if (existing) existing.push(r);
121
+ else byNameAll.set(name, [r]);
81
122
  }
123
+ for (const [name, list] of byNameAll) {
124
+ if (list.length <= 1) continue;
125
+ const [first, ...rest] = list;
126
+ const firstLabel = `${first.kind}/${name}`;
127
+ for (const dup of rest) {
128
+ const dupMeta = dup.metadata as { source?: string; sourceLine?: number } | undefined;
129
+ const range =
130
+ typeof dupMeta?.sourceLine === "number"
131
+ ? {
132
+ start: { line: dupMeta.sourceLine, character: 0 },
133
+ end: { line: dupMeta.sourceLine, character: Number.MAX_SAFE_INTEGER },
134
+ }
135
+ : undefined;
136
+ diagnostics.push({
137
+ severity: DiagnosticSeverity.Error,
138
+ code: "DUPLICATE_RESOURCE_NAME",
139
+ source: SOURCE,
140
+ message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
141
+ ...(range ? { range } : {}),
142
+ data: {
143
+ resource: { kind: dup.kind, name },
144
+ filePath: dupMeta?.source,
145
+ path: "metadata.name",
146
+ },
147
+ });
148
+ }
149
+ }
150
+ // Single-resource map for the resolution / scope lookups below — when a
151
+ // collision exists, falling back to the first occurrence keeps the rest
152
+ // of the pass behaving the same as before the duplicate diagnostic was
153
+ // added (resolution still finds *something*; the duplicate diagnostic
154
+ // is what surfaces the underlying problem to the user).
155
+ const byName = new Map<string, ResourceManifest>();
156
+ for (const [name, list] of byNameAll) byName.set(name, list[0]);
82
157
 
83
158
  for (const r of resources) {
84
159
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
@@ -0,0 +1,48 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
3
+
4
+ /**
5
+ * Stamp `metadata.source` and `metadata.sourceLine` on every non-system
6
+ * manifest that lacks them, returning a new array with cloned `metadata`
7
+ * objects for the affected entries.
8
+ *
9
+ * `StaticAnalyzer.analyze()` requires position info on every non-system
10
+ * manifest (the dedup that backs `DUPLICATE_RESOURCE_NAME` reads
11
+ * `(source, sourceLine)` to distinguish pipeline echoes from real
12
+ * collisions). Production callers — the `Loader`, `flattenForAnalyzer`,
13
+ * the telo-editor's `emitDocsFor`, the VSCode extension — all stamp
14
+ * positions already. This helper is the escape hatch for **programmatic
15
+ * callers** (tests, ad-hoc scripts) that construct `ResourceManifest`
16
+ * literals without going through a loader: it gives every otherwise-naked
17
+ * manifest a synthetic, deterministic position so the analyzer's
18
+ * invariant holds without each test having to spell positions out.
19
+ *
20
+ * The synthetic source defaults to `"<programmatic>"` — override via
21
+ * `source` when a stable, recognisable label helps diagnostic output.
22
+ * Each unstamped manifest gets a unique `sourceLine` (1-based array
23
+ * index) so two real duplicates supplied without positions retain
24
+ * distinct fingerprints and still trip `DUPLICATE_RESOURCE_NAME`.
25
+ *
26
+ * Manifests that already carry `metadata.source` and `metadata.sourceLine`
27
+ * pass through unchanged.
28
+ */
29
+ export function withSyntheticPositions(
30
+ manifests: ResourceManifest[],
31
+ source: string = "<programmatic>",
32
+ ): ResourceManifest[] {
33
+ return manifests.map((m, i) => {
34
+ if (REF_VALIDATION_SKIP_KINDS.has(m.kind)) return m;
35
+ const meta = m.metadata as { source?: string; sourceLine?: number } | undefined;
36
+ const hasSource = typeof meta?.source === "string" && meta.source.length > 0;
37
+ const hasLine = typeof meta?.sourceLine === "number";
38
+ if (hasSource && hasLine) return m;
39
+ return {
40
+ ...m,
41
+ metadata: {
42
+ ...m.metadata,
43
+ source: hasSource ? meta!.source : source,
44
+ sourceLine: hasLine ? meta!.sourceLine : i,
45
+ },
46
+ } as ResourceManifest;
47
+ });
48
+ }