@telorun/analyzer 0.46.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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +8 -2
- package/dist/artifact-layer-index.d.ts +55 -0
- package/dist/artifact-layer-index.d.ts.map +1 -0
- package/dist/artifact-layer-index.js +116 -0
- package/dist/artifact-selector.d.ts +81 -0
- package/dist/artifact-selector.d.ts.map +1 -0
- package/dist/artifact-selector.js +122 -0
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +58 -19
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/resolve-ref-sentinels.d.ts +13 -1
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +56 -5
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +17 -1
- package/dist/validate-module-artifact.d.ts +27 -0
- package/dist/validate-module-artifact.d.ts.map +1 -0
- package/dist/validate-module-artifact.js +131 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +8 -2
- package/package.json +2 -2
- package/src/analyzer.ts +14 -2
- package/src/artifact-layer-index.ts +162 -0
- package/src/artifact-selector.ts +171 -0
- package/src/builtins.ts +61 -19
- package/src/index.ts +25 -0
- package/src/resolve-ref-sentinels.ts +68 -4
- package/src/validate-cel-context.ts +20 -1
- package/src/validate-module-artifact.ts +141 -0
- package/src/validate-references.ts +8 -2
|
@@ -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
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
|
684
|
-
//
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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";
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
3
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import {
|
|
5
|
+
isScopeEntry,
|
|
6
|
+
resolveFieldEntries,
|
|
7
|
+
type ReferenceFieldMap,
|
|
8
|
+
} from "./reference-field-map.js";
|
|
4
9
|
import { REF_RESOLUTION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
5
10
|
|
|
11
|
+
/** The slice of the definition registry this pass needs: a kind's field map, from
|
|
12
|
+
* which the `x-telo-scope` slots are read. */
|
|
13
|
+
export interface ScopeFieldMapSource {
|
|
14
|
+
getFieldMapForKind(
|
|
15
|
+
kind: string,
|
|
16
|
+
aliases?: { resolveKind(k: string): string | undefined },
|
|
17
|
+
): ReferenceFieldMap | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
/** Resolved ref shape written in place of a `!ref` sentinel. `alias` is set only for
|
|
7
21
|
* cross-module references (resolved into an imported library's exported instance). */
|
|
8
22
|
type ResolvedRef = { kind: string; name: string; alias?: string };
|
|
@@ -52,6 +66,10 @@ export function resolveRefSentinels(
|
|
|
52
66
|
// pass — which loads the entry module only — can still resolve `!ref Alias.name` against
|
|
53
67
|
// imported libraries' exported instances.
|
|
54
68
|
crossModuleTargets: ResourceManifest[] = [],
|
|
69
|
+
/** Supplies each kind's `x-telo-scope` slots. Without it a scoped name cannot be
|
|
70
|
+
* told from a module-level one, and a shadowed `!ref` resolves to the resource
|
|
71
|
+
* it shadows — so both call sites pass it. */
|
|
72
|
+
defs?: ScopeFieldMapSource,
|
|
55
73
|
): void {
|
|
56
74
|
const moduleOf = (r: ResourceManifest): string | undefined =>
|
|
57
75
|
(r.metadata as { module?: string } | undefined)?.module;
|
|
@@ -109,21 +127,67 @@ export function resolveRefSentinels(
|
|
|
109
127
|
return undefined;
|
|
110
128
|
};
|
|
111
129
|
|
|
130
|
+
/** Names a resource declares in its own execution scopes, read from the kind's
|
|
131
|
+
* `x-telo-scope` slots — the analyzer's single definition of "scope", shared
|
|
132
|
+
* with `manifest-visitor`. Inferring it structurally instead (any array of
|
|
133
|
+
* named inline manifests) would give scope-local shadowing to the first kind
|
|
134
|
+
* that happens to carry such an array without asking for it, and this pass is
|
|
135
|
+
* shared with the kernel, so the guess would be baked into the runtime tree
|
|
136
|
+
* rather than merely reported. */
|
|
137
|
+
const declaredInScopes = (
|
|
138
|
+
resource: ResourceManifest,
|
|
139
|
+
): Map<string, ResourceManifest> | undefined => {
|
|
140
|
+
const fieldMap = defs?.getFieldMapForKind(resource.kind, aliases);
|
|
141
|
+
if (!fieldMap) return undefined;
|
|
142
|
+
let declared: Map<string, ResourceManifest> | undefined;
|
|
143
|
+
for (const [fieldPath, entry] of fieldMap) {
|
|
144
|
+
if (!isScopeEntry(entry)) continue;
|
|
145
|
+
for (const { value } of resolveFieldEntries(resource, fieldPath)) {
|
|
146
|
+
for (const element of Array.isArray(value) ? value : [value]) {
|
|
147
|
+
if (!element || typeof element !== "object" || Array.isArray(element)) continue;
|
|
148
|
+
const manifest = element as ResourceManifest;
|
|
149
|
+
const name = (manifest.metadata as { name?: string } | undefined)?.name;
|
|
150
|
+
if (typeof manifest.kind === "string" && typeof name === "string") {
|
|
151
|
+
(declared ??= new Map()).set(name, manifest);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return declared;
|
|
157
|
+
};
|
|
158
|
+
|
|
112
159
|
// Resolve every `!ref` sentinel in the tree; leave opaque tagged / precompiled
|
|
113
160
|
// nodes (e.g. `!cel`) untouched and don't descend into them.
|
|
114
|
-
|
|
161
|
+
//
|
|
162
|
+
// `scoped` carries the names the enclosing resource declares in its `x-telo-scope`
|
|
163
|
+
// slots, and they SHADOW the module-level ones — the order the runtime resolves
|
|
164
|
+
// in. Baking the module-level kind into a shadowed reference would label traces
|
|
165
|
+
// and `getRefIdentity` with a resource that never runs.
|
|
166
|
+
const walk = (value: unknown, scoped?: Map<string, ResourceManifest>): unknown => {
|
|
115
167
|
if (isRefSentinel(value)) {
|
|
116
|
-
|
|
168
|
+
const source = value.source;
|
|
169
|
+
const bare = source.indexOf(".") === -1;
|
|
170
|
+
const shadow = bare ? scoped?.get(source) : undefined;
|
|
171
|
+
if (shadow) return { kind: shadow.kind as string, name: source };
|
|
172
|
+
return resolveTarget(source) ?? value;
|
|
117
173
|
}
|
|
118
174
|
if (value === null || typeof value !== "object") return value;
|
|
119
175
|
if (isTaggedSentinel(value)) return value;
|
|
120
176
|
if ((value as { __compiled?: unknown }).__compiled) return value;
|
|
121
177
|
if (Array.isArray(value)) {
|
|
122
|
-
for (let i = 0; i < value.length; i++) value[i] = walk(value[i]);
|
|
178
|
+
for (let i = 0; i < value.length; i++) value[i] = walk(value[i], scoped);
|
|
123
179
|
return value;
|
|
124
180
|
}
|
|
125
181
|
const obj = value as Record<string, unknown>;
|
|
126
|
-
|
|
182
|
+
// A nested inline resource may declare scopes of its own (a `Run.Sequence`
|
|
183
|
+
// inside another sequence's `with:`). Collected before descending, so the
|
|
184
|
+
// declarations are visible to every region of the resource that declares
|
|
185
|
+
// them — a sequence's `with:` names resolve in its `targets:` and `steps:`
|
|
186
|
+
// alike, not only inside `with:` itself.
|
|
187
|
+
const declared =
|
|
188
|
+
typeof obj.kind === "string" ? declaredInScopes(obj as ResourceManifest) : undefined;
|
|
189
|
+
const inner = declared ? new Map([...(scoped ?? new Map()), ...declared]) : scoped;
|
|
190
|
+
for (const key of Object.keys(obj)) obj[key] = walk(obj[key], inner);
|
|
127
191
|
return value;
|
|
128
192
|
};
|
|
129
193
|
|
|
@@ -323,18 +323,37 @@ export function resolveContextAnnotations(
|
|
|
323
323
|
typeof ref.name === "string" &&
|
|
324
324
|
subpath
|
|
325
325
|
) {
|
|
326
|
+
const segments = subpath.split("/");
|
|
326
327
|
const refManifest = allManifests.find(
|
|
327
328
|
(m) => m.kind === ref.kind && (m.metadata as any)?.name === ref.name,
|
|
328
329
|
) as Record<string, any> | undefined;
|
|
329
330
|
if (refManifest) {
|
|
330
331
|
const resolved = resolveTypeFieldToSchema(
|
|
331
|
-
navigatePath(refManifest,
|
|
332
|
+
navigatePath(refManifest, segments) as unknown,
|
|
332
333
|
allManifests,
|
|
333
334
|
);
|
|
334
335
|
if (resolved && typeof resolved === "object") {
|
|
335
336
|
return resolved;
|
|
336
337
|
}
|
|
337
338
|
}
|
|
339
|
+
// The instance declares nothing, so fall back to its KIND's declaration —
|
|
340
|
+
// the same layering `buildStepContextSchema` applies to `steps.<name>.result`,
|
|
341
|
+
// so a kind with one fixed output shape (declared once on its Telo.Definition)
|
|
342
|
+
// types the context, while a kind that exposes the field for per-instance
|
|
343
|
+
// narrowing keeps winning above.
|
|
344
|
+
if (defs) {
|
|
345
|
+
const canonical = aliases?.resolveKind(ref.kind) ?? ref.kind;
|
|
346
|
+
const def = defs.resolve(canonical) as Record<string, unknown> | undefined;
|
|
347
|
+
if (def) {
|
|
348
|
+
const resolved = resolveTypeFieldToSchema(
|
|
349
|
+
navigatePath(def, segments) as unknown,
|
|
350
|
+
allManifests,
|
|
351
|
+
);
|
|
352
|
+
if (resolved && typeof resolved === "object") {
|
|
353
|
+
return resolved;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
338
357
|
}
|
|
339
358
|
// Fallback: open schema (no false errors when outputType is not declared)
|
|
340
359
|
return { ...schema, additionalProperties: true };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
import { parseLayerIndex, LayerIndexError } from "./artifact-layer-index.js";
|
|
4
|
+
import {
|
|
5
|
+
ArtifactSelectorError,
|
|
6
|
+
PLATFORM_AXES,
|
|
7
|
+
selectorFromQualifiers,
|
|
8
|
+
} from "./artifact-selector.js";
|
|
9
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const SOURCE = "telo-analyzer";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Static validation of the module-artifact surface — `kernel/specs/module-artifact.md`.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is decidable from the manifest text alone, and every case would
|
|
17
|
+
* otherwise surface on a *consumer's* machine at controller-resolve time (or, worse,
|
|
18
|
+
* not at all). That is the whole argument: an author who mistypes a platform axis
|
|
19
|
+
* gets a platform-neutral candidate, publish emits one layer, and every host
|
|
20
|
+
* happily loads a binary built for one architecture — silently, forever.
|
|
21
|
+
*
|
|
22
|
+
* Two checks. Note that several candidates *sharing* one selector is not among
|
|
23
|
+
* them: a controller layer holds the entry points of every candidate with that
|
|
24
|
+
* selector (spec §1), which is what every module with two `js` controllers relies
|
|
25
|
+
* on.
|
|
26
|
+
*
|
|
27
|
+
* 1. **Controller selector qualifiers.** `os` / `arch` / `libc` / `siblings` are
|
|
28
|
+
* authored surface. An unknown qualifier is reported rather than ignored, since
|
|
29
|
+
* ignoring is what makes a typo invisible; an invalid value is reported here
|
|
30
|
+
* instead of throwing from the loader later.
|
|
31
|
+
* 2. **The published layer index.** The owner doc's JSON Schema covers shape; the
|
|
32
|
+
* semantic rules — controller-requires-selector, singletons carry none, no
|
|
33
|
+
* duplicate selector, the token grammar (`os: Linux` passes the schema and
|
|
34
|
+
* throws at runtime) — live in the parser, so run it.
|
|
35
|
+
*/
|
|
36
|
+
export function validateModuleArtifact(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
|
|
37
|
+
const out: AnalysisDiagnostic[] = [];
|
|
38
|
+
for (const manifest of manifests) {
|
|
39
|
+
validateLayerIndex(manifest, out);
|
|
40
|
+
validateControllerSelectors(manifest, out);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const KNOWN_QUALIFIERS = new Set<string>(["path", "siblings", ...PLATFORM_AXES]);
|
|
46
|
+
|
|
47
|
+
/** `pkg:telo/local/<format>?…` — the bundled-controller delivery mode. Parsed by
|
|
48
|
+
* hand rather than with a PURL library: the analyzer must stay browser-safe and
|
|
49
|
+
* dependency-light, and the only thing needed here is the qualifier map. */
|
|
50
|
+
function parseBundledPurl(
|
|
51
|
+
purl: string,
|
|
52
|
+
): { format: string; qualifiers: Record<string, string> } | null {
|
|
53
|
+
if (!purl.startsWith("pkg:telo/local/")) return null;
|
|
54
|
+
const withoutFragment = purl.split("#")[0];
|
|
55
|
+
const [head, query = ""] = withoutFragment.split("?");
|
|
56
|
+
const format = head.slice("pkg:telo/local/".length);
|
|
57
|
+
if (format === "") return null;
|
|
58
|
+
const qualifiers: Record<string, string> = {};
|
|
59
|
+
for (const pair of query.split("&")) {
|
|
60
|
+
if (pair === "") continue;
|
|
61
|
+
const eq = pair.indexOf("=");
|
|
62
|
+
if (eq < 0) continue;
|
|
63
|
+
qualifiers[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent(pair.slice(eq + 1));
|
|
64
|
+
}
|
|
65
|
+
return { format, qualifiers };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateControllerSelectors(
|
|
69
|
+
manifest: ResourceManifest,
|
|
70
|
+
out: AnalysisDiagnostic[],
|
|
71
|
+
): void {
|
|
72
|
+
const controllers = (manifest as { controllers?: unknown }).controllers;
|
|
73
|
+
if (!Array.isArray(controllers)) return;
|
|
74
|
+
const metadata = manifest.metadata as
|
|
75
|
+
| { name?: string; module?: string; source?: string }
|
|
76
|
+
| undefined;
|
|
77
|
+
const name = metadata?.name;
|
|
78
|
+
const filePath = metadata?.source;
|
|
79
|
+
const resource = { kind: manifest.kind, name };
|
|
80
|
+
|
|
81
|
+
controllers.forEach((candidate, index) => {
|
|
82
|
+
if (typeof candidate !== "string") return;
|
|
83
|
+
const parsed = parseBundledPurl(candidate);
|
|
84
|
+
if (!parsed) return;
|
|
85
|
+
const at = `controllers[${index}]`;
|
|
86
|
+
|
|
87
|
+
const unknown = Object.keys(parsed.qualifiers).filter((k) => !KNOWN_QUALIFIERS.has(k));
|
|
88
|
+
for (const key of unknown) {
|
|
89
|
+
out.push({
|
|
90
|
+
severity: DiagnosticSeverity.Error,
|
|
91
|
+
code: "CONTROLLER_UNKNOWN_QUALIFIER",
|
|
92
|
+
source: SOURCE,
|
|
93
|
+
message:
|
|
94
|
+
`${manifest.kind}/${name ?? "(unnamed)"}: bundled controller qualifier '${key}' is not ` +
|
|
95
|
+
`recognized. Known qualifiers: ${[...KNOWN_QUALIFIERS].sort().join(", ")}. An ` +
|
|
96
|
+
`unrecognized platform axis is ignored, which would make this candidate ` +
|
|
97
|
+
`platform-neutral and offer a single-platform binary to every host.`,
|
|
98
|
+
data: { resource, filePath, path: `${at}?${key}` },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate the selector; the value is not otherwise needed here, since
|
|
103
|
+
// candidates sharing a selector legitimately share a layer.
|
|
104
|
+
try {
|
|
105
|
+
selectorFromQualifiers(parsed.format, parsed.qualifiers, candidate);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (!(err instanceof ArtifactSelectorError)) throw err;
|
|
108
|
+
out.push({
|
|
109
|
+
severity: DiagnosticSeverity.Error,
|
|
110
|
+
code: "CONTROLLER_INVALID_SELECTOR",
|
|
111
|
+
source: SOURCE,
|
|
112
|
+
message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
|
|
113
|
+
data: { resource, filePath, path: at },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function validateLayerIndex(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
|
|
120
|
+
if (manifest.kind !== "Telo.Application" && manifest.kind !== "Telo.Library") return;
|
|
121
|
+
const layers = (manifest as { layers?: unknown }).layers;
|
|
122
|
+
if (layers === undefined) return;
|
|
123
|
+
const metadata = manifest.metadata as { name?: string; source?: string } | undefined;
|
|
124
|
+
const name = metadata?.name;
|
|
125
|
+
try {
|
|
126
|
+
parseLayerIndex(layers);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (!(err instanceof LayerIndexError) && !(err instanceof ArtifactSelectorError)) throw err;
|
|
129
|
+
out.push({
|
|
130
|
+
severity: DiagnosticSeverity.Error,
|
|
131
|
+
code: "INVALID_LAYER_INDEX",
|
|
132
|
+
source: SOURCE,
|
|
133
|
+
message: `${manifest.kind}/${name ?? "(unnamed)"}: ${err.message}`,
|
|
134
|
+
data: {
|
|
135
|
+
resource: { kind: manifest.kind, name },
|
|
136
|
+
filePath: metadata?.source,
|
|
137
|
+
path: "layers",
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -267,9 +267,15 @@ export function validateReferences(
|
|
|
267
267
|
|
|
268
268
|
// Local reference (bare name or explicit `Self.`-qualified).
|
|
269
269
|
const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
|
|
270
|
+
// Scope-local FIRST, enclosing module as the fallback — the order the
|
|
271
|
+
// runtime uses at every name-resolution site (`ScopeContext.getInstance`,
|
|
272
|
+
// `ResourceContext.resolveRef`, and the CEL `resources` layering). Module-first
|
|
273
|
+
// here would validate a shadowed name against the resource the kernel will
|
|
274
|
+
// never bind: a false pass when the outer kind fits and the scoped one does
|
|
275
|
+
// not, a false REFERENCE_KIND_MISMATCH when it is the other way round.
|
|
270
276
|
const target =
|
|
271
|
-
|
|
272
|
-
|
|
277
|
+
visibleScopeManifests.find((m) => m.metadata?.name === localName) ??
|
|
278
|
+
byName.get(localName);
|
|
273
279
|
if (!target) {
|
|
274
280
|
diagnostics.push({
|
|
275
281
|
severity: DiagnosticSeverity.Error,
|