@telorun/analyzer 0.67.0 → 0.69.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 (96) hide show
  1. package/dist/analysis-registry.d.ts +12 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +26 -0
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +23 -0
  6. package/dist/call-graph.d.ts +80 -1
  7. package/dist/call-graph.d.ts.map +1 -1
  8. package/dist/call-graph.js +145 -12
  9. package/dist/extends-resolution.d.ts +20 -0
  10. package/dist/extends-resolution.d.ts.map +1 -1
  11. package/dist/extends-resolution.js +29 -0
  12. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  13. package/dist/import-resolution-diagnostics.js +22 -8
  14. package/dist/index.d.ts +8 -7
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +6 -6
  17. package/dist/invocation-contract.d.ts +20 -0
  18. package/dist/invocation-contract.d.ts.map +1 -1
  19. package/dist/invocation-contract.js +74 -0
  20. package/dist/loaded-types.d.ts +4 -4
  21. package/dist/loaded-types.d.ts.map +1 -1
  22. package/dist/manifest-analysis.d.ts +19 -0
  23. package/dist/manifest-analysis.d.ts.map +1 -1
  24. package/dist/manifest-analysis.js +27 -0
  25. package/dist/manifest-loader.d.ts +3 -4
  26. package/dist/manifest-loader.d.ts.map +1 -1
  27. package/dist/manifest-loader.js +4 -5
  28. package/dist/manifest-schemas.d.ts +2 -0
  29. package/dist/manifest-schemas.d.ts.map +1 -1
  30. package/dist/manifest-schemas.js +4 -0
  31. package/dist/module-graph.d.ts +500 -0
  32. package/dist/module-graph.d.ts.map +1 -0
  33. package/dist/module-graph.js +1411 -0
  34. package/dist/reconcile-module-versions.d.ts.map +1 -1
  35. package/dist/reconcile-module-versions.js +10 -11
  36. package/dist/release/release-plan.d.ts +1 -1
  37. package/dist/resolve-zone-containment.d.ts +9 -1
  38. package/dist/resolve-zone-containment.d.ts.map +1 -1
  39. package/dist/resolve-zone-containment.js +34 -6
  40. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  41. package/dist/resolve-zone-requirements.js +4 -2
  42. package/dist/schema-keywords.d.ts +16 -1
  43. package/dist/schema-keywords.d.ts.map +1 -1
  44. package/dist/schema-keywords.js +20 -0
  45. package/dist/sources/default-sources.d.ts +6 -6
  46. package/dist/sources/default-sources.d.ts.map +1 -1
  47. package/dist/sources/default-sources.js +7 -8
  48. package/dist/sources/integrity.d.ts +3 -2
  49. package/dist/sources/integrity.d.ts.map +1 -1
  50. package/dist/sources/integrity.js +26 -3
  51. package/dist/sources/versioned-ref.d.ts +17 -12
  52. package/dist/sources/versioned-ref.d.ts.map +1 -1
  53. package/dist/sources/versioned-ref.js +22 -24
  54. package/dist/telo-version.d.ts +1 -1
  55. package/dist/telo-version.js +1 -1
  56. package/dist/validate-extends.d.ts.map +1 -1
  57. package/dist/validate-extends.js +42 -1
  58. package/dist/validate-sensitive-slots.d.ts +28 -0
  59. package/dist/validate-sensitive-slots.d.ts.map +1 -0
  60. package/dist/validate-sensitive-slots.js +75 -0
  61. package/dist/value-type-keyword.d.ts +1 -1
  62. package/dist/value-type-keyword.d.ts.map +1 -1
  63. package/dist/value-type-keyword.js +1 -0
  64. package/package.json +1 -1
  65. package/src/analysis-registry.ts +37 -0
  66. package/src/analyzer.ts +26 -0
  67. package/src/call-graph.ts +207 -14
  68. package/src/extends-resolution.ts +33 -0
  69. package/src/import-resolution-diagnostics.ts +24 -7
  70. package/src/index.ts +42 -5
  71. package/src/invocation-contract.ts +71 -0
  72. package/src/loaded-types.ts +4 -4
  73. package/src/manifest-analysis.ts +39 -0
  74. package/src/manifest-loader.ts +4 -5
  75. package/src/manifest-schemas.ts +4 -0
  76. package/src/module-graph.ts +2005 -0
  77. package/src/reconcile-module-versions.ts +10 -11
  78. package/src/release/release-plan.ts +1 -1
  79. package/src/resolve-zone-containment.ts +49 -9
  80. package/src/resolve-zone-requirements.ts +7 -2
  81. package/src/schema-keywords.ts +33 -1
  82. package/src/sources/default-sources.ts +7 -8
  83. package/src/sources/integrity.ts +28 -3
  84. package/src/sources/versioned-ref.ts +26 -28
  85. package/src/telo-version.ts +1 -1
  86. package/src/validate-extends.ts +51 -1
  87. package/src/validate-sensitive-slots.ts +109 -0
  88. package/src/value-type-keyword.ts +1 -0
  89. package/dist/sources/module-ref.d.ts +0 -21
  90. package/dist/sources/module-ref.d.ts.map +0 -1
  91. package/dist/sources/module-ref.js +0 -36
  92. package/dist/sources/registry-source.d.ts +0 -14
  93. package/dist/sources/registry-source.d.ts.map +0 -1
  94. package/dist/sources/registry-source.js +0 -45
  95. package/src/sources/module-ref.ts +0 -49
  96. package/src/sources/registry-source.ts +0 -52
@@ -50,15 +50,14 @@ interface ModuleIdentity {
50
50
  * **What this key cannot relate.** It compares ref *spellings*, so it groups by
51
51
  * origin exactly and nothing else. Two consequences, both accepted:
52
52
  *
53
- * - A module imported once by a registry ref and once by a relative path is two
54
- * groups, so a version skew between them is not hoisted. Keying on what the
55
- * module declares about itself would catch that case, but only by trusting a
56
- * self-declared identity — which is what this change removes, and which
57
- * cannot tell two same-named modules from different origins apart.
58
- * - A bare `std/kv-store@0.4.0` and the equivalent direct
59
- * `https://<registry>/std/kv-store/0.4.0/telo.yaml` are two groups. Relating
60
- * them needs the configured registry base, which this pure, browser-safe
61
- * function does not have. */
53
+ * - A module imported once by an `oci://` ref and once by a relative path is
54
+ * two groups, so a version skew between them is not hoisted. Keying on what
55
+ * the module declares about itself would catch that case, but only by
56
+ * trusting a self-declared identity — which is what this change removes, and
57
+ * which cannot tell two same-named modules from different origins apart.
58
+ * - An `oci://` ref and a direct `https://…/telo.yaml` URL serving the same
59
+ * module are two groups. Relating them needs knowledge of the origin's
60
+ * layout, which this pure, browser-safe function does not have. */
62
61
  function refIdentity(ref: string): string | null {
63
62
  const base = ref.split("#")[0];
64
63
  if (!base || base.startsWith(".") || base.startsWith("/") || base.startsWith("file:")) {
@@ -185,8 +184,8 @@ export function reconcileModuleVersions(
185
184
  // source normally maps to exactly one identity; the entry module has no
186
185
  // inbound edge and needs none (it is never reconciled against itself).
187
186
  //
188
- // One source CAN be reached by two spellings of the same location (a bare
189
- // registry ref and the direct URL it resolves to). Both name the same module
187
+ // One source CAN be reached by two spellings of the same location (a ref and
188
+ // the direct URL it resolves to). Both name the same module
190
189
  // at the same version, so either identity groups it correctly — but the choice
191
190
  // must not depend on edge iteration order, or the same graph could reconcile
192
191
  // differently across runs. First edge wins.
@@ -60,7 +60,7 @@ export interface ModuleEvidence {
60
60
  readonly inlines: ReadonlyMap<ModuleKey, readonly string[]>;
61
61
  /**
62
62
  * Modules reached by an in-repo **relative** `imports:` source. A pinned
63
- * registry ref is deliberately not an edge: pinning is the statement "I am not
63
+ * remote ref is deliberately not an edge: pinning is the statement "I am not
64
64
  * affected until I choose to be", and moving it is `telo upgrade`'s job.
65
65
  */
66
66
  readonly imports: readonly ModuleKey[];
@@ -195,8 +195,16 @@ function collect(
195
195
  }
196
196
  }
197
197
 
198
+ /** A region as the WALK finds it, before any attribute is asked for: the slot
199
+ * that opens it and everything it reaches. Every attribute query is a filter
200
+ * over these, so a consumer wanting regions regardless of what they declare —
201
+ * an editor drawing the enclosure, where a zone declaring no attribute is
202
+ * still a zone — reads them directly instead of enumerating the vocabulary and
203
+ * silently missing whatever is not in it. */
204
+ export type ZoneProvider = Omit<ZoneRegion, "attribute" | "reason">;
205
+
198
206
  /**
199
- * Every region in the graph opened by a slot declaring `attribute`.
207
+ * Every region in the graph, keyed by the slot that opens it.
200
208
  *
201
209
  * The provider itself is NOT in `contents` — a zone constrains what runs inside
202
210
  * its body, not the resource that establishes it. A transaction's own
@@ -204,12 +212,11 @@ function collect(
204
212
  * treated the provider as contained would report the provider against its own
205
213
  * constraint.
206
214
  */
207
- export function findZoneRegions(
215
+ export function findZoneProviders(
208
216
  graph: CallGraph,
209
217
  resolveDef: DefinitionLookup,
210
- attribute: string,
211
- ): ZoneRegion[] {
212
- const regions: ZoneRegion[] = [];
218
+ ): ZoneProvider[] {
219
+ const regions: ZoneProvider[] = [];
213
220
 
214
221
  for (const node of graph.nodes.values()) {
215
222
  if (node.type !== "resource") continue;
@@ -221,8 +228,7 @@ export function findZoneRegions(
221
228
  // properties rather than by knowing any kind's field names.
222
229
  for (const [slot, slotSchema] of providingSlots(rootSchema)) {
223
230
  const provides = readProvidesZone(slotSchema);
224
- const reason = provides?.attributes[attribute as keyof typeof provides.attributes];
225
- if (!provides || typeof reason !== "string") continue;
231
+ if (!provides) continue;
226
232
 
227
233
  const contents = new Map<string, ContainedNode>();
228
234
  const boundaries: RegionBoundary[] = [];
@@ -257,8 +263,6 @@ export function findZoneRegions(
257
263
  }
258
264
 
259
265
  regions.push({
260
- attribute,
261
- reason,
262
266
  provider: node,
263
267
  slot,
264
268
  attributes: provides.attributes as Readonly<Record<string, string>>,
@@ -271,6 +275,42 @@ export function findZoneRegions(
271
275
  return regions;
272
276
  }
273
277
 
278
+ /**
279
+ * Every region opened by a slot declaring `attribute`.
280
+ *
281
+ * A filter over {@link findZoneProviders}, so the walk is stated once: what an
282
+ * attribute query adds is which regions are in scope and the author's `reason`,
283
+ * which is the attribute's own value.
284
+ *
285
+ * **The provider list is memoized per graph**, because the filter made the walk
286
+ * unconditional: it used to skip the containment BFS for any provider not
287
+ * declaring the queried attribute, and six queries per analysis (four zone
288
+ * attributes plus two durable ones) became six full walks over every provider
289
+ * in the module. The cache is keyed on the CallGraph object, which is itself
290
+ * built once per analysis — so it lives exactly as long as the answer stays
291
+ * true, and a rebuilt graph gets a fresh one.
292
+ */
293
+ const providersByGraph = new WeakMap<CallGraph, ZoneProvider[]>();
294
+
295
+ export function findZoneRegions(
296
+ graph: CallGraph,
297
+ resolveDef: DefinitionLookup,
298
+ attribute: string,
299
+ ): ZoneRegion[] {
300
+ let providers = providersByGraph.get(graph);
301
+ if (!providers) {
302
+ providers = findZoneProviders(graph, resolveDef);
303
+ providersByGraph.set(graph, providers);
304
+ }
305
+ const out: ZoneRegion[] = [];
306
+ for (const region of providers) {
307
+ const reason = region.attributes[attribute];
308
+ if (typeof reason !== "string") continue;
309
+ out.push({ ...region, attribute, reason });
310
+ }
311
+ return out;
312
+ }
313
+
274
314
  /** Field-map paths of every slot in a kind's schema carrying a provides-zone
275
315
  * annotation, with the schema node at each. Walks properties, array items and
276
316
  * `additionalProperties`, resolving local `$ref`s — the paths the call graph's
@@ -308,7 +308,12 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
308
308
  const local = scopeLocal.get(`${from.scopeOwner}\0${from.scopeSite}`)?.get(name);
309
309
  if (local) return local;
310
310
  }
311
- return graph.resourceByName(name);
311
+ // In the scope that WROTE the name: a bare reference means the reader's own
312
+ // module, and two libraries may each declare one.
313
+ return graph.resourceByName(
314
+ name,
315
+ (from.manifest.metadata as { module?: string } | undefined)?.module,
316
+ );
312
317
  };
313
318
 
314
319
  const acceptedFor = (zone: string): ReadonlySet<string> => {
@@ -809,7 +814,7 @@ export function runZoneAnalysis(args: ZoneAnalysisArgs): AnalysisDiagnostic[] {
809
814
  for (const lib of rootLibraries) {
810
815
  for (const [exportName, specs] of openExports) {
811
816
  if (!lib.exportedNames.has(exportName)) continue;
812
- const node = graph.resourceByName(exportName);
817
+ const node = graph.resourceByName(exportName, lib.module);
813
818
  if (!node || (node.manifest.metadata as { module?: string } | undefined)?.module !== lib.module) {
814
819
  continue;
815
820
  }
@@ -27,6 +27,9 @@
27
27
  */
28
28
 
29
29
  import { X_TELO_TYPE } from "@telorun/sdk";
30
+
31
+ /** The one annotation read from a DATA schema rather than a kind schema. */
32
+ const X_TELO_SENSITIVE = "x-telo-sensitive" as const;
30
33
  import { ANNOTATION_KEYWORDS } from "./value-type-keyword.js";
31
34
 
32
35
  /** A keyword entry: the JSON Schema its VALUE must satisfy, carrying the title
@@ -227,7 +230,13 @@ export function jsonSchemaKeywords(self: string): SchemaKeywords {
227
230
  * a schema describing an annotation from an annotation, which nothing does yet.
228
231
  */
229
232
  export const TELO_SCHEMA_ANNOTATIONS: Record<
230
- (typeof ANNOTATION_KEYWORDS)[number] | typeof X_TELO_TYPE,
233
+ // Exhaustive over the kind vocabulary, so adding a keyword without an entry is
234
+ // a compile error. `x-telo-sensitive` is excluded because it belongs to the
235
+ // DATA vocabulary below — it is read from a contract, not from a kind's own
236
+ // `schema:` — and offering it here would put it on the one schema where
237
+ // nothing reads it.
238
+ | Exclude<(typeof ANNOTATION_KEYWORDS)[number], typeof X_TELO_SENSITIVE>
239
+ | typeof X_TELO_TYPE,
231
240
  Record<string, unknown>
232
241
  > = {
233
242
  "x-telo-eval": {
@@ -381,3 +390,26 @@ export const TELO_SCHEMA_ANNOTATIONS: Record<
381
390
  type: "string",
382
391
  },
383
392
  };
393
+
394
+ /**
395
+ * Annotations that belong on a DATA schema — an `inputType` / `outputType`
396
+ * contract — rather than on a kind's own `schema:`.
397
+ *
398
+ * Split out because {@link TELO_SCHEMA_ANNOTATIONS} is offered only where the
399
+ * fragment is `KindSchema`, which is a kind's CONFIGURATION. `x-telo-sensitive`
400
+ * is read from the opposite place: the kernel resolves it off a bound contract,
401
+ * which stamps `JsonSchema7`. Offering it from the kind vocabulary alone put it
402
+ * on the one schema where nothing reads it and withheld it from the two where it
403
+ * is the whole mechanism.
404
+ */
405
+ export const TELO_DATA_SCHEMA_ANNOTATIONS: Record<
406
+ typeof X_TELO_SENSITIVE,
407
+ Record<string, unknown>
408
+ > = {
409
+ [X_TELO_SENSITIVE]: {
410
+ title: "Sensitive",
411
+ description:
412
+ "This value is auth material or equivalent: carry it as `[redacted]` in trace payloads and on the debug wire rather than verbatim. Read only from a resource's declared `inputType` / `outputType`.",
413
+ type: "boolean",
414
+ },
415
+ };
@@ -1,12 +1,11 @@
1
1
  import type { ManifestSource } from "../types.js";
2
2
  import { HttpSource } from "./http-source.js";
3
- import { RegistrySource } from "./registry-source.js";
4
3
 
5
- /** The browser-safe built-in sources, in resolution order: HTTP fetch then
6
- * registry. Node-specific sources (local filesystem) are supplied by the
7
- * consuming package and passed alongside these into the `Loader` constructor.
8
- * Callers that only want a subset (e.g. the editor, which brings its own
9
- * registry adapters) construct the individual sources directly. */
10
- export function defaultSources(registryUrl?: string): ManifestSource[] {
11
- return [new HttpSource(), new RegistrySource(registryUrl)];
4
+ /** The browser-safe built-in sources. Node-specific sources (local filesystem)
5
+ * are supplied by the consuming package and passed alongside these into the
6
+ * `Loader` constructor. Callers that only want a subset (e.g. the editor, which
7
+ * brings its own manifest-cache adapter) construct the individual sources
8
+ * directly. */
9
+ export function defaultSources(): ManifestSource[] {
10
+ return [new HttpSource()];
12
11
  }
@@ -110,11 +110,26 @@ export async function verifyIntegrity(
110
110
  }
111
111
  }
112
112
 
113
+ /** Object-storage backends (Cloudflare R2, S3) surface auth and permission
114
+ * failures as a **200 with an XML error body**. Caught here rather than left to
115
+ * the loader, which would parse the XML as YAML and report a downstream
116
+ * `UNDEFINED_KIND` — naming the manifest's contents as the problem instead of
117
+ * the origin that refused to serve it. Returns the failure detail, or `null`
118
+ * when the body is not an XML error document. */
119
+ function objectStorageError(text: string): string | null {
120
+ const head = text.trimStart();
121
+ if (!head.startsWith("<?xml") && !head.startsWith("<Error")) return null;
122
+ const code = text.match(/<Code>([^<]+)<\/Code>/);
123
+ const message = text.match(/<Message>([^<]+)<\/Message>/);
124
+ return code && message ? `${code[1]}: ${message[1]}` : text.slice(0, 200);
125
+ }
126
+
113
127
  /** The single verified network read for remote manifests: fetch `fetchUrl`,
114
128
  * verify the raw bytes against `integrity` (when pinned), and return both the
115
129
  * bytes and the decoded text. The one choke point every network `ManifestSource`
116
- * routes through, so verification cannot drift between them. `describe` names
117
- * the artifact in error messages. */
130
+ * routes through, so verification cannot drift between them which is also why
131
+ * the object-storage non-manifest check lives here rather than in each source.
132
+ * `describe` names the artifact in error messages. */
118
133
  export async function verifiedFetch(
119
134
  fetchUrl: string,
120
135
  integrity: string | undefined,
@@ -127,6 +142,16 @@ export async function verifiedFetch(
127
142
  );
128
143
  }
129
144
  const bytes = new Uint8Array(await response.arrayBuffer());
145
+ const text = new TextDecoder().decode(bytes);
146
+ // Before the integrity check: a bucket's error page hashes to something, and
147
+ // reporting a pin mismatch would name the wrong cause just as the loader does.
148
+ const storageError = objectStorageError(text);
149
+ if (storageError) {
150
+ throw new Error(
151
+ `The origin returned a non-manifest response for ${describe} ` +
152
+ `(URL: ${fetchUrl}): ${storageError}`,
153
+ );
154
+ }
130
155
  if (integrity) await verifyIntegrity(bytes, integrity, describe);
131
- return { bytes, text: new TextDecoder().decode(bytes) };
156
+ return { bytes, text };
132
157
  }
@@ -1,16 +1,15 @@
1
1
  import { splitIntegrity } from "./integrity.js";
2
- import { isRegistryRef } from "./module-ref.js";
3
2
  import { OCI_SCHEME } from "./oci-ref.js";
4
3
 
5
4
  /** A module ref split into the parts an upgrade needs: the version-independent
6
5
  * ref, the version segment it currently names, and any inline pin. */
7
6
  export interface ParsedVersionedRef {
8
7
  /** The ref with its `@version` segment and integrity fragment removed —
9
- * `std/run`, `oci://ghcr.io/telorun/timer`. This is the identity a version
10
- * list is keyed by (the hub registers modules under exactly this form). */
8
+ * `oci://ghcr.io/telorun/timer`. This is the identity a version list is keyed
9
+ * by (the hub registers modules under exactly this form). */
11
10
  baseRef: string;
12
- /** The version segment, raw — a registry `@version`, an OCI tag, or an OCI
13
- * digest reference (`sha256:…`). The caller applies its own SemVer check. */
11
+ /** The version segment, raw — an OCI tag or an OCI digest reference
12
+ * (`sha256:…`). The caller applies its own SemVer check. */
14
13
  version: string;
15
14
  /** Telo's inline `sha256-<base64url>` pin, when the ref carried one. */
16
15
  integrity?: string;
@@ -20,12 +19,17 @@ export interface ParsedVersionedRef {
20
19
  * upgradeable version — a local path, a bare `https://` URL, or an OCI ref
21
20
  * with no explicit reference (an implicit `latest` is not a pin).
22
21
  *
23
- * Browser-safe and transport-neutral: this is the *grammar* half of an
24
- * upgrade, shared by the kernel transports (whose `refVersion` / `withVersion`
25
- * delegate here) and the editor, which cannot use a transport at all — the
26
- * *network* half (enumerating versions) is scheme-specific and stays behind
27
- * `Transport.listVersions` on Node and the hub's `/module/versions` in the
28
- * browser. */
22
+ * Browser-safe: this is the *grammar* half of an upgrade, shared by the kernel
23
+ * transports (whose `refVersion` / `withVersion` delegate here) and the editor,
24
+ * which cannot use a transport at all — the *network* half (enumerating
25
+ * versions) is scheme-specific and stays behind `Transport.listVersions` on
26
+ * Node and the hub's `/module/versions` in the browser.
27
+ *
28
+ * It is NOT transport-neutral: `oci://` is the only versionable grammar, named
29
+ * literally below. That was equally true when the registry ref was the second
30
+ * branch, and a third scheme still means editing this file — the versionable
31
+ * set would have to become data the transports contribute before that claim
32
+ * could be made honestly. */
29
33
  export function parseVersionedRef(ref: string): ParsedVersionedRef | null {
30
34
  const { base, integrity } = splitIntegrity(ref);
31
35
  const at = versionSeparator(base);
@@ -41,37 +45,31 @@ export function parseVersionedRef(ref: string): ParsedVersionedRef | null {
41
45
  *
42
46
  * Throws when the ref's grammar has no version segment at all — a relative
43
47
  * path, a bare `https://` URL. Producing `../lib@0.4.0` for those would write
44
- * a ref nothing can resolve, so this fails where the transport-specific
45
- * parsers it replaced (`parseOciRef` / `parseModuleRef`) also failed. */
48
+ * a ref nothing can resolve, so this fails where the transport-specific parser
49
+ * it replaced (`parseOciRef`) also failed. */
46
50
  export function withRefVersion(ref: string, version: string): string {
47
51
  const { base } = splitIntegrity(ref);
48
- if (refGrammar(base) === null) {
52
+ if (!isVersionableRef(base)) {
49
53
  throw new Error(
50
- `Cannot set a version on '${ref}' — only registry (namespace/name@version) ` +
51
- `and oci:// refs carry a version segment.`,
54
+ `Cannot set a version on '${ref}' — only oci:// refs carry a version segment.`,
52
55
  );
53
56
  }
54
57
  const at = versionSeparator(base);
55
58
  return `${at === null ? base : base.slice(0, at)}@${version}`;
56
59
  }
57
60
 
58
- /** Which versionable ref grammar `base` is written in, or `null` when it is
59
- * neither — a relative/absolute path, a `file:`/`https://` URL. */
60
- function refGrammar(base: string): "oci" | "registry" | null {
61
- if (base.startsWith(OCI_SCHEME)) {
62
- // A host alone is not addressable; the repo path is what carries a version.
63
- return base.indexOf("/", OCI_SCHEME.length) > OCI_SCHEME.length ? "oci" : null;
64
- }
65
- // `isRegistryRef` requires the `@`, so a version-less `std/console` is not a
66
- // registry ref by this test — matching `parseModuleRef`, which throws on it.
67
- return isRegistryRef(base) ? "registry" : null;
61
+ /** True when `base` is written in a grammar that carries a version segment — a
62
+ * relative/absolute path and a `file:`/`https://` URL are not. */
63
+ function isVersionableRef(base: string): boolean {
64
+ // A host alone is not addressable; the repo path is what carries a version.
65
+ return base.startsWith(OCI_SCHEME) && base.indexOf("/", OCI_SCHEME.length) > OCI_SCHEME.length;
68
66
  }
69
67
 
70
68
  /** Index of the `@` that separates the version, or `null` when the ref names
71
69
  * none. Split on the LAST `@` so a digest reference (`repo@sha256:…`) keeps
72
70
  * everything before it as the ref. */
73
71
  function versionSeparator(base: string): number | null {
74
- if (refGrammar(base) === null) return null;
72
+ if (!isVersionableRef(base)) return null;
75
73
  const at = base.lastIndexOf("@");
76
- return at > (base.startsWith(OCI_SCHEME) ? OCI_SCHEME.length : 0) ? at : null;
74
+ return at > OCI_SCHEME.length ? at : null;
77
75
  }
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.83.0";
9
+ export const TELO_SURFACE_VERSION = "0.85.0";
@@ -1,7 +1,13 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import type { AliasResolver } from "./alias-resolver.js";
3
3
  import type { DefinitionRegistry } from "./definition-registry.js";
4
- import { inheritedCapability, type DefResolver } from "./extends-resolution.js";
4
+ import {
5
+ controllerBearingAncestor,
6
+ effectiveAuthorSchema,
7
+ inheritedCapability,
8
+ isInheritedDelegation,
9
+ type DefResolver,
10
+ } from "./extends-resolution.js";
5
11
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
12
 
7
13
  const SOURCE = "telo-analyzer";
@@ -25,6 +31,9 @@ const EXTENDS_ALIAS_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
25
31
  * - EXTENDS_UNKNOWN_TARGET: alias resolves to a module, but that module has no
26
32
  * registered definition with the target name.
27
33
  * - EXTENDS_NON_ABSTRACT: target resolves to a Telo.Definition, not a Telo.Abstract.
34
+ * - EXTENDS_CLOSED_PARENT_ADDS_FIELD: a child with no `base:` declares a property its
35
+ * controller-bearing ancestor's closed schema rejects — the runtime counterpart is a
36
+ * create-time failure phrased against the ancestor kind.
28
37
  * - CAPABILITY_SHADOWS_EXTENDS (warning): `capability` names a user-declared abstract
29
38
  * (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
30
39
  * never trigger this — they're lifecycle roles by design.
@@ -149,6 +158,47 @@ export function validateExtends(
149
158
  data: { resource, filePath, path: "capability" },
150
159
  });
151
160
  }
161
+
162
+ // A merge-form child (no `base:`) forwards its WHOLE config to the
163
+ // inherited controller as the parent's config, so a field the
164
+ // parent's schema closes out is rejected at the parent's own
165
+ // create-time validation — phrased against the parent kind, at the
166
+ // instance's line, about a resource the author never wrote.
167
+ // Reported here instead, at the property that has to change.
168
+ if (
169
+ (m as { base?: unknown }).base == null &&
170
+ isInheritedDelegation(m as ResourceDefinition, resolveDef)
171
+ ) {
172
+ const ancestor = controllerBearingAncestor(m as ResourceDefinition, resolveDef);
173
+ const ancestorSchema = ancestor
174
+ ? effectiveAuthorSchema(ancestor, resolveDef)
175
+ : undefined;
176
+ if (ancestorSchema?.additionalProperties === false) {
177
+ const inherited = new Set(Object.keys(ancestorSchema.properties ?? {}));
178
+ const ownProps = ((m as { schema?: { properties?: Record<string, unknown> } })
179
+ .schema?.properties ?? {}) as Record<string, unknown>;
180
+ const ancestorKind = `${ancestor?.metadata.module}.${ancestor?.metadata.name}`;
181
+ for (const prop of Object.keys(ownProps)) {
182
+ if (inherited.has(prop)) continue;
183
+ diagnostics.push({
184
+ severity: DiagnosticSeverity.Error,
185
+ code: "EXTENDS_CLOSED_PARENT_ADDS_FIELD",
186
+ source: SOURCE,
187
+ message:
188
+ `${label}: declares '${prop}', but '${ancestorKind}' closes its schema ` +
189
+ `(additionalProperties: false) and a child without 'base:' forwards its whole ` +
190
+ `config as that kind's config — so '${prop}' is rejected when the resource is ` +
191
+ `created. Add a 'base:' mapping, which makes this kind's fields construction ` +
192
+ `inputs and builds the parent's config explicitly.`,
193
+ data: {
194
+ resource,
195
+ filePath,
196
+ path: `schema.properties.${prop}`,
197
+ },
198
+ });
199
+ }
200
+ }
201
+ }
152
202
  }
153
203
  }
154
204
  }
@@ -0,0 +1,109 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { SCHEMA_REGION_KEYS } from "./schema-region.js";
3
+
4
+ /**
5
+ * `x-telo-sensitive` where nothing reads it.
6
+ *
7
+ * The annotation has exactly one consumer: the kernel resolves it from a
8
+ * resource's bound CONTRACT — `inputType` / `outputType` — and carries the
9
+ * marked value as `[redacted]` in trace payloads. Written anywhere else it is an
10
+ * unknown keyword in an open schema, which is to say it validates, ships, and
11
+ * does nothing.
12
+ *
13
+ * For a security control that is the worst available failure: an author marks a
14
+ * token, sees no error, and puts it on the debug wire anyway. So a misplacement
15
+ * is reported rather than ignored — the same posture `X_TELO_REF_UNRESOLVED`
16
+ * takes toward a reference constraint that resolves to nothing, and for the same
17
+ * reason: silence reads as protection.
18
+ *
19
+ * Scoped by the caller to the entry's own modules, since a dependency's schema
20
+ * is not the consumer's to fix.
21
+ */
22
+ export interface SensitiveSlotIssue {
23
+ code: "SENSITIVE_ANNOTATION_MISPLACED" | "SENSITIVE_ANNOTATION_INVALID";
24
+ manifest: ResourceManifest;
25
+ /** Dotted path to the annotated schema node. */
26
+ path: string;
27
+ message: string;
28
+ }
29
+
30
+ const ANNOTATION = "x-telo-sensitive";
31
+
32
+ /** The only regions the kernel reads the annotation from. */
33
+ const CONTRACT_KEYS = new Set(["inputType", "outputType"]);
34
+
35
+ export function validateSensitiveSlots(manifest: ResourceManifest): SensitiveSlotIssue[] {
36
+ const issues: SensitiveSlotIssue[] = [];
37
+
38
+ const walk = (node: unknown, path: (string | number)[], seen: Set<object>): void => {
39
+ if (!node || typeof node !== "object") return;
40
+ if (seen.has(node as object)) return;
41
+ seen.add(node as object);
42
+
43
+ if (Array.isArray(node)) {
44
+ node.forEach((child, i) => walk(child, [...path, i], seen));
45
+ return;
46
+ }
47
+
48
+ const record = node as Record<string, unknown>;
49
+ if (Object.hasOwn(record, ANNOTATION)) {
50
+ const dotted = path.join(".");
51
+ if (record[ANNOTATION] !== true) {
52
+ issues.push({
53
+ code: "SENSITIVE_ANNOTATION_INVALID",
54
+ manifest,
55
+ path: dotted,
56
+ message:
57
+ `'${ANNOTATION}' must be \`true\`; got ${JSON.stringify(record[ANNOTATION])}. ` +
58
+ `It is a marker, not a level — a value other than \`true\` reads as "not sensitive".`,
59
+ });
60
+ } else {
61
+ const region = enclosingSchemaRegion(path);
62
+ if (region === undefined || !CONTRACT_KEYS.has(region)) {
63
+ issues.push({
64
+ code: "SENSITIVE_ANNOTATION_MISPLACED",
65
+ manifest,
66
+ path: dotted,
67
+ message:
68
+ `'${ANNOTATION}' is only read from a resource's declared contract ` +
69
+ `(\`inputType\` / \`outputType\`), and this node is ` +
70
+ (region === undefined
71
+ ? "not inside a schema at all"
72
+ : `inside \`${region}\``) +
73
+ `. The kernel will not redact it, so the value would still reach trace ` +
74
+ `payloads and the debug wire. Move the mark onto the contract property ` +
75
+ `that carries the value.`,
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ for (const [key, child] of Object.entries(record)) {
82
+ walk(child, [...path, key], seen);
83
+ }
84
+ };
85
+
86
+ walk(manifest as unknown as Record<string, unknown>, [], new Set());
87
+ return issues;
88
+ }
89
+
90
+ /**
91
+ * The OUTERMOST segment naming a schema-valued key, or `undefined` when the node
92
+ * is not inside a schema.
93
+ *
94
+ * Outermost, not nearest, because a contract is routinely written in the inline
95
+ * `{kind: Telo.JsonSchema, schema: …}` form — so the path to a marked property is
96
+ * `outputType.schema.properties.headers`, and the nearest region key is that
97
+ * wrapper's own `schema`. Reading it as a kind's configuration would report every
98
+ * correctly-marked contract as misplaced, which is how this check first behaved.
99
+ *
100
+ * It is the same reasoning `expandManifestFragments` uses when it keys on the
101
+ * top-level slot: the object a contract resolver is handed is the one under
102
+ * `inputType` / `outputType`, whatever nesting the authoring form adds beneath.
103
+ */
104
+ function enclosingSchemaRegion(path: readonly (string | number)[]): string | undefined {
105
+ for (const segment of path) {
106
+ if (typeof segment === "string" && SCHEMA_REGION_KEYS.includes(segment)) return segment;
107
+ }
108
+ return undefined;
109
+ }
@@ -69,6 +69,7 @@ export const ANNOTATION_KEYWORDS = [
69
69
  "x-telo-schema-projection",
70
70
  "x-telo-schema-projection-from",
71
71
  "x-telo-scope",
72
+ "x-telo-sensitive",
72
73
  "x-telo-step-context",
73
74
  "x-telo-topology-role",
74
75
  "x-telo-value-schema-from",
@@ -1,21 +0,0 @@
1
- /** A parsed registry module reference. `modulePath` is `namespace/name`,
2
- * `version` has any leading `v` stripped, and `integrity` carries the inline
3
- * `sha256-<base64url>` hash when the ref was pinned. */
4
- export interface ParsedModuleRef {
5
- modulePath: string;
6
- version: string;
7
- integrity?: string;
8
- }
9
- /** True when `url` has the bare registry-ref shape `namespace/name@version`
10
- * (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
11
- * The integrity fragment, if any, does not affect the classification.
12
- *
13
- * A registry ref never carries a `scheme://` — that guard is what keeps an
14
- * `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
15
- * scheme-owning transport claims it instead. */
16
- export declare function isRegistryRef(url: string): boolean;
17
- /** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
18
- * source of truth shared by the registry source, the kernel manifest cache,
19
- * and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
20
- export declare function parseModuleRef(ref: string): ParsedModuleRef;
21
- //# sourceMappingURL=module-ref.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"module-ref.d.ts","sourceRoot":"","sources":["../../src/sources/module-ref.ts"],"names":[],"mappings":"AAEA;;yDAEyD;AACzD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;iDAMiD;AACjD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CASlD;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAgB3D"}
@@ -1,36 +0,0 @@
1
- import { splitIntegrity } from "./integrity.js";
2
- /** True when `url` has the bare registry-ref shape `namespace/name@version`
3
- * (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
4
- * The integrity fragment, if any, does not affect the classification.
5
- *
6
- * A registry ref never carries a `scheme://` — that guard is what keeps an
7
- * `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
8
- * scheme-owning transport claims it instead. */
9
- export function isRegistryRef(url) {
10
- const { base } = splitIntegrity(url);
11
- return (!base.includes("://") &&
12
- !base.startsWith("/") &&
13
- !base.startsWith(".") &&
14
- base.includes("@") &&
15
- base.includes("/"));
16
- }
17
- /** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
18
- * source of truth shared by the registry source, the kernel manifest cache,
19
- * and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
20
- export function parseModuleRef(ref) {
21
- const { base, integrity } = splitIntegrity(ref);
22
- const atIdx = base.lastIndexOf("@");
23
- if (atIdx <= 0 || atIdx === base.length - 1) {
24
- throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
25
- }
26
- const modulePath = base.slice(0, atIdx);
27
- if (!modulePath.includes("/")) {
28
- throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
29
- }
30
- const rawVersion = base.slice(atIdx + 1);
31
- const version = rawVersion.startsWith("v") ? rawVersion.slice(1) : rawVersion;
32
- if (!version) {
33
- throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
34
- }
35
- return { modulePath, version, integrity };
36
- }