@telorun/analyzer 0.60.0 → 0.61.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.
@@ -1,5 +1,6 @@
1
1
  import { parseLayerIndex, LayerIndexError } from "./artifact-layer-index.js";
2
- import { ArtifactSelectorError, PLATFORM_AXES, selectorFromQualifiers, } from "./artifact-selector.js";
2
+ import { ArtifactSelectorError, PLATFORM_AXES, selectorFromQualifiers, selectorKey, } from "./artifact-selector.js";
3
+ import { readLibraryCandidates } from "./module-library.js";
3
4
  import { DiagnosticSeverity } from "./types.js";
4
5
  const SOURCE = "telo-analyzer";
5
6
  /**
@@ -30,9 +31,60 @@ export function validateModuleArtifact(manifests) {
30
31
  for (const manifest of manifests) {
31
32
  validateLayerIndex(manifest, out);
32
33
  validateControllerSelectors(manifest, out);
34
+ validateLibraryCandidates(manifest, out);
33
35
  }
34
36
  return out;
35
37
  }
38
+ /**
39
+ * The `exports.code:` block on a `Telo.Library` doc.
40
+ *
41
+ * Reported here rather than left to the loader for the same reason a controller
42
+ * selector is: an entry that cannot be read names no entry point, so a
43
+ * consumer's bundle falls back to *inlining* the library — the module scope
44
+ * duplication this whole mechanism exists to remove — and it does so silently, on
45
+ * someone else's machine.
46
+ */
47
+ function validateLibraryCandidates(manifest, out) {
48
+ // `Telo.Library` only. An application is a root with no importer, so it has no
49
+ // `exports:` block at all — and its schema is `additionalProperties: false`,
50
+ // so AJV already rejects the key by name in this same pass. A second
51
+ // diagnostic on that node would be two squiggles saying one thing.
52
+ if (manifest.kind !== "Telo.Library")
53
+ return;
54
+ const metadata = manifest.metadata;
55
+ const { candidates, problems } = readLibraryCandidates(manifest);
56
+ const resource = { kind: manifest.kind, name: metadata?.name };
57
+ for (const problem of problems) {
58
+ out.push({
59
+ severity: DiagnosticSeverity.Error,
60
+ code: "LIBRARY_CANDIDATE_INVALID",
61
+ source: SOURCE,
62
+ message: `Telo.Library/${metadata?.name ?? "(unnamed)"}: ${problem.origin}: ${problem.detail}`,
63
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
64
+ });
65
+ }
66
+ // One specifier per selector: two candidates of one format claiming the same
67
+ // specifier leave the resolution ambiguous, and two specifiers for one format
68
+ // mean a consumer's import resolves by whichever candidate is read first.
69
+ const seen = new Map();
70
+ for (const candidate of candidates) {
71
+ const key = selectorKey(candidate.selector);
72
+ const first = seen.get(key);
73
+ if (first) {
74
+ out.push({
75
+ severity: DiagnosticSeverity.Error,
76
+ code: "LIBRARY_CANDIDATE_DUPLICATE",
77
+ source: SOURCE,
78
+ message: `Telo.Library/${metadata?.name ?? "(unnamed)"}: two 'exports.code' entries declare the ` +
79
+ `selector ${key} ('${first.specifier}' and '${candidate.specifier}'). A module has one ` +
80
+ `entry point per format — which is what makes "one specifier, one module scope" true.`,
81
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
82
+ });
83
+ continue;
84
+ }
85
+ seen.set(key, candidate);
86
+ }
87
+ }
36
88
  /** `local_path` names the source `path=` was built from, so a working copy runs
37
89
  * with no build step. It is inert in a published artifact — which ships no
38
90
  * `src/` — and contributes nothing to the selector, so it never affects which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -29,8 +29,10 @@
29
29
  */
30
30
 
31
31
  import {
32
+ LAYER_ROLES,
32
33
  isLayerRole,
33
34
  normalizeSelector,
35
+ roleCarriesSelector,
34
36
  selectorKey,
35
37
  selectorMatches,
36
38
  type ArtifactSelector,
@@ -46,7 +48,7 @@ const CONTENT_DIGEST = /^sha256-[A-Za-z0-9_-]{43}$/;
46
48
 
47
49
  export interface ArtifactLayer {
48
50
  role: LayerRole;
49
- /** Present on `controller` layers only. */
51
+ /** Present on the code-bearing roles (`controller`, `library`) only. */
50
52
  selector?: ArtifactSelector;
51
53
  /** OCI blob digest — addresses the layer and verifies the transfer. */
52
54
  blob: string;
@@ -97,25 +99,35 @@ export function parseLayerIndex(value: unknown, describe = "layers"): ArtifactLa
97
99
  throw new LayerIndexError(`${where}: expected an object.`);
98
100
  }
99
101
  const entry = raw as Record<string, unknown>;
100
- if (!isLayerRole(entry.role)) {
102
+ if (typeof entry.role !== "string" || entry.role === "") {
101
103
  throw new LayerIndexError(
102
- `${where}: role must be one of 'controller', 'assets', 'common'; got ` +
103
- `${entry.role === undefined ? "nothing" : `'${String(entry.role)}'`}.`,
104
+ `${where}: role is required and must be one of ${LAYER_ROLES.map((r) => `'${r}'`).join(", ")}; ` +
105
+ `got ${entry.role === undefined ? "nothing" : `'${String(entry.role)}'`}.`,
104
106
  );
105
107
  }
108
+ // A role this runtime does not know is SKIPPED, never rejected. Roles are
109
+ // added over time, and a runtime that cannot name one cannot need it — while
110
+ // throwing would make the whole manifest unreadable, so a module gaining a
111
+ // layer for a newer runtime would stop loading on an older one entirely
112
+ // rather than merely lacking that layer. The error stays for a structurally
113
+ // invalid entry, which is a malformed index rather than a newer one.
114
+ if (!isLayerRole(entry.role)) return;
106
115
  const role = entry.role;
107
116
 
108
117
  let selector: ArtifactSelector | undefined;
109
- if (role === "controller") {
118
+ if (roleCarriesSelector(role)) {
110
119
  if (entry.selector === undefined) {
111
- throw new LayerIndexError(`${where}: a controller layer must declare a selector.`);
120
+ throw new LayerIndexError(`${where}: a ${role} layer must declare a selector.`);
112
121
  }
113
122
  selector = normalizeSelector(entry.selector, where);
114
- const key = selectorKey(selector);
123
+ // Scoped by role: a module's `js` controller layer and its `js` library
124
+ // layer are different layers with the same selector, and only a collision
125
+ // *within* one role means two layers claim one address.
126
+ const key = `${role}\0${selectorKey(selector)}`;
115
127
  if (seenSelectors.has(key)) {
116
128
  throw new LayerIndexError(
117
- `${where}: a second controller layer claims the selector ${key}. ` +
118
- `Each selector addresses exactly one layer.`,
129
+ `${where}: a second ${role} layer claims the selector ${selectorKey(selector)}. ` +
130
+ `Each selector addresses exactly one layer of a role.`,
119
131
  );
120
132
  }
121
133
  seenSelectors.add(key);
@@ -145,18 +157,39 @@ export function parseLayerIndex(value: unknown, describe = "layers"): ArtifactLa
145
157
  /** The singleton layer for a role, or undefined when the artifact has none. */
146
158
  export function singletonLayer(
147
159
  layers: readonly ArtifactLayer[],
148
- role: Exclude<LayerRole, "controller">,
160
+ role: Exclude<LayerRole, "controller" | "library">,
149
161
  ): ArtifactLayer | undefined {
150
162
  return layers.find((l) => l.role === role);
151
163
  }
152
164
 
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(
165
+ /** The layer of one code role carrying exactly `selector`, or undefined.
166
+ *
167
+ * By exact key rather than by re-matching a host: the candidate being resolved
168
+ * already *is* one selector, and it is by construction the key of the layer
169
+ * that carries it. */
170
+ export function codeLayerFor(
171
+ layers: readonly ArtifactLayer[],
172
+ role: Extract<LayerRole, "controller" | "library">,
173
+ selector: ArtifactSelector,
174
+ ): ArtifactLayer | undefined {
175
+ const key = selectorKey(selector);
176
+ return layers.find(
177
+ (l) => l.role === role && l.selector !== undefined && selectorKey(l.selector) === key,
178
+ );
179
+ }
180
+
181
+ /** Every code layer — controller and library alike — matching `target`, in
182
+ * declaration order. Used by `telo install` to warm a cache for one platform:
183
+ * a library layer is as much a prerequisite of an offline run as the controller
184
+ * layer that imports it. */
185
+ export function matchCodeLayers(
156
186
  layers: readonly ArtifactLayer[],
157
187
  target: PlatformTarget,
158
188
  ): ArtifactLayer[] {
159
189
  return layers.filter(
160
- (l) => l.role === "controller" && l.selector !== undefined && selectorMatches(l.selector, target),
190
+ (l) =>
191
+ (l.role === "controller" || l.role === "library") &&
192
+ l.selector !== undefined &&
193
+ selectorMatches(l.selector, target),
161
194
  );
162
195
  }
@@ -25,16 +25,27 @@
25
25
  * published into OCI descriptors.
26
26
  */
27
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";
28
+ /** The role a layer plays in a module artifact. `controller` and `library` layers
29
+ * carry a selector; `assets` and `common` are singletons and carry none. */
30
+ export type LayerRole = "controller" | "library" | "assets" | "common";
31
31
 
32
- export const LAYER_ROLES: readonly LayerRole[] = ["controller", "assets", "common"];
32
+ export const LAYER_ROLES: readonly LayerRole[] = ["controller", "library", "assets", "common"];
33
33
 
34
34
  export function isLayerRole(value: unknown): value is LayerRole {
35
35
  return typeof value === "string" && (LAYER_ROLES as readonly string[]).includes(value);
36
36
  }
37
37
 
38
+ /** The roles that hold executable code, and are therefore per format rather than
39
+ * singletons. A `library` layer is per selector for the same reason a
40
+ * `controller` layer is: a module's JS entry point and its future Rust one are
41
+ * different files, and a consumer resolves the one its own runtime can import.
42
+ * A singleton would be wrong the moment a second runtime ships. */
43
+ export const CODE_LAYER_ROLES: readonly LayerRole[] = ["controller", "library"];
44
+
45
+ export function roleCarriesSelector(role: LayerRole): boolean {
46
+ return (CODE_LAYER_ROLES as readonly string[]).includes(role);
47
+ }
48
+
38
49
  /** The platform axes, in canonical order. Not a closed vocabulary of *values* —
39
50
  * new architectures appear without a Telo release — only of axis names. */
40
51
  export const PLATFORM_AXES = ["os", "arch", "libc"] as const;
package/src/builtins.ts CHANGED
@@ -33,6 +33,33 @@ const ASSETS_FILES_SCHEMA = {
33
33
  items: { type: "string" },
34
34
  };
35
35
 
36
+ /** `exports.code` — the entry point a sibling module's controller bundle resolves
37
+ * this library's bare specifier to, one per format.
38
+ *
39
+ * Data rather than a package URL: `controllers:` needs a PURL because it can name
40
+ * an ecosystem fetch (`pkg:npm`, `pkg:cargo`), while this always names a file the
41
+ * module already ships, so the type/namespace segments would be constant noise —
42
+ * and a query string is one opaque box to the visual editor. `format` plus the
43
+ * platform axes build the same `ArtifactSelector` a controller candidate does.
44
+ * Semantics and diagnostics live in `analyzer/nodejs/src/module-library.ts`. */
45
+ const LIBRARY_CANDIDATES_SCHEMA = {
46
+ type: "array",
47
+ items: {
48
+ type: "object",
49
+ required: ["specifier", "format", "path"],
50
+ properties: {
51
+ specifier: { type: "string" },
52
+ format: { type: "string" },
53
+ path: { type: "string" },
54
+ source: { type: "string" },
55
+ os: { type: "string" },
56
+ arch: { type: "string" },
57
+ libc: { type: "string" },
58
+ },
59
+ additionalProperties: false,
60
+ },
61
+ };
62
+
36
63
  /** The published layer index, written by `telo publish` (never hand-authored).
37
64
  * One entry per layer except the manifest layer, which cannot list its own hash
38
65
  * inside itself and is pinned by the importer's `#sha256-...` instead. Shape and
@@ -44,7 +71,7 @@ const LAYER_INDEX_SCHEMA = {
44
71
  type: "object",
45
72
  required: ["role", "blob", "integrity"],
46
73
  properties: {
47
- role: { type: "string", enum: ["controller", "assets", "common"] },
74
+ role: { type: "string", enum: ["controller", "library", "assets", "common"] },
48
75
  selector: {
49
76
  type: "object",
50
77
  required: ["format"],
@@ -846,6 +873,7 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
846
873
  type: "array",
847
874
  items: { type: "string", not: { enum: ["variables", "secrets"] } },
848
875
  },
876
+ code: LIBRARY_CANDIDATES_SCHEMA,
849
877
  },
850
878
  additionalProperties: true,
851
879
  },
package/src/index.ts CHANGED
@@ -222,12 +222,14 @@ export {
222
222
  } from "./sources/manifest-cache.js";
223
223
  export type { ManifestCacheCoords } from "./sources/manifest-cache.js";
224
224
  export {
225
+ CODE_LAYER_ROLES,
225
226
  LAYER_ROLES,
226
227
  PLATFORM_AXES,
227
228
  ArtifactSelectorError,
228
229
  describeSelector,
229
230
  isLayerRole,
230
231
  normalizeSelector,
232
+ roleCarriesSelector,
231
233
  selectorFromQualifiers,
232
234
  selectorKey,
233
235
  selectorMatches,
@@ -240,9 +242,16 @@ export type {
240
242
  } from "./artifact-selector.js";
241
243
  export { collectModuleFileClaims } from "./module-file-claims.js";
242
244
  export type { ModuleFileClaim } from "./module-file-claims.js";
245
+ export { readLibraryCandidates } from "./module-library.js";
246
+ export type {
247
+ LibraryCandidate,
248
+ LibraryCandidateProblem,
249
+ LibraryCandidates,
250
+ } from "./module-library.js";
243
251
  export {
244
252
  LayerIndexError,
245
- matchControllerLayers,
253
+ codeLayerFor,
254
+ matchCodeLayers,
246
255
  parseLayerIndex,
247
256
  singletonLayer,
248
257
  } from "./artifact-layer-index.js";
@@ -7,6 +7,7 @@ import {
7
7
  import { PackageURL } from "packageurl-js";
8
8
  import { parseAllDocuments } from "yaml";
9
9
  import { selectorFromQualifiers, selectorKey, type ArtifactSelector } from "./artifact-selector.js";
10
+ import { readLibraryCandidates } from "./module-library.js";
10
11
 
11
12
  /**
12
13
  * One module-relative file a manifest names, and the artifact layer it belongs
@@ -51,6 +52,14 @@ interface ClaimBase {
51
52
  * controller claim with no selector that nothing would reject.
52
53
  */
53
54
  export type ModuleFileClaim =
55
+ | (ClaimBase & {
56
+ readonly role: "library";
57
+ readonly selector: ArtifactSelector;
58
+ /** The bare specifier a consumer's bundle imports this entry point by. */
59
+ readonly specifier: string;
60
+ /** The source `path` was built from, as on a controller claim. */
61
+ readonly localPath?: string;
62
+ })
54
63
  | (ClaimBase & {
55
64
  readonly role: "controller";
56
65
  readonly selector: ArtifactSelector;
@@ -121,6 +130,22 @@ function controllerClaims(json: unknown): ModuleFileClaim[] {
121
130
  return claims;
122
131
  }
123
132
 
133
+ /** The library entry points one document's `library:` block names. Unlike a
134
+ * controller entry — reached only when this module's own kinds instantiate —
135
+ * this one is what a *sibling* resolves a bare specifier to, which is why it
136
+ * gets its own layer rather than riding in the controller layer: a consumer
137
+ * must reach it without loading this module's controllers. */
138
+ function libraryClaims(json: unknown): ModuleFileClaim[] {
139
+ return readLibraryCandidates(json).candidates.map((candidate) => ({
140
+ role: "library",
141
+ path: candidate.path,
142
+ selector: candidate.selector,
143
+ specifier: candidate.specifier,
144
+ ...(candidate.localPath ? { localPath: candidate.localPath } : {}),
145
+ origin: candidate.origin,
146
+ }));
147
+ }
148
+
124
149
  /** Claims contributed by tagged values, asked of the engine that owns each tag.
125
150
  * The walk reaches every tagged scalar in the document, so an engine that
126
151
  * embeds files is discovered wherever its tag was written.
@@ -146,7 +171,8 @@ function taggedClaims(json: unknown, registry: TemplatingEngineRegistry): Module
146
171
  * their layers — dropping one would leave a platform's layer short a file it
147
172
  * declared it needs. */
148
173
  function claimKey(claim: ModuleFileClaim): string {
149
- const selector = claim.role === "controller" ? selectorKey(claim.selector) : "";
174
+ const selector =
175
+ claim.role === "controller" || claim.role === "library" ? selectorKey(claim.selector) : "";
150
176
  return `${claim.role}\0${selector}\0${claim.path}`;
151
177
  }
152
178
 
@@ -167,7 +193,11 @@ export function collectModuleFileClaims(
167
193
  const claims: ModuleFileClaim[] = [];
168
194
  for (const doc of parseAllDocuments(manifestText, { customTags: defaultCustomTags() })) {
169
195
  const json = doc.toJSON() as unknown;
170
- for (const claim of [...controllerClaims(json), ...taggedClaims(json, registry)]) {
196
+ for (const claim of [
197
+ ...libraryClaims(json),
198
+ ...controllerClaims(json),
199
+ ...taggedClaims(json, registry),
200
+ ]) {
171
201
  const key = claimKey(claim);
172
202
  if (seen.has(key)) continue;
173
203
  seen.add(key);
@@ -0,0 +1,208 @@
1
+ /**
2
+ * A module's **exported code** — the `exports.code:` block on a `Telo.Library`
3
+ * doc, which names the entry point a *sibling module's* controller bundle
4
+ * resolves this module's bare specifier to.
5
+ *
6
+ * ```yaml
7
+ * exports:
8
+ * kinds:
9
+ * - Store
10
+ * code:
11
+ * - specifier: "@telorun/kv-store"
12
+ * format: js
13
+ * path: ./nodejs/kv-store.mjs
14
+ * source: ./nodejs/src/index.ts
15
+ * ```
16
+ *
17
+ * ## Why it sits under `exports:`
18
+ *
19
+ * A library already declares what crosses its boundary — the kinds importers may
20
+ * name, the resource instances they may `!ref`. This is the same statement about
21
+ * its *code*, and it gates the same way: a specifier nobody declares resolves to
22
+ * nothing. Putting it beside them keeps one block for "reachable from outside"
23
+ * rather than a second top-level key whose name (`library:` on a `Telo.Library`)
24
+ * meant a different thing from the kind one line above it.
25
+ *
26
+ * ## Why it is not a package URL
27
+ *
28
+ * `controllers:` names a PURL because it must be able to say `pkg:npm/…` or
29
+ * `pkg:cargo/…` — an ecosystem fetch. This entry never fetches: it names a file
30
+ * the module already ships, so `pkg:telo/local/` would be three constant segments
31
+ * before the first real datum. What is left after removing them is exactly these
32
+ * fields, and as data they are visually editable, where a query string is one
33
+ * opaque text box.
34
+ *
35
+ * The **model** is unchanged: `format` plus the optional platform axes build the
36
+ * same `ArtifactSelector` a controller candidate does, so layer matching, platform
37
+ * fallthrough and lazy materialization are inherited whole.
38
+ *
39
+ * ## Why the specifier is declared here
40
+ *
41
+ * A bundle imports the bare specifier `@telorun/sql`; the consumer's manifest
42
+ * declares the dependency as `Sql: ../sql`. Something has to connect the two, and
43
+ * it is the *library* that says so, once, rather than each of its consumers:
44
+ *
45
+ * - the specifier is a property of the library — its name in a host language's
46
+ * ecosystem — not of the relationship, so N consumers cannot disagree about it
47
+ * and adding a consumer restates nothing;
48
+ * - it sits beside the format, which keeps runtime **derived, never declared**:
49
+ * the entry says `format: js`, and a Rust entry carries `specifier:
50
+ * telorun-sql` with no runtime-keyed map anywhere.
51
+ *
52
+ * **One specifier, one entry point.** Subpaths are deliberately not
53
+ * representable: reproducing npm's `exports` map inside the artifact would pull a
54
+ * package manager's resolution semantics into Telo, which is what the "only
55
+ * workspace modules are de-inlined" rule refuses on `kysely`'s behalf.
56
+ *
57
+ * `Telo.Application` has no `exports:` block at all — an application is a root
58
+ * with no importer, so nothing could resolve a specifier to it.
59
+ *
60
+ * Browser-safe: string work only. Whether the named file EXISTS is a separate
61
+ * question, asked by the Node-side caller that has a directory.
62
+ */
63
+
64
+ import {
65
+ ArtifactSelectorError,
66
+ PLATFORM_AXES,
67
+ selectorFromQualifiers,
68
+ type ArtifactSelector,
69
+ } from "./artifact-selector.js";
70
+
71
+ /** Every key an entry may carry: the two locators, plus the selector axes. */
72
+ const KNOWN_KEYS = new Set<string>(["specifier", "path", "source", "format", ...PLATFORM_AXES]);
73
+
74
+ export interface LibraryCandidate {
75
+ /** The bare specifier a sibling's controller bundle imports this library by. */
76
+ readonly specifier: string;
77
+ /** Module-root-relative path of the built entry point. */
78
+ readonly path: string;
79
+ /** Module-root-relative TypeScript source it is built from (`source:`), when
80
+ * the entry names one. Present only while the module is a working copy; a
81
+ * published artifact ships no `src/`. */
82
+ readonly localPath?: string;
83
+ readonly selector: ArtifactSelector;
84
+ /** Where the entry was written, for diagnostics. */
85
+ readonly origin: string;
86
+ }
87
+
88
+ /** Why an `exports.code` entry could not be read. Returned rather than thrown so
89
+ * the analyzer can report every entry of a block, and so a reader on the load
90
+ * path can carry on with the entries that are well-formed. */
91
+ export interface LibraryCandidateProblem {
92
+ readonly origin: string;
93
+ readonly detail: string;
94
+ }
95
+
96
+ export interface LibraryCandidates {
97
+ readonly candidates: LibraryCandidate[];
98
+ readonly problems: LibraryCandidateProblem[];
99
+ }
100
+
101
+ /** Normalize a `path` / `source` value to the manifest-relative POSIX form the
102
+ * file selector returns, so membership is a string comparison. */
103
+ function normalizeRelative(value: string): string {
104
+ return value.replace(/^\.\//, "").replace(/\\/g, "/");
105
+ }
106
+
107
+ function requiredString(
108
+ entry: Record<string, unknown>,
109
+ key: string,
110
+ ): { value: string } | { detail: string } {
111
+ const raw = entry[key];
112
+ if (typeof raw !== "string" || raw.trim() === "") {
113
+ return { detail: `'${key}' is required and must be a non-empty string.` };
114
+ }
115
+ return { value: raw.trim() };
116
+ }
117
+
118
+ /**
119
+ * Read the `exports.code:` block off an owner document's JSON projection.
120
+ *
121
+ * Everything malformed is a problem rather than a silent skip: an entry that
122
+ * cannot be read names no entry point, so a consumer's bundle falls back to
123
+ * *inlining* the library — the duplicated module scope this whole mechanism
124
+ * exists to remove — and it does so on someone else's machine.
125
+ */
126
+ export function readLibraryCandidates(ownerJson: unknown): LibraryCandidates {
127
+ const declared = (ownerJson as { exports?: { code?: unknown } } | null)?.exports?.code;
128
+ const candidates: LibraryCandidate[] = [];
129
+ const problems: LibraryCandidateProblem[] = [];
130
+ if (declared === undefined) return { candidates, problems };
131
+ if (!Array.isArray(declared)) {
132
+ return {
133
+ candidates,
134
+ problems: [{ origin: "exports.code", detail: "expected a list of entries." }],
135
+ };
136
+ }
137
+
138
+ declared.forEach((raw, index) => {
139
+ const origin = `exports.code[${index}]`;
140
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
141
+ problems.push({ origin, detail: "expected an object." });
142
+ return;
143
+ }
144
+ const entry = raw as Record<string, unknown>;
145
+
146
+ const unknown = Object.keys(entry).filter((key) => !KNOWN_KEYS.has(key));
147
+ if (unknown.length > 0) {
148
+ // Reported, never ignored: an unrecognized platform axis would leave the
149
+ // entry platform-neutral and offer a single-platform file to every host.
150
+ problems.push({
151
+ origin,
152
+ detail:
153
+ `unknown ${unknown.length === 1 ? "key" : "keys"} ${unknown.map((k) => `'${k}'`).join(", ")}. ` +
154
+ `Known: ${[...KNOWN_KEYS].join(", ")}.`,
155
+ });
156
+ return;
157
+ }
158
+
159
+ const specifier = requiredString(entry, "specifier");
160
+ if ("detail" in specifier) {
161
+ problems.push({ origin, detail: specifier.detail });
162
+ return;
163
+ }
164
+ const file = requiredString(entry, "path");
165
+ if ("detail" in file) {
166
+ problems.push({ origin: `${origin} ('${specifier.value}')`, detail: file.detail });
167
+ return;
168
+ }
169
+ // Explicit rather than inferred from the file extension: a `.mjs` can be
170
+ // wasm glue, and an inference rule is something every other runtime's reader
171
+ // would have to copy exactly.
172
+ const format = requiredString(entry, "format");
173
+ if ("detail" in format) {
174
+ problems.push({ origin: `${origin} ('${specifier.value}')`, detail: format.detail });
175
+ return;
176
+ }
177
+
178
+ let selector: ArtifactSelector;
179
+ try {
180
+ selector = selectorFromQualifiers(format.value, entry, `${origin} ('${specifier.value}')`);
181
+ } catch (err) {
182
+ problems.push({
183
+ origin: `${origin} ('${specifier.value}')`,
184
+ detail: err instanceof ArtifactSelectorError ? err.message : String(err),
185
+ });
186
+ return;
187
+ }
188
+
189
+ const source = entry.source;
190
+ if (source !== undefined && (typeof source !== "string" || source.trim() === "")) {
191
+ problems.push({
192
+ origin: `${origin} ('${specifier.value}')`,
193
+ detail: "'source' must be a non-empty string when present.",
194
+ });
195
+ return;
196
+ }
197
+
198
+ candidates.push({
199
+ specifier: specifier.value,
200
+ path: normalizeRelative(file.value),
201
+ ...(typeof source === "string" ? { localPath: normalizeRelative(source.trim()) } : {}),
202
+ selector,
203
+ origin: `${origin} ('${specifier.value}')`,
204
+ });
205
+ });
206
+
207
+ return { candidates, problems };
208
+ }
@@ -5,7 +5,9 @@ import {
5
5
  ArtifactSelectorError,
6
6
  PLATFORM_AXES,
7
7
  selectorFromQualifiers,
8
+ selectorKey,
8
9
  } from "./artifact-selector.js";
10
+ import { readLibraryCandidates, type LibraryCandidate } from "./module-library.js";
9
11
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
10
12
 
11
13
  const SOURCE = "telo-analyzer";
@@ -38,10 +40,64 @@ export function validateModuleArtifact(manifests: ResourceManifest[]): AnalysisD
38
40
  for (const manifest of manifests) {
39
41
  validateLayerIndex(manifest, out);
40
42
  validateControllerSelectors(manifest, out);
43
+ validateLibraryCandidates(manifest, out);
41
44
  }
42
45
  return out;
43
46
  }
44
47
 
48
+ /**
49
+ * The `exports.code:` block on a `Telo.Library` doc.
50
+ *
51
+ * Reported here rather than left to the loader for the same reason a controller
52
+ * selector is: an entry that cannot be read names no entry point, so a
53
+ * consumer's bundle falls back to *inlining* the library — the module scope
54
+ * duplication this whole mechanism exists to remove — and it does so silently, on
55
+ * someone else's machine.
56
+ */
57
+ function validateLibraryCandidates(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
58
+ // `Telo.Library` only. An application is a root with no importer, so it has no
59
+ // `exports:` block at all — and its schema is `additionalProperties: false`,
60
+ // so AJV already rejects the key by name in this same pass. A second
61
+ // diagnostic on that node would be two squiggles saying one thing.
62
+ if (manifest.kind !== "Telo.Library") return;
63
+ const metadata = manifest.metadata as { name?: string; source?: string } | undefined;
64
+ const { candidates, problems } = readLibraryCandidates(manifest);
65
+ const resource = { kind: manifest.kind, name: metadata?.name };
66
+
67
+ for (const problem of problems) {
68
+ out.push({
69
+ severity: DiagnosticSeverity.Error,
70
+ code: "LIBRARY_CANDIDATE_INVALID",
71
+ source: SOURCE,
72
+ message: `Telo.Library/${metadata?.name ?? "(unnamed)"}: ${problem.origin}: ${problem.detail}`,
73
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
74
+ });
75
+ }
76
+
77
+ // One specifier per selector: two candidates of one format claiming the same
78
+ // specifier leave the resolution ambiguous, and two specifiers for one format
79
+ // mean a consumer's import resolves by whichever candidate is read first.
80
+ const seen = new Map<string, LibraryCandidate>();
81
+ for (const candidate of candidates) {
82
+ const key = selectorKey(candidate.selector);
83
+ const first = seen.get(key);
84
+ if (first) {
85
+ out.push({
86
+ severity: DiagnosticSeverity.Error,
87
+ code: "LIBRARY_CANDIDATE_DUPLICATE",
88
+ source: SOURCE,
89
+ message:
90
+ `Telo.Library/${metadata?.name ?? "(unnamed)"}: two 'exports.code' entries declare the ` +
91
+ `selector ${key} ('${first.specifier}' and '${candidate.specifier}'). A module has one ` +
92
+ `entry point per format — which is what makes "one specifier, one module scope" true.`,
93
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
94
+ });
95
+ continue;
96
+ }
97
+ seen.set(key, candidate);
98
+ }
99
+ }
100
+
45
101
  /** `local_path` names the source `path=` was built from, so a working copy runs
46
102
  * with no build step. It is inert in a published artifact — which ships no
47
103
  * `src/` — and contributes nothing to the selector, so it never affects which