@telorun/analyzer 1.4.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
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,CAkZtB"}
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"}
@@ -73,11 +73,39 @@ export function validateReferences(resources, context) {
73
73
  // identity (aliases live in a separate namespace from resources, and
74
74
  // colliding aliases vs. resource names is benign — the alias is only
75
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.
76
97
  const byNameAll = new Map();
98
+ const seen = new Set();
77
99
  for (const r of resources) {
78
100
  if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
79
101
  continue;
80
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);
81
109
  const existing = byNameAll.get(name);
82
110
  if (existing)
83
111
  existing.push(r);
@@ -90,14 +118,22 @@ export function validateReferences(resources, context) {
90
118
  const [first, ...rest] = list;
91
119
  const firstLabel = `${first.kind}/${name}`;
92
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;
93
128
  diagnostics.push({
94
129
  severity: DiagnosticSeverity.Error,
95
130
  code: "DUPLICATE_RESOURCE_NAME",
96
131
  source: SOURCE,
97
132
  message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
133
+ ...(range ? { range } : {}),
98
134
  data: {
99
135
  resource: { kind: dup.kind, name },
100
- filePath: dup.metadata?.source,
136
+ filePath: dupMeta?.source,
101
137
  path: "metadata.name",
102
138
  },
103
139
  });
@@ -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.4.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.
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,
@@ -85,10 +85,37 @@ export function validateReferences(
85
85
  // identity (aliases live in a separate namespace from resources, and
86
86
  // colliding aliases vs. resource names is benign — the alias is only
87
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.
88
109
  const byNameAll = new Map<string, ResourceManifest[]>();
110
+ const seen = new Set<string>();
89
111
  for (const r of resources) {
90
112
  if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import") continue;
91
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);
92
119
  const existing = byNameAll.get(name);
93
120
  if (existing) existing.push(r);
94
121
  else byNameAll.set(name, [r]);
@@ -98,14 +125,23 @@ export function validateReferences(
98
125
  const [first, ...rest] = list;
99
126
  const firstLabel = `${first.kind}/${name}`;
100
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;
101
136
  diagnostics.push({
102
137
  severity: DiagnosticSeverity.Error,
103
138
  code: "DUPLICATE_RESOURCE_NAME",
104
139
  source: SOURCE,
105
140
  message: `${dup.kind}/${name}: resource name collides with ${firstLabel} declared earlier (kernel runtime would fail with ERR_DUPLICATE_RESOURCE)`,
141
+ ...(range ? { range } : {}),
106
142
  data: {
107
143
  resource: { kind: dup.kind, name },
108
- filePath: (dup.metadata as { source?: string } | undefined)?.source,
144
+ filePath: dupMeta?.source,
109
145
  path: "metadata.name",
110
146
  },
111
147
  });
@@ -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
+ }