@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
@@ -0,0 +1,98 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefSentinel } from "@telorun/templating";
3
+ import { isScopeEntry, resolveFieldEntries, type ReferenceFieldMap } from "./reference-field-map.js";
4
+
5
+ /**
6
+ * The execution scopes ONE resource declares — the `x-telo-scope` arrays it
7
+ * holds — read off its kind's field map, which is the analyzer's single
8
+ * definition of "scope" (the same one `resolve-ref-sentinels` and
9
+ * `manifest-visitor` read). Inferring a scope from shape instead would give one
10
+ * to any kind that happens to carry an array of named declarations.
11
+ *
12
+ * Browser-safe.
13
+ */
14
+
15
+ /** One declared scope: the array a scope run creates, and where its names resolve. */
16
+ export interface DeclaredScope {
17
+ /** Concrete path of the scope slot on its resource (`with`). */
18
+ path: string;
19
+ /** The declaration array itself — what a scope run registers and tears down. */
20
+ declarations: unknown[];
21
+ /** Dot-form prefixes of every region its names resolve in (`steps`, `targets`). */
22
+ regions: string[];
23
+ }
24
+
25
+ export type ScopeMember = ResourceManifest & { metadata: { name: string } };
26
+
27
+ /** Every scope `resource` declares, in field-map order. A scope slot left unset
28
+ * declares nothing: no scope run happens, so nothing is created in one. */
29
+ export function declaredScopes(
30
+ resource: Record<string, unknown>,
31
+ fieldMap: ReferenceFieldMap | undefined,
32
+ ): DeclaredScope[] {
33
+ if (!fieldMap) return [];
34
+ const out: DeclaredScope[] = [];
35
+ for (const [fieldPath, entry] of fieldMap) {
36
+ if (!isScopeEntry(entry)) continue;
37
+ const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
38
+ const regions = pointers.map((p) => p.replace(/^\//, "").replace(/\//g, "."));
39
+ for (const { value, path } of resolveFieldEntries(resource, fieldPath)) {
40
+ if (Array.isArray(value)) out.push({ path, declarations: value, regions });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** True when `path` — a field-map path (`steps[].invoke`) or a concrete one
47
+ * (`steps[0].invoke`) — lies inside one of the scope's regions. */
48
+ export function scopeEncloses(scope: DeclaredScope, path: string): boolean {
49
+ return scope.regions.some(
50
+ (region) => path === region || path.startsWith(`${region}.`) || path.startsWith(`${region}[`),
51
+ );
52
+ }
53
+
54
+ /** A scope's inline resource declarations. A `!ref` entry is a category error
55
+ * (`SCOPE_ENTRY_NOT_INLINE`), not a member. */
56
+ export function scopeMembers(scope: DeclaredScope): ScopeMember[] {
57
+ return scope.declarations.filter(isScopeMember);
58
+ }
59
+
60
+ /** True when a scope array entry is an inline resource declaration. */
61
+ export function isScopeMember(value: unknown): value is ScopeMember {
62
+ if (!value || typeof value !== "object" || Array.isArray(value) || isRefSentinel(value)) {
63
+ return false;
64
+ }
65
+ const entry = value as Record<string, unknown>;
66
+ const name = (entry.metadata as { name?: unknown } | undefined)?.name;
67
+ return typeof entry.kind === "string" && typeof entry.name !== "string" && typeof name === "string";
68
+ }
69
+
70
+ /**
71
+ * A scope an extracted declaration was WRITTEN inside and is NOT created in.
72
+ *
73
+ * An inline declaration is created in the set its owner is declared in, never in
74
+ * a scope its owner declares — moving it there would make every step target of a
75
+ * sequence with a `with:` block a scoped resource, which is a change of durable
76
+ * identity. So a declaration written in `steps:` next to a `with:` block is
77
+ * lexically inside that scope and lives outside it, and the names the scope
78
+ * declares are out of its reach. Recorded at extraction, where both facts are in
79
+ * hand, as `metadata.xTeloOrigin.outsideScopes`.
80
+ */
81
+ export interface OutsideScope {
82
+ /** The resource declaring the scope, as its manifest spells it. */
83
+ ownerKind: string;
84
+ ownerName: string;
85
+ /** The scope slot (`with`). */
86
+ field: string;
87
+ /** Every name the scope declares. */
88
+ names: string[];
89
+ }
90
+
91
+ /** The scopes recorded as out of reach for an extracted declaration. */
92
+ export function outsideScopesOf(manifest: unknown): OutsideScope[] {
93
+ const origin = (
94
+ (manifest as { metadata?: { xTeloOrigin?: { outsideScopes?: unknown } } } | undefined)
95
+ ?.metadata?.xTeloOrigin
96
+ )?.outsideScopes;
97
+ return Array.isArray(origin) ? (origin as OutsideScope[]) : [];
98
+ }
@@ -0,0 +1,502 @@
1
+ /**
2
+ * A module's **sources** — the `sources:` block on a `Telo.Library` or
3
+ * `Telo.Application` doc, saying where every staged file comes from: a pinned
4
+ * upstream archive, fetched by `telo release stage`.
5
+ *
6
+ * ```yaml
7
+ * sources:
8
+ * better-sqlite3:
9
+ * version: 12.8.0
10
+ * url: https://github.com/WiseLibs/better-sqlite3/releases/download/v{version}/better-sqlite3-v{version}-{upstream}.tar.gz
11
+ * archive: tar.gz
12
+ * notices: [./notices/better-sqlite3.LICENSE]
13
+ * entries:
14
+ * ./native/linux-amd64-gnu-node-137/better_sqlite3.node:
15
+ * upstream: node-v137-linux-x64
16
+ * member: build/Release/better_sqlite3.node
17
+ * sha256: 4c9e…
18
+ * executable: false
19
+ * ./native/linux-amd64-gnu/libvips.so:
20
+ * target: libvips.so.42
21
+ * ```
22
+ *
23
+ * Keyed by the module-relative path an entry produces, because the manifest
24
+ * already names every staged file — a `native:` entry's path or a
25
+ * platform-qualified controller candidate's `path=`.
26
+ *
27
+ * The single reader: every rule decidable from the block alone is a problem
28
+ * here, so `telo check` (`validate-source-entries.ts`), the stager and the
29
+ * kernels refuse the same blocks. Browser-safe: string work only.
30
+ */
31
+
32
+ import { normalizeNativePath } from "./native-entries.js";
33
+
34
+ const SOURCE_NAME_TOKEN = /^[a-z0-9][a-z0-9_.-]*$/;
35
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
36
+ const INPUTS_DIGEST = /^sha256-[A-Za-z0-9_-]{43}$/;
37
+ const SOURCE_KEYS = ["version", "url", "archive", "notices", "entries", "build"];
38
+ const BUILD_KEYS = ["cargo", "inputs"];
39
+ const URL_PLACEHOLDERS = new Set(["version", "upstream"]);
40
+ const FILE_KEYS = ["upstream", "member", "sha256", "executable"] as const;
41
+ const URL_ORIGIN = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i;
42
+ const LOOPBACK_HOST = /^(localhost|127\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[::1\])$/i;
43
+
44
+ /** The archive formats an upstream may be. */
45
+ export const SOURCE_ARCHIVE_FORMATS = ["tar.gz"] as const;
46
+ export type SourceArchiveFormat = (typeof SOURCE_ARCHIVE_FORMATS)[number];
47
+
48
+ export interface SourcePin {
49
+ /** Lowercase hex digest of the file's bytes. */
50
+ readonly sha256: string;
51
+ readonly executable: boolean;
52
+ }
53
+
54
+ interface SourceEntryBase {
55
+ /** The key as written, for diagnostic paths. */
56
+ readonly key: string;
57
+ /** Module-root-relative POSIX path, normalized. */
58
+ readonly path: string;
59
+ }
60
+
61
+ export type SourceEntry =
62
+ | (SourceEntryBase & {
63
+ readonly kind: "file";
64
+ readonly upstream: string;
65
+ /** Path of the file inside the archive. */
66
+ readonly member: string;
67
+ /** Absent until `telo release stage --pin` writes it. */
68
+ readonly pin?: SourcePin;
69
+ })
70
+ | (SourceEntryBase & {
71
+ readonly kind: "link";
72
+ /** The link target, exactly as the link stores it. */
73
+ readonly target: string;
74
+ /** The module-relative path the target resolves to. */
75
+ readonly resolved: string;
76
+ });
77
+
78
+ /** How a source's files are built in this repository, keyed by build system. */
79
+ export interface SourceBuild {
80
+ /** Module-root-relative directory of the Cargo crate the files are built
81
+ * from, normalized (`""` for the module root). */
82
+ readonly cargo: string;
83
+ /** Digest of that crate's build inputs, `sha256-<base64url>`; absent until
84
+ * `telo release stage --pin` records it. */
85
+ readonly inputs?: string;
86
+ }
87
+
88
+ export interface ModuleSource {
89
+ readonly name: string;
90
+ readonly version: string;
91
+ /** The URL template, as written. */
92
+ readonly url: string;
93
+ readonly archive: SourceArchiveFormat;
94
+ /** Module-root-relative notice paths, normalized. */
95
+ readonly notices: readonly string[];
96
+ readonly entries: readonly SourceEntry[];
97
+ readonly build?: SourceBuild;
98
+ }
99
+
100
+ /**
101
+ * Why part of the block could not be read.
102
+ *
103
+ * `SHAPE` is a missing, mistyped or unknown key the owner doc's JSON Schema
104
+ * reports in the same pass; every other code is a rule `telo check` reports.
105
+ */
106
+ export type SourceProblemCode =
107
+ | "SHAPE"
108
+ | "SOURCE_INVALID"
109
+ | "SOURCE_URL_PLACEHOLDER_UNKNOWN"
110
+ | "SOURCE_URL_INSECURE"
111
+ | "SOURCE_ENTRY_INVALID"
112
+ | "SOURCE_LINK_TARGET_UNRESOLVED"
113
+ | "SOURCE_LINK_CYCLE"
114
+ | "SOURCE_ENTRY_DUPLICATE"
115
+ | "SOURCE_ENTRY_NESTED";
116
+
117
+ export interface SourceProblem {
118
+ readonly code: SourceProblemCode;
119
+ /** Dotted path from the module doc root, for `data.path`. */
120
+ readonly path: string;
121
+ readonly message: string;
122
+ }
123
+
124
+ export interface ModuleSources {
125
+ readonly sources: ModuleSource[];
126
+ readonly problems: SourceProblem[];
127
+ }
128
+
129
+ const isObject = (value: unknown): value is Record<string, unknown> =>
130
+ typeof value === "object" && value !== null && !Array.isArray(value);
131
+
132
+ /** The URL an entry is fetched from: substitution only. */
133
+ export function resolveSourceUrl(source: ModuleSource, upstream: string): string {
134
+ return source.url.replaceAll("{version}", source.version).replaceAll("{upstream}", upstream);
135
+ }
136
+
137
+ /** Resolve a link target against the link's own directory, confined to the module. */
138
+ function resolveLinkTarget(linkPath: string, target: string): string | undefined {
139
+ const dir = linkPath.includes("/") ? linkPath.slice(0, linkPath.lastIndexOf("/")) : "";
140
+ if (target.startsWith("/") || target.startsWith("\\")) return undefined;
141
+ const verdict = normalizeNativePath(dir === "" ? target : `${dir}/${target}`);
142
+ return "path" in verdict ? verdict.path : undefined;
143
+ }
144
+
145
+ /** Why a url — a template, or the URL a fetch ended at after redirects — may not
146
+ * be fetched from, or undefined when it may: https, or plain http to a loopback
147
+ * host. */
148
+ export function sourceUrlProblem(url: string): { code: SourceProblemCode; detail: string } | undefined {
149
+ const origin = URL_ORIGIN.exec(url);
150
+ if (!origin) {
151
+ return {
152
+ code: "SOURCE_INVALID",
153
+ detail: `url '${url}' is not an absolute URL. Write an https:// URL template.`,
154
+ };
155
+ }
156
+ const scheme = origin[1]!.toLowerCase();
157
+ if (scheme === "https") return undefined;
158
+ const authority = origin[2]!;
159
+ const hostPort = authority.slice(authority.lastIndexOf("@") + 1);
160
+ const host = hostPort.startsWith("[")
161
+ ? hostPort.slice(0, hostPort.indexOf("]") + 1)
162
+ : hostPort.split(":")[0]!;
163
+ if (scheme === "http" && LOOPBACK_HOST.test(host)) return undefined;
164
+ return {
165
+ code: "SOURCE_URL_INSECURE",
166
+ detail:
167
+ `url '${url}' is fetched over ${scheme}, where the archive could be altered on the way. ` +
168
+ `Use https; plain http is accepted only for a loopback host (localhost, 127.0.0.0/8, [::1]).`,
169
+ };
170
+ }
171
+
172
+ /** Whether one normalized module-relative path is a directory the other runs through. */
173
+ function runsThrough(path: string, parent: string): boolean {
174
+ return path.startsWith(`${parent}/`);
175
+ }
176
+
177
+ /**
178
+ * Read the `sources:` block off an owner document's JSON projection.
179
+ *
180
+ * A source or entry with any problem is left out, never read partially: a
181
+ * half-read entry would stage a file the author did not declare.
182
+ *
183
+ * `ignorePins` reads a file entry's `sha256` / `executable` and a build's
184
+ * `inputs` as absent — for the one writer of pins, which replaces them whatever
185
+ * they held.
186
+ */
187
+ export function readModuleSources(
188
+ ownerJson: unknown,
189
+ options: { ignorePins?: boolean } = {},
190
+ ): ModuleSources {
191
+ const ignorePins = options.ignorePins === true;
192
+ const declared = (ownerJson as { sources?: unknown } | null)?.sources;
193
+ const sources: ModuleSource[] = [];
194
+ const problems: SourceProblem[] = [];
195
+ if (declared === undefined) return { sources, problems };
196
+ const problem = (code: SourceProblemCode, path: string, message: string) =>
197
+ problems.push({ code, path, message });
198
+ if (!isObject(declared)) {
199
+ problem("SHAPE", "sources", "sources: expected a map of source name to source.");
200
+ return { sources, problems };
201
+ }
202
+
203
+ const producers = new Map<string, { source: string; key: string }>();
204
+ for (const [name, raw] of Object.entries(declared)) {
205
+ const at = `sources.${name}`;
206
+ const label = `source '${name}'`;
207
+ if (!isObject(raw)) {
208
+ problem("SHAPE", at, `${label}: expected an object.`);
209
+ continue;
210
+ }
211
+ const build = raw.build;
212
+ if (
213
+ typeof raw.version !== "string" ||
214
+ typeof raw.url !== "string" ||
215
+ !(SOURCE_ARCHIVE_FORMATS as readonly unknown[]).includes(raw.archive) ||
216
+ !Array.isArray(raw.notices) ||
217
+ raw.notices.length === 0 ||
218
+ raw.notices.some((notice) => typeof notice !== "string") ||
219
+ !isObject(raw.entries) ||
220
+ (build !== undefined &&
221
+ (!isObject(build) ||
222
+ typeof build.cargo !== "string" ||
223
+ (!ignorePins && "inputs" in build && typeof build.inputs !== "string") ||
224
+ Object.keys(build).some((key) => !BUILD_KEYS.includes(key)))) ||
225
+ Object.keys(raw).some((key) => !SOURCE_KEYS.includes(key))
226
+ ) {
227
+ problem(
228
+ "SHAPE",
229
+ at,
230
+ `${label}: a source is { version, url, archive (${SOURCE_ARCHIVE_FORMATS.join(" | ")}), ` +
231
+ `notices (non-empty list), entries, build? { cargo, inputs? } } and nothing else.`,
232
+ );
233
+ continue;
234
+ }
235
+
236
+ let valid = true;
237
+ if (!SOURCE_NAME_TOKEN.test(name)) {
238
+ problem(
239
+ "SOURCE_INVALID",
240
+ `${at}`,
241
+ `${label}: the name is not a canonical token. Use lowercase letters, digits, '.', '-' or ` +
242
+ `'_', starting with a letter or digit.`,
243
+ );
244
+ valid = false;
245
+ }
246
+ for (const key of ["version", "url"] as const) {
247
+ if ((raw[key] as string).trim() === "") {
248
+ problem("SOURCE_INVALID", `${at}.${key}`, `${label}: '${key}' must not be empty.`);
249
+ valid = false;
250
+ }
251
+ }
252
+ const url = raw.url as string;
253
+ for (const match of url.matchAll(/\{([^{}]*)\}/g)) {
254
+ if (URL_PLACEHOLDERS.has(match[1]!)) continue;
255
+ problem(
256
+ "SOURCE_URL_PLACEHOLDER_UNKNOWN",
257
+ `${at}.url`,
258
+ `${label}: the url placeholder '{${match[1]}}' is not recognized. A url template may use ` +
259
+ `only {version} (the source's version) and {upstream} (each entry's upstream).`,
260
+ );
261
+ valid = false;
262
+ }
263
+ const insecure = url.trim() === "" ? undefined : sourceUrlProblem(url);
264
+ if (insecure) {
265
+ problem(insecure.code, `${at}.url`, `${label}: ${insecure.detail}`);
266
+ valid = false;
267
+ }
268
+ const notices: string[] = [];
269
+ (raw.notices as string[]).forEach((notice, index) => {
270
+ const verdict = normalizeNativePath(notice.trim());
271
+ if ("path" in verdict) {
272
+ notices.push(verdict.path);
273
+ return;
274
+ }
275
+ problem("SOURCE_INVALID", `${at}.notices[${index}]`, `${label}: notice ${verdict.detail}`);
276
+ valid = false;
277
+ });
278
+
279
+ let readBuild: SourceBuild | undefined;
280
+ if (isObject(build)) {
281
+ const written = (build.cargo as string).trim();
282
+ const verdict = /^\.?\/?$/.test(written) ? { path: "" } : normalizeNativePath(written);
283
+ const inputs = ignorePins ? undefined : (build.inputs as string | undefined);
284
+ if (!("path" in verdict)) {
285
+ problem(
286
+ "SOURCE_INVALID",
287
+ `${at}.build.cargo`,
288
+ verdict.kind === "escape"
289
+ ? `${label}: build.cargo '${written}' is not a directory inside the module. Name the ` +
290
+ `crate relative to the directory holding telo.yaml; code outside the module reaches ` +
291
+ `its digest as a path dependency of that crate.`
292
+ : `${label}: build.cargo '${written}' does not name one module-relative directory.`,
293
+ );
294
+ valid = false;
295
+ } else if (inputs !== undefined && !INPUTS_DIGEST.test(inputs)) {
296
+ problem(
297
+ "SOURCE_INVALID",
298
+ `${at}.build.inputs`,
299
+ `${label}: build.inputs must be a digest of the form sha256-<43 base64url characters> — ` +
300
+ `run \`telo release stage --pin\` to write it.`,
301
+ );
302
+ valid = false;
303
+ } else {
304
+ readBuild = { cargo: verdict.path, ...(inputs !== undefined ? { inputs } : {}) };
305
+ }
306
+ }
307
+
308
+ const entries: SourceEntry[] = [];
309
+ const rawEntries = raw.entries as Record<string, unknown>;
310
+ const entryPaths = new Map<string, string>();
311
+ const links: Array<{ key: string; path: string; target: string }> = [];
312
+ // Paths first, from every key, so a link to an entry with an unrelated
313
+ // problem is not also reported as unresolved.
314
+ const pathOfKey = new Map<string, string>();
315
+ for (const key of Object.keys(rawEntries)) {
316
+ const entryAt = `${at}.entries.${key}`;
317
+ const verdict = normalizeNativePath(key.trim());
318
+ if (!("path" in verdict)) {
319
+ problem("SOURCE_ENTRY_INVALID", entryAt, `${label} entry '${key}': ${verdict.detail}`);
320
+ valid = false;
321
+ continue;
322
+ }
323
+ if (entryPaths.has(verdict.path)) {
324
+ problem(
325
+ "SOURCE_ENTRY_DUPLICATE",
326
+ entryAt,
327
+ `${label} entry '${key}': path '${verdict.path}' is also produced by entry ` +
328
+ `'${entryPaths.get(verdict.path)}'.`,
329
+ );
330
+ valid = false;
331
+ continue;
332
+ }
333
+ entryPaths.set(verdict.path, key);
334
+ pathOfKey.set(key, verdict.path);
335
+ }
336
+ for (const [key, value] of Object.entries(rawEntries)) {
337
+ const entryAt = `${at}.entries.${key}`;
338
+ const entryLabel = `${label} entry '${key}'`;
339
+ const path = pathOfKey.get(key);
340
+ if (path === undefined) continue;
341
+ if (!isObject(value)) {
342
+ problem("SHAPE", entryAt, `${entryLabel}: expected an object.`);
343
+ valid = false;
344
+ continue;
345
+ }
346
+ const fileKeys = FILE_KEYS.filter((k) => k in value);
347
+ const isLink = "target" in value;
348
+ if (isLink && fileKeys.length > 0) {
349
+ problem(
350
+ "SOURCE_ENTRY_INVALID",
351
+ entryAt,
352
+ `${entryLabel}: an entry is either a link (target) or a file (upstream, member, ` +
353
+ `sha256, executable) — remove ${fileKeys.map((k) => `'${k}'`).join(", ")} or 'target'.`,
354
+ );
355
+ valid = false;
356
+ continue;
357
+ }
358
+ const strings = isLink ? ["target"] : ["upstream", "member"];
359
+ if (
360
+ strings.some((k) => typeof value[k] !== "string") ||
361
+ (!ignorePins && "sha256" in value && typeof value.sha256 !== "string") ||
362
+ (!ignorePins && "executable" in value && typeof value.executable !== "boolean")
363
+ ) {
364
+ problem("SHAPE", entryAt, `${entryLabel}: expected a file or a link entry.`);
365
+ valid = false;
366
+ continue;
367
+ }
368
+ const blank = strings.filter((k) => (value[k] as string).trim() === "");
369
+ if (blank.length > 0) {
370
+ for (const k of blank) {
371
+ problem("SOURCE_ENTRY_INVALID", `${entryAt}.${k}`, `${entryLabel}: '${k}' must not be empty.`);
372
+ }
373
+ valid = false;
374
+ continue;
375
+ }
376
+ if (isLink) {
377
+ links.push({ key, path, target: value.target as string });
378
+ continue;
379
+ }
380
+ const sha256 = ignorePins ? undefined : (value.sha256 as string | undefined);
381
+ const executable = ignorePins ? undefined : (value.executable as boolean | undefined);
382
+ if (sha256 !== undefined && !SHA256_HEX.test(sha256)) {
383
+ problem(
384
+ "SOURCE_ENTRY_INVALID",
385
+ `${entryAt}.sha256`,
386
+ `${entryLabel}: sha256 must be 64 lowercase hex characters — run ` +
387
+ `\`telo release stage --pin\` to write it.`,
388
+ );
389
+ valid = false;
390
+ continue;
391
+ }
392
+ if ((sha256 === undefined) !== (executable === undefined)) {
393
+ problem(
394
+ "SOURCE_ENTRY_INVALID",
395
+ entryAt,
396
+ `${entryLabel}: a pin is sha256 and executable together — run ` +
397
+ `\`telo release stage --pin\` to write both.`,
398
+ );
399
+ valid = false;
400
+ continue;
401
+ }
402
+ entries.push({
403
+ kind: "file",
404
+ key,
405
+ path,
406
+ upstream: value.upstream as string,
407
+ member: value.member as string,
408
+ ...(sha256 !== undefined ? { pin: { sha256, executable: executable! } } : {}),
409
+ });
410
+ }
411
+
412
+ const linkTargets = new Map<string, string>();
413
+ for (const link of links) {
414
+ const resolved = resolveLinkTarget(link.path, link.target);
415
+ const targetAt = `${at}.entries.${link.key}.target`;
416
+ if (resolved === link.path) {
417
+ problem(
418
+ "SOURCE_LINK_CYCLE",
419
+ targetAt,
420
+ `${label} entry '${link.key}': link target '${link.target}' resolves to the link itself.`,
421
+ );
422
+ valid = false;
423
+ continue;
424
+ }
425
+ if (resolved === undefined || !entryPaths.has(resolved)) {
426
+ problem(
427
+ "SOURCE_LINK_TARGET_UNRESOLVED",
428
+ targetAt,
429
+ `${label} entry '${link.key}': link target '${link.target}' resolves, relative to the ` +
430
+ `link's directory, to ${resolved === undefined ? "a path outside the module" : `'${resolved}'`}, ` +
431
+ `which is not another entry of this source.`,
432
+ );
433
+ valid = false;
434
+ continue;
435
+ }
436
+ linkTargets.set(link.path, resolved);
437
+ entries.push({ kind: "link", key: link.key, path: link.path, target: link.target, resolved });
438
+ }
439
+ for (const link of links) {
440
+ if (!linkTargets.has(link.path)) continue;
441
+ const chain = [link.path];
442
+ for (let next = linkTargets.get(link.path); next !== undefined; next = linkTargets.get(next)) {
443
+ if (!chain.includes(next)) {
444
+ chain.push(next);
445
+ continue;
446
+ }
447
+ problem(
448
+ "SOURCE_LINK_CYCLE",
449
+ `${at}.entries.${link.key}.target`,
450
+ `${label} entry '${link.key}': the link never reaches a file — it cycles through ` +
451
+ `${chain.map((p) => `'${p}'`).join(" → ")} → '${next}'. A link chain must end at a file entry.`,
452
+ );
453
+ valid = false;
454
+ break;
455
+ }
456
+ }
457
+
458
+ for (const entry of entries) {
459
+ const other = producers.get(entry.path);
460
+ if (other !== undefined) {
461
+ problem(
462
+ "SOURCE_ENTRY_DUPLICATE",
463
+ `${at}.entries.${entry.key}`,
464
+ `${label} entry '${entry.key}': path '${entry.path}' is also produced by source ` +
465
+ `'${other.source}'. Each staged file has exactly one source.`,
466
+ );
467
+ valid = false;
468
+ continue;
469
+ }
470
+ for (const [path, producer] of producers) {
471
+ const relation = runsThrough(entry.path, path)
472
+ ? `runs through '${path}' as a directory`
473
+ : runsThrough(path, entry.path)
474
+ ? `is a directory that '${path}' runs through`
475
+ : undefined;
476
+ if (relation === undefined) continue;
477
+ problem(
478
+ "SOURCE_ENTRY_NESTED",
479
+ `${at}.entries.${entry.key}`,
480
+ `${label} entry '${entry.key}': path '${entry.path}' ${relation}, and source ` +
481
+ `'${producer.source}' entry '${producer.key}' produces '${path}'. A staged path cannot ` +
482
+ `be both a file and a directory — give each entry its own path.`,
483
+ );
484
+ valid = false;
485
+ break;
486
+ }
487
+ producers.set(entry.path, { source: name, key: entry.key });
488
+ }
489
+
490
+ if (!valid) continue;
491
+ sources.push({
492
+ name,
493
+ version: raw.version as string,
494
+ url,
495
+ archive: raw.archive as SourceArchiveFormat,
496
+ notices,
497
+ entries,
498
+ ...(readBuild !== undefined ? { build: readBuild } : {}),
499
+ });
500
+ }
501
+ return { sources, problems };
502
+ }
@@ -0,0 +1,55 @@
1
+ import { gatherPropertySchemas, resolveLocalRef, walkStepArray } from "./schema-walk.js";
2
+ import { readStepSlot } from "./step-slot.js";
3
+
4
+ /**
5
+ * A declaration's step bodies, found through its kind's step-body slots — the
6
+ * ONLY way a value is read as a step, never its shape. Shared by inline
7
+ * extraction and the scope-reach check, which must agree about where a step's
8
+ * dispatch target is.
9
+ *
10
+ * Browser-safe.
11
+ */
12
+
13
+ /** One step body of one declaration. */
14
+ export interface StepBody {
15
+ /** The field holding it (`steps`). */
16
+ field: string;
17
+ /** The field on each step naming what it dispatches (`invoke`). */
18
+ invokeField: string;
19
+ steps: unknown[];
20
+ /** The step item schema, local `$ref` resolved, which says how steps nest. */
21
+ itemSchema: Record<string, any> | undefined;
22
+ }
23
+
24
+ /** Every step body `declaration` holds, per its kind's author-facing `schema`. */
25
+ export function stepBodiesOf(
26
+ declaration: Record<string, unknown>,
27
+ schema: Record<string, any>,
28
+ ): StepBody[] {
29
+ const out: StepBody[] = [];
30
+ const seen = new Set<string>();
31
+ for (const [field, fieldSchema] of gatherPropertySchemas(schema)) {
32
+ const slot = readStepSlot(fieldSchema);
33
+ if (!slot || seen.has(field)) continue;
34
+ const steps = declaration[field];
35
+ if (!Array.isArray(steps)) continue;
36
+ seen.add(field);
37
+ out.push({
38
+ field,
39
+ invokeField: slot.invoke,
40
+ steps,
41
+ itemSchema: resolveLocalRef(fieldSchema.items as Record<string, any> | undefined, schema),
42
+ });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Visit every step of a body at every nesting depth, with its concrete path
48
+ * (`steps[1].then[0]`). */
49
+ export function forEachStep(
50
+ body: StepBody,
51
+ schema: Record<string, any>,
52
+ visit: (step: Record<string, any>, stepPath: string) => void,
53
+ ): void {
54
+ walkStepArray(body.steps, body.itemSchema, schema, body.field, visit);
55
+ }
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.89.0";
9
+ export const TELO_SURFACE_VERSION = "0.90.0";
@@ -496,8 +496,9 @@ export function resolveContextAnnotations(
496
496
  }
497
497
  }
498
498
  }
499
- // Open fallback so unresolved types never produce false-positive CEL diagnostics.
500
- return { type: "object", additionalProperties: true };
499
+ // Untyped (`dyn`) so an unresolved type never produces a false-positive CEL
500
+ // diagnostic; an open object would still type as `map` and refuse `acc + x`.
501
+ return {};
501
502
  }
502
503
 
503
504
  const refFrom = schema["x-telo-context-ref-from"] as string | undefined;