@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-sensitive-slots.d.ts","sourceRoot":"","sources":["../src/validate-sensitive-slots.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,gCAAgC,GAAG,8BAA8B,CAAC;IACxE,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAOD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,kBAAkB,EAAE,CAqDvF"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SCHEMA_REGION_KEYS } from "./schema-region.js";
|
|
2
|
+
const ANNOTATION = "x-telo-sensitive";
|
|
3
|
+
/** The only regions the kernel reads the annotation from. */
|
|
4
|
+
const CONTRACT_KEYS = new Set(["inputType", "outputType"]);
|
|
5
|
+
export function validateSensitiveSlots(manifest) {
|
|
6
|
+
const issues = [];
|
|
7
|
+
const walk = (node, path, seen) => {
|
|
8
|
+
if (!node || typeof node !== "object")
|
|
9
|
+
return;
|
|
10
|
+
if (seen.has(node))
|
|
11
|
+
return;
|
|
12
|
+
seen.add(node);
|
|
13
|
+
if (Array.isArray(node)) {
|
|
14
|
+
node.forEach((child, i) => walk(child, [...path, i], seen));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const record = node;
|
|
18
|
+
if (Object.hasOwn(record, ANNOTATION)) {
|
|
19
|
+
const dotted = path.join(".");
|
|
20
|
+
if (record[ANNOTATION] !== true) {
|
|
21
|
+
issues.push({
|
|
22
|
+
code: "SENSITIVE_ANNOTATION_INVALID",
|
|
23
|
+
manifest,
|
|
24
|
+
path: dotted,
|
|
25
|
+
message: `'${ANNOTATION}' must be \`true\`; got ${JSON.stringify(record[ANNOTATION])}. ` +
|
|
26
|
+
`It is a marker, not a level — a value other than \`true\` reads as "not sensitive".`,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const region = enclosingSchemaRegion(path);
|
|
31
|
+
if (region === undefined || !CONTRACT_KEYS.has(region)) {
|
|
32
|
+
issues.push({
|
|
33
|
+
code: "SENSITIVE_ANNOTATION_MISPLACED",
|
|
34
|
+
manifest,
|
|
35
|
+
path: dotted,
|
|
36
|
+
message: `'${ANNOTATION}' is only read from a resource's declared contract ` +
|
|
37
|
+
`(\`inputType\` / \`outputType\`), and this node is ` +
|
|
38
|
+
(region === undefined
|
|
39
|
+
? "not inside a schema at all"
|
|
40
|
+
: `inside \`${region}\``) +
|
|
41
|
+
`. The kernel will not redact it, so the value would still reach trace ` +
|
|
42
|
+
`payloads and the debug wire. Move the mark onto the contract property ` +
|
|
43
|
+
`that carries the value.`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const [key, child] of Object.entries(record)) {
|
|
49
|
+
walk(child, [...path, key], seen);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(manifest, [], new Set());
|
|
53
|
+
return issues;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The OUTERMOST segment naming a schema-valued key, or `undefined` when the node
|
|
57
|
+
* is not inside a schema.
|
|
58
|
+
*
|
|
59
|
+
* Outermost, not nearest, because a contract is routinely written in the inline
|
|
60
|
+
* `{kind: Telo.JsonSchema, schema: …}` form — so the path to a marked property is
|
|
61
|
+
* `outputType.schema.properties.headers`, and the nearest region key is that
|
|
62
|
+
* wrapper's own `schema`. Reading it as a kind's configuration would report every
|
|
63
|
+
* correctly-marked contract as misplaced, which is how this check first behaved.
|
|
64
|
+
*
|
|
65
|
+
* It is the same reasoning `expandManifestFragments` uses when it keys on the
|
|
66
|
+
* top-level slot: the object a contract resolver is handed is the one under
|
|
67
|
+
* `inputType` / `outputType`, whatever nesting the authoring form adds beneath.
|
|
68
|
+
*/
|
|
69
|
+
function enclosingSchemaRegion(path) {
|
|
70
|
+
for (const segment of path) {
|
|
71
|
+
if (typeof segment === "string" && SCHEMA_REGION_KEYS.includes(segment))
|
|
72
|
+
return segment;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
@@ -28,7 +28,7 @@ import type { KeywordDefinition } from "ajv";
|
|
|
28
28
|
*
|
|
29
29
|
* `x-telo-type` is deliberately absent — it is the one that emits code.
|
|
30
30
|
*/
|
|
31
|
-
export declare const ANNOTATION_KEYWORDS: readonly ["x-telo-bindings-from", "x-telo-catches-for", "x-telo-context", "x-telo-context-collection-from", "x-telo-context-element-from", "x-telo-context-from", "x-telo-context-from-ref-kind", "x-telo-context-from-root", "x-telo-context-ref-from", "x-telo-error-context", "x-telo-eval", "x-telo-inline", "x-telo-outcome-list", "x-telo-provides-zone", "x-telo-ref", "x-telo-requires-zone", "x-telo-resource-rules", "x-telo-schema-from", "x-telo-schema-map", "x-telo-schema-projection", "x-telo-schema-projection-from", "x-telo-scope", "x-telo-step-context", "x-telo-topology-role", "x-telo-value-schema-from", "x-telo-widget"];
|
|
31
|
+
export declare const ANNOTATION_KEYWORDS: readonly ["x-telo-bindings-from", "x-telo-catches-for", "x-telo-context", "x-telo-context-collection-from", "x-telo-context-element-from", "x-telo-context-from", "x-telo-context-from-ref-kind", "x-telo-context-from-root", "x-telo-context-ref-from", "x-telo-error-context", "x-telo-eval", "x-telo-inline", "x-telo-outcome-list", "x-telo-provides-zone", "x-telo-ref", "x-telo-requires-zone", "x-telo-resource-rules", "x-telo-schema-from", "x-telo-schema-map", "x-telo-schema-projection", "x-telo-schema-projection-from", "x-telo-scope", "x-telo-sensitive", "x-telo-step-context", "x-telo-topology-role", "x-telo-value-schema-from", "x-telo-widget"];
|
|
32
32
|
/**
|
|
33
33
|
* The `x-telo-type` keyword.
|
|
34
34
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"value-type-keyword.d.ts","sourceRoot":"","sources":["../src/value-type-keyword.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;+EAqB+E;AAG/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,KAAK,CAAC;AAiB7C;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,
|
|
1
|
+
{"version":3,"file":"value-type-keyword.d.ts","sourceRoot":"","sources":["../src/value-type-keyword.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;+EAqB+E;AAG/E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,KAAK,CAAC;AAiB7C;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,woBA4BtB,CAAC;AAEX;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,IAAI,iBAAiB,CA+BpD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE;IACxC,UAAU,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,UAAU,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;CACzD,GAAG,IAAI,CAGP"}
|
package/package.json
CHANGED
package/src/analysis-registry.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import { KERNEL_BUILTINS } from "./builtins.js";
|
|
4
|
+
import type { BuildModuleGraphOptions, ModuleGraphDeps } from "./module-graph.js";
|
|
4
5
|
import { ManifestAnalysis } from "./manifest-analysis.js";
|
|
5
6
|
import { DefinitionRegistry } from "./definition-registry.js";
|
|
6
7
|
import { computeSuggestKind, computeValidUserFacingKinds } from "./kind-suggest.js";
|
|
7
8
|
import { visitManifest as runVisitManifest, type ManifestVisitor } from "./manifest-visitor.js";
|
|
8
9
|
import type { ContractDirection, DefResolver } from "./extends-resolution.js";
|
|
9
10
|
import { resolveContract } from "./invocation-contract.js";
|
|
11
|
+
import { createResolveCtx, resolveThrowsUnion } from "./resolve-throws-union.js";
|
|
10
12
|
import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
|
|
11
13
|
import { resolveSchemaTypeRefs as resolveSchemaTypeRefsIn } from "./resolve-schema-type-refs.js";
|
|
12
14
|
import type { AnalysisContext } from "./types.js";
|
|
@@ -279,6 +281,41 @@ export class AnalysisRegistry {
|
|
|
279
281
|
});
|
|
280
282
|
}
|
|
281
283
|
|
|
284
|
+
/**
|
|
285
|
+
* What the projection needs that only this registry knows: how a kind
|
|
286
|
+
* resolves, what its reference slots are, which capabilities they target, and
|
|
287
|
+
* what a resource can raise.
|
|
288
|
+
*
|
|
289
|
+
* The GRAPH itself is built by `ManifestAnalysis.moduleGraph`, which is the
|
|
290
|
+
* pairing of a registry with a manifest set — a fifth
|
|
291
|
+
* `registry.x(manifests, …)` factory is the accretion naming that class
|
|
292
|
+
* stopped. This is the half the registry owns.
|
|
293
|
+
*/
|
|
294
|
+
moduleGraphDeps(
|
|
295
|
+
manifests: ResourceManifest[],
|
|
296
|
+
options: BuildModuleGraphOptions = {},
|
|
297
|
+
): ModuleGraphDeps {
|
|
298
|
+
// The error contract is resolved through the SAME resolver `telo check`
|
|
299
|
+
// uses, memoized across the whole graph — so what a box says it can raise
|
|
300
|
+
// is what a route's `catches:` is validated against.
|
|
301
|
+
const throwsCtx = createResolveCtx(
|
|
302
|
+
manifests,
|
|
303
|
+
this.defs,
|
|
304
|
+
this.aliases,
|
|
305
|
+
this.aliasesByModule,
|
|
306
|
+
new Set(options.entryModule ? [options.entryModule] : []),
|
|
307
|
+
);
|
|
308
|
+
return {
|
|
309
|
+
refFields: (resource) => this.refFieldsForResource(resource),
|
|
310
|
+
definition: (kind, module) => this.resolveDefinitionIn(kind, module),
|
|
311
|
+
aliasesForModule: (module) => this.aliases.aliasesFor(module),
|
|
312
|
+
throwsOf: (resource) => {
|
|
313
|
+
const union = resolveThrowsUnion(resource, throwsCtx);
|
|
314
|
+
return { codes: [...union.codes.keys()], unbounded: union.unbounded };
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
282
319
|
/**
|
|
283
320
|
* Returns the built-in kernel definitions. The underlying DefinitionRegistry already
|
|
284
321
|
* seeds these on construction; this method exposes them so callers (e.g. the kernel's
|
package/src/analyzer.ts
CHANGED
|
@@ -108,6 +108,10 @@ import {
|
|
|
108
108
|
validateValueTypeSlots,
|
|
109
109
|
type ValueTypeSlotIssue,
|
|
110
110
|
} from "./validate-value-type-slots.js";
|
|
111
|
+
import {
|
|
112
|
+
validateSensitiveSlots,
|
|
113
|
+
type SensitiveSlotIssue,
|
|
114
|
+
} from "./validate-sensitive-slots.js";
|
|
111
115
|
import { resolveSchemaTypeRefs } from "./resolve-schema-type-refs.js";
|
|
112
116
|
import { validateSchemaTypeRefs } from "./validate-schema-type-refs.js";
|
|
113
117
|
import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
|
|
@@ -979,10 +983,16 @@ export class StaticAnalyzer {
|
|
|
979
983
|
// definition's `schema:` does. Same scoping as every other schema issue —
|
|
980
984
|
// the entry's own modules, since a dependency is not the consumer's to fix.
|
|
981
985
|
const valueTypeSlotIssues: ValueTypeSlotIssue[] = [];
|
|
986
|
+
// `x-telo-sensitive` rides the same walk and the same scoping. It is read by
|
|
987
|
+
// ONE consumer — the kernel, off a bound contract — so an occurrence outside
|
|
988
|
+
// `inputType` / `outputType` is inert; for a security control, inert-and-
|
|
989
|
+
// silent is the failure worth reporting.
|
|
990
|
+
const sensitiveSlotIssues: SensitiveSlotIssue[] = [];
|
|
982
991
|
for (const m of manifests) {
|
|
983
992
|
const declaringModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
984
993
|
if (!declaringModule || rootModules.has(declaringModule)) {
|
|
985
994
|
valueTypeSlotIssues.push(...validateValueTypeSlots(m as unknown as ResourceManifest));
|
|
995
|
+
sensitiveSlotIssues.push(...validateSensitiveSlots(m as unknown as ResourceManifest));
|
|
986
996
|
}
|
|
987
997
|
}
|
|
988
998
|
for (const m of manifests) {
|
|
@@ -1212,6 +1222,22 @@ export class StaticAnalyzer {
|
|
|
1212
1222
|
},
|
|
1213
1223
|
});
|
|
1214
1224
|
}
|
|
1225
|
+
for (const issue of sensitiveSlotIssues) {
|
|
1226
|
+
diagnostics.push({
|
|
1227
|
+
severity: DiagnosticSeverity.Error,
|
|
1228
|
+
code: issue.code,
|
|
1229
|
+
source: SOURCE,
|
|
1230
|
+
message: issue.message,
|
|
1231
|
+
data: {
|
|
1232
|
+
resource: {
|
|
1233
|
+
kind: issue.manifest.kind,
|
|
1234
|
+
name: issue.manifest.metadata?.name as string,
|
|
1235
|
+
},
|
|
1236
|
+
filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
|
|
1237
|
+
path: issue.path,
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1215
1241
|
// Same split for the two zone annotations. Unreadable ones fail in
|
|
1216
1242
|
// OPPOSITE directions — a dropped requirement is silently unenforced, a
|
|
1217
1243
|
// dropped provision invents failures — so neither can be left to
|
package/src/call-graph.ts
CHANGED
|
@@ -56,7 +56,12 @@ import {
|
|
|
56
56
|
type RefUseCases,
|
|
57
57
|
} from "./ref-slot.js";
|
|
58
58
|
import { isStepSlot } from "./step-slot.js";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
isInlineResource,
|
|
61
|
+
isRefEntry,
|
|
62
|
+
resolveFieldEntries,
|
|
63
|
+
type RefFieldEntry,
|
|
64
|
+
} from "./reference-field-map.js";
|
|
60
65
|
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
61
66
|
|
|
62
67
|
export interface ResourceGraphNode {
|
|
@@ -92,6 +97,42 @@ export interface StepGraphNode {
|
|
|
92
97
|
index: number;
|
|
93
98
|
/** The step value as written. */
|
|
94
99
|
step: Record<string, unknown>;
|
|
100
|
+
/**
|
|
101
|
+
* Which branch of the step grammar this step IS — the branch's own title
|
|
102
|
+
* (`invoke`, `if/then/else`, `while/do`, `switch/cases/default`,
|
|
103
|
+
* `try/catch/finally`, `throw`, `value`).
|
|
104
|
+
*
|
|
105
|
+
* Matched on the branch's REQUIRED keys, and IS those keys — sorted and
|
|
106
|
+
* joined (`if+then`, `invoke`, `do+while`). A machine identity, so a consumer
|
|
107
|
+
* may branch on it; it was the branch's `title`, which is prose an author may
|
|
108
|
+
* reword at any time, and a view was splitting it on `/` to recover a keyword.
|
|
109
|
+
* Absent for a step matching no branch — one just added, and still empty.
|
|
110
|
+
*/
|
|
111
|
+
variant?: string;
|
|
112
|
+
/** The branch's `title` as its author wrote it, for a view to render verbatim.
|
|
113
|
+
* Never parsed: it is a label, and the moment anything reads its SHAPE it
|
|
114
|
+
* becomes a vocabulary the author cannot change. */
|
|
115
|
+
variantLabel?: string;
|
|
116
|
+
/**
|
|
117
|
+
* The expression that decides whether or how this step runs, as written: an
|
|
118
|
+
* `if:`, a `while:`, a `switch:`, or the `when:` guard on a dispatch.
|
|
119
|
+
*
|
|
120
|
+
* Found through `x-telo-topology-role`, never by keyword, so a third-party
|
|
121
|
+
* composer annotating its own predicate is read the same way.
|
|
122
|
+
*/
|
|
123
|
+
predicate?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Every reference slot this step's grammar declares — filled, empty, or
|
|
126
|
+
* holding a declaration written at the site.
|
|
127
|
+
*
|
|
128
|
+
* Recorded here because this is the one place a step's item schema and its
|
|
129
|
+
* value are both in hand: a step array's items sit behind a local `$ref`, so
|
|
130
|
+
* the reference field map deliberately never reaches them, and a consumer
|
|
131
|
+
* asking "what may this step dispatch to, and where is that written" has
|
|
132
|
+
* nowhere else to look. An EMPTY slot is listed for exactly that reason — it
|
|
133
|
+
* is a site an editor can offer to fill, and an edge says nothing about one.
|
|
134
|
+
*/
|
|
135
|
+
refSlots?: { key: string; path: string; kinds: string[]; inline?: boolean }[];
|
|
95
136
|
}
|
|
96
137
|
|
|
97
138
|
export type CallGraphNode = ResourceGraphNode | StepGraphNode;
|
|
@@ -154,7 +195,15 @@ export interface CallGraph {
|
|
|
154
195
|
/** Edges arriving at a resource node. */
|
|
155
196
|
edgesTo(id: string): CallGraphEdge[];
|
|
156
197
|
resource(kind: string, name: string): ResourceGraphNode | undefined;
|
|
157
|
-
|
|
198
|
+
/**
|
|
199
|
+
* The resource a bare name addresses, read from `fromModule`'s scope.
|
|
200
|
+
*
|
|
201
|
+
* A name is module-scoped, so the module is how two declarations of one name
|
|
202
|
+
* are told apart — see {@link resolveScopedName}. Omitting it answers for a
|
|
203
|
+
* name declared once and, where several modules declare it, declines rather
|
|
204
|
+
* than taking whichever came first.
|
|
205
|
+
*/
|
|
206
|
+
resourceByName(name: string, fromModule?: string): ResourceGraphNode | undefined;
|
|
158
207
|
/** Step nodes declared by a resource, in lexical order. */
|
|
159
208
|
steps(resourceId: string): StepGraphNode[];
|
|
160
209
|
/** Every edge whose `use` includes at least one control transfer, plus every
|
|
@@ -164,6 +213,55 @@ export interface CallGraph {
|
|
|
164
213
|
|
|
165
214
|
export const resourceId = (kind: string, name: string): string => `${kind}\0${name}`;
|
|
166
215
|
|
|
216
|
+
/**
|
|
217
|
+
* The id a manifest's node is keyed by.
|
|
218
|
+
*
|
|
219
|
+
* **A resource name is MODULE-SCOPED**, so `(kind, name)` is unique inside one
|
|
220
|
+
* module and not across a flattened set: two libraries each exporting an
|
|
221
|
+
* `Http.Api` named `routes` are two declarations, and keying both as
|
|
222
|
+
* `http.Api\0routes` silently made them one — the second overwrote the first,
|
|
223
|
+
* taking its edges and its steps with it. The module is part of the identity
|
|
224
|
+
* wherever the loader stamped one; a manifest with no stamp keeps the bare form,
|
|
225
|
+
* so nothing that never crossed a module boundary moves.
|
|
226
|
+
*/
|
|
227
|
+
export function nodeIdFor(manifest: ResourceManifest): string {
|
|
228
|
+
const kind = manifest.kind as string;
|
|
229
|
+
const name = manifest.metadata?.name as string;
|
|
230
|
+
const module = (manifest.metadata as { module?: string } | undefined)?.module;
|
|
231
|
+
return module ? `${module}\0${resourceId(kind, name)}` : resourceId(kind, name);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The module a manifest was declared in, when the loader stamped one. */
|
|
235
|
+
const declaringModule = (manifest: ResourceManifest): string | undefined =>
|
|
236
|
+
(manifest.metadata as { module?: string } | undefined)?.module;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A bare `<name>` read from a resource declared in `fromModule`.
|
|
240
|
+
*
|
|
241
|
+
* **Same module first**, because that is what a bare name MEANS: it is resolved
|
|
242
|
+
* in the scope it was written in, and a cross-module reference is written with
|
|
243
|
+
* an alias. A name declared in exactly one module resolves to it whatever the
|
|
244
|
+
* reader's scope — which is what keeps an unstamped manifest set (every
|
|
245
|
+
* fixture, every single-module load) behaving as it did. A name declared in
|
|
246
|
+
* SEVERAL other modules and none of the reader's resolves to NOTHING: guessing
|
|
247
|
+
* between them attributes a reference to a resource the author never named, and
|
|
248
|
+
* some other pass reports the dangling name honestly.
|
|
249
|
+
*
|
|
250
|
+
* Exported because more than one pass resolves a bare name — the call graph's
|
|
251
|
+
* own sites, and the projection's inline declarations and CEL state reads. Two
|
|
252
|
+
* spellings of this rule is how a fix in one leaves a first-wins lookup in the
|
|
253
|
+
* other, which is exactly the collision it exists to prevent.
|
|
254
|
+
*/
|
|
255
|
+
export function resolveScopedName<T>(
|
|
256
|
+
candidates: readonly T[] | undefined,
|
|
257
|
+
moduleOf: (item: T) => string | undefined,
|
|
258
|
+
fromModule: string | undefined,
|
|
259
|
+
): T | undefined {
|
|
260
|
+
if (!candidates || candidates.length === 0) return undefined;
|
|
261
|
+
if (candidates.length === 1) return candidates[0];
|
|
262
|
+
return candidates.find((candidate) => moduleOf(candidate) === fromModule);
|
|
263
|
+
}
|
|
264
|
+
|
|
167
265
|
/**
|
|
168
266
|
* Does control reach this edge's target?
|
|
169
267
|
*
|
|
@@ -323,6 +421,65 @@ interface StepWalkContext {
|
|
|
323
421
|
stepEdgesByPath: Map<string, CallGraphEdge>;
|
|
324
422
|
}
|
|
325
423
|
|
|
424
|
+
/**
|
|
425
|
+
* What a step IS, and what decides whether it runs.
|
|
426
|
+
*
|
|
427
|
+
* Both come off the schema rather than off a keyword list: the variant is the
|
|
428
|
+
* `oneOf` branch whose REQUIRED keys the step carries, and the predicate is the
|
|
429
|
+
* field annotated as one. A composer that declares a step body of its own is
|
|
430
|
+
* therefore described in its own words, and nothing here knows that `while`
|
|
431
|
+
* exists.
|
|
432
|
+
*
|
|
433
|
+
* A step matching several branches takes the FIRST — the grammar is a `oneOf`,
|
|
434
|
+
* so more than one match is a manifest the checker rejects, and guessing among
|
|
435
|
+
* them would be a second opinion on a question the checker already answers.
|
|
436
|
+
*/
|
|
437
|
+
function classifyStep(
|
|
438
|
+
step: Record<string, unknown>,
|
|
439
|
+
itemSchema: Record<string, any> | undefined,
|
|
440
|
+
rootSchema: Record<string, any>,
|
|
441
|
+
): { variant?: string; variantLabel?: string; predicate?: string } {
|
|
442
|
+
if (!itemSchema) return {};
|
|
443
|
+
const branches = Array.isArray(itemSchema.oneOf) ? itemSchema.oneOf : [];
|
|
444
|
+
let variant: string | undefined;
|
|
445
|
+
let variantLabel: string | undefined;
|
|
446
|
+
for (const raw of branches) {
|
|
447
|
+
const branch = resolveLocalRef(raw, rootSchema);
|
|
448
|
+
const required = Array.isArray(branch?.required) ? (branch.required as string[]) : [];
|
|
449
|
+
if (required.length === 0 || !required.every((key) => step[key] !== undefined)) continue;
|
|
450
|
+
// The keys are what the branch IS; the title is what it is called.
|
|
451
|
+
variant = [...required].sort().join("+");
|
|
452
|
+
if (typeof branch?.title === "string") variantLabel = branch.title;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let predicate: string | undefined;
|
|
457
|
+
for (const [key, propSchema] of propertySchemas(itemSchema)) {
|
|
458
|
+
const role = propSchema?.["x-telo-topology-role"];
|
|
459
|
+
if (role !== "predicate" && role !== "discriminator") continue;
|
|
460
|
+
const written = expressionSource(step[key]);
|
|
461
|
+
if (written !== undefined) {
|
|
462
|
+
predicate = written;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
...(variant ? { variant } : {}),
|
|
468
|
+
...(variantLabel ? { variantLabel } : {}),
|
|
469
|
+
...(predicate ? { predicate } : {}),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A predicate as the author wrote it. A CEL value reaches here as a tagged
|
|
474
|
+
* sentinel or as an already-compiled node, and the SOURCE is the only part of
|
|
475
|
+
* either worth showing. */
|
|
476
|
+
function expressionSource(value: unknown): string | undefined {
|
|
477
|
+
if (typeof value === "string") return value;
|
|
478
|
+
if (value === null || typeof value !== "object") return undefined;
|
|
479
|
+
const record = value as { source?: unknown; __compiled?: unknown };
|
|
480
|
+
return typeof record.source === "string" ? record.source : undefined;
|
|
481
|
+
}
|
|
482
|
+
|
|
326
483
|
/**
|
|
327
484
|
* Emit the edges a single step's own ref slots declare.
|
|
328
485
|
*
|
|
@@ -338,7 +495,21 @@ function emitStepEdges(node: StepGraphNode, ctx: StepWalkContext): void {
|
|
|
338
495
|
for (const [key, propSchema] of propertySchemas(ctx.itemSchema)) {
|
|
339
496
|
const slot = readRefSlot(propSchema);
|
|
340
497
|
if (!slot || slot.kinds.length === 0) continue;
|
|
341
|
-
const
|
|
498
|
+
const written = node.step[key];
|
|
499
|
+
const inline =
|
|
500
|
+
written !== null &&
|
|
501
|
+
typeof written === "object" &&
|
|
502
|
+
!Array.isArray(written) &&
|
|
503
|
+
isInlineResource(written as Record<string, unknown>);
|
|
504
|
+
// Every declared slot is recorded, whatever is in it: an empty one is a
|
|
505
|
+
// site an editor can fill, and a declaration written at the site is a real
|
|
506
|
+
// dispatch that emits no edge — which is what made an inline step read
|
|
507
|
+
// exactly like one that dispatches nothing.
|
|
508
|
+
node.refSlots = [
|
|
509
|
+
...(node.refSlots ?? []),
|
|
510
|
+
{ key, path: `${node.path}.${key}`, kinds: slot.kinds, ...(inline ? { inline: true } : {}) },
|
|
511
|
+
];
|
|
512
|
+
const targetName = refTargetName(written);
|
|
342
513
|
if (targetName === undefined) continue;
|
|
343
514
|
const entry: RefFieldEntry = {
|
|
344
515
|
refs: slot.kinds,
|
|
@@ -441,6 +612,10 @@ function walkSteps(
|
|
|
441
612
|
step: value,
|
|
442
613
|
};
|
|
443
614
|
if (typeof value.name === "string") node.name = value.name;
|
|
615
|
+
const { variant, variantLabel, predicate } = classifyStep(value, ctx.itemSchema, ctx.rootSchema);
|
|
616
|
+
if (variant) node.variant = variant;
|
|
617
|
+
if (variantLabel) node.variantLabel = variantLabel;
|
|
618
|
+
if (predicate) node.predicate = predicate;
|
|
444
619
|
if (parent) node.parent = parent;
|
|
445
620
|
ctx.nodes.set(id, node);
|
|
446
621
|
ctx.order.push(node);
|
|
@@ -498,7 +673,9 @@ export function buildCallGraph(
|
|
|
498
673
|
): CallGraph {
|
|
499
674
|
const nodes = new Map<string, CallGraphNode>();
|
|
500
675
|
const edges: CallGraphEdge[] = [];
|
|
501
|
-
|
|
676
|
+
/** Every declaration of a bare name, in declaration order — a name may be
|
|
677
|
+
* declared once per module, so this is a list rather than one node. */
|
|
678
|
+
const byName = new Map<string, ResourceGraphNode[]>();
|
|
502
679
|
const stepsByOwner = new Map<string, StepGraphNode[]>();
|
|
503
680
|
const stepEdgesByPath = new Map<string, CallGraphEdge>();
|
|
504
681
|
|
|
@@ -527,15 +704,22 @@ export function buildCallGraph(
|
|
|
527
704
|
if (!name || !manifest.kind || SYSTEM_KINDS.has(manifest.kind)) continue;
|
|
528
705
|
const node: ResourceGraphNode = {
|
|
529
706
|
type: "resource",
|
|
530
|
-
id:
|
|
707
|
+
id: nodeIdFor(manifest),
|
|
531
708
|
kind: manifest.kind,
|
|
532
709
|
name: name as string,
|
|
533
710
|
manifest,
|
|
534
711
|
};
|
|
712
|
+
if (nodes.has(node.id)) continue;
|
|
535
713
|
nodes.set(node.id, node);
|
|
536
|
-
byName.set(node.name, node);
|
|
714
|
+
byName.set(node.name, [...(byName.get(node.name) ?? []), node]);
|
|
537
715
|
}
|
|
538
716
|
|
|
717
|
+
const resolveByName = (
|
|
718
|
+
name: string,
|
|
719
|
+
fromModule: string | undefined,
|
|
720
|
+
): ResourceGraphNode | undefined =>
|
|
721
|
+
resolveScopedName(byName.get(name), (node) => declaringModule(node.manifest), fromModule);
|
|
722
|
+
|
|
539
723
|
// --- step nodes ---
|
|
540
724
|
const collectStepsFor = (
|
|
541
725
|
node: ResourceGraphNode,
|
|
@@ -565,7 +749,7 @@ export function buildCallGraph(
|
|
|
565
749
|
};
|
|
566
750
|
|
|
567
751
|
for (const node of [...nodes.values()] as ResourceGraphNode[]) {
|
|
568
|
-
collectStepsFor(node, (name) =>
|
|
752
|
+
collectStepsFor(node, (name) => resolveByName(name, declaringModule(node.manifest)));
|
|
569
753
|
}
|
|
570
754
|
|
|
571
755
|
// --- edges ---
|
|
@@ -593,7 +777,7 @@ export function buildCallGraph(
|
|
|
593
777
|
scopeLocal = new Map();
|
|
594
778
|
const ownerName = event.source.metadata?.name as string | undefined;
|
|
595
779
|
if (!ownerName || !event.source.kind) return;
|
|
596
|
-
const ownerId =
|
|
780
|
+
const ownerId = nodeIdFor(event.source);
|
|
597
781
|
|
|
598
782
|
// Inline declarations inside `x-telo-scope` arrays become nodes of
|
|
599
783
|
// their own, keyed by their scope site — the declaration-site identity
|
|
@@ -620,8 +804,9 @@ export function buildCallGraph(
|
|
|
620
804
|
}
|
|
621
805
|
if (scopeLocal.size === 0) return;
|
|
622
806
|
|
|
807
|
+
const ownerModule = declaringModule(event.source);
|
|
623
808
|
const resolveScoped = (name: string): ResourceGraphNode | undefined =>
|
|
624
|
-
scopeLocal.get(name) ??
|
|
809
|
+
scopeLocal.get(name) ?? resolveByName(name, ownerModule);
|
|
625
810
|
|
|
626
811
|
// The owner's own step edges were emitted before this scope was seen
|
|
627
812
|
// (step collection precedes the visit), so their names resolved
|
|
@@ -680,7 +865,7 @@ export function buildCallGraph(
|
|
|
680
865
|
onRef: (event) => {
|
|
681
866
|
const sourceName = event.source.metadata?.name as string | undefined;
|
|
682
867
|
if (!sourceName || !event.source.kind) return;
|
|
683
|
-
const sourceId =
|
|
868
|
+
const sourceId = nodeIdFor(event.source);
|
|
684
869
|
if (!nodes.has(sourceId)) return;
|
|
685
870
|
|
|
686
871
|
// A site inside a step was already emitted by the step walk, which
|
|
@@ -719,9 +904,10 @@ export function buildCallGraph(
|
|
|
719
904
|
path: event.concretePath,
|
|
720
905
|
use,
|
|
721
906
|
};
|
|
907
|
+
const sourceModule = declaringModule(event.source);
|
|
722
908
|
const target = scopedNames.has(targetName)
|
|
723
|
-
? (scopeLocal.get(targetName) ??
|
|
724
|
-
:
|
|
909
|
+
? (scopeLocal.get(targetName) ?? resolveByName(targetName, sourceModule))
|
|
910
|
+
: resolveByName(targetName, sourceModule);
|
|
725
911
|
if (target) edge.to = target.id;
|
|
726
912
|
if (unresolved) edge.unresolved = unresolved;
|
|
727
913
|
if (unresolvedReason) edge.unresolvedReason = unresolvedReason;
|
|
@@ -758,8 +944,15 @@ export function buildCallGraph(
|
|
|
758
944
|
edges,
|
|
759
945
|
edgesFrom: (id) => fromIndex.get(id) ?? [],
|
|
760
946
|
edgesTo: (id) => toIndex.get(id) ?? [],
|
|
761
|
-
|
|
762
|
-
|
|
947
|
+
// Both accessors take a name with no module, so both answer for the one
|
|
948
|
+
// declaration that name has — and, where a flattened set has several, the
|
|
949
|
+
// first in declaration order. A caller that must tell two apart holds a
|
|
950
|
+
// node id, which carries the module.
|
|
951
|
+
resource: (kind, name) =>
|
|
952
|
+
(nodes.get(resourceId(kind, name)) as ResourceGraphNode | undefined) ??
|
|
953
|
+
byName.get(name)?.find((n) => n.kind === kind),
|
|
954
|
+
resourceByName: (name, fromModule) =>
|
|
955
|
+
resolveScopedName(byName.get(name), (node) => declaringModule(node.manifest), fromModule),
|
|
763
956
|
steps: (ownerId) => stepsByOwner.get(ownerId) ?? [],
|
|
764
957
|
controlEdges: () => edges.filter(reachesTarget),
|
|
765
958
|
};
|
|
@@ -137,6 +137,39 @@ export function effectiveAuthorSchema(
|
|
|
137
137
|
return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The fields a merge-form inheriting child publishes over its parent's reading.
|
|
142
|
+
*
|
|
143
|
+
* A child that inherits its controller by delegation and declares no `base:` IS
|
|
144
|
+
* the parent instance, so `resources.<child>` is the parent's `snapshot()` — and
|
|
145
|
+
* a field the parent has never heard of would be readable from nowhere. These
|
|
146
|
+
* are exactly those fields: the ones the effective (merged) schema declares that
|
|
147
|
+
* the controller-bearing ancestor's does not.
|
|
148
|
+
*
|
|
149
|
+
* A REDECLARED name is deliberately excluded. Narrowing an inherited field (a
|
|
150
|
+
* description, a pattern, a widget hint) is ordinary in an additive extension and
|
|
151
|
+
* says nothing about publication, while the parent's `snapshot()` is the sole
|
|
152
|
+
* authority on what a parent instance publishes — its normalizations, its
|
|
153
|
+
* deliberate omissions, and its redactions. Republishing such a field from raw
|
|
154
|
+
* config would silently undo a provider's decision to keep it out.
|
|
155
|
+
*
|
|
156
|
+
* Empty for the `base:` form, whose fields are construction inputs consumed by
|
|
157
|
+
* the mapping, and for a child with its own controller, which publishes itself.
|
|
158
|
+
*/
|
|
159
|
+
export function publishedOwnFields(
|
|
160
|
+
def: ResourceDefinition | undefined,
|
|
161
|
+
resolve: DefResolver,
|
|
162
|
+
): string[] {
|
|
163
|
+
if (body(def).base || !isInheritedDelegation(def, resolve)) return [];
|
|
164
|
+
const ancestor = controllerBearingAncestor(def, resolve);
|
|
165
|
+
if (!ancestor) return [];
|
|
166
|
+
const inherited = new Set(
|
|
167
|
+
Object.keys((effectiveAuthorSchema(ancestor, resolve).properties ?? {}) as object),
|
|
168
|
+
);
|
|
169
|
+
const own = (effectiveAuthorSchema(def, resolve).properties ?? {}) as Record<string, unknown>;
|
|
170
|
+
return Object.keys(own).filter((name) => !inherited.has(name));
|
|
171
|
+
}
|
|
172
|
+
|
|
140
173
|
/** The two directions of a kind's invocation contract. `inputType` is what a
|
|
141
174
|
* caller sends to `invoke()`; `outputType` is what `invoke()` / `provide()`
|
|
142
175
|
* returns. */
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import type { GraphLoadError, LoadedGraph } from "./loaded-types.js";
|
|
2
2
|
import { isLocalPathSource } from "./sources/local-path-ref.js";
|
|
3
|
-
import { isRegistryRef } from "./sources/module-ref.js";
|
|
4
3
|
import { isOciRef } from "./sources/oci-ref.js";
|
|
5
4
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
6
5
|
|
|
7
6
|
const SOURCE = "telo-analyzer";
|
|
8
7
|
|
|
9
|
-
/** True when `source` is a shape some transport claims —
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
/** True when `source` is a shape some transport claims — an OCI ref, an HTTP(S)
|
|
9
|
+
* URL, or a relative/absolute path. A source matching none of these is
|
|
10
|
+
* malformed (no transport can ever resolve it), which we report differently
|
|
11
|
+
* from a well-formed ref that simply failed to fetch. */
|
|
13
12
|
function isRecognizedSourceShape(source: string): boolean {
|
|
14
13
|
return (
|
|
15
|
-
isRegistryRef(source) ||
|
|
16
14
|
isOciRef(source) ||
|
|
17
15
|
source.startsWith("http://") ||
|
|
18
16
|
source.startsWith("https://") ||
|
|
@@ -27,13 +25,32 @@ function classify(e: GraphLoadError): "INVALID_IMPORT_TARGET" | "INVALID_IMPORT_
|
|
|
27
25
|
return isRecognizedSourceShape(e.source ?? e.url) ? "IMPORT_UNRESOLVED" : "INVALID_IMPORT_SOURCE";
|
|
28
26
|
}
|
|
29
27
|
|
|
28
|
+
/** The removed bare `<namespace>/<name>@<version>` ref: no scheme, not a path,
|
|
29
|
+
* carrying both a `/` and an `@`. Recognised only to explain itself — no
|
|
30
|
+
* migration can rewrite it, because the OCI host it moved to is not derivable
|
|
31
|
+
* from the ref, and the failing line is frequently inside a dependency the
|
|
32
|
+
* consumer cannot edit. The message is the whole remedy available to them. */
|
|
33
|
+
function isRemovedBareRef(source: string): boolean {
|
|
34
|
+
return (
|
|
35
|
+
!source.includes("://") &&
|
|
36
|
+
!source.startsWith("/") &&
|
|
37
|
+
!source.startsWith(".") &&
|
|
38
|
+
source.includes("@") &&
|
|
39
|
+
source.includes("/")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
function messageFor(e: GraphLoadError, code: ReturnType<typeof classify>): string {
|
|
31
44
|
const authored = e.source ?? e.url;
|
|
32
45
|
const via = e.alias ? `import '${e.alias}' → '${authored}'` : `'${authored}'`;
|
|
33
46
|
if (code === "INVALID_IMPORT_SOURCE") {
|
|
47
|
+
const removedForm = isRemovedBareRef(authored)
|
|
48
|
+
? ` The bare '<namespace>/<name>@<version>' form was removed — rewrite it to the ` +
|
|
49
|
+
`module's 'oci://' ref (\`telo search <name>\` reports it).`
|
|
50
|
+
: "";
|
|
34
51
|
return (
|
|
35
52
|
`Cannot resolve ${via}: not a recognized module reference. Expected ` +
|
|
36
|
-
`'
|
|
53
|
+
`'oci://host/repo@tag', 'https://…', or a relative path.${removedForm}`
|
|
37
54
|
);
|
|
38
55
|
}
|
|
39
56
|
// The target WAS obtained, so "cannot resolve" would name the wrong problem —
|