@telorun/analyzer 0.56.0 → 0.57.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/analyzer.d.ts +5 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +155 -94
- package/dist/flatten-for-analyzer.d.ts +20 -1
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +48 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/kernel-globals.d.ts +25 -11
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +54 -24
- package/dist/manifest-visitor.d.ts +4 -0
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +3 -3
- package/dist/module-file-claims.d.ts +65 -0
- package/dist/module-file-claims.d.ts.map +1 -0
- package/dist/module-file-claims.js +106 -0
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +12 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/validate-include-placement.d.ts +26 -0
- package/dist/validate-include-placement.d.ts.map +1 -0
- package/dist/validate-include-placement.js +67 -0
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +15 -12
- package/package.json +4 -3
- package/src/analyzer.ts +189 -131
- package/src/flatten-for-analyzer.ts +61 -3
- package/src/index.ts +5 -1
- package/src/kernel-globals.ts +74 -25
- package/src/manifest-visitor.ts +11 -3
- package/src/module-file-claims.ts +168 -0
- package/src/schema-compat.ts +19 -1
- package/src/types.ts +37 -0
- package/src/validate-include-placement.ts +70 -0
- package/src/validate-throws-coverage.ts +16 -11
|
@@ -171,7 +171,7 @@ function checkCatchAllPlacement(entries, resource, channel, filePath, arrayPath)
|
|
|
171
171
|
* in coverage-proving `when:` clauses. Phase 2 accepts inherit/passthrough
|
|
172
172
|
* handler unions too — when the resolved union is unbounded, a catch-all is
|
|
173
173
|
* required (rule 4 extension). */
|
|
174
|
-
function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env) {
|
|
174
|
+
function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handler) {
|
|
175
175
|
const diagnostics = [];
|
|
176
176
|
const declaredCodes = new Set(union.codes.keys());
|
|
177
177
|
const covered = new Set();
|
|
@@ -221,16 +221,19 @@ function checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env
|
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
if (!hasCatchAll) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
224
|
+
// One diagnostic per block, not per code: every uncovered code sits at the
|
|
225
|
+
// same `catches:` array, and one catch-all answers all of them at once. A
|
|
226
|
+
// diagnostic each repeated the same location and the same fix N times.
|
|
227
|
+
const uncovered = [...declaredCodes].filter((c) => !covered.has(c)).sort();
|
|
228
|
+
if (uncovered.length > 0) {
|
|
229
|
+
diagnostics.push({
|
|
230
|
+
severity: DiagnosticSeverity.Error,
|
|
231
|
+
code: "UNCOVERED_THROW_CODE",
|
|
232
|
+
source: SOURCE,
|
|
233
|
+
message: `handler ${handler?.name ? `\`!ref ${handler.name}\`` : `\`${handler?.kind ?? "?"}\``} can throw ${uncovered.length} code${uncovered.length === 1 ? "" : "s"} that no catches: entry handles: ${uncovered.map((c) => `'${c}'`).join(", ")}. ` +
|
|
234
|
+
`Give each a matching \`when:\` (e.g. \`when: !cel "error.code == '${uncovered[0]}'"\`), or add a catch-all entry — one with no \`when:\`, placed last.`,
|
|
235
|
+
data: { resource, filePath, path: arrayPath, uncovered },
|
|
236
|
+
});
|
|
234
237
|
}
|
|
235
238
|
}
|
|
236
239
|
return diagnostics;
|
|
@@ -447,7 +450,7 @@ export function validateThrowsCoverage(manifests, defs, aliases, env, aliasesByM
|
|
|
447
450
|
diagnostics.push(...checkCatchAllPlacement(entries, resource, "catches", filePath, arrayPath));
|
|
448
451
|
const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
|
|
449
452
|
const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
|
|
450
|
-
diagnostics.push(...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env));
|
|
453
|
+
diagnostics.push(...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handlerRef));
|
|
451
454
|
diagnostics.push(...checkTypedErrorData(entries, union, resource, filePath, arrayPath, env));
|
|
452
455
|
});
|
|
453
456
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/analyzer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Telo Analyzer - Static manifest validator for Telo manifests.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -41,14 +41,15 @@
|
|
|
41
41
|
"ajv": "^8.17.1",
|
|
42
42
|
"ajv-formats": "^3.0.1",
|
|
43
43
|
"jsonpath-plus": "^10.3.0",
|
|
44
|
+
"packageurl-js": "^2.0.1",
|
|
44
45
|
"yaml": "^2.8.3",
|
|
45
|
-
"@telorun/templating": "0.
|
|
46
|
+
"@telorun/templating": "0.13.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^20.0.0",
|
|
49
50
|
"typescript": "^5.0.0",
|
|
50
51
|
"vitest": "^2.1.8",
|
|
51
|
-
"@telorun/sdk": "0.
|
|
52
|
+
"@telorun/sdk": "0.72.0"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"@telorun/sdk": "*"
|
package/src/analyzer.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { canonicalTypeSchemaId, OBSERVED_STATE_KEY } from "@telorun/sdk";
|
|
3
3
|
import type { Environment } from "@marcbachmann/cel-js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
defaultRegistry,
|
|
6
|
+
isRefSentinel,
|
|
7
|
+
isTaggedSentinel,
|
|
8
|
+
type CelSurface,
|
|
9
|
+
} from "@telorun/templating";
|
|
10
|
+
import type { DiagnosticFix } from "./types.js";
|
|
5
11
|
import {
|
|
6
12
|
AliasResolver,
|
|
7
13
|
moduleScopedDefResolver,
|
|
@@ -25,7 +31,7 @@ import {
|
|
|
25
31
|
import { buildCallGraph } from "./call-graph.js";
|
|
26
32
|
import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
|
|
27
33
|
import {
|
|
28
|
-
|
|
34
|
+
buildKernelGlobalsIndex,
|
|
29
35
|
KERNEL_GLOBAL_NAMES,
|
|
30
36
|
mergeKernelGlobalsIntoContext,
|
|
31
37
|
} from "./kernel-globals.js";
|
|
@@ -84,6 +90,7 @@ import {
|
|
|
84
90
|
import { validateExtends } from "./validate-extends.js";
|
|
85
91
|
import { validateLogging } from "./validate-logging.js";
|
|
86
92
|
import { validateModuleArtifact } from "./validate-module-artifact.js";
|
|
93
|
+
import { validateIncludePlacement } from "./validate-include-placement.js";
|
|
87
94
|
import { validateModuleMetadata } from "./validate-module-metadata.js";
|
|
88
95
|
import { validateBaseMapping } from "./validate-base-mapping.js";
|
|
89
96
|
import { validateInvocationContract } from "./validate-invocation-contract.js";
|
|
@@ -906,89 +913,62 @@ function celAccessChains(env: Environment, expr: string): string[][] {
|
|
|
906
913
|
const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
|
|
907
914
|
const CEL_EXPR_RE = /\$\{\{\s*([^}]+?)\s*\}\}/;
|
|
908
915
|
|
|
909
|
-
/**
|
|
910
|
-
*
|
|
911
|
-
|
|
916
|
+
/** Restore the delimiters an engine's fix was computed without, so the
|
|
917
|
+
* replacement is the whole scalar rather than a bare expression that would be
|
|
918
|
+
* read back as literal text. A tagged scalar carries no wrapper and passes
|
|
919
|
+
* through untouched. */
|
|
920
|
+
function rewrapFix(
|
|
921
|
+
fix: DiagnosticFix | undefined,
|
|
922
|
+
wrapper: CelSurface["wrapper"],
|
|
923
|
+
): DiagnosticFix | undefined {
|
|
924
|
+
if (!fix || !wrapper) return fix;
|
|
925
|
+
return { replacement: wrapper.prefix + fix.replacement + wrapper.suffix };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** A pure-CEL leaf and the schema of the field it sits in. */
|
|
929
|
+
export interface CelValueSlot {
|
|
930
|
+
readonly path: string;
|
|
931
|
+
readonly schema: Record<string, any>;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/** Recursively walk `data`+`schema` together, collecting every pure CEL leaf
|
|
935
|
+
* with the schema of the field holding it.
|
|
936
|
+
*
|
|
937
|
+
* Type *checking* is deliberately not done here. It belongs to the templating
|
|
938
|
+
* engine, which owns the expression's syntax and now runs it once against the
|
|
939
|
+
* environment typed for that path; checking again here would mean two verdicts
|
|
940
|
+
* from two environments about one expression — which is exactly how an opaque
|
|
941
|
+
* "no matching overload" used to survive next to the diagnostic that explained
|
|
942
|
+
* it. What this walk supplies is the half the engine cannot know: the declared
|
|
943
|
+
* type of the slot the value flows into. The comparison happens once both are
|
|
944
|
+
* in hand (`reportCelReturnMismatches`). */
|
|
945
|
+
function collectCelValueSlots(
|
|
912
946
|
data: unknown,
|
|
913
947
|
schema: Record<string, any>,
|
|
914
948
|
path: string,
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
): SchemaIssue[] {
|
|
921
|
-
const issues: SchemaIssue[] = [];
|
|
922
|
-
|
|
923
|
-
// A pure CEL value type-checks the same regardless of surface form: a
|
|
924
|
-
// `${{ … }}` string and a `!cel`-tagged sentinel must behave identically.
|
|
949
|
+
): CelValueSlot[] {
|
|
950
|
+
const slots: CelValueSlot[] = [];
|
|
951
|
+
|
|
952
|
+
// A pure CEL value behaves the same regardless of surface form: a
|
|
953
|
+
// `${{ … }}` string and a `!cel`-tagged sentinel are the same expression.
|
|
925
954
|
let celExpr: string | undefined;
|
|
926
955
|
if (isTaggedSentinel(data)) {
|
|
927
956
|
// Non-CEL engines (e.g. `!literal`) are analyzed by their own engine pass.
|
|
928
|
-
if (data.engine !== "cel") return
|
|
957
|
+
if (data.engine !== "cel") return slots;
|
|
929
958
|
celExpr = data.source;
|
|
930
959
|
} else if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
931
960
|
celExpr = data.match(CEL_EXPR_RE)?.[1]?.trim();
|
|
932
961
|
}
|
|
933
962
|
|
|
934
963
|
if (celExpr !== undefined) {
|
|
935
|
-
{
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
// Merge x-telo-context variables for this path if applicable
|
|
939
|
-
let typedEnv = baseTypedEnv;
|
|
940
|
-
if (definition.schema) {
|
|
941
|
-
for (const ctx of extractContextsFromSchema(definition.schema)) {
|
|
942
|
-
if (!pathMatchesScope(path, ctx.scope)) continue;
|
|
943
|
-
typedEnv = buildTypedCelEnvironment(rootEnv, manifest, ctx.schema, rootModuleManifest);
|
|
944
|
-
break;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
let checkResult: ReturnType<typeof typedEnv.check> | undefined;
|
|
949
|
-
try {
|
|
950
|
-
checkResult = typedEnv.check(expr);
|
|
951
|
-
} catch {
|
|
952
|
-
/* degrade gracefully */
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
if (checkResult?.valid === false && checkResult.error) {
|
|
956
|
-
// env.check() rejected the expression itself — e.g. wrong method, wrong
|
|
957
|
-
// argument types, wrong operator overload. Surface the first line of the
|
|
958
|
-
// error message; the tail is a source-code caret diagram we don't need.
|
|
959
|
-
const message = String((checkResult.error as { message?: string }).message ?? checkResult.error)
|
|
960
|
-
.split("\n")[0]
|
|
961
|
-
.trim();
|
|
962
|
-
issues.push({ message: `CEL type error: ${message}`, path });
|
|
963
|
-
} else if (checkResult?.valid && checkResult.type && schema) {
|
|
964
|
-
const celType = checkResult.type.split("<")[0]!;
|
|
965
|
-
if (!celTypeSatisfiesJsonSchema(celType, schema)) {
|
|
966
|
-
const expected = schema["x-telo-type"] ?? schema.type ?? "unknown";
|
|
967
|
-
issues.push({
|
|
968
|
-
message: `CEL returns '${checkResult.type}' but field expects '${expected}'`,
|
|
969
|
-
path,
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
return issues;
|
|
964
|
+
if (schema) slots.push({ path, schema });
|
|
965
|
+
return slots;
|
|
975
966
|
}
|
|
976
967
|
|
|
977
968
|
if (Array.isArray(data)) {
|
|
978
969
|
const itemSchema = (schema.items ?? {}) as Record<string, any>;
|
|
979
970
|
for (let i = 0; i < data.length; i++) {
|
|
980
|
-
|
|
981
|
-
...collectCelTypeIssues(
|
|
982
|
-
data[i],
|
|
983
|
-
itemSchema,
|
|
984
|
-
`${path}[${i}]`,
|
|
985
|
-
definition,
|
|
986
|
-
manifest,
|
|
987
|
-
baseTypedEnv,
|
|
988
|
-
rootEnv,
|
|
989
|
-
rootModuleManifest,
|
|
990
|
-
),
|
|
991
|
-
);
|
|
971
|
+
slots.push(...collectCelValueSlots(data[i], itemSchema, `${path}[${i}]`));
|
|
992
972
|
}
|
|
993
973
|
} else if (data !== null && typeof data === "object") {
|
|
994
974
|
const props = (schema.properties ?? {}) as Record<string, any>;
|
|
@@ -997,22 +977,17 @@ function collectCelTypeIssues(
|
|
|
997
977
|
? (schema.additionalProperties as Record<string, any>)
|
|
998
978
|
: {};
|
|
999
979
|
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
1000
|
-
|
|
1001
|
-
...
|
|
980
|
+
slots.push(
|
|
981
|
+
...collectCelValueSlots(
|
|
1002
982
|
v,
|
|
1003
983
|
(props[k] ?? mapValueSchema) as Record<string, any>,
|
|
1004
984
|
path ? `${path}.${k}` : k,
|
|
1005
|
-
definition,
|
|
1006
|
-
manifest,
|
|
1007
|
-
baseTypedEnv,
|
|
1008
|
-
rootEnv,
|
|
1009
|
-
rootModuleManifest,
|
|
1010
985
|
),
|
|
1011
986
|
);
|
|
1012
987
|
}
|
|
1013
988
|
}
|
|
1014
989
|
|
|
1015
|
-
return
|
|
990
|
+
return slots;
|
|
1016
991
|
}
|
|
1017
992
|
|
|
1018
993
|
export interface StaticAnalyzerOptions {
|
|
@@ -1501,6 +1476,9 @@ export class StaticAnalyzer {
|
|
|
1501
1476
|
// these fields, which is precisely why they need a check: a mistyped one
|
|
1502
1477
|
// has no runtime failure mode that would ever surface it.
|
|
1503
1478
|
diagnostics.push(...validateModuleMetadata(allManifests, defs, aliases));
|
|
1479
|
+
// A file embed resolves at resource creation, so one written on a doc that
|
|
1480
|
+
// is never instantiated is read by nothing and would ship silently.
|
|
1481
|
+
diagnostics.push(...validateIncludePlacement(allManifests));
|
|
1504
1482
|
}
|
|
1505
1483
|
resolveSchemaTypeRefs(allManifests, aliases, aliasesByModule);
|
|
1506
1484
|
|
|
@@ -1613,7 +1591,7 @@ export class StaticAnalyzer {
|
|
|
1613
1591
|
|
|
1614
1592
|
// Build typed kernel globals schema so x-telo-context chain validation
|
|
1615
1593
|
// recognises variables, secrets, resources, env automatically
|
|
1616
|
-
const kernelGlobals =
|
|
1594
|
+
const kernelGlobals = buildKernelGlobalsIndex(allManifests, observedState);
|
|
1617
1595
|
|
|
1618
1596
|
// Fallback context for CEL in a slot with no `x-telo-context` annotation:
|
|
1619
1597
|
// everything stays open except the typed `.status` nodes, so unknown-field
|
|
@@ -1632,11 +1610,25 @@ export class StaticAnalyzer {
|
|
|
1632
1610
|
|
|
1633
1611
|
// The module doc (Application/Library) carries the Application-only `ports`
|
|
1634
1612
|
// namespace; threaded into per-resource CEL typing so `${{ ports.X }}`
|
|
1635
|
-
// resolves its nominal brand cross-doc.
|
|
1613
|
+
// resolves its nominal brand cross-doc. A flattened set holds exactly one —
|
|
1614
|
+
// the entry's; see `buildKernelGlobalsSchema`.
|
|
1636
1615
|
const moduleManifest =
|
|
1637
1616
|
allManifests.find((mm) => mm.kind === "Telo.Application") ??
|
|
1638
1617
|
allManifests.find((mm) => mm.kind === "Telo.Library");
|
|
1639
1618
|
|
|
1619
|
+
// Every pure-CEL leaf, with the slot it flows into. Compared against the
|
|
1620
|
+
// type the engine walk resolves, once both halves exist — see
|
|
1621
|
+
// `collectCelValueSlots`.
|
|
1622
|
+
const celReturnSlots: (CelValueSlot & {
|
|
1623
|
+
manifest: ResourceManifest;
|
|
1624
|
+
resource: { kind: string; name: string };
|
|
1625
|
+
filePath?: string;
|
|
1626
|
+
})[] = [];
|
|
1627
|
+
const celTypeByPath = new Map<ResourceManifest, Map<string, string>>();
|
|
1628
|
+
// Context-free typed environments, one per manifest. Reused across every
|
|
1629
|
+
// expression in it — see the build site for why a matched context opts out.
|
|
1630
|
+
const typedEnvByManifest = new Map<ResourceManifest, Environment>();
|
|
1631
|
+
|
|
1640
1632
|
// Validate each non-definition, non-system resource
|
|
1641
1633
|
for (const m of allManifests) {
|
|
1642
1634
|
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
@@ -1721,7 +1713,15 @@ export class StaticAnalyzer {
|
|
|
1721
1713
|
code: "UNDEFINED_KIND",
|
|
1722
1714
|
source: SOURCE,
|
|
1723
1715
|
message: `No Telo.Definition found for kind '${m.kind}'.${hint}`,
|
|
1724
|
-
|
|
1716
|
+
// `suggestedKind` is kept beside the generic `fix` because it names
|
|
1717
|
+
// what the replacement IS; the fix is how to apply it.
|
|
1718
|
+
data: {
|
|
1719
|
+
resource,
|
|
1720
|
+
filePath,
|
|
1721
|
+
path: "kind",
|
|
1722
|
+
suggestedKind,
|
|
1723
|
+
...(suggestedKind ? { fix: { replacement: suggestedKind } } : {}),
|
|
1724
|
+
},
|
|
1725
1725
|
});
|
|
1726
1726
|
continue;
|
|
1727
1727
|
}
|
|
@@ -1748,37 +1748,12 @@ export class StaticAnalyzer {
|
|
|
1748
1748
|
},
|
|
1749
1749
|
}
|
|
1750
1750
|
: authorSchema;
|
|
1751
|
-
// Phase 1: CEL
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
// importer is undefined here and variables/secrets fall back to a permissive `map`
|
|
1758
|
-
// (no false positives) while resources/env stay rejected.
|
|
1759
|
-
const importerModule =
|
|
1760
|
-
m.kind === "Telo.Import"
|
|
1761
|
-
? allManifests.find(
|
|
1762
|
-
(mm) =>
|
|
1763
|
-
(mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
|
|
1764
|
-
(mm.metadata as { name?: string } | undefined)?.name ===
|
|
1765
|
-
(m.metadata as { module?: string } | undefined)?.module,
|
|
1766
|
-
)
|
|
1767
|
-
: undefined;
|
|
1768
|
-
const baseTypedEnv =
|
|
1769
|
-
m.kind === "Telo.Import"
|
|
1770
|
-
? buildImportInputCelEnvironment(this.celEnv, importerModule)
|
|
1771
|
-
: buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
|
|
1772
|
-
const celIssues = collectCelTypeIssues(
|
|
1773
|
-
m,
|
|
1774
|
-
schema,
|
|
1775
|
-
"",
|
|
1776
|
-
definition,
|
|
1777
|
-
m,
|
|
1778
|
-
baseTypedEnv,
|
|
1779
|
-
this.celEnv,
|
|
1780
|
-
moduleManifest,
|
|
1781
|
-
);
|
|
1751
|
+
// Phase 1: collect the pure-CEL leaves and the schema of the slot each
|
|
1752
|
+
// flows into. The expression's own type is resolved later, by the
|
|
1753
|
+
// engine walk that owns type-checking; this half only knows the target.
|
|
1754
|
+
for (const slot of collectCelValueSlots(m, schema, "")) {
|
|
1755
|
+
celReturnSlots.push({ manifest: m, resource, filePath, ...slot });
|
|
1756
|
+
}
|
|
1782
1757
|
// Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
|
|
1783
1758
|
const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
|
|
1784
1759
|
// Phase 4: value slots that must satisfy a type declared elsewhere on
|
|
@@ -1790,7 +1765,7 @@ export class StaticAnalyzer {
|
|
|
1790
1765
|
schema,
|
|
1791
1766
|
allManifests as Record<string, any>[],
|
|
1792
1767
|
);
|
|
1793
|
-
const issues = [...
|
|
1768
|
+
const issues = [...ajvIssues, ...valueSchemaIssues];
|
|
1794
1769
|
for (const issue of issues) {
|
|
1795
1770
|
diagnostics.push({
|
|
1796
1771
|
severity: DiagnosticSeverity.Error,
|
|
@@ -2217,7 +2192,10 @@ export class StaticAnalyzer {
|
|
|
2217
2192
|
});
|
|
2218
2193
|
effectiveContext = mergeKernelGlobalsIntoContext(
|
|
2219
2194
|
withBindingNames(resolvedContext, m as Record<string, any>),
|
|
2220
|
-
|
|
2195
|
+
// Typed in the module that DECLARED this resource — for a manifest
|
|
2196
|
+
// forwarded from an imported library, that is its `moduleGlobals`
|
|
2197
|
+
// stamp, not the consuming application's block.
|
|
2198
|
+
kernelGlobals.forResource(m),
|
|
2221
2199
|
);
|
|
2222
2200
|
} else if (observedStateContext) {
|
|
2223
2201
|
// No `x-telo-context` matched, so nothing was chain-validated here
|
|
@@ -2240,42 +2218,104 @@ export class StaticAnalyzer {
|
|
|
2240
2218
|
});
|
|
2241
2219
|
return;
|
|
2242
2220
|
}
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2221
|
+
// The engine type-checks, so it gets the environment typed for THIS
|
|
2222
|
+
// path — not the bare base one. A `Telo.Import`'s variables/secrets
|
|
2223
|
+
// are a config-only contract evaluated in the IMPORTING module's
|
|
2224
|
+
// scope, so they type from the owning module doc and drop
|
|
2225
|
+
// `resources`/`env`, making a reference to either an error.
|
|
2226
|
+
//
|
|
2227
|
+
// Cached per manifest when no `x-telo-context` applied, which is most
|
|
2228
|
+
// expressions: the environment then depends only on the manifest, so
|
|
2229
|
+
// rebuilding it per expression is pure waste — a clone plus a
|
|
2230
|
+
// re-registration of every variable, on every keystroke in the IDE.
|
|
2231
|
+
// A matched context makes the environment path-specific (its schema is
|
|
2232
|
+
// resolved against the enclosing array item), so those still build
|
|
2233
|
+
// fresh rather than risk one item's types leaking into another's.
|
|
2234
|
+
const cached = effectiveContext === null ? typedEnvByManifest.get(m) : undefined;
|
|
2235
|
+
const typedEnv =
|
|
2236
|
+
cached ??
|
|
2237
|
+
(m.kind === "Telo.Import"
|
|
2238
|
+
? buildImportInputCelEnvironment(
|
|
2239
|
+
this.celEnv,
|
|
2240
|
+
allManifests.find(
|
|
2241
|
+
(mm) =>
|
|
2242
|
+
(mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
|
|
2243
|
+
(mm.metadata as { name?: string } | undefined)?.name ===
|
|
2244
|
+
(m.metadata as { module?: string } | undefined)?.module,
|
|
2245
|
+
),
|
|
2246
|
+
)
|
|
2247
|
+
: buildTypedCelEnvironment(
|
|
2248
|
+
this.celEnv,
|
|
2249
|
+
m,
|
|
2250
|
+
effectiveContext ?? undefined,
|
|
2251
|
+
moduleManifest,
|
|
2252
|
+
));
|
|
2253
|
+
if (effectiveContext === null && !cached) typedEnvByManifest.set(m, typedEnv);
|
|
2254
|
+
|
|
2255
|
+
const result = engine.analyze(expr, { celEnv: typedEnv, contextSchema: effectiveContext });
|
|
2256
|
+
|
|
2257
|
+
if (result.type !== undefined) {
|
|
2258
|
+
let byPath = celTypeByPath.get(m);
|
|
2259
|
+
if (!byPath) celTypeByPath.set(m, (byPath = new Map()));
|
|
2260
|
+
byPath.set(path, result.type);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// A non-deterministic call in a compile-eval field is baked once at
|
|
2264
|
+
// load: `nowIso()` there freezes at boot. Sometimes that is the
|
|
2265
|
+
// intent (a boot timestamp, a run id), so it warns rather than
|
|
2266
|
+
// blocking. The engine reports which calls re-evaluate; the eval mode
|
|
2267
|
+
// is manifest policy and stays here.
|
|
2268
|
+
if (celRuleApplies && evalPathsCover(celCompilePaths, path)) {
|
|
2269
|
+
const volatile = [
|
|
2270
|
+
...new Set(result.calls.filter((c) => c.deterministic === false).map((c) => c.name)),
|
|
2271
|
+
].sort();
|
|
2272
|
+
if (volatile.length > 0) {
|
|
2246
2273
|
diagnostics.push({
|
|
2247
|
-
severity: DiagnosticSeverity.
|
|
2248
|
-
code: "
|
|
2274
|
+
severity: DiagnosticSeverity.Warning,
|
|
2275
|
+
code: "CEL_NONDETERMINISTIC_IN_COMPILE_FIELD",
|
|
2249
2276
|
source: SOURCE,
|
|
2250
|
-
message:
|
|
2277
|
+
message: `${m.kind}/${resource.name}: '${path}' is evaluated once at startup, so ${volatile.map((n) => `\`${n}()\``).join(", ")} ${volatile.length === 1 ? "is" : "are"} baked in at load and never re-evaluated. Move the expression to a field evaluated per call (x-telo-eval: runtime) if it should change over time.`,
|
|
2251
2278
|
data: { resource, filePath, path },
|
|
2252
2279
|
});
|
|
2253
|
-
}
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
for (const f of result.diagnostics) {
|
|
2284
|
+
// A repair is applicable only when the analyzed expression covers
|
|
2285
|
+
// the whole scalar. For one `${{ }}` among literal text, replacing
|
|
2286
|
+
// the node would drop the text around it, so the correction stays
|
|
2287
|
+
// in the message and no fix is stamped.
|
|
2288
|
+
const fix = e.surface.whole ? rewrapFix(f.fix, e.surface.wrapper) : undefined;
|
|
2289
|
+
const data = { resource, filePath, path, ...(fix ? { fix } : {}) };
|
|
2290
|
+
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
2254
2291
|
diagnostics.push({
|
|
2255
2292
|
severity: DiagnosticSeverity.Error,
|
|
2256
|
-
code: "
|
|
2293
|
+
code: "CEL_SYNTAX_ERROR",
|
|
2257
2294
|
source: SOURCE,
|
|
2258
|
-
message:
|
|
2259
|
-
data
|
|
2295
|
+
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
2296
|
+
data,
|
|
2260
2297
|
});
|
|
2261
|
-
} else if (f.code ===
|
|
2298
|
+
} else if (f.code === undefined) {
|
|
2299
|
+
// No code from a future engine — pass the message through, tagged
|
|
2300
|
+
// with a generic ENGINE_DIAGNOSTIC code so downstream filters can
|
|
2301
|
+
// still bucket it.
|
|
2262
2302
|
diagnostics.push({
|
|
2263
2303
|
severity: DiagnosticSeverity.Error,
|
|
2264
|
-
code: "
|
|
2304
|
+
code: "ENGINE_DIAGNOSTIC",
|
|
2265
2305
|
source: SOURCE,
|
|
2266
|
-
message: `${m.kind}/${resource.name}:
|
|
2267
|
-
data
|
|
2306
|
+
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
2307
|
+
data,
|
|
2268
2308
|
});
|
|
2269
2309
|
} else {
|
|
2270
|
-
//
|
|
2271
|
-
//
|
|
2272
|
-
//
|
|
2310
|
+
// Named by ENGINE, not hardcoded to CEL: the seam exists so a
|
|
2311
|
+
// second engine can produce coded findings, and labelling them
|
|
2312
|
+
// `CEL` would misattribute the first one that does.
|
|
2273
2313
|
diagnostics.push({
|
|
2274
2314
|
severity: DiagnosticSeverity.Error,
|
|
2275
|
-
code: f.code
|
|
2315
|
+
code: f.code,
|
|
2276
2316
|
source: SOURCE,
|
|
2277
2317
|
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
2278
|
-
data
|
|
2318
|
+
data,
|
|
2279
2319
|
});
|
|
2280
2320
|
}
|
|
2281
2321
|
}
|
|
@@ -2284,6 +2324,24 @@ export class StaticAnalyzer {
|
|
|
2284
2324
|
{ aliases },
|
|
2285
2325
|
);
|
|
2286
2326
|
|
|
2327
|
+
// The two halves of "does this expression fit the slot it flows into" meet
|
|
2328
|
+
// here: the engine resolved the expression's type during the walk above,
|
|
2329
|
+
// and the schema walk recorded the slot. An expression that failed to check
|
|
2330
|
+
// recorded no type and is already reported by its own diagnostic.
|
|
2331
|
+
for (const slot of celReturnSlots) {
|
|
2332
|
+
const type = celTypeByPath.get(slot.manifest)?.get(slot.path);
|
|
2333
|
+
if (type === undefined) continue;
|
|
2334
|
+
if (celTypeSatisfiesJsonSchema(type.split("<")[0]!, slot.schema)) continue;
|
|
2335
|
+
const expected = slot.schema["x-telo-type"] ?? slot.schema.type ?? "unknown";
|
|
2336
|
+
diagnostics.push({
|
|
2337
|
+
severity: DiagnosticSeverity.Error,
|
|
2338
|
+
code: "CEL_TYPE_ERROR",
|
|
2339
|
+
source: SOURCE,
|
|
2340
|
+
message: `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' returns '${type}' but the field expects '${expected}'.`,
|
|
2341
|
+
data: { resource: slot.resource, filePath: slot.filePath, path: slot.path },
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2287
2345
|
// Validate resource references (Phase 3)
|
|
2288
2346
|
diagnostics.push(
|
|
2289
2347
|
...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
|
|
@@ -41,7 +41,17 @@ export function parseExportEntry(entry: string): ParsedExportEntry {
|
|
|
41
41
|
* `byModuleName` and `!ref Alias.name` resolves, while `validate-references` /
|
|
42
42
|
* the per-resource validation loop never re-walk or re-validate it against the
|
|
43
43
|
* consumer's scope. A consumer that instead emits every module doc as a peer
|
|
44
|
-
* local manifest silently breaks both.
|
|
44
|
+
* local manifest silently breaks both.
|
|
45
|
+
*
|
|
46
|
+
* Dropping the module doc costs the consumer's analysis the one thing a
|
|
47
|
+
* forwarded manifest's CEL still needs: the `variables` / `secrets` / `ports`
|
|
48
|
+
* contract its expressions are evaluated against. That contract is carried
|
|
49
|
+
* across as {@link ModuleGlobals} on each forwarded manifest instead, so the
|
|
50
|
+
* consumer can check those reads in the DECLARING module's scope rather than
|
|
51
|
+
* either skipping them (a diagnostic performed nowhere) or judging them by its
|
|
52
|
+
* own block (a hard error the library author cannot act on). Stamped here
|
|
53
|
+
* because this is the only point where a manifest and its module doc are both
|
|
54
|
+
* in hand — by the time `analyze()` sees the flat list, the doc is gone. */
|
|
45
55
|
export function selectModuleManifestsForAnalysis(
|
|
46
56
|
moduleManifests: ResourceManifest[],
|
|
47
57
|
isRoot: boolean,
|
|
@@ -58,10 +68,12 @@ export function selectModuleManifestsForAnalysis(
|
|
|
58
68
|
exportedResources.add(parseExportEntry(entry).name);
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
const moduleGlobals = readModuleGlobals(libDoc);
|
|
72
|
+
|
|
61
73
|
const out: ResourceManifest[] = [];
|
|
62
74
|
for (const m of moduleManifests) {
|
|
63
75
|
if (m.kind === "Telo.Definition" || m.kind === "Telo.Abstract" || m.kind === "Telo.Import") {
|
|
64
|
-
out.push(m);
|
|
76
|
+
out.push(withModuleGlobals(m, moduleGlobals));
|
|
65
77
|
} else if (
|
|
66
78
|
!isModuleKind(m.kind) &&
|
|
67
79
|
typeof m.metadata?.name === "string" &&
|
|
@@ -69,13 +81,59 @@ export function selectModuleManifestsForAnalysis(
|
|
|
69
81
|
) {
|
|
70
82
|
out.push({
|
|
71
83
|
...m,
|
|
72
|
-
metadata: {
|
|
84
|
+
metadata: {
|
|
85
|
+
...m.metadata,
|
|
86
|
+
forwardedExport: true,
|
|
87
|
+
...(moduleGlobals ? { moduleGlobals } : {}),
|
|
88
|
+
} as ResourceManifest["metadata"],
|
|
73
89
|
});
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
return out;
|
|
77
93
|
}
|
|
78
94
|
|
|
95
|
+
/** The declaring module's config contract, carried on a forwarded manifest so
|
|
96
|
+
* the consumer's analysis can type its CEL globals in the right scope. Only the
|
|
97
|
+
* namespaces whose typing is scope-dependent: `resources` is deliberately
|
|
98
|
+
* absent, see {@link readModuleGlobals}. */
|
|
99
|
+
export interface ModuleGlobals {
|
|
100
|
+
variables?: Record<string, unknown>;
|
|
101
|
+
secrets?: Record<string, unknown>;
|
|
102
|
+
ports?: Record<string, unknown>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read the schema-map blocks off a module doc, or `undefined` when it declares
|
|
106
|
+
* none (the globals then stay permissive, exactly as an absent doc does).
|
|
107
|
+
*
|
|
108
|
+
* `resources` is NOT carried. The flat list holds only this module's EXPORTED
|
|
109
|
+
* instances, so a name list built from it would omit every internal one — and a
|
|
110
|
+
* `with:`-scoped resource is declared inline rather than as a doc, so it would
|
|
111
|
+
* be missing too. Both would read as "no such resource" in a consumer's pass:
|
|
112
|
+
* a false positive worse than the unchecked read it replaced. The consumer
|
|
113
|
+
* leaves a forwarded manifest's `resources` node open and the library's own
|
|
114
|
+
* analysis, which sees every name, does that check. */
|
|
115
|
+
function readModuleGlobals(libDoc: ResourceManifest | undefined): ModuleGlobals | undefined {
|
|
116
|
+
const doc = libDoc as Record<string, any> | undefined;
|
|
117
|
+
if (!doc) return undefined;
|
|
118
|
+
const globals: ModuleGlobals = {};
|
|
119
|
+
for (const key of ["variables", "secrets", "ports"] as const) {
|
|
120
|
+
const block = doc[key];
|
|
121
|
+
if (block && typeof block === "object" && !Array.isArray(block)) globals[key] = block;
|
|
122
|
+
}
|
|
123
|
+
return Object.keys(globals).length > 0 ? globals : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function withModuleGlobals(
|
|
127
|
+
m: ResourceManifest,
|
|
128
|
+
moduleGlobals: ModuleGlobals | undefined,
|
|
129
|
+
): ResourceManifest {
|
|
130
|
+
if (!moduleGlobals) return m;
|
|
131
|
+
return {
|
|
132
|
+
...m,
|
|
133
|
+
metadata: { ...m.metadata, moduleGlobals } as ResourceManifest["metadata"],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
79
137
|
/** Produce the flat manifest list `analyze()` consumes today.
|
|
80
138
|
*
|
|
81
139
|
* Combines the entry module's manifests with `Telo.Definition`,
|
package/src/index.ts
CHANGED
|
@@ -207,6 +207,8 @@ export type {
|
|
|
207
207
|
PlatformAxis,
|
|
208
208
|
PlatformTarget,
|
|
209
209
|
} from "./artifact-selector.js";
|
|
210
|
+
export { collectModuleFileClaims } from "./module-file-claims.js";
|
|
211
|
+
export type { ModuleFileClaim } from "./module-file-claims.js";
|
|
210
212
|
export {
|
|
211
213
|
LayerIndexError,
|
|
212
214
|
matchControllerLayers,
|
|
@@ -224,10 +226,12 @@ export { documentToAst, parseToAst } from "./yaml-ast.js";
|
|
|
224
226
|
export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
|
|
225
227
|
export { CelParseError, buildCelSegments, wrapCelAst } from "./cel-ast.js";
|
|
226
228
|
export type { CelNode, CelSegment } from "./cel-ast.js";
|
|
227
|
-
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
229
|
+
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity, diagnosticFix } from "./types.js";
|
|
228
230
|
export type {
|
|
229
231
|
AnalysisDiagnostic,
|
|
230
232
|
AnalysisOptions,
|
|
233
|
+
DiagnosticData,
|
|
234
|
+
DiagnosticFix,
|
|
231
235
|
LoaderInitOptions,
|
|
232
236
|
LoadOptions,
|
|
233
237
|
ManifestSource,
|