@telorun/analyzer 0.67.0 → 0.69.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/analysis-registry.d.ts +12 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +26 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +23 -0
- package/dist/call-graph.d.ts +80 -1
- package/dist/call-graph.d.ts.map +1 -1
- package/dist/call-graph.js +145 -12
- package/dist/extends-resolution.d.ts +20 -0
- package/dist/extends-resolution.d.ts.map +1 -1
- package/dist/extends-resolution.js +29 -0
- package/dist/import-resolution-diagnostics.d.ts.map +1 -1
- package/dist/import-resolution-diagnostics.js +22 -8
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -6
- package/dist/invocation-contract.d.ts +20 -0
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +74 -0
- package/dist/loaded-types.d.ts +4 -4
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-analysis.d.ts +19 -0
- package/dist/manifest-analysis.d.ts.map +1 -1
- package/dist/manifest-analysis.js +27 -0
- package/dist/manifest-loader.d.ts +3 -4
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +4 -5
- package/dist/manifest-schemas.d.ts +2 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +4 -0
- package/dist/module-graph.d.ts +500 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +1411 -0
- package/dist/reconcile-module-versions.d.ts.map +1 -1
- package/dist/reconcile-module-versions.js +10 -11
- package/dist/release/release-plan.d.ts +1 -1
- package/dist/resolve-zone-containment.d.ts +9 -1
- package/dist/resolve-zone-containment.d.ts.map +1 -1
- package/dist/resolve-zone-containment.js +34 -6
- package/dist/resolve-zone-requirements.d.ts.map +1 -1
- package/dist/resolve-zone-requirements.js +4 -2
- package/dist/schema-keywords.d.ts +16 -1
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +20 -0
- package/dist/sources/default-sources.d.ts +6 -6
- package/dist/sources/default-sources.d.ts.map +1 -1
- package/dist/sources/default-sources.js +7 -8
- package/dist/sources/integrity.d.ts +3 -2
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +26 -3
- package/dist/sources/versioned-ref.d.ts +17 -12
- package/dist/sources/versioned-ref.d.ts.map +1 -1
- package/dist/sources/versioned-ref.js +22 -24
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +42 -1
- package/dist/validate-sensitive-slots.d.ts +28 -0
- package/dist/validate-sensitive-slots.d.ts.map +1 -0
- package/dist/validate-sensitive-slots.js +75 -0
- package/dist/value-type-keyword.d.ts +1 -1
- package/dist/value-type-keyword.d.ts.map +1 -1
- package/dist/value-type-keyword.js +1 -0
- package/package.json +1 -1
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +26 -0
- package/src/call-graph.ts +207 -14
- package/src/extends-resolution.ts +33 -0
- package/src/import-resolution-diagnostics.ts +24 -7
- package/src/index.ts +42 -5
- package/src/invocation-contract.ts +71 -0
- package/src/loaded-types.ts +4 -4
- package/src/manifest-analysis.ts +39 -0
- package/src/manifest-loader.ts +4 -5
- package/src/manifest-schemas.ts +4 -0
- package/src/module-graph.ts +2005 -0
- package/src/reconcile-module-versions.ts +10 -11
- package/src/release/release-plan.ts +1 -1
- package/src/resolve-zone-containment.ts +49 -9
- package/src/resolve-zone-requirements.ts +7 -2
- package/src/schema-keywords.ts +33 -1
- package/src/sources/default-sources.ts +7 -8
- package/src/sources/integrity.ts +28 -3
- package/src/sources/versioned-ref.ts +26 -28
- package/src/telo-version.ts +1 -1
- package/src/validate-extends.ts +51 -1
- package/src/validate-sensitive-slots.ts +109 -0
- package/src/value-type-keyword.ts +1 -0
- package/dist/sources/module-ref.d.ts +0 -21
- package/dist/sources/module-ref.d.ts.map +0 -1
- package/dist/sources/module-ref.js +0 -36
- package/dist/sources/registry-source.d.ts +0 -14
- package/dist/sources/registry-source.d.ts.map +0 -1
- package/dist/sources/registry-source.js +0 -45
- package/src/sources/module-ref.ts +0 -49
- package/src/sources/registry-source.ts +0 -52
|
@@ -50,15 +50,14 @@ interface ModuleIdentity {
|
|
|
50
50
|
* **What this key cannot relate.** It compares ref *spellings*, so it groups by
|
|
51
51
|
* origin exactly and nothing else. Two consequences, both accepted:
|
|
52
52
|
*
|
|
53
|
-
* - A module imported once by
|
|
54
|
-
* groups, so a version skew between them is not hoisted. Keying on what
|
|
55
|
-
* module declares about itself would catch that case, but only by
|
|
56
|
-
* self-declared identity — which is what this change removes, and
|
|
57
|
-
* cannot tell two same-named modules from different origins apart.
|
|
58
|
-
* -
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* function does not have. */
|
|
53
|
+
* - A module imported once by an `oci://` ref and once by a relative path is
|
|
54
|
+
* two groups, so a version skew between them is not hoisted. Keying on what
|
|
55
|
+
* the module declares about itself would catch that case, but only by
|
|
56
|
+
* trusting a self-declared identity — which is what this change removes, and
|
|
57
|
+
* which cannot tell two same-named modules from different origins apart.
|
|
58
|
+
* - An `oci://` ref and a direct `https://…/telo.yaml` URL serving the same
|
|
59
|
+
* module are two groups. Relating them needs knowledge of the origin's
|
|
60
|
+
* layout, which this pure, browser-safe function does not have. */
|
|
62
61
|
function refIdentity(ref: string): string | null {
|
|
63
62
|
const base = ref.split("#")[0];
|
|
64
63
|
if (!base || base.startsWith(".") || base.startsWith("/") || base.startsWith("file:")) {
|
|
@@ -185,8 +184,8 @@ export function reconcileModuleVersions(
|
|
|
185
184
|
// source normally maps to exactly one identity; the entry module has no
|
|
186
185
|
// inbound edge and needs none (it is never reconciled against itself).
|
|
187
186
|
//
|
|
188
|
-
// One source CAN be reached by two spellings of the same location (a
|
|
189
|
-
//
|
|
187
|
+
// One source CAN be reached by two spellings of the same location (a ref and
|
|
188
|
+
// the direct URL it resolves to). Both name the same module
|
|
190
189
|
// at the same version, so either identity groups it correctly — but the choice
|
|
191
190
|
// must not depend on edge iteration order, or the same graph could reconcile
|
|
192
191
|
// differently across runs. First edge wins.
|
|
@@ -60,7 +60,7 @@ export interface ModuleEvidence {
|
|
|
60
60
|
readonly inlines: ReadonlyMap<ModuleKey, readonly string[]>;
|
|
61
61
|
/**
|
|
62
62
|
* Modules reached by an in-repo **relative** `imports:` source. A pinned
|
|
63
|
-
*
|
|
63
|
+
* remote ref is deliberately not an edge: pinning is the statement "I am not
|
|
64
64
|
* affected until I choose to be", and moving it is `telo upgrade`'s job.
|
|
65
65
|
*/
|
|
66
66
|
readonly imports: readonly ModuleKey[];
|
|
@@ -195,8 +195,16 @@ function collect(
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** A region as the WALK finds it, before any attribute is asked for: the slot
|
|
199
|
+
* that opens it and everything it reaches. Every attribute query is a filter
|
|
200
|
+
* over these, so a consumer wanting regions regardless of what they declare —
|
|
201
|
+
* an editor drawing the enclosure, where a zone declaring no attribute is
|
|
202
|
+
* still a zone — reads them directly instead of enumerating the vocabulary and
|
|
203
|
+
* silently missing whatever is not in it. */
|
|
204
|
+
export type ZoneProvider = Omit<ZoneRegion, "attribute" | "reason">;
|
|
205
|
+
|
|
198
206
|
/**
|
|
199
|
-
* Every region in the graph
|
|
207
|
+
* Every region in the graph, keyed by the slot that opens it.
|
|
200
208
|
*
|
|
201
209
|
* The provider itself is NOT in `contents` — a zone constrains what runs inside
|
|
202
210
|
* its body, not the resource that establishes it. A transaction's own
|
|
@@ -204,12 +212,11 @@ function collect(
|
|
|
204
212
|
* treated the provider as contained would report the provider against its own
|
|
205
213
|
* constraint.
|
|
206
214
|
*/
|
|
207
|
-
export function
|
|
215
|
+
export function findZoneProviders(
|
|
208
216
|
graph: CallGraph,
|
|
209
217
|
resolveDef: DefinitionLookup,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const regions: ZoneRegion[] = [];
|
|
218
|
+
): ZoneProvider[] {
|
|
219
|
+
const regions: ZoneProvider[] = [];
|
|
213
220
|
|
|
214
221
|
for (const node of graph.nodes.values()) {
|
|
215
222
|
if (node.type !== "resource") continue;
|
|
@@ -221,8 +228,7 @@ export function findZoneRegions(
|
|
|
221
228
|
// properties rather than by knowing any kind's field names.
|
|
222
229
|
for (const [slot, slotSchema] of providingSlots(rootSchema)) {
|
|
223
230
|
const provides = readProvidesZone(slotSchema);
|
|
224
|
-
|
|
225
|
-
if (!provides || typeof reason !== "string") continue;
|
|
231
|
+
if (!provides) continue;
|
|
226
232
|
|
|
227
233
|
const contents = new Map<string, ContainedNode>();
|
|
228
234
|
const boundaries: RegionBoundary[] = [];
|
|
@@ -257,8 +263,6 @@ export function findZoneRegions(
|
|
|
257
263
|
}
|
|
258
264
|
|
|
259
265
|
regions.push({
|
|
260
|
-
attribute,
|
|
261
|
-
reason,
|
|
262
266
|
provider: node,
|
|
263
267
|
slot,
|
|
264
268
|
attributes: provides.attributes as Readonly<Record<string, string>>,
|
|
@@ -271,6 +275,42 @@ export function findZoneRegions(
|
|
|
271
275
|
return regions;
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Every region opened by a slot declaring `attribute`.
|
|
280
|
+
*
|
|
281
|
+
* A filter over {@link findZoneProviders}, so the walk is stated once: what an
|
|
282
|
+
* attribute query adds is which regions are in scope and the author's `reason`,
|
|
283
|
+
* which is the attribute's own value.
|
|
284
|
+
*
|
|
285
|
+
* **The provider list is memoized per graph**, because the filter made the walk
|
|
286
|
+
* unconditional: it used to skip the containment BFS for any provider not
|
|
287
|
+
* declaring the queried attribute, and six queries per analysis (four zone
|
|
288
|
+
* attributes plus two durable ones) became six full walks over every provider
|
|
289
|
+
* in the module. The cache is keyed on the CallGraph object, which is itself
|
|
290
|
+
* built once per analysis — so it lives exactly as long as the answer stays
|
|
291
|
+
* true, and a rebuilt graph gets a fresh one.
|
|
292
|
+
*/
|
|
293
|
+
const providersByGraph = new WeakMap<CallGraph, ZoneProvider[]>();
|
|
294
|
+
|
|
295
|
+
export function findZoneRegions(
|
|
296
|
+
graph: CallGraph,
|
|
297
|
+
resolveDef: DefinitionLookup,
|
|
298
|
+
attribute: string,
|
|
299
|
+
): ZoneRegion[] {
|
|
300
|
+
let providers = providersByGraph.get(graph);
|
|
301
|
+
if (!providers) {
|
|
302
|
+
providers = findZoneProviders(graph, resolveDef);
|
|
303
|
+
providersByGraph.set(graph, providers);
|
|
304
|
+
}
|
|
305
|
+
const out: ZoneRegion[] = [];
|
|
306
|
+
for (const region of providers) {
|
|
307
|
+
const reason = region.attributes[attribute];
|
|
308
|
+
if (typeof reason !== "string") continue;
|
|
309
|
+
out.push({ ...region, attribute, reason });
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
274
314
|
/** Field-map paths of every slot in a kind's schema carrying a provides-zone
|
|
275
315
|
* annotation, with the schema node at each. Walks properties, array items and
|
|
276
316
|
* `additionalProperties`, resolving local `$ref`s — the paths the call graph's
|
|
@@ -308,7 +308,12 @@ export function projectZoneRequirements(args: ProjectionArgs): ProjectionResult
|
|
|
308
308
|
const local = scopeLocal.get(`${from.scopeOwner}\0${from.scopeSite}`)?.get(name);
|
|
309
309
|
if (local) return local;
|
|
310
310
|
}
|
|
311
|
-
|
|
311
|
+
// In the scope that WROTE the name: a bare reference means the reader's own
|
|
312
|
+
// module, and two libraries may each declare one.
|
|
313
|
+
return graph.resourceByName(
|
|
314
|
+
name,
|
|
315
|
+
(from.manifest.metadata as { module?: string } | undefined)?.module,
|
|
316
|
+
);
|
|
312
317
|
};
|
|
313
318
|
|
|
314
319
|
const acceptedFor = (zone: string): ReadonlySet<string> => {
|
|
@@ -809,7 +814,7 @@ export function runZoneAnalysis(args: ZoneAnalysisArgs): AnalysisDiagnostic[] {
|
|
|
809
814
|
for (const lib of rootLibraries) {
|
|
810
815
|
for (const [exportName, specs] of openExports) {
|
|
811
816
|
if (!lib.exportedNames.has(exportName)) continue;
|
|
812
|
-
const node = graph.resourceByName(exportName);
|
|
817
|
+
const node = graph.resourceByName(exportName, lib.module);
|
|
813
818
|
if (!node || (node.manifest.metadata as { module?: string } | undefined)?.module !== lib.module) {
|
|
814
819
|
continue;
|
|
815
820
|
}
|
package/src/schema-keywords.ts
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { X_TELO_TYPE } from "@telorun/sdk";
|
|
30
|
+
|
|
31
|
+
/** The one annotation read from a DATA schema rather than a kind schema. */
|
|
32
|
+
const X_TELO_SENSITIVE = "x-telo-sensitive" as const;
|
|
30
33
|
import { ANNOTATION_KEYWORDS } from "./value-type-keyword.js";
|
|
31
34
|
|
|
32
35
|
/** A keyword entry: the JSON Schema its VALUE must satisfy, carrying the title
|
|
@@ -227,7 +230,13 @@ export function jsonSchemaKeywords(self: string): SchemaKeywords {
|
|
|
227
230
|
* a schema describing an annotation from an annotation, which nothing does yet.
|
|
228
231
|
*/
|
|
229
232
|
export const TELO_SCHEMA_ANNOTATIONS: Record<
|
|
230
|
-
|
|
233
|
+
// Exhaustive over the kind vocabulary, so adding a keyword without an entry is
|
|
234
|
+
// a compile error. `x-telo-sensitive` is excluded because it belongs to the
|
|
235
|
+
// DATA vocabulary below — it is read from a contract, not from a kind's own
|
|
236
|
+
// `schema:` — and offering it here would put it on the one schema where
|
|
237
|
+
// nothing reads it.
|
|
238
|
+
| Exclude<(typeof ANNOTATION_KEYWORDS)[number], typeof X_TELO_SENSITIVE>
|
|
239
|
+
| typeof X_TELO_TYPE,
|
|
231
240
|
Record<string, unknown>
|
|
232
241
|
> = {
|
|
233
242
|
"x-telo-eval": {
|
|
@@ -381,3 +390,26 @@ export const TELO_SCHEMA_ANNOTATIONS: Record<
|
|
|
381
390
|
type: "string",
|
|
382
391
|
},
|
|
383
392
|
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Annotations that belong on a DATA schema — an `inputType` / `outputType`
|
|
396
|
+
* contract — rather than on a kind's own `schema:`.
|
|
397
|
+
*
|
|
398
|
+
* Split out because {@link TELO_SCHEMA_ANNOTATIONS} is offered only where the
|
|
399
|
+
* fragment is `KindSchema`, which is a kind's CONFIGURATION. `x-telo-sensitive`
|
|
400
|
+
* is read from the opposite place: the kernel resolves it off a bound contract,
|
|
401
|
+
* which stamps `JsonSchema7`. Offering it from the kind vocabulary alone put it
|
|
402
|
+
* on the one schema where nothing reads it and withheld it from the two where it
|
|
403
|
+
* is the whole mechanism.
|
|
404
|
+
*/
|
|
405
|
+
export const TELO_DATA_SCHEMA_ANNOTATIONS: Record<
|
|
406
|
+
typeof X_TELO_SENSITIVE,
|
|
407
|
+
Record<string, unknown>
|
|
408
|
+
> = {
|
|
409
|
+
[X_TELO_SENSITIVE]: {
|
|
410
|
+
title: "Sensitive",
|
|
411
|
+
description:
|
|
412
|
+
"This value is auth material or equivalent: carry it as `[redacted]` in trace payloads and on the debug wire rather than verbatim. Read only from a resource's declared `inputType` / `outputType`.",
|
|
413
|
+
type: "boolean",
|
|
414
|
+
},
|
|
415
|
+
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { ManifestSource } from "../types.js";
|
|
2
2
|
import { HttpSource } from "./http-source.js";
|
|
3
|
-
import { RegistrySource } from "./registry-source.js";
|
|
4
3
|
|
|
5
|
-
/** The browser-safe built-in sources
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
export function defaultSources(
|
|
11
|
-
return [new HttpSource()
|
|
4
|
+
/** The browser-safe built-in sources. Node-specific sources (local filesystem)
|
|
5
|
+
* are supplied by the consuming package and passed alongside these into the
|
|
6
|
+
* `Loader` constructor. Callers that only want a subset (e.g. the editor, which
|
|
7
|
+
* brings its own manifest-cache adapter) construct the individual sources
|
|
8
|
+
* directly. */
|
|
9
|
+
export function defaultSources(): ManifestSource[] {
|
|
10
|
+
return [new HttpSource()];
|
|
12
11
|
}
|
package/src/sources/integrity.ts
CHANGED
|
@@ -110,11 +110,26 @@ export async function verifyIntegrity(
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** Object-storage backends (Cloudflare R2, S3) surface auth and permission
|
|
114
|
+
* failures as a **200 with an XML error body**. Caught here rather than left to
|
|
115
|
+
* the loader, which would parse the XML as YAML and report a downstream
|
|
116
|
+
* `UNDEFINED_KIND` — naming the manifest's contents as the problem instead of
|
|
117
|
+
* the origin that refused to serve it. Returns the failure detail, or `null`
|
|
118
|
+
* when the body is not an XML error document. */
|
|
119
|
+
function objectStorageError(text: string): string | null {
|
|
120
|
+
const head = text.trimStart();
|
|
121
|
+
if (!head.startsWith("<?xml") && !head.startsWith("<Error")) return null;
|
|
122
|
+
const code = text.match(/<Code>([^<]+)<\/Code>/);
|
|
123
|
+
const message = text.match(/<Message>([^<]+)<\/Message>/);
|
|
124
|
+
return code && message ? `${code[1]}: ${message[1]}` : text.slice(0, 200);
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
/** The single verified network read for remote manifests: fetch `fetchUrl`,
|
|
114
128
|
* verify the raw bytes against `integrity` (when pinned), and return both the
|
|
115
129
|
* bytes and the decoded text. The one choke point every network `ManifestSource`
|
|
116
|
-
* routes through, so verification cannot drift between them
|
|
117
|
-
* the
|
|
130
|
+
* routes through, so verification cannot drift between them — which is also why
|
|
131
|
+
* the object-storage non-manifest check lives here rather than in each source.
|
|
132
|
+
* `describe` names the artifact in error messages. */
|
|
118
133
|
export async function verifiedFetch(
|
|
119
134
|
fetchUrl: string,
|
|
120
135
|
integrity: string | undefined,
|
|
@@ -127,6 +142,16 @@ export async function verifiedFetch(
|
|
|
127
142
|
);
|
|
128
143
|
}
|
|
129
144
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
145
|
+
const text = new TextDecoder().decode(bytes);
|
|
146
|
+
// Before the integrity check: a bucket's error page hashes to something, and
|
|
147
|
+
// reporting a pin mismatch would name the wrong cause just as the loader does.
|
|
148
|
+
const storageError = objectStorageError(text);
|
|
149
|
+
if (storageError) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`The origin returned a non-manifest response for ${describe} ` +
|
|
152
|
+
`(URL: ${fetchUrl}): ${storageError}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
130
155
|
if (integrity) await verifyIntegrity(bytes, integrity, describe);
|
|
131
|
-
return { bytes, text
|
|
156
|
+
return { bytes, text };
|
|
132
157
|
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { splitIntegrity } from "./integrity.js";
|
|
2
|
-
import { isRegistryRef } from "./module-ref.js";
|
|
3
2
|
import { OCI_SCHEME } from "./oci-ref.js";
|
|
4
3
|
|
|
5
4
|
/** A module ref split into the parts an upgrade needs: the version-independent
|
|
6
5
|
* ref, the version segment it currently names, and any inline pin. */
|
|
7
6
|
export interface ParsedVersionedRef {
|
|
8
7
|
/** The ref with its `@version` segment and integrity fragment removed —
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
8
|
+
* `oci://ghcr.io/telorun/timer`. This is the identity a version list is keyed
|
|
9
|
+
* by (the hub registers modules under exactly this form). */
|
|
11
10
|
baseRef: string;
|
|
12
|
-
/** The version segment, raw —
|
|
13
|
-
*
|
|
11
|
+
/** The version segment, raw — an OCI tag or an OCI digest reference
|
|
12
|
+
* (`sha256:…`). The caller applies its own SemVer check. */
|
|
14
13
|
version: string;
|
|
15
14
|
/** Telo's inline `sha256-<base64url>` pin, when the ref carried one. */
|
|
16
15
|
integrity?: string;
|
|
@@ -20,12 +19,17 @@ export interface ParsedVersionedRef {
|
|
|
20
19
|
* upgradeable version — a local path, a bare `https://` URL, or an OCI ref
|
|
21
20
|
* with no explicit reference (an implicit `latest` is not a pin).
|
|
22
21
|
*
|
|
23
|
-
* Browser-safe
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
22
|
+
* Browser-safe: this is the *grammar* half of an upgrade, shared by the kernel
|
|
23
|
+
* transports (whose `refVersion` / `withVersion` delegate here) and the editor,
|
|
24
|
+
* which cannot use a transport at all — the *network* half (enumerating
|
|
25
|
+
* versions) is scheme-specific and stays behind `Transport.listVersions` on
|
|
26
|
+
* Node and the hub's `/module/versions` in the browser.
|
|
27
|
+
*
|
|
28
|
+
* It is NOT transport-neutral: `oci://` is the only versionable grammar, named
|
|
29
|
+
* literally below. That was equally true when the registry ref was the second
|
|
30
|
+
* branch, and a third scheme still means editing this file — the versionable
|
|
31
|
+
* set would have to become data the transports contribute before that claim
|
|
32
|
+
* could be made honestly. */
|
|
29
33
|
export function parseVersionedRef(ref: string): ParsedVersionedRef | null {
|
|
30
34
|
const { base, integrity } = splitIntegrity(ref);
|
|
31
35
|
const at = versionSeparator(base);
|
|
@@ -41,37 +45,31 @@ export function parseVersionedRef(ref: string): ParsedVersionedRef | null {
|
|
|
41
45
|
*
|
|
42
46
|
* Throws when the ref's grammar has no version segment at all — a relative
|
|
43
47
|
* path, a bare `https://` URL. Producing `../lib@0.4.0` for those would write
|
|
44
|
-
* a ref nothing can resolve, so this fails where the transport-specific
|
|
45
|
-
*
|
|
48
|
+
* a ref nothing can resolve, so this fails where the transport-specific parser
|
|
49
|
+
* it replaced (`parseOciRef`) also failed. */
|
|
46
50
|
export function withRefVersion(ref: string, version: string): string {
|
|
47
51
|
const { base } = splitIntegrity(ref);
|
|
48
|
-
if (
|
|
52
|
+
if (!isVersionableRef(base)) {
|
|
49
53
|
throw new Error(
|
|
50
|
-
`Cannot set a version on '${ref}' — only
|
|
51
|
-
`and oci:// refs carry a version segment.`,
|
|
54
|
+
`Cannot set a version on '${ref}' — only oci:// refs carry a version segment.`,
|
|
52
55
|
);
|
|
53
56
|
}
|
|
54
57
|
const at = versionSeparator(base);
|
|
55
58
|
return `${at === null ? base : base.slice(0, at)}@${version}`;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return base.indexOf("/", OCI_SCHEME.length) > OCI_SCHEME.length ? "oci" : null;
|
|
64
|
-
}
|
|
65
|
-
// `isRegistryRef` requires the `@`, so a version-less `std/console` is not a
|
|
66
|
-
// registry ref by this test — matching `parseModuleRef`, which throws on it.
|
|
67
|
-
return isRegistryRef(base) ? "registry" : null;
|
|
61
|
+
/** True when `base` is written in a grammar that carries a version segment — a
|
|
62
|
+
* relative/absolute path and a `file:`/`https://` URL are not. */
|
|
63
|
+
function isVersionableRef(base: string): boolean {
|
|
64
|
+
// A host alone is not addressable; the repo path is what carries a version.
|
|
65
|
+
return base.startsWith(OCI_SCHEME) && base.indexOf("/", OCI_SCHEME.length) > OCI_SCHEME.length;
|
|
68
66
|
}
|
|
69
67
|
|
|
70
68
|
/** Index of the `@` that separates the version, or `null` when the ref names
|
|
71
69
|
* none. Split on the LAST `@` so a digest reference (`repo@sha256:…`) keeps
|
|
72
70
|
* everything before it as the ref. */
|
|
73
71
|
function versionSeparator(base: string): number | null {
|
|
74
|
-
if (
|
|
72
|
+
if (!isVersionableRef(base)) return null;
|
|
75
73
|
const at = base.lastIndexOf("@");
|
|
76
|
-
return at >
|
|
74
|
+
return at > OCI_SCHEME.length ? at : null;
|
|
77
75
|
}
|
package/src/telo-version.ts
CHANGED
package/src/validate-extends.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
controllerBearingAncestor,
|
|
6
|
+
effectiveAuthorSchema,
|
|
7
|
+
inheritedCapability,
|
|
8
|
+
isInheritedDelegation,
|
|
9
|
+
type DefResolver,
|
|
10
|
+
} from "./extends-resolution.js";
|
|
5
11
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
6
12
|
|
|
7
13
|
const SOURCE = "telo-analyzer";
|
|
@@ -25,6 +31,9 @@ const EXTENDS_ALIAS_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
|
|
|
25
31
|
* - EXTENDS_UNKNOWN_TARGET: alias resolves to a module, but that module has no
|
|
26
32
|
* registered definition with the target name.
|
|
27
33
|
* - EXTENDS_NON_ABSTRACT: target resolves to a Telo.Definition, not a Telo.Abstract.
|
|
34
|
+
* - EXTENDS_CLOSED_PARENT_ADDS_FIELD: a child with no `base:` declares a property its
|
|
35
|
+
* controller-bearing ancestor's closed schema rejects — the runtime counterpart is a
|
|
36
|
+
* create-time failure phrased against the ancestor kind.
|
|
28
37
|
* - CAPABILITY_SHADOWS_EXTENDS (warning): `capability` names a user-declared abstract
|
|
29
38
|
* (metadata.module !== "Telo"). Builtin lifecycle capabilities (Telo.Invocable, etc.)
|
|
30
39
|
* never trigger this — they're lifecycle roles by design.
|
|
@@ -149,6 +158,47 @@ export function validateExtends(
|
|
|
149
158
|
data: { resource, filePath, path: "capability" },
|
|
150
159
|
});
|
|
151
160
|
}
|
|
161
|
+
|
|
162
|
+
// A merge-form child (no `base:`) forwards its WHOLE config to the
|
|
163
|
+
// inherited controller as the parent's config, so a field the
|
|
164
|
+
// parent's schema closes out is rejected at the parent's own
|
|
165
|
+
// create-time validation — phrased against the parent kind, at the
|
|
166
|
+
// instance's line, about a resource the author never wrote.
|
|
167
|
+
// Reported here instead, at the property that has to change.
|
|
168
|
+
if (
|
|
169
|
+
(m as { base?: unknown }).base == null &&
|
|
170
|
+
isInheritedDelegation(m as ResourceDefinition, resolveDef)
|
|
171
|
+
) {
|
|
172
|
+
const ancestor = controllerBearingAncestor(m as ResourceDefinition, resolveDef);
|
|
173
|
+
const ancestorSchema = ancestor
|
|
174
|
+
? effectiveAuthorSchema(ancestor, resolveDef)
|
|
175
|
+
: undefined;
|
|
176
|
+
if (ancestorSchema?.additionalProperties === false) {
|
|
177
|
+
const inherited = new Set(Object.keys(ancestorSchema.properties ?? {}));
|
|
178
|
+
const ownProps = ((m as { schema?: { properties?: Record<string, unknown> } })
|
|
179
|
+
.schema?.properties ?? {}) as Record<string, unknown>;
|
|
180
|
+
const ancestorKind = `${ancestor?.metadata.module}.${ancestor?.metadata.name}`;
|
|
181
|
+
for (const prop of Object.keys(ownProps)) {
|
|
182
|
+
if (inherited.has(prop)) continue;
|
|
183
|
+
diagnostics.push({
|
|
184
|
+
severity: DiagnosticSeverity.Error,
|
|
185
|
+
code: "EXTENDS_CLOSED_PARENT_ADDS_FIELD",
|
|
186
|
+
source: SOURCE,
|
|
187
|
+
message:
|
|
188
|
+
`${label}: declares '${prop}', but '${ancestorKind}' closes its schema ` +
|
|
189
|
+
`(additionalProperties: false) and a child without 'base:' forwards its whole ` +
|
|
190
|
+
`config as that kind's config — so '${prop}' is rejected when the resource is ` +
|
|
191
|
+
`created. Add a 'base:' mapping, which makes this kind's fields construction ` +
|
|
192
|
+
`inputs and builds the parent's config explicitly.`,
|
|
193
|
+
data: {
|
|
194
|
+
resource,
|
|
195
|
+
filePath,
|
|
196
|
+
path: `schema.properties.${prop}`,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
152
202
|
}
|
|
153
203
|
}
|
|
154
204
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { SCHEMA_REGION_KEYS } from "./schema-region.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `x-telo-sensitive` where nothing reads it.
|
|
6
|
+
*
|
|
7
|
+
* The annotation has exactly one consumer: the kernel resolves it from a
|
|
8
|
+
* resource's bound CONTRACT — `inputType` / `outputType` — and carries the
|
|
9
|
+
* marked value as `[redacted]` in trace payloads. Written anywhere else it is an
|
|
10
|
+
* unknown keyword in an open schema, which is to say it validates, ships, and
|
|
11
|
+
* does nothing.
|
|
12
|
+
*
|
|
13
|
+
* For a security control that is the worst available failure: an author marks a
|
|
14
|
+
* token, sees no error, and puts it on the debug wire anyway. So a misplacement
|
|
15
|
+
* is reported rather than ignored — the same posture `X_TELO_REF_UNRESOLVED`
|
|
16
|
+
* takes toward a reference constraint that resolves to nothing, and for the same
|
|
17
|
+
* reason: silence reads as protection.
|
|
18
|
+
*
|
|
19
|
+
* Scoped by the caller to the entry's own modules, since a dependency's schema
|
|
20
|
+
* is not the consumer's to fix.
|
|
21
|
+
*/
|
|
22
|
+
export interface SensitiveSlotIssue {
|
|
23
|
+
code: "SENSITIVE_ANNOTATION_MISPLACED" | "SENSITIVE_ANNOTATION_INVALID";
|
|
24
|
+
manifest: ResourceManifest;
|
|
25
|
+
/** Dotted path to the annotated schema node. */
|
|
26
|
+
path: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ANNOTATION = "x-telo-sensitive";
|
|
31
|
+
|
|
32
|
+
/** The only regions the kernel reads the annotation from. */
|
|
33
|
+
const CONTRACT_KEYS = new Set(["inputType", "outputType"]);
|
|
34
|
+
|
|
35
|
+
export function validateSensitiveSlots(manifest: ResourceManifest): SensitiveSlotIssue[] {
|
|
36
|
+
const issues: SensitiveSlotIssue[] = [];
|
|
37
|
+
|
|
38
|
+
const walk = (node: unknown, path: (string | number)[], seen: Set<object>): void => {
|
|
39
|
+
if (!node || typeof node !== "object") return;
|
|
40
|
+
if (seen.has(node as object)) return;
|
|
41
|
+
seen.add(node as object);
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(node)) {
|
|
44
|
+
node.forEach((child, i) => walk(child, [...path, i], seen));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const record = node as Record<string, unknown>;
|
|
49
|
+
if (Object.hasOwn(record, ANNOTATION)) {
|
|
50
|
+
const dotted = path.join(".");
|
|
51
|
+
if (record[ANNOTATION] !== true) {
|
|
52
|
+
issues.push({
|
|
53
|
+
code: "SENSITIVE_ANNOTATION_INVALID",
|
|
54
|
+
manifest,
|
|
55
|
+
path: dotted,
|
|
56
|
+
message:
|
|
57
|
+
`'${ANNOTATION}' must be \`true\`; got ${JSON.stringify(record[ANNOTATION])}. ` +
|
|
58
|
+
`It is a marker, not a level — a value other than \`true\` reads as "not sensitive".`,
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
const region = enclosingSchemaRegion(path);
|
|
62
|
+
if (region === undefined || !CONTRACT_KEYS.has(region)) {
|
|
63
|
+
issues.push({
|
|
64
|
+
code: "SENSITIVE_ANNOTATION_MISPLACED",
|
|
65
|
+
manifest,
|
|
66
|
+
path: dotted,
|
|
67
|
+
message:
|
|
68
|
+
`'${ANNOTATION}' is only read from a resource's declared contract ` +
|
|
69
|
+
`(\`inputType\` / \`outputType\`), and this node is ` +
|
|
70
|
+
(region === undefined
|
|
71
|
+
? "not inside a schema at all"
|
|
72
|
+
: `inside \`${region}\``) +
|
|
73
|
+
`. The kernel will not redact it, so the value would still reach trace ` +
|
|
74
|
+
`payloads and the debug wire. Move the mark onto the contract property ` +
|
|
75
|
+
`that carries the value.`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const [key, child] of Object.entries(record)) {
|
|
82
|
+
walk(child, [...path, key], seen);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
walk(manifest as unknown as Record<string, unknown>, [], new Set());
|
|
87
|
+
return issues;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The OUTERMOST segment naming a schema-valued key, or `undefined` when the node
|
|
92
|
+
* is not inside a schema.
|
|
93
|
+
*
|
|
94
|
+
* Outermost, not nearest, because a contract is routinely written in the inline
|
|
95
|
+
* `{kind: Telo.JsonSchema, schema: …}` form — so the path to a marked property is
|
|
96
|
+
* `outputType.schema.properties.headers`, and the nearest region key is that
|
|
97
|
+
* wrapper's own `schema`. Reading it as a kind's configuration would report every
|
|
98
|
+
* correctly-marked contract as misplaced, which is how this check first behaved.
|
|
99
|
+
*
|
|
100
|
+
* It is the same reasoning `expandManifestFragments` uses when it keys on the
|
|
101
|
+
* top-level slot: the object a contract resolver is handed is the one under
|
|
102
|
+
* `inputType` / `outputType`, whatever nesting the authoring form adds beneath.
|
|
103
|
+
*/
|
|
104
|
+
function enclosingSchemaRegion(path: readonly (string | number)[]): string | undefined {
|
|
105
|
+
for (const segment of path) {
|
|
106
|
+
if (typeof segment === "string" && SCHEMA_REGION_KEYS.includes(segment)) return segment;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/** A parsed registry module reference. `modulePath` is `namespace/name`,
|
|
2
|
-
* `version` has any leading `v` stripped, and `integrity` carries the inline
|
|
3
|
-
* `sha256-<base64url>` hash when the ref was pinned. */
|
|
4
|
-
export interface ParsedModuleRef {
|
|
5
|
-
modulePath: string;
|
|
6
|
-
version: string;
|
|
7
|
-
integrity?: string;
|
|
8
|
-
}
|
|
9
|
-
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
10
|
-
* (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
|
|
11
|
-
* The integrity fragment, if any, does not affect the classification.
|
|
12
|
-
*
|
|
13
|
-
* A registry ref never carries a `scheme://` — that guard is what keeps an
|
|
14
|
-
* `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
|
|
15
|
-
* scheme-owning transport claims it instead. */
|
|
16
|
-
export declare function isRegistryRef(url: string): boolean;
|
|
17
|
-
/** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
|
|
18
|
-
* source of truth shared by the registry source, the kernel manifest cache,
|
|
19
|
-
* and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
|
|
20
|
-
export declare function parseModuleRef(ref: string): ParsedModuleRef;
|
|
21
|
-
//# sourceMappingURL=module-ref.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"module-ref.d.ts","sourceRoot":"","sources":["../../src/sources/module-ref.ts"],"names":[],"mappings":"AAEA;;yDAEyD;AACzD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;iDAMiD;AACjD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CASlD;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAgB3D"}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { splitIntegrity } from "./integrity.js";
|
|
2
|
-
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
3
|
-
* (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
|
|
4
|
-
* The integrity fragment, if any, does not affect the classification.
|
|
5
|
-
*
|
|
6
|
-
* A registry ref never carries a `scheme://` — that guard is what keeps an
|
|
7
|
-
* `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
|
|
8
|
-
* scheme-owning transport claims it instead. */
|
|
9
|
-
export function isRegistryRef(url) {
|
|
10
|
-
const { base } = splitIntegrity(url);
|
|
11
|
-
return (!base.includes("://") &&
|
|
12
|
-
!base.startsWith("/") &&
|
|
13
|
-
!base.startsWith(".") &&
|
|
14
|
-
base.includes("@") &&
|
|
15
|
-
base.includes("/"));
|
|
16
|
-
}
|
|
17
|
-
/** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
|
|
18
|
-
* source of truth shared by the registry source, the kernel manifest cache,
|
|
19
|
-
* and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
|
|
20
|
-
export function parseModuleRef(ref) {
|
|
21
|
-
const { base, integrity } = splitIntegrity(ref);
|
|
22
|
-
const atIdx = base.lastIndexOf("@");
|
|
23
|
-
if (atIdx <= 0 || atIdx === base.length - 1) {
|
|
24
|
-
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
25
|
-
}
|
|
26
|
-
const modulePath = base.slice(0, atIdx);
|
|
27
|
-
if (!modulePath.includes("/")) {
|
|
28
|
-
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
29
|
-
}
|
|
30
|
-
const rawVersion = base.slice(atIdx + 1);
|
|
31
|
-
const version = rawVersion.startsWith("v") ? rawVersion.slice(1) : rawVersion;
|
|
32
|
-
if (!version) {
|
|
33
|
-
throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
|
|
34
|
-
}
|
|
35
|
-
return { modulePath, version, integrity };
|
|
36
|
-
}
|