@telorun/analyzer 0.31.0 → 0.33.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.
package/src/analyzer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
  import { canonicalTypeSchemaId } from "@telorun/sdk";
3
3
  import type { Environment } from "@marcbachmann/cel-js";
4
- import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
4
+ import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
5
5
  import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
6
6
  import { AnalysisRegistry } from "./analysis-registry.js";
7
7
  import {
@@ -40,6 +40,7 @@ import {
40
40
  import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
41
41
  import { validateExtends } from "./validate-extends.js";
42
42
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
43
+ import { validateKindDescriptions } from "./validate-kind-descriptions.js";
43
44
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
44
45
  import { validateReferences } from "./validate-references.js";
45
46
  import { validateReferenceForms } from "./validate-reference-forms.js";
@@ -236,6 +237,73 @@ function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Reco
236
237
  return out;
237
238
  }
238
239
 
240
+ /**
241
+ * Generic, role-driven walk over an `x-telo-step-context` step array. Calls
242
+ * `visit(step, stepPath)` for every step — top-level and nested through the
243
+ * `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
244
+ * the single definition of how steps nest, shared by `buildStepContextSchema`
245
+ * (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
246
+ * checks invoke refs), so the topology contract lives in one place — adding a
247
+ * role or nesting form updates both consumers at once. No resource kind is
248
+ * hardcoded; recursion is driven entirely by the schema annotations.
249
+ */
250
+ function walkStepArray(
251
+ steps: unknown[],
252
+ stepItemSchema: Record<string, any> | undefined,
253
+ rootSchema: Record<string, any>,
254
+ basePath: string,
255
+ visit: (step: Record<string, any>, stepPath: string) => void,
256
+ ): void {
257
+ const dispatchRole = (
258
+ data: unknown,
259
+ role: string,
260
+ itemsSchema: Record<string, any> | undefined,
261
+ path: string,
262
+ ): void => {
263
+ if (role === "branch" && Array.isArray(data)) {
264
+ walkStepArray(data, stepItemSchema, rootSchema, path, visit);
265
+ } else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
266
+ for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
267
+ if (Array.isArray(arr)) walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
268
+ }
269
+ } else if (role === "branch-list" && Array.isArray(data)) {
270
+ const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
271
+ if (!entrySchema) return;
272
+ data.forEach((entry, i) => {
273
+ if (!entry || typeof entry !== "object") return;
274
+ for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
275
+ const subRole = subSchema["x-telo-topology-role"];
276
+ if (typeof subRole !== "string") continue;
277
+ dispatchRole(
278
+ (entry as Record<string, any>)[subKey],
279
+ subRole,
280
+ subSchema.items as Record<string, any> | undefined,
281
+ `${path}[${i}].${subKey}`,
282
+ );
283
+ }
284
+ });
285
+ }
286
+ };
287
+
288
+ steps.forEach((step, i) => {
289
+ if (!step || typeof step !== "object") return;
290
+ const s = step as Record<string, any>;
291
+ const stepPath = `${basePath}[${i}]`;
292
+ visit(s, stepPath);
293
+ if (!stepItemSchema) return;
294
+ for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
295
+ const role = propSchema["x-telo-topology-role"];
296
+ if (typeof role !== "string") continue;
297
+ dispatchRole(
298
+ s[propKey],
299
+ role,
300
+ propSchema.items as Record<string, any> | undefined,
301
+ `${stepPath}.${propKey}`,
302
+ );
303
+ }
304
+ });
305
+ }
306
+
239
307
  /**
240
308
  * Build a `steps` context schema from `x-telo-step-context` annotation.
241
309
  * Walks each step in the manifest array, resolves the invoked resource's outputType,
@@ -290,90 +358,47 @@ function buildStepContextSchema(
290
358
 
291
359
  const stepProperties: Record<string, any> = {};
292
360
 
293
- const dispatchRole = (
294
- data: unknown,
295
- role: string,
296
- itemsSchema: Record<string, any> | undefined,
297
- ): void => {
298
- if (role === "branch" && Array.isArray(data)) {
299
- collectSteps(data);
300
- } else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
301
- for (const arr of Object.values(data as Record<string, unknown>)) {
302
- if (Array.isArray(arr)) collectSteps(arr);
303
- }
304
- } else if (role === "branch-list" && Array.isArray(data)) {
305
- const entrySchema = resolveLocalRef(itemsSchema, defSchema);
306
- if (!entrySchema) return;
307
- for (const entry of data) {
308
- if (!entry || typeof entry !== "object") continue;
309
- for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
310
- const subRole = subSchema["x-telo-topology-role"];
311
- if (typeof subRole !== "string") continue;
312
- dispatchRole(
313
- (entry as Record<string, any>)[subKey],
314
- subRole,
315
- subSchema.items as Record<string, any> | undefined,
316
- );
317
- }
361
+ walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
362
+ const name = s.name;
363
+ const invoke = s[invokeField] as Record<string, any> | undefined;
364
+ // Only invoke steps register a `steps.<name>.result` entry — control-flow
365
+ // wrappers (try/if/while/switch/throw) don't produce a result and must
366
+ // not shadow real entries with a permissive `additionalProperties: true`,
367
+ // or unknown step references slip through chain validation.
368
+ if (typeof name !== "string" || !invoke || typeof invoke !== "object") return;
369
+ let outputSchema: Record<string, any> | undefined;
370
+ const invokedKind = invoke.kind as string | undefined;
371
+ const invokedName = invoke.name as string | undefined;
372
+ if (invokedName) {
373
+ const invokedManifest = allManifests.find(
374
+ (m) =>
375
+ (m.metadata as any)?.name === invokedName &&
376
+ (!invokedKind || m.kind === invokedKind),
377
+ ) as Record<string, any> | undefined;
378
+ if (invokedManifest) {
379
+ outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
318
380
  }
381
+ } else {
382
+ outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
319
383
  }
320
- };
321
-
322
- function collectSteps(items: unknown[]): void {
323
- for (const step of items) {
324
- if (!step || typeof step !== "object") continue;
325
- const s = step as Record<string, any>;
326
- const name = s.name;
327
- const invoke = s[invokeField] as Record<string, any> | undefined;
328
- // Only invoke steps register a `steps.<name>.result` entry — control-flow
329
- // wrappers (try/if/while/switch/throw) don't produce a result and must
330
- // not shadow real entries with a permissive `additionalProperties: true`,
331
- // or unknown step references slip through chain validation.
332
- if (typeof name === "string" && invoke && typeof invoke === "object") {
333
- let outputSchema: Record<string, any> | undefined;
334
- const invokedKind = invoke.kind as string | undefined;
335
- const invokedName = invoke.name as string | undefined;
336
- if (invokedName) {
337
- const invokedManifest = allManifests.find(
338
- (m) =>
339
- (m.metadata as any)?.name === invokedName &&
340
- (!invokedKind || m.kind === invokedKind),
341
- ) as Record<string, any> | undefined;
342
- if (invokedManifest) {
343
- outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
344
- }
345
- } else {
346
- outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
347
- }
348
- // Fallback: pull outputType from the kind's Telo.Definition. The
349
- // resource manifest typically doesn't carry outputType; the def does.
350
- if (!outputSchema && invokedKind) {
351
- outputSchema = lookupDefinitionTypeField(
352
- invokedKind,
353
- outputTypeField,
354
- defs,
355
- aliases,
356
- allManifests,
357
- );
358
- }
359
- stepProperties[name] = {
360
- type: "object",
361
- properties: {
362
- result: outputSchema ?? { type: "object", additionalProperties: true },
363
- },
364
- };
365
- }
366
- if (stepItemSchema) {
367
- for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
368
- const role = propSchema["x-telo-topology-role"];
369
- if (typeof role !== "string") continue;
370
- dispatchRole(s[propKey], role, propSchema.items as Record<string, any> | undefined);
371
- }
372
- }
384
+ // Fallback: pull outputType from the kind's Telo.Definition. The
385
+ // resource manifest typically doesn't carry outputType; the def does.
386
+ if (!outputSchema && invokedKind) {
387
+ outputSchema = lookupDefinitionTypeField(
388
+ invokedKind,
389
+ outputTypeField,
390
+ defs,
391
+ aliases,
392
+ allManifests,
393
+ );
373
394
  }
374
- }
375
-
376
- collectSteps(steps);
395
+ stepProperties[name] = {
396
+ type: "object",
397
+ properties: {
398
+ result: outputSchema ?? { type: "object", additionalProperties: true },
399
+ },
400
+ };
401
+ });
377
402
 
378
403
  if (Object.keys(stepProperties).length > 0) {
379
404
  return {
@@ -386,6 +411,192 @@ function buildStepContextSchema(
386
411
  return undefined;
387
412
  }
388
413
 
414
+ /**
415
+ * Capabilities whose instances structurally expose no `invoke`/`run` method, so
416
+ * a step `invoke` of one always fails at runtime with ERR_RESOURCE_NOT_INVOKABLE
417
+ * (kernel dispatch checks method presence, not capability — evaluation-context.ts).
418
+ * `Telo.Service` is intentionally absent: some services are invocable (a function
419
+ * handler dispatched directly, e.g. `Lambda.Function`), so it can't be rejected
420
+ * statically without false positives. This is the sound subset of the runtime rule.
421
+ */
422
+ const NON_INVOKABLE_CAPABILITIES = new Set([
423
+ "Telo.Provider",
424
+ "Telo.Mount",
425
+ "Telo.Type",
426
+ "Telo.Template",
427
+ ]);
428
+
429
+ /**
430
+ * Validate `x-telo-step-context` step `invoke` references (e.g. `Run.Sequence`
431
+ * steps).
432
+ *
433
+ * The reference field map deliberately does NOT descend into step `invoke`
434
+ * slots — they sit behind a local `$ref` to the shared step definition, and
435
+ * turning the descent on would make Phase 5 inject live instances there,
436
+ * breaking the invoke dispatch path (see `reference-field-map.ts`). A
437
+ * consequence is that `validateReferences` never sees these slots, so a bad
438
+ * step invoke passes `telo check` and only fails at runtime. This pass covers
439
+ * exactly those slots, in two dimensions:
440
+ * - Existence: an `invoke: !ref <name>` that names a missing instance — or a
441
+ * *kind* instead of an exported instance (`!ref Stream.Of`) — is a still-a-
442
+ * sentinel after Phase 2.5 resolution → `UNRESOLVED_REFERENCE` (runtime
443
+ * `ERR_RESOURCE_NOT_FOUND`).
444
+ * - Invokability: a resolved instance whose capability structurally has no
445
+ * invoke/run method (`NON_INVOKABLE_CAPABILITIES`) → `REFERENCE_KIND_MISMATCH`
446
+ * (runtime `ERR_RESOURCE_NOT_INVOKABLE`).
447
+ *
448
+ * Generic and topology-driven — it walks steps via the same `x-telo-step-context`
449
+ * / `x-telo-topology-role` annotations `buildStepContextSchema` uses (through the
450
+ * shared `walkStepArray`), so nested branches (then/else/do/catch/cases) are
451
+ * covered and no `Run.Sequence` field name is hardcoded. The cross-module
452
+ * partial-analysis guard mirrors `validateReferences`, so a reference into an
453
+ * unloaded import is skipped rather than false-flagged.
454
+ */
455
+ function validateStepInvokeReferences(
456
+ allManifests: Record<string, any>[],
457
+ defs: DefinitionRegistry,
458
+ aliases: AliasResolver,
459
+ ): AnalysisDiagnostic[] {
460
+ const diagnostics: AnalysisDiagnostic[] = [];
461
+
462
+ // Local instance names + loaded-module set — same construction as
463
+ // validateReferences, so the cross-module guard behaves identically.
464
+ const localNames = new Set<string>();
465
+ const loadedModules = new Set<string>();
466
+
467
+ // Also collect names of resources nested inside a manifest tree — notably
468
+ // `with:`-scoped resources (an `x-telo-scope` region the field map does not
469
+ // extract to a top-level manifest). A step can invoke one by bare name, so
470
+ // omitting them would false-flag a valid `!ref`. Conservative: any nested
471
+ // object carrying both a `kind` and a `metadata.name` is a resource
472
+ // definition; scope visibility is left to the runtime.
473
+ const collectNestedNames = (value: unknown): void => {
474
+ if (!value || typeof value !== "object" || isTaggedSentinel(value)) return;
475
+ if (Array.isArray(value)) {
476
+ for (const item of value) collectNestedNames(item);
477
+ return;
478
+ }
479
+ const obj = value as Record<string, unknown>;
480
+ const name = (obj.metadata as { name?: unknown } | undefined)?.name;
481
+ if (typeof obj.kind === "string" && typeof name === "string") localNames.add(name);
482
+ for (const v of Object.values(obj)) collectNestedNames(v);
483
+ };
484
+
485
+ for (const r of allManifests) {
486
+ if (r.kind === "Telo.Import") {
487
+ const m = (r.metadata as { resolvedModuleName?: unknown } | undefined)?.resolvedModuleName;
488
+ if (typeof m === "string") loadedModules.add(m);
489
+ continue;
490
+ }
491
+ const meta = r.metadata as { name?: unknown; module?: unknown; forwardedExport?: unknown };
492
+ if (typeof meta?.name !== "string" || REF_VALIDATION_SKIP_KINDS.has(r.kind)) continue;
493
+ if (meta.forwardedExport === true) {
494
+ if (typeof meta.module === "string") loadedModules.add(meta.module);
495
+ continue;
496
+ }
497
+ localNames.add(meta.name);
498
+ collectNestedNames(r);
499
+ }
500
+
501
+ const validateInvoke = (
502
+ value: unknown,
503
+ resource: { kind: string; name: string },
504
+ filePath: string | undefined,
505
+ path: string,
506
+ ): void => {
507
+ if (isRefSentinel(value)) {
508
+ // An unresolved `!ref` is a miss: a real instance would have resolved to
509
+ // `{kind, name}` in Phase 2.5.
510
+ const refName = value.source;
511
+ const dot = refName.indexOf(".");
512
+ const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
513
+
514
+ if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
515
+ const module = aliases.moduleForAlias(aliasPrefix);
516
+ // Partial single-file analysis (import not loaded) — skip to avoid a false miss.
517
+ if (module && !loadedModules.has(module)) return;
518
+ diagnostics.push({
519
+ severity: DiagnosticSeverity.Error,
520
+ code: "UNRESOLVED_REFERENCE",
521
+ source: SOURCE,
522
+ message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${refName}' is not an exported instance of module '${module ?? aliasPrefix}' (reference a declared instance, not a kind)`,
523
+ data: { resource, filePath, path },
524
+ });
525
+ return;
526
+ }
527
+
528
+ const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
529
+ if (localNames.has(localName)) return;
530
+ diagnostics.push({
531
+ severity: DiagnosticSeverity.Error,
532
+ code: "UNRESOLVED_REFERENCE",
533
+ source: SOURCE,
534
+ message: `${resource.kind}/${resource.name}: step invoke at '${path}' → resource '${localName}' not found`,
535
+ data: { resource, filePath, path },
536
+ });
537
+ return;
538
+ }
539
+
540
+ // Resolved `{kind, name}` (or an inline `{kind, …}` definition) — the
541
+ // instance exists. Mirror the kernel's ERR_RESOURCE_NOT_INVOKABLE, which
542
+ // fires when the instance has neither an `invoke` nor a `run` method
543
+ // (evaluation-context.ts). That is a per-instance property, not a pure
544
+ // capability, so only capabilities that STRUCTURALLY expose no such method
545
+ // are rejected statically — Service is intentionally excluded, since some
546
+ // services are invocable (e.g. a function handler dispatched directly).
547
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
548
+ const kind = (value as Record<string, unknown>).kind;
549
+ if (typeof kind !== "string") return;
550
+ const capability = defs.resolve(aliases.resolveKind(kind) ?? kind)?.capability;
551
+ if (typeof capability === "string" && NON_INVOKABLE_CAPABILITIES.has(capability)) {
552
+ diagnostics.push({
553
+ severity: DiagnosticSeverity.Error,
554
+ code: "REFERENCE_KIND_MISMATCH",
555
+ source: SOURCE,
556
+ message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${kind}' is a ${capability} and cannot be invoked in a step — it has no invoke or run method (runtime ERR_RESOURCE_NOT_INVOKABLE)`,
557
+ data: { resource, filePath, path },
558
+ });
559
+ }
560
+ };
561
+
562
+ for (const m of allManifests) {
563
+ const meta = m.metadata as { name?: unknown; source?: unknown; forwardedExport?: unknown };
564
+ if (
565
+ typeof meta?.name !== "string" ||
566
+ REF_VALIDATION_SKIP_KINDS.has(m.kind) ||
567
+ meta.forwardedExport === true
568
+ )
569
+ continue;
570
+ const def = defs.resolve(aliases.resolveKind(m.kind) ?? m.kind);
571
+ const defSchema = def?.schema as Record<string, any> | undefined;
572
+ if (!defSchema?.properties) continue;
573
+ const resource = { kind: m.kind, name: meta.name };
574
+ const filePath = typeof meta.source === "string" ? meta.source : undefined;
575
+
576
+ for (const [fieldName, fieldSchema] of Object.entries(
577
+ defSchema.properties as Record<string, any>,
578
+ )) {
579
+ const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
580
+ const invokeField = stepCtx?.invoke;
581
+ if (!invokeField) continue;
582
+ const steps = m[fieldName];
583
+ if (!Array.isArray(steps)) continue;
584
+ const stepItemSchema = resolveLocalRef(
585
+ fieldSchema.items as Record<string, any> | undefined,
586
+ defSchema,
587
+ );
588
+
589
+ walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s, stepPath) => {
590
+ const invoke = s[invokeField];
591
+ if (invoke === undefined || invoke === null) return;
592
+ validateInvoke(invoke, resource, filePath, `${stepPath}.${invokeField}`);
593
+ });
594
+ }
595
+ }
596
+
597
+ return diagnostics;
598
+ }
599
+
389
600
  /**
390
601
  * Collect every field annotated with `x-telo-error-context` anywhere in a
391
602
  * definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
@@ -1409,12 +1620,20 @@ export class StaticAnalyzer {
1409
1620
  ...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
1410
1621
  );
1411
1622
 
1623
+ // Validate step `invoke` references — the slots the reference field map
1624
+ // deliberately skips (behind the step `$ref`), so a missing instance or a
1625
+ // kind-instead-of-instance ref there is caught statically, not at runtime.
1626
+ diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
1627
+
1412
1628
  // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
1413
1629
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
1414
1630
 
1415
1631
  // Validate provider coherence rules for `provide:` template-target definitions.
1416
1632
  diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
1417
1633
 
1634
+ // Warn about exported kinds lacking a metadata.description (semantic-search input).
1635
+ diagnostics.push(...validateKindDescriptions(allManifests));
1636
+
1418
1637
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
1419
1638
  diagnostics.push(
1420
1639
  ...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv, aliasesByModule, rootModules),
package/src/builtins.ts CHANGED
@@ -227,6 +227,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
227
227
  additionalProperties: true,
228
228
  },
229
229
  source: { type: "string" },
230
+ integrity: { type: "string" },
230
231
  variables: { type: "object" },
231
232
  secrets: { type: "object" },
232
233
  runtime: {
@@ -361,6 +362,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
361
362
  type: "array",
362
363
  items: { type: "string" },
363
364
  },
365
+ // Integrity hash of the decompressed payload tar (`module.tar.gz`,
366
+ // telo.yaml excluded), written by `telo publish`. Pinned transitively
367
+ // by the importer's `#sha256-...` hash over this telo.yaml; verified at
368
+ // extract time. See plans/federated-registries.md.
369
+ filesIntegrity: { type: "string" },
364
370
  // Inline imports — name-keyed map sugar for separate `Telo.Import`
365
371
  // documents. The key is the PascalCase alias (the import's
366
372
  // `metadata.name`). Each value is either a bare source string
@@ -378,6 +384,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
378
384
  required: ["source"],
379
385
  properties: {
380
386
  source: { type: "string" },
387
+ integrity: { type: "string" },
381
388
  variables: { type: "object" },
382
389
  secrets: { type: "object" },
383
390
  runtime: {
@@ -492,6 +499,11 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
492
499
  type: "array",
493
500
  items: { type: "string" },
494
501
  },
502
+ // Integrity hash of the decompressed payload tar (`module.tar.gz`,
503
+ // telo.yaml excluded), written by `telo publish`. Pinned transitively
504
+ // by the importer's `#sha256-...` hash over this telo.yaml; verified at
505
+ // extract time. See plans/federated-registries.md.
506
+ filesIntegrity: { type: "string" },
495
507
  // Inline imports — same name-keyed map sugar as Telo.Application; the
496
508
  // loader desugars each entry into a synthetic Telo.Import. See the
497
509
  // Application schema above and analyzer/nodejs/src/inline-imports.ts.
@@ -505,6 +517,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
505
517
  required: ["source"],
506
518
  properties: {
507
519
  source: { type: "string" },
520
+ integrity: { type: "string" },
508
521
  variables: { type: "object" },
509
522
  secrets: { type: "object" },
510
523
  runtime: {
package/src/index.ts CHANGED
@@ -53,6 +53,16 @@ export type { DocumentPosition } from "./position-metadata.js";
53
53
  export { HttpSource } from "./sources/http-source.js";
54
54
  export { RegistrySource } from "./sources/registry-source.js";
55
55
  export { defaultSources } from "./sources/default-sources.js";
56
+ export {
57
+ splitIntegrity,
58
+ foldIntegrity,
59
+ verifyIntegrity,
60
+ verifiedFetch,
61
+ sha256Base64Url,
62
+ IntegrityError,
63
+ } from "./sources/integrity.js";
64
+ export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
65
+ export type { ParsedModuleRef } from "./sources/module-ref.js";
56
66
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
57
67
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
58
68
  export type {
@@ -1,5 +1,6 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { LoadedFile } from "./loaded-types.js";
3
+ import { foldIntegrity } from "./sources/integrity.js";
3
4
  import { isModuleKind } from "./module-kinds.js";
4
5
  import type { DocumentPosition } from "./position-metadata.js";
5
6
  import type { PositionIndex } from "./types.js";
@@ -42,10 +43,15 @@ export function inlineImportManifests(
42
43
  : undefined;
43
44
  if (!entry || typeof entry.source !== "string") continue;
44
45
 
46
+ // The object form carries integrity as a sibling `integrity:` field; fold it
47
+ // into the source string as a `#sha256-...` fragment so every downstream
48
+ // consumer sees a single representation (the scalar form already inlines it).
49
+ const source = foldIntegrity(entry.source, entry.integrity);
50
+
45
51
  const manifest = {
46
52
  kind: "Telo.Import",
47
53
  metadata: { name: alias },
48
- source: entry.source,
54
+ source,
49
55
  ...(entry.variables !== undefined ? { variables: entry.variables } : {}),
50
56
  ...(entry.secrets !== undefined ? { secrets: entry.secrets } : {}),
51
57
  ...(entry.runtime !== undefined ? { runtime: entry.runtime } : {}),
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_MANIFEST_FILENAME, type ManifestSource } from "../types.js";
2
+ import { splitIntegrity, verifiedFetch } from "./integrity.js";
2
3
 
3
4
  export class HttpSource implements ManifestSource {
4
5
  supports(url: string): boolean {
@@ -6,14 +7,10 @@ export class HttpSource implements ManifestSource {
6
7
  }
7
8
 
8
9
  async read(url: string): Promise<{ text: string; source: string }> {
9
- const fetchUrl = url.includes(".yaml") ? url : `${url}/${DEFAULT_MANIFEST_FILENAME}`;
10
- const response = await fetch(fetchUrl);
11
- if (!response.ok) {
12
- throw new Error(
13
- `Failed to fetch manifest from ${fetchUrl}: ${response.status} ${response.statusText}`,
14
- );
15
- }
16
- return { text: await response.text(), source: fetchUrl };
10
+ const { base, integrity } = splitIntegrity(url);
11
+ const fetchUrl = base.includes(".yaml") ? base : `${base}/${DEFAULT_MANIFEST_FILENAME}`;
12
+ const { text } = await verifiedFetch(fetchUrl, integrity, fetchUrl);
13
+ return { text, source: fetchUrl };
17
14
  }
18
15
 
19
16
  resolveRelative(base: string, relative: string): string {
@@ -0,0 +1,112 @@
1
+ /** Inline module integrity — the `#sha256-<base64url>` fragment carried on a
2
+ * remote import ref. Browser-safe: uses Web Crypto (`crypto.subtle`) and
3
+ * `btoa`, both globals in Node and the browser. No Node built-ins.
4
+ *
5
+ * The fragment is authoritative across every transport: a source's `read()`
6
+ * hashes the fetched bytes and compares against it before the manifest is
7
+ * parsed or cached. A mismatch is a terminal error — never a cache miss. */
8
+
9
+ /** Only a `#<alg>-<base64url>` suffix is treated as integrity; other `#`
10
+ * fragments (rare in module refs) pass through untouched. `sha256` is the
11
+ * only algorithm accepted today; the prefix leaves room to migrate. */
12
+ const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
13
+
14
+ /** A failed integrity/tamper check — always terminal, never best-effort. A
15
+ * distinct type so a caller doing best-effort network handling (e.g. the
16
+ * bundle extractor warning-and-skipping on a fetch blip) can still let a
17
+ * tamper error propagate rather than swallow it. */
18
+ export class IntegrityError extends Error {
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = "IntegrityError";
22
+ }
23
+ }
24
+
25
+ /** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
26
+ * `base` (safe to build fetch URLs and cache paths from) and the fragment in
27
+ * `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
28
+ export function splitIntegrity(ref: string): { base: string; integrity?: string } {
29
+ const match = ref.match(INTEGRITY_FRAGMENT);
30
+ if (!match) return { base: ref };
31
+ return { base: ref.slice(0, match.index), integrity: match[1] };
32
+ }
33
+
34
+ /** Attach an integrity hash to a ref as a `#<alg>-...` fragment. No-op when the
35
+ * hash is absent/non-string or the ref already carries a fragment (an
36
+ * author-authored pin is never overwritten). Inverse of `splitIntegrity`;
37
+ * used to fold the object form's `integrity:` sibling into the source string. */
38
+ export function foldIntegrity(source: string, integrity: unknown): string {
39
+ return typeof integrity === "string" && !source.includes("#")
40
+ ? `${source}#${integrity}`
41
+ : source;
42
+ }
43
+
44
+ function toBase64Url(bytes: Uint8Array): string {
45
+ let binary = "";
46
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
47
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
48
+ }
49
+
50
+ /** Normalize an encoded digest to unpadded base64url so a standard-base64 or
51
+ * padded input still compares equal to our canonical form. */
52
+ function normalizeDigest(value: string): string {
53
+ return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
54
+ }
55
+
56
+ /** SHA-256 of `bytes` as unpadded base64url — the canonical inline-hash form. */
57
+ export async function sha256Base64Url(bytes: Uint8Array): Promise<string> {
58
+ // Copy into a plain ArrayBuffer: a Uint8Array may be backed by a
59
+ // SharedArrayBuffer, which `crypto.subtle.digest` does not accept.
60
+ const buffer = new ArrayBuffer(bytes.byteLength);
61
+ new Uint8Array(buffer).set(bytes);
62
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
63
+ return toBase64Url(new Uint8Array(digest));
64
+ }
65
+
66
+ /** Hash `bytes` and compare against `integrity` (`<alg>-<digest>`). Throws a
67
+ * terminal error on mismatch or an unsupported algorithm. `describe` names the
68
+ * artifact in the error (e.g. the module ref) so the failure is actionable. */
69
+ export async function verifyIntegrity(
70
+ bytes: Uint8Array,
71
+ integrity: string,
72
+ describe: string,
73
+ ): Promise<void> {
74
+ const dash = integrity.indexOf("-");
75
+ const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
76
+ const expected = dash > 0 ? integrity.slice(dash + 1) : "";
77
+ if (algorithm !== "sha256") {
78
+ throw new IntegrityError(
79
+ `Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
80
+ `Only sha256 is supported (sha256-<base64url>).`,
81
+ );
82
+ }
83
+ const actual = await sha256Base64Url(bytes);
84
+ if (actual !== normalizeDigest(expected)) {
85
+ throw new IntegrityError(
86
+ `Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
87
+ `got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
88
+ `the module may have been tampered with or republished.`,
89
+ );
90
+ }
91
+ }
92
+
93
+ /** The single verified network read for remote manifests: fetch `fetchUrl`,
94
+ * verify the raw bytes against `integrity` (when pinned), and return both the
95
+ * bytes and the decoded text. The one choke point every network `ManifestSource`
96
+ * routes through, so verification cannot drift between them. `describe` names
97
+ * the artifact in error messages. */
98
+ export async function verifiedFetch(
99
+ fetchUrl: string,
100
+ integrity: string | undefined,
101
+ describe: string,
102
+ ): Promise<{ bytes: Uint8Array; text: string }> {
103
+ const response = await fetch(fetchUrl);
104
+ if (!response.ok) {
105
+ throw new Error(
106
+ `Failed to fetch manifest ${describe}: ${response.status} ${response.statusText} (${fetchUrl})`,
107
+ );
108
+ }
109
+ const bytes = new Uint8Array(await response.arrayBuffer());
110
+ if (integrity) await verifyIntegrity(bytes, integrity, describe);
111
+ return { bytes, text: new TextDecoder().decode(bytes) };
112
+ }