@telorun/analyzer 0.12.1 → 0.14.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/adapters/http-adapter.d.ts +10 -0
- package/dist/adapters/http-adapter.d.ts.map +1 -0
- package/dist/adapters/http-adapter.js +18 -0
- package/dist/adapters/node-adapter.d.ts +17 -0
- package/dist/adapters/node-adapter.d.ts.map +1 -0
- package/dist/adapters/node-adapter.js +71 -0
- package/dist/adapters/registry-adapter.d.ts +15 -0
- package/dist/adapters/registry-adapter.d.ts.map +1 -0
- package/dist/adapters/registry-adapter.js +53 -0
- package/dist/alias-resolver.d.ts +6 -0
- package/dist/alias-resolver.d.ts.map +1 -1
- package/dist/alias-resolver.js +8 -0
- package/dist/analysis-registry.d.ts +13 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +15 -0
- package/dist/analyzer.d.ts +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +176 -88
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +86 -0
- package/dist/cel-environment.d.ts +1 -1
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +40 -2
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +41 -62
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kernel-globals.d.ts +1 -1
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +19 -1
- package/dist/manifest-visitor.d.ts +124 -0
- package/dist/manifest-visitor.d.ts.map +1 -0
- package/dist/manifest-visitor.js +181 -0
- package/dist/reference-field-map.js +16 -0
- package/dist/resolve-ref-sentinels.d.ts +15 -17
- package/dist/resolve-ref-sentinels.d.ts.map +1 -1
- package/dist/resolve-ref-sentinels.js +94 -40
- package/dist/resolve-throws-union.d.ts +10 -0
- package/dist/resolve-throws-union.d.ts.map +1 -1
- package/dist/resolve-throws-union.js +35 -7
- package/dist/schema-compat.d.ts +10 -0
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +32 -0
- package/dist/validate-cel-context.d.ts +14 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +38 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +215 -160
- package/dist/validate-unused-declarations.d.ts +25 -0
- package/dist/validate-unused-declarations.d.ts.map +1 -0
- package/dist/validate-unused-declarations.js +91 -0
- package/package.json +3 -3
- package/src/analysis-registry.ts +20 -0
- package/src/analyzer.ts +256 -168
- package/src/builtins.ts +85 -0
- package/src/cel-environment.ts +42 -1
- package/src/dependency-graph.ts +37 -52
- package/src/index.ts +11 -0
- package/src/kernel-globals.ts +22 -1
- package/src/manifest-visitor.ts +340 -0
- package/src/reference-field-map.ts +14 -0
- package/src/resolve-throws-union.ts +36 -8
- package/src/schema-compat.ts +32 -0
- package/src/validate-cel-context.ts +50 -0
- package/src/validate-references.ts +175 -211
- package/src/validate-unused-declarations.ts +95 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isRefSentinel } from "@telorun/templating";
|
|
2
|
-
import {
|
|
2
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
3
|
+
import { isInlineResource, resolveFieldEntries, resolveFieldValues } from "./reference-field-map.js";
|
|
3
4
|
import { navigateJsonPointer } from "./schema-compat.js";
|
|
4
5
|
import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
5
6
|
import { DiagnosticSeverity } from "./types.js";
|
|
7
|
+
import { isModuleKind } from "./module-kinds.js";
|
|
6
8
|
const SOURCE = "telo-analyzer";
|
|
7
9
|
/**
|
|
8
10
|
* Checks whether `kind` satisfies the ref constraint in `entry`.
|
|
@@ -94,10 +96,41 @@ export function validateReferences(resources, context) {
|
|
|
94
96
|
// source, different sourceLine) keep separate fingerprints and still
|
|
95
97
|
// trip the diagnostic. `analyze()` enforces that every non-system
|
|
96
98
|
// manifest carries both positional fields — no defensive guard needed.
|
|
99
|
+
// Forwarded foreign exports (an imported library's exported instances, carrying a
|
|
100
|
+
// metadata.module that isn't a root module) are resolution TARGETS only: excluded from
|
|
101
|
+
// duplicate detection and local name resolution, and never walked as ref sources.
|
|
102
|
+
const rootModules = new Set();
|
|
103
|
+
for (const r of resources) {
|
|
104
|
+
if (isModuleKind(r.kind) && typeof r.metadata?.name === "string") {
|
|
105
|
+
rootModules.add(r.metadata.name);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const moduleOf = (r) => r.metadata?.module;
|
|
109
|
+
const isForeign = (r) => {
|
|
110
|
+
const mod = moduleOf(r);
|
|
111
|
+
return typeof mod === "string" && !rootModules.has(mod);
|
|
112
|
+
};
|
|
113
|
+
const byModuleName = new Map();
|
|
114
|
+
for (const r of resources) {
|
|
115
|
+
if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || !isForeign(r))
|
|
116
|
+
continue;
|
|
117
|
+
byModuleName.set(`${moduleOf(r)}\0${r.metadata.name}`, r);
|
|
118
|
+
}
|
|
119
|
+
/** Whether the analysis set actually carries the named module's exported instances.
|
|
120
|
+
* False in partial single-file analysis (the import subtree wasn't loaded) — used to
|
|
121
|
+
* skip cross-module resolution checks rather than emit false `UNRESOLVED_REFERENCE`. */
|
|
122
|
+
const moduleLoaded = (module) => {
|
|
123
|
+
const prefix = `${module}\0`;
|
|
124
|
+
for (const key of byModuleName.keys())
|
|
125
|
+
if (key.startsWith(prefix))
|
|
126
|
+
return true;
|
|
127
|
+
return false;
|
|
128
|
+
};
|
|
129
|
+
const localResources = resources.filter((r) => !isForeign(r));
|
|
97
130
|
const byNameAll = new Map();
|
|
98
131
|
const seen = new Set();
|
|
99
132
|
for (const r of resources) {
|
|
100
|
-
if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import")
|
|
133
|
+
if (!r.metadata?.name || SYSTEM_KINDS.has(r.kind) || r.kind === "Telo.Import" || isForeign(r))
|
|
101
134
|
continue;
|
|
102
135
|
const name = r.metadata.name;
|
|
103
136
|
// `analyze()` guarantees both fields are present on non-system manifests.
|
|
@@ -139,6 +172,32 @@ export function validateReferences(resources, context) {
|
|
|
139
172
|
});
|
|
140
173
|
}
|
|
141
174
|
}
|
|
175
|
+
// A resource name must contain no dot. The `!ref` resolver splits the tag's source on
|
|
176
|
+
// the first dot to separate an import alias from the resource name, so a dotted name
|
|
177
|
+
// would mis-resolve into a cross-module lookup. This is the load-bearing invariant of
|
|
178
|
+
// the reference grammar, so it is enforced here rather than left to the (unenforced)
|
|
179
|
+
// casing convention.
|
|
180
|
+
for (const [name, list] of byNameAll) {
|
|
181
|
+
if (!name.includes("."))
|
|
182
|
+
continue;
|
|
183
|
+
for (const r of list) {
|
|
184
|
+
const m = r.metadata;
|
|
185
|
+
const range = typeof m?.sourceLine === "number"
|
|
186
|
+
? {
|
|
187
|
+
start: { line: m.sourceLine, character: 0 },
|
|
188
|
+
end: { line: m.sourceLine, character: Number.MAX_SAFE_INTEGER },
|
|
189
|
+
}
|
|
190
|
+
: undefined;
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
severity: DiagnosticSeverity.Error,
|
|
193
|
+
code: "INVALID_RESOURCE_NAME",
|
|
194
|
+
source: SOURCE,
|
|
195
|
+
message: `${r.kind}/${name}: resource name must not contain '.' — in a '!ref' the '.' separates an import alias from the resource name`,
|
|
196
|
+
...(range ? { range } : {}),
|
|
197
|
+
data: { resource: { kind: r.kind, name }, filePath: m?.source, path: "metadata.name" },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
142
201
|
// Single-resource map for the resolution / scope lookups below — when a
|
|
143
202
|
// collision exists, falling back to the first occurrence keeps the rest
|
|
144
203
|
// of the pass behaving the same as before the duplicate diagnostic was
|
|
@@ -147,142 +206,58 @@ export function validateReferences(resources, context) {
|
|
|
147
206
|
const byName = new Map();
|
|
148
207
|
for (const [name, list] of byNameAll)
|
|
149
208
|
byName.set(name, list[0]);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
:
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const visibleScopeManifests = [];
|
|
187
|
-
if (inScope) {
|
|
188
|
-
for (const [pointer, manifests] of scopeManifestsByPointer) {
|
|
189
|
-
const prefix = pointer.replace(/^\//, "").replace(/\//g, ".");
|
|
190
|
-
if (fieldPath === prefix ||
|
|
191
|
-
fieldPath.startsWith(prefix + ".") ||
|
|
192
|
-
fieldPath.startsWith(prefix + "[")) {
|
|
193
|
-
visibleScopeManifests.push(...manifests);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
198
|
-
if (!val)
|
|
199
|
-
continue;
|
|
200
|
-
// `!ref <name>` sentinel — bare resource name marked at parse time as a
|
|
201
|
-
// reference. Look it up against the slot's x-telo-ref constraint exactly
|
|
202
|
-
// like the legacy bare-string path; the only difference is the value's
|
|
203
|
-
// shape (a TaggedSentinel rather than a raw string), which removed the
|
|
204
|
-
// string/inline ambiguity at the source.
|
|
205
|
-
if (isRefSentinel(val)) {
|
|
206
|
-
const refName = val.source;
|
|
207
|
-
const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
208
|
-
if (!target) {
|
|
209
|
-
diagnostics.push({
|
|
210
|
-
severity: DiagnosticSeverity.Error,
|
|
211
|
-
code: "UNRESOLVED_REFERENCE",
|
|
212
|
-
source: SOURCE,
|
|
213
|
-
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
|
|
214
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
215
|
-
});
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
const kindErrors = checkKind(target.kind, entry, registry, aliases);
|
|
219
|
-
if (kindErrors.length > 0) {
|
|
220
|
-
diagnostics.push({
|
|
221
|
-
severity: DiagnosticSeverity.Error,
|
|
222
|
-
code: "REFERENCE_KIND_MISMATCH",
|
|
223
|
-
source: SOURCE,
|
|
224
|
-
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
225
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
// Name-only reference (plain string) — look up by name to validate.
|
|
231
|
-
// Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
|
|
232
|
-
// extract the resource name from the last dot segment.
|
|
233
|
-
if (typeof val === "string") {
|
|
234
|
-
const lastDot = val.lastIndexOf(".");
|
|
235
|
-
const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
|
|
236
|
-
const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
|
|
237
|
-
const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
238
|
-
if (!target) {
|
|
239
|
-
// Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
|
|
240
|
-
// The resource lives in the imported module's scope and can't be validated here.
|
|
241
|
-
// Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
|
|
242
|
-
// kinds — those must be validated.
|
|
243
|
-
if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
diagnostics.push({
|
|
247
|
-
severity: DiagnosticSeverity.Error,
|
|
248
|
-
code: "UNRESOLVED_REFERENCE",
|
|
249
|
-
source: SOURCE,
|
|
250
|
-
message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
|
|
251
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
252
|
-
});
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
const kindErrors = checkKind(target.kind, entry, registry, aliases);
|
|
256
|
-
if (kindErrors.length > 0) {
|
|
257
|
-
diagnostics.push({
|
|
258
|
-
severity: DiagnosticSeverity.Error,
|
|
259
|
-
code: "REFERENCE_KIND_MISMATCH",
|
|
260
|
-
source: SOURCE,
|
|
261
|
-
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
262
|
-
data: { resource: resourceData, filePath, path: concretePath },
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
continue;
|
|
209
|
+
// Phase 3 — per-ref validation. The walker supplies each ref site already
|
|
210
|
+
// resolved against the schema-from-expanded field map, with its source
|
|
211
|
+
// enclosure (`inScope`) and the scope manifests visible to it — so this
|
|
212
|
+
// handler only validates, it does not re-walk.
|
|
213
|
+
visitManifest(localResources, registry, {
|
|
214
|
+
onRef: (e) => {
|
|
215
|
+
const r = e.source;
|
|
216
|
+
const resourceLabel = `${r.kind}/${r.metadata.name}`;
|
|
217
|
+
const resourceData = { kind: r.kind, name: r.metadata.name };
|
|
218
|
+
const filePath = r.metadata?.source;
|
|
219
|
+
const { value: val, concretePath, entry, visibleScopeManifests } = e;
|
|
220
|
+
// `!ref <name>` sentinel — bare resource name marked at parse time as a
|
|
221
|
+
// reference. Look it up against the slot's x-telo-ref constraint exactly
|
|
222
|
+
// like the legacy bare-string path; the only difference is the value's
|
|
223
|
+
// shape (a TaggedSentinel rather than a raw string), which removed the
|
|
224
|
+
// string/inline ambiguity at the source.
|
|
225
|
+
if (isRefSentinel(val)) {
|
|
226
|
+
const refName = val.source;
|
|
227
|
+
const dot = refName.indexOf(".");
|
|
228
|
+
const aliasPrefix = dot > 0 ? refName.slice(0, dot) : undefined;
|
|
229
|
+
// Cross-module sentinel left unresolved by Phase 2.5 — it qualifies an import
|
|
230
|
+
// alias. If that module's exports are loaded in this analysis, the miss is real
|
|
231
|
+
// (name not in exports.resources, or a typo); if not (partial single-file
|
|
232
|
+
// analysis), skip rather than emit a false UNRESOLVED_REFERENCE.
|
|
233
|
+
if (aliasPrefix && aliasPrefix !== "Self" && aliases.hasAlias(aliasPrefix)) {
|
|
234
|
+
const module = aliases.moduleForAlias(aliasPrefix);
|
|
235
|
+
if (module && !moduleLoaded(module))
|
|
236
|
+
return;
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
severity: DiagnosticSeverity.Error,
|
|
239
|
+
code: "UNRESOLVED_REFERENCE",
|
|
240
|
+
source: SOURCE,
|
|
241
|
+
message: `${resourceLabel}: reference at '${concretePath}' → '${refName}' is not exported by module '${module ?? aliasPrefix}' (add it to exports.resources)`,
|
|
242
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
266
245
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
continue;
|
|
273
|
-
// 1. Structural check
|
|
274
|
-
if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
|
|
246
|
+
// Local reference (bare name or explicit `Self.`-qualified).
|
|
247
|
+
const localName = aliasPrefix === "Self" ? refName.slice(dot + 1) : refName;
|
|
248
|
+
const target = byName.get(localName) ??
|
|
249
|
+
visibleScopeManifests.find((m) => m.metadata?.name === localName);
|
|
250
|
+
if (!target) {
|
|
275
251
|
diagnostics.push({
|
|
276
252
|
severity: DiagnosticSeverity.Error,
|
|
277
|
-
code: "
|
|
253
|
+
code: "UNRESOLVED_REFERENCE",
|
|
278
254
|
source: SOURCE,
|
|
279
|
-
message: `${resourceLabel}: reference at '${concretePath}'
|
|
255
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${localName}' not found`,
|
|
280
256
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
281
257
|
});
|
|
282
|
-
|
|
258
|
+
return;
|
|
283
259
|
}
|
|
284
|
-
|
|
285
|
-
const kindErrors = checkKind(refVal.kind, entry, registry, aliases);
|
|
260
|
+
const kindErrors = checkKind(target.kind, entry, registry, aliases);
|
|
286
261
|
if (kindErrors.length > 0) {
|
|
287
262
|
diagnostics.push({
|
|
288
263
|
severity: DiagnosticSeverity.Error,
|
|
@@ -292,38 +267,118 @@ export function validateReferences(resources, context) {
|
|
|
292
267
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
293
268
|
});
|
|
294
269
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Name-only reference (plain string) — look up by name to validate.
|
|
273
|
+
// Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
|
|
274
|
+
// extract the resource name from the last dot segment.
|
|
275
|
+
if (typeof val === "string") {
|
|
276
|
+
const lastDot = val.lastIndexOf(".");
|
|
277
|
+
const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
|
|
278
|
+
const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
|
|
279
|
+
const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
|
|
280
|
+
if (!target) {
|
|
281
|
+
// Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
|
|
282
|
+
// The resource lives in the imported module's scope and can't be validated here.
|
|
283
|
+
// Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
|
|
284
|
+
// kinds — those must be validated.
|
|
285
|
+
if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
299
288
|
diagnostics.push({
|
|
300
289
|
severity: DiagnosticSeverity.Error,
|
|
301
290
|
code: "UNRESOLVED_REFERENCE",
|
|
302
291
|
source: SOURCE,
|
|
303
|
-
message: `${resourceLabel}: reference at '${concretePath}' → resource '${
|
|
292
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
|
|
304
293
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
305
294
|
});
|
|
295
|
+
return;
|
|
306
296
|
}
|
|
297
|
+
const kindErrors = checkKind(target.kind, entry, registry, aliases);
|
|
298
|
+
if (kindErrors.length > 0) {
|
|
299
|
+
diagnostics.push({
|
|
300
|
+
severity: DiagnosticSeverity.Error,
|
|
301
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
302
|
+
source: SOURCE,
|
|
303
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
304
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
307
308
|
}
|
|
308
|
-
|
|
309
|
-
|
|
309
|
+
if (typeof val !== "object")
|
|
310
|
+
return;
|
|
311
|
+
const refVal = val;
|
|
312
|
+
// Skip inline resources — Phase 2 normalization hasn't run yet.
|
|
313
|
+
if (isInlineResource(refVal))
|
|
314
|
+
return;
|
|
315
|
+
// Polymorphic ref slots (Application `targets`) accept object forms
|
|
316
|
+
// whose references live in nested slots rather than being a `{kind,
|
|
317
|
+
// name}` ref themselves — inline `{ invoke }` and gated `{ ref }`.
|
|
318
|
+
// Those nested refs are validated via their own field-map entries, so
|
|
319
|
+
// skip the item-level structural check here.
|
|
320
|
+
if (typeof refVal.kind !== "string" && ("invoke" in refVal || "ref" in refVal))
|
|
321
|
+
return;
|
|
322
|
+
// 1. Structural check
|
|
323
|
+
if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
|
|
324
|
+
diagnostics.push({
|
|
325
|
+
severity: DiagnosticSeverity.Error,
|
|
326
|
+
code: "INVALID_REFERENCE",
|
|
327
|
+
source: SOURCE,
|
|
328
|
+
message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
|
|
329
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// 2. Kind check
|
|
334
|
+
const kindErrors = checkKind(refVal.kind, entry, registry, aliases);
|
|
335
|
+
if (kindErrors.length > 0) {
|
|
336
|
+
diagnostics.push({
|
|
337
|
+
severity: DiagnosticSeverity.Error,
|
|
338
|
+
code: "REFERENCE_KIND_MISMATCH",
|
|
339
|
+
source: SOURCE,
|
|
340
|
+
message: `${resourceLabel}: reference at '${concretePath}' → ${kindErrors.join("; ")}`,
|
|
341
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
// 3. Resolution check — resource with this name must exist.
|
|
345
|
+
let exists;
|
|
346
|
+
if (typeof refVal.alias === "string" && refVal.alias !== "Self") {
|
|
347
|
+
// Cross-module ref resolved by Phase 2.5. Validate against the forwarded
|
|
348
|
+
// exports when loaded; in partial context (module not loaded) assume resolvable.
|
|
349
|
+
const module = aliases.moduleForAlias(refVal.alias);
|
|
350
|
+
exists =
|
|
351
|
+
!module || !moduleLoaded(module) || byModuleName.has(`${module}\0${refVal.name}`);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
exists =
|
|
355
|
+
byName.has(refVal.name) ||
|
|
356
|
+
visibleScopeManifests.some((m) => m.metadata?.name === refVal.name);
|
|
357
|
+
}
|
|
358
|
+
if (!exists) {
|
|
359
|
+
diagnostics.push({
|
|
360
|
+
severity: DiagnosticSeverity.Error,
|
|
361
|
+
code: "UNRESOLVED_REFERENCE",
|
|
362
|
+
source: SOURCE,
|
|
363
|
+
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refVal.name}' not found`,
|
|
364
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
}, { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true });
|
|
310
369
|
// Phase 3b — x-telo-schema-from validation.
|
|
311
370
|
// For each field with a schemaFrom path expression, resolve the anchor ref to get the
|
|
312
371
|
// concrete kind, navigate the JSON Pointer into that kind's definition schema, and
|
|
313
|
-
// validate the field value against the resulting sub-schema.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
for (const [fieldPath, entry] of fieldMap) {
|
|
324
|
-
if (!isSchemaFromEntry(entry))
|
|
325
|
-
continue;
|
|
326
|
-
const { schemaFrom } = entry;
|
|
372
|
+
// validate the field value against the resulting sub-schema. Driven off the base map
|
|
373
|
+
// (un-expanded) so each schema-from slot is seen as its own site.
|
|
374
|
+
visitManifest(localResources, registry, {
|
|
375
|
+
onSchemaFrom: (e) => {
|
|
376
|
+
const r = e.source;
|
|
377
|
+
const fieldPath = e.fieldPath;
|
|
378
|
+
const resourceLabel = `${r.kind}/${r.metadata.name}`;
|
|
379
|
+
const resourceData = { kind: r.kind, name: r.metadata.name };
|
|
380
|
+
const filePath = r.metadata?.source;
|
|
381
|
+
const { schemaFrom } = e.entry;
|
|
327
382
|
const isAbsolute = schemaFrom.startsWith("/");
|
|
328
383
|
const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
|
|
329
384
|
const slashIdx = expr.indexOf("/");
|
|
@@ -335,7 +390,7 @@ export function validateReferences(resources, context) {
|
|
|
335
390
|
message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
|
|
336
391
|
data: { resource: resourceData, filePath, path: fieldPath },
|
|
337
392
|
});
|
|
338
|
-
|
|
393
|
+
return;
|
|
339
394
|
}
|
|
340
395
|
const anchorName = expr.slice(0, slashIdx);
|
|
341
396
|
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
@@ -360,7 +415,7 @@ export function validateReferences(resources, context) {
|
|
|
360
415
|
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
|
|
361
416
|
data: { resource: resourceData, filePath, path: fieldPath },
|
|
362
417
|
});
|
|
363
|
-
|
|
418
|
+
return;
|
|
364
419
|
}
|
|
365
420
|
const targetDef = registry.resolve(targetKind);
|
|
366
421
|
if (!targetDef?.schema) {
|
|
@@ -371,7 +426,7 @@ export function validateReferences(resources, context) {
|
|
|
371
426
|
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
|
|
372
427
|
data: { resource: resourceData, filePath, path: fieldPath },
|
|
373
428
|
});
|
|
374
|
-
|
|
429
|
+
return;
|
|
375
430
|
}
|
|
376
431
|
const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
|
|
377
432
|
if (subSchema === undefined) {
|
|
@@ -382,7 +437,7 @@ export function validateReferences(resources, context) {
|
|
|
382
437
|
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
|
|
383
438
|
data: { resource: resourceData, filePath, path: fieldPath },
|
|
384
439
|
});
|
|
385
|
-
|
|
440
|
+
return;
|
|
386
441
|
}
|
|
387
442
|
for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
388
443
|
if (fieldValue == null)
|
|
@@ -398,7 +453,7 @@ export function validateReferences(resources, context) {
|
|
|
398
453
|
});
|
|
399
454
|
}
|
|
400
455
|
}
|
|
401
|
-
|
|
456
|
+
return;
|
|
402
457
|
}
|
|
403
458
|
// Derive the anchor path in the resource config.
|
|
404
459
|
let anchorPath;
|
|
@@ -413,7 +468,7 @@ export function validateReferences(resources, context) {
|
|
|
413
468
|
}
|
|
414
469
|
const anchorValues = resolveFieldValues(r, anchorPath);
|
|
415
470
|
if (anchorValues.length === 0)
|
|
416
|
-
|
|
471
|
+
return; // anchor field not set — nothing to validate
|
|
417
472
|
const fieldEntries = resolveFieldEntries(r, fieldPath);
|
|
418
473
|
for (let i = 0; i < fieldEntries.length; i++) {
|
|
419
474
|
const { value: fieldValue, path: concretePath } = fieldEntries[i];
|
|
@@ -460,7 +515,7 @@ export function validateReferences(resources, context) {
|
|
|
460
515
|
});
|
|
461
516
|
}
|
|
462
517
|
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
518
|
+
},
|
|
519
|
+
}, { aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: false });
|
|
465
520
|
return diagnostics;
|
|
466
521
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Environment } from "@marcbachmann/cel-js";
|
|
2
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
3
|
+
import { type AnalysisDiagnostic } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Warn about declared `variables` / `secrets` / `ports` entries that no CEL
|
|
6
|
+
* expression references. A declared-but-unconsumed entry is dead weight at
|
|
7
|
+
* best and misleading at worst (an unbound `ports` entry makes a runner
|
|
8
|
+
* advertise a port the app never listens on).
|
|
9
|
+
*
|
|
10
|
+
* Generic across all three namespaces. References are collected from every CEL
|
|
11
|
+
* expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
|
|
12
|
+
* extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
|
|
13
|
+
* Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
|
|
14
|
+
* `keys(variables)`) yields a chain that stops at the namespace root — that
|
|
15
|
+
* can't be attributed to a name, so the whole namespace is conservatively
|
|
16
|
+
* suppressed to avoid false positives.
|
|
17
|
+
*
|
|
18
|
+
* Application-only: an Application's `variables` / `secrets` / `ports` flow
|
|
19
|
+
* exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
|
|
20
|
+
* so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
|
|
21
|
+
* public input contract consumed by its controllers — invisible to CEL
|
|
22
|
+
* analysis — so they are deliberately not flagged.
|
|
23
|
+
*/
|
|
24
|
+
export declare function validateUnusedDeclarations(manifests: ResourceManifest[], celEnv: Environment): AnalysisDiagnostic[];
|
|
25
|
+
//# sourceMappingURL=validate-unused-declarations.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate-unused-declarations.d.ts","sourceRoot":"","sources":["../src/validate-unused-declarations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAAE,KAAK,kBAAkB,EAAsB,MAAM,YAAY,CAAC;AASzE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,0BAA0B,CACxC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,MAAM,EAAE,WAAW,GAClB,kBAAkB,EAAE,CA2DtB"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { extractAccessChains, INDEX_SEGMENT, walkCelExpressions } from "@telorun/templating";
|
|
2
|
+
import { DiagnosticSeverity } from "./types.js";
|
|
3
|
+
const SOURCE = "telo-analyzer";
|
|
4
|
+
/** Module-doc namespaces whose entries are consumed via `<ns>.<name>` CEL
|
|
5
|
+
* access. One table drives the whole check — adding a namespace is an entry,
|
|
6
|
+
* not a branch. */
|
|
7
|
+
const NAMESPACES = ["variables", "secrets", "ports"];
|
|
8
|
+
/**
|
|
9
|
+
* Warn about declared `variables` / `secrets` / `ports` entries that no CEL
|
|
10
|
+
* expression references. A declared-but-unconsumed entry is dead weight at
|
|
11
|
+
* best and misleading at worst (an unbound `ports` entry makes a runner
|
|
12
|
+
* advertise a port the app never listens on).
|
|
13
|
+
*
|
|
14
|
+
* Generic across all three namespaces. References are collected from every CEL
|
|
15
|
+
* expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
|
|
16
|
+
* extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
|
|
17
|
+
* Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
|
|
18
|
+
* `keys(variables)`) yields a chain that stops at the namespace root — that
|
|
19
|
+
* can't be attributed to a name, so the whole namespace is conservatively
|
|
20
|
+
* suppressed to avoid false positives.
|
|
21
|
+
*
|
|
22
|
+
* Application-only: an Application's `variables` / `secrets` / `ports` flow
|
|
23
|
+
* exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
|
|
24
|
+
* so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
|
|
25
|
+
* public input contract consumed by its controllers — invisible to CEL
|
|
26
|
+
* analysis — so they are deliberately not flagged.
|
|
27
|
+
*/
|
|
28
|
+
export function validateUnusedDeclarations(manifests, celEnv) {
|
|
29
|
+
const moduleManifest = manifests.find((m) => m.kind === "Telo.Application");
|
|
30
|
+
if (!moduleManifest)
|
|
31
|
+
return [];
|
|
32
|
+
const declared = new Map();
|
|
33
|
+
for (const ns of NAMESPACES) {
|
|
34
|
+
const block = moduleManifest[ns];
|
|
35
|
+
if (block && typeof block === "object" && !Array.isArray(block)) {
|
|
36
|
+
const names = Object.keys(block);
|
|
37
|
+
if (names.length > 0)
|
|
38
|
+
declared.set(ns, names);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (declared.size === 0)
|
|
42
|
+
return [];
|
|
43
|
+
const used = new Map(NAMESPACES.map((ns) => [ns, new Set()]));
|
|
44
|
+
const suppressed = new Set();
|
|
45
|
+
for (const m of manifests) {
|
|
46
|
+
walkCelExpressions(m, "", (expr, _path, engineName) => {
|
|
47
|
+
if (engineName !== "cel")
|
|
48
|
+
return;
|
|
49
|
+
let ast;
|
|
50
|
+
try {
|
|
51
|
+
ast = celEnv.parse(expr).ast;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return; // syntax errors are reported by the CEL engine pass
|
|
55
|
+
}
|
|
56
|
+
for (const chain of extractAccessChains(ast)) {
|
|
57
|
+
const ns = chain[0];
|
|
58
|
+
if (!used.has(ns))
|
|
59
|
+
continue;
|
|
60
|
+
const member = chain[1];
|
|
61
|
+
// No static member after the namespace root — either the namespace is
|
|
62
|
+
// used whole (`keys(ports)` → ["ports"]) or accessed dynamically
|
|
63
|
+
// (`ports[x]` → ["ports", "[*]"]). Neither can be attributed to a
|
|
64
|
+
// declared name, so suppress the namespace rather than false-positive.
|
|
65
|
+
if (member === undefined || member === INDEX_SEGMENT)
|
|
66
|
+
suppressed.add(ns);
|
|
67
|
+
else
|
|
68
|
+
used.get(ns).add(member);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const diagnostics = [];
|
|
73
|
+
const filePath = moduleManifest.metadata?.source;
|
|
74
|
+
for (const [ns, names] of declared) {
|
|
75
|
+
if (suppressed.has(ns))
|
|
76
|
+
continue;
|
|
77
|
+
const seen = used.get(ns);
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
if (seen.has(name))
|
|
80
|
+
continue;
|
|
81
|
+
diagnostics.push({
|
|
82
|
+
severity: DiagnosticSeverity.Warning,
|
|
83
|
+
code: "UNUSED_DECLARATION",
|
|
84
|
+
source: SOURCE,
|
|
85
|
+
message: `${ns}.${name} is declared but never referenced in any CEL expression.`,
|
|
86
|
+
data: { filePath, path: `${ns}.${name}` },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return diagnostics;
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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.
|
|
45
|
+
"@telorun/templating": "0.4.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^20.0.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"vitest": "^2.1.8"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@telorun/sdk": "0.
|
|
53
|
+
"@telorun/sdk": "0.13.0"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
56
|
"build": "tsc -p tsconfig.lib.json",
|