@telorun/analyzer 0.73.0 → 0.74.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 (138) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +26 -7
  3. package/dist/artifact-axes.d.ts +14 -0
  4. package/dist/artifact-axes.d.ts.map +1 -0
  5. package/dist/artifact-axes.js +12 -0
  6. package/dist/artifact-layer-index.d.ts +2 -2
  7. package/dist/artifact-layer-index.d.ts.map +1 -1
  8. package/dist/artifact-layer-index.js +9 -4
  9. package/dist/artifact-selector.d.ts +47 -21
  10. package/dist/artifact-selector.d.ts.map +1 -1
  11. package/dist/artifact-selector.js +85 -16
  12. package/dist/builtins.d.ts.map +1 -1
  13. package/dist/builtins.js +73 -16
  14. package/dist/call-graph.d.ts +12 -1
  15. package/dist/call-graph.d.ts.map +1 -1
  16. package/dist/call-graph.js +22 -0
  17. package/dist/catch-scope.js +1 -1
  18. package/dist/forwarded-declaration.d.ts +26 -0
  19. package/dist/forwarded-declaration.d.ts.map +1 -0
  20. package/dist/forwarded-declaration.js +31 -0
  21. package/dist/index.d.ts +9 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/kernel-globals.d.ts.map +1 -1
  25. package/dist/kernel-globals.js +21 -5
  26. package/dist/module-alias-scope.d.ts +11 -0
  27. package/dist/module-alias-scope.d.ts.map +1 -1
  28. package/dist/module-alias-scope.js +10 -0
  29. package/dist/module-file-claims.d.ts +17 -1
  30. package/dist/module-file-claims.d.ts.map +1 -1
  31. package/dist/module-file-claims.js +38 -3
  32. package/dist/module-named-files.d.ts +72 -0
  33. package/dist/module-named-files.d.ts.map +1 -0
  34. package/dist/module-named-files.js +115 -0
  35. package/dist/native-entries.d.ts +83 -0
  36. package/dist/native-entries.d.ts.map +1 -0
  37. package/dist/native-entries.js +169 -0
  38. package/dist/normalize-inline-resources.d.ts +41 -13
  39. package/dist/normalize-inline-resources.d.ts.map +1 -1
  40. package/dist/normalize-inline-resources.js +236 -54
  41. package/dist/reference-field-map.d.ts +59 -1
  42. package/dist/reference-field-map.d.ts.map +1 -1
  43. package/dist/reference-field-map.js +200 -34
  44. package/dist/release/payload-digest.d.ts +2 -1
  45. package/dist/release/payload-digest.d.ts.map +1 -1
  46. package/dist/release/payload-digest.js +2 -1
  47. package/dist/resolve-ref-sentinels.d.ts.map +1 -1
  48. package/dist/resolve-ref-sentinels.js +8 -5
  49. package/dist/resolve-throws-union.d.ts +25 -4
  50. package/dist/resolve-throws-union.d.ts.map +1 -1
  51. package/dist/resolve-throws-union.js +62 -32
  52. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  53. package/dist/resolve-zone-requirements.js +2 -1
  54. package/dist/rewrite-synthetic-origins.d.ts +6 -1
  55. package/dist/rewrite-synthetic-origins.d.ts.map +1 -1
  56. package/dist/rewrite-synthetic-origins.js +107 -13
  57. package/dist/schema-compat.d.ts.map +1 -1
  58. package/dist/schema-compat.js +18 -5
  59. package/dist/schema-walk.d.ts +47 -13
  60. package/dist/schema-walk.d.ts.map +1 -1
  61. package/dist/schema-walk.js +150 -30
  62. package/dist/scope-declarations.d.ts +59 -0
  63. package/dist/scope-declarations.d.ts.map +1 -0
  64. package/dist/scope-declarations.js +45 -0
  65. package/dist/source-entries.d.ts +119 -0
  66. package/dist/source-entries.d.ts.map +1 -0
  67. package/dist/source-entries.js +349 -0
  68. package/dist/step-bodies.d.ts +24 -0
  69. package/dist/step-bodies.d.ts.map +1 -0
  70. package/dist/step-bodies.js +28 -0
  71. package/dist/telo-version.d.ts +1 -1
  72. package/dist/telo-version.js +1 -1
  73. package/dist/validate-cel-context.d.ts.map +1 -1
  74. package/dist/validate-cel-context.js +3 -2
  75. package/dist/validate-module-artifact.d.ts +4 -2
  76. package/dist/validate-module-artifact.d.ts.map +1 -1
  77. package/dist/validate-module-artifact.js +43 -4
  78. package/dist/validate-native-entries.d.ts +15 -0
  79. package/dist/validate-native-entries.d.ts.map +1 -0
  80. package/dist/validate-native-entries.js +103 -0
  81. package/dist/validate-observed-state.d.ts +7 -1
  82. package/dist/validate-observed-state.d.ts.map +1 -1
  83. package/dist/validate-observed-state.js +24 -6
  84. package/dist/validate-reference-forms.d.ts.map +1 -1
  85. package/dist/validate-reference-forms.js +2 -2
  86. package/dist/validate-references.d.ts.map +1 -1
  87. package/dist/validate-references.js +11 -5
  88. package/dist/validate-scope-reach.d.ts +31 -0
  89. package/dist/validate-scope-reach.d.ts.map +1 -0
  90. package/dist/validate-scope-reach.js +180 -0
  91. package/dist/validate-source-entries.d.ts +16 -0
  92. package/dist/validate-source-entries.d.ts.map +1 -0
  93. package/dist/validate-source-entries.js +90 -0
  94. package/dist/validate-throws-coverage.d.ts.map +1 -1
  95. package/dist/validate-throws-coverage.js +21 -39
  96. package/dist/value-type-keyword.d.ts +22 -5
  97. package/dist/value-type-keyword.d.ts.map +1 -1
  98. package/dist/value-type-keyword.js +16 -7
  99. package/package.json +3 -3
  100. package/src/analyzer.ts +33 -9
  101. package/src/artifact-axes.ts +24 -0
  102. package/src/artifact-layer-index.ts +9 -6
  103. package/src/artifact-selector.ts +110 -35
  104. package/src/builtins.ts +75 -16
  105. package/src/call-graph.ts +30 -0
  106. package/src/catch-scope.ts +1 -1
  107. package/src/forwarded-declaration.ts +32 -0
  108. package/src/index.ts +36 -1
  109. package/src/kernel-globals.ts +28 -5
  110. package/src/migrations/CLAUDE.md +35 -0
  111. package/src/module-alias-scope.ts +17 -0
  112. package/src/module-file-claims.ts +63 -4
  113. package/src/module-named-files.ts +162 -0
  114. package/src/native-entries.ts +227 -0
  115. package/src/normalize-inline-resources.ts +326 -70
  116. package/src/reference-field-map.ts +257 -52
  117. package/src/release/CLAUDE.md +32 -0
  118. package/src/release/payload-digest.ts +2 -1
  119. package/src/resolve-ref-sentinels.ts +7 -6
  120. package/src/resolve-throws-union.ts +87 -32
  121. package/src/resolve-zone-requirements.ts +2 -1
  122. package/src/rewrite-synthetic-origins.ts +107 -16
  123. package/src/schema-compat.ts +18 -4
  124. package/src/schema-walk.ts +162 -34
  125. package/src/scope-declarations.ts +98 -0
  126. package/src/source-entries.ts +502 -0
  127. package/src/step-bodies.ts +55 -0
  128. package/src/telo-version.ts +1 -1
  129. package/src/validate-cel-context.ts +3 -2
  130. package/src/validate-module-artifact.ts +46 -3
  131. package/src/validate-native-entries.ts +147 -0
  132. package/src/validate-observed-state.ts +36 -7
  133. package/src/validate-reference-forms.ts +2 -3
  134. package/src/validate-references.ts +10 -6
  135. package/src/validate-scope-reach.ts +213 -0
  136. package/src/validate-source-entries.ts +120 -0
  137. package/src/validate-throws-coverage.ts +24 -31
  138. package/src/value-type-keyword.ts +35 -9
@@ -4,16 +4,22 @@ import {
4
4
  walkCelExpressions,
5
5
  type TemplatingEngineRegistry,
6
6
  } from "@telorun/templating";
7
+ import type { ResourceManifest } from "@telorun/sdk";
7
8
  import { PackageURL } from "packageurl-js";
8
9
  import { parseAllDocuments } from "yaml";
9
- import { selectorFromQualifiers, selectorKey, type ArtifactSelector } from "./artifact-selector.js";
10
+ import {
11
+ ArtifactSelectorError,
12
+ selectorFromQualifiers,
13
+ selectorKey,
14
+ type ArtifactSelector,
15
+ } from "./artifact-selector.js";
10
16
  import { readLibraryCandidates } from "./module-library.js";
11
17
 
12
18
  /**
13
19
  * One module-relative file a manifest names, and the artifact layer it belongs
14
20
  * to.
15
21
  *
16
- * The single answer to "why is this file in the payload", replacing two
22
+ * The answer to "why is this file in the payload" for everything but `native:` entries (read by `native-entries.ts`), replacing two
17
23
  * derivations that happened to agree: publish used to re-parse the manifest with
18
24
  * PURL knowledge hardcoded into the CLI, and any second vocabulary — a tag that
19
25
  * embeds a file, say — would have had to be added there by hand. Here the
@@ -188,11 +194,64 @@ function claimKey(claim: ModuleFileClaim): string {
188
194
  export function collectModuleFileClaims(
189
195
  manifestText: string,
190
196
  registry: TemplatingEngineRegistry = defaultRegistry(),
197
+ ): ModuleFileClaim[] {
198
+ return collectDocumentFileClaims(
199
+ parseAllDocuments(manifestText, { customTags: defaultCustomTags() }).map(
200
+ (doc) => doc.toJSON() as unknown,
201
+ ),
202
+ registry,
203
+ );
204
+ }
205
+
206
+ /** A claim, with the document that made it when the caller knows which. */
207
+ export interface LocatedClaim {
208
+ readonly claim: ModuleFileClaim;
209
+ /** `<kind>/<name>` of the declaring document. */
210
+ readonly where?: string;
211
+ }
212
+
213
+ /**
214
+ * The claims one module's own documents make, out of an analysis set that also
215
+ * holds its dependencies' documents. A document whose candidate selector cannot
216
+ * be read contributes none — `validateModuleArtifact` reports it.
217
+ */
218
+ export function moduleDocumentClaims(
219
+ manifests: readonly ResourceManifest[],
220
+ moduleName: string | undefined,
221
+ registry: TemplatingEngineRegistry = defaultRegistry(),
222
+ ): LocatedClaim[] {
223
+ const out: LocatedClaim[] = [];
224
+ for (const doc of manifests) {
225
+ const metadata = (doc.metadata ?? {}) as Record<string, unknown>;
226
+ const docModule =
227
+ typeof metadata.module === "string"
228
+ ? metadata.module
229
+ : doc.kind === "Telo.Application" || doc.kind === "Telo.Library"
230
+ ? metadata.name
231
+ : undefined;
232
+ if (docModule !== moduleName) continue;
233
+ let claims: ModuleFileClaim[];
234
+ try {
235
+ claims = collectDocumentFileClaims([doc], registry);
236
+ } catch (err) {
237
+ if (err instanceof ArtifactSelectorError) continue;
238
+ throw err;
239
+ }
240
+ const where = `${doc.kind}/${typeof metadata.name === "string" ? metadata.name : "(unnamed)"}`;
241
+ for (const claim of claims) out.push({ claim, where });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /** The same claims, from one module's already-parsed documents — the form the
247
+ * analysis pass holds. */
248
+ export function collectDocumentFileClaims(
249
+ docs: readonly unknown[],
250
+ registry: TemplatingEngineRegistry = defaultRegistry(),
191
251
  ): ModuleFileClaim[] {
192
252
  const seen = new Set<string>();
193
253
  const claims: ModuleFileClaim[] = [];
194
- for (const doc of parseAllDocuments(manifestText, { customTags: defaultCustomTags() })) {
195
- const json = doc.toJSON() as unknown;
254
+ for (const json of docs) {
196
255
  for (const claim of [
197
256
  ...libraryClaims(json),
198
257
  ...controllerClaims(json),
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The rules that relate a module's `native:` entries, its file claims and its
3
+ * `sources:` block — which files a source may stage, which declarations collide
4
+ * on one path, and which staged entries nothing names.
5
+ *
6
+ * One home, because `telo check` (`validate-native-entries.ts`,
7
+ * `validate-source-entries.ts`) and `telo publish` apply the same rules to the
8
+ * same manifest; two copies would each drift toward whatever its own tests pin.
9
+ * Callers read the three blocks with their own readers and hand the results in.
10
+ * Browser-safe: no filesystem.
11
+ */
12
+
13
+ import { PLATFORM_AXES, describeSelector } from "./artifact-selector.js";
14
+ import type { LocatedClaim, ModuleFileClaim } from "./module-file-claims.js";
15
+ import type { NativeEntry } from "./native-entries.js";
16
+ import type { ModuleSource, SourceEntry } from "./source-entries.js";
17
+
18
+ const CLAIM_LABEL: Record<ModuleFileClaim["role"], string> = {
19
+ controller: "the controller candidate",
20
+ library: "the exports.code entry",
21
+ assets: "the embed",
22
+ };
23
+
24
+ /** The declaration behind a claim, for messages: `the controller candidate pkg:… on Telo.Definition/Reader`. */
25
+ export function describeClaim(located: LocatedClaim): string {
26
+ const { claim, where } = located;
27
+ return `${CLAIM_LABEL[claim.role]} ${claim.origin}${where === undefined ? "" : ` on ${where}`}`;
28
+ }
29
+
30
+ /** A `native:` entry whose path another declaration also names. */
31
+ export interface NativeClaimConflict {
32
+ readonly entry: NativeEntry;
33
+ readonly claim: LocatedClaim;
34
+ }
35
+
36
+ /**
37
+ * Every `native:` entry whose path the module also names as code or as an embed.
38
+ * Each declaration decides a different layer, and a file extracts from exactly
39
+ * one — a runtime materializing one would find the file missing through the other.
40
+ */
41
+ export function nativeClaimConflicts(
42
+ native: readonly NativeEntry[],
43
+ claims: readonly LocatedClaim[],
44
+ ): NativeClaimConflict[] {
45
+ const out: NativeClaimConflict[] = [];
46
+ for (const entry of native) {
47
+ for (const claim of claims) {
48
+ if (claim.claim.path === entry.path) out.push({ entry, claim });
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /** A file a `sources:` entry may stage. */
55
+ export interface StageableFile {
56
+ readonly path: string;
57
+ /** The declaration naming it, for messages. */
58
+ readonly label: string;
59
+ /** The layer it ships in, for messages. */
60
+ readonly layer: string;
61
+ /** Produced by a controller build (`local_path=`) rather than staged or committed. */
62
+ readonly built: boolean;
63
+ }
64
+
65
+ /**
66
+ * The files the module names that a `sources:` entry may stage: every `native:`
67
+ * entry's path, and every bundled controller candidate's `path=` that carries a
68
+ * platform qualifier. A platform-neutral candidate is one bundle for every host,
69
+ * which nothing stages.
70
+ */
71
+ export function stageableFiles(
72
+ native: readonly NativeEntry[],
73
+ claims: readonly LocatedClaim[],
74
+ ): Map<string, StageableFile> {
75
+ const out = new Map<string, StageableFile>();
76
+ for (const entry of native) {
77
+ if (out.has(entry.path)) continue;
78
+ out.set(entry.path, {
79
+ path: entry.path,
80
+ label: `${entry.origin} for ${describeSelector(entry.selector)}`,
81
+ layer: `native ${describeSelector(entry.selector)}`,
82
+ built: false,
83
+ });
84
+ }
85
+ for (const { claim } of claims) {
86
+ if (claim.role !== "controller") continue;
87
+ if (!PLATFORM_AXES.some((axis) => claim.selector[axis] !== undefined)) continue;
88
+ const existing = out.get(claim.path);
89
+ if (existing) {
90
+ if (claim.localPath && !existing.built) out.set(claim.path, { ...existing, built: true });
91
+ continue;
92
+ }
93
+ out.set(claim.path, {
94
+ path: claim.path,
95
+ label: `controller candidate ${claim.origin}`,
96
+ layer: `controller ${describeSelector(claim.selector)}`,
97
+ built: claim.localPath !== undefined,
98
+ });
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** A `sources:` entry, with the source declaring it. */
104
+ export interface LocatedSourceEntry {
105
+ readonly source: ModuleSource;
106
+ readonly entry: SourceEntry;
107
+ }
108
+
109
+ /**
110
+ * The staged entries nothing in the module names: not a stageable file, and not
111
+ * one of the entry's own source's notices. `alsoNamed` holds paths the caller
112
+ * knows are named though they did not read — a `native:` entry with an unrelated
113
+ * problem — so one defect is not reported twice.
114
+ */
115
+ export function unclaimedSourceEntries(
116
+ sources: readonly ModuleSource[],
117
+ stageable: ReadonlyMap<string, StageableFile>,
118
+ alsoNamed: ReadonlySet<string> = new Set(),
119
+ ): LocatedSourceEntry[] {
120
+ const out: LocatedSourceEntry[] = [];
121
+ for (const source of sources) {
122
+ for (const entry of source.entries) {
123
+ if (stageable.has(entry.path) || source.notices.includes(entry.path)) continue;
124
+ if (alsoNamed.has(entry.path)) continue;
125
+ out.push({ source, entry });
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** A link whose target ships in a different layer than the link. */
132
+ export interface CrossLayerLink {
133
+ readonly source: ModuleSource;
134
+ readonly entry: Extract<SourceEntry, { kind: "link" }>;
135
+ readonly layer: string;
136
+ readonly targetLayer: string;
137
+ }
138
+
139
+ /**
140
+ * The link entries whose target ships in another layer. A runtime extracts only
141
+ * the layers it needs, so such a link dangles wherever the other layer is not
142
+ * materialized. A notice ships in `common`.
143
+ */
144
+ export function crossLayerSourceLinks(
145
+ sources: readonly ModuleSource[],
146
+ stageable: ReadonlyMap<string, StageableFile>,
147
+ ): CrossLayerLink[] {
148
+ const layerOf = (source: ModuleSource, path: string): string | undefined =>
149
+ stageable.get(path)?.layer ?? (source.notices.includes(path) ? "common" : undefined);
150
+ const out: CrossLayerLink[] = [];
151
+ for (const source of sources) {
152
+ for (const entry of source.entries) {
153
+ if (entry.kind !== "link") continue;
154
+ const layer = layerOf(source, entry.path);
155
+ const targetLayer = layerOf(source, entry.resolved);
156
+ if (layer !== undefined && targetLayer !== undefined && layer !== targetLayer) {
157
+ out.push({ source, entry, layer, targetLayer });
158
+ }
159
+ }
160
+ }
161
+ return out;
162
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * A module's **native files** — the `native:` block on a `Telo.Library` or
3
+ * `Telo.Application` doc, naming each platform-specific file the runtime does
4
+ * not import as a controller (a sidecar addon, a `dlopen`'d library, a
5
+ * per-platform data blob) once per platform tuple.
6
+ *
7
+ * ```yaml
8
+ * native:
9
+ * - name: better-sqlite3
10
+ * format: node
11
+ * os: linux
12
+ * arch: amd64
13
+ * libc: gnu
14
+ * abi: node-137
15
+ * path: ./native/linux-amd64-gnu-node-137/better_sqlite3.node
16
+ * ```
17
+ *
18
+ * Declared on the module doc rather than on a controller candidate, so the
19
+ * platform matrix is written once whatever the kind count, and the
20
+ * platform-neutral bundle ships once. Publish places each entry's file in the
21
+ * `native` layer of its selector (spec §1.2).
22
+ *
23
+ * `format` plus the platform axes build the same `ArtifactSelector` a
24
+ * controller candidate does, through the one selector normalizer. The declared
25
+ * `path` is the in-layer path — never flattened — because every layer of a
26
+ * module extracts into one directory, so paths must be disjoint per tuple.
27
+ *
28
+ * The single reader: the strict half is `validate-native-entries.ts`, and
29
+ * publish consumes the entries this returns. Browser-safe: string work only.
30
+ */
31
+
32
+ import {
33
+ ArtifactSelectorError,
34
+ PLATFORM_AXES,
35
+ selectorFromQualifiers,
36
+ type ArtifactSelector,
37
+ } from "./artifact-selector.js";
38
+
39
+ /** Every key an entry may carry. */
40
+ const KNOWN_KEYS = new Set<string>(["name", "format", "path", ...PLATFORM_AXES]);
41
+
42
+ /** Axes an entry must state: a native file is built for one platform. */
43
+ const REQUIRED_AXES = ["os", "arch"] as const;
44
+
45
+ /** The selector value grammar, applied to `name` as written. */
46
+ const NAME_TOKEN = /^[a-z0-9][a-z0-9_.-]*$/;
47
+
48
+ const GLOB_CHARS = /[*?[\]{}]/;
49
+ const URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
50
+
51
+ export interface NativeEntry {
52
+ /** The logical name controller code asks for. */
53
+ readonly name: string;
54
+ readonly selector: ArtifactSelector;
55
+ /** Module-root-relative POSIX path, normalized. */
56
+ readonly path: string;
57
+ /** Position in the `native:` list. */
58
+ readonly index: number;
59
+ /** Human-facing label for diagnostics: `native[0] ('better-sqlite3')`. */
60
+ readonly origin: string;
61
+ }
62
+
63
+ /**
64
+ * Why an entry could not be read.
65
+ *
66
+ * - `shape` — a missing, non-string or unknown key; the owner doc's JSON Schema
67
+ * reports these in the same analysis pass.
68
+ * - `invalid` — a value the schema accepts and the grammar rejects: an empty
69
+ * required string, a `name` or selector value outside the token grammar, an
70
+ * `abi` outside its value form, a path that names no single file.
71
+ * - `escape` — a `path` pointing outside the module directory.
72
+ */
73
+ export interface NativeEntryProblem {
74
+ readonly kind: "shape" | "invalid" | "escape";
75
+ readonly index?: number;
76
+ readonly origin: string;
77
+ /** Dotted/bracketed path from the module doc root, for `data.path`. */
78
+ readonly path: string;
79
+ /** What is wrong, prefixed with `origin`. */
80
+ readonly message: string;
81
+ }
82
+
83
+ export interface NativeEntries {
84
+ readonly entries: NativeEntry[];
85
+ readonly problems: NativeEntryProblem[];
86
+ }
87
+
88
+ export type PathVerdict = { path: string } | { kind: "invalid" | "escape"; detail: string };
89
+
90
+ /** Normalize a module-relative path, deciding confinement from the written
91
+ * path alone: the module root is the one directory every path is measured
92
+ * from, so `..` below depth zero is an escape wherever the module sits. */
93
+ export function normalizeNativePath(raw: string): PathVerdict {
94
+ if (URI_SCHEME.test(raw) || raw.startsWith("/") || raw.startsWith("\\")) {
95
+ return {
96
+ kind: "escape",
97
+ detail: `path '${raw}' is not relative to the module root. A native file ships inside the module artifact, so name it relative to the directory holding telo.yaml.`,
98
+ };
99
+ }
100
+ if (GLOB_CHARS.test(raw)) {
101
+ return {
102
+ kind: "invalid",
103
+ detail: `path '${raw}' looks like a pattern. A native entry names exactly one file.`,
104
+ };
105
+ }
106
+ const out: string[] = [];
107
+ for (const segment of raw.split(/[/\\]+/)) {
108
+ if (segment === "" || segment === ".") continue;
109
+ if (segment !== "..") {
110
+ out.push(segment);
111
+ continue;
112
+ }
113
+ if (out.length === 0) {
114
+ return {
115
+ kind: "escape",
116
+ detail: `path '${raw}' points above the module root. A native file must ship inside the module directory.`,
117
+ };
118
+ }
119
+ out.pop();
120
+ }
121
+ if (out.length === 0) {
122
+ return { kind: "invalid", detail: `path '${raw}' resolves to the module root, not to a file.` };
123
+ }
124
+ return { path: out.join("/") };
125
+ }
126
+
127
+ /**
128
+ * Read the `native:` block off an owner document's JSON projection.
129
+ *
130
+ * An entry with any problem is left out of `entries`, never read partially: a
131
+ * half-read entry would name a layer the author did not declare.
132
+ */
133
+ export function readNativeEntries(ownerJson: unknown): NativeEntries {
134
+ const declared = (ownerJson as { native?: unknown } | null)?.native;
135
+ const entries: NativeEntry[] = [];
136
+ const problems: NativeEntryProblem[] = [];
137
+ if (declared === undefined) return { entries, problems };
138
+ if (!Array.isArray(declared)) {
139
+ problems.push({
140
+ kind: "shape",
141
+ origin: "native",
142
+ path: "native",
143
+ message: "native: expected a list of entries.",
144
+ });
145
+ return { entries, problems };
146
+ }
147
+
148
+ declared.forEach((raw, index) => {
149
+ const at = `native[${index}]`;
150
+ const shape = (detail: string) =>
151
+ problems.push({ kind: "shape", index, origin: at, path: at, message: `${at}: ${detail}` });
152
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
153
+ shape("expected an object.");
154
+ return;
155
+ }
156
+ const entry = raw as Record<string, unknown>;
157
+
158
+ const unknown = Object.keys(entry).filter((key) => !KNOWN_KEYS.has(key));
159
+ if (unknown.length > 0) {
160
+ shape(
161
+ `unknown ${unknown.length === 1 ? "key" : "keys"} ${unknown.map((k) => `'${k}'`).join(", ")}. ` +
162
+ `Known: ${[...KNOWN_KEYS].join(", ")}.`,
163
+ );
164
+ return;
165
+ }
166
+ const requiredKeys = ["name", "format", ...REQUIRED_AXES, "path"];
167
+ const missing = requiredKeys.filter((key) => typeof entry[key] !== "string");
168
+ if (missing.length > 0) {
169
+ shape(`${missing.map((k) => `'${k}'`).join(", ")} must be strings.`);
170
+ return;
171
+ }
172
+ // A blank string satisfies the schema's `type: string`, so it is reported here.
173
+ const blank = requiredKeys.filter((key) => (entry[key] as string).trim() === "");
174
+ if (blank.length > 0) {
175
+ for (const key of blank) {
176
+ problems.push({
177
+ kind: "invalid",
178
+ index,
179
+ origin: at,
180
+ path: `${at}.${key}`,
181
+ message: `${at}: '${key}' must not be empty.`,
182
+ });
183
+ }
184
+ return;
185
+ }
186
+
187
+ const name = entry.name as string;
188
+ const origin = `${at} ('${name}')`;
189
+ if (!NAME_TOKEN.test(name)) {
190
+ problems.push({
191
+ kind: "invalid",
192
+ index,
193
+ origin,
194
+ path: `${at}.name`,
195
+ message:
196
+ `${origin}: name '${name}' is not a canonical token. Use lowercase letters, digits, ` +
197
+ `'.', '-' or '_', starting with a letter or digit.`,
198
+ });
199
+ return;
200
+ }
201
+
202
+ let selector: ArtifactSelector;
203
+ try {
204
+ selector = selectorFromQualifiers(entry.format, entry, origin);
205
+ } catch (err) {
206
+ if (!(err instanceof ArtifactSelectorError)) throw err;
207
+ problems.push({ kind: "invalid", index, origin, path: at, message: err.message });
208
+ return;
209
+ }
210
+
211
+ const verdict = normalizeNativePath((entry.path as string).trim());
212
+ if (!("path" in verdict)) {
213
+ problems.push({
214
+ kind: verdict.kind,
215
+ index,
216
+ origin,
217
+ path: `${at}.path`,
218
+ message: `${origin}: ${verdict.detail}`,
219
+ });
220
+ return;
221
+ }
222
+
223
+ entries.push({ name, selector, path: verdict.path, index, origin });
224
+ });
225
+
226
+ return { entries, problems };
227
+ }