@telorun/analyzer 0.47.0 → 0.48.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.
@@ -0,0 +1,131 @@
1
+ import { parseLayerIndex, LayerIndexError } from "./artifact-layer-index.js";
2
+ import { ArtifactSelectorError, PLATFORM_AXES, selectorFromQualifiers, } from "./artifact-selector.js";
3
+ import { DiagnosticSeverity } from "./types.js";
4
+ const SOURCE = "telo-analyzer";
5
+ /**
6
+ * Static validation of the module-artifact surface — `kernel/specs/module-artifact.md`.
7
+ *
8
+ * Everything here is decidable from the manifest text alone, and every case would
9
+ * otherwise surface on a *consumer's* machine at controller-resolve time (or, worse,
10
+ * not at all). That is the whole argument: an author who mistypes a platform axis
11
+ * gets a platform-neutral candidate, publish emits one layer, and every host
12
+ * happily loads a binary built for one architecture — silently, forever.
13
+ *
14
+ * Two checks. Note that several candidates *sharing* one selector is not among
15
+ * them: a controller layer holds the entry points of every candidate with that
16
+ * selector (spec §1), which is what every module with two `js` controllers relies
17
+ * on.
18
+ *
19
+ * 1. **Controller selector qualifiers.** `os` / `arch` / `libc` / `siblings` are
20
+ * authored surface. An unknown qualifier is reported rather than ignored, since
21
+ * ignoring is what makes a typo invisible; an invalid value is reported here
22
+ * instead of throwing from the loader later.
23
+ * 2. **The published layer index.** The owner doc's JSON Schema covers shape; the
24
+ * semantic rules — controller-requires-selector, singletons carry none, no
25
+ * duplicate selector, the token grammar (`os: Linux` passes the schema and
26
+ * throws at runtime) — live in the parser, so run it.
27
+ */
28
+ export function validateModuleArtifact(manifests) {
29
+ const out = [];
30
+ for (const manifest of manifests) {
31
+ validateLayerIndex(manifest, out);
32
+ validateControllerSelectors(manifest, out);
33
+ }
34
+ return out;
35
+ }
36
+ const KNOWN_QUALIFIERS = new Set(["path", "siblings", ...PLATFORM_AXES]);
37
+ /** `pkg:telo/local/<format>?…` — the bundled-controller delivery mode. Parsed by
38
+ * hand rather than with a PURL library: the analyzer must stay browser-safe and
39
+ * dependency-light, and the only thing needed here is the qualifier map. */
40
+ function parseBundledPurl(purl) {
41
+ if (!purl.startsWith("pkg:telo/local/"))
42
+ return null;
43
+ const withoutFragment = purl.split("#")[0];
44
+ const [head, query = ""] = withoutFragment.split("?");
45
+ const format = head.slice("pkg:telo/local/".length);
46
+ if (format === "")
47
+ return null;
48
+ const qualifiers = {};
49
+ for (const pair of query.split("&")) {
50
+ if (pair === "")
51
+ continue;
52
+ const eq = pair.indexOf("=");
53
+ if (eq < 0)
54
+ continue;
55
+ qualifiers[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1));
56
+ }
57
+ return { format, qualifiers };
58
+ }
59
+ function validateControllerSelectors(manifest, out) {
60
+ const controllers = manifest.controllers;
61
+ if (!Array.isArray(controllers))
62
+ return;
63
+ const metadata = manifest.metadata;
64
+ const name = metadata?.name;
65
+ const filePath = metadata?.source;
66
+ const resource = { kind: manifest.kind, name };
67
+ controllers.forEach((candidate, index) => {
68
+ if (typeof candidate !== "string")
69
+ return;
70
+ const parsed = parseBundledPurl(candidate);
71
+ if (!parsed)
72
+ return;
73
+ const at = `controllers[${index}]`;
74
+ const unknown = Object.keys(parsed.qualifiers).filter((k) => !KNOWN_QUALIFIERS.has(k));
75
+ for (const key of unknown) {
76
+ out.push({
77
+ severity: DiagnosticSeverity.Error,
78
+ code: "CONTROLLER_UNKNOWN_QUALIFIER",
79
+ source: SOURCE,
80
+ message: `${manifest.kind}/${name ?? "(unnamed)"}: bundled controller qualifier '${key}' is not ` +
81
+ `recognized. Known qualifiers: ${[...KNOWN_QUALIFIERS].sort().join(", ")}. An ` +
82
+ `unrecognized platform axis is ignored, which would make this candidate ` +
83
+ `platform-neutral and offer a single-platform binary to every host.`,
84
+ data: { resource, filePath, path: `${at}?${key}` },
85
+ });
86
+ }
87
+ // Validate the selector; the value is not otherwise needed here, since
88
+ // candidates sharing a selector legitimately share a layer.
89
+ try {
90
+ selectorFromQualifiers(parsed.format, parsed.qualifiers, candidate);
91
+ }
92
+ catch (err) {
93
+ if (!(err instanceof ArtifactSelectorError))
94
+ throw err;
95
+ out.push({
96
+ severity: DiagnosticSeverity.Error,
97
+ code: "CONTROLLER_INVALID_SELECTOR",
98
+ source: SOURCE,
99
+ message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
100
+ data: { resource, filePath, path: at },
101
+ });
102
+ }
103
+ });
104
+ }
105
+ function validateLayerIndex(manifest, out) {
106
+ if (manifest.kind !== "Telo.Application" && manifest.kind !== "Telo.Library")
107
+ return;
108
+ const layers = manifest.layers;
109
+ if (layers === undefined)
110
+ return;
111
+ const metadata = manifest.metadata;
112
+ const name = metadata?.name;
113
+ try {
114
+ parseLayerIndex(layers);
115
+ }
116
+ catch (err) {
117
+ if (!(err instanceof LayerIndexError) && !(err instanceof ArtifactSelectorError))
118
+ throw err;
119
+ out.push({
120
+ severity: DiagnosticSeverity.Error,
121
+ code: "INVALID_LAYER_INDEX",
122
+ source: SOURCE,
123
+ message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
124
+ data: {
125
+ resource: { kind: manifest.kind, name },
126
+ filePath: metadata?.source,
127
+ path: "layers",
128
+ },
129
+ });
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -48,7 +48,7 @@
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.59.0"
51
+ "@telorun/sdk": "0.60.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"
package/src/analyzer.ts CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
52
52
  import { validateExtends } from "./validate-extends.js";
53
53
  import { validateLogging } from "./validate-logging.js";
54
+ import { validateModuleArtifact } from "./validate-module-artifact.js";
54
55
  import { validateBaseMapping } from "./validate-base-mapping.js";
55
56
  import { validateNestedInlineResources } from "./validate-nested-inline.js";
56
57
  import { validateProviderCoherence } from "./validate-provider-coherence.js";
@@ -1202,6 +1203,11 @@ export class StaticAnalyzer {
1202
1203
  // §14.1 / §10.3: redaction paths and `on_full: block` are statically
1203
1204
  // detectable, so they fail `telo check` rather than only at boot.
1204
1205
  diagnostics.push(...validateLogging(allManifests, defs, aliases, aliasesByModule));
1206
+ // Module-artifact surface: bundled-controller selector qualifiers and the
1207
+ // published `layers:` index. Every case is decidable from the manifest and
1208
+ // would otherwise fail on a consumer's machine — or, for a mistyped platform
1209
+ // axis, silently offer one platform's binary to every host.
1210
+ diagnostics.push(...validateModuleArtifact(allManifests));
1205
1211
  }
1206
1212
  resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
1207
1213
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The **layer index** of `kernel/specs/module-artifact.md` — the `layers:` block
3
+ * a published `telo.yaml` carries, listing every layer of the module artifact
4
+ * except the manifest layer itself.
5
+ *
6
+ * Why it lives in `telo.yaml` rather than in the OCI manifest, which has layers
7
+ * natively: a Telo import is pinned to a hash of `telo.yaml` and nothing else.
8
+ * The OCI manifest sits one level up, is fetched by a reference that is usually
9
+ * a mutable tag, and is never hashed by Telo — so digests held only there would
10
+ * leave the pin proving nothing about the payload. Pinning the OCI manifest
11
+ * instead is circular: `telo.yaml` is one of its layers.
12
+ *
13
+ * The manifest layer therefore has no entry — a hash of `telo.yaml` cannot sit
14
+ * inside `telo.yaml`. It is pinned by the importer's `#sha256-...` instead, so
15
+ * the chain reads `import pin -> telo.yaml -> blob digest -> layer contents`.
16
+ *
17
+ * Each entry carries two digests, answering different questions:
18
+ * - `blob` — the OCI blob digest over the pushed bytes. It *addresses* the
19
+ * layer, so a client pulls by digest and never reads the OCI layer list, and
20
+ * it verifies the transfer. Publish pushes payload blobs first and injects
21
+ * their digests here, then pushes the manifest blob, so nothing is circular.
22
+ * - `integrity` — the content digest (`computeFilesIntegrity`) over that
23
+ * layer's own files, independent of tar/gzip framing. It verifies what is
24
+ * already extracted on disk and can be re-derived from it without re-tarring,
25
+ * which is what makes a per-layer cache marker checkable.
26
+ *
27
+ * Browser-safe: `telo check`, the editor and the hub validate an index through
28
+ * this module; only the kernel fetches and extracts.
29
+ */
30
+
31
+ import {
32
+ isLayerRole,
33
+ normalizeSelector,
34
+ selectorKey,
35
+ selectorMatches,
36
+ type ArtifactSelector,
37
+ type LayerRole,
38
+ type PlatformTarget,
39
+ } from "./artifact-selector.js";
40
+
41
+ /** OCI content digest: `sha256:` + 64 lowercase hex. */
42
+ const BLOB_DIGEST = /^sha256:[0-9a-f]{64}$/;
43
+
44
+ /** Telo content digest: `sha256-` + unpadded base64url of 32 bytes. */
45
+ const CONTENT_DIGEST = /^sha256-[A-Za-z0-9_-]{43}$/;
46
+
47
+ export interface ArtifactLayer {
48
+ role: LayerRole;
49
+ /** Present on `controller` layers only. */
50
+ selector?: ArtifactSelector;
51
+ /** OCI blob digest — addresses the layer and verifies the transfer. */
52
+ blob: string;
53
+ /** Content digest over the layer's files — verifies what is on disk. */
54
+ integrity: string;
55
+ }
56
+
57
+ export class LayerIndexError extends Error {
58
+ readonly code = "INVALID_LAYER_INDEX";
59
+
60
+ constructor(detail: string) {
61
+ super(detail);
62
+ this.name = "LayerIndexError";
63
+ }
64
+ }
65
+
66
+ function digest(field: "blob" | "integrity", raw: unknown, describe: string): string {
67
+ if (typeof raw !== "string" || raw === "") {
68
+ throw new LayerIndexError(`${describe}: ${field} is required and must be a string.`);
69
+ }
70
+ const pattern = field === "blob" ? BLOB_DIGEST : CONTENT_DIGEST;
71
+ if (!pattern.test(raw)) {
72
+ throw new LayerIndexError(
73
+ field === "blob"
74
+ ? `${describe}: blob '${raw}' is not an OCI digest (expected 'sha256:' + 64 hex characters).`
75
+ : `${describe}: integrity '${raw}' is not a content digest (expected 'sha256-' + 43 base64url characters).`,
76
+ );
77
+ }
78
+ return raw;
79
+ }
80
+
81
+ /**
82
+ * Parse and validate a `layers:` value off an owner document. Order is
83
+ * preserved — when several controller layers match a target, precedence is
84
+ * declaration order, so the author controls it.
85
+ */
86
+ export function parseLayerIndex(value: unknown, describe = "layers"): ArtifactLayer[] {
87
+ if (!Array.isArray(value)) {
88
+ throw new LayerIndexError(`${describe}: expected an array of layer entries.`);
89
+ }
90
+ const layers: ArtifactLayer[] = [];
91
+ const seenSelectors = new Set<string>();
92
+ const seenSingletons = new Set<LayerRole>();
93
+
94
+ value.forEach((raw, index) => {
95
+ const where = `${describe}[${index}]`;
96
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
97
+ throw new LayerIndexError(`${where}: expected an object.`);
98
+ }
99
+ const entry = raw as Record<string, unknown>;
100
+ if (!isLayerRole(entry.role)) {
101
+ throw new LayerIndexError(
102
+ `${where}: role must be one of 'controller', 'assets', 'common'; got ` +
103
+ `${entry.role === undefined ? "nothing" : `'${String(entry.role)}'`}.`,
104
+ );
105
+ }
106
+ const role = entry.role;
107
+
108
+ let selector: ArtifactSelector | undefined;
109
+ if (role === "controller") {
110
+ if (entry.selector === undefined) {
111
+ throw new LayerIndexError(`${where}: a controller layer must declare a selector.`);
112
+ }
113
+ selector = normalizeSelector(entry.selector, where);
114
+ const key = selectorKey(selector);
115
+ if (seenSelectors.has(key)) {
116
+ throw new LayerIndexError(
117
+ `${where}: a second controller layer claims the selector ${key}. ` +
118
+ `Each selector addresses exactly one layer.`,
119
+ );
120
+ }
121
+ seenSelectors.add(key);
122
+ } else {
123
+ if (entry.selector !== undefined) {
124
+ throw new LayerIndexError(
125
+ `${where}: a '${role}' layer must not declare a selector — it is a singleton.`,
126
+ );
127
+ }
128
+ if (seenSingletons.has(role)) {
129
+ throw new LayerIndexError(`${where}: a second '${role}' layer is declared.`);
130
+ }
131
+ seenSingletons.add(role);
132
+ }
133
+
134
+ layers.push({
135
+ role,
136
+ ...(selector ? { selector } : {}),
137
+ blob: digest("blob", entry.blob, where),
138
+ integrity: digest("integrity", entry.integrity, where),
139
+ });
140
+ });
141
+
142
+ return layers;
143
+ }
144
+
145
+ /** The singleton layer for a role, or undefined when the artifact has none. */
146
+ export function singletonLayer(
147
+ layers: readonly ArtifactLayer[],
148
+ role: Exclude<LayerRole, "controller">,
149
+ ): ArtifactLayer | undefined {
150
+ return layers.find((l) => l.role === role);
151
+ }
152
+
153
+ /** Every controller layer matching `target`, in declaration order. Used by
154
+ * `telo install` to warm a cache for one platform. */
155
+ export function matchControllerLayers(
156
+ layers: readonly ArtifactLayer[],
157
+ target: PlatformTarget,
158
+ ): ArtifactLayer[] {
159
+ return layers.filter(
160
+ (l) => l.role === "controller" && l.selector !== undefined && selectorMatches(l.selector, target),
161
+ );
162
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The **selector** of `kernel/specs/module-artifact.md` — the tuple a bundled
3
+ * controller candidate is chosen by, and the key a controller layer of a module
4
+ * artifact is stored under.
5
+ *
6
+ * A selector is `format` plus the optional platform axes `os` / `arch` / `libc`.
7
+ * Matching is one rule, applied per axis: an axis the selector omits accepts
8
+ * anything, an axis it states must be equal. That is what lets a `js` controller
9
+ * be platform-neutral and a `napi` controller be pinned to one triple, with no
10
+ * special case for either.
11
+ *
12
+ * Browser-safe and dependency-free by construction. Three consumers must agree
13
+ * on this grammar or a published artifact stops loading: `telo publish`
14
+ * (partitioning files into layers), `telo install --platform` (deciding which
15
+ * layers to pre-fetch), and the kernel's bundle controller loader (matching a
16
+ * candidate against the host). Keeping it here — beside the redaction path
17
+ * parser, for the same reason — means one implementation rather than three that
18
+ * drift.
19
+ *
20
+ * PURL *syntax* is deliberately not parsed here. Callers hand in the format and
21
+ * an already-decoded qualifier map, so this module owns selector semantics while
22
+ * the caller owns its own package-URL library. The Node vocabulary is likewise
23
+ * not known here: `process.platform` / `process.arch` are mapped to the
24
+ * canonical OCI/GOOS names at the kernel boundary, since these values are
25
+ * published into OCI descriptors.
26
+ */
27
+
28
+ /** The role a layer plays in a module artifact. `controller` layers carry a
29
+ * selector; `assets` and `common` are singletons and carry none. */
30
+ export type LayerRole = "controller" | "assets" | "common";
31
+
32
+ export const LAYER_ROLES: readonly LayerRole[] = ["controller", "assets", "common"];
33
+
34
+ export function isLayerRole(value: unknown): value is LayerRole {
35
+ return typeof value === "string" && (LAYER_ROLES as readonly string[]).includes(value);
36
+ }
37
+
38
+ /** The platform axes, in canonical order. Not a closed vocabulary of *values* —
39
+ * new architectures appear without a Telo release — only of axis names. */
40
+ export const PLATFORM_AXES = ["os", "arch", "libc"] as const;
41
+
42
+ export type PlatformAxis = (typeof PLATFORM_AXES)[number];
43
+
44
+ export interface ArtifactSelector {
45
+ /** Bundled controller format: the PURL name segment (`js`, `napi`, `wasm`, …). */
46
+ format: string;
47
+ os?: string;
48
+ arch?: string;
49
+ libc?: string;
50
+ }
51
+
52
+ /** What a selector is matched against: the host the kernel runs on, or the
53
+ * target `telo install --platform` is warming a cache for. An axis left
54
+ * undetermined (a host whose libc cannot be detected) matches no selector that
55
+ * constrains it — refusing to load is the safe direction for a native binary. */
56
+ export interface PlatformTarget {
57
+ format?: string;
58
+ os?: string;
59
+ arch?: string;
60
+ libc?: string;
61
+ }
62
+
63
+ export class ArtifactSelectorError extends Error {
64
+ readonly code = "INVALID_ARTIFACT_SELECTOR";
65
+
66
+ constructor(detail: string) {
67
+ super(detail);
68
+ this.name = "ArtifactSelectorError";
69
+ }
70
+ }
71
+
72
+ /** Canonical token shape for every selector value. Lowercase, so the same
73
+ * platform written two ways is one layer rather than two. */
74
+ const TOKEN = /^[a-z0-9][a-z0-9_.-]*$/;
75
+
76
+ function normalizeToken(axis: string, raw: unknown, describe: string): string {
77
+ if (typeof raw !== "string") {
78
+ throw new ArtifactSelectorError(
79
+ `${describe}: ${axis} must be a string, got ${raw === null ? "null" : typeof raw}.`,
80
+ );
81
+ }
82
+ const value = raw.trim().toLowerCase();
83
+ if (!TOKEN.test(value)) {
84
+ throw new ArtifactSelectorError(
85
+ `${describe}: ${axis} value '${raw}' is not a canonical token. ` +
86
+ `Use lowercase letters, digits, '.', '-' or '_', starting with a letter or digit.`,
87
+ );
88
+ }
89
+ return value;
90
+ }
91
+
92
+ /**
93
+ * Build a selector from a controller candidate's format and qualifier map.
94
+ * Qualifier keys other than the platform axes are ignored — `path` and the
95
+ * sibling list live in the same map and are not part of the selector.
96
+ */
97
+ export function selectorFromQualifiers(
98
+ format: unknown,
99
+ qualifiers: Readonly<Record<string, unknown>> | undefined,
100
+ describe = "controller selector",
101
+ ): ArtifactSelector {
102
+ const selector: ArtifactSelector = {
103
+ format: normalizeToken("format", format, describe),
104
+ };
105
+ for (const axis of PLATFORM_AXES) {
106
+ const raw = qualifiers?.[axis];
107
+ if (raw === undefined || raw === "") continue;
108
+ selector[axis] = normalizeToken(axis, raw, describe);
109
+ }
110
+ return selector;
111
+ }
112
+
113
+ /** Validate and normalize a selector read off a published layer index. */
114
+ export function normalizeSelector(
115
+ value: unknown,
116
+ describe = "layer selector",
117
+ ): ArtifactSelector {
118
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
119
+ throw new ArtifactSelectorError(`${describe}: expected an object of selector axes.`);
120
+ }
121
+ const record = value as Record<string, unknown>;
122
+ const unknown = Object.keys(record).filter(
123
+ (k) => k !== "format" && !(PLATFORM_AXES as readonly string[]).includes(k),
124
+ );
125
+ if (unknown.length > 0) {
126
+ throw new ArtifactSelectorError(
127
+ `${describe}: unknown selector ${unknown.length === 1 ? "axis" : "axes"} ` +
128
+ `${unknown.map((k) => `'${k}'`).join(", ")}. Known axes: format, ${PLATFORM_AXES.join(", ")}.`,
129
+ );
130
+ }
131
+ return selectorFromQualifiers(record.format, record, describe);
132
+ }
133
+
134
+ /**
135
+ * The canonical stable key for a selector: sorted `axis=value` pairs joined by
136
+ * `;`. Used to group entry points into layers at publish time and to detect two
137
+ * layers claiming the same selector. Sorted and fully qualified so no two
138
+ * distinct selectors can collide and no one selector has two spellings.
139
+ */
140
+ export function selectorKey(selector: ArtifactSelector): string {
141
+ const pairs: string[] = [`format=${selector.format}`];
142
+ for (const axis of PLATFORM_AXES) {
143
+ const value = selector[axis];
144
+ if (value !== undefined) pairs.push(`${axis}=${value}`);
145
+ }
146
+ return pairs.sort().join(";");
147
+ }
148
+
149
+ /** Human-facing rendering for diagnostics and the publish partition printout. */
150
+ export function describeSelector(selector: ArtifactSelector): string {
151
+ const platform = PLATFORM_AXES.map((axis) => selector[axis]).filter(
152
+ (v): v is string => v !== undefined,
153
+ );
154
+ return platform.length === 0 ? selector.format : `${selector.format} (${platform.join("/")})`;
155
+ }
156
+
157
+ /**
158
+ * The matching rule: every axis the selector states must equal the target's;
159
+ * every axis it omits accepts anything. A target axis left undetermined matches
160
+ * only a selector that does not constrain it — a host whose libc is unknown must
161
+ * not be handed a `libc=gnu` binary on the assumption it will run.
162
+ */
163
+ export function selectorMatches(selector: ArtifactSelector, target: PlatformTarget): boolean {
164
+ if (target.format !== undefined && selector.format !== target.format) return false;
165
+ for (const axis of PLATFORM_AXES) {
166
+ const constraint = selector[axis];
167
+ if (constraint === undefined) continue;
168
+ if (target[axis] !== constraint) return false;
169
+ }
170
+ return true;
171
+ }
package/src/builtins.ts CHANGED
@@ -23,6 +23,53 @@ const PROVENANCE_METADATA = {
23
23
  documentation: { type: "string" },
24
24
  };
25
25
 
26
+ /** Author-declared subset of `files:` that ships in the artifact's lazily
27
+ * materialized `assets` layer. Optional: an unclaimed file joins the `common`
28
+ * layer, which is pulled alongside any controller layer, so omitting this costs
29
+ * laziness rather than correctness. See kernel/specs/module-artifact.md. */
30
+ const ASSETS_FILES_SCHEMA = {
31
+ type: "array",
32
+ items: { type: "string" },
33
+ };
34
+
35
+ /** The published layer index, written by `telo publish` (never hand-authored).
36
+ * One entry per layer except the manifest layer, which cannot list its own hash
37
+ * inside itself and is pinned by the importer's `#sha256-...` instead. Shape and
38
+ * matching rules are normative in kernel/specs/module-artifact.md; the parser
39
+ * that enforces them is `artifact-layer-index.ts`. */
40
+ const LAYER_INDEX_SCHEMA = {
41
+ type: "array",
42
+ items: {
43
+ type: "object",
44
+ required: ["role", "blob", "integrity"],
45
+ properties: {
46
+ role: { type: "string", enum: ["controller", "assets", "common"] },
47
+ selector: {
48
+ type: "object",
49
+ required: ["format"],
50
+ properties: {
51
+ format: { type: "string" },
52
+ os: { type: "string" },
53
+ arch: { type: "string" },
54
+ libc: { type: "string" },
55
+ },
56
+ additionalProperties: false,
57
+ },
58
+ blob: { type: "string", pattern: "^sha256:[0-9a-f]{64}$" },
59
+ integrity: { type: "string", pattern: "^sha256-[A-Za-z0-9_-]{43}$" },
60
+ },
61
+ additionalProperties: false,
62
+ },
63
+ };
64
+
65
+ /** The pre-layers payload digest, superseded by the per-layer `integrity` values
66
+ * in `layers:`. Accepted and ignored, for one reason only: a module published in
67
+ * the old single-blob shape must reach the *actionable* failure — the controller
68
+ * loader's "republish the module" error — instead of dying earlier on
69
+ * `must NOT have additional properties`, which tells an author nothing. Nothing
70
+ * reads this field. */
71
+ const LEGACY_FILES_INTEGRITY_SCHEMA = { type: "string" };
72
+
26
73
  /** The six named levels of `kernel/specs/logging.md` §5.1. The full 1–24 OTel
27
74
  * range stays valid on the wire; only these are nameable in a manifest. */
28
75
  const LOG_LEVEL_ENUM = ["trace", "debug", "info", "warn", "error", "fatal"];
@@ -529,21 +576,18 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
529
576
  type: "array",
530
577
  items: { type: "string" },
531
578
  },
532
- // Files bundled alongside `telo.yaml` into the module's registry
533
- // artifact (`module.tar.gz`) static assets served by Http.Static,
534
- // templates, etc. Ordered `.gitignore`-style patterns resolved against
535
- // the manifest dir at publish time. Analyzer-only role: accept the
536
- // field (the schema is additionalProperties:false); the analyzer never
537
- // reads the assets. See kernel/nodejs/plans/bundle-controllers.md.
579
+ // Files bundled alongside `telo.yaml` into the module's artifact —
580
+ // controller bundles, static assets served by Http.Static, templates,
581
+ // etc. Ordered `.gitignore`-style patterns resolved against the manifest
582
+ // dir at publish time. Analyzer-only role: accept the field (the schema
583
+ // is additionalProperties:false); the analyzer never reads the payload.
538
584
  files: {
539
585
  type: "array",
540
586
  items: { type: "string" },
541
587
  },
542
- // Integrity hash of the decompressed payload tar (`module.tar.gz`,
543
- // telo.yaml excluded), written by `telo publish`. Pinned transitively
544
- // by the importer's `#sha256-...` hash over this telo.yaml; verified at
545
- // extract time. See plans/federated-registries.md.
546
- filesIntegrity: { type: "string" },
588
+ assets: ASSETS_FILES_SCHEMA,
589
+ layers: LAYER_INDEX_SCHEMA,
590
+ filesIntegrity: LEGACY_FILES_INTEGRITY_SCHEMA,
547
591
  // Inline imports — name-keyed map sugar for separate `Telo.Import`
548
592
  // documents. The key is the PascalCase alias (the import's
549
593
  // `metadata.name`). Each value is either a bare source string
@@ -680,18 +724,16 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
680
724
  type: "array",
681
725
  items: { type: "string" },
682
726
  },
683
- // Files bundled into the module's registry artifact — same semantics as
684
- // the Telo.Application `files` field above (a library may ship bundled
685
- // templates, migrations, seed data).
727
+ // Files bundled into the module's artifact — same semantics as the
728
+ // Telo.Application `files` field above (a library may ship bundled
729
+ // controllers, templates, migrations, seed data).
686
730
  files: {
687
731
  type: "array",
688
732
  items: { type: "string" },
689
733
  },
690
- // Integrity hash of the decompressed payload tar (`module.tar.gz`,
691
- // telo.yaml excluded), written by `telo publish`. Pinned transitively
692
- // by the importer's `#sha256-...` hash over this telo.yaml; verified at
693
- // extract time. See plans/federated-registries.md.
694
- filesIntegrity: { type: "string" },
734
+ assets: ASSETS_FILES_SCHEMA,
735
+ layers: LAYER_INDEX_SCHEMA,
736
+ filesIntegrity: LEGACY_FILES_INTEGRITY_SCHEMA,
695
737
  // Inline imports — same name-keyed map sugar as Telo.Application; the
696
738
  // loader desugars each entry into a synthetic Telo.Import. See the
697
739
  // Application schema above and analyzer/nodejs/src/inline-imports.ts.
package/src/index.ts CHANGED
@@ -120,6 +120,31 @@ export {
120
120
  urlManifestCacheCoords,
121
121
  } from "./sources/manifest-cache.js";
122
122
  export type { ManifestCacheCoords } from "./sources/manifest-cache.js";
123
+ export {
124
+ LAYER_ROLES,
125
+ PLATFORM_AXES,
126
+ ArtifactSelectorError,
127
+ describeSelector,
128
+ isLayerRole,
129
+ normalizeSelector,
130
+ selectorFromQualifiers,
131
+ selectorKey,
132
+ selectorMatches,
133
+ } from "./artifact-selector.js";
134
+ export type {
135
+ ArtifactSelector,
136
+ LayerRole,
137
+ PlatformAxis,
138
+ PlatformTarget,
139
+ } from "./artifact-selector.js";
140
+ export {
141
+ LayerIndexError,
142
+ matchControllerLayers,
143
+ parseLayerIndex,
144
+ singletonLayer,
145
+ } from "./artifact-layer-index.js";
146
+ export type { ArtifactLayer } from "./artifact-layer-index.js";
147
+ export { validateModuleArtifact } from "./validate-module-artifact.js";
123
148
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
124
149
  export { documentToAst, parseToAst } from "./yaml-ast.js";
125
150
  export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";