@telorun/analyzer 0.32.0 → 0.33.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 +263 -71
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/sources/integrity.d.ts +7 -0
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +12 -2
- package/dist/sources/module-ref.d.ts +6 -2
- package/dist/sources/module-ref.d.ts.map +1 -1
- package/dist/sources/module-ref.js +7 -6
- package/dist/validate-kind-descriptions.d.ts +20 -0
- package/dist/validate-kind-descriptions.d.ts.map +1 -0
- package/dist/validate-kind-descriptions.js +65 -0
- package/package.json +2 -2
- package/src/analyzer.ts +301 -82
- package/src/index.ts +1 -0
- package/src/sources/integrity.ts +13 -2
- package/src/sources/module-ref.ts +7 -6
- package/src/validate-kind-descriptions.ts +65 -0
package/dist/analyzer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAmB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAsB,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAKzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,EAIL,KAAK,WAAW,EACjB,MAAM,sBAAsB,CAAC;AAmB9B,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA2wB/F,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,EAAE,WAAW,CAAC;CAC3B;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;gBAEzB,OAAO,GAAE,qBAA0B;IAI/C;;;;;;;;;;;;;;OAcG;IACH,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IA0yBvB,aAAa,CACX,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,CAAC,EAAE,eAAe,EACzB,QAAQ,CAAC,EAAE,gBAAgB,GAC1B,kBAAkB,EAAE;IAMvB,SAAS,CACP,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,EAI1B,kBAAkB,CAAC,EAAE,gBAAgB,EAAE,GACtC,gBAAgB,EAAE;IAmBrB,OAAO,CACL,SAAS,EAAE,gBAAgB,EAAE,EAC7B,QAAQ,EAAE,gBAAgB,GACzB;QAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;CAsB5F"}
|
package/dist/analyzer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalTypeSchemaId } from "@telorun/sdk";
|
|
2
|
-
import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
2
|
+
import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
3
3
|
import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
|
|
4
4
|
import { buildCelEnvironment, buildImportInputCelEnvironment, buildTypedCelEnvironment, } from "./cel-environment.js";
|
|
5
5
|
import { DefinitionRegistry } from "./definition-registry.js";
|
|
@@ -20,6 +20,7 @@ import { extractCelRegionScopes, extractContextsFromSchema, getManifestItem, pat
|
|
|
20
20
|
import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
|
|
21
21
|
import { validateExtends } from "./validate-extends.js";
|
|
22
22
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
23
|
+
import { validateKindDescriptions } from "./validate-kind-descriptions.js";
|
|
23
24
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
24
25
|
import { validateReferences } from "./validate-references.js";
|
|
25
26
|
import { validateReferenceForms } from "./validate-reference-forms.js";
|
|
@@ -186,6 +187,59 @@ function gatherPropertySchemas(schema) {
|
|
|
186
187
|
}
|
|
187
188
|
return out;
|
|
188
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Generic, role-driven walk over an `x-telo-step-context` step array. Calls
|
|
192
|
+
* `visit(step, stepPath)` for every step — top-level and nested through the
|
|
193
|
+
* `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
|
|
194
|
+
* the single definition of how steps nest, shared by `buildStepContextSchema`
|
|
195
|
+
* (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
|
|
196
|
+
* checks invoke refs), so the topology contract lives in one place — adding a
|
|
197
|
+
* role or nesting form updates both consumers at once. No resource kind is
|
|
198
|
+
* hardcoded; recursion is driven entirely by the schema annotations.
|
|
199
|
+
*/
|
|
200
|
+
function walkStepArray(steps, stepItemSchema, rootSchema, basePath, visit) {
|
|
201
|
+
const dispatchRole = (data, role, itemsSchema, path) => {
|
|
202
|
+
if (role === "branch" && Array.isArray(data)) {
|
|
203
|
+
walkStepArray(data, stepItemSchema, rootSchema, path, visit);
|
|
204
|
+
}
|
|
205
|
+
else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
|
|
206
|
+
for (const [caseKey, arr] of Object.entries(data)) {
|
|
207
|
+
if (Array.isArray(arr))
|
|
208
|
+
walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (role === "branch-list" && Array.isArray(data)) {
|
|
212
|
+
const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
|
|
213
|
+
if (!entrySchema)
|
|
214
|
+
return;
|
|
215
|
+
data.forEach((entry, i) => {
|
|
216
|
+
if (!entry || typeof entry !== "object")
|
|
217
|
+
return;
|
|
218
|
+
for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
|
|
219
|
+
const subRole = subSchema["x-telo-topology-role"];
|
|
220
|
+
if (typeof subRole !== "string")
|
|
221
|
+
continue;
|
|
222
|
+
dispatchRole(entry[subKey], subRole, subSchema.items, `${path}[${i}].${subKey}`);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
steps.forEach((step, i) => {
|
|
228
|
+
if (!step || typeof step !== "object")
|
|
229
|
+
return;
|
|
230
|
+
const s = step;
|
|
231
|
+
const stepPath = `${basePath}[${i}]`;
|
|
232
|
+
visit(s, stepPath);
|
|
233
|
+
if (!stepItemSchema)
|
|
234
|
+
return;
|
|
235
|
+
for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
|
|
236
|
+
const role = propSchema["x-telo-topology-role"];
|
|
237
|
+
if (typeof role !== "string")
|
|
238
|
+
continue;
|
|
239
|
+
dispatchRole(s[propKey], role, propSchema.items, `${stepPath}.${propKey}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
189
243
|
/**
|
|
190
244
|
* Build a `steps` context schema from `x-telo-step-context` annotation.
|
|
191
245
|
* Walks each step in the manifest array, resolves the invoked resource's outputType,
|
|
@@ -229,80 +283,40 @@ function buildStepContextSchema(manifest, defSchema, allManifests, defs, aliases
|
|
|
229
283
|
continue;
|
|
230
284
|
const stepItemSchema = resolveLocalRef(fieldSchema.items, defSchema);
|
|
231
285
|
const stepProperties = {};
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
286
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
|
|
287
|
+
const name = s.name;
|
|
288
|
+
const invoke = s[invokeField];
|
|
289
|
+
// Only invoke steps register a `steps.<name>.result` entry — control-flow
|
|
290
|
+
// wrappers (try/if/while/switch/throw) don't produce a result and must
|
|
291
|
+
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
292
|
+
// or unknown step references slip through chain validation.
|
|
293
|
+
if (typeof name !== "string" || !invoke || typeof invoke !== "object")
|
|
294
|
+
return;
|
|
295
|
+
let outputSchema;
|
|
296
|
+
const invokedKind = invoke.kind;
|
|
297
|
+
const invokedName = invoke.name;
|
|
298
|
+
if (invokedName) {
|
|
299
|
+
const invokedManifest = allManifests.find((m) => m.metadata?.name === invokedName &&
|
|
300
|
+
(!invokedKind || m.kind === invokedKind));
|
|
301
|
+
if (invokedManifest) {
|
|
302
|
+
outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
|
|
240
303
|
}
|
|
241
304
|
}
|
|
242
|
-
else
|
|
243
|
-
|
|
244
|
-
if (!entrySchema)
|
|
245
|
-
return;
|
|
246
|
-
for (const entry of data) {
|
|
247
|
-
if (!entry || typeof entry !== "object")
|
|
248
|
-
continue;
|
|
249
|
-
for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
|
|
250
|
-
const subRole = subSchema["x-telo-topology-role"];
|
|
251
|
-
if (typeof subRole !== "string")
|
|
252
|
-
continue;
|
|
253
|
-
dispatchRole(entry[subKey], subRole, subSchema.items);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
305
|
+
else {
|
|
306
|
+
outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
|
|
256
307
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
continue;
|
|
262
|
-
const s = step;
|
|
263
|
-
const name = s.name;
|
|
264
|
-
const invoke = s[invokeField];
|
|
265
|
-
// Only invoke steps register a `steps.<name>.result` entry — control-flow
|
|
266
|
-
// wrappers (try/if/while/switch/throw) don't produce a result and must
|
|
267
|
-
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
268
|
-
// or unknown step references slip through chain validation.
|
|
269
|
-
if (typeof name === "string" && invoke && typeof invoke === "object") {
|
|
270
|
-
let outputSchema;
|
|
271
|
-
const invokedKind = invoke.kind;
|
|
272
|
-
const invokedName = invoke.name;
|
|
273
|
-
if (invokedName) {
|
|
274
|
-
const invokedManifest = allManifests.find((m) => m.metadata?.name === invokedName &&
|
|
275
|
-
(!invokedKind || m.kind === invokedKind));
|
|
276
|
-
if (invokedManifest) {
|
|
277
|
-
outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
else {
|
|
281
|
-
outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
|
|
282
|
-
}
|
|
283
|
-
// Fallback: pull outputType from the kind's Telo.Definition. The
|
|
284
|
-
// resource manifest typically doesn't carry outputType; the def does.
|
|
285
|
-
if (!outputSchema && invokedKind) {
|
|
286
|
-
outputSchema = lookupDefinitionTypeField(invokedKind, outputTypeField, defs, aliases, allManifests);
|
|
287
|
-
}
|
|
288
|
-
stepProperties[name] = {
|
|
289
|
-
type: "object",
|
|
290
|
-
properties: {
|
|
291
|
-
result: outputSchema ?? { type: "object", additionalProperties: true },
|
|
292
|
-
},
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
|
-
if (stepItemSchema) {
|
|
296
|
-
for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
|
|
297
|
-
const role = propSchema["x-telo-topology-role"];
|
|
298
|
-
if (typeof role !== "string")
|
|
299
|
-
continue;
|
|
300
|
-
dispatchRole(s[propKey], role, propSchema.items);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
308
|
+
// Fallback: pull outputType from the kind's Telo.Definition. The
|
|
309
|
+
// resource manifest typically doesn't carry outputType; the def does.
|
|
310
|
+
if (!outputSchema && invokedKind) {
|
|
311
|
+
outputSchema = lookupDefinitionTypeField(invokedKind, outputTypeField, defs, aliases, allManifests);
|
|
303
312
|
}
|
|
304
|
-
|
|
305
|
-
|
|
313
|
+
stepProperties[name] = {
|
|
314
|
+
type: "object",
|
|
315
|
+
properties: {
|
|
316
|
+
result: outputSchema ?? { type: "object", additionalProperties: true },
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
});
|
|
306
320
|
if (Object.keys(stepProperties).length > 0) {
|
|
307
321
|
return {
|
|
308
322
|
type: "object",
|
|
@@ -312,6 +326,178 @@ function buildStepContextSchema(manifest, defSchema, allManifests, defs, aliases
|
|
|
312
326
|
}
|
|
313
327
|
return undefined;
|
|
314
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Capabilities whose instances structurally expose no `invoke`/`run` method, so
|
|
331
|
+
* a step `invoke` of one always fails at runtime with ERR_RESOURCE_NOT_INVOKABLE
|
|
332
|
+
* (kernel dispatch checks method presence, not capability — evaluation-context.ts).
|
|
333
|
+
* `Telo.Service` is intentionally absent: some services are invocable (a function
|
|
334
|
+
* handler dispatched directly, e.g. `Lambda.Function`), so it can't be rejected
|
|
335
|
+
* statically without false positives. This is the sound subset of the runtime rule.
|
|
336
|
+
*/
|
|
337
|
+
const NON_INVOKABLE_CAPABILITIES = new Set([
|
|
338
|
+
"Telo.Provider",
|
|
339
|
+
"Telo.Mount",
|
|
340
|
+
"Telo.Type",
|
|
341
|
+
"Telo.Template",
|
|
342
|
+
]);
|
|
343
|
+
/**
|
|
344
|
+
* Validate `x-telo-step-context` step `invoke` references (e.g. `Run.Sequence`
|
|
345
|
+
* steps).
|
|
346
|
+
*
|
|
347
|
+
* The reference field map deliberately does NOT descend into step `invoke`
|
|
348
|
+
* slots — they sit behind a local `$ref` to the shared step definition, and
|
|
349
|
+
* turning the descent on would make Phase 5 inject live instances there,
|
|
350
|
+
* breaking the invoke dispatch path (see `reference-field-map.ts`). A
|
|
351
|
+
* consequence is that `validateReferences` never sees these slots, so a bad
|
|
352
|
+
* step invoke passes `telo check` and only fails at runtime. This pass covers
|
|
353
|
+
* exactly those slots, in two dimensions:
|
|
354
|
+
* - Existence: an `invoke: !ref <name>` that names a missing instance — or a
|
|
355
|
+
* *kind* instead of an exported instance (`!ref Stream.Of`) — is a still-a-
|
|
356
|
+
* sentinel after Phase 2.5 resolution → `UNRESOLVED_REFERENCE` (runtime
|
|
357
|
+
* `ERR_RESOURCE_NOT_FOUND`).
|
|
358
|
+
* - Invokability: a resolved instance whose capability structurally has no
|
|
359
|
+
* invoke/run method (`NON_INVOKABLE_CAPABILITIES`) → `REFERENCE_KIND_MISMATCH`
|
|
360
|
+
* (runtime `ERR_RESOURCE_NOT_INVOKABLE`).
|
|
361
|
+
*
|
|
362
|
+
* Generic and topology-driven — it walks steps via the same `x-telo-step-context`
|
|
363
|
+
* / `x-telo-topology-role` annotations `buildStepContextSchema` uses (through the
|
|
364
|
+
* shared `walkStepArray`), so nested branches (then/else/do/catch/cases) are
|
|
365
|
+
* covered and no `Run.Sequence` field name is hardcoded. The cross-module
|
|
366
|
+
* partial-analysis guard mirrors `validateReferences`, so a reference into an
|
|
367
|
+
* unloaded import is skipped rather than false-flagged.
|
|
368
|
+
*/
|
|
369
|
+
function validateStepInvokeReferences(allManifests, defs, aliases) {
|
|
370
|
+
const diagnostics = [];
|
|
371
|
+
// Local instance names + loaded-module set — same construction as
|
|
372
|
+
// validateReferences, so the cross-module guard behaves identically.
|
|
373
|
+
const localNames = new Set();
|
|
374
|
+
const loadedModules = new Set();
|
|
375
|
+
// Also collect names of resources nested inside a manifest tree — notably
|
|
376
|
+
// `with:`-scoped resources (an `x-telo-scope` region the field map does not
|
|
377
|
+
// extract to a top-level manifest). A step can invoke one by bare name, so
|
|
378
|
+
// omitting them would false-flag a valid `!ref`. Conservative: any nested
|
|
379
|
+
// object carrying both a `kind` and a `metadata.name` is a resource
|
|
380
|
+
// definition; scope visibility is left to the runtime.
|
|
381
|
+
const collectNestedNames = (value) => {
|
|
382
|
+
if (!value || typeof value !== "object" || isTaggedSentinel(value))
|
|
383
|
+
return;
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
for (const item of value)
|
|
386
|
+
collectNestedNames(item);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const obj = value;
|
|
390
|
+
const name = obj.metadata?.name;
|
|
391
|
+
if (typeof obj.kind === "string" && typeof name === "string")
|
|
392
|
+
localNames.add(name);
|
|
393
|
+
for (const v of Object.values(obj))
|
|
394
|
+
collectNestedNames(v);
|
|
395
|
+
};
|
|
396
|
+
for (const r of allManifests) {
|
|
397
|
+
if (r.kind === "Telo.Import") {
|
|
398
|
+
const m = r.metadata?.resolvedModuleName;
|
|
399
|
+
if (typeof m === "string")
|
|
400
|
+
loadedModules.add(m);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
const meta = r.metadata;
|
|
404
|
+
if (typeof meta?.name !== "string" || REF_VALIDATION_SKIP_KINDS.has(r.kind))
|
|
405
|
+
continue;
|
|
406
|
+
if (meta.forwardedExport === true) {
|
|
407
|
+
if (typeof meta.module === "string")
|
|
408
|
+
loadedModules.add(meta.module);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
localNames.add(meta.name);
|
|
412
|
+
collectNestedNames(r);
|
|
413
|
+
}
|
|
414
|
+
const validateInvoke = (value, resource, filePath, path) => {
|
|
415
|
+
if (isRefSentinel(value)) {
|
|
416
|
+
// An unresolved `!ref` is a miss: a real instance would have resolved to
|
|
417
|
+
// `{kind, name}` in Phase 2.5.
|
|
418
|
+
const refName = value.source;
|
|
419
|
+
const dot = refName.indexOf(".");
|
|
420
|
+
const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
|
|
421
|
+
if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
|
|
422
|
+
const module = aliases.moduleForAlias(aliasPrefix);
|
|
423
|
+
// Partial single-file analysis (import not loaded) — skip to avoid a false miss.
|
|
424
|
+
if (module && !loadedModules.has(module))
|
|
425
|
+
return;
|
|
426
|
+
diagnostics.push({
|
|
427
|
+
severity: DiagnosticSeverity.Error,
|
|
428
|
+
code: "UNRESOLVED_REFERENCE",
|
|
429
|
+
source: SOURCE,
|
|
430
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${refName}' is not an exported instance of module '${module ?? aliasPrefix}' (reference a declared instance, not a kind)`,
|
|
431
|
+
data: { resource, filePath, path },
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
|
|
436
|
+
if (localNames.has(localName))
|
|
437
|
+
return;
|
|
438
|
+
diagnostics.push({
|
|
439
|
+
severity: DiagnosticSeverity.Error,
|
|
440
|
+
code: "UNRESOLVED_REFERENCE",
|
|
441
|
+
source: SOURCE,
|
|
442
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → resource '${localName}' not found`,
|
|
443
|
+
data: { resource, filePath, path },
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
// Resolved `{kind, name}` (or an inline `{kind, …}` definition) — the
|
|
448
|
+
// instance exists. Mirror the kernel's ERR_RESOURCE_NOT_INVOKABLE, which
|
|
449
|
+
// fires when the instance has neither an `invoke` nor a `run` method
|
|
450
|
+
// (evaluation-context.ts). That is a per-instance property, not a pure
|
|
451
|
+
// capability, so only capabilities that STRUCTURALLY expose no such method
|
|
452
|
+
// are rejected statically — Service is intentionally excluded, since some
|
|
453
|
+
// services are invocable (e.g. a function handler dispatched directly).
|
|
454
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
455
|
+
return;
|
|
456
|
+
const kind = value.kind;
|
|
457
|
+
if (typeof kind !== "string")
|
|
458
|
+
return;
|
|
459
|
+
const capability = defs.resolve(aliases.resolveKind(kind) ?? kind)?.capability;
|
|
460
|
+
if (typeof capability === "string" && NON_INVOKABLE_CAPABILITIES.has(capability)) {
|
|
461
|
+
diagnostics.push({
|
|
462
|
+
severity: DiagnosticSeverity.Error,
|
|
463
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
464
|
+
source: SOURCE,
|
|
465
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${kind}' is a ${capability} and cannot be invoked in a step — it has no invoke or run method (runtime ERR_RESOURCE_NOT_INVOKABLE)`,
|
|
466
|
+
data: { resource, filePath, path },
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
for (const m of allManifests) {
|
|
471
|
+
const meta = m.metadata;
|
|
472
|
+
if (typeof meta?.name !== "string" ||
|
|
473
|
+
REF_VALIDATION_SKIP_KINDS.has(m.kind) ||
|
|
474
|
+
meta.forwardedExport === true)
|
|
475
|
+
continue;
|
|
476
|
+
const def = defs.resolve(aliases.resolveKind(m.kind) ?? m.kind);
|
|
477
|
+
const defSchema = def?.schema;
|
|
478
|
+
if (!defSchema?.properties)
|
|
479
|
+
continue;
|
|
480
|
+
const resource = { kind: m.kind, name: meta.name };
|
|
481
|
+
const filePath = typeof meta.source === "string" ? meta.source : undefined;
|
|
482
|
+
for (const [fieldName, fieldSchema] of Object.entries(defSchema.properties)) {
|
|
483
|
+
const stepCtx = fieldSchema["x-telo-step-context"];
|
|
484
|
+
const invokeField = stepCtx?.invoke;
|
|
485
|
+
if (!invokeField)
|
|
486
|
+
continue;
|
|
487
|
+
const steps = m[fieldName];
|
|
488
|
+
if (!Array.isArray(steps))
|
|
489
|
+
continue;
|
|
490
|
+
const stepItemSchema = resolveLocalRef(fieldSchema.items, defSchema);
|
|
491
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s, stepPath) => {
|
|
492
|
+
const invoke = s[invokeField];
|
|
493
|
+
if (invoke === undefined || invoke === null)
|
|
494
|
+
return;
|
|
495
|
+
validateInvoke(invoke, resource, filePath, `${stepPath}.${invokeField}`);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return diagnostics;
|
|
500
|
+
}
|
|
315
501
|
/**
|
|
316
502
|
* Collect every field annotated with `x-telo-error-context` anywhere in a
|
|
317
503
|
* definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
|
|
@@ -1177,10 +1363,16 @@ export class StaticAnalyzer {
|
|
|
1177
1363
|
}, { aliases });
|
|
1178
1364
|
// Validate resource references (Phase 3)
|
|
1179
1365
|
diagnostics.push(...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }));
|
|
1366
|
+
// Validate step `invoke` references — the slots the reference field map
|
|
1367
|
+
// deliberately skips (behind the step `$ref`), so a missing instance or a
|
|
1368
|
+
// kind-instead-of-instance ref there is caught statically, not at runtime.
|
|
1369
|
+
diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
|
|
1180
1370
|
// Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
|
|
1181
1371
|
diagnostics.push(...validateExtends(allManifests, defs, aliases));
|
|
1182
1372
|
// Validate provider coherence rules for `provide:` template-target definitions.
|
|
1183
1373
|
diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
|
|
1374
|
+
// Warn about exported kinds lacking a metadata.description (semantic-search input).
|
|
1375
|
+
diagnostics.push(...validateKindDescriptions(allManifests));
|
|
1184
1376
|
// Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
|
|
1185
1377
|
diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv, aliasesByModule, rootModules));
|
|
1186
1378
|
// Warn about declared variables / secrets / ports that no CEL references.
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type { DocumentPosition } from "./position-metadata.js";
|
|
|
21
21
|
export { HttpSource } from "./sources/http-source.js";
|
|
22
22
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
23
23
|
export { defaultSources } from "./sources/default-sources.js";
|
|
24
|
-
export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, } from "./sources/integrity.js";
|
|
24
|
+
export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, IntegrityError, } from "./sources/integrity.js";
|
|
25
25
|
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
26
26
|
export type { ParsedModuleRef } from "./sources/module-ref.js";
|
|
27
27
|
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACH,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,gCAAgC,EAChC,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GACpB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACR,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EACL,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,eAAe,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EACR,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACX,YAAY,EACZ,UAAU,GACb,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACH,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,EACxB,gBAAgB,EAChB,wBAAwB,EACxB,oBAAoB,EACpB,gCAAgC,EAChC,oBAAoB,EACpB,KAAK,iBAAiB,EACtB,KAAK,YAAY,GACpB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,YAAY,EACR,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC/D,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC/E,YAAY,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AACzE,YAAY,EAAE,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AACnF,OAAO,EACH,sBAAsB,EACtB,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,GACtB,MAAM,wBAAwB,CAAC;AAChC,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,OAAO,EACL,cAAc,EACd,aAAa,EACb,eAAe,EACf,aAAa,EACb,eAAe,EACf,cAAc,GACf,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AACxE,YAAY,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC/D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,yBAAyB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAC3E,YAAY,EACR,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,QAAQ,EACR,aAAa,EACb,KAAK,EACR,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ export { buildDocumentPositions, buildLineOffsets, buildPositionIndex, documentL
|
|
|
13
13
|
export { HttpSource } from "./sources/http-source.js";
|
|
14
14
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
15
15
|
export { defaultSources } from "./sources/default-sources.js";
|
|
16
|
-
export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, } from "./sources/integrity.js";
|
|
16
|
+
export { splitIntegrity, foldIntegrity, verifyIntegrity, verifiedFetch, sha256Base64Url, IntegrityError, } from "./sources/integrity.js";
|
|
17
17
|
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
18
18
|
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
19
19
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* The fragment is authoritative across every transport: a source's `read()`
|
|
6
6
|
* hashes the fetched bytes and compares against it before the manifest is
|
|
7
7
|
* parsed or cached. A mismatch is a terminal error — never a cache miss. */
|
|
8
|
+
/** A failed integrity/tamper check — always terminal, never best-effort. A
|
|
9
|
+
* distinct type so a caller doing best-effort network handling (e.g. the
|
|
10
|
+
* bundle extractor warning-and-skipping on a fetch blip) can still let a
|
|
11
|
+
* tamper error propagate rather than swallow it. */
|
|
12
|
+
export declare class IntegrityError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
8
15
|
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
9
16
|
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
10
17
|
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integrity.d.ts","sourceRoot":"","sources":["../../src/sources/integrity.ts"],"names":[],"mappings":"AAAA;;;;;;6EAM6E;AAO7E;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAIhF;AAED;;;kFAGkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAIxE;AAcD,iFAAiF;AACjF,wBAAsB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAOxE;AAED;;gFAEgF;AAChF,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;sCAIsC;AACtC,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAU9C"}
|
|
1
|
+
{"version":3,"file":"integrity.d.ts","sourceRoot":"","sources":["../../src/sources/integrity.ts"],"names":[],"mappings":"AAAA;;;;;;6EAM6E;AAO7E;;;qDAGqD;AACrD,qBAAa,cAAe,SAAQ,KAAK;gBAC3B,OAAO,EAAE,MAAM;CAI5B;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAIhF;AAED;;;kFAGkF;AAClF,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAIxE;AAcD,iFAAiF;AACjF,wBAAsB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAOxE;AAED;;gFAEgF;AAChF,wBAAsB,eAAe,CACnC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CAkBf;AAED;;;;sCAIsC;AACtC,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,KAAK,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAU9C"}
|
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
* fragments (rare in module refs) pass through untouched. `sha256` is the
|
|
10
10
|
* only algorithm accepted today; the prefix leaves room to migrate. */
|
|
11
11
|
const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
|
|
12
|
+
/** A failed integrity/tamper check — always terminal, never best-effort. A
|
|
13
|
+
* distinct type so a caller doing best-effort network handling (e.g. the
|
|
14
|
+
* bundle extractor warning-and-skipping on a fetch blip) can still let a
|
|
15
|
+
* tamper error propagate rather than swallow it. */
|
|
16
|
+
export class IntegrityError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "IntegrityError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
12
22
|
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
13
23
|
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
14
24
|
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
@@ -55,12 +65,12 @@ export async function verifyIntegrity(bytes, integrity, describe) {
|
|
|
55
65
|
const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
|
|
56
66
|
const expected = dash > 0 ? integrity.slice(dash + 1) : "";
|
|
57
67
|
if (algorithm !== "sha256") {
|
|
58
|
-
throw new
|
|
68
|
+
throw new IntegrityError(`Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
|
|
59
69
|
`Only sha256 is supported (sha256-<base64url>).`);
|
|
60
70
|
}
|
|
61
71
|
const actual = await sha256Base64Url(bytes);
|
|
62
72
|
if (actual !== normalizeDigest(expected)) {
|
|
63
|
-
throw new
|
|
73
|
+
throw new IntegrityError(`Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
|
|
64
74
|
`got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
|
|
65
75
|
`the module may have been tampered with or republished.`);
|
|
66
76
|
}
|
|
@@ -7,8 +7,12 @@ export interface ParsedModuleRef {
|
|
|
7
7
|
integrity?: string;
|
|
8
8
|
}
|
|
9
9
|
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
10
|
-
* (no scheme, no leading `/` or `.`, contains both `@` and `/`).
|
|
11
|
-
* fragment, if any, does not affect the classification.
|
|
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. */
|
|
12
16
|
export declare function isRegistryRef(url: string): boolean;
|
|
13
17
|
/** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
|
|
14
18
|
* source of truth shared by the registry source, the kernel manifest cache,
|
|
@@ -1 +1 @@
|
|
|
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
|
|
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,15 +1,16 @@
|
|
|
1
1
|
import { splitIntegrity } from "./integrity.js";
|
|
2
2
|
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
3
|
-
* (no scheme, no leading `/` or `.`, contains both `@` and `/`).
|
|
4
|
-
* fragment, if any, does not affect the classification.
|
|
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. */
|
|
5
9
|
export function isRegistryRef(url) {
|
|
6
10
|
const { base } = splitIntegrity(url);
|
|
7
|
-
return (!base.
|
|
8
|
-
!base.startsWith("https://") &&
|
|
11
|
+
return (!base.includes("://") &&
|
|
9
12
|
!base.startsWith("/") &&
|
|
10
13
|
!base.startsWith(".") &&
|
|
11
|
-
!base.startsWith("file://") &&
|
|
12
|
-
!base.startsWith("memory://") &&
|
|
13
14
|
base.includes("@") &&
|
|
14
15
|
base.includes("/"));
|
|
15
16
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { type AnalysisDiagnostic } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Warns when a library exports a kind (via `exports.kinds`) whose local
|
|
5
|
+
* `Telo.Definition` carries no `metadata.description`.
|
|
6
|
+
*
|
|
7
|
+
* The description is the primary human text the federated-discovery hub embeds
|
|
8
|
+
* for semantic search (`search_resources`), so an exported kind without one is
|
|
9
|
+
* undiscoverable by meaning. A warning (not an error) so the stdlib backfill is
|
|
10
|
+
* incremental and CI isn't blocked mid-migration.
|
|
11
|
+
*
|
|
12
|
+
* Scope: only kinds a library *exports* and *defines locally* are checked.
|
|
13
|
+
* Re-exported kinds (`exports.kinds: [Alias.Kind]`) belong to their owning
|
|
14
|
+
* module and are skipped; an exported name that isn't a local Telo.Definition
|
|
15
|
+
* (e.g. a Telo.Abstract) is skipped too. The check keys off the root library's
|
|
16
|
+
* own module doc, which is only present when that library is analyzed directly
|
|
17
|
+
* — so importing an under-described library never leaks warnings to its consumer.
|
|
18
|
+
*/
|
|
19
|
+
export declare function validateKindDescriptions(manifests: ResourceManifest[]): AnalysisDiagnostic[];
|
|
20
|
+
//# sourceMappingURL=validate-kind-descriptions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-kind-descriptions.d.ts","sourceRoot":"","sources":["../src/validate-kind-descriptions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,kBAAkB,EAAE,CA2C5F"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
2
|
+
const SOURCE = "telo-analyzer";
|
|
3
|
+
/**
|
|
4
|
+
* Warns when a library exports a kind (via `exports.kinds`) whose local
|
|
5
|
+
* `Telo.Definition` carries no `metadata.description`.
|
|
6
|
+
*
|
|
7
|
+
* The description is the primary human text the federated-discovery hub embeds
|
|
8
|
+
* for semantic search (`search_resources`), so an exported kind without one is
|
|
9
|
+
* undiscoverable by meaning. A warning (not an error) so the stdlib backfill is
|
|
10
|
+
* incremental and CI isn't blocked mid-migration.
|
|
11
|
+
*
|
|
12
|
+
* Scope: only kinds a library *exports* and *defines locally* are checked.
|
|
13
|
+
* Re-exported kinds (`exports.kinds: [Alias.Kind]`) belong to their owning
|
|
14
|
+
* module and are skipped; an exported name that isn't a local Telo.Definition
|
|
15
|
+
* (e.g. a Telo.Abstract) is skipped too. The check keys off the root library's
|
|
16
|
+
* own module doc, which is only present when that library is analyzed directly
|
|
17
|
+
* — so importing an under-described library never leaks warnings to its consumer.
|
|
18
|
+
*/
|
|
19
|
+
export function validateKindDescriptions(manifests) {
|
|
20
|
+
const diagnostics = [];
|
|
21
|
+
// Local Telo.Definition docs keyed by `<module>/<name>`.
|
|
22
|
+
const definitions = new Map();
|
|
23
|
+
for (const m of manifests) {
|
|
24
|
+
if (m.kind !== "Telo.Definition")
|
|
25
|
+
continue;
|
|
26
|
+
const name = m.metadata?.name;
|
|
27
|
+
const mod = m.metadata?.module;
|
|
28
|
+
if (name && mod)
|
|
29
|
+
definitions.set(`${mod}/${name}`, m);
|
|
30
|
+
}
|
|
31
|
+
for (const m of manifests) {
|
|
32
|
+
if (m.kind !== "Telo.Library")
|
|
33
|
+
continue;
|
|
34
|
+
const moduleName = m.metadata?.name;
|
|
35
|
+
if (!moduleName)
|
|
36
|
+
continue;
|
|
37
|
+
const exportedKinds = m.exports?.kinds;
|
|
38
|
+
if (!Array.isArray(exportedKinds))
|
|
39
|
+
continue;
|
|
40
|
+
for (const entry of exportedKinds) {
|
|
41
|
+
// Re-export (`Alias.Kind`) — the owning module owns its description.
|
|
42
|
+
if (typeof entry !== "string" || entry.includes("."))
|
|
43
|
+
continue;
|
|
44
|
+
const def = definitions.get(`${moduleName}/${entry}`);
|
|
45
|
+
if (!def)
|
|
46
|
+
continue; // not a local Telo.Definition (e.g. an abstract)
|
|
47
|
+
const description = def.metadata?.description;
|
|
48
|
+
if (typeof description === "string" && description.trim() !== "")
|
|
49
|
+
continue;
|
|
50
|
+
diagnostics.push({
|
|
51
|
+
severity: DiagnosticSeverity.Warning,
|
|
52
|
+
code: "KIND_MISSING_DESCRIPTION",
|
|
53
|
+
source: SOURCE,
|
|
54
|
+
message: `${moduleName}.${entry}: exported kind has no 'metadata.description'. Add a one-line ` +
|
|
55
|
+
`description — it is the primary text indexed for semantic discovery (search_resources).`,
|
|
56
|
+
data: {
|
|
57
|
+
resource: { kind: "Telo.Definition", name: entry },
|
|
58
|
+
filePath: def.metadata?.source,
|
|
59
|
+
path: "metadata.description",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return diagnostics;
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"ajv-formats": "^3.0.1",
|
|
43
43
|
"jsonpath-plus": "^10.3.0",
|
|
44
44
|
"yaml": "^2.8.3",
|
|
45
|
-
"@telorun/templating": "0.10.
|
|
45
|
+
"@telorun/templating": "0.10.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^20.0.0",
|
package/src/analyzer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { canonicalTypeSchemaId } from "@telorun/sdk";
|
|
3
3
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
4
|
-
import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
4
|
+
import { defaultRegistry, isRefSentinel, isTaggedSentinel } from "@telorun/templating";
|
|
5
5
|
import { AliasResolver, scopeResolverForModule } from "./alias-resolver.js";
|
|
6
6
|
import { AnalysisRegistry } from "./analysis-registry.js";
|
|
7
7
|
import {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
import { buildEvalPaths, evalPathsCover } from "./eval-paths.js";
|
|
41
41
|
import { validateExtends } from "./validate-extends.js";
|
|
42
42
|
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
43
|
+
import { validateKindDescriptions } from "./validate-kind-descriptions.js";
|
|
43
44
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
44
45
|
import { validateReferences } from "./validate-references.js";
|
|
45
46
|
import { validateReferenceForms } from "./validate-reference-forms.js";
|
|
@@ -236,6 +237,73 @@ function gatherPropertySchemas(schema: Record<string, any>): Array<[string, Reco
|
|
|
236
237
|
return out;
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Generic, role-driven walk over an `x-telo-step-context` step array. Calls
|
|
242
|
+
* `visit(step, stepPath)` for every step — top-level and nested through the
|
|
243
|
+
* `x-telo-topology-role` forms (`branch`, `branch-list`, `case-map`). This is
|
|
244
|
+
* the single definition of how steps nest, shared by `buildStepContextSchema`
|
|
245
|
+
* (which types `steps.<name>.result`) and `validateStepInvokeReferences` (which
|
|
246
|
+
* checks invoke refs), so the topology contract lives in one place — adding a
|
|
247
|
+
* role or nesting form updates both consumers at once. No resource kind is
|
|
248
|
+
* hardcoded; recursion is driven entirely by the schema annotations.
|
|
249
|
+
*/
|
|
250
|
+
function walkStepArray(
|
|
251
|
+
steps: unknown[],
|
|
252
|
+
stepItemSchema: Record<string, any> | undefined,
|
|
253
|
+
rootSchema: Record<string, any>,
|
|
254
|
+
basePath: string,
|
|
255
|
+
visit: (step: Record<string, any>, stepPath: string) => void,
|
|
256
|
+
): void {
|
|
257
|
+
const dispatchRole = (
|
|
258
|
+
data: unknown,
|
|
259
|
+
role: string,
|
|
260
|
+
itemsSchema: Record<string, any> | undefined,
|
|
261
|
+
path: string,
|
|
262
|
+
): void => {
|
|
263
|
+
if (role === "branch" && Array.isArray(data)) {
|
|
264
|
+
walkStepArray(data, stepItemSchema, rootSchema, path, visit);
|
|
265
|
+
} else if (role === "case-map" && data && typeof data === "object" && !Array.isArray(data)) {
|
|
266
|
+
for (const [caseKey, arr] of Object.entries(data as Record<string, unknown>)) {
|
|
267
|
+
if (Array.isArray(arr)) walkStepArray(arr, stepItemSchema, rootSchema, `${path}.${caseKey}`, visit);
|
|
268
|
+
}
|
|
269
|
+
} else if (role === "branch-list" && Array.isArray(data)) {
|
|
270
|
+
const entrySchema = resolveLocalRef(itemsSchema, rootSchema);
|
|
271
|
+
if (!entrySchema) return;
|
|
272
|
+
data.forEach((entry, i) => {
|
|
273
|
+
if (!entry || typeof entry !== "object") return;
|
|
274
|
+
for (const [subKey, subSchema] of gatherPropertySchemas(entrySchema)) {
|
|
275
|
+
const subRole = subSchema["x-telo-topology-role"];
|
|
276
|
+
if (typeof subRole !== "string") continue;
|
|
277
|
+
dispatchRole(
|
|
278
|
+
(entry as Record<string, any>)[subKey],
|
|
279
|
+
subRole,
|
|
280
|
+
subSchema.items as Record<string, any> | undefined,
|
|
281
|
+
`${path}[${i}].${subKey}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
steps.forEach((step, i) => {
|
|
289
|
+
if (!step || typeof step !== "object") return;
|
|
290
|
+
const s = step as Record<string, any>;
|
|
291
|
+
const stepPath = `${basePath}[${i}]`;
|
|
292
|
+
visit(s, stepPath);
|
|
293
|
+
if (!stepItemSchema) return;
|
|
294
|
+
for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
|
|
295
|
+
const role = propSchema["x-telo-topology-role"];
|
|
296
|
+
if (typeof role !== "string") continue;
|
|
297
|
+
dispatchRole(
|
|
298
|
+
s[propKey],
|
|
299
|
+
role,
|
|
300
|
+
propSchema.items as Record<string, any> | undefined,
|
|
301
|
+
`${stepPath}.${propKey}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
239
307
|
/**
|
|
240
308
|
* Build a `steps` context schema from `x-telo-step-context` annotation.
|
|
241
309
|
* Walks each step in the manifest array, resolves the invoked resource's outputType,
|
|
@@ -290,90 +358,47 @@ function buildStepContextSchema(
|
|
|
290
358
|
|
|
291
359
|
const stepProperties: Record<string, any> = {};
|
|
292
360
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
dispatchRole(
|
|
313
|
-
(entry as Record<string, any>)[subKey],
|
|
314
|
-
subRole,
|
|
315
|
-
subSchema.items as Record<string, any> | undefined,
|
|
316
|
-
);
|
|
317
|
-
}
|
|
361
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s) => {
|
|
362
|
+
const name = s.name;
|
|
363
|
+
const invoke = s[invokeField] as Record<string, any> | undefined;
|
|
364
|
+
// Only invoke steps register a `steps.<name>.result` entry — control-flow
|
|
365
|
+
// wrappers (try/if/while/switch/throw) don't produce a result and must
|
|
366
|
+
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
367
|
+
// or unknown step references slip through chain validation.
|
|
368
|
+
if (typeof name !== "string" || !invoke || typeof invoke !== "object") return;
|
|
369
|
+
let outputSchema: Record<string, any> | undefined;
|
|
370
|
+
const invokedKind = invoke.kind as string | undefined;
|
|
371
|
+
const invokedName = invoke.name as string | undefined;
|
|
372
|
+
if (invokedName) {
|
|
373
|
+
const invokedManifest = allManifests.find(
|
|
374
|
+
(m) =>
|
|
375
|
+
(m.metadata as any)?.name === invokedName &&
|
|
376
|
+
(!invokedKind || m.kind === invokedKind),
|
|
377
|
+
) as Record<string, any> | undefined;
|
|
378
|
+
if (invokedManifest) {
|
|
379
|
+
outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
|
|
318
380
|
}
|
|
381
|
+
} else {
|
|
382
|
+
outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
|
|
319
383
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
// not shadow real entries with a permissive `additionalProperties: true`,
|
|
331
|
-
// or unknown step references slip through chain validation.
|
|
332
|
-
if (typeof name === "string" && invoke && typeof invoke === "object") {
|
|
333
|
-
let outputSchema: Record<string, any> | undefined;
|
|
334
|
-
const invokedKind = invoke.kind as string | undefined;
|
|
335
|
-
const invokedName = invoke.name as string | undefined;
|
|
336
|
-
if (invokedName) {
|
|
337
|
-
const invokedManifest = allManifests.find(
|
|
338
|
-
(m) =>
|
|
339
|
-
(m.metadata as any)?.name === invokedName &&
|
|
340
|
-
(!invokedKind || m.kind === invokedKind),
|
|
341
|
-
) as Record<string, any> | undefined;
|
|
342
|
-
if (invokedManifest) {
|
|
343
|
-
outputSchema = resolveTypeFieldToSchema(invokedManifest[outputTypeField], allManifests);
|
|
344
|
-
}
|
|
345
|
-
} else {
|
|
346
|
-
outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
|
|
347
|
-
}
|
|
348
|
-
// Fallback: pull outputType from the kind's Telo.Definition. The
|
|
349
|
-
// resource manifest typically doesn't carry outputType; the def does.
|
|
350
|
-
if (!outputSchema && invokedKind) {
|
|
351
|
-
outputSchema = lookupDefinitionTypeField(
|
|
352
|
-
invokedKind,
|
|
353
|
-
outputTypeField,
|
|
354
|
-
defs,
|
|
355
|
-
aliases,
|
|
356
|
-
allManifests,
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
stepProperties[name] = {
|
|
360
|
-
type: "object",
|
|
361
|
-
properties: {
|
|
362
|
-
result: outputSchema ?? { type: "object", additionalProperties: true },
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
if (stepItemSchema) {
|
|
367
|
-
for (const [propKey, propSchema] of gatherPropertySchemas(stepItemSchema)) {
|
|
368
|
-
const role = propSchema["x-telo-topology-role"];
|
|
369
|
-
if (typeof role !== "string") continue;
|
|
370
|
-
dispatchRole(s[propKey], role, propSchema.items as Record<string, any> | undefined);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
384
|
+
// Fallback: pull outputType from the kind's Telo.Definition. The
|
|
385
|
+
// resource manifest typically doesn't carry outputType; the def does.
|
|
386
|
+
if (!outputSchema && invokedKind) {
|
|
387
|
+
outputSchema = lookupDefinitionTypeField(
|
|
388
|
+
invokedKind,
|
|
389
|
+
outputTypeField,
|
|
390
|
+
defs,
|
|
391
|
+
aliases,
|
|
392
|
+
allManifests,
|
|
393
|
+
);
|
|
373
394
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
395
|
+
stepProperties[name] = {
|
|
396
|
+
type: "object",
|
|
397
|
+
properties: {
|
|
398
|
+
result: outputSchema ?? { type: "object", additionalProperties: true },
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
});
|
|
377
402
|
|
|
378
403
|
if (Object.keys(stepProperties).length > 0) {
|
|
379
404
|
return {
|
|
@@ -386,6 +411,192 @@ function buildStepContextSchema(
|
|
|
386
411
|
return undefined;
|
|
387
412
|
}
|
|
388
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Capabilities whose instances structurally expose no `invoke`/`run` method, so
|
|
416
|
+
* a step `invoke` of one always fails at runtime with ERR_RESOURCE_NOT_INVOKABLE
|
|
417
|
+
* (kernel dispatch checks method presence, not capability — evaluation-context.ts).
|
|
418
|
+
* `Telo.Service` is intentionally absent: some services are invocable (a function
|
|
419
|
+
* handler dispatched directly, e.g. `Lambda.Function`), so it can't be rejected
|
|
420
|
+
* statically without false positives. This is the sound subset of the runtime rule.
|
|
421
|
+
*/
|
|
422
|
+
const NON_INVOKABLE_CAPABILITIES = new Set([
|
|
423
|
+
"Telo.Provider",
|
|
424
|
+
"Telo.Mount",
|
|
425
|
+
"Telo.Type",
|
|
426
|
+
"Telo.Template",
|
|
427
|
+
]);
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Validate `x-telo-step-context` step `invoke` references (e.g. `Run.Sequence`
|
|
431
|
+
* steps).
|
|
432
|
+
*
|
|
433
|
+
* The reference field map deliberately does NOT descend into step `invoke`
|
|
434
|
+
* slots — they sit behind a local `$ref` to the shared step definition, and
|
|
435
|
+
* turning the descent on would make Phase 5 inject live instances there,
|
|
436
|
+
* breaking the invoke dispatch path (see `reference-field-map.ts`). A
|
|
437
|
+
* consequence is that `validateReferences` never sees these slots, so a bad
|
|
438
|
+
* step invoke passes `telo check` and only fails at runtime. This pass covers
|
|
439
|
+
* exactly those slots, in two dimensions:
|
|
440
|
+
* - Existence: an `invoke: !ref <name>` that names a missing instance — or a
|
|
441
|
+
* *kind* instead of an exported instance (`!ref Stream.Of`) — is a still-a-
|
|
442
|
+
* sentinel after Phase 2.5 resolution → `UNRESOLVED_REFERENCE` (runtime
|
|
443
|
+
* `ERR_RESOURCE_NOT_FOUND`).
|
|
444
|
+
* - Invokability: a resolved instance whose capability structurally has no
|
|
445
|
+
* invoke/run method (`NON_INVOKABLE_CAPABILITIES`) → `REFERENCE_KIND_MISMATCH`
|
|
446
|
+
* (runtime `ERR_RESOURCE_NOT_INVOKABLE`).
|
|
447
|
+
*
|
|
448
|
+
* Generic and topology-driven — it walks steps via the same `x-telo-step-context`
|
|
449
|
+
* / `x-telo-topology-role` annotations `buildStepContextSchema` uses (through the
|
|
450
|
+
* shared `walkStepArray`), so nested branches (then/else/do/catch/cases) are
|
|
451
|
+
* covered and no `Run.Sequence` field name is hardcoded. The cross-module
|
|
452
|
+
* partial-analysis guard mirrors `validateReferences`, so a reference into an
|
|
453
|
+
* unloaded import is skipped rather than false-flagged.
|
|
454
|
+
*/
|
|
455
|
+
function validateStepInvokeReferences(
|
|
456
|
+
allManifests: Record<string, any>[],
|
|
457
|
+
defs: DefinitionRegistry,
|
|
458
|
+
aliases: AliasResolver,
|
|
459
|
+
): AnalysisDiagnostic[] {
|
|
460
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
461
|
+
|
|
462
|
+
// Local instance names + loaded-module set — same construction as
|
|
463
|
+
// validateReferences, so the cross-module guard behaves identically.
|
|
464
|
+
const localNames = new Set<string>();
|
|
465
|
+
const loadedModules = new Set<string>();
|
|
466
|
+
|
|
467
|
+
// Also collect names of resources nested inside a manifest tree — notably
|
|
468
|
+
// `with:`-scoped resources (an `x-telo-scope` region the field map does not
|
|
469
|
+
// extract to a top-level manifest). A step can invoke one by bare name, so
|
|
470
|
+
// omitting them would false-flag a valid `!ref`. Conservative: any nested
|
|
471
|
+
// object carrying both a `kind` and a `metadata.name` is a resource
|
|
472
|
+
// definition; scope visibility is left to the runtime.
|
|
473
|
+
const collectNestedNames = (value: unknown): void => {
|
|
474
|
+
if (!value || typeof value !== "object" || isTaggedSentinel(value)) return;
|
|
475
|
+
if (Array.isArray(value)) {
|
|
476
|
+
for (const item of value) collectNestedNames(item);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const obj = value as Record<string, unknown>;
|
|
480
|
+
const name = (obj.metadata as { name?: unknown } | undefined)?.name;
|
|
481
|
+
if (typeof obj.kind === "string" && typeof name === "string") localNames.add(name);
|
|
482
|
+
for (const v of Object.values(obj)) collectNestedNames(v);
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
for (const r of allManifests) {
|
|
486
|
+
if (r.kind === "Telo.Import") {
|
|
487
|
+
const m = (r.metadata as { resolvedModuleName?: unknown } | undefined)?.resolvedModuleName;
|
|
488
|
+
if (typeof m === "string") loadedModules.add(m);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const meta = r.metadata as { name?: unknown; module?: unknown; forwardedExport?: unknown };
|
|
492
|
+
if (typeof meta?.name !== "string" || REF_VALIDATION_SKIP_KINDS.has(r.kind)) continue;
|
|
493
|
+
if (meta.forwardedExport === true) {
|
|
494
|
+
if (typeof meta.module === "string") loadedModules.add(meta.module);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
localNames.add(meta.name);
|
|
498
|
+
collectNestedNames(r);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const validateInvoke = (
|
|
502
|
+
value: unknown,
|
|
503
|
+
resource: { kind: string; name: string },
|
|
504
|
+
filePath: string | undefined,
|
|
505
|
+
path: string,
|
|
506
|
+
): void => {
|
|
507
|
+
if (isRefSentinel(value)) {
|
|
508
|
+
// An unresolved `!ref` is a miss: a real instance would have resolved to
|
|
509
|
+
// `{kind, name}` in Phase 2.5.
|
|
510
|
+
const refName = value.source;
|
|
511
|
+
const dot = refName.indexOf(".");
|
|
512
|
+
const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
|
|
513
|
+
|
|
514
|
+
if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
|
|
515
|
+
const module = aliases.moduleForAlias(aliasPrefix);
|
|
516
|
+
// Partial single-file analysis (import not loaded) — skip to avoid a false miss.
|
|
517
|
+
if (module && !loadedModules.has(module)) return;
|
|
518
|
+
diagnostics.push({
|
|
519
|
+
severity: DiagnosticSeverity.Error,
|
|
520
|
+
code: "UNRESOLVED_REFERENCE",
|
|
521
|
+
source: SOURCE,
|
|
522
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${refName}' is not an exported instance of module '${module ?? aliasPrefix}' (reference a declared instance, not a kind)`,
|
|
523
|
+
data: { resource, filePath, path },
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
|
|
529
|
+
if (localNames.has(localName)) return;
|
|
530
|
+
diagnostics.push({
|
|
531
|
+
severity: DiagnosticSeverity.Error,
|
|
532
|
+
code: "UNRESOLVED_REFERENCE",
|
|
533
|
+
source: SOURCE,
|
|
534
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → resource '${localName}' not found`,
|
|
535
|
+
data: { resource, filePath, path },
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Resolved `{kind, name}` (or an inline `{kind, …}` definition) — the
|
|
541
|
+
// instance exists. Mirror the kernel's ERR_RESOURCE_NOT_INVOKABLE, which
|
|
542
|
+
// fires when the instance has neither an `invoke` nor a `run` method
|
|
543
|
+
// (evaluation-context.ts). That is a per-instance property, not a pure
|
|
544
|
+
// capability, so only capabilities that STRUCTURALLY expose no such method
|
|
545
|
+
// are rejected statically — Service is intentionally excluded, since some
|
|
546
|
+
// services are invocable (e.g. a function handler dispatched directly).
|
|
547
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
|
548
|
+
const kind = (value as Record<string, unknown>).kind;
|
|
549
|
+
if (typeof kind !== "string") return;
|
|
550
|
+
const capability = defs.resolve(aliases.resolveKind(kind) ?? kind)?.capability;
|
|
551
|
+
if (typeof capability === "string" && NON_INVOKABLE_CAPABILITIES.has(capability)) {
|
|
552
|
+
diagnostics.push({
|
|
553
|
+
severity: DiagnosticSeverity.Error,
|
|
554
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
555
|
+
source: SOURCE,
|
|
556
|
+
message: `${resource.kind}/${resource.name}: step invoke at '${path}' → '${kind}' is a ${capability} and cannot be invoked in a step — it has no invoke or run method (runtime ERR_RESOURCE_NOT_INVOKABLE)`,
|
|
557
|
+
data: { resource, filePath, path },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
for (const m of allManifests) {
|
|
563
|
+
const meta = m.metadata as { name?: unknown; source?: unknown; forwardedExport?: unknown };
|
|
564
|
+
if (
|
|
565
|
+
typeof meta?.name !== "string" ||
|
|
566
|
+
REF_VALIDATION_SKIP_KINDS.has(m.kind) ||
|
|
567
|
+
meta.forwardedExport === true
|
|
568
|
+
)
|
|
569
|
+
continue;
|
|
570
|
+
const def = defs.resolve(aliases.resolveKind(m.kind) ?? m.kind);
|
|
571
|
+
const defSchema = def?.schema as Record<string, any> | undefined;
|
|
572
|
+
if (!defSchema?.properties) continue;
|
|
573
|
+
const resource = { kind: m.kind, name: meta.name };
|
|
574
|
+
const filePath = typeof meta.source === "string" ? meta.source : undefined;
|
|
575
|
+
|
|
576
|
+
for (const [fieldName, fieldSchema] of Object.entries(
|
|
577
|
+
defSchema.properties as Record<string, any>,
|
|
578
|
+
)) {
|
|
579
|
+
const stepCtx = fieldSchema["x-telo-step-context"] as Record<string, string> | undefined;
|
|
580
|
+
const invokeField = stepCtx?.invoke;
|
|
581
|
+
if (!invokeField) continue;
|
|
582
|
+
const steps = m[fieldName];
|
|
583
|
+
if (!Array.isArray(steps)) continue;
|
|
584
|
+
const stepItemSchema = resolveLocalRef(
|
|
585
|
+
fieldSchema.items as Record<string, any> | undefined,
|
|
586
|
+
defSchema,
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
walkStepArray(steps, stepItemSchema, defSchema, fieldName, (s, stepPath) => {
|
|
590
|
+
const invoke = s[invokeField];
|
|
591
|
+
if (invoke === undefined || invoke === null) return;
|
|
592
|
+
validateInvoke(invoke, resource, filePath, `${stepPath}.${invokeField}`);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return diagnostics;
|
|
598
|
+
}
|
|
599
|
+
|
|
389
600
|
/**
|
|
390
601
|
* Collect every field annotated with `x-telo-error-context` anywhere in a
|
|
391
602
|
* definition schema (resolving local `$ref`s into `$defs`, cycle-safe), mapping
|
|
@@ -1409,12 +1620,20 @@ export class StaticAnalyzer {
|
|
|
1409
1620
|
...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
|
|
1410
1621
|
);
|
|
1411
1622
|
|
|
1623
|
+
// Validate step `invoke` references — the slots the reference field map
|
|
1624
|
+
// deliberately skips (behind the step `$ref`), so a missing instance or a
|
|
1625
|
+
// kind-instead-of-instance ref there is caught statically, not at runtime.
|
|
1626
|
+
diagnostics.push(...validateStepInvokeReferences(allManifests, defs, aliases));
|
|
1627
|
+
|
|
1412
1628
|
// Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
|
|
1413
1629
|
diagnostics.push(...validateExtends(allManifests, defs, aliases));
|
|
1414
1630
|
|
|
1415
1631
|
// Validate provider coherence rules for `provide:` template-target definitions.
|
|
1416
1632
|
diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
|
|
1417
1633
|
|
|
1634
|
+
// Warn about exported kinds lacking a metadata.description (semantic-search input).
|
|
1635
|
+
diagnostics.push(...validateKindDescriptions(allManifests));
|
|
1636
|
+
|
|
1418
1637
|
// Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
|
|
1419
1638
|
diagnostics.push(
|
|
1420
1639
|
...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv, aliasesByModule, rootModules),
|
package/src/index.ts
CHANGED
|
@@ -59,6 +59,7 @@ export {
|
|
|
59
59
|
verifyIntegrity,
|
|
60
60
|
verifiedFetch,
|
|
61
61
|
sha256Base64Url,
|
|
62
|
+
IntegrityError,
|
|
62
63
|
} from "./sources/integrity.js";
|
|
63
64
|
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
64
65
|
export type { ParsedModuleRef } from "./sources/module-ref.js";
|
package/src/sources/integrity.ts
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
* only algorithm accepted today; the prefix leaves room to migrate. */
|
|
12
12
|
const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
|
|
13
13
|
|
|
14
|
+
/** A failed integrity/tamper check — always terminal, never best-effort. A
|
|
15
|
+
* distinct type so a caller doing best-effort network handling (e.g. the
|
|
16
|
+
* bundle extractor warning-and-skipping on a fetch blip) can still let a
|
|
17
|
+
* tamper error propagate rather than swallow it. */
|
|
18
|
+
export class IntegrityError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "IntegrityError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
15
26
|
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
16
27
|
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
@@ -64,14 +75,14 @@ export async function verifyIntegrity(
|
|
|
64
75
|
const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
|
|
65
76
|
const expected = dash > 0 ? integrity.slice(dash + 1) : "";
|
|
66
77
|
if (algorithm !== "sha256") {
|
|
67
|
-
throw new
|
|
78
|
+
throw new IntegrityError(
|
|
68
79
|
`Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
|
|
69
80
|
`Only sha256 is supported (sha256-<base64url>).`,
|
|
70
81
|
);
|
|
71
82
|
}
|
|
72
83
|
const actual = await sha256Base64Url(bytes);
|
|
73
84
|
if (actual !== normalizeDigest(expected)) {
|
|
74
|
-
throw new
|
|
85
|
+
throw new IntegrityError(
|
|
75
86
|
`Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
|
|
76
87
|
`got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
|
|
77
88
|
`the module may have been tampered with or republished.`,
|
|
@@ -10,17 +10,18 @@ export interface ParsedModuleRef {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
13
|
-
* (no scheme, no leading `/` or `.`, contains both `@` and `/`).
|
|
14
|
-
* fragment, if any, does not affect the classification.
|
|
13
|
+
* (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
|
|
14
|
+
* The integrity fragment, if any, does not affect the classification.
|
|
15
|
+
*
|
|
16
|
+
* A registry ref never carries a `scheme://` — that guard is what keeps an
|
|
17
|
+
* `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
|
|
18
|
+
* scheme-owning transport claims it instead. */
|
|
15
19
|
export function isRegistryRef(url: string): boolean {
|
|
16
20
|
const { base } = splitIntegrity(url);
|
|
17
21
|
return (
|
|
18
|
-
!base.
|
|
19
|
-
!base.startsWith("https://") &&
|
|
22
|
+
!base.includes("://") &&
|
|
20
23
|
!base.startsWith("/") &&
|
|
21
24
|
!base.startsWith(".") &&
|
|
22
|
-
!base.startsWith("file://") &&
|
|
23
|
-
!base.startsWith("memory://") &&
|
|
24
25
|
base.includes("@") &&
|
|
25
26
|
base.includes("/")
|
|
26
27
|
);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const SOURCE = "telo-analyzer";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Warns when a library exports a kind (via `exports.kinds`) whose local
|
|
8
|
+
* `Telo.Definition` carries no `metadata.description`.
|
|
9
|
+
*
|
|
10
|
+
* The description is the primary human text the federated-discovery hub embeds
|
|
11
|
+
* for semantic search (`search_resources`), so an exported kind without one is
|
|
12
|
+
* undiscoverable by meaning. A warning (not an error) so the stdlib backfill is
|
|
13
|
+
* incremental and CI isn't blocked mid-migration.
|
|
14
|
+
*
|
|
15
|
+
* Scope: only kinds a library *exports* and *defines locally* are checked.
|
|
16
|
+
* Re-exported kinds (`exports.kinds: [Alias.Kind]`) belong to their owning
|
|
17
|
+
* module and are skipped; an exported name that isn't a local Telo.Definition
|
|
18
|
+
* (e.g. a Telo.Abstract) is skipped too. The check keys off the root library's
|
|
19
|
+
* own module doc, which is only present when that library is analyzed directly
|
|
20
|
+
* — so importing an under-described library never leaks warnings to its consumer.
|
|
21
|
+
*/
|
|
22
|
+
export function validateKindDescriptions(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
|
|
23
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
24
|
+
|
|
25
|
+
// Local Telo.Definition docs keyed by `<module>/<name>`.
|
|
26
|
+
const definitions = new Map<string, ResourceManifest>();
|
|
27
|
+
for (const m of manifests) {
|
|
28
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
29
|
+
const name = m.metadata?.name as string | undefined;
|
|
30
|
+
const mod = (m.metadata as { module?: string } | undefined)?.module;
|
|
31
|
+
if (name && mod) definitions.set(`${mod}/${name}`, m);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const m of manifests) {
|
|
35
|
+
if (m.kind !== "Telo.Library") continue;
|
|
36
|
+
const moduleName = m.metadata?.name as string | undefined;
|
|
37
|
+
if (!moduleName) continue;
|
|
38
|
+
const exportedKinds = (m as { exports?: { kinds?: unknown } }).exports?.kinds;
|
|
39
|
+
if (!Array.isArray(exportedKinds)) continue;
|
|
40
|
+
|
|
41
|
+
for (const entry of exportedKinds) {
|
|
42
|
+
// Re-export (`Alias.Kind`) — the owning module owns its description.
|
|
43
|
+
if (typeof entry !== "string" || entry.includes(".")) continue;
|
|
44
|
+
const def = definitions.get(`${moduleName}/${entry}`);
|
|
45
|
+
if (!def) continue; // not a local Telo.Definition (e.g. an abstract)
|
|
46
|
+
const description = (def.metadata as { description?: unknown } | undefined)?.description;
|
|
47
|
+
if (typeof description === "string" && description.trim() !== "") continue;
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
severity: DiagnosticSeverity.Warning,
|
|
50
|
+
code: "KIND_MISSING_DESCRIPTION",
|
|
51
|
+
source: SOURCE,
|
|
52
|
+
message:
|
|
53
|
+
`${moduleName}.${entry}: exported kind has no 'metadata.description'. Add a one-line ` +
|
|
54
|
+
`description — it is the primary text indexed for semantic discovery (search_resources).`,
|
|
55
|
+
data: {
|
|
56
|
+
resource: { kind: "Telo.Definition", name: entry },
|
|
57
|
+
filePath: (def.metadata as { source?: string } | undefined)?.source,
|
|
58
|
+
path: "metadata.description",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return diagnostics;
|
|
65
|
+
}
|