@telorun/analyzer 0.11.0 → 0.12.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.
Files changed (92) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +3 -3
  3. package/dist/adapters/http-adapter.d.ts +10 -0
  4. package/dist/adapters/http-adapter.d.ts.map +1 -0
  5. package/dist/adapters/http-adapter.js +18 -0
  6. package/dist/adapters/node-adapter.d.ts +17 -0
  7. package/dist/adapters/node-adapter.d.ts.map +1 -0
  8. package/dist/adapters/node-adapter.js +71 -0
  9. package/dist/adapters/registry-adapter.d.ts +15 -0
  10. package/dist/adapters/registry-adapter.d.ts.map +1 -0
  11. package/dist/adapters/registry-adapter.js +53 -0
  12. package/dist/analysis-registry.d.ts +7 -0
  13. package/dist/analysis-registry.d.ts.map +1 -1
  14. package/dist/analysis-registry.js +38 -0
  15. package/dist/analyzer.d.ts +15 -0
  16. package/dist/analyzer.d.ts.map +1 -1
  17. package/dist/analyzer.js +114 -10
  18. package/dist/builtins.d.ts.map +1 -1
  19. package/dist/builtins.js +58 -1
  20. package/dist/definition-registry.d.ts.map +1 -1
  21. package/dist/definition-registry.js +16 -0
  22. package/dist/dependency-graph.d.ts.map +1 -1
  23. package/dist/dependency-graph.js +27 -13
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -0
  27. package/dist/kernel-globals.d.ts.map +1 -1
  28. package/dist/kernel-globals.js +9 -11
  29. package/dist/manifest-loader.d.ts +23 -1
  30. package/dist/manifest-loader.d.ts.map +1 -1
  31. package/dist/manifest-loader.js +66 -3
  32. package/dist/normalize-inline-resources.d.ts.map +1 -1
  33. package/dist/normalize-inline-resources.js +26 -14
  34. package/dist/position-metadata.d.ts +11 -2
  35. package/dist/position-metadata.d.ts.map +1 -1
  36. package/dist/position-metadata.js +18 -3
  37. package/dist/precompile.d.ts.map +1 -1
  38. package/dist/precompile.js +9 -1
  39. package/dist/reference-field-map.d.ts +21 -4
  40. package/dist/reference-field-map.d.ts.map +1 -1
  41. package/dist/reference-field-map.js +93 -25
  42. package/dist/residual-schema.d.ts +23 -0
  43. package/dist/residual-schema.d.ts.map +1 -0
  44. package/dist/residual-schema.js +45 -0
  45. package/dist/resolve-ref-sentinels.d.ts +27 -0
  46. package/dist/resolve-ref-sentinels.d.ts.map +1 -0
  47. package/dist/resolve-ref-sentinels.js +114 -0
  48. package/dist/rewrite-synthetic-origins.d.ts +10 -0
  49. package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
  50. package/dist/rewrite-synthetic-origins.js +55 -0
  51. package/dist/schema-compat.d.ts +7 -1
  52. package/dist/schema-compat.d.ts.map +1 -1
  53. package/dist/schema-compat.js +19 -2
  54. package/dist/system-kinds.d.ts +25 -0
  55. package/dist/system-kinds.d.ts.map +1 -0
  56. package/dist/system-kinds.js +34 -0
  57. package/dist/types.d.ts +12 -0
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/validate-cel-context.d.ts +5 -0
  60. package/dist/validate-cel-context.d.ts.map +1 -1
  61. package/dist/validate-cel-context.js +27 -15
  62. package/dist/validate-provider-coherence.d.ts +23 -0
  63. package/dist/validate-provider-coherence.d.ts.map +1 -0
  64. package/dist/validate-provider-coherence.js +148 -0
  65. package/dist/validate-references.d.ts.map +1 -1
  66. package/dist/validate-references.js +141 -36
  67. package/dist/with-synthetic-positions.d.ts +28 -0
  68. package/dist/with-synthetic-positions.d.ts.map +1 -0
  69. package/dist/with-synthetic-positions.js +45 -0
  70. package/package.json +7 -4
  71. package/src/analysis-registry.ts +37 -0
  72. package/src/analyzer.ts +118 -12
  73. package/src/builtins.ts +58 -1
  74. package/src/definition-registry.ts +15 -0
  75. package/src/dependency-graph.ts +27 -14
  76. package/src/index.ts +2 -0
  77. package/src/kernel-globals.ts +9 -11
  78. package/src/manifest-loader.ts +69 -4
  79. package/src/normalize-inline-resources.ts +48 -13
  80. package/src/position-metadata.ts +18 -3
  81. package/src/precompile.ts +8 -1
  82. package/src/reference-field-map.ts +129 -24
  83. package/src/residual-schema.ts +49 -0
  84. package/src/resolve-ref-sentinels.ts +127 -0
  85. package/src/rewrite-synthetic-origins.ts +75 -0
  86. package/src/schema-compat.ts +19 -2
  87. package/src/system-kinds.ts +37 -0
  88. package/src/types.ts +12 -0
  89. package/src/validate-cel-context.ts +28 -15
  90. package/src/validate-provider-coherence.ts +166 -0
  91. package/src/validate-references.ts +138 -35
  92. package/src/with-synthetic-positions.ts +48 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -29,7 +29,8 @@
29
29
  "bun": "./src/index.ts",
30
30
  "import": "./dist/index.js",
31
31
  "default": "./dist/index.js"
32
- }
32
+ },
33
+ "./package.json": "./package.json"
33
34
  },
34
35
  "files": [
35
36
  "dist/**",
@@ -41,14 +42,16 @@
41
42
  "ajv-formats": "^3.0.1",
42
43
  "jsonpath-plus": "^10.3.0",
43
44
  "yaml": "^2.8.3",
44
- "@telorun/sdk": "0.11.1",
45
- "@telorun/templating": "0.2.3"
45
+ "@telorun/templating": "0.3.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8"
51
51
  },
52
+ "peerDependencies": {
53
+ "@telorun/sdk": "0.12.0"
54
+ },
52
55
  "scripts": {
53
56
  "build": "tsc -p tsconfig.lib.json",
54
57
  "test": "vitest run",
@@ -101,6 +101,43 @@ export class AnalysisRegistry {
101
101
  return computeSuggestKind(badKind, this.aliases, this.defs);
102
102
  }
103
103
 
104
+ /** Returns every user-facing (alias-form) kind that satisfies the given
105
+ * `x-telo-ref` constraint string (e.g. `"telo#Invocable"`, `"std/sql#Connection"`).
106
+ * Resolution mirrors `validateReferences.checkKind`: abstract targets expand to
107
+ * the set of definitions extending them; concrete targets yield just themselves.
108
+ * Returns `undefined` when the ref can't be resolved (e.g. unregistered identity),
109
+ * so callers can fall back to the unfiltered kind list. */
110
+ userFacingKindsForRef(xTeloRef: string): string[] | undefined {
111
+ const targetKind = this.defs.resolveRef(xTeloRef);
112
+ if (!targetKind) return undefined;
113
+ const targetDef = this.defs.resolve(targetKind);
114
+ if (!targetDef) return undefined;
115
+
116
+ const canonicalKinds: string[] = [];
117
+ if (targetDef.kind === "Telo.Abstract") {
118
+ for (const def of this.defs.getByExtends(targetKind)) {
119
+ const module = (def.metadata as { module?: string } | undefined)?.module;
120
+ if (module && def.metadata?.name) {
121
+ canonicalKinds.push(`${module}.${def.metadata.name as string}`);
122
+ }
123
+ }
124
+ } else {
125
+ canonicalKinds.push(targetKind);
126
+ }
127
+
128
+ const out = new Set<string>();
129
+ for (const kind of canonicalKinds) {
130
+ const dot = kind.indexOf(".");
131
+ if (dot === -1) continue;
132
+ const moduleName = kind.slice(0, dot);
133
+ const typeName = kind.slice(dot + 1);
134
+ for (const alias of this.aliases.aliasesFor(moduleName)) {
135
+ out.add(`${alias}.${typeName}`);
136
+ }
137
+ }
138
+ return Array.from(out);
139
+ }
140
+
104
141
  /** @internal Bridge for StaticAnalyzer — do not use outside the analyzer package. */
105
142
  _context(): AnalysisContext {
106
143
  return { aliases: this.aliases, definitions: this.defs, aliasesByModule: this.aliasesByModule };
package/src/analyzer.ts CHANGED
@@ -14,6 +14,9 @@ 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";
18
+ import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
19
+ import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
17
20
  import {
18
21
  celTypeSatisfiesJsonSchema,
19
22
  substituteCelFields,
@@ -28,11 +31,45 @@ import {
28
31
  resolveTypeFieldToSchema,
29
32
  } from "./validate-cel-context.js";
30
33
  import { validateExtends } from "./validate-extends.js";
34
+ import { validateProviderCoherence } from "./validate-provider-coherence.js";
31
35
  import { validateReferences } from "./validate-references.js";
32
36
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
33
37
 
34
38
  const SELF_PREFIX = "Self.";
35
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
+
36
73
  /** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
37
74
  * to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
38
75
  * the magic alias for "this library's own module" — and other prefixes
@@ -493,11 +530,27 @@ export class StaticAnalyzer {
493
530
  this.celEnv = buildCelEnvironment(options.celHandlers);
494
531
  }
495
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
+ */
496
548
  analyze(
497
549
  manifests: ResourceManifest[],
498
550
  options?: AnalysisOptions,
499
551
  registry?: AnalysisRegistry,
500
552
  ): AnalysisDiagnostic[] {
553
+ assertManifestPositions(manifests);
501
554
  const diagnostics: AnalysisDiagnostic[] = [];
502
555
 
503
556
  // Use pre-seeded registries from the provided AnalysisRegistry, or create fresh ones.
@@ -621,6 +674,22 @@ export class StaticAnalyzer {
621
674
  // Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
622
675
  const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
623
676
 
677
+ // Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
678
+ // {kind, name} objects so downstream phases (validation, dependency graph,
679
+ // kernel controllers) see a uniform shape. Runs after normalize so both
680
+ // original and inline-extracted manifests have their sentinels resolved.
681
+ resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
682
+
683
+ // Trusted-input fast path: when the caller has already attested that
684
+ // this exact manifest set passes analysis (e.g. via the kernel's
685
+ // hash-stamped `.validated.json` cache), skip the validation walk.
686
+ // Registration of identities / aliases / definitions and inline-resource
687
+ // normalisation have already run above; that's all downstream
688
+ // consumers (prepare, init loop) require.
689
+ if (options?.skipValidation) {
690
+ return diagnostics;
691
+ }
692
+
624
693
  // Build a name→manifest map for looking up referenced resources
625
694
  const byName = new Map<string, ResourceManifest>();
626
695
  for (const m of allManifests) {
@@ -629,6 +698,35 @@ export class StaticAnalyzer {
629
698
  }
630
699
  }
631
700
 
701
+ // Library env: rejection — `env:` on a Library `variables` / `secrets`
702
+ // entry is forbidden. The Library entry schema is otherwise open so that
703
+ // any JSON Schema property schema is valid; this targeted check produces
704
+ // a clear diagnostic instead of a generic "additional property" error.
705
+ for (const m of allManifests) {
706
+ if (m.kind !== "Telo.Library") continue;
707
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
708
+ const moduleName = m.metadata?.name as string | undefined;
709
+ const resource = moduleName ? { kind: m.kind, name: moduleName } : undefined;
710
+ for (const block of ["variables", "secrets"] as const) {
711
+ const entries = (m as Record<string, any>)[block];
712
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
713
+ for (const [entryName, entry] of Object.entries(entries)) {
714
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
715
+ if ("env" in (entry as Record<string, unknown>)) {
716
+ diagnostics.push({
717
+ severity: DiagnosticSeverity.Error,
718
+ code: "LIBRARY_ENV_KEY_REJECTED",
719
+ source: SOURCE,
720
+ message:
721
+ `Telo.Library ${block}/${entryName}: 'env:' is only permitted on Telo.Application entries. ` +
722
+ `Libraries must receive values from importers via the parent manifest's variables / secrets block.`,
723
+ data: { resource, filePath, path: `${block}.${entryName}.env` },
724
+ });
725
+ }
726
+ }
727
+ }
728
+ }
729
+
632
730
  // Build typed kernel globals schema so x-telo-context chain validation
633
731
  // recognises variables, secrets, resources, env automatically
634
732
  const kernelGlobals = buildKernelGlobalsSchema(allManifests);
@@ -776,17 +874,15 @@ export class StaticAnalyzer {
776
874
  }
777
875
  }
778
876
 
779
- // Top-level `result:` (a sibling, only meaningful with `provide:`) is a
780
- // post-call mapping that must satisfy the abstract this definition
781
- // `extends` (`outputType`). The target's outputType lives on `provide.kind`
877
+ // Top-level `result:` is a post-call mapping that must satisfy the abstract
878
+ // this definition `extends` (`outputType`). It's a sibling of whichever
879
+ // dispatch entry-point declared a kind-typed target (`provide:` or
880
+ // `invoke:`). The target's outputType lives on the dispatcher's `kind`
782
881
  // and is what `result` is typed against *inside* CEL — separate role.
783
- if (
784
- provide &&
785
- typeof provide === "object" &&
786
- !Array.isArray(provide) &&
787
- md.result &&
788
- typeof md.result === "object"
789
- ) {
882
+ const hasDispatchObject =
883
+ (provide && typeof provide === "object" && !Array.isArray(provide)) ||
884
+ (invoke && typeof invoke === "object" && !Array.isArray(invoke));
885
+ if (hasDispatchObject && md.result && typeof md.result === "object") {
790
886
  const extendsValue = md.extends as string | undefined;
791
887
  if (typeof extendsValue === "string" && extendsValue.length > 0) {
792
888
  const abstractSchema = lookupDefinitionTypeField(
@@ -920,10 +1016,15 @@ export class StaticAnalyzer {
920
1016
  // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
921
1017
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
922
1018
 
1019
+ // Validate provider coherence rules for `provide:` template-target definitions.
1020
+ diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
1021
+
923
1022
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
924
1023
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
925
1024
 
926
- return diagnostics;
1025
+ // Reroute diagnostics on synthetic (inline-extracted) resources back to
1026
+ // the chain root so position-index lookups land on the parent doc.
1027
+ return rewriteSyntheticOrigins(diagnostics, allManifests);
927
1028
  }
928
1029
 
929
1030
  analyzeErrors(
@@ -938,12 +1039,17 @@ export class StaticAnalyzer {
938
1039
 
939
1040
  normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[] {
940
1041
  const ctx = registry._context();
941
- return normalizeInlineResources(
1042
+ const normalized = normalizeInlineResources(
942
1043
  manifests,
943
1044
  ctx.definitions!,
944
1045
  ctx.aliases,
945
1046
  ctx.aliasesByModule,
946
1047
  );
1048
+ // Resolve !ref sentinels after normalize so both the original and
1049
+ // inline-extracted manifests get their refs canonicalized to
1050
+ // {kind, name} for the kernel that consumes this output.
1051
+ resolveRefSentinels(normalized, ctx.definitions!, ctx.aliases, ctx.aliasesByModule);
1052
+ return normalized;
947
1053
  }
948
1054
 
949
1055
  prepare(
package/src/builtins.ts CHANGED
@@ -149,7 +149,12 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
149
149
  additionalProperties: false,
150
150
  properties: {
151
151
  self: { "x-telo-context-from-root": "schema" },
152
- result: { "x-telo-context-from-ref-kind": "provide/kind#outputType" },
152
+ result: {
153
+ "x-telo-context-from-ref-kind": [
154
+ "provide/kind#outputType",
155
+ "invoke/kind#outputType",
156
+ ],
157
+ },
153
158
  },
154
159
  },
155
160
  },
@@ -215,6 +220,20 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
215
220
  anyOf: [
216
221
  { type: "string", "x-telo-ref": "telo#Runnable" },
217
222
  { type: "string", "x-telo-ref": "telo#Service" },
223
+ // Post-resolution shape that `resolveRefSentinels`
224
+ // substitutes a `!ref <name>` sentinel into. The
225
+ // adjacent `x-telo-ref` constraints govern the kind
226
+ // check; this branch only admits the structural form so
227
+ // AJV doesn't reject a resolved ref.
228
+ {
229
+ type: "object",
230
+ required: ["kind", "name"],
231
+ properties: {
232
+ kind: { type: "string" },
233
+ name: { type: "string" },
234
+ },
235
+ additionalProperties: true,
236
+ },
218
237
  ],
219
238
  },
220
239
  },
@@ -222,6 +241,44 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
222
241
  type: "array",
223
242
  items: { type: "string" },
224
243
  },
244
+ // Application-level environment contract. Each entry layers `env:`
245
+ // (required, names the source env var) and `default:` (optional, used
246
+ // when the env var is unset) on top of an open JSON Schema property
247
+ // schema. `type:` constrains the coercion rule applied to the raw env
248
+ // string (scalars per-type; `object` / `array` via JSON.parse with the
249
+ // matching top-level type). All other JSON Schema keywords are passed
250
+ // through unchanged and applied to the coerced value via the standard
251
+ // schema validator. See kernel/nodejs/src/application-env.ts.
252
+ variables: {
253
+ type: "object",
254
+ additionalProperties: {
255
+ type: "object",
256
+ required: ["env", "type"],
257
+ properties: {
258
+ env: { type: "string" },
259
+ type: {
260
+ type: "string",
261
+ enum: ["string", "integer", "number", "boolean", "object", "array"],
262
+ },
263
+ default: {},
264
+ },
265
+ },
266
+ },
267
+ secrets: {
268
+ type: "object",
269
+ additionalProperties: {
270
+ type: "object",
271
+ required: ["env", "type"],
272
+ properties: {
273
+ env: { type: "string" },
274
+ type: {
275
+ type: "string",
276
+ enum: ["string", "integer", "number", "boolean", "object", "array"],
277
+ },
278
+ default: {},
279
+ },
280
+ },
281
+ },
225
282
  },
226
283
  required: ["metadata"],
227
284
  additionalProperties: false,
@@ -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);
@@ -1,7 +1,9 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
2
3
  import type { AliasResolver } from "./alias-resolver.js";
3
4
  import type { DefinitionRegistry } from "./definition-registry.js";
4
5
  import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
6
+ import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
5
7
 
6
8
  export interface ResourceNode {
7
9
  kind: string;
@@ -17,16 +19,6 @@ export interface DependencyGraph {
17
19
  cycle?: ReadonlyArray<ResourceNode>;
18
20
  }
19
21
 
20
- /** System resource kinds that are not runtime nodes in the dependency graph.
21
- * Module-identity docs (Telo.Application, Telo.Library) are intentionally
22
- * not in this set: an Application's `targets` use `x-telo-ref` to real
23
- * Runnable/Service resources, so the Application legitimately depends on
24
- * them in boot order — modeling that as a graph edge is correct. A Library
25
- * has no `targets`, so it becomes a zero-edge node, which is harmless.
26
- * If the graph is ever consumed as "things to init", skip these kinds at
27
- * the consumer site; the controller already runs them separately. */
28
- const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Import"]);
29
-
30
22
  const nodeKey = (kind: string, name: string) => `${kind}\0${name}`;
31
23
 
32
24
  /**
@@ -47,12 +39,18 @@ export function buildDependencyGraph(
47
39
  aliases?: AliasResolver,
48
40
  aliasesByModule?: Map<string, AliasResolver>,
49
41
  ): DependencyGraph {
50
- // --- Build node set ---
42
+ // --- Build node set + name index ---
51
43
  const nodes = new Map<string, ResourceNode>();
44
+ // Sentinel lookup (`!ref <name>`) needs to resolve a bare name to its
45
+ // declared kind. Names are unique within a manifest scope, so a flat
46
+ // map suffices and lets the sentinel branch below avoid a full
47
+ // O(N) scan of the node set on every reference.
48
+ const nodesByName = new Map<string, ResourceNode>();
52
49
  for (const r of resources) {
53
50
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
54
- const key = nodeKey(r.kind, r.metadata.name as string);
55
- nodes.set(key, { kind: r.kind, name: r.metadata.name as string });
51
+ const node = { kind: r.kind, name: r.metadata.name as string };
52
+ nodes.set(nodeKey(node.kind, node.name), node);
53
+ nodesByName.set(node.name, node);
56
54
  }
57
55
 
58
56
  // --- Build adjacency: from → deps (from depends on dep) ---
@@ -90,7 +88,22 @@ export function buildDependencyGraph(
90
88
  if (!isRefEntry(entry)) continue;
91
89
 
92
90
  for (const val of resolveFieldValues(r, fieldPath)) {
93
- if (!val || typeof val !== "object") continue;
91
+ if (!val) continue;
92
+
93
+ // `!ref <name>` sentinel — look up the target's kind from the
94
+ // name (resources are unique by name) so the edge carries the
95
+ // concrete kind, matching the {kind, name} edge shape below.
96
+ if (isRefSentinel(val)) {
97
+ const refName = val.source;
98
+ if (scopedNames.has(refName)) continue;
99
+ const node = nodesByName.get(refName);
100
+ if (node) {
101
+ deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
102
+ }
103
+ continue;
104
+ }
105
+
106
+ if (typeof val !== "object") continue;
94
107
  const ref = val as Record<string, unknown>;
95
108
  if (!ref.kind || !ref.name) continue;
96
109
  // Edges to scoped resources are runtime deps, not boot-time deps — exclude from DAG
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
14
14
  export type { ModuleKind } from "./module-kinds.js";
15
15
  export { parseLoadedFile } from "./parse-loaded-file.js";
16
16
  export type { ParseOptions } from "./parse-loaded-file.js";
17
+ export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.js";
17
18
  export {
18
19
  buildDocumentPositions,
19
20
  buildLineOffsets,
@@ -23,6 +24,7 @@ export {
23
24
  export type { DocumentPosition } from "./position-metadata.js";
24
25
  export { HttpSource } from "./sources/http-source.js";
25
26
  export { RegistrySource } from "./sources/registry-source.js";
27
+ export { withSyntheticPositions } from "./with-synthetic-positions.js";
26
28
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
27
29
  export type {
28
30
  AnalysisDiagnostic,
@@ -1,4 +1,5 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
+ import { residualEntrySchemaMap } from "./residual-schema.js";
2
3
 
3
4
  /**
4
5
  * Kernel global names available in every CEL evaluation context at runtime.
@@ -72,20 +73,17 @@ export function buildKernelGlobalsSchema(
72
73
  }
73
74
 
74
75
  /** Wrap a JSON Schema property map (like `Telo.Application.variables`) into a
75
- * closed object schema suitable for chain-access validation. Falls back to
76
- * an open map when the module declares no variables/secrets. */
76
+ * closed object schema suitable for chain-access validation. For Application
77
+ * entries the per-entry shape carries kernel-specific keys (`env`, `default`)
78
+ * on top of an otherwise-standard JSON Schema property schema; those keys are
79
+ * stripped via `residualEntrySchemaMap` so CEL sees the coerced shape, not
80
+ * the env-binding wrapper. Library entries are pure JSON Schema property
81
+ * schemas and pass through the same call unchanged. Falls back to an open map
82
+ * when the module declares no variables/secrets. */
77
83
  function buildSchemaMapSchema(
78
84
  schemaMap: Record<string, any> | null | undefined,
79
85
  ): Record<string, any> {
80
- if (!schemaMap || typeof schemaMap !== "object" || Array.isArray(schemaMap)) {
81
- return { type: "object", additionalProperties: true };
82
- }
83
- const props: Record<string, any> = {};
84
- for (const [key, value] of Object.entries(schemaMap)) {
85
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
86
- props[key] = value;
87
- }
88
- }
86
+ const props = residualEntrySchemaMap(schemaMap);
89
87
  if (Object.keys(props).length === 0) {
90
88
  return { type: "object", additionalProperties: true };
91
89
  }
@@ -33,6 +33,14 @@ export class Loader {
33
33
  * get distinct entries, so neither sees the wrong manifest tree. */
34
34
  private readonly fileCache = new Map<string, LoadedFile>();
35
35
 
36
+ /** requestUrl → canonical `source`. Lets `loadFile` skip the source read
37
+ * when a URL it has already canonicalised is requested again — kernel
38
+ * load → boot and the import-controller each ask the loader for the same
39
+ * modules. Without this fast path every duplicate request re-runs the
40
+ * source's `read()` (a `fetch` for `RegistrySource`, a disk read for
41
+ * `LocalFileSource`). */
42
+ private readonly urlToSource = new Map<string, string>();
43
+
36
44
  protected sources: ManifestSource[];
37
45
  private readonly celEnv: Environment;
38
46
 
@@ -67,8 +75,22 @@ export class Loader {
67
75
  }
68
76
 
69
77
  async resolveEntryPoint(url: string): Promise<string> {
70
- const { source } = await this.pick(url).read(url);
71
- return source;
78
+ // Route through `loadFile` so the resolved source URL and parsed
79
+ // entry are populated in `urlToSource` + `fileCache` in one read.
80
+ // Callers (kernel.load) immediately call `loadGraph(entryUrl)`
81
+ // afterwards — without this priming, the entry file would be read
82
+ // twice (twice over the network for `RegistrySource`).
83
+ const file = await this.loadFile(url);
84
+ return file.source;
85
+ }
86
+
87
+ /** Returns the canonical source URL the loader has already mapped `url`
88
+ * to during a prior `loadFile`/`loadModule`/`loadGraph` call, or
89
+ * `undefined` when the URL has not been seen. Callers use it to test
90
+ * set-membership against a previous graph walk's modules without
91
+ * triggering an extra source read. */
92
+ canonicalize(url: string): string | undefined {
93
+ return this.urlToSource.get(url);
72
94
  }
73
95
 
74
96
  // --- New API: returns LoadedFile / LoadedModule / LoadedGraph ----------
@@ -78,8 +100,42 @@ export class Loader {
78
100
  * private mutable copy must call `parseLoadedFile` directly with the
79
101
  * LoadedFile's `text`. */
80
102
  async loadFile(url: string, options?: LoadOptions): Promise<LoadedFile> {
103
+ const compileKey = options?.compile ? "compiled" : "raw";
104
+ const knownSource = this.urlToSource.get(url);
105
+ if (knownSource) {
106
+ const cached = this.fileCache.get(`${compileKey}:${knownSource}`);
107
+ if (cached) return cached;
108
+ // The other compile-mode entry is cached — reparse from its text
109
+ // instead of re-reading the source.
110
+ //
111
+ // NOTE for watch-mode reactivation (cli/nodejs/src/commands/run.ts
112
+ // currently has `setupWatchMode` commented out): this branch
113
+ // assumes file contents don't change underneath a single Loader.
114
+ // Reviving watch mode will need a public `invalidate(url)` (or
115
+ // similar) that drops both `urlToSource[url]` and the cached
116
+ // entries for its canonical source before the loader serves the
117
+ // file again.
118
+ const altKey = `${compileKey === "compiled" ? "raw" : "compiled"}:${knownSource}`;
119
+ const alt = this.fileCache.get(altKey);
120
+ if (alt) {
121
+ const reparsed = parseLoadedFile(knownSource, url, alt.text, {
122
+ compile: options?.compile,
123
+ celEnv: this.celEnv,
124
+ });
125
+ this.fileCache.set(`${compileKey}:${knownSource}`, reparsed);
126
+ return reparsed;
127
+ }
128
+ }
129
+
81
130
  const { text, source } = await this.pick(url).read(url);
82
- const cacheKey = `${options?.compile ? "compiled" : "raw"}:${source}`;
131
+ this.urlToSource.set(url, source);
132
+ // Also map the canonical source to itself so subsequent `loadFile`
133
+ // calls that already received a canonical URL — `kernel.load` passes
134
+ // the result of `resolveEntryPoint` to `loadGraph`, which then asks
135
+ // for that exact URL — hit the urlToSource fast path instead of
136
+ // falling through to a redundant `pick(url).read(url)`.
137
+ this.urlToSource.set(source, source);
138
+ const cacheKey = `${compileKey}:${source}`;
83
139
  const cached = this.fileCache.get(cacheKey);
84
140
  if (cached && cached.text === text) return cached;
85
141
 
@@ -224,7 +280,16 @@ export class Loader {
224
280
  return { rootSource, entry, modules, importEdges, errors };
225
281
  }
226
282
 
227
- private resolveImportUrl(fromSource: string, importSource: string): string {
283
+ /** Resolve an `import` URL against the file it appears in. Relative /
284
+ * absolute-path forms run through the owning `ManifestSource`'s
285
+ * `resolveRelative`; registry refs and full URLs pass through
286
+ * unchanged. Exposed so the import-controller (and any other
287
+ * caller-side resolver) lands on the *exact same* canonical URL the
288
+ * loader used when walking the entry graph — divergent resolution
289
+ * would silently break optimizations like `canonicalize()`-keyed
290
+ * cache hits whenever a non-trivial `ManifestSource.resolveRelative`
291
+ * is in play. */
292
+ resolveImportUrl(fromSource: string, importSource: string): string {
228
293
  if (importSource.startsWith(".") || importSource.startsWith("/")) {
229
294
  return this.pick(fromSource).resolveRelative(fromSource, importSource);
230
295
  }