@telorun/analyzer 0.71.0 → 0.72.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 +42 -2
- package/dist/catch-scope.d.ts +72 -0
- package/dist/catch-scope.d.ts.map +1 -0
- package/dist/catch-scope.js +102 -0
- package/dist/deprecation.d.ts +21 -0
- package/dist/deprecation.d.ts.map +1 -0
- package/dist/deprecation.js +26 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest-visitor.d.ts +17 -1
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +5 -1
- package/dist/migrations/report.d.ts +1 -1
- package/dist/migrations/report.d.ts.map +1 -1
- package/dist/migrations/report.js +5 -0
- package/dist/ref-slot.d.ts +15 -0
- package/dist/ref-slot.d.ts.map +1 -1
- package/dist/ref-slot.js +7 -0
- package/dist/resolve-throws-union.d.ts +29 -1
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +111 -16
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +13 -1
- package/dist/schema-error-report.d.ts.map +1 -1
- package/dist/schema-error-report.js +48 -4
- package/dist/schema-keywords.d.ts.map +1 -1
- package/dist/schema-keywords.js +3 -1
- package/dist/schema-walk.d.ts +27 -0
- package/dist/schema-walk.d.ts.map +1 -1
- package/dist/schema-walk.js +44 -0
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +11 -0
- package/dist/validate-identifier-names.d.ts +2 -2
- package/dist/validate-identifier-names.d.ts.map +1 -1
- package/dist/validate-identifier-names.js +22 -7
- package/dist/validate-ref-slots.d.ts +1 -1
- package/dist/validate-ref-slots.d.ts.map +1 -1
- package/dist/validate-ref-slots.js +34 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +27 -3
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +236 -85
- package/package.json +2 -2
- package/src/analyzer.ts +54 -1
- package/src/catch-scope.ts +157 -0
- package/src/deprecation.ts +36 -0
- package/src/index.ts +8 -1
- package/src/manifest-visitor.ts +19 -2
- package/src/migrations/report.ts +5 -1
- package/src/ref-slot.ts +19 -0
- package/src/resolve-throws-union.ts +139 -21
- package/src/schema-compat.ts +13 -0
- package/src/schema-error-report.ts +50 -6
- package/src/schema-keywords.ts +4 -1
- package/src/schema-walk.ts +56 -0
- package/src/telo-version.ts +1 -1
- package/src/types.ts +18 -0
- package/src/validate-identifier-names.ts +28 -9
- package/src/validate-ref-slots.ts +41 -1
- package/src/validate-references.ts +33 -3
- package/src/validate-throws-coverage.ts +333 -92
package/src/analyzer.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type CelHandlers,
|
|
25
25
|
} from "./cel-environment.js";
|
|
26
26
|
import { DefinitionRegistry } from "./definition-registry.js";
|
|
27
|
+
import { readDeprecation } from "./deprecation.js";
|
|
27
28
|
import { type ContractDirection, effectiveAuthorSchema } from "./extends-resolution.js";
|
|
28
29
|
import {
|
|
29
30
|
analyzerContractScope,
|
|
@@ -125,7 +126,12 @@ import {
|
|
|
125
126
|
type SchemaIssue,
|
|
126
127
|
} from "./schema-compat.js";
|
|
127
128
|
import { collectValueSchemaIssues } from "./validate-value-schema.js";
|
|
128
|
-
import {
|
|
129
|
+
import {
|
|
130
|
+
DiagnosticSeverity,
|
|
131
|
+
DiagnosticTag,
|
|
132
|
+
type AnalysisDiagnostic,
|
|
133
|
+
type AnalysisOptions,
|
|
134
|
+
} from "./types.js";
|
|
129
135
|
import {
|
|
130
136
|
extractAccessChains,
|
|
131
137
|
extractContextsFromSchema,
|
|
@@ -1535,6 +1541,7 @@ export class StaticAnalyzer {
|
|
|
1535
1541
|
aliases,
|
|
1536
1542
|
rootModules,
|
|
1537
1543
|
getCallGraph(),
|
|
1544
|
+
{ aliasesByModule, rootModules },
|
|
1538
1545
|
),
|
|
1539
1546
|
);
|
|
1540
1547
|
// A file embed resolves at resource creation, so one written on a doc that
|
|
@@ -1911,6 +1918,52 @@ export class StaticAnalyzer {
|
|
|
1911
1918
|
continue;
|
|
1912
1919
|
}
|
|
1913
1920
|
|
|
1921
|
+
// A kind its own author marked `metadata.deprecated` still works exactly as
|
|
1922
|
+
// it did — the manifest is valid and keeps running — so this is a WARNING,
|
|
1923
|
+
// reported at the declaration because `kind:` is the line that has to
|
|
1924
|
+
// change. Emitted here rather than in a pass of its own so it reads the
|
|
1925
|
+
// kind THIS walk resolved: the resolution is alias- and gate-aware and
|
|
1926
|
+
// scope-dependent, and a second implementation of it would eventually
|
|
1927
|
+
// disagree about which definition a name means.
|
|
1928
|
+
//
|
|
1929
|
+
// Entry-module-scoped, like every other "not the consumer's to fix" check:
|
|
1930
|
+
// a library's internal use of a kind its own author deprecated is that
|
|
1931
|
+
// author's concern, and reporting it floods a consumer with lines they
|
|
1932
|
+
// cannot act on.
|
|
1933
|
+
//
|
|
1934
|
+
// No `DiagnosticFix`. `replacedBy` names a kind through the DECLARING
|
|
1935
|
+
// module's aliases; writing it into the consumer's file would produce a
|
|
1936
|
+
// prefix that resolves to nothing there — and a kind swap is not a
|
|
1937
|
+
// whole-value replacement anyway, since the successor needs its own import
|
|
1938
|
+
// and usually a different configuration.
|
|
1939
|
+
const deprecation = readDeprecation(definition.metadata);
|
|
1940
|
+
if (deprecation && (!ownModule || rootModules.has(ownModule))) {
|
|
1941
|
+
// Quoted CANONICALLY, not verbatim: `Self.Thing` is how the declaring
|
|
1942
|
+
// library names its own kind and means nothing where the warning lands.
|
|
1943
|
+
const declaringScope =
|
|
1944
|
+
scopeResolverForModule(
|
|
1945
|
+
(definition.metadata as { module?: string } | undefined)?.module,
|
|
1946
|
+
rootModules,
|
|
1947
|
+
aliasesByModule,
|
|
1948
|
+
) ?? aliases;
|
|
1949
|
+
const replacement = deprecation.replacedBy
|
|
1950
|
+
? (declaringScope.resolveKind(deprecation.replacedBy) ?? deprecation.replacedBy)
|
|
1951
|
+
: undefined;
|
|
1952
|
+
diagnostics.push({
|
|
1953
|
+
severity: DiagnosticSeverity.Warning,
|
|
1954
|
+
code: "DEPRECATED_KIND",
|
|
1955
|
+
source: SOURCE,
|
|
1956
|
+
// Warning-grade AND a deprecation: the severity says it must
|
|
1957
|
+
// eventually be dealt with, the tag says what it is, and an editor
|
|
1958
|
+
// strikes the kind through on the strength of the second.
|
|
1959
|
+
tags: [DiagnosticTag.Deprecated],
|
|
1960
|
+
message:
|
|
1961
|
+
`Kind '${m.kind}' is deprecated: ${deprecation.reason}` +
|
|
1962
|
+
(replacement ? ` Use '${replacement}' instead.` : ""),
|
|
1963
|
+
data: { resource, filePath, path: "kind" },
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1914
1967
|
// Validate resource config against the definition's AUTHOR-FACING schema —
|
|
1915
1968
|
// inheritance-resolved, with `kind` / `metadata` injected. See
|
|
1916
1969
|
// `validationSchemaFor`, which is where both derivations and the reason
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which `catches:` lists answer for one dispatch site.
|
|
3
|
+
*
|
|
4
|
+
* A catch list is one rung of a SCOPE LADDER — a route's, its router's, its
|
|
5
|
+
* server's — and every rule over those lists needs the same two answers: what
|
|
6
|
+
* a scope list is checked against, and which scopes enclose a given site. This
|
|
7
|
+
* module owns that model; the rule checks in `validate-throws-coverage.ts` read
|
|
8
|
+
* one answer instead of rebuilding it beside four unrelated jobs.
|
|
9
|
+
*
|
|
10
|
+
* Browser-safe: no Node built-ins.
|
|
11
|
+
*/
|
|
12
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
13
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
14
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
15
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
16
|
+
import { forEachDrivenSlot } from "./schema-walk.js";
|
|
17
|
+
import { resolveRefManifest, type ResolveCtx } from "./resolve-throws-union.js";
|
|
18
|
+
|
|
19
|
+
/** What a list proves it renders: the codes its coverage-proving `when:` clauses
|
|
20
|
+
* name, and whether it ends in a catch-all.
|
|
21
|
+
*
|
|
22
|
+
* Unfiltered by any denominator, because this is also what an enclosing scope
|
|
23
|
+
* contributes DOWNWARD — a server entry naming a code the server's own closure
|
|
24
|
+
* could not enumerate still renders it for the route that throws it. Filtering
|
|
25
|
+
* the local answer against the declared union happens where that answer is
|
|
26
|
+
* used, and subtracting from the declared set makes the two equivalent there. */
|
|
27
|
+
export interface ProvenCoverage {
|
|
28
|
+
codes: Set<string>;
|
|
29
|
+
hasCatchAll: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const NO_COVERAGE: ProvenCoverage = { codes: new Set(), hasCatchAll: false };
|
|
33
|
+
|
|
34
|
+
/** A declaration inside another resource's `x-telo-scope` array, kept with the
|
|
35
|
+
* resource that encloses it and the path it is written at in that resource's
|
|
36
|
+
* document. Both are needed: the owner is the only place its kind's alias scope
|
|
37
|
+
* can be found, and the only document position lookup can reach. */
|
|
38
|
+
export interface ScopedManifest {
|
|
39
|
+
manifest: ResourceManifest;
|
|
40
|
+
owner: ResourceManifest;
|
|
41
|
+
/** Concrete owner-relative path of the declaration (`with[0]`). */
|
|
42
|
+
path: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every `with:`-scoped declaration in the set, once each.
|
|
46
|
+
*
|
|
47
|
+
* Scoped resources are absent from the flat manifest list, so every check that
|
|
48
|
+
* iterates it skips them — and standing a server up around a test is exactly
|
|
49
|
+
* that shape, so the sanctioned pattern was the one the pass could not see.
|
|
50
|
+
* Discovered through the shared visitor rather than a second scope walk. */
|
|
51
|
+
export function collectScopedManifests(
|
|
52
|
+
manifests: ResourceManifest[],
|
|
53
|
+
defs: DefinitionRegistry,
|
|
54
|
+
aliases: AliasResolver,
|
|
55
|
+
aliasesByModule: Map<string, AliasResolver>,
|
|
56
|
+
rootModules: Set<string>,
|
|
57
|
+
): ScopedManifest[] {
|
|
58
|
+
const scoped: ScopedManifest[] = [];
|
|
59
|
+
visitManifest(
|
|
60
|
+
manifests,
|
|
61
|
+
defs,
|
|
62
|
+
{
|
|
63
|
+
onScope: (event) => {
|
|
64
|
+
for (const { manifest, path } of event.declarations) {
|
|
65
|
+
if (!manifest?.kind || !manifest.metadata?.name) continue;
|
|
66
|
+
scoped.push({ manifest, owner: event.source, path });
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ aliases, aliasesByModule, rootModules },
|
|
71
|
+
);
|
|
72
|
+
return scoped;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Which resources each resource's scope list encloses, along the slots that
|
|
76
|
+
* declare `throwsThrough` — one fact stated once, since the edge a throws
|
|
77
|
+
* closure crosses is the edge a catch scope encloses through. */
|
|
78
|
+
export function buildEnclosers(
|
|
79
|
+
manifests: ResourceManifest[],
|
|
80
|
+
definitionOf: (m: ResourceManifest) => { schema?: Record<string, any> } | undefined,
|
|
81
|
+
moduleOf: (m: ResourceManifest) => string | undefined,
|
|
82
|
+
ctx: ResolveCtx,
|
|
83
|
+
): Map<ResourceManifest, ResourceManifest[]> {
|
|
84
|
+
const enclosers = new Map<ResourceManifest, ResourceManifest[]>();
|
|
85
|
+
for (const manifest of manifests) {
|
|
86
|
+
const definition = definitionOf(manifest);
|
|
87
|
+
if (!definition?.schema) continue;
|
|
88
|
+
forEachDrivenSlot(definition.schema, manifest, (driven) => {
|
|
89
|
+
if (driven.kind !== "ref" || !driven.slot.throwsThrough) return;
|
|
90
|
+
const target = resolveRefManifest(driven.data, ctx, moduleOf(manifest));
|
|
91
|
+
if (!target || target === manifest) return;
|
|
92
|
+
const list = enclosers.get(target);
|
|
93
|
+
if (list) list.push(manifest);
|
|
94
|
+
else enclosers.set(target, [manifest]);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return enclosers;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What every scope enclosing a resource is guaranteed to render for it.
|
|
102
|
+
*
|
|
103
|
+
* **Across enclosers this INTERSECTS, and that is the whole correctness of the
|
|
104
|
+
* reduction.** Coverage claims that a throw cannot escape unrendered, so it holds
|
|
105
|
+
* only when EVERY path to the site renders it — a router mounted on a public
|
|
106
|
+
* server with a catch-all and an internal one without is covered on one path and
|
|
107
|
+
* bare on the other, and unioning the two reported it as fully covered while the
|
|
108
|
+
* internal server answered with the built-in envelope. A resource with no
|
|
109
|
+
* encloser contributes nothing rather than everything: the empty set is the
|
|
110
|
+
* identity for the site's own coverage, and treating "no paths" as "all paths
|
|
111
|
+
* agree" would assert coverage no list provides.
|
|
112
|
+
*
|
|
113
|
+
* A resource's OWN scope list is unioned in, because it applies on every path.
|
|
114
|
+
*
|
|
115
|
+
* Memoized, and a cycle among `throwsThrough` edges resolves to what has been
|
|
116
|
+
* accumulated so far rather than raising: this answers what a scope renders, and
|
|
117
|
+
* no cycle makes that answer larger.
|
|
118
|
+
*/
|
|
119
|
+
export function enclosingCoverage(
|
|
120
|
+
manifest: ResourceManifest,
|
|
121
|
+
ownScope: Map<ResourceManifest, ProvenCoverage>,
|
|
122
|
+
enclosers: Map<ResourceManifest, ResourceManifest[]>,
|
|
123
|
+
memo: Map<ResourceManifest, ProvenCoverage> = new Map(),
|
|
124
|
+
walking: Set<ResourceManifest> = new Set(),
|
|
125
|
+
): ProvenCoverage {
|
|
126
|
+
const cached = memo.get(manifest);
|
|
127
|
+
if (cached) return cached;
|
|
128
|
+
if (walking.has(manifest)) return NO_COVERAGE;
|
|
129
|
+
walking.add(manifest);
|
|
130
|
+
try {
|
|
131
|
+
const own = ownScope.get(manifest);
|
|
132
|
+
const result: ProvenCoverage = {
|
|
133
|
+
codes: new Set(own?.codes ?? []),
|
|
134
|
+
hasCatchAll: own?.hasCatchAll ?? false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const outer = (enclosers.get(manifest) ?? []).map((e) =>
|
|
138
|
+
enclosingCoverage(e, ownScope, enclosers, memo, walking),
|
|
139
|
+
);
|
|
140
|
+
if (outer.length > 0) {
|
|
141
|
+
const [first, ...rest] = outer;
|
|
142
|
+
const shared = new Set(first.codes);
|
|
143
|
+
let everyCatchAll = first.hasCatchAll;
|
|
144
|
+
for (const other of rest) {
|
|
145
|
+
for (const code of [...shared]) if (!other.codes.has(code)) shared.delete(code);
|
|
146
|
+
everyCatchAll &&= other.hasCatchAll;
|
|
147
|
+
}
|
|
148
|
+
for (const code of shared) result.codes.add(code);
|
|
149
|
+
if (everyCatchAll) result.hasCatchAll = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
memo.set(manifest, result);
|
|
153
|
+
return result;
|
|
154
|
+
} finally {
|
|
155
|
+
walking.delete(manifest);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `metadata.deprecated` — the annotation's single reader.
|
|
3
|
+
*
|
|
4
|
+
* Structural validity belongs to `validate-module-metadata.ts` (the strict half,
|
|
5
|
+
* the `ref-slot.ts` split). This side is deliberately lenient: it reads a
|
|
6
|
+
* well-formed block and treats everything else as absent, so a malformed
|
|
7
|
+
* declaration in a published dependency never becomes a warning at a consumer's
|
|
8
|
+
* use site. The consumer can fix neither one, and reporting the second blames
|
|
9
|
+
* the wrong author.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A deprecation as declared: why, and optionally what to use instead. */
|
|
13
|
+
export interface Deprecation {
|
|
14
|
+
/** What a consumer reads to know what to do instead. Always non-empty. */
|
|
15
|
+
reason: string;
|
|
16
|
+
/** Alias-qualified kind (kind docs) or module ref (module docs), **as written
|
|
17
|
+
* in the declaring file's own scope** — `Self.Thing` means nothing to a
|
|
18
|
+
* consumer, so a use site resolves it before quoting it. */
|
|
19
|
+
replacedBy?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function readDeprecation(metadata: unknown): Deprecation | undefined {
|
|
23
|
+
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
|
|
24
|
+
const block = (metadata as Record<string, unknown>).deprecated;
|
|
25
|
+
if (block === null || typeof block !== "object" || Array.isArray(block)) return undefined;
|
|
26
|
+
|
|
27
|
+
const { reason, replacedBy } = block as Record<string, unknown>;
|
|
28
|
+
if (typeof reason !== "string" || reason.trim() === "") return undefined;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
reason: reason.trim(),
|
|
32
|
+
...(typeof replacedBy === "string" && replacedBy.trim() !== ""
|
|
33
|
+
? { replacedBy: replacedBy.trim() }
|
|
34
|
+
: {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -185,6 +185,8 @@ export {
|
|
|
185
185
|
export type { RefSlot, RefUse, RefUseCases } from "./ref-slot.js";
|
|
186
186
|
export { isStepSlot, readStepSlot, STEP_FRAGMENT } from "./step-slot.js";
|
|
187
187
|
export type { StepSlot } from "./step-slot.js";
|
|
188
|
+
export { readDeprecation } from "./deprecation.js";
|
|
189
|
+
export type { Deprecation } from "./deprecation.js";
|
|
188
190
|
export {
|
|
189
191
|
ANNOTATION_KEYWORDS,
|
|
190
192
|
registerTeloKeywords,
|
|
@@ -489,7 +491,12 @@ export type { CelScopeQueryContext, ContextDeclarationSite } from "./cel-scope-q
|
|
|
489
491
|
export { navigateConcretePath } from "./manifest-path.js";
|
|
490
492
|
export { ManifestAnalysis } from "./manifest-analysis.js";
|
|
491
493
|
export type { ManifestRef } from "./manifest-analysis.js";
|
|
492
|
-
export {
|
|
494
|
+
export {
|
|
495
|
+
DEFAULT_MANIFEST_FILENAME,
|
|
496
|
+
DiagnosticSeverity,
|
|
497
|
+
DiagnosticTag,
|
|
498
|
+
diagnosticFix,
|
|
499
|
+
} from "./types.js";
|
|
493
500
|
export type {
|
|
494
501
|
AnalysisDiagnostic,
|
|
495
502
|
AnalysisOptions,
|
package/src/manifest-visitor.ts
CHANGED
|
@@ -66,8 +66,21 @@ export interface ScopeBoundaryEvent {
|
|
|
66
66
|
source: ResourceManifest;
|
|
67
67
|
/** Dot-form prefixes of every `x-telo-scope` field on this resource. */
|
|
68
68
|
scopePrefixes: string[];
|
|
69
|
-
/**
|
|
69
|
+
/** VISIBILITY pointer (an `x-telo-scope` entry, e.g. `/steps`) → the manifests
|
|
70
|
+
* visible there. Keyed by where scoped names may be REFERENCED, which is not
|
|
71
|
+
* where they are DECLARED: `Run.Sequence` declares `x-telo-scope:
|
|
72
|
+
* ["/steps", "/targets"]` on its `with:` field, so one declaration appears
|
|
73
|
+
* under two keys and neither names `with`. Use {@link declarations} for the
|
|
74
|
+
* declaration site. */
|
|
70
75
|
manifestsByPointer: Map<string, ResourceManifest[]>;
|
|
76
|
+
/** Every inline declaration inside this resource's scopes, ONCE each, with the
|
|
77
|
+
* concrete path it is written at in the owner's document (`with[0]`).
|
|
78
|
+
*
|
|
79
|
+
* A diagnostic about a scoped resource has to anchor there: the resource is
|
|
80
|
+
* not a top-level document, so position lookup finds the OWNER and then walks
|
|
81
|
+
* this path into it. Deriving one from a visibility pointer instead names a
|
|
82
|
+
* region the declaration is not in (`steps[0]`). */
|
|
83
|
+
declarations: { manifest: ResourceManifest; path: string }[];
|
|
71
84
|
/** Names of every resource declared inside this resource's scopes. Used by
|
|
72
85
|
* the dependency graph to drop boot edges to scoped (on-demand) targets. */
|
|
73
86
|
enclosedNames: Set<string>;
|
|
@@ -271,6 +284,7 @@ export function visitManifest(
|
|
|
271
284
|
if (refScopeMap && (wantsRefs || wantsScope)) {
|
|
272
285
|
const manifestsByPointer = new Map<string, ResourceManifest[]>();
|
|
273
286
|
const scopeRefEntries: { path: string; refName: string }[] = [];
|
|
287
|
+
const declarations: { manifest: ResourceManifest; path: string }[] = [];
|
|
274
288
|
for (const [fieldPath, entry] of refScopeMap) {
|
|
275
289
|
if (!isScopeEntry(entry)) continue;
|
|
276
290
|
const raw: ResourceManifest[] = [];
|
|
@@ -278,6 +292,7 @@ export function visitManifest(
|
|
|
278
292
|
const items = Array.isArray(fe.value) ? fe.value : [fe.value];
|
|
279
293
|
items.forEach((v, i) => {
|
|
280
294
|
if (!v || typeof v !== "object") return;
|
|
295
|
+
const declarationPath = Array.isArray(fe.value) ? `${fe.path}[${i}]` : fe.path;
|
|
281
296
|
// A scope entry must be an inline resource definition; a `!ref`
|
|
282
297
|
// (tagged sentinel or resolved `{kind, name}`) is not — record it
|
|
283
298
|
// so a static diagnostic flags it instead of registering a
|
|
@@ -288,12 +303,13 @@ export function visitManifest(
|
|
|
288
303
|
(typeof rec.kind === "string" && typeof rec.name === "string")
|
|
289
304
|
) {
|
|
290
305
|
scopeRefEntries.push({
|
|
291
|
-
path:
|
|
306
|
+
path: declarationPath,
|
|
292
307
|
refName: isRefSentinel(v) ? v.source : String(rec.name),
|
|
293
308
|
});
|
|
294
309
|
return;
|
|
295
310
|
}
|
|
296
311
|
raw.push(v as ResourceManifest);
|
|
312
|
+
declarations.push({ manifest: v as ResourceManifest, path: declarationPath });
|
|
297
313
|
});
|
|
298
314
|
}
|
|
299
315
|
const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
|
|
@@ -313,6 +329,7 @@ export function visitManifest(
|
|
|
313
329
|
source: r,
|
|
314
330
|
scopePrefixes,
|
|
315
331
|
manifestsByPointer,
|
|
332
|
+
declarations,
|
|
316
333
|
enclosedNames,
|
|
317
334
|
scopeRefEntries,
|
|
318
335
|
});
|
package/src/migrations/report.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
MigrationPath,
|
|
22
22
|
MigrationRewrite,
|
|
23
23
|
} from "./types.js";
|
|
24
|
-
import type
|
|
24
|
+
import { DiagnosticTag, type AnalysisDiagnostic, type DiagnosticFix } from "../types.js";
|
|
25
25
|
|
|
26
26
|
/** One patch that applied, as the reporting side needs to see it. */
|
|
27
27
|
export interface AppliedPatch {
|
|
@@ -63,6 +63,10 @@ export function toDiagnostic(
|
|
|
63
63
|
severity: applied.entry.severity,
|
|
64
64
|
code: applied.entry.code,
|
|
65
65
|
source: "telo-analyzer",
|
|
66
|
+
// A migration IS a deprecation — a legacy spelling still read, and the one
|
|
67
|
+
// an author is being asked to stop writing — so the range carries the tag
|
|
68
|
+
// whatever severity the entry chose for itself.
|
|
69
|
+
tags: [DiagnosticTag.Deprecated],
|
|
66
70
|
message: `${rewrite.summary}\n${applied.entry.reason}\n${closing}`,
|
|
67
71
|
data: {
|
|
68
72
|
filePath: source,
|
package/src/ref-slot.ts
CHANGED
|
@@ -94,6 +94,21 @@ export interface RefSlot {
|
|
|
94
94
|
* that carries this call's arguments. Replaces
|
|
95
95
|
* `x-telo-topology-role: inputs`. */
|
|
96
96
|
inputs?: string;
|
|
97
|
+
/** Throws raised by the target — or by anything the target drives — surface
|
|
98
|
+
* through the DECLARING resource, so its own throws union includes the
|
|
99
|
+
* target's and its catch scope encloses the target's.
|
|
100
|
+
*
|
|
101
|
+
* It exists because the relation is real where `use` correctly says nothing:
|
|
102
|
+
* `Http.Server.mounts[].mount` is a `dependency` (the server holds the mount
|
|
103
|
+
* and calls a convention method on it — control reaches a route through the
|
|
104
|
+
* MOUNT's own `trigger.inbound` slot, not through this one), yet a throw from
|
|
105
|
+
* that route is exactly what the server renders. Following every `dependency`
|
|
106
|
+
* edge instead would drag a connection's throws into a router's denominator.
|
|
107
|
+
*
|
|
108
|
+
* Declared by the kind that HOLDS, because only it knows the throws surface
|
|
109
|
+
* through it, and it is the same fact its runtime establishes by rethrowing —
|
|
110
|
+
* so the annotation and the behaviour cannot disagree. */
|
|
111
|
+
throwsThrough?: boolean;
|
|
97
112
|
/** `x-telo-inline: true` on the slot or any `anyOf` branch — accepts an inline
|
|
98
113
|
* `{kind, ...config}` definition, not only a `!ref`. */
|
|
99
114
|
inline: boolean;
|
|
@@ -228,6 +243,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
|
|
|
228
243
|
const uses = new Set<RefUse>();
|
|
229
244
|
let useCases: RefUseCases | undefined;
|
|
230
245
|
let inputs: string | undefined;
|
|
246
|
+
let throwsThrough = false;
|
|
231
247
|
|
|
232
248
|
for (const carrier of nodes) {
|
|
233
249
|
const annotation = carrier["x-telo-ref"];
|
|
@@ -239,6 +255,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
|
|
|
239
255
|
for (const use of normalizeUses(obj.use)) uses.add(use);
|
|
240
256
|
useCases ??= readUseCases(obj.use);
|
|
241
257
|
if (typeof obj.inputs === "string") inputs ??= obj.inputs;
|
|
258
|
+
if (obj.throwsThrough === true) throwsThrough = true;
|
|
242
259
|
}
|
|
243
260
|
|
|
244
261
|
const slot: RefSlot = {
|
|
@@ -249,6 +266,7 @@ export function readRefSlot(node: Record<string, any> | undefined): RefSlot | un
|
|
|
249
266
|
};
|
|
250
267
|
if (useCases) slot.useCases = useCases;
|
|
251
268
|
if (inputs !== undefined) slot.inputs = inputs;
|
|
269
|
+
if (throwsThrough) slot.throwsThrough = true;
|
|
252
270
|
return slot;
|
|
253
271
|
}
|
|
254
272
|
|
|
@@ -274,6 +292,7 @@ export function refSlotAnnotation(slot: RefSlot): Record<string, unknown> {
|
|
|
274
292
|
else if (slot.uses.length === 1) annotation.use = slot.uses[0];
|
|
275
293
|
else if (slot.uses.length > 1) annotation.use = slot.uses;
|
|
276
294
|
if (slot.inputs !== undefined) annotation.inputs = slot.inputs;
|
|
295
|
+
if (slot.throwsThrough) annotation.throwsThrough = true;
|
|
277
296
|
return annotation;
|
|
278
297
|
}
|
|
279
298
|
|
|
@@ -4,7 +4,9 @@ import { scopeResolverForModule, type AliasResolver } from "./alias-resolver.js"
|
|
|
4
4
|
import { resolveScopedName } from "./call-graph.js";
|
|
5
5
|
import { refSentinelTarget, type RefSentinelTarget } from "./ref-sentinel-target.js";
|
|
6
6
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
7
|
+
import { possibleUses, readRefSlot, transfersControl, type RefSlot } from "./ref-slot.js";
|
|
7
8
|
import { readStepSlot } from "./step-slot.js";
|
|
9
|
+
import { forEachDrivenSlot } from "./schema-walk.js";
|
|
8
10
|
|
|
9
11
|
export interface ThrowsCodeMeta {
|
|
10
12
|
data?: Record<string, any>;
|
|
@@ -263,6 +265,16 @@ export function resolveThrowsUnion(
|
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
/**
|
|
269
|
+
* `throws.inherit: true` — the union a composer's own STEP BODIES reach.
|
|
270
|
+
*
|
|
271
|
+
* Deliberately steps only, and not every slot the resource drives: `inherit` is
|
|
272
|
+
* a DECLARATION that a kind's union is the union of what it dispatches, and a
|
|
273
|
+
* kind that does not make that claim must not have it inferred — a kind holding
|
|
274
|
+
* a `call` ref it catches internally would silently gain codes it never lets
|
|
275
|
+
* escape. What a CATCH SCOPE needs is a different question with a different
|
|
276
|
+
* answer, and it has its own resolver below.
|
|
277
|
+
*/
|
|
266
278
|
function resolveInherited(
|
|
267
279
|
manifest: ResourceManifest,
|
|
268
280
|
definition: ResourceDefinition,
|
|
@@ -284,6 +296,66 @@ function resolveInherited(
|
|
|
284
296
|
return result;
|
|
285
297
|
}
|
|
286
298
|
|
|
299
|
+
/**
|
|
300
|
+
* The union a SCOPE-LEVEL `catches:` list can be asked to render — everything
|
|
301
|
+
* the resource it is written on drives, transitively.
|
|
302
|
+
*
|
|
303
|
+
* `x-telo-catches-for: ""` is itself the claim that this is the denominator, so
|
|
304
|
+
* nothing is inferred from a kind that did not opt in, and no definition has to
|
|
305
|
+
* declare a `throws:` block to carry a catch scope (the kernel forbids one on a
|
|
306
|
+
* `Telo.Service` and a `Telo.Mount`, and rightly: what a router renders is not
|
|
307
|
+
* what a router THROWS).
|
|
308
|
+
*
|
|
309
|
+
* Three edges, three answers. A **step body** contributes its own traversal,
|
|
310
|
+
* subtraction included. A **control-transferring ref** contributes the target's
|
|
311
|
+
* own declared union — a route handler is a leaf here, and asking what IT drives
|
|
312
|
+
* would credit this scope with codes the handler catches internally. A
|
|
313
|
+
* **`throwsThrough` ref** recurses, because the target is another scope on the
|
|
314
|
+
* same ladder: a server renders what its mounts' routes throw, not what the
|
|
315
|
+
* mounts themselves declare.
|
|
316
|
+
*/
|
|
317
|
+
export function resolveScopeUnion(
|
|
318
|
+
manifest: ResourceManifest,
|
|
319
|
+
definition: ResourceDefinition,
|
|
320
|
+
ctx: ResolveCtx,
|
|
321
|
+
seen: Set<ResourceManifest> = new Set(),
|
|
322
|
+
): ThrowsUnion {
|
|
323
|
+
const result: ThrowsUnion = { codes: new Map(), unbounded: false };
|
|
324
|
+
if (seen.has(manifest)) return result;
|
|
325
|
+
seen.add(manifest);
|
|
326
|
+
const ownerModule = (manifest.metadata as { module?: string } | undefined)?.module;
|
|
327
|
+
|
|
328
|
+
forEachDrivenSlot(definition.schema, manifest, (driven) => {
|
|
329
|
+
if (driven.kind === "step") {
|
|
330
|
+
unionInto(
|
|
331
|
+
result,
|
|
332
|
+
collectStepArrayThrows(driven.data, driven.slot.invoke, undefined, ctx, ownerModule),
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (driven.slot.throwsThrough) {
|
|
337
|
+
const target = resolveRefManifest(driven.data, ctx, ownerModule);
|
|
338
|
+
const targetDef = target
|
|
339
|
+
? definitionFor(
|
|
340
|
+
target.kind,
|
|
341
|
+
ctx.defs,
|
|
342
|
+
ctx.aliases,
|
|
343
|
+
scopeResolverFor(ctx, (target.metadata as { module?: string } | undefined)?.module),
|
|
344
|
+
)
|
|
345
|
+
: undefined;
|
|
346
|
+
if (target && targetDef) unionInto(result, resolveScopeUnion(target, targetDef, ctx, seen));
|
|
347
|
+
// A target that cannot be resolved says nothing about what it throws, so
|
|
348
|
+
// the scope's union is no longer enumerable.
|
|
349
|
+
else result.unbounded = true;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (!possibleUses(driven.slot).some(transfersControl)) return;
|
|
353
|
+
unionInto(result, resolveRefTargetThrows(driven.data, ctx, ownerModule));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
|
|
287
359
|
function collectStepArrayThrows(
|
|
288
360
|
steps: unknown[],
|
|
289
361
|
invokeField: string,
|
|
@@ -410,9 +482,32 @@ function resolveStepInvokeThrows(
|
|
|
410
482
|
ctx: ResolveCtx,
|
|
411
483
|
ownerModule: string | undefined,
|
|
412
484
|
): ThrowsUnion {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
485
|
+
return resolveRefTargetThrows(step[invokeField], ctx, ownerModule, () =>
|
|
486
|
+
resolvePassthroughAtCallSite(step, enclosingTryCodes),
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The effective throw union behind a resolved reference value.
|
|
492
|
+
*
|
|
493
|
+
* Shared by both ways a resource drives another: a step's `invoke:` and a
|
|
494
|
+
* reference slot that carries throws. The two used to differ only in where the
|
|
495
|
+
* ref value was read from, and keeping one copy is what stops a router's
|
|
496
|
+
* denominator and a sequence's from disagreeing about what a name resolves to.
|
|
497
|
+
*
|
|
498
|
+
* `onPassthrough` is the one genuine difference: a passthrough kind's union is a
|
|
499
|
+
* property of the CALL SITE (`inputs.code`), which only a step has. A reference
|
|
500
|
+
* slot has no such site, so the union is unbounded there rather than guessed.
|
|
501
|
+
*/
|
|
502
|
+
function resolveRefTargetThrows(
|
|
503
|
+
refValue: unknown,
|
|
504
|
+
ctx: ResolveCtx,
|
|
505
|
+
ownerModule: string | undefined,
|
|
506
|
+
onPassthrough?: () => ThrowsUnion,
|
|
507
|
+
): ThrowsUnion {
|
|
508
|
+
if (!refValue || typeof refValue !== "object" || Array.isArray(refValue)) return emptyUnion();
|
|
509
|
+
const ref = refValue as Record<string, any>;
|
|
510
|
+
const invokedKind = ref.kind as string | undefined;
|
|
416
511
|
// A reference that still carries its parse-time sentinel — a library-internal
|
|
417
512
|
// `!ref` inside a manifest forwarded into a consumer's flat set, where Phase
|
|
418
513
|
// 2.5 had nothing to resolve it against. The target is in the declaring
|
|
@@ -422,7 +517,7 @@ function resolveStepInvokeThrows(
|
|
|
422
517
|
// its consumer and get the consumer's `catches:` rejected for the code the
|
|
423
518
|
// entry point documents.
|
|
424
519
|
if (!invokedKind) {
|
|
425
|
-
const sentinel = refSentinelTarget(
|
|
520
|
+
const sentinel = refSentinelTarget(ref);
|
|
426
521
|
if (!sentinel) return emptyUnion();
|
|
427
522
|
const target = findSentinelTarget(ctx, sentinel, ownerModule);
|
|
428
523
|
if (target) return resolveThrowsUnion(target, ctx);
|
|
@@ -430,32 +525,19 @@ function resolveStepInvokeThrows(
|
|
|
430
525
|
}
|
|
431
526
|
|
|
432
527
|
// The invoked kind's alias resolves in the OWNER manifest's lexical scope (the
|
|
433
|
-
//
|
|
528
|
+
// resource that declares the slot), so a library's step referencing its own
|
|
434
529
|
// import resolves against that library, not the consumer.
|
|
435
530
|
const scopeResolver = scopeResolverFor(ctx, ownerModule);
|
|
436
531
|
const definition = definitionFor(invokedKind, ctx.defs, ctx.aliases, scopeResolver);
|
|
437
532
|
if (!definition) return { codes: new Map(), unbounded: true };
|
|
438
533
|
|
|
439
534
|
if (definition.throws?.passthrough) {
|
|
440
|
-
return
|
|
535
|
+
return onPassthrough ? onPassthrough() : { codes: new Map(), unbounded: true };
|
|
441
536
|
}
|
|
442
537
|
|
|
443
538
|
// Named manifest: resolve the full chain (covers transitive inherit).
|
|
444
|
-
const
|
|
445
|
-
if (
|
|
446
|
-
const scopedInvokedKind = scopeResolver?.resolveKind(invokedKind);
|
|
447
|
-
const target = findTarget(
|
|
448
|
-
ctx,
|
|
449
|
-
invokeName,
|
|
450
|
-
ownerModule,
|
|
451
|
-
(m) =>
|
|
452
|
-
m.kind === invokedKind ||
|
|
453
|
-
ctx.aliases.resolveKind(m.kind) === invokedKind ||
|
|
454
|
-
m.kind === ctx.aliases.resolveKind(invokedKind) ||
|
|
455
|
-
(scopedInvokedKind !== undefined && m.kind === scopedInvokedKind),
|
|
456
|
-
);
|
|
457
|
-
if (target) return resolveThrowsUnion(target, ctx);
|
|
458
|
-
}
|
|
539
|
+
const target = resolveRefManifest(ref, ctx, ownerModule);
|
|
540
|
+
if (target) return resolveThrowsUnion(target, ctx);
|
|
459
541
|
|
|
460
542
|
// Fall back to the definition's own explicit codes. Mark unbounded when the
|
|
461
543
|
// definition depends on call-site or transitive resolution we couldn't
|
|
@@ -466,6 +548,42 @@ function resolveStepInvokeThrows(
|
|
|
466
548
|
return { codes, unbounded };
|
|
467
549
|
}
|
|
468
550
|
|
|
551
|
+
/**
|
|
552
|
+
* The manifest a resolved reference value names, in either shape it arrives in —
|
|
553
|
+
* a `{kind, name}` pair, or a `!ref` still carrying its parse-time sentinel.
|
|
554
|
+
*
|
|
555
|
+
* Exported because the catch-scope enclosure walk asks the same question about
|
|
556
|
+
* the same values; resolving a name twice by two rules is how two passes end up
|
|
557
|
+
* disagreeing about which resource a slot points at.
|
|
558
|
+
*/
|
|
559
|
+
export function resolveRefManifest(
|
|
560
|
+
refValue: unknown,
|
|
561
|
+
ctx: ResolveCtx,
|
|
562
|
+
ownerModule: string | undefined,
|
|
563
|
+
): ResourceManifest | undefined {
|
|
564
|
+
if (!refValue || typeof refValue !== "object" || Array.isArray(refValue)) return undefined;
|
|
565
|
+
const ref = refValue as Record<string, any>;
|
|
566
|
+
const kind = ref.kind as string | undefined;
|
|
567
|
+
if (!kind) {
|
|
568
|
+
const sentinel = refSentinelTarget(ref);
|
|
569
|
+
return sentinel ? findSentinelTarget(ctx, sentinel, ownerModule) : undefined;
|
|
570
|
+
}
|
|
571
|
+
const name = ref.name as string | undefined;
|
|
572
|
+
if (!name) return undefined;
|
|
573
|
+
const scopeResolver = scopeResolverFor(ctx, ownerModule);
|
|
574
|
+
const scopedKind = scopeResolver?.resolveKind(kind);
|
|
575
|
+
return findTarget(
|
|
576
|
+
ctx,
|
|
577
|
+
name,
|
|
578
|
+
ownerModule,
|
|
579
|
+
(m) =>
|
|
580
|
+
m.kind === kind ||
|
|
581
|
+
ctx.aliases.resolveKind(m.kind) === kind ||
|
|
582
|
+
m.kind === ctx.aliases.resolveKind(kind) ||
|
|
583
|
+
(scopedKind !== undefined && m.kind === scopedKind),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
469
587
|
/** Resolve a passthrough-style invocable at a specific call site. Recognised forms
|
|
470
588
|
* (see "passthrough: true" in the plan):
|
|
471
589
|
* - constant literal (no template) → `{ <literal> }`
|
package/src/schema-compat.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import {
|
|
9
9
|
celBaseOfValueType,
|
|
10
10
|
celTypeOfValueType,
|
|
11
|
+
isCompiledValue,
|
|
11
12
|
readValueTypeSlot,
|
|
12
13
|
valueBrandBases,
|
|
13
14
|
valueTypeOf,
|
|
@@ -799,6 +800,18 @@ export function substituteCelFields(
|
|
|
799
800
|
mark();
|
|
800
801
|
return celPlaceholderForSchema(resolved);
|
|
801
802
|
}
|
|
803
|
+
// The same fact in its third spelling. An expression reaches this walk as a
|
|
804
|
+
// `${{ … }}` string or a `!cel` sentinel BEFORE `precompileDoc`, and as a
|
|
805
|
+
// CompiledValue after — so a caller running under `compile: true` (every
|
|
806
|
+
// `telo run`, unlike `telo check`) handed one to AJV as a plain object, and a
|
|
807
|
+
// slot typed `boolean` rejected a `when:` the author wrote correctly. The
|
|
808
|
+
// kernel's `stripCompiledValues` has always substituted here; missing it on
|
|
809
|
+
// this side made one manifest mean two things depending on which command read
|
|
810
|
+
// it.
|
|
811
|
+
if (isCompiledValue(data)) {
|
|
812
|
+
mark();
|
|
813
|
+
return celPlaceholderForSchema(resolved);
|
|
814
|
+
}
|
|
802
815
|
if (Array.isArray(data)) {
|
|
803
816
|
const item = resolveRefIn((resolved.items ?? {}) as Record<string, any>, root, external);
|
|
804
817
|
return data.map((element, i) =>
|