@telorun/analyzer 0.56.1 → 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/dist/analyzer.d.ts +5 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +146 -90
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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 +181 -127
- package/src/index.ts +5 -1
- 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
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,
|
|
@@ -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
|
|
|
@@ -1638,6 +1616,19 @@ export class StaticAnalyzer {
|
|
|
1638
1616
|
allManifests.find((mm) => mm.kind === "Telo.Application") ??
|
|
1639
1617
|
allManifests.find((mm) => mm.kind === "Telo.Library");
|
|
1640
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
|
+
|
|
1641
1632
|
// Validate each non-definition, non-system resource
|
|
1642
1633
|
for (const m of allManifests) {
|
|
1643
1634
|
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
@@ -1722,7 +1713,15 @@ export class StaticAnalyzer {
|
|
|
1722
1713
|
code: "UNDEFINED_KIND",
|
|
1723
1714
|
source: SOURCE,
|
|
1724
1715
|
message: `No Telo.Definition found for kind '${m.kind}'.${hint}`,
|
|
1725
|
-
|
|
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
|
+
},
|
|
1726
1725
|
});
|
|
1727
1726
|
continue;
|
|
1728
1727
|
}
|
|
@@ -1749,37 +1748,12 @@ export class StaticAnalyzer {
|
|
|
1749
1748
|
},
|
|
1750
1749
|
}
|
|
1751
1750
|
: authorSchema;
|
|
1752
|
-
// Phase 1: CEL
|
|
1753
|
-
//
|
|
1754
|
-
//
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
// importer is undefined here and variables/secrets fall back to a permissive `map`
|
|
1759
|
-
// (no false positives) while resources/env stay rejected.
|
|
1760
|
-
const importerModule =
|
|
1761
|
-
m.kind === "Telo.Import"
|
|
1762
|
-
? allManifests.find(
|
|
1763
|
-
(mm) =>
|
|
1764
|
-
(mm.kind === "Telo.Application" || mm.kind === "Telo.Library") &&
|
|
1765
|
-
(mm.metadata as { name?: string } | undefined)?.name ===
|
|
1766
|
-
(m.metadata as { module?: string } | undefined)?.module,
|
|
1767
|
-
)
|
|
1768
|
-
: undefined;
|
|
1769
|
-
const baseTypedEnv =
|
|
1770
|
-
m.kind === "Telo.Import"
|
|
1771
|
-
? buildImportInputCelEnvironment(this.celEnv, importerModule)
|
|
1772
|
-
: buildTypedCelEnvironment(this.celEnv, m, undefined, moduleManifest);
|
|
1773
|
-
const celIssues = collectCelTypeIssues(
|
|
1774
|
-
m,
|
|
1775
|
-
schema,
|
|
1776
|
-
"",
|
|
1777
|
-
definition,
|
|
1778
|
-
m,
|
|
1779
|
-
baseTypedEnv,
|
|
1780
|
-
this.celEnv,
|
|
1781
|
-
moduleManifest,
|
|
1782
|
-
);
|
|
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
|
+
}
|
|
1783
1757
|
// Phase 2+3: AJV on substituted data — CEL fields replaced with typed placeholders
|
|
1784
1758
|
const ajvIssues = validateAgainstSchema(substituteCelFields(m, schema), schema);
|
|
1785
1759
|
// Phase 4: value slots that must satisfy a type declared elsewhere on
|
|
@@ -1791,7 +1765,7 @@ export class StaticAnalyzer {
|
|
|
1791
1765
|
schema,
|
|
1792
1766
|
allManifests as Record<string, any>[],
|
|
1793
1767
|
);
|
|
1794
|
-
const issues = [...
|
|
1768
|
+
const issues = [...ajvIssues, ...valueSchemaIssues];
|
|
1795
1769
|
for (const issue of issues) {
|
|
1796
1770
|
diagnostics.push({
|
|
1797
1771
|
severity: DiagnosticSeverity.Error,
|
|
@@ -2244,42 +2218,104 @@ export class StaticAnalyzer {
|
|
|
2244
2218
|
});
|
|
2245
2219
|
return;
|
|
2246
2220
|
}
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
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) {
|
|
2250
2273
|
diagnostics.push({
|
|
2251
|
-
severity: DiagnosticSeverity.
|
|
2252
|
-
code: "
|
|
2274
|
+
severity: DiagnosticSeverity.Warning,
|
|
2275
|
+
code: "CEL_NONDETERMINISTIC_IN_COMPILE_FIELD",
|
|
2253
2276
|
source: SOURCE,
|
|
2254
|
-
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.`,
|
|
2255
2278
|
data: { resource, filePath, path },
|
|
2256
2279
|
});
|
|
2257
|
-
}
|
|
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") {
|
|
2258
2291
|
diagnostics.push({
|
|
2259
2292
|
severity: DiagnosticSeverity.Error,
|
|
2260
|
-
code: "
|
|
2293
|
+
code: "CEL_SYNTAX_ERROR",
|
|
2261
2294
|
source: SOURCE,
|
|
2262
|
-
message:
|
|
2263
|
-
data
|
|
2295
|
+
message: `CEL syntax error at ${path}: ${f.message}`,
|
|
2296
|
+
data,
|
|
2264
2297
|
});
|
|
2265
|
-
} 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.
|
|
2266
2302
|
diagnostics.push({
|
|
2267
2303
|
severity: DiagnosticSeverity.Error,
|
|
2268
|
-
code: "
|
|
2304
|
+
code: "ENGINE_DIAGNOSTIC",
|
|
2269
2305
|
source: SOURCE,
|
|
2270
|
-
message: `${m.kind}/${resource.name}:
|
|
2271
|
-
data
|
|
2306
|
+
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
2307
|
+
data,
|
|
2272
2308
|
});
|
|
2273
2309
|
} else {
|
|
2274
|
-
//
|
|
2275
|
-
//
|
|
2276
|
-
//
|
|
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.
|
|
2277
2313
|
diagnostics.push({
|
|
2278
2314
|
severity: DiagnosticSeverity.Error,
|
|
2279
|
-
code: f.code
|
|
2315
|
+
code: f.code,
|
|
2280
2316
|
source: SOURCE,
|
|
2281
2317
|
message: `${m.kind}/${resource.name}: !${engineName} at '${path}': ${f.message}`,
|
|
2282
|
-
data
|
|
2318
|
+
data,
|
|
2283
2319
|
});
|
|
2284
2320
|
}
|
|
2285
2321
|
}
|
|
@@ -2288,6 +2324,24 @@ export class StaticAnalyzer {
|
|
|
2288
2324
|
{ aliases },
|
|
2289
2325
|
);
|
|
2290
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
|
+
|
|
2291
2345
|
// Validate resource references (Phase 3)
|
|
2292
2346
|
diagnostics.push(
|
|
2293
2347
|
...validateReferences(allManifests, { aliases, definitions: defs, aliasesByModule }),
|
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,
|
package/src/manifest-visitor.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
isRefSentinel,
|
|
4
|
+
isTaggedSentinel,
|
|
5
|
+
walkCelExpressions,
|
|
6
|
+
type CelSurface,
|
|
7
|
+
} from "@telorun/templating";
|
|
3
8
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
9
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
10
|
import {
|
|
@@ -114,6 +119,9 @@ export interface CelSiteEvent {
|
|
|
114
119
|
contextSchema?: Record<string, any>;
|
|
115
120
|
/** Scope of the matched context (e.g. `$.routes[*].handler`), if matched. */
|
|
116
121
|
matchedScope?: string;
|
|
122
|
+
/** Where `expr` sits in the scalar at `path`, and the delimiters to restore
|
|
123
|
+
* around a corrected expression. See `CelSurface`. */
|
|
124
|
+
surface: CelSurface;
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
export interface ManifestVisitor {
|
|
@@ -352,7 +360,7 @@ export function visitManifest(
|
|
|
352
360
|
|
|
353
361
|
if (wantsCel) {
|
|
354
362
|
const contexts = definition?.schema ? extractContextsFromSchema(definition.schema) : [];
|
|
355
|
-
walkCelExpressions(r, "", (expr, path, engineName) => {
|
|
363
|
+
walkCelExpressions(r, "", (expr, path, engineName, surface) => {
|
|
356
364
|
let contextSchema: Record<string, any> | undefined;
|
|
357
365
|
let matchedScope: string | undefined;
|
|
358
366
|
for (const ctx of contexts) {
|
|
@@ -362,7 +370,7 @@ export function visitManifest(
|
|
|
362
370
|
break;
|
|
363
371
|
}
|
|
364
372
|
}
|
|
365
|
-
visitor.onCel!({ source: r, path, expr, engineName, contextSchema, matchedScope });
|
|
373
|
+
visitor.onCel!({ source: r, path, expr, engineName, contextSchema, matchedScope, surface });
|
|
366
374
|
});
|
|
367
375
|
}
|
|
368
376
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultCustomTags,
|
|
3
|
+
defaultRegistry,
|
|
4
|
+
walkCelExpressions,
|
|
5
|
+
type TemplatingEngineRegistry,
|
|
6
|
+
} from "@telorun/templating";
|
|
7
|
+
import { PackageURL } from "packageurl-js";
|
|
8
|
+
import { parseAllDocuments } from "yaml";
|
|
9
|
+
import { selectorFromQualifiers, selectorKey, type ArtifactSelector } from "./artifact-selector.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One module-relative file a manifest names, and the artifact layer it belongs
|
|
13
|
+
* to.
|
|
14
|
+
*
|
|
15
|
+
* The single answer to "why is this file in the payload", replacing two
|
|
16
|
+
* derivations that happened to agree: publish used to re-parse the manifest with
|
|
17
|
+
* PURL knowledge hardcoded into the CLI, and any second vocabulary — a tag that
|
|
18
|
+
* embeds a file, say — would have had to be added there by hand. Here the
|
|
19
|
+
* knowledge sits with whoever owns the syntax: a controller candidate is read by
|
|
20
|
+
* this module, and a tagged value is read by the engine that owns its tag, via
|
|
21
|
+
* `TemplatingEngine.fileClaims`. Publish maps role to layer and recognises
|
|
22
|
+
* neither.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT hung off `analyze()`. That pass runs over a flattened,
|
|
25
|
+
* import-inclusive manifest set, so its claims would mix in imported libraries'
|
|
26
|
+
* files — whose paths are relative to *their* module and must never join this
|
|
27
|
+
* artifact — and it would make packaging, today derivable offline from manifest
|
|
28
|
+
* text, a product of resolving the whole import graph. This is per-module by
|
|
29
|
+
* construction and needs nothing but the text.
|
|
30
|
+
*
|
|
31
|
+
* Browser-safe, like the rest of the analyzer: parsing and string work only, no
|
|
32
|
+
* filesystem. Whether a claimed file EXISTS is a separate question, asked by the
|
|
33
|
+
* Node-side caller that has a directory to look in.
|
|
34
|
+
*/
|
|
35
|
+
interface ClaimBase {
|
|
36
|
+
/** Module-root-relative POSIX path — relative to the directory holding
|
|
37
|
+
* `telo.yaml`, never to the file the claim was written in. Publish inlines
|
|
38
|
+
* every `include:` partial into the published `telo.yaml`, so a
|
|
39
|
+
* per-file-relative path would change meaning in the artifact. */
|
|
40
|
+
readonly path: string;
|
|
41
|
+
/** Where the claim came from, for diagnostics: the PURL, or `!<tag>` and the
|
|
42
|
+
* path of the value that carried it. */
|
|
43
|
+
readonly origin: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A **discriminated union**, not one shape with optional fields: a controller
|
|
48
|
+
* layer is one per selector and carries sibling patterns, and an assets layer is
|
|
49
|
+
* neither. Optional fields on a single shape put the consumer one `!` away from
|
|
50
|
+
* a crash inside `selectorKey` with no useful message, and let a producer emit a
|
|
51
|
+
* controller claim with no selector that nothing would reject.
|
|
52
|
+
*/
|
|
53
|
+
export type ModuleFileClaim =
|
|
54
|
+
| (ClaimBase & {
|
|
55
|
+
readonly role: "controller";
|
|
56
|
+
readonly selector: ArtifactSelector;
|
|
57
|
+
/** Extra payload patterns that belong in the same layer as this claim —
|
|
58
|
+
* `.gitignore`-style globs over the selected files, matched by the
|
|
59
|
+
* caller, which is the side that knows what was selected. */
|
|
60
|
+
readonly siblings: readonly string[];
|
|
61
|
+
})
|
|
62
|
+
| (ClaimBase & { readonly role: "assets" });
|
|
63
|
+
|
|
64
|
+
/** `pkg:telo/local/<format>?path=…` — the bundled-controller delivery mode.
|
|
65
|
+
* Anything else (`pkg:npm`, `pkg:cargo`) fetches from its own ecosystem and
|
|
66
|
+
* contributes no layer. */
|
|
67
|
+
const BUNDLED_TYPE = "telo";
|
|
68
|
+
const BUNDLED_NAMESPACE = "local";
|
|
69
|
+
|
|
70
|
+
/** Qualifier naming extra files that belong in a controller's layer — what an
|
|
71
|
+
* entry point loads but the manifest cannot otherwise see (a `.wasm` beside its
|
|
72
|
+
* glue, a native library opened at runtime). */
|
|
73
|
+
const SIBLINGS_QUALIFIER = "siblings";
|
|
74
|
+
|
|
75
|
+
/** Normalize a `path=` / sibling value to the manifest-relative POSIX form the
|
|
76
|
+
* file selector returns, so membership is a string comparison. */
|
|
77
|
+
function normalizeRelative(value: string): string {
|
|
78
|
+
return value.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Bundled-controller claims from one document's `controllers:` list. */
|
|
82
|
+
function controllerClaims(json: unknown): ModuleFileClaim[] {
|
|
83
|
+
const candidates = (json as { controllers?: unknown } | null)?.controllers;
|
|
84
|
+
if (!Array.isArray(candidates)) return [];
|
|
85
|
+
const claims: ModuleFileClaim[] = [];
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
if (typeof candidate !== "string") continue;
|
|
88
|
+
let parsed: PackageURL;
|
|
89
|
+
try {
|
|
90
|
+
parsed = PackageURL.fromString(candidate);
|
|
91
|
+
} catch {
|
|
92
|
+
// Not a parseable PURL — claim collection is not the place to reject it;
|
|
93
|
+
// the analyzer's own validation and the controller loader both report it
|
|
94
|
+
// with better context.
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (parsed.type !== BUNDLED_TYPE || parsed.namespace !== BUNDLED_NAMESPACE) continue;
|
|
98
|
+
const entry = parsed.qualifiers?.path;
|
|
99
|
+
if (typeof entry !== "string" || entry === "") continue;
|
|
100
|
+
claims.push({
|
|
101
|
+
role: "controller",
|
|
102
|
+
path: normalizeRelative(entry),
|
|
103
|
+
selector: selectorFromQualifiers(parsed.name, parsed.qualifiers, `controller "${candidate}"`),
|
|
104
|
+
siblings: String(parsed.qualifiers?.[SIBLINGS_QUALIFIER] ?? "")
|
|
105
|
+
.split(",")
|
|
106
|
+
.map((p) => p.trim())
|
|
107
|
+
.filter((p) => p !== ""),
|
|
108
|
+
origin: candidate,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return claims;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Claims contributed by tagged values, asked of the engine that owns each tag.
|
|
115
|
+
* The walk reaches every tagged scalar in the document, so an engine that
|
|
116
|
+
* embeds files is discovered wherever its tag was written.
|
|
117
|
+
*
|
|
118
|
+
* The layer role is assigned HERE, not by the engine: an engine reports what it
|
|
119
|
+
* embeds, and which layer that belongs in is this module's vocabulary. A file a
|
|
120
|
+
* tag embeds is read only when the resource holding it is created, so `assets`
|
|
121
|
+
* — the lazily-fetched layer — is what it is. */
|
|
122
|
+
function taggedClaims(json: unknown, registry: TemplatingEngineRegistry): ModuleFileClaim[] {
|
|
123
|
+
const claims: ModuleFileClaim[] = [];
|
|
124
|
+
walkCelExpressions(json, "", (source, path, engineName) => {
|
|
125
|
+
const engine = registry.get(engineName);
|
|
126
|
+
for (const claim of engine?.fileClaims?.(source) ?? []) {
|
|
127
|
+
claims.push({ role: "assets", path: claim.path, origin: `!${engineName} at '${path}'` });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return claims;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Identity of a claim for de-duplication: the same file claimed twice by two
|
|
134
|
+
* resources is one file in one layer. Role and selector are part of it because
|
|
135
|
+
* a file two controller candidates both claim is genuinely copied into each of
|
|
136
|
+
* their layers — dropping one would leave a platform's layer short a file it
|
|
137
|
+
* declared it needs. */
|
|
138
|
+
function claimKey(claim: ModuleFileClaim): string {
|
|
139
|
+
const selector = claim.role === "controller" ? selectorKey(claim.selector) : "";
|
|
140
|
+
return `${claim.role}\0${selector}\0${claim.path}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Every module-relative file the manifest names, from every syntax that can name
|
|
145
|
+
* one.
|
|
146
|
+
*
|
|
147
|
+
* `manifestText` is one module's `telo.yaml`. Publish passes the text it is
|
|
148
|
+
* about to ship — i.e. after `include:` partials have been inlined — but the
|
|
149
|
+
* answer does not depend on that: claims are root-relative, so collecting them
|
|
150
|
+
* before or after inlining gives the same set.
|
|
151
|
+
*/
|
|
152
|
+
export function collectModuleFileClaims(
|
|
153
|
+
manifestText: string,
|
|
154
|
+
registry: TemplatingEngineRegistry = defaultRegistry(),
|
|
155
|
+
): ModuleFileClaim[] {
|
|
156
|
+
const seen = new Set<string>();
|
|
157
|
+
const claims: ModuleFileClaim[] = [];
|
|
158
|
+
for (const doc of parseAllDocuments(manifestText, { customTags: defaultCustomTags() })) {
|
|
159
|
+
const json = doc.toJSON() as unknown;
|
|
160
|
+
for (const claim of [...controllerClaims(json), ...taggedClaims(json, registry)]) {
|
|
161
|
+
const key = claimKey(claim);
|
|
162
|
+
if (seen.has(key)) continue;
|
|
163
|
+
seen.add(key);
|
|
164
|
+
claims.push(claim);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return claims;
|
|
168
|
+
}
|