@telorun/analyzer 0.11.0 → 0.12.1
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/LICENSE +2 -2
- package/README.md +3 -3
- package/dist/analysis-registry.d.ts +7 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +38 -0
- package/dist/analyzer.d.ts +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +181 -11
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +58 -1
- package/dist/definition-registry.d.ts +12 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +36 -1
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +27 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +9 -11
- package/dist/manifest-loader.d.ts +23 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +66 -3
- package/dist/normalize-inline-resources.d.ts.map +1 -1
- package/dist/normalize-inline-resources.js +26 -14
- package/dist/position-metadata.d.ts +11 -2
- package/dist/position-metadata.d.ts.map +1 -1
- package/dist/position-metadata.js +18 -3
- package/dist/precompile.d.ts.map +1 -1
- package/dist/precompile.js +9 -1
- package/dist/reference-field-map.d.ts +22 -4
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +94 -26
- package/dist/residual-schema.d.ts +23 -0
- package/dist/residual-schema.d.ts.map +1 -0
- package/dist/residual-schema.js +45 -0
- package/dist/resolve-ref-sentinels.d.ts +27 -0
- package/dist/resolve-ref-sentinels.d.ts.map +1 -0
- package/dist/resolve-ref-sentinels.js +114 -0
- package/dist/rewrite-synthetic-origins.d.ts +10 -0
- package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
- package/dist/rewrite-synthetic-origins.js +55 -0
- package/dist/schema-compat.d.ts +11 -1
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +25 -4
- package/dist/system-kinds.d.ts +25 -0
- package/dist/system-kinds.d.ts.map +1 -0
- package/dist/system-kinds.js +34 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cel-context.d.ts +5 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +27 -15
- 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-provider-coherence.d.ts +23 -0
- package/dist/validate-provider-coherence.d.ts.map +1 -0
- package/dist/validate-provider-coherence.js +148 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +141 -36
- package/dist/with-synthetic-positions.d.ts +28 -0
- package/dist/with-synthetic-positions.d.ts.map +1 -0
- package/dist/with-synthetic-positions.js +45 -0
- package/package.json +7 -4
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +190 -13
- package/src/builtins.ts +58 -1
- package/src/definition-registry.ts +35 -1
- package/src/dependency-graph.ts +27 -14
- package/src/index.ts +2 -0
- package/src/kernel-globals.ts +9 -11
- package/src/manifest-loader.ts +69 -4
- package/src/normalize-inline-resources.ts +48 -13
- package/src/position-metadata.ts +18 -3
- package/src/precompile.ts +8 -1
- package/src/reference-field-map.ts +130 -25
- package/src/residual-schema.ts +49 -0
- package/src/resolve-ref-sentinels.ts +127 -0
- package/src/rewrite-synthetic-origins.ts +75 -0
- package/src/schema-compat.ts +25 -4
- package/src/system-kinds.ts +37 -0
- package/src/types.ts +12 -0
- package/src/validate-cel-context.ts +28 -15
- package/src/validate-nested-inline.ts +158 -0
- package/src/validate-provider-coherence.ts +166 -0
- package/src/validate-references.ts +138 -35
- package/src/with-synthetic-positions.ts +48 -0
package/src/analyzer.ts
CHANGED
|
@@ -14,6 +14,9 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
|
|
|
14
14
|
import { computeSuggestKind } from "./kind-suggest.js";
|
|
15
15
|
import { isModuleKind } from "./module-kinds.js";
|
|
16
16
|
import { normalizeInlineResources } from "./normalize-inline-resources.js";
|
|
17
|
+
import { REF_VALIDATION_SKIP_KINDS } from "./system-kinds.js";
|
|
18
|
+
import { resolveRefSentinels } from "./resolve-ref-sentinels.js";
|
|
19
|
+
import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
|
|
17
20
|
import {
|
|
18
21
|
celTypeSatisfiesJsonSchema,
|
|
19
22
|
substituteCelFields,
|
|
@@ -28,11 +31,46 @@ import {
|
|
|
28
31
|
resolveTypeFieldToSchema,
|
|
29
32
|
} from "./validate-cel-context.js";
|
|
30
33
|
import { validateExtends } from "./validate-extends.js";
|
|
34
|
+
import { validateNestedInlineResources } from "./validate-nested-inline.js";
|
|
35
|
+
import { validateProviderCoherence } from "./validate-provider-coherence.js";
|
|
31
36
|
import { validateReferences } from "./validate-references.js";
|
|
32
37
|
import { validateThrowsCoverage } from "./validate-throws-coverage.js";
|
|
33
38
|
|
|
34
39
|
const SELF_PREFIX = "Self.";
|
|
35
40
|
|
|
41
|
+
/**
|
|
42
|
+
* `StaticAnalyzer.analyze()` requires `metadata.source` (non-empty) and
|
|
43
|
+
* `metadata.sourceLine` (number) on every non-system manifest — see the
|
|
44
|
+
* JSDoc on `analyze()`. Production callers stamp these via the `Loader` /
|
|
45
|
+
* `flattenForAnalyzer` / `emitDocsFor` paths; programmatic callers (tests,
|
|
46
|
+
* scripts) should pre-process inputs with `withSyntheticPositions(...)`.
|
|
47
|
+
* Surfacing the violation here turns silent dedup misbehaviour into a
|
|
48
|
+
* loud, actionable error.
|
|
49
|
+
*/
|
|
50
|
+
function assertManifestPositions(manifests: ResourceManifest[]): void {
|
|
51
|
+
for (let i = 0; i < manifests.length; i++) {
|
|
52
|
+
const m = manifests[i];
|
|
53
|
+
if (REF_VALIDATION_SKIP_KINDS.has(m.kind)) continue;
|
|
54
|
+
const meta = m.metadata as { source?: string; sourceLine?: number } | undefined;
|
|
55
|
+
const okSource = typeof meta?.source === "string" && meta.source.length > 0;
|
|
56
|
+
const okLine = typeof meta?.sourceLine === "number";
|
|
57
|
+
if (okSource && okLine) continue;
|
|
58
|
+
const label = `${m.kind}/${m.metadata?.name ?? "(unnamed)"}`;
|
|
59
|
+
const missing = [
|
|
60
|
+
!okSource ? "metadata.source" : null,
|
|
61
|
+
!okLine ? "metadata.sourceLine" : null,
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" and ");
|
|
65
|
+
throw new Error(
|
|
66
|
+
`StaticAnalyzer.analyze(): manifest #${i} (${label}) is missing ${missing}. ` +
|
|
67
|
+
`Real callers stamp positions automatically; programmatic callers ` +
|
|
68
|
+
`(tests, ad-hoc scripts) should pass inputs through ` +
|
|
69
|
+
`\`withSyntheticPositions(manifests)\` before calling analyze().`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
/** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
|
|
37
75
|
* to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
|
|
38
76
|
* the magic alias for "this library's own module" — and other prefixes
|
|
@@ -493,11 +531,27 @@ export class StaticAnalyzer {
|
|
|
493
531
|
this.celEnv = buildCelEnvironment(options.celHandlers);
|
|
494
532
|
}
|
|
495
533
|
|
|
534
|
+
/**
|
|
535
|
+
* Run static analysis over a flattened manifest list.
|
|
536
|
+
*
|
|
537
|
+
* **Contract**: every non-system manifest (anything outside `Telo.Definition`,
|
|
538
|
+
* `Telo.Abstract`) must carry `metadata.source` (non-empty string) and
|
|
539
|
+
* `metadata.sourceLine` (number). The dedup that backs
|
|
540
|
+
* `DUPLICATE_RESOURCE_NAME` reads those fields to tell a pipeline echo
|
|
541
|
+
* apart from a genuine collision, and downstream diagnostic positioning
|
|
542
|
+
* depends on them too. Real callers stamp positions already (the `Loader`,
|
|
543
|
+
* `flattenForAnalyzer`, the telo-editor's `emitDocsFor`, the VSCode
|
|
544
|
+
* extension). Programmatic callers — tests, ad-hoc scripts — should pass
|
|
545
|
+
* their inputs through `withSyntheticPositions(...)` before calling
|
|
546
|
+
* `analyze()`. A missing position throws a clear error rather than
|
|
547
|
+
* silently producing wrong diagnostics.
|
|
548
|
+
*/
|
|
496
549
|
analyze(
|
|
497
550
|
manifests: ResourceManifest[],
|
|
498
551
|
options?: AnalysisOptions,
|
|
499
552
|
registry?: AnalysisRegistry,
|
|
500
553
|
): AnalysisDiagnostic[] {
|
|
554
|
+
assertManifestPositions(manifests);
|
|
501
555
|
const diagnostics: AnalysisDiagnostic[] = [];
|
|
502
556
|
|
|
503
557
|
// Use pre-seeded registries from the provided AnalysisRegistry, or create fresh ones.
|
|
@@ -621,6 +675,22 @@ export class StaticAnalyzer {
|
|
|
621
675
|
// Phase 2: extract inline resources from x-telo-ref slots into first-class manifests
|
|
622
676
|
const allManifests = normalizeInlineResources(manifests, defs, aliases, aliasesByModule);
|
|
623
677
|
|
|
678
|
+
// Phase 2.5: resolve `!ref <name>` sentinels at every ref slot to canonical
|
|
679
|
+
// {kind, name} objects so downstream phases (validation, dependency graph,
|
|
680
|
+
// kernel controllers) see a uniform shape. Runs after normalize so both
|
|
681
|
+
// original and inline-extracted manifests have their sentinels resolved.
|
|
682
|
+
resolveRefSentinels(allManifests, defs, aliases, aliasesByModule);
|
|
683
|
+
|
|
684
|
+
// Trusted-input fast path: when the caller has already attested that
|
|
685
|
+
// this exact manifest set passes analysis (e.g. via the kernel's
|
|
686
|
+
// hash-stamped `.validated.json` cache), skip the validation walk.
|
|
687
|
+
// Registration of identities / aliases / definitions and inline-resource
|
|
688
|
+
// normalisation have already run above; that's all downstream
|
|
689
|
+
// consumers (prepare, init loop) require.
|
|
690
|
+
if (options?.skipValidation) {
|
|
691
|
+
return diagnostics;
|
|
692
|
+
}
|
|
693
|
+
|
|
624
694
|
// Build a name→manifest map for looking up referenced resources
|
|
625
695
|
const byName = new Map<string, ResourceManifest>();
|
|
626
696
|
for (const m of allManifests) {
|
|
@@ -629,6 +699,61 @@ export class StaticAnalyzer {
|
|
|
629
699
|
}
|
|
630
700
|
}
|
|
631
701
|
|
|
702
|
+
// Fail loud on definition schemas AJV cannot compile. `validateAgainstSchema`
|
|
703
|
+
// and `validateWithRefs` swallow compile failures (returning no issues),
|
|
704
|
+
// which would silently skip schema validation for every resource of that
|
|
705
|
+
// kind — surface the broken schema once, anchored on the definition itself.
|
|
706
|
+
for (const m of allManifests) {
|
|
707
|
+
if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
|
|
708
|
+
const schema = (m as Record<string, any>).schema;
|
|
709
|
+
if (!schema || typeof schema !== "object") continue;
|
|
710
|
+
const name = m.metadata?.name as string | undefined;
|
|
711
|
+
if (!name) continue;
|
|
712
|
+
const compileError = defs.schemaCompileError(schema as Record<string, any>);
|
|
713
|
+
if (compileError) {
|
|
714
|
+
diagnostics.push({
|
|
715
|
+
severity: DiagnosticSeverity.Error,
|
|
716
|
+
code: "SCHEMA_COMPILE_ERROR",
|
|
717
|
+
source: SOURCE,
|
|
718
|
+
message: `${m.kind}/${name}: definition schema failed to compile: ${compileError}`,
|
|
719
|
+
data: {
|
|
720
|
+
resource: { kind: m.kind, name },
|
|
721
|
+
filePath: (m.metadata as { source?: string } | undefined)?.source,
|
|
722
|
+
path: "schema",
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Library env: rejection — `env:` on a Library `variables` / `secrets`
|
|
729
|
+
// entry is forbidden. The Library entry schema is otherwise open so that
|
|
730
|
+
// any JSON Schema property schema is valid; this targeted check produces
|
|
731
|
+
// a clear diagnostic instead of a generic "additional property" error.
|
|
732
|
+
for (const m of allManifests) {
|
|
733
|
+
if (m.kind !== "Telo.Library") continue;
|
|
734
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
735
|
+
const moduleName = m.metadata?.name as string | undefined;
|
|
736
|
+
const resource = moduleName ? { kind: m.kind, name: moduleName } : undefined;
|
|
737
|
+
for (const block of ["variables", "secrets"] as const) {
|
|
738
|
+
const entries = (m as Record<string, any>)[block];
|
|
739
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
|
|
740
|
+
for (const [entryName, entry] of Object.entries(entries)) {
|
|
741
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
742
|
+
if ("env" in (entry as Record<string, unknown>)) {
|
|
743
|
+
diagnostics.push({
|
|
744
|
+
severity: DiagnosticSeverity.Error,
|
|
745
|
+
code: "LIBRARY_ENV_KEY_REJECTED",
|
|
746
|
+
source: SOURCE,
|
|
747
|
+
message:
|
|
748
|
+
`Telo.Library ${block}/${entryName}: 'env:' is only permitted on Telo.Application entries. ` +
|
|
749
|
+
`Libraries must receive values from importers via the parent manifest's variables / secrets block.`,
|
|
750
|
+
data: { resource, filePath, path: `${block}.${entryName}.env` },
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
632
757
|
// Build typed kernel globals schema so x-telo-context chain validation
|
|
633
758
|
// recognises variables, secrets, resources, env automatically
|
|
634
759
|
const kernelGlobals = buildKernelGlobalsSchema(allManifests);
|
|
@@ -707,6 +832,39 @@ export class StaticAnalyzer {
|
|
|
707
832
|
}
|
|
708
833
|
}
|
|
709
834
|
|
|
835
|
+
// Validate inline resources nested inside this resource's body (e.g. a
|
|
836
|
+
// Run.Sequence step's `invoke: { kind, ...config }`). These sit at
|
|
837
|
+
// x-telo-ref slots reached only through local `$ref`s, which the
|
|
838
|
+
// reference field map intentionally does not follow, so they escape both
|
|
839
|
+
// inline-extraction and the per-resource schema check above.
|
|
840
|
+
if (definition.schema) {
|
|
841
|
+
// Resolve inline kinds in the parent resource's scope: direct kind
|
|
842
|
+
// first, then the parent module's own aliases (for resources declared
|
|
843
|
+
// inside an imported module), then the root aliases. Mirrors how the
|
|
844
|
+
// analyzer resolves kinds elsewhere so module-scoped aliases don't
|
|
845
|
+
// produce false UNDEFINED_KIND diagnostics.
|
|
846
|
+
const ownModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
847
|
+
const scopeResolver =
|
|
848
|
+
ownModule && !rootModules.has(ownModule) ? aliasesByModule.get(ownModule) : undefined;
|
|
849
|
+
diagnostics.push(
|
|
850
|
+
...validateNestedInlineResources(
|
|
851
|
+
m,
|
|
852
|
+
definition.schema as Record<string, any>,
|
|
853
|
+
(kind: string) => {
|
|
854
|
+
const direct = defs.resolve(kind);
|
|
855
|
+
if (direct) return direct;
|
|
856
|
+
const viaScope = scopeResolver?.resolveKind(kind);
|
|
857
|
+
if (viaScope) {
|
|
858
|
+
const scoped = defs.resolve(viaScope);
|
|
859
|
+
if (scoped) return scoped;
|
|
860
|
+
}
|
|
861
|
+
const viaRoot = aliases.resolveKind(kind);
|
|
862
|
+
return viaRoot ? defs.resolve(viaRoot) : undefined;
|
|
863
|
+
},
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
710
868
|
// (Invocation context compatibility check is handled via x-telo-context in the CEL pass below)
|
|
711
869
|
}
|
|
712
870
|
|
|
@@ -776,17 +934,15 @@ export class StaticAnalyzer {
|
|
|
776
934
|
}
|
|
777
935
|
}
|
|
778
936
|
|
|
779
|
-
// Top-level `result:`
|
|
780
|
-
//
|
|
781
|
-
//
|
|
937
|
+
// Top-level `result:` is a post-call mapping that must satisfy the abstract
|
|
938
|
+
// this definition `extends` (`outputType`). It's a sibling of whichever
|
|
939
|
+
// dispatch entry-point declared a kind-typed target (`provide:` or
|
|
940
|
+
// `invoke:`). The target's outputType lives on the dispatcher's `kind`
|
|
782
941
|
// and is what `result` is typed against *inside* CEL — separate role.
|
|
783
|
-
|
|
784
|
-
provide &&
|
|
785
|
-
typeof
|
|
786
|
-
|
|
787
|
-
md.result &&
|
|
788
|
-
typeof md.result === "object"
|
|
789
|
-
) {
|
|
942
|
+
const hasDispatchObject =
|
|
943
|
+
(provide && typeof provide === "object" && !Array.isArray(provide)) ||
|
|
944
|
+
(invoke && typeof invoke === "object" && !Array.isArray(invoke));
|
|
945
|
+
if (hasDispatchObject && md.result && typeof md.result === "object") {
|
|
790
946
|
const extendsValue = md.extends as string | undefined;
|
|
791
947
|
if (typeof extendsValue === "string" && extendsValue.length > 0) {
|
|
792
948
|
const abstractSchema = lookupDefinitionTypeField(
|
|
@@ -877,7 +1033,18 @@ export class StaticAnalyzer {
|
|
|
877
1033
|
}
|
|
878
1034
|
|
|
879
1035
|
const engine = defaultRegistry().get(engineName);
|
|
880
|
-
if (!engine)
|
|
1036
|
+
if (!engine) {
|
|
1037
|
+
// No registered engine owns this tag — the expression would go
|
|
1038
|
+
// entirely unanalyzed. Surface it rather than skipping silently.
|
|
1039
|
+
diagnostics.push({
|
|
1040
|
+
severity: DiagnosticSeverity.Error,
|
|
1041
|
+
code: "UNKNOWN_ENGINE",
|
|
1042
|
+
source: SOURCE,
|
|
1043
|
+
message: `${m.kind}/${resource.name}: no templating engine registered for '!${engineName}' at '${path}'.`,
|
|
1044
|
+
data: { resource, filePath, path },
|
|
1045
|
+
});
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
881
1048
|
const findings = engine.analyze(expr, { celEnv: this.celEnv, contextSchema: effectiveContext });
|
|
882
1049
|
for (const f of findings) {
|
|
883
1050
|
if (f.code === "CEL_SYNTAX_ERROR") {
|
|
@@ -920,10 +1087,15 @@ export class StaticAnalyzer {
|
|
|
920
1087
|
// Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
|
|
921
1088
|
diagnostics.push(...validateExtends(allManifests, defs, aliases));
|
|
922
1089
|
|
|
1090
|
+
// Validate provider coherence rules for `provide:` template-target definitions.
|
|
1091
|
+
diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
|
|
1092
|
+
|
|
923
1093
|
// Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
|
|
924
1094
|
diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
|
|
925
1095
|
|
|
926
|
-
|
|
1096
|
+
// Reroute diagnostics on synthetic (inline-extracted) resources back to
|
|
1097
|
+
// the chain root so position-index lookups land on the parent doc.
|
|
1098
|
+
return rewriteSyntheticOrigins(diagnostics, allManifests);
|
|
927
1099
|
}
|
|
928
1100
|
|
|
929
1101
|
analyzeErrors(
|
|
@@ -938,12 +1110,17 @@ export class StaticAnalyzer {
|
|
|
938
1110
|
|
|
939
1111
|
normalize(manifests: ResourceManifest[], registry: AnalysisRegistry): ResourceManifest[] {
|
|
940
1112
|
const ctx = registry._context();
|
|
941
|
-
|
|
1113
|
+
const normalized = normalizeInlineResources(
|
|
942
1114
|
manifests,
|
|
943
1115
|
ctx.definitions!,
|
|
944
1116
|
ctx.aliases,
|
|
945
1117
|
ctx.aliasesByModule,
|
|
946
1118
|
);
|
|
1119
|
+
// Resolve !ref sentinels after normalize so both the original and
|
|
1120
|
+
// inline-extracted manifests get their refs canonicalized to
|
|
1121
|
+
// {kind, name} for the kernel that consumes this output.
|
|
1122
|
+
resolveRefSentinels(normalized, ctx.definitions!, ctx.aliases, ctx.aliasesByModule);
|
|
1123
|
+
return normalized;
|
|
947
1124
|
}
|
|
948
1125
|
|
|
949
1126
|
prepare(
|
package/src/builtins.ts
CHANGED
|
@@ -149,7 +149,12 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
149
149
|
additionalProperties: false,
|
|
150
150
|
properties: {
|
|
151
151
|
self: { "x-telo-context-from-root": "schema" },
|
|
152
|
-
result: {
|
|
152
|
+
result: {
|
|
153
|
+
"x-telo-context-from-ref-kind": [
|
|
154
|
+
"provide/kind#outputType",
|
|
155
|
+
"invoke/kind#outputType",
|
|
156
|
+
],
|
|
157
|
+
},
|
|
153
158
|
},
|
|
154
159
|
},
|
|
155
160
|
},
|
|
@@ -215,6 +220,20 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
215
220
|
anyOf: [
|
|
216
221
|
{ type: "string", "x-telo-ref": "telo#Runnable" },
|
|
217
222
|
{ type: "string", "x-telo-ref": "telo#Service" },
|
|
223
|
+
// Post-resolution shape that `resolveRefSentinels`
|
|
224
|
+
// substitutes a `!ref <name>` sentinel into. The
|
|
225
|
+
// adjacent `x-telo-ref` constraints govern the kind
|
|
226
|
+
// check; this branch only admits the structural form so
|
|
227
|
+
// AJV doesn't reject a resolved ref.
|
|
228
|
+
{
|
|
229
|
+
type: "object",
|
|
230
|
+
required: ["kind", "name"],
|
|
231
|
+
properties: {
|
|
232
|
+
kind: { type: "string" },
|
|
233
|
+
name: { type: "string" },
|
|
234
|
+
},
|
|
235
|
+
additionalProperties: true,
|
|
236
|
+
},
|
|
218
237
|
],
|
|
219
238
|
},
|
|
220
239
|
},
|
|
@@ -222,6 +241,44 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
|
|
|
222
241
|
type: "array",
|
|
223
242
|
items: { type: "string" },
|
|
224
243
|
},
|
|
244
|
+
// Application-level environment contract. Each entry layers `env:`
|
|
245
|
+
// (required, names the source env var) and `default:` (optional, used
|
|
246
|
+
// when the env var is unset) on top of an open JSON Schema property
|
|
247
|
+
// schema. `type:` constrains the coercion rule applied to the raw env
|
|
248
|
+
// string (scalars per-type; `object` / `array` via JSON.parse with the
|
|
249
|
+
// matching top-level type). All other JSON Schema keywords are passed
|
|
250
|
+
// through unchanged and applied to the coerced value via the standard
|
|
251
|
+
// schema validator. See kernel/nodejs/src/application-env.ts.
|
|
252
|
+
variables: {
|
|
253
|
+
type: "object",
|
|
254
|
+
additionalProperties: {
|
|
255
|
+
type: "object",
|
|
256
|
+
required: ["env", "type"],
|
|
257
|
+
properties: {
|
|
258
|
+
env: { type: "string" },
|
|
259
|
+
type: {
|
|
260
|
+
type: "string",
|
|
261
|
+
enum: ["string", "integer", "number", "boolean", "object", "array"],
|
|
262
|
+
},
|
|
263
|
+
default: {},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
secrets: {
|
|
268
|
+
type: "object",
|
|
269
|
+
additionalProperties: {
|
|
270
|
+
type: "object",
|
|
271
|
+
required: ["env", "type"],
|
|
272
|
+
properties: {
|
|
273
|
+
env: { type: "string" },
|
|
274
|
+
type: {
|
|
275
|
+
type: "string",
|
|
276
|
+
enum: ["string", "integer", "number", "boolean", "object", "array"],
|
|
277
|
+
},
|
|
278
|
+
default: {},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
225
282
|
},
|
|
226
283
|
required: ["metadata"],
|
|
227
284
|
additionalProperties: false,
|
|
@@ -80,6 +80,21 @@ export class DefinitionRegistry {
|
|
|
80
80
|
* @param namespace The module's metadata.namespace (e.g. "std"), or null for telo built-ins.
|
|
81
81
|
* @param moduleName The module's metadata.name (e.g. "pipeline", "http-server"). */
|
|
82
82
|
registerModuleIdentity(namespace: string | null, moduleName: string): void {
|
|
83
|
+
// The "telo" identity is reserved for the Telo built-in module and gets
|
|
84
|
+
// populated automatically when a Telo.Abstract definition registers (see
|
|
85
|
+
// `register` below). A user app / library without a namespace must NOT
|
|
86
|
+
// claim it — silently overwriting the built-in entry breaks every
|
|
87
|
+
// x-telo-ref that resolves through "telo#…". Concretely, the
|
|
88
|
+
// `Http.Api.routes[].handler` slot in the http-server schema carries
|
|
89
|
+
// `x-telo-ref: "telo#Invocable"`. If the entry application is, say,
|
|
90
|
+
// `Telo.Application/HelloApi` (no namespace), this method previously
|
|
91
|
+
// overwrote `"telo" → "Telo"` with `"telo" → "HelloApi"`. The handler's
|
|
92
|
+
// ref then resolved to a nonexistent `HelloApi.Invocable`, the
|
|
93
|
+
// kind-mismatch check inside `validate-references.ts` short-circuited
|
|
94
|
+
// on partial context, and the analyzer reported zero issues for a
|
|
95
|
+
// manifest that explodes at runtime. Skip non-Telo no-namespace modules;
|
|
96
|
+
// they have no x-telo-ref identity to declare anyway.
|
|
97
|
+
if (!namespace && moduleName !== "Telo") return;
|
|
83
98
|
const identity = namespace ? `${namespace}/${moduleName}` : "telo";
|
|
84
99
|
this.identityMap.set(identity, moduleName);
|
|
85
100
|
this.reverseIdentityMap.set(moduleName, identity);
|
|
@@ -104,7 +119,10 @@ export class DefinitionRegistry {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/** Validates data against a schema using this registry's AJV instance, which has all
|
|
107
|
-
* registered definition schemas loaded — enabling cross-module $ref resolution.
|
|
122
|
+
* registered definition schemas loaded — enabling cross-module $ref resolution.
|
|
123
|
+
* A compile failure returns `[]` here; it is surfaced loudly (once, on the
|
|
124
|
+
* owning definition) by `schemaCompileError` via the analyzer's
|
|
125
|
+
* definition-schema compile check, so resources are never silently skipped. */
|
|
108
126
|
validateWithRefs(data: unknown, schema: Record<string, any>): string[] {
|
|
109
127
|
let validate: ReturnType<typeof this.ajv.compile>;
|
|
110
128
|
try {
|
|
@@ -116,6 +134,22 @@ export class DefinitionRegistry {
|
|
|
116
134
|
return (validate.errors ?? []).map(formatSingleError);
|
|
117
135
|
}
|
|
118
136
|
|
|
137
|
+
/** Returns the AJV compile error for `schema`, or `undefined` when it compiles.
|
|
138
|
+
* Compiles on this registry's instance, which has every loaded module schema
|
|
139
|
+
* plus the manifest root registered, so local `#/$defs`, `telo://manifest`,
|
|
140
|
+
* and cross-module `$ref`s all resolve. Used to fail loud on a definition
|
|
141
|
+
* schema that AJV cannot compile — otherwise `validateAgainstSchema` /
|
|
142
|
+
* `validateWithRefs` would swallow the failure and silently skip every
|
|
143
|
+
* resource of that kind. */
|
|
144
|
+
schemaCompileError(schema: Record<string, any>): string | undefined {
|
|
145
|
+
try {
|
|
146
|
+
this.ajv.compile(schema);
|
|
147
|
+
return undefined;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return err instanceof Error ? err.message : String(err);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
private tryRegisterSchema(
|
|
120
154
|
moduleName: string,
|
|
121
155
|
typeName: string,
|
package/src/dependency-graph.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isRefSentinel } from "@telorun/templating";
|
|
2
3
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
4
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
5
|
import { isRefEntry, isScopeEntry, resolveFieldValues } from "./reference-field-map.js";
|
|
6
|
+
import { DEPENDENCY_GRAPH_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
5
7
|
|
|
6
8
|
export interface ResourceNode {
|
|
7
9
|
kind: string;
|
|
@@ -17,16 +19,6 @@ export interface DependencyGraph {
|
|
|
17
19
|
cycle?: ReadonlyArray<ResourceNode>;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
/** System resource kinds that are not runtime nodes in the dependency graph.
|
|
21
|
-
* Module-identity docs (Telo.Application, Telo.Library) are intentionally
|
|
22
|
-
* not in this set: an Application's `targets` use `x-telo-ref` to real
|
|
23
|
-
* Runnable/Service resources, so the Application legitimately depends on
|
|
24
|
-
* them in boot order — modeling that as a graph edge is correct. A Library
|
|
25
|
-
* has no `targets`, so it becomes a zero-edge node, which is harmless.
|
|
26
|
-
* If the graph is ever consumed as "things to init", skip these kinds at
|
|
27
|
-
* the consumer site; the controller already runs them separately. */
|
|
28
|
-
const SYSTEM_KINDS = new Set(["Telo.Definition", "Telo.Import"]);
|
|
29
|
-
|
|
30
22
|
const nodeKey = (kind: string, name: string) => `${kind}\0${name}`;
|
|
31
23
|
|
|
32
24
|
/**
|
|
@@ -47,12 +39,18 @@ export function buildDependencyGraph(
|
|
|
47
39
|
aliases?: AliasResolver,
|
|
48
40
|
aliasesByModule?: Map<string, AliasResolver>,
|
|
49
41
|
): DependencyGraph {
|
|
50
|
-
// --- Build node set ---
|
|
42
|
+
// --- Build node set + name index ---
|
|
51
43
|
const nodes = new Map<string, ResourceNode>();
|
|
44
|
+
// Sentinel lookup (`!ref <name>`) needs to resolve a bare name to its
|
|
45
|
+
// declared kind. Names are unique within a manifest scope, so a flat
|
|
46
|
+
// map suffices and lets the sentinel branch below avoid a full
|
|
47
|
+
// O(N) scan of the node set on every reference.
|
|
48
|
+
const nodesByName = new Map<string, ResourceNode>();
|
|
52
49
|
for (const r of resources) {
|
|
53
50
|
if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
|
|
54
|
-
const
|
|
55
|
-
nodes.set(
|
|
51
|
+
const node = { kind: r.kind, name: r.metadata.name as string };
|
|
52
|
+
nodes.set(nodeKey(node.kind, node.name), node);
|
|
53
|
+
nodesByName.set(node.name, node);
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
// --- Build adjacency: from → deps (from depends on dep) ---
|
|
@@ -90,7 +88,22 @@ export function buildDependencyGraph(
|
|
|
90
88
|
if (!isRefEntry(entry)) continue;
|
|
91
89
|
|
|
92
90
|
for (const val of resolveFieldValues(r, fieldPath)) {
|
|
93
|
-
if (!val
|
|
91
|
+
if (!val) continue;
|
|
92
|
+
|
|
93
|
+
// `!ref <name>` sentinel — look up the target's kind from the
|
|
94
|
+
// name (resources are unique by name) so the edge carries the
|
|
95
|
+
// concrete kind, matching the {kind, name} edge shape below.
|
|
96
|
+
if (isRefSentinel(val)) {
|
|
97
|
+
const refName = val.source;
|
|
98
|
+
if (scopedNames.has(refName)) continue;
|
|
99
|
+
const node = nodesByName.get(refName);
|
|
100
|
+
if (node) {
|
|
101
|
+
deps.get(sourceKey)!.add(nodeKey(node.kind, node.name));
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof val !== "object") continue;
|
|
94
107
|
const ref = val as Record<string, unknown>;
|
|
95
108
|
if (!ref.kind || !ref.name) continue;
|
|
96
109
|
// Edges to scoped resources are runtime deps, not boot-time deps — exclude from DAG
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
|
|
|
14
14
|
export type { ModuleKind } from "./module-kinds.js";
|
|
15
15
|
export { parseLoadedFile } from "./parse-loaded-file.js";
|
|
16
16
|
export type { ParseOptions } from "./parse-loaded-file.js";
|
|
17
|
+
export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.js";
|
|
17
18
|
export {
|
|
18
19
|
buildDocumentPositions,
|
|
19
20
|
buildLineOffsets,
|
|
@@ -23,6 +24,7 @@ export {
|
|
|
23
24
|
export type { DocumentPosition } from "./position-metadata.js";
|
|
24
25
|
export { HttpSource } from "./sources/http-source.js";
|
|
25
26
|
export { RegistrySource } from "./sources/registry-source.js";
|
|
27
|
+
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
26
28
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
|
|
27
29
|
export type {
|
|
28
30
|
AnalysisDiagnostic,
|
package/src/kernel-globals.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { residualEntrySchemaMap } from "./residual-schema.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Kernel global names available in every CEL evaluation context at runtime.
|
|
@@ -72,20 +73,17 @@ export function buildKernelGlobalsSchema(
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** Wrap a JSON Schema property map (like `Telo.Application.variables`) into a
|
|
75
|
-
* closed object schema suitable for chain-access validation.
|
|
76
|
-
*
|
|
76
|
+
* closed object schema suitable for chain-access validation. For Application
|
|
77
|
+
* entries the per-entry shape carries kernel-specific keys (`env`, `default`)
|
|
78
|
+
* on top of an otherwise-standard JSON Schema property schema; those keys are
|
|
79
|
+
* stripped via `residualEntrySchemaMap` so CEL sees the coerced shape, not
|
|
80
|
+
* the env-binding wrapper. Library entries are pure JSON Schema property
|
|
81
|
+
* schemas and pass through the same call unchanged. Falls back to an open map
|
|
82
|
+
* when the module declares no variables/secrets. */
|
|
77
83
|
function buildSchemaMapSchema(
|
|
78
84
|
schemaMap: Record<string, any> | null | undefined,
|
|
79
85
|
): Record<string, any> {
|
|
80
|
-
|
|
81
|
-
return { type: "object", additionalProperties: true };
|
|
82
|
-
}
|
|
83
|
-
const props: Record<string, any> = {};
|
|
84
|
-
for (const [key, value] of Object.entries(schemaMap)) {
|
|
85
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
86
|
-
props[key] = value;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
86
|
+
const props = residualEntrySchemaMap(schemaMap);
|
|
89
87
|
if (Object.keys(props).length === 0) {
|
|
90
88
|
return { type: "object", additionalProperties: true };
|
|
91
89
|
}
|
package/src/manifest-loader.ts
CHANGED
|
@@ -33,6 +33,14 @@ export class Loader {
|
|
|
33
33
|
* get distinct entries, so neither sees the wrong manifest tree. */
|
|
34
34
|
private readonly fileCache = new Map<string, LoadedFile>();
|
|
35
35
|
|
|
36
|
+
/** requestUrl → canonical `source`. Lets `loadFile` skip the source read
|
|
37
|
+
* when a URL it has already canonicalised is requested again — kernel
|
|
38
|
+
* load → boot and the import-controller each ask the loader for the same
|
|
39
|
+
* modules. Without this fast path every duplicate request re-runs the
|
|
40
|
+
* source's `read()` (a `fetch` for `RegistrySource`, a disk read for
|
|
41
|
+
* `LocalFileSource`). */
|
|
42
|
+
private readonly urlToSource = new Map<string, string>();
|
|
43
|
+
|
|
36
44
|
protected sources: ManifestSource[];
|
|
37
45
|
private readonly celEnv: Environment;
|
|
38
46
|
|
|
@@ -67,8 +75,22 @@ export class Loader {
|
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
async resolveEntryPoint(url: string): Promise<string> {
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
// Route through `loadFile` so the resolved source URL and parsed
|
|
79
|
+
// entry are populated in `urlToSource` + `fileCache` in one read.
|
|
80
|
+
// Callers (kernel.load) immediately call `loadGraph(entryUrl)`
|
|
81
|
+
// afterwards — without this priming, the entry file would be read
|
|
82
|
+
// twice (twice over the network for `RegistrySource`).
|
|
83
|
+
const file = await this.loadFile(url);
|
|
84
|
+
return file.source;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Returns the canonical source URL the loader has already mapped `url`
|
|
88
|
+
* to during a prior `loadFile`/`loadModule`/`loadGraph` call, or
|
|
89
|
+
* `undefined` when the URL has not been seen. Callers use it to test
|
|
90
|
+
* set-membership against a previous graph walk's modules without
|
|
91
|
+
* triggering an extra source read. */
|
|
92
|
+
canonicalize(url: string): string | undefined {
|
|
93
|
+
return this.urlToSource.get(url);
|
|
72
94
|
}
|
|
73
95
|
|
|
74
96
|
// --- New API: returns LoadedFile / LoadedModule / LoadedGraph ----------
|
|
@@ -78,8 +100,42 @@ export class Loader {
|
|
|
78
100
|
* private mutable copy must call `parseLoadedFile` directly with the
|
|
79
101
|
* LoadedFile's `text`. */
|
|
80
102
|
async loadFile(url: string, options?: LoadOptions): Promise<LoadedFile> {
|
|
103
|
+
const compileKey = options?.compile ? "compiled" : "raw";
|
|
104
|
+
const knownSource = this.urlToSource.get(url);
|
|
105
|
+
if (knownSource) {
|
|
106
|
+
const cached = this.fileCache.get(`${compileKey}:${knownSource}`);
|
|
107
|
+
if (cached) return cached;
|
|
108
|
+
// The other compile-mode entry is cached — reparse from its text
|
|
109
|
+
// instead of re-reading the source.
|
|
110
|
+
//
|
|
111
|
+
// NOTE for watch-mode reactivation (cli/nodejs/src/commands/run.ts
|
|
112
|
+
// currently has `setupWatchMode` commented out): this branch
|
|
113
|
+
// assumes file contents don't change underneath a single Loader.
|
|
114
|
+
// Reviving watch mode will need a public `invalidate(url)` (or
|
|
115
|
+
// similar) that drops both `urlToSource[url]` and the cached
|
|
116
|
+
// entries for its canonical source before the loader serves the
|
|
117
|
+
// file again.
|
|
118
|
+
const altKey = `${compileKey === "compiled" ? "raw" : "compiled"}:${knownSource}`;
|
|
119
|
+
const alt = this.fileCache.get(altKey);
|
|
120
|
+
if (alt) {
|
|
121
|
+
const reparsed = parseLoadedFile(knownSource, url, alt.text, {
|
|
122
|
+
compile: options?.compile,
|
|
123
|
+
celEnv: this.celEnv,
|
|
124
|
+
});
|
|
125
|
+
this.fileCache.set(`${compileKey}:${knownSource}`, reparsed);
|
|
126
|
+
return reparsed;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
const { text, source } = await this.pick(url).read(url);
|
|
82
|
-
|
|
131
|
+
this.urlToSource.set(url, source);
|
|
132
|
+
// Also map the canonical source to itself so subsequent `loadFile`
|
|
133
|
+
// calls that already received a canonical URL — `kernel.load` passes
|
|
134
|
+
// the result of `resolveEntryPoint` to `loadGraph`, which then asks
|
|
135
|
+
// for that exact URL — hit the urlToSource fast path instead of
|
|
136
|
+
// falling through to a redundant `pick(url).read(url)`.
|
|
137
|
+
this.urlToSource.set(source, source);
|
|
138
|
+
const cacheKey = `${compileKey}:${source}`;
|
|
83
139
|
const cached = this.fileCache.get(cacheKey);
|
|
84
140
|
if (cached && cached.text === text) return cached;
|
|
85
141
|
|
|
@@ -224,7 +280,16 @@ export class Loader {
|
|
|
224
280
|
return { rootSource, entry, modules, importEdges, errors };
|
|
225
281
|
}
|
|
226
282
|
|
|
227
|
-
|
|
283
|
+
/** Resolve an `import` URL against the file it appears in. Relative /
|
|
284
|
+
* absolute-path forms run through the owning `ManifestSource`'s
|
|
285
|
+
* `resolveRelative`; registry refs and full URLs pass through
|
|
286
|
+
* unchanged. Exposed so the import-controller (and any other
|
|
287
|
+
* caller-side resolver) lands on the *exact same* canonical URL the
|
|
288
|
+
* loader used when walking the entry graph — divergent resolution
|
|
289
|
+
* would silently break optimizations like `canonicalize()`-keyed
|
|
290
|
+
* cache hits whenever a non-trivial `ManifestSource.resolveRelative`
|
|
291
|
+
* is in play. */
|
|
292
|
+
resolveImportUrl(fromSource: string, importSource: string): string {
|
|
228
293
|
if (importSource.startsWith(".") || importSource.startsWith("/")) {
|
|
229
294
|
return this.pick(fromSource).resolveRelative(fromSource, importSource);
|
|
230
295
|
}
|