@telorun/analyzer 0.12.0 → 0.13.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/README.md +2 -2
- package/dist/analysis-registry.d.ts +12 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +131 -85
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +25 -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/definition-registry.d.ts +12 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +20 -1
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +41 -62
- 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 +109 -0
- package/dist/manifest-visitor.d.ts.map +1 -0
- package/dist/manifest-visitor.js +110 -0
- package/dist/reference-field-map.d.ts +1 -0
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +1 -1
- package/dist/schema-compat.d.ts +14 -0
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +38 -2
- 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-nested-inline.d.ts +30 -0
- package/dist/validate-nested-inline.d.ts.map +1 -0
- package/dist/validate-nested-inline.js +129 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +117 -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 +2 -2
- package/src/analysis-registry.ts +20 -0
- package/src/analyzer.ts +217 -158
- package/src/builtins.ts +25 -0
- package/src/cel-environment.ts +42 -1
- package/src/definition-registry.ts +20 -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 +251 -0
- package/src/reference-field-map.ts +1 -1
- package/src/schema-compat.ts +38 -2
- package/src/validate-cel-context.ts +50 -0
- package/src/validate-nested-inline.ts +158 -0
- package/src/validate-references.ts +168 -211
- package/src/validate-unused-declarations.ts +95 -0
- package/dist/adapters/http-adapter.d.ts +0 -10
- package/dist/adapters/http-adapter.d.ts.map +0 -1
- package/dist/adapters/http-adapter.js +0 -18
- package/dist/adapters/node-adapter.d.ts +0 -17
- package/dist/adapters/node-adapter.d.ts.map +0 -1
- package/dist/adapters/node-adapter.js +0 -71
- package/dist/adapters/registry-adapter.d.ts +0 -15
- package/dist/adapters/registry-adapter.d.ts.map +0 -1
- package/dist/adapters/registry-adapter.js +0 -53
|
@@ -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.13.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.3.
|
|
45
|
+
"@telorun/templating": "0.3.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^20.0.0",
|
package/src/analysis-registry.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AliasResolver } from "./alias-resolver.js";
|
|
|
3
3
|
import { KERNEL_BUILTINS } from "./builtins.js";
|
|
4
4
|
import { DefinitionRegistry } from "./definition-registry.js";
|
|
5
5
|
import { computeSuggestKind, computeValidUserFacingKinds } from "./kind-suggest.js";
|
|
6
|
+
import { visitManifest as runVisitManifest, type ManifestVisitor } from "./manifest-visitor.js";
|
|
6
7
|
import { isRefEntry, isScopeEntry } from "./reference-field-map.js";
|
|
7
8
|
import type { AnalysisContext } from "./types.js";
|
|
8
9
|
|
|
@@ -62,6 +63,25 @@ export class AnalysisRegistry {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Walks a manifest's annotation sites (refs, scopes, schema-from, CEL) via
|
|
68
|
+
* the shared manifest visitor, bound to this registry's definitions and
|
|
69
|
+
* aliases. The public seam for hosts (editor overview graph, tooling) that
|
|
70
|
+
* need the same site discovery the analyzer's own passes use, without
|
|
71
|
+
* reaching into the internal DefinitionRegistry.
|
|
72
|
+
*/
|
|
73
|
+
visitManifest(
|
|
74
|
+
resources: ResourceManifest[],
|
|
75
|
+
visitor: ManifestVisitor,
|
|
76
|
+
opts?: { skipKinds?: ReadonlySet<string>; expand?: boolean },
|
|
77
|
+
): void {
|
|
78
|
+
runVisitManifest(resources, this.defs, visitor, {
|
|
79
|
+
aliases: this.aliases,
|
|
80
|
+
aliasesByModule: this.aliasesByModule,
|
|
81
|
+
...opts,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
/**
|
|
66
86
|
* Returns the built-in kernel definitions. The underlying DefinitionRegistry already
|
|
67
87
|
* seeds these on construction; this method exposes them so callers (e.g. the kernel's
|
package/src/analyzer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
3
|
-
import { defaultRegistry,
|
|
3
|
+
import { defaultRegistry, isTaggedSentinel } from "@telorun/templating";
|
|
4
4
|
import { AliasResolver } from "./alias-resolver.js";
|
|
5
5
|
import { AnalysisRegistry } from "./analysis-registry.js";
|
|
6
6
|
import {
|
|
@@ -12,6 +12,7 @@ import { DefinitionRegistry } from "./definition-registry.js";
|
|
|
12
12
|
import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
|
|
13
13
|
import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
|
|
14
14
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
15
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
15
16
|
import { isModuleKind } from "./module-kinds.js";
|
|
16
17
|
import { normalizeInlineResources } from "./normalize-inline-resources.js";
|
|
17
18
|
import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
|
|
@@ -25,14 +26,17 @@ import {
|
|
|
25
26
|
} from "./schema-compat.js";
|
|
26
27
|
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisOptions } from "./types.js";
|
|
27
28
|
import {
|
|
29
|
+
extractContextsFromSchema,
|
|
28
30
|
getManifestItem,
|
|
29
31
|
pathMatchesScope,
|
|
30
32
|
resolveContextAnnotations,
|
|
31
33
|
resolveTypeFieldToSchema,
|
|
32
34
|
} from "./validate-cel-context.js";
|
|
33
35
|
import { validateExtends } from "./validate-extends.js";
|
|
36
|
+
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
34
37
|
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
35
38
|
import { validateReferences } from "./validate-references.js";
|
|
39
|
+
import { validateUnusedDeclarations } from "./validate-unused-declarations.js";
|
|
36
40
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
37
41
|
|
|
38
42
|
const SELF_PREFIX = "Self.";
|
|
@@ -186,56 +190,6 @@ function manifestRootForResolver(
|
|
|
186
190
|
};
|
|
187
191
|
}
|
|
188
192
|
|
|
189
|
-
/**
|
|
190
|
-
* Walk a JSON Schema tree and collect all `x-telo-context` annotations,
|
|
191
|
-
* returning them as `{ scope, schema }` pairs using JSONPath-style scopes —
|
|
192
|
-
* the same format the analyzer uses for CEL context validation.
|
|
193
|
-
*
|
|
194
|
-
* Result is sorted by scope specificity (longer scope first) so that the
|
|
195
|
-
* per-expression resolver's first-match-wins logic picks the most-specific
|
|
196
|
-
* context. Without this, a broader ancestor scope (e.g. `$.resources[*]`)
|
|
197
|
-
* could shadow a narrower descendant scope whose activation differs.
|
|
198
|
-
*/
|
|
199
|
-
function extractContextsFromSchema(
|
|
200
|
-
schema: Record<string, any>,
|
|
201
|
-
path = "$",
|
|
202
|
-
): Array<{ scope: string; schema: Record<string, any> }> {
|
|
203
|
-
const all = collectContexts(schema, path);
|
|
204
|
-
return all.sort((a, b) => b.scope.length - a.scope.length);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function collectContexts(
|
|
208
|
-
schema: Record<string, any>,
|
|
209
|
-
path: string,
|
|
210
|
-
): Array<{ scope: string; schema: Record<string, any> }> {
|
|
211
|
-
if (!schema || typeof schema !== "object") return [];
|
|
212
|
-
const results: Array<{ scope: string; schema: Record<string, any> }> = [];
|
|
213
|
-
|
|
214
|
-
if (schema["x-telo-context"]) {
|
|
215
|
-
results.push({ scope: path, schema: schema["x-telo-context"] });
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (schema.properties) {
|
|
219
|
-
for (const [key, value] of Object.entries(schema.properties as Record<string, any>)) {
|
|
220
|
-
results.push(...collectContexts(value, `${path}.${key}`));
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (schema.items && typeof schema.items === "object") {
|
|
225
|
-
results.push(...collectContexts(schema.items, `${path}[*]`));
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
for (const key of ["oneOf", "anyOf", "allOf"] as const) {
|
|
229
|
-
if (Array.isArray(schema[key])) {
|
|
230
|
-
for (const subschema of schema[key]) {
|
|
231
|
-
results.push(...collectContexts(subschema, path));
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return results;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
193
|
/** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
|
|
240
194
|
* Non-refs and unresolved refs pass through unchanged. */
|
|
241
195
|
function resolveLocalRef(
|
|
@@ -438,20 +392,31 @@ function collectCelTypeIssues(
|
|
|
438
392
|
manifest: ResourceManifest,
|
|
439
393
|
baseTypedEnv: Environment,
|
|
440
394
|
rootEnv: Environment,
|
|
395
|
+
rootModuleManifest?: ResourceManifest,
|
|
441
396
|
): SchemaIssue[] {
|
|
442
397
|
const issues: SchemaIssue[] = [];
|
|
443
398
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
399
|
+
// A pure CEL value type-checks the same regardless of surface form: a
|
|
400
|
+
// `${{ … }}` string and a `!cel`-tagged sentinel must behave identically.
|
|
401
|
+
let celExpr: string | undefined;
|
|
402
|
+
if (isTaggedSentinel(data)) {
|
|
403
|
+
// Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
|
|
404
|
+
if (data.engine !== "cel") return issues;
|
|
405
|
+
celExpr = data.source;
|
|
406
|
+
} else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
407
|
+
celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (celExpr !== undefined) {
|
|
411
|
+
{
|
|
412
|
+
const expr = celExpr;
|
|
448
413
|
|
|
449
414
|
// Merge x-telo-context variables for this path if applicable
|
|
450
415
|
let typedEnv = baseTypedEnv;
|
|
451
416
|
if (definition.schema) {
|
|
452
417
|
for (const ctx of extractContextsFromSchema(definition.schema)) {
|
|
453
418
|
if (!pathMatchesScope(path, ctx.scope)) continue;
|
|
454
|
-
typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema);
|
|
419
|
+
typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
|
|
455
420
|
break;
|
|
456
421
|
}
|
|
457
422
|
}
|
|
@@ -474,8 +439,9 @@ function collectCelTypeIssues(
|
|
|
474
439
|
} else if (checkResult?.valid && checkResult.type && schema) {
|
|
475
440
|
const celType = checkResult.type.split("<")[0]!;
|
|
476
441
|
if (!celTypeSatisfiesJsonSchema(celType, schema)) {
|
|
442
|
+
const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
|
|
477
443
|
issues.push({
|
|
478
|
-
message: `CEL returns '${checkResult.type}' but field expects '${
|
|
444
|
+
message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
|
|
479
445
|
path,
|
|
480
446
|
});
|
|
481
447
|
}
|
|
@@ -496,6 +462,7 @@ function collectCelTypeIssues(
|
|
|
496
462
|
manifest,
|
|
497
463
|
baseTypedEnv,
|
|
498
464
|
rootEnv,
|
|
465
|
+
rootModuleManifest,
|
|
499
466
|
),
|
|
500
467
|
);
|
|
501
468
|
}
|
|
@@ -511,6 +478,7 @@ function collectCelTypeIssues(
|
|
|
511
478
|
manifest,
|
|
512
479
|
baseTypedEnv,
|
|
513
480
|
rootEnv,
|
|
481
|
+
rootModuleManifest,
|
|
514
482
|
),
|
|
515
483
|
);
|
|
516
484
|
}
|
|
@@ -698,6 +666,32 @@ export class StaticAnalyzer {
|
|
|
698
666
|
}
|
|
699
667
|
}
|
|
700
668
|
|
|
669
|
+
// Fail loud on definition schemas AJV cannot compile. `validateAgainstSchema`
|
|
670
|
+
// and `validateWithRefs` swallow compile failures (returning no issues),
|
|
671
|
+
// which would silently skip schema validation for every resource of that
|
|
672
|
+
// kind — surface the broken schema once, anchored on the definition itself.
|
|
673
|
+
for (const m of allManifests) {
|
|
674
|
+
if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
|
|
675
|
+
const schema = (m as Record<string, any>).schema;
|
|
676
|
+
if (!schema || typeof schema !== "object") continue;
|
|
677
|
+
const name = m.metadata?.name as string | undefined;
|
|
678
|
+
if (!name) continue;
|
|
679
|
+
const compileError = defs.schemaCompileError(schema as Record<string, any>);
|
|
680
|
+
if (compileError) {
|
|
681
|
+
diagnostics.push({
|
|
682
|
+
severity: DiagnosticSeverity.Error,
|
|
683
|
+
code: "SCHEMA_COMPILE_ERROR",
|
|
684
|
+
source: SOURCE,
|
|
685
|
+
message: `${m.kind}/${name}: definition schema failed to compile: ${compileError}`,
|
|
686
|
+
data: {
|
|
687
|
+
resource: { kind: m.kind, name },
|
|
688
|
+
filePath: (m.metadata as { source?: string } | undefined)?.source,
|
|
689
|
+
path: "schema",
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
701
695
|
// Library env: rejection — `env:` on a Library `variables` / `secrets`
|
|
702
696
|
// entry is forbidden. The Library entry schema is otherwise open so that
|
|
703
697
|
// any JSON Schema property schema is valid; this targeted check produces
|
|
@@ -731,6 +725,13 @@ export class StaticAnalyzer {
|
|
|
731
725
|
// recognises variables, secrets, resources, env automatically
|
|
732
726
|
const kernelGlobals = buildKernelGlobalsSchema(allManifests);
|
|
733
727
|
|
|
728
|
+
// The module doc (Application/Library) carries the Application-only `ports`
|
|
729
|
+
// namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
|
|
730
|
+
// resolves its nominal brand cross-doc.
|
|
731
|
+
const moduleManifest =
|
|
732
|
+
allManifests.find((mm) => mm.kind === "Telo.Application") ??
|
|
733
|
+
allManifests.find((mm) => mm.kind === "Telo.Library");
|
|
734
|
+
|
|
734
735
|
// Validate each non-definition, non-system resource
|
|
735
736
|
for (const m of allManifests) {
|
|
736
737
|
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
@@ -789,8 +790,17 @@ export class StaticAnalyzer {
|
|
|
789
790
|
}
|
|
790
791
|
: definition.schema;
|
|
791
792
|
// Phase 1: CEL type checking — walk data+schema together, check env.check() return types
|
|
792
|
-
const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m);
|
|
793
|
-
const celIssues = collectCelTypeIssues(
|
|
793
|
+
const baseTypedEnv = buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
|
|
794
|
+
const celIssues = collectCelTypeIssues(
|
|
795
|
+
m,
|
|
796
|
+
schema,
|
|
797
|
+
"",
|
|
798
|
+
definition,
|
|
799
|
+
m,
|
|
800
|
+
baseTypedEnv,
|
|
801
|
+
this.celEnv,
|
|
802
|
+
moduleManifest,
|
|
803
|
+
);
|
|
794
804
|
// Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
|
|
795
805
|
const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
|
|
796
806
|
const issues = [...celIssues, ...ajvIssues];
|
|
@@ -805,6 +815,39 @@ export class StaticAnalyzer {
|
|
|
805
815
|
}
|
|
806
816
|
}
|
|
807
817
|
|
|
818
|
+
// Validate inline resources nested inside this resource's body (e.g. a
|
|
819
|
+
// Run.Sequence step's `invoke: { kind, ...config }`). These sit at
|
|
820
|
+
// x-telo-ref slots reached only through local `$ref`s, which the
|
|
821
|
+
// reference field map intentionally does not follow, so they escape both
|
|
822
|
+
// inline-extraction and the per-resource schema check above.
|
|
823
|
+
if (definition.schema) {
|
|
824
|
+
// Resolve inline kinds in the parent resource's scope: direct kind
|
|
825
|
+
// first, then the parent module's own aliases (for resources declared
|
|
826
|
+
// inside an imported module), then the root aliases. Mirrors how the
|
|
827
|
+
// analyzer resolves kinds elsewhere so module-scoped aliases don't
|
|
828
|
+
// produce false UNDEFINED_KIND diagnostics.
|
|
829
|
+
const ownModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
830
|
+
const scopeResolver =
|
|
831
|
+
ownModule && !rootModules.has(ownModule) ? aliasesByModule.get(ownModule) : undefined;
|
|
832
|
+
diagnostics.push(
|
|
833
|
+
...validateNestedInlineResources(
|
|
834
|
+
m,
|
|
835
|
+
definition.schema as Record<string, any>,
|
|
836
|
+
(kind: string) => {
|
|
837
|
+
const direct = defs.resolve(kind);
|
|
838
|
+
if (direct) return direct;
|
|
839
|
+
const viaScope = scopeResolver?.resolveKind(kind);
|
|
840
|
+
if (viaScope) {
|
|
841
|
+
const scoped = defs.resolve(viaScope);
|
|
842
|
+
if (scoped) return scoped;
|
|
843
|
+
}
|
|
844
|
+
const viaRoot = aliases.resolveKind(kind);
|
|
845
|
+
return viaRoot ? defs.resolve(viaRoot) : undefined;
|
|
846
|
+
},
|
|
847
|
+
),
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
808
851
|
// (Invocation context compatibility check is handled via x-telo-context in the CEL pass below)
|
|
809
852
|
}
|
|
810
853
|
|
|
@@ -899,114 +942,127 @@ export class StaticAnalyzer {
|
|
|
899
942
|
}
|
|
900
943
|
}
|
|
901
944
|
|
|
902
|
-
// Validate CEL syntax and context variable access in all manifests
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
945
|
+
// Validate CEL syntax and context variable access in all manifests. The
|
|
946
|
+
// walker discovers every compiled CEL node by scanning the value tree and
|
|
947
|
+
// hands back the `x-telo-context` schema matched at the enclosing path; the
|
|
948
|
+
// per-path resolution (step context, kernel-globals merge, x-telo-context-*
|
|
949
|
+
// annotation resolution) stays here because it depends on analyzer-internal
|
|
950
|
+
// state (definitions, aliases, the typed CEL env).
|
|
951
|
+
// Per-resource state computed at enter and read by that resource's CEL
|
|
952
|
+
// sites. The manifest / resource / filePath come straight off each CelSite's
|
|
953
|
+
// `source` (no need to capture them); only the derived step / invocation
|
|
954
|
+
// context — which require analyzer state to build — are stashed here.
|
|
955
|
+
let celStepContextSchema: Record<string, any> | undefined;
|
|
956
|
+
let celInvocationContext: Record<string, any> | undefined;
|
|
957
|
+
|
|
958
|
+
visitManifest(
|
|
959
|
+
allManifests,
|
|
960
|
+
defs,
|
|
961
|
+
{
|
|
962
|
+
onResourceEnter: (e) => {
|
|
963
|
+
const m = e.source;
|
|
964
|
+
celInvocationContext = (m.metadata as any)?.xTeloInvocationContext as
|
|
965
|
+
| Record<string, any>
|
|
966
|
+
| undefined;
|
|
967
|
+
celStepContextSchema = e.definition?.schema
|
|
968
|
+
? buildStepContextSchema(
|
|
969
|
+
m as Record<string, any>,
|
|
970
|
+
e.definition.schema as Record<string, any>,
|
|
971
|
+
allManifests as Record<string, any>[],
|
|
972
|
+
defs,
|
|
973
|
+
aliases,
|
|
974
|
+
)
|
|
975
|
+
: undefined;
|
|
976
|
+
},
|
|
977
|
+
onCel: (e) => {
|
|
978
|
+
const m = e.source;
|
|
979
|
+
const resource = { kind: m.kind, name: m.metadata?.name as string };
|
|
980
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
981
|
+
const { expr, path, engineName, matchedScope } = e;
|
|
982
|
+
|
|
983
|
+
let matchedContext: Record<string, any> | undefined =
|
|
984
|
+
e.contextSchema ?? celInvocationContext;
|
|
985
|
+
|
|
986
|
+
if (celStepContextSchema) {
|
|
987
|
+
const base =
|
|
988
|
+
matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
989
|
+
matchedContext = {
|
|
990
|
+
...base,
|
|
991
|
+
properties: {
|
|
992
|
+
...(base.properties ?? {}),
|
|
993
|
+
steps: celStepContextSchema,
|
|
994
|
+
},
|
|
995
|
+
};
|
|
940
996
|
}
|
|
941
|
-
}
|
|
942
|
-
if (!matchedContext) matchedContext = invocationContext;
|
|
943
|
-
|
|
944
|
-
if (stepContextSchema) {
|
|
945
|
-
const base = matchedContext ?? { type: "object", properties: {}, additionalProperties: true };
|
|
946
|
-
matchedContext = {
|
|
947
|
-
...base,
|
|
948
|
-
properties: {
|
|
949
|
-
...(base.properties ?? {}),
|
|
950
|
-
steps: stepContextSchema,
|
|
951
|
-
},
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
let effectiveContext: Record<string, any> | null = null;
|
|
956
|
-
if (matchedContext) {
|
|
957
|
-
const manifestItem = matchedScope
|
|
958
|
-
? getManifestItem(path, matchedScope, m as Record<string, any>)
|
|
959
|
-
: (m as Record<string, any>);
|
|
960
|
-
const rootForResolver = manifestRootForResolver(
|
|
961
|
-
m as Record<string, any>,
|
|
962
|
-
defs,
|
|
963
|
-
aliases,
|
|
964
|
-
allManifests as Record<string, any>[],
|
|
965
|
-
);
|
|
966
|
-
const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
|
|
967
|
-
manifestRoot: rootForResolver,
|
|
968
|
-
defs,
|
|
969
|
-
aliases,
|
|
970
|
-
allManifests: allManifests as Record<string, any>[],
|
|
971
|
-
});
|
|
972
|
-
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
973
|
-
}
|
|
974
997
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
source: SOURCE,
|
|
992
|
-
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
993
|
-
data: { resource, filePath, path },
|
|
998
|
+
let effectiveContext: Record<string, any> | null = null;
|
|
999
|
+
if (matchedContext) {
|
|
1000
|
+
const manifestItem = matchedScope
|
|
1001
|
+
? getManifestItem(path, matchedScope, m as Record<string, any>)
|
|
1002
|
+
: (m as Record<string, any>);
|
|
1003
|
+
const rootForResolver = manifestRootForResolver(
|
|
1004
|
+
m as Record<string, any>,
|
|
1005
|
+
defs,
|
|
1006
|
+
aliases,
|
|
1007
|
+
allManifests as Record<string, any>[],
|
|
1008
|
+
);
|
|
1009
|
+
const resolvedContext = resolveContextAnnotations(matchedContext, manifestItem, {
|
|
1010
|
+
manifestRoot: rootForResolver,
|
|
1011
|
+
defs,
|
|
1012
|
+
aliases,
|
|
1013
|
+
allManifests: allManifests as Record<string, any>[],
|
|
994
1014
|
});
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1015
|
+
effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const engine = defaultRegistry().get(engineName);
|
|
1019
|
+
if (!engine) {
|
|
1020
|
+
// No registered engine owns this tag — the expression would go
|
|
1021
|
+
// entirely unanalyzed. Surface it rather than skipping silently.
|
|
999
1022
|
diagnostics.push({
|
|
1000
1023
|
severity: DiagnosticSeverity.Error,
|
|
1001
|
-
code:
|
|
1024
|
+
code: "UNKNOWN_ENGINE",
|
|
1002
1025
|
source: SOURCE,
|
|
1003
|
-
message: `${m.kind}/${resource.name}: !${engineName} at '${path}'
|
|
1026
|
+
message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
|
|
1004
1027
|
data: { resource, filePath, path },
|
|
1005
1028
|
});
|
|
1029
|
+
return;
|
|
1006
1030
|
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1031
|
+
const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
|
|
1032
|
+
for (const f of findings) {
|
|
1033
|
+
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
1034
|
+
diagnostics.push({
|
|
1035
|
+
severity: DiagnosticSeverity.Error,
|
|
1036
|
+
code: "CEL_SYNTAX_ERROR",
|
|
1037
|
+
source: SOURCE,
|
|
1038
|
+
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
1039
|
+
data: { resource, filePath, path },
|
|
1040
|
+
});
|
|
1041
|
+
} else if (f.code === "CEL_UNKNOWN_FIELD") {
|
|
1042
|
+
diagnostics.push({
|
|
1043
|
+
severity: DiagnosticSeverity.Error,
|
|
1044
|
+
code: "CEL_UNKNOWN_FIELD",
|
|
1045
|
+
source: SOURCE,
|
|
1046
|
+
message: `${m.kind}/${resource.name}: CEL at '${path}': ${f.message}`,
|
|
1047
|
+
data: { resource, filePath, path },
|
|
1048
|
+
});
|
|
1049
|
+
} else {
|
|
1050
|
+
// Unknown code from a future engine — pass the message through,
|
|
1051
|
+
// tagged with a generic ENGINE_DIAGNOSTIC code so downstream
|
|
1052
|
+
// filters can still bucket it.
|
|
1053
|
+
diagnostics.push({
|
|
1054
|
+
severity: DiagnosticSeverity.Error,
|
|
1055
|
+
code: f.code ?? "ENGINE_DIAGNOSTIC",
|
|
1056
|
+
source: SOURCE,
|
|
1057
|
+
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
1058
|
+
data: { resource, filePath, path },
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
{ aliases },
|
|
1065
|
+
);
|
|
1010
1066
|
|
|
1011
1067
|
// Validate resource references (Phase 3)
|
|
1012
1068
|
diagnostics.push(
|
|
@@ -1022,6 +1078,9 @@ export class StaticAnalyzer {
|
|
|
1022
1078
|
// Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
|
|
1023
1079
|
diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
|
|
1024
1080
|
|
|
1081
|
+
// Warn about declared variables / secrets / ports that no CEL references.
|
|
1082
|
+
diagnostics.push(...validateUnusedDeclarations(allManifests, this.celEnv));
|
|
1083
|
+
|
|
1025
1084
|
// Reroute diagnostics on synthetic (inline-extracted) resources back to
|
|
1026
1085
|
// the chain root so position-index lookups land on the parent doc.
|
|
1027
1086
|
return rewriteSyntheticOrigins(diagnostics, allManifests);
|
package/src/builtins.ts
CHANGED
|
@@ -279,6 +279,31 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
279
279
|
},
|
|
280
280
|
},
|
|
281
281
|
},
|
|
282
|
+
// Inbound ports the Application listens on. A name-keyed map mirroring
|
|
283
|
+
// `variables`: each entry binds a host env var (`env:`) that supplies a
|
|
284
|
+
// port integer (implicitly typed `integer`, 1–65535), with an optional
|
|
285
|
+
// `default:` used when the env var is unset. `protocol:` (default `tcp`)
|
|
286
|
+
// selects the transport — the runner reads this list to know the
|
|
287
|
+
// exposed ports before launch, and the analyzer brands the resolved
|
|
288
|
+
// `ports.<name>` value (tcp → TcpPort, udp → UdpPort) for static wiring
|
|
289
|
+
// checks. Application-only. See kernel/nodejs/src/application-env.ts.
|
|
290
|
+
ports: {
|
|
291
|
+
type: "object",
|
|
292
|
+
additionalProperties: {
|
|
293
|
+
type: "object",
|
|
294
|
+
required: ["env"],
|
|
295
|
+
properties: {
|
|
296
|
+
env: { type: "string" },
|
|
297
|
+
protocol: {
|
|
298
|
+
type: "string",
|
|
299
|
+
enum: ["tcp", "udp"],
|
|
300
|
+
default: "tcp",
|
|
301
|
+
},
|
|
302
|
+
default: { type: "integer", minimum: 1, maximum: 65535 },
|
|
303
|
+
},
|
|
304
|
+
additionalProperties: false,
|
|
305
|
+
},
|
|
306
|
+
},
|
|
282
307
|
},
|
|
283
308
|
required: ["metadata"],
|
|
284
309
|
additionalProperties: false,
|