@telorun/analyzer 0.32.0 → 0.34.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/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +10 -9
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +284 -80
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +15 -0
- package/dist/definition-registry.d.ts +4 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +20 -3
- package/dist/extends-resolution.d.ts +33 -0
- package/dist/extends-resolution.d.ts.map +1 -0
- package/dist/extends-resolution.js +82 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/sources/integrity.d.ts +7 -0
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +12 -2
- package/dist/sources/module-ref.d.ts +6 -2
- package/dist/sources/module-ref.d.ts.map +1 -1
- package/dist/sources/module-ref.js +7 -6
- package/dist/validate-base-mapping.d.ts +21 -0
- package/dist/validate-base-mapping.d.ts.map +1 -0
- package/dist/validate-base-mapping.js +130 -0
- package/dist/validate-extends.d.ts.map +1 -1
- package/dist/validate-extends.js +20 -9
- package/dist/validate-kind-descriptions.d.ts +20 -0
- package/dist/validate-kind-descriptions.d.ts.map +1 -0
- package/dist/validate-kind-descriptions.js +65 -0
- package/dist/validate-provider-coherence.d.ts.map +1 -1
- package/dist/validate-provider-coherence.js +8 -1
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +15 -9
- package/package.json +3 -3
- package/src/analysis-registry.ts +9 -8
- package/src/analyzer.ts +333 -91
- package/src/builtins.ts +15 -0
- package/src/definition-registry.ts +18 -3
- package/src/extends-resolution.ts +124 -0
- package/src/index.ts +13 -0
- package/src/sources/integrity.ts +13 -2
- package/src/sources/module-ref.ts +7 -6
- package/src/validate-base-mapping.ts +154 -0
- package/src/validate-extends.ts +24 -11
- package/src/validate-kind-descriptions.ts +65 -0
- package/src/validate-provider-coherence.ts +12 -2
- package/src/validate-references.ts +13 -9
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ResourceDefinition } from "@telorun/sdk";
|
|
2
|
+
import { mergeTypeSchemas } from "@telorun/sdk";
|
|
3
|
+
|
|
4
|
+
/** Resolves a kind string (canonical or alias form, depending on the caller's
|
|
5
|
+
* registry) to its `Telo.Definition` / `Telo.Abstract`, or undefined. */
|
|
6
|
+
export type DefResolver = (kind: string) => ResourceDefinition | undefined;
|
|
7
|
+
|
|
8
|
+
/** The template-body / controller fields a definition may carry. Kept local
|
|
9
|
+
* because `ResourceDefinition` intentionally types only the stable surface;
|
|
10
|
+
* template bodies are read structurally. */
|
|
11
|
+
interface DefinitionBody {
|
|
12
|
+
extends?: string;
|
|
13
|
+
capability?: string;
|
|
14
|
+
controllers?: unknown[];
|
|
15
|
+
invoke?: unknown;
|
|
16
|
+
run?: unknown;
|
|
17
|
+
provide?: unknown;
|
|
18
|
+
mount?: unknown;
|
|
19
|
+
resources?: unknown[];
|
|
20
|
+
base?: Record<string, unknown>;
|
|
21
|
+
schema?: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const body = (def: ResourceDefinition | undefined): DefinitionBody =>
|
|
25
|
+
(def ?? {}) as unknown as DefinitionBody;
|
|
26
|
+
|
|
27
|
+
/** The definition a given definition directly `extends`, or undefined when it
|
|
28
|
+
* extends nothing / the target can't be resolved. */
|
|
29
|
+
export function resolveParent(
|
|
30
|
+
def: ResourceDefinition | undefined,
|
|
31
|
+
resolve: DefResolver,
|
|
32
|
+
): ResourceDefinition | undefined {
|
|
33
|
+
const ext = body(def).extends;
|
|
34
|
+
if (typeof ext !== "string" || ext.length === 0) return undefined;
|
|
35
|
+
return resolve(ext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `extends` ancestor chain, nearest-first, excluding `def` itself.
|
|
39
|
+
* Cycle-guarded so a malformed self/mutual `extends` can't loop forever. */
|
|
40
|
+
export function ancestorChain(
|
|
41
|
+
def: ResourceDefinition | undefined,
|
|
42
|
+
resolve: DefResolver,
|
|
43
|
+
): ResourceDefinition[] {
|
|
44
|
+
const chain: ResourceDefinition[] = [];
|
|
45
|
+
const seen = new Set<ResourceDefinition>();
|
|
46
|
+
let cur = resolveParent(def, resolve);
|
|
47
|
+
while (cur && !seen.has(cur)) {
|
|
48
|
+
seen.add(cur);
|
|
49
|
+
chain.push(cur);
|
|
50
|
+
cur = resolveParent(cur, resolve);
|
|
51
|
+
}
|
|
52
|
+
return chain;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when a definition carries its own controller (`controllers:`) or a
|
|
56
|
+
* template body (`invoke:` / `run:` / `provide:` / `mount:` / `resources:`). */
|
|
57
|
+
export function hasOwnControllerOrTemplate(def: ResourceDefinition | undefined): boolean {
|
|
58
|
+
const d = body(def);
|
|
59
|
+
return !!(
|
|
60
|
+
(d.controllers && d.controllers.length) ||
|
|
61
|
+
d.invoke ||
|
|
62
|
+
d.run ||
|
|
63
|
+
d.provide ||
|
|
64
|
+
d.mount ||
|
|
65
|
+
d.resources
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The nearest concrete ancestor that provides a controller (own `controllers:`
|
|
70
|
+
* or a template body) — the definition whose controller an inherited child
|
|
71
|
+
* delegates to. Undefined when no controller-bearing concrete ancestor exists. */
|
|
72
|
+
export function controllerBearingAncestor(
|
|
73
|
+
def: ResourceDefinition | undefined,
|
|
74
|
+
resolve: DefResolver,
|
|
75
|
+
): ResourceDefinition | undefined {
|
|
76
|
+
for (const a of ancestorChain(def, resolve)) {
|
|
77
|
+
if (a.kind === "Telo.Abstract") continue;
|
|
78
|
+
if (hasOwnControllerOrTemplate(a)) return a;
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when this definition inherits its controller by delegation: it declares
|
|
84
|
+
* `extends`, has no own controller/template body, and its nearest concrete
|
|
85
|
+
* ancestor is controller-bearing. */
|
|
86
|
+
export function isInheritedDelegation(
|
|
87
|
+
def: ResourceDefinition | undefined,
|
|
88
|
+
resolve: DefResolver,
|
|
89
|
+
): boolean {
|
|
90
|
+
if (!body(def).extends || hasOwnControllerOrTemplate(def)) return false;
|
|
91
|
+
return controllerBearingAncestor(def, resolve) !== undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The effective (possibly inherited) capability: the nearest self-or-ancestor
|
|
95
|
+
* that declares a `capability`. Undefined when none in the chain does. */
|
|
96
|
+
export function inheritedCapability(
|
|
97
|
+
def: ResourceDefinition | undefined,
|
|
98
|
+
resolve: DefResolver,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
if (body(def).capability) return body(def).capability;
|
|
101
|
+
for (const a of ancestorChain(def, resolve)) {
|
|
102
|
+
if (body(a).capability) return body(a).capability;
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The author-facing schema for a definition:
|
|
108
|
+
* - with `base:` present → the definition's **own** schema (the parent's config
|
|
109
|
+
* fields are internal, set solely through `base:`).
|
|
110
|
+
* - without `base:` but with `extends` → `merge(parent-effective, own)` (a pure
|
|
111
|
+
* additive extension; child overrides on key conflicts), reusing the same
|
|
112
|
+
* `mergeTypeSchemas` that `Type.JsonSchema.extends` uses.
|
|
113
|
+
* - no `extends` → the own schema unchanged. */
|
|
114
|
+
export function effectiveAuthorSchema(
|
|
115
|
+
def: ResourceDefinition | undefined,
|
|
116
|
+
resolve: DefResolver,
|
|
117
|
+
): Record<string, any> {
|
|
118
|
+
const own = (body(def).schema ?? {}) as Record<string, any>;
|
|
119
|
+
const parent = resolveParent(def, resolve);
|
|
120
|
+
if (!parent) return own;
|
|
121
|
+
if (body(def).base) return own;
|
|
122
|
+
const parentSchema = effectiveAuthorSchema(parent, resolve);
|
|
123
|
+
return mergeTypeSchemas([parentSchema, own]) as Record<string, any>;
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,18 @@ export {
|
|
|
22
22
|
type ReExportSpec,
|
|
23
23
|
} from "./flatten-for-analyzer.js";
|
|
24
24
|
export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
|
|
25
|
+
export {
|
|
26
|
+
ancestorChain,
|
|
27
|
+
controllerBearingAncestor,
|
|
28
|
+
effectiveAuthorSchema,
|
|
29
|
+
hasOwnControllerOrTemplate,
|
|
30
|
+
inheritedCapability,
|
|
31
|
+
isInheritedDelegation,
|
|
32
|
+
resolveParent,
|
|
33
|
+
} from "./extends-resolution.js";
|
|
34
|
+
export type { DefResolver } from "./extends-resolution.js";
|
|
35
|
+
export { buildReferenceFieldMap, isRefEntry, isScopeEntry } from "./reference-field-map.js";
|
|
36
|
+
export type { ReferenceFieldMap, RefFieldEntry } from "./reference-field-map.js";
|
|
25
37
|
export { visitManifest } from "./manifest-visitor.js";
|
|
26
38
|
export type {
|
|
27
39
|
CelSiteEvent,
|
|
@@ -59,6 +71,7 @@ export {
|
|
|
59
71
|
verifyIntegrity,
|
|
60
72
|
verifiedFetch,
|
|
61
73
|
sha256Base64Url,
|
|
74
|
+
IntegrityError,
|
|
62
75
|
} from "./sources/integrity.js";
|
|
63
76
|
export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
|
|
64
77
|
export type { ParsedModuleRef } from "./sources/module-ref.js";
|
package/src/sources/integrity.ts
CHANGED
|
@@ -11,6 +11,17 @@
|
|
|
11
11
|
* only algorithm accepted today; the prefix leaves room to migrate. */
|
|
12
12
|
const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
|
|
13
13
|
|
|
14
|
+
/** A failed integrity/tamper check — always terminal, never best-effort. A
|
|
15
|
+
* distinct type so a caller doing best-effort network handling (e.g. the
|
|
16
|
+
* bundle extractor warning-and-skipping on a fetch blip) can still let a
|
|
17
|
+
* tamper error propagate rather than swallow it. */
|
|
18
|
+
export class IntegrityError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "IntegrityError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
|
|
15
26
|
* `base` (safe to build fetch URLs and cache paths from) and the fragment in
|
|
16
27
|
* `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
|
|
@@ -64,14 +75,14 @@ export async function verifyIntegrity(
|
|
|
64
75
|
const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
|
|
65
76
|
const expected = dash > 0 ? integrity.slice(dash + 1) : "";
|
|
66
77
|
if (algorithm !== "sha256") {
|
|
67
|
-
throw new
|
|
78
|
+
throw new IntegrityError(
|
|
68
79
|
`Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
|
|
69
80
|
`Only sha256 is supported (sha256-<base64url>).`,
|
|
70
81
|
);
|
|
71
82
|
}
|
|
72
83
|
const actual = await sha256Base64Url(bytes);
|
|
73
84
|
if (actual !== normalizeDigest(expected)) {
|
|
74
|
-
throw new
|
|
85
|
+
throw new IntegrityError(
|
|
75
86
|
`Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
|
|
76
87
|
`got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
|
|
77
88
|
`the module may have been tampered with or republished.`,
|
|
@@ -10,17 +10,18 @@ export interface ParsedModuleRef {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** True when `url` has the bare registry-ref shape `namespace/name@version`
|
|
13
|
-
* (no scheme, no leading `/` or `.`, contains both `@` and `/`).
|
|
14
|
-
* fragment, if any, does not affect the classification.
|
|
13
|
+
* (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
|
|
14
|
+
* The integrity fragment, if any, does not affect the classification.
|
|
15
|
+
*
|
|
16
|
+
* A registry ref never carries a `scheme://` — that guard is what keeps an
|
|
17
|
+
* `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
|
|
18
|
+
* scheme-owning transport claims it instead. */
|
|
15
19
|
export function isRegistryRef(url: string): boolean {
|
|
16
20
|
const { base } = splitIntegrity(url);
|
|
17
21
|
return (
|
|
18
|
-
!base.
|
|
19
|
-
!base.startsWith("https://") &&
|
|
22
|
+
!base.includes("://") &&
|
|
20
23
|
!base.startsWith("/") &&
|
|
21
24
|
!base.startsWith(".") &&
|
|
22
|
-
!base.startsWith("file://") &&
|
|
23
|
-
!base.startsWith("memory://") &&
|
|
24
25
|
base.includes("@") &&
|
|
25
26
|
base.includes("/")
|
|
26
27
|
);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { isCompiledValue } from "@telorun/sdk";
|
|
3
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import { effectiveAuthorSchema, resolveParent, type DefResolver } from "./extends-resolution.js";
|
|
6
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const SOURCE = "telo-analyzer";
|
|
9
|
+
|
|
10
|
+
/** True when a value subtree contains a compiled CEL leaf — such a value can
|
|
11
|
+
* produce anything at runtime, so its type is not statically checkable. */
|
|
12
|
+
function containsCel(value: unknown): boolean {
|
|
13
|
+
if (isCompiledValue(value)) return true;
|
|
14
|
+
if (Array.isArray(value)) return value.some(containsCel);
|
|
15
|
+
if (value && typeof value === "object") {
|
|
16
|
+
return Object.values(value as Record<string, unknown>).some(containsCel);
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Phase 3c — Static validation of a definition's `base:` construction mapping
|
|
23
|
+
* against the parent kind's config schema. The kernel evaluates `base:` and
|
|
24
|
+
* passes the result to the inherited controller's `create()`, which validates it
|
|
25
|
+
* at boot; this mirrors that check statically so an omitted required field or a
|
|
26
|
+
* wrong literal type surfaces at `telo check`, not first boot.
|
|
27
|
+
*
|
|
28
|
+
* Diagnostics:
|
|
29
|
+
* - BASE_MISSING_REQUIRED: `base:` omits a field the parent schema requires.
|
|
30
|
+
* - BASE_UNKNOWN_FIELD: `base:` sets a field the parent schema (with
|
|
31
|
+
* `additionalProperties: false`) does not declare.
|
|
32
|
+
* - BASE_SCHEMA_MISMATCH: a CEL-free `base:` value violates the parent field's
|
|
33
|
+
* schema (wrong type / constraint). CEL-bearing values are skipped — their
|
|
34
|
+
* runtime value is unknown — but still count as present for required checks.
|
|
35
|
+
*/
|
|
36
|
+
export function validateBaseMapping(
|
|
37
|
+
manifests: ResourceManifest[],
|
|
38
|
+
registry: DefinitionRegistry,
|
|
39
|
+
aliases: AliasResolver,
|
|
40
|
+
): AnalysisDiagnostic[] {
|
|
41
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
42
|
+
const resolveDef: DefResolver = (k) =>
|
|
43
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
44
|
+
|
|
45
|
+
const importedModules = new Set<string>();
|
|
46
|
+
for (const m of manifests) {
|
|
47
|
+
if (m.kind !== "Telo.Import") continue;
|
|
48
|
+
const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)?.resolvedModuleName;
|
|
49
|
+
if (resolved) importedModules.add(resolved);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const m of manifests) {
|
|
53
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
54
|
+
const base = (m as { base?: unknown }).base;
|
|
55
|
+
if (!base || typeof base !== "object" || Array.isArray(base)) continue;
|
|
56
|
+
const name = m.metadata?.name as string | undefined;
|
|
57
|
+
if (!name) continue;
|
|
58
|
+
const ownModule = (m.metadata as { module?: string } | undefined)?.module;
|
|
59
|
+
if (ownModule && importedModules.has(ownModule)) continue;
|
|
60
|
+
|
|
61
|
+
const parent = resolveParent(m as unknown as ResourceDefinition, resolveDef);
|
|
62
|
+
// Missing / unresolved `extends` is already reported by validateExtends.
|
|
63
|
+
if (!parent) continue;
|
|
64
|
+
const parentSchema = effectiveAuthorSchema(parent, resolveDef);
|
|
65
|
+
if (!parentSchema || typeof parentSchema !== "object") continue;
|
|
66
|
+
|
|
67
|
+
const filePath = (m.metadata as { source?: string } | undefined)?.source;
|
|
68
|
+
const resource = { kind: m.kind, name };
|
|
69
|
+
const label = `${m.kind}/${name}`;
|
|
70
|
+
checkObject(base as Record<string, unknown>, parentSchema, "base", {
|
|
71
|
+
diagnostics,
|
|
72
|
+
registry,
|
|
73
|
+
label,
|
|
74
|
+
resource,
|
|
75
|
+
filePath,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return diagnostics;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface CheckCtx {
|
|
83
|
+
diagnostics: AnalysisDiagnostic[];
|
|
84
|
+
registry: DefinitionRegistry;
|
|
85
|
+
label: string;
|
|
86
|
+
resource: { kind: string; name: string };
|
|
87
|
+
filePath: string | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkObject(
|
|
91
|
+
value: Record<string, unknown>,
|
|
92
|
+
schema: Record<string, any>,
|
|
93
|
+
path: string,
|
|
94
|
+
ctx: CheckCtx,
|
|
95
|
+
): void {
|
|
96
|
+
const properties = (schema.properties ?? {}) as Record<string, Record<string, any>>;
|
|
97
|
+
const required = Array.isArray(schema.required) ? (schema.required as string[]) : [];
|
|
98
|
+
const additionalFalse = schema.additionalProperties === false;
|
|
99
|
+
|
|
100
|
+
for (const req of required) {
|
|
101
|
+
if (!(req in value)) {
|
|
102
|
+
ctx.diagnostics.push({
|
|
103
|
+
severity: DiagnosticSeverity.Error,
|
|
104
|
+
code: "BASE_MISSING_REQUIRED",
|
|
105
|
+
source: SOURCE,
|
|
106
|
+
message: `${ctx.label}: '${path}' does not set required parent field '${req}'.`,
|
|
107
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
113
|
+
const fieldPath = `${path}.${key}`;
|
|
114
|
+
const propSchema = properties[key];
|
|
115
|
+
if (!propSchema) {
|
|
116
|
+
if (additionalFalse) {
|
|
117
|
+
ctx.diagnostics.push({
|
|
118
|
+
severity: DiagnosticSeverity.Error,
|
|
119
|
+
code: "BASE_UNKNOWN_FIELD",
|
|
120
|
+
source: SOURCE,
|
|
121
|
+
message: `${ctx.label}: '${fieldPath}' is not a field of the parent kind's schema.`,
|
|
122
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: fieldPath },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// A CEL-bearing value produces its shape at runtime — not statically
|
|
128
|
+
// checkable. Recurse into a partially-CEL nested object so its literal
|
|
129
|
+
// sub-fields still get validated; fully-literal values validate directly.
|
|
130
|
+
if (containsCel(fieldValue)) {
|
|
131
|
+
if (
|
|
132
|
+
fieldValue &&
|
|
133
|
+
typeof fieldValue === "object" &&
|
|
134
|
+
!Array.isArray(fieldValue) &&
|
|
135
|
+
!isCompiledValue(fieldValue) &&
|
|
136
|
+
propSchema.type === "object" &&
|
|
137
|
+
propSchema.properties
|
|
138
|
+
) {
|
|
139
|
+
checkObject(fieldValue as Record<string, unknown>, propSchema, fieldPath, ctx);
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const issues = ctx.registry.validateWithRefs(fieldValue, propSchema);
|
|
144
|
+
for (const issue of issues) {
|
|
145
|
+
ctx.diagnostics.push({
|
|
146
|
+
severity: DiagnosticSeverity.Error,
|
|
147
|
+
code: "BASE_SCHEMA_MISMATCH",
|
|
148
|
+
source: SOURCE,
|
|
149
|
+
message: `${ctx.label}: '${fieldPath}' does not match the parent field's schema: ${issue}`,
|
|
150
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: fieldPath },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/validate-extends.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ResourceManifest } from "@telorun/sdk";
|
|
1
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { inheritedCapability, type DefResolver } from "./extends-resolution.js";
|
|
4
5
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
5
6
|
|
|
6
7
|
const SOURCE = "telo-analyzer";
|
|
@@ -119,16 +120,28 @@ export function validateExtends(
|
|
|
119
120
|
message: `${label}: 'extends' target '${extendsValue}' (resolved: '${canonical}') is not a registered definition.`,
|
|
120
121
|
data: { resource, filePath, path: "extends" },
|
|
121
122
|
});
|
|
122
|
-
} else
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
} else {
|
|
124
|
+
// General single inheritance: any concrete or abstract kind may be
|
|
125
|
+
// extended. What inheritance must NOT do is change the lifecycle
|
|
126
|
+
// role — a child that restates `capability` differently from an
|
|
127
|
+
// ancestor is a hard error (no silent capability change).
|
|
128
|
+
const resolveDef: DefResolver = (k) =>
|
|
129
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
130
|
+
const ownCap = (m as { capability?: unknown }).capability;
|
|
131
|
+
const ownCapResolved =
|
|
132
|
+
typeof ownCap === "string" ? aliases.resolveKind(ownCap) ?? ownCap : undefined;
|
|
133
|
+
const ancestorCap = inheritedCapability(targetDef, resolveDef);
|
|
134
|
+
if (ownCapResolved && ancestorCap && ownCapResolved !== ancestorCap) {
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
severity: DiagnosticSeverity.Error,
|
|
137
|
+
code: "EXTENDS_CAPABILITY_MISMATCH",
|
|
138
|
+
source: SOURCE,
|
|
139
|
+
message:
|
|
140
|
+
`${label}: declares 'capability: ${ownCap}' but extends '${extendsValue}' whose inherited capability is '${ancestorCap}'. ` +
|
|
141
|
+
`Capability is inherited and immutable — omit 'capability' or restate it identically.`,
|
|
142
|
+
data: { resource, filePath, path: "capability" },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
132
145
|
}
|
|
133
146
|
}
|
|
134
147
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const SOURCE = "telo-analyzer";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Warns when a library exports a kind (via `exports.kinds`) whose local
|
|
8
|
+
* `Telo.Definition` carries no `metadata.description`.
|
|
9
|
+
*
|
|
10
|
+
* The description is the primary human text the federated-discovery hub embeds
|
|
11
|
+
* for semantic search (`search_resources`), so an exported kind without one is
|
|
12
|
+
* undiscoverable by meaning. A warning (not an error) so the stdlib backfill is
|
|
13
|
+
* incremental and CI isn't blocked mid-migration.
|
|
14
|
+
*
|
|
15
|
+
* Scope: only kinds a library *exports* and *defines locally* are checked.
|
|
16
|
+
* Re-exported kinds (`exports.kinds: [Alias.Kind]`) belong to their owning
|
|
17
|
+
* module and are skipped; an exported name that isn't a local Telo.Definition
|
|
18
|
+
* (e.g. a Telo.Abstract) is skipped too. The check keys off the root library's
|
|
19
|
+
* own module doc, which is only present when that library is analyzed directly
|
|
20
|
+
* — so importing an under-described library never leaks warnings to its consumer.
|
|
21
|
+
*/
|
|
22
|
+
export function validateKindDescriptions(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
|
|
23
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
24
|
+
|
|
25
|
+
// Local Telo.Definition docs keyed by `<module>/<name>`.
|
|
26
|
+
const definitions = new Map<string, ResourceManifest>();
|
|
27
|
+
for (const m of manifests) {
|
|
28
|
+
if (m.kind !== "Telo.Definition") continue;
|
|
29
|
+
const name = m.metadata?.name as string | undefined;
|
|
30
|
+
const mod = (m.metadata as { module?: string } | undefined)?.module;
|
|
31
|
+
if (name && mod) definitions.set(`${mod}/${name}`, m);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const m of manifests) {
|
|
35
|
+
if (m.kind !== "Telo.Library") continue;
|
|
36
|
+
const moduleName = m.metadata?.name as string | undefined;
|
|
37
|
+
if (!moduleName) continue;
|
|
38
|
+
const exportedKinds = (m as { exports?: { kinds?: unknown } }).exports?.kinds;
|
|
39
|
+
if (!Array.isArray(exportedKinds)) continue;
|
|
40
|
+
|
|
41
|
+
for (const entry of exportedKinds) {
|
|
42
|
+
// Re-export (`Alias.Kind`) — the owning module owns its description.
|
|
43
|
+
if (typeof entry !== "string" || entry.includes(".")) continue;
|
|
44
|
+
const def = definitions.get(`${moduleName}/${entry}`);
|
|
45
|
+
if (!def) continue; // not a local Telo.Definition (e.g. an abstract)
|
|
46
|
+
const description = (def.metadata as { description?: unknown } | undefined)?.description;
|
|
47
|
+
if (typeof description === "string" && description.trim() !== "") continue;
|
|
48
|
+
diagnostics.push({
|
|
49
|
+
severity: DiagnosticSeverity.Warning,
|
|
50
|
+
code: "KIND_MISSING_DESCRIPTION",
|
|
51
|
+
source: SOURCE,
|
|
52
|
+
message:
|
|
53
|
+
`${moduleName}.${entry}: exported kind has no 'metadata.description'. Add a one-line ` +
|
|
54
|
+
`description — it is the primary text indexed for semantic discovery (search_resources).`,
|
|
55
|
+
data: {
|
|
56
|
+
resource: { kind: "Telo.Definition", name: entry },
|
|
57
|
+
filePath: (def.metadata as { source?: string } | undefined)?.source,
|
|
58
|
+
path: "metadata.description",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return diagnostics;
|
|
65
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ResourceManifest } from "@telorun/sdk";
|
|
1
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import type { AliasResolver } from "./alias-resolver.js";
|
|
3
3
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
4
|
+
import { controllerBearingAncestor, type DefResolver } from "./extends-resolution.js";
|
|
4
5
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
5
6
|
|
|
6
7
|
const SOURCE = "telo-analyzer";
|
|
@@ -234,7 +235,16 @@ export function validateProviderCoherence(
|
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
|
|
238
|
+
// A definition that inherits a controller by delegation (concrete `extends`,
|
|
239
|
+
// no own controller/template) satisfies the implementation requirement
|
|
240
|
+
// through its parent — `base:` supplies the parent's config.
|
|
241
|
+
const resolveDef: DefResolver = (k) =>
|
|
242
|
+
registry.resolve(aliases.resolveKind(k) ?? k) ?? registry.resolve(k);
|
|
243
|
+
const inheritsController =
|
|
244
|
+
typeof md.extends === "string" &&
|
|
245
|
+
controllerBearingAncestor(m as ResourceDefinition, resolveDef) !== undefined;
|
|
246
|
+
|
|
247
|
+
if (capability === "Telo.Provider" && !hasControllers && !hasProvide && !inheritsController) {
|
|
238
248
|
diagnostics.push({
|
|
239
249
|
severity: DiagnosticSeverity.Error,
|
|
240
250
|
code: "PROVIDER_MISSING_IMPLEMENTATION",
|
|
@@ -29,20 +29,24 @@ function checkKind(
|
|
|
29
29
|
if (!targetKind) return [];
|
|
30
30
|
const targetDef = registry.resolve(targetKind);
|
|
31
31
|
if (!targetDef) return [];
|
|
32
|
+
// Liskov substitutability: a value satisfies the slot when it transitively
|
|
33
|
+
// extends the target kind, or — for a CONCRETE target — IS that kind.
|
|
34
|
+
// `getByExtends` is the same transitive subtype index for abstract and
|
|
35
|
+
// concrete targets alike; an abstract is satisfied only by an implementer,
|
|
36
|
+
// never by the abstract kind itself (which is non-instantiable).
|
|
37
|
+
if (targetDef.kind !== "Telo.Abstract" && resolved === targetKind) return [];
|
|
38
|
+
const subtypes = registry.getByExtends(targetKind);
|
|
39
|
+
const subtypeKinds = new Set(subtypes.map((d) => `${d.metadata.module}.${d.metadata.name}`));
|
|
40
|
+
if (subtypeKinds.has(resolved)) return [];
|
|
32
41
|
if (targetDef.kind === "Telo.Abstract") {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const implementingKinds = new Set(
|
|
36
|
-
implementing.map((d) => `${d.metadata.module}.${d.metadata.name}`),
|
|
37
|
-
);
|
|
38
|
-
if (implementingKinds.has(resolved)) return [];
|
|
39
|
-
const options = [...implementingKinds].join(", ");
|
|
42
|
+
if (subtypes.length === 0) return []; // partial context — no implementations loaded yet
|
|
43
|
+
const options = [...subtypeKinds].join(", ");
|
|
40
44
|
errors.push(
|
|
41
45
|
`'${kind}' does not implement '${targetKind}' (known implementations: ${options})`,
|
|
42
46
|
);
|
|
43
47
|
} else {
|
|
44
|
-
|
|
45
|
-
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'`);
|
|
48
|
+
const options = subtypeKinds.size > 0 ? ` or a subtype (${[...subtypeKinds].join(", ")})` : "";
|
|
49
|
+
errors.push(`'${kind}' (resolved: '${resolved}') does not match required '${targetKind}'${options}`);
|
|
46
50
|
}
|
|
47
51
|
}
|
|
48
52
|
return errors;
|