@telorun/analyzer 0.51.0 → 0.53.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.map +1 -1
- package/dist/analyzer.js +126 -4
- package/dist/cel-bindings.d.ts +57 -0
- package/dist/cel-bindings.d.ts.map +1 -0
- package/dist/cel-bindings.js +207 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/sources/integrity.d.ts +10 -0
- package/dist/sources/integrity.d.ts.map +1 -1
- package/dist/sources/integrity.js +18 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +8 -3
- package/dist/validate-module-metadata.d.ts +38 -0
- package/dist/validate-module-metadata.d.ts.map +1 -0
- package/dist/validate-module-metadata.js +256 -0
- package/package.json +2 -2
- package/src/analyzer.ts +151 -3
- package/src/cel-bindings.ts +222 -0
- package/src/index.ts +13 -0
- package/src/sources/integrity.ts +20 -0
- package/src/validate-cel-context.ts +8 -3
- package/src/validate-module-metadata.ts +335 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { isCompiledValue } from "@telorun/sdk";
|
|
2
|
+
import { buildCelEnvironment, extractAccessChains } from "@telorun/templating";
|
|
3
|
+
import { extractContextsFromSchema } from "./validate-cel-context.js";
|
|
4
|
+
|
|
5
|
+
/** Annotation on an `x-telo-context` node naming the resource field that holds
|
|
6
|
+
* the kind's named CEL bindings. The field is read from the RESOURCE ROOT, not
|
|
7
|
+
* the per-scope manifest item: a bindings map belongs to the resource, while the
|
|
8
|
+
* contexts that see it may be anchored anywhere (a decision table annotates both
|
|
9
|
+
* `choices` and `default`). */
|
|
10
|
+
export const BINDINGS_ANNOTATION = "x-telo-bindings-from";
|
|
11
|
+
|
|
12
|
+
export interface BindingSites {
|
|
13
|
+
/** Resource field holding the bindings map. */
|
|
14
|
+
field: string;
|
|
15
|
+
/** Every field named by an annotation on this kind. More than one is a
|
|
16
|
+
* kind-authoring mistake: which of them holds the bindings would be decided
|
|
17
|
+
* by schema walk order. */
|
|
18
|
+
fields: string[];
|
|
19
|
+
/** Variable names the annotated contexts declare — a binding may not shadow one. */
|
|
20
|
+
scopeNames: Set<string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CEL keywords. A binding named after one is unreachable — `true` lexes as a
|
|
25
|
+
* literal, `in` as an operator — so it is reserved alongside the scope names,
|
|
26
|
+
* which turns silence into a diagnostic.
|
|
27
|
+
*/
|
|
28
|
+
export const CEL_RESERVED_WORDS: readonly string[] = [
|
|
29
|
+
"as",
|
|
30
|
+
"break",
|
|
31
|
+
"const",
|
|
32
|
+
"continue",
|
|
33
|
+
"else",
|
|
34
|
+
"false",
|
|
35
|
+
"for",
|
|
36
|
+
"function",
|
|
37
|
+
"if",
|
|
38
|
+
"import",
|
|
39
|
+
"in",
|
|
40
|
+
"let",
|
|
41
|
+
"loop",
|
|
42
|
+
"namespace",
|
|
43
|
+
"null",
|
|
44
|
+
"package",
|
|
45
|
+
"return",
|
|
46
|
+
"true",
|
|
47
|
+
"var",
|
|
48
|
+
"void",
|
|
49
|
+
"while",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Locate a kind's bindings field and the scope names its annotated contexts
|
|
53
|
+
* declare. Returns undefined for a kind that declares no bindings region. */
|
|
54
|
+
export function findBindingSites(
|
|
55
|
+
definitionSchema: Record<string, any> | undefined,
|
|
56
|
+
): BindingSites | undefined {
|
|
57
|
+
if (!definitionSchema) return undefined;
|
|
58
|
+
const fields: string[] = [];
|
|
59
|
+
const scopeNames = new Set<string>();
|
|
60
|
+
for (const { schema } of extractContextsFromSchema(definitionSchema)) {
|
|
61
|
+
const declared = schema?.[BINDINGS_ANNOTATION];
|
|
62
|
+
if (typeof declared !== "string" || declared.length === 0) continue;
|
|
63
|
+
if (!fields.includes(declared)) fields.push(declared);
|
|
64
|
+
for (const name of Object.keys(schema.properties ?? {})) scopeNames.add(name);
|
|
65
|
+
}
|
|
66
|
+
return fields.length > 0 ? { field: fields[0]!, fields, scopeNames } : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TEMPLATE_RE = /\$\{\{\s*([^}]+?)\s*\}\}/g;
|
|
70
|
+
const EXACT_TEMPLATE_RE = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
|
|
71
|
+
|
|
72
|
+
/** Parser for expressions that reach here uncompiled. Built once; the base
|
|
73
|
+
* environment is stateless and shared with the runtime's own. */
|
|
74
|
+
let parseEnv: ReturnType<typeof buildCelEnvironment> | undefined;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Root identifiers an expression source reads — the first element of every
|
|
78
|
+
* member-access chain, which is what a dependency edge is made of.
|
|
79
|
+
*
|
|
80
|
+
* Parsed, never lexed: `inputs.total` reads `inputs`, not `total`, and a name
|
|
81
|
+
* inside a string literal reads nothing. A token scan would make two bindings
|
|
82
|
+
* named after each other's *fields* look mutually recursive and reject a correct
|
|
83
|
+
* manifest — the worst outcome a static check has. An expression that does not
|
|
84
|
+
* parse contributes no edges; its syntax error is the engine pass's to report.
|
|
85
|
+
*/
|
|
86
|
+
function addRootIdentifiers(source: string, out: Set<string>): void {
|
|
87
|
+
try {
|
|
88
|
+
parseEnv ??= buildCelEnvironment();
|
|
89
|
+
for (const chain of extractAccessChains(parseEnv.parse(source).ast)) {
|
|
90
|
+
if (chain.length > 0) out.add(chain[0]!);
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Unparseable — see above.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Root identifiers a binding's value reads. Walks the whole value so a
|
|
98
|
+
* structured binding (a map with `!cel` leaves) is covered, and reads both a
|
|
99
|
+
* compiled expression and a still-raw `${{ }}` string — the editor's
|
|
100
|
+
* round-trip view never compiles. An untagged plain string is a literal, not
|
|
101
|
+
* an expression, and contributes nothing. */
|
|
102
|
+
function collectRefs(value: unknown, out: Set<string>): void {
|
|
103
|
+
if (isCompiledValue(value)) {
|
|
104
|
+
const refs = (value as { refs?: readonly string[] }).refs;
|
|
105
|
+
if (refs) for (const ref of refs) out.add(ref);
|
|
106
|
+
else addRootIdentifiers((value as { source?: string }).source ?? "", out);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value === "string") {
|
|
110
|
+
for (const match of value.matchAll(TEMPLATE_RE)) addRootIdentifiers(match[1]!, out);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
for (const entry of value) collectRefs(entry, out);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (value !== null && typeof value === "object") {
|
|
118
|
+
for (const entry of Object.values(value)) collectRefs(entry, out);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Each binding's dependencies, restricted to its siblings — an identifier that
|
|
123
|
+
* names something else in scope (`inputs`, `item`) is not an edge. */
|
|
124
|
+
export function bindingDependencies(
|
|
125
|
+
bindings: Record<string, unknown>,
|
|
126
|
+
): Map<string, Set<string>> {
|
|
127
|
+
const names = new Set(Object.keys(bindings));
|
|
128
|
+
const deps = new Map<string, Set<string>>();
|
|
129
|
+
for (const [name, value] of Object.entries(bindings)) {
|
|
130
|
+
const refs = new Set<string>();
|
|
131
|
+
collectRefs(value, refs);
|
|
132
|
+
const own = new Set<string>();
|
|
133
|
+
for (const ref of refs) if (names.has(ref)) own.add(ref);
|
|
134
|
+
deps.set(name, own);
|
|
135
|
+
}
|
|
136
|
+
return deps;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Member-access chain for a binding whose value is one bare dotted identifier
|
|
140
|
+
* expression (`inputs.user.name`). Null for anything else — a literal, a call,
|
|
141
|
+
* a comprehension, a structured value — none of which reduces to a typed path. */
|
|
142
|
+
export function bindingPathChain(value: unknown): string[] | null {
|
|
143
|
+
let source: string | undefined;
|
|
144
|
+
if (isCompiledValue(value)) source = (value as { source?: string }).source;
|
|
145
|
+
else if (typeof value === "string") source = value.match(EXACT_TEMPLATE_RE)?.[1];
|
|
146
|
+
if (source === undefined) return null;
|
|
147
|
+
const expr = source.trim();
|
|
148
|
+
if (!/^[A-Za-z_]\w*(\.[A-Za-z_]\w*)*$/.test(expr)) return null;
|
|
149
|
+
return expr.split(".");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The context properties a bindings map contributes: each name typed from its
|
|
154
|
+
* expression where that expression is a chain into an already-typed scope
|
|
155
|
+
* variable, and left open otherwise.
|
|
156
|
+
*
|
|
157
|
+
* Gradual by design — the same stance `x-telo-context-element-from` takes. An
|
|
158
|
+
* open schema costs a missed `CEL_UNKNOWN_FIELD` under that name; inventing a
|
|
159
|
+
* type would cost a false one.
|
|
160
|
+
*/
|
|
161
|
+
export function bindingContextProperties(
|
|
162
|
+
bindings: Record<string, unknown>,
|
|
163
|
+
contextSchema: Record<string, any>,
|
|
164
|
+
): Record<string, any> {
|
|
165
|
+
const props: Record<string, any> = {};
|
|
166
|
+
for (const [name, value] of Object.entries(bindings)) {
|
|
167
|
+
props[name] = schemaAtChain(bindingPathChain(value), contextSchema) ?? {};
|
|
168
|
+
}
|
|
169
|
+
return props;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Walk a member-access chain through a schema's `properties`, returning the
|
|
173
|
+
* terminal node or undefined once the path leaves typed schema. */
|
|
174
|
+
export function schemaAtChain(
|
|
175
|
+
chain: string[] | null,
|
|
176
|
+
root: Record<string, any>,
|
|
177
|
+
): Record<string, any> | undefined {
|
|
178
|
+
if (!chain) return undefined;
|
|
179
|
+
let current: Record<string, any> | undefined = root;
|
|
180
|
+
for (const key of chain) {
|
|
181
|
+
const props = current?.properties as Record<string, any> | undefined;
|
|
182
|
+
if (!props || !(key in props)) return undefined;
|
|
183
|
+
current = props[key] as Record<string, any>;
|
|
184
|
+
}
|
|
185
|
+
return current && typeof current === "object" ? current : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Evaluation order derived from the reference graph, plus any cycles found.
|
|
190
|
+
*
|
|
191
|
+
* Order is what the editor and diagnostics show; the runtime does not need it,
|
|
192
|
+
* since lazy evaluation reaches a binding's dependencies by construction. A
|
|
193
|
+
* cycle is reported as the path that closes it (`a → b → a`).
|
|
194
|
+
*/
|
|
195
|
+
export function resolveBindingOrder(bindings: Record<string, unknown>): {
|
|
196
|
+
order: string[];
|
|
197
|
+
cycles: string[][];
|
|
198
|
+
} {
|
|
199
|
+
const deps = bindingDependencies(bindings);
|
|
200
|
+
const order: string[] = [];
|
|
201
|
+
const cycles: string[][] = [];
|
|
202
|
+
const state = new Map<string, "visiting" | "done">();
|
|
203
|
+
const path: string[] = [];
|
|
204
|
+
|
|
205
|
+
const visit = (name: string): void => {
|
|
206
|
+
const seen = state.get(name);
|
|
207
|
+
if (seen === "done") return;
|
|
208
|
+
if (seen === "visiting") {
|
|
209
|
+
cycles.push([...path.slice(path.indexOf(name)), name]);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
state.set(name, "visiting");
|
|
213
|
+
path.push(name);
|
|
214
|
+
for (const dep of deps.get(name) ?? []) visit(dep);
|
|
215
|
+
path.pop();
|
|
216
|
+
state.set(name, "done");
|
|
217
|
+
order.push(name);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
for (const name of Object.keys(bindings)) visit(name);
|
|
221
|
+
return { order, cycles };
|
|
222
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,14 @@ export {
|
|
|
24
24
|
type ReExportSpec,
|
|
25
25
|
} from "./flatten-for-analyzer.js";
|
|
26
26
|
export { buildEvalPaths, evalPathCovers } from "./eval-paths.js";
|
|
27
|
+
export {
|
|
28
|
+
BINDINGS_ANNOTATION,
|
|
29
|
+
bindingContextProperties,
|
|
30
|
+
bindingDependencies,
|
|
31
|
+
findBindingSites,
|
|
32
|
+
resolveBindingOrder,
|
|
33
|
+
} from "./cel-bindings.js";
|
|
34
|
+
export type { BindingSites } from "./cel-bindings.js";
|
|
27
35
|
export {
|
|
28
36
|
applyObservedStateNode,
|
|
29
37
|
buildObservedStateIndex,
|
|
@@ -111,6 +119,7 @@ export { defaultSources } from "./sources/default-sources.js";
|
|
|
111
119
|
export {
|
|
112
120
|
splitIntegrity,
|
|
113
121
|
foldIntegrity,
|
|
122
|
+
isCanonicalIntegrity,
|
|
114
123
|
verifyIntegrity,
|
|
115
124
|
verifiedFetch,
|
|
116
125
|
sha256Base64Url,
|
|
@@ -158,6 +167,10 @@ export {
|
|
|
158
167
|
} from "./artifact-layer-index.js";
|
|
159
168
|
export type { ArtifactLayer } from "./artifact-layer-index.js";
|
|
160
169
|
export { validateModuleArtifact } from "./validate-module-artifact.js";
|
|
170
|
+
// Warnings everywhere, fatal at `telo publish` — descriptive metadata has no
|
|
171
|
+
// runtime failure mode, so it must not stop a manifest running, but it is the
|
|
172
|
+
// module's public face the moment it is published.
|
|
173
|
+
export { PUBLISH_BLOCKING_CODES } from "./validate-module-metadata.js";
|
|
161
174
|
export { withSyntheticPositions } from "./with-synthetic-positions.js";
|
|
162
175
|
export { documentToAst, parseToAst } from "./yaml-ast.js";
|
|
163
176
|
export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
|
package/src/sources/integrity.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
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
|
+
/** The canonical written form: SHA-256 as unpadded base64url, exactly what
|
|
15
|
+
* {@link sha256Base64Url} emits and what the `layers[].integrity` schema
|
|
16
|
+
* accepts. Stricter than {@link INTEGRITY_FRAGMENT}, which also tolerates the
|
|
17
|
+
* padded / standard-base64 spellings {@link verifyIntegrity} normalizes on the
|
|
18
|
+
* way in — reading is forgiving, writing is not. */
|
|
19
|
+
const CANONICAL_INTEGRITY = /^sha256-[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
|
|
14
21
|
/** A failed integrity/tamper check — always terminal, never best-effort. A
|
|
15
22
|
* distinct type so a caller doing best-effort network handling (e.g. the
|
|
16
23
|
* bundle extractor warning-and-skipping on a fetch blip) can still let a
|
|
@@ -41,6 +48,19 @@ export function foldIntegrity(source: string, integrity: unknown): string {
|
|
|
41
48
|
: source;
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
/** True for a value safe to WRITE into a manifest as an integrity pin.
|
|
52
|
+
*
|
|
53
|
+
* A pin arriving from outside the file — the hub's version index, a registry
|
|
54
|
+
* response — is untrusted text before it is untrusted *content*: a value
|
|
55
|
+
* carrying a quote, a `#`, or a newline corrupts the YAML it is spliced into,
|
|
56
|
+
* which no later install-time verification can catch because the manifest no
|
|
57
|
+
* longer parses. A wrong-but-well-formed hash is the case install *does*
|
|
58
|
+
* catch; this is the case it cannot. Callers writing a pin they did not
|
|
59
|
+
* compute check here first and fall back to writing none. */
|
|
60
|
+
export function isCanonicalIntegrity(value: unknown): value is string {
|
|
61
|
+
return typeof value === "string" && CANONICAL_INTEGRITY.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
function toBase64Url(bytes: Uint8Array): string {
|
|
45
65
|
let binary = "";
|
|
46
66
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
@@ -325,11 +325,16 @@ export function resolveContextAnnotations(
|
|
|
325
325
|
: [fromRefKindRaw];
|
|
326
326
|
if (fromRoot || fromRefKinds.length > 0) {
|
|
327
327
|
if (fromRoot) {
|
|
328
|
-
const
|
|
328
|
+
const navigated = navigatePath(manifestRoot, fromRoot.split("/")) as
|
|
329
329
|
| Record<string, any>
|
|
330
330
|
| undefined;
|
|
331
|
-
if (
|
|
332
|
-
|
|
331
|
+
if (navigated && typeof navigated === "object" && !Array.isArray(navigated)) {
|
|
332
|
+
// A `telo#Type` slot resolves to the schema it names — the inline
|
|
333
|
+
// `{ kind, schema }` wrapper, a `!ref` to a named type, or a bare name —
|
|
334
|
+
// so the variable is typed by the CONTRACT rather than by the wrapper
|
|
335
|
+
// around it. A raw JSON Schema resolves to itself, and a plain property
|
|
336
|
+
// map (a transport scope) resolves to nothing and is used verbatim.
|
|
337
|
+
return resolveTypeFieldToSchema(navigated, allManifests ?? []) ?? navigated;
|
|
333
338
|
}
|
|
334
339
|
}
|
|
335
340
|
if (defs) {
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
import type { AliasResolver } from "./alias-resolver.js";
|
|
4
|
+
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
5
|
+
import { distance } from "./levenshtein.js";
|
|
6
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const SOURCE = "telo-analyzer";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Static validation of the `metadata:` block on module docs (`Telo.Application` /
|
|
12
|
+
* `Telo.Library`) and of `metadata.deprecated` wherever it appears.
|
|
13
|
+
*
|
|
14
|
+
* These fields are descriptive — nothing in the kernel branches on them — but they
|
|
15
|
+
* are the module's public face: a hub indexes them, and a consumer reads them
|
|
16
|
+
* before deciding to import. That is exactly why they need checking. A field the
|
|
17
|
+
* runtime ignores has no failure mode that would ever surface it, so a mistyped
|
|
18
|
+
* `licence:` or `deprecatd:` is invisible forever, and the module ships claiming
|
|
19
|
+
* nothing while its author believes otherwise.
|
|
20
|
+
*
|
|
21
|
+
* The vocabulary stays **open** — `metadata` accepts any key, because a publisher
|
|
22
|
+
* may carry their own — so an unknown key is only reported when it is a near-miss
|
|
23
|
+
* of a known one. That catches the typo without closing the set.
|
|
24
|
+
*
|
|
25
|
+
* **Everything here is a WARNING, and fatal only at `telo publish`** (see
|
|
26
|
+
* {@link PUBLISH_BLOCKING_CODES}). Refusing to *run* a manifest over a field no
|
|
27
|
+
* runtime reads gets the cost backwards: `version: 1.0` is a YAML float rather
|
|
28
|
+
* than a string, which is a real mistake worth reporting, but stopping the app
|
|
29
|
+
* from starting over it is worse than the mistake. Publication is the moment
|
|
30
|
+
* these fields become consequential — they are projected onto the artifact's
|
|
31
|
+
* annotations and indexed by the hub — so that is where they block.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Codes that must not block running a manifest but MUST block publishing one.
|
|
36
|
+
*
|
|
37
|
+
* Kept as a set rather than a severity because the two audiences differ: a
|
|
38
|
+
* developer running a manifest wants to know, a publisher must be stopped. If a
|
|
39
|
+
* later check earns the same treatment, add its code here rather than inventing
|
|
40
|
+
* a third severity level.
|
|
41
|
+
*/
|
|
42
|
+
export const PUBLISH_BLOCKING_CODES: ReadonlySet<string> = new Set([
|
|
43
|
+
"METADATA_INVALID_TYPE",
|
|
44
|
+
"METADATA_UNKNOWN_FIELD",
|
|
45
|
+
"INVALID_DEPRECATION",
|
|
46
|
+
"DEPRECATION_REPLACEMENT_UNRESOLVED",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
type FieldType = "string" | "string[]" | "object";
|
|
50
|
+
|
|
51
|
+
/** Conventional module-doc metadata, and the type each carries. Descriptive only;
|
|
52
|
+
* `name` is the sole field anything resolves against. */
|
|
53
|
+
const MODULE_METADATA_TYPES: Record<string, FieldType> = {
|
|
54
|
+
name: "string",
|
|
55
|
+
module: "string",
|
|
56
|
+
version: "string",
|
|
57
|
+
description: "string",
|
|
58
|
+
repository: "string",
|
|
59
|
+
homepage: "string",
|
|
60
|
+
documentation: "string",
|
|
61
|
+
license: "string",
|
|
62
|
+
namespace: "string",
|
|
63
|
+
categories: "string[]",
|
|
64
|
+
deprecated: "object",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** What a kind doc's `metadata:` may carry.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately narrower than a module's: `version`, `license` and the rest
|
|
70
|
+
* belong to the module, and a kind restating them means nothing. `categories`
|
|
71
|
+
* is legal and *replaces* the module's for that kind; `description` is hub
|
|
72
|
+
* search text. Both have exactly the failure mode this file exists for — a
|
|
73
|
+
* `descriptoin:` on a kind doc is read by nothing and reported by nothing, so
|
|
74
|
+
* it ships silently — which is why kind docs are checked rather than exempt. */
|
|
75
|
+
const KIND_METADATA_TYPES: Record<string, FieldType> = {
|
|
76
|
+
name: "string",
|
|
77
|
+
module: "string",
|
|
78
|
+
description: "string",
|
|
79
|
+
categories: "string[]",
|
|
80
|
+
deprecated: "object",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Alias-qualified kind — `Self.Migrations`, `Cache.Store`, `Telo.JsonSchema`. */
|
|
84
|
+
const ALIAS_KIND_RE = /^[A-Z][A-Za-z0-9_]*\.[A-Z][A-Za-z0-9_]*$/;
|
|
85
|
+
|
|
86
|
+
/** The built-in namespace, resolvable without an import — mirrors `validate-extends`. */
|
|
87
|
+
const TELO_BUILTIN_ALIAS = "Telo";
|
|
88
|
+
|
|
89
|
+
function typeOf(value: unknown): "string" | "string[]" | "object" | "other" {
|
|
90
|
+
if (typeof value === "string") return "string";
|
|
91
|
+
if (Array.isArray(value)) return value.every((v) => typeof v === "string") ? "string[]" : "other";
|
|
92
|
+
if (value !== null && typeof value === "object") return "object";
|
|
93
|
+
return "other";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function validateModuleMetadata(
|
|
97
|
+
manifests: ResourceManifest[],
|
|
98
|
+
registry: DefinitionRegistry,
|
|
99
|
+
aliases: AliasResolver,
|
|
100
|
+
): AnalysisDiagnostic[] {
|
|
101
|
+
const out: AnalysisDiagnostic[] = [];
|
|
102
|
+
|
|
103
|
+
// Docs forwarded from imported libraries carry `metadata.module` set to that
|
|
104
|
+
// library's name. Their `replacedBy` aliases — `Self`, or any alias private to
|
|
105
|
+
// that library — belong to the library's OWN scope, which the consumer's
|
|
106
|
+
// resolver knows nothing about, so re-checking them here reports a false
|
|
107
|
+
// DEPRECATION_REPLACEMENT_UNRESOLVED against a manifest the consumer does not
|
|
108
|
+
// own. They are validated when that library is analyzed as a root, which is
|
|
109
|
+
// its author's concern. Same rule, and the same reason, as `validate-extends`.
|
|
110
|
+
const importedModules = new Set<string>();
|
|
111
|
+
for (const m of manifests) {
|
|
112
|
+
if (m.kind !== "Telo.Import") continue;
|
|
113
|
+
const resolved = (m.metadata as { resolvedModuleName?: string } | undefined)
|
|
114
|
+
?.resolvedModuleName;
|
|
115
|
+
if (resolved) importedModules.add(resolved);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const manifest of manifests) {
|
|
119
|
+
const isModuleDoc = manifest.kind === "Telo.Application" || manifest.kind === "Telo.Library";
|
|
120
|
+
const isKindDoc = manifest.kind === "Telo.Definition" || manifest.kind === "Telo.Abstract";
|
|
121
|
+
if (!isModuleDoc && !isKindDoc) continue;
|
|
122
|
+
|
|
123
|
+
const metadata = manifest.metadata as Record<string, unknown> | undefined;
|
|
124
|
+
if (!metadata) continue;
|
|
125
|
+
|
|
126
|
+
const ownModule = (metadata as { module?: string }).module;
|
|
127
|
+
if (ownModule && importedModules.has(ownModule)) continue;
|
|
128
|
+
|
|
129
|
+
const name = typeof metadata.name === "string" ? metadata.name : undefined;
|
|
130
|
+
const filePath = typeof metadata.source === "string" ? metadata.source : undefined;
|
|
131
|
+
const ctx = {
|
|
132
|
+
label: `${manifest.kind}/${name ?? "(unnamed)"}`,
|
|
133
|
+
resource: { kind: manifest.kind, name },
|
|
134
|
+
filePath,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
validateFieldTypes(
|
|
138
|
+
metadata,
|
|
139
|
+
isModuleDoc ? MODULE_METADATA_TYPES : KIND_METADATA_TYPES,
|
|
140
|
+
ctx,
|
|
141
|
+
out,
|
|
142
|
+
);
|
|
143
|
+
validateDeprecation(metadata, isModuleDoc, ctx, registry, aliases, out);
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface DocContext {
|
|
149
|
+
label: string;
|
|
150
|
+
resource: { kind: string; name: string | undefined };
|
|
151
|
+
filePath: string | undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** How far a key may be from a known one and still be called a typo.
|
|
155
|
+
*
|
|
156
|
+
* Scaled, not absolute: at a flat 2, `date:` (a perfectly ordinary key an
|
|
157
|
+
* author might carry) is two edits from `name` and gets told it is a
|
|
158
|
+
* misspelling of it. The vocabulary is open, so a false accusation on a short
|
|
159
|
+
* key is worse than missing a typo on one. */
|
|
160
|
+
function typoThreshold(key: string, known: string): number {
|
|
161
|
+
return Math.max(1, Math.floor(Math.min(key.length, known.length) / 3));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateFieldTypes(
|
|
165
|
+
metadata: Record<string, unknown>,
|
|
166
|
+
allowed: Record<string, FieldType>,
|
|
167
|
+
ctx: DocContext,
|
|
168
|
+
out: AnalysisDiagnostic[],
|
|
169
|
+
): void {
|
|
170
|
+
const known = Object.keys(allowed);
|
|
171
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
172
|
+
// Stamped by the loader, not authored — never a typo to report on.
|
|
173
|
+
if (key === "source") continue;
|
|
174
|
+
|
|
175
|
+
// Listed as known so a typo still gets suggested against it, but its shape
|
|
176
|
+
// belongs to `validateDeprecation`, which can say what is actually wrong.
|
|
177
|
+
// Type-checking it here too would report one mistake twice.
|
|
178
|
+
if (key === "deprecated") continue;
|
|
179
|
+
|
|
180
|
+
const expected = allowed[key];
|
|
181
|
+
if (expected === undefined) {
|
|
182
|
+
const near = known.find((k) => distance(key, k) <= typoThreshold(key, k));
|
|
183
|
+
if (near) {
|
|
184
|
+
out.push({
|
|
185
|
+
severity: DiagnosticSeverity.Warning,
|
|
186
|
+
code: "METADATA_UNKNOWN_FIELD",
|
|
187
|
+
source: SOURCE,
|
|
188
|
+
message:
|
|
189
|
+
`${ctx.label}: 'metadata.${key}' is not a known field — did you mean '${near}'? ` +
|
|
190
|
+
`Nothing reads an unrecognized key, so this declares nothing.`,
|
|
191
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: `metadata.${key}` },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (typeOf(value) !== expected) {
|
|
198
|
+
out.push({
|
|
199
|
+
severity: DiagnosticSeverity.Warning,
|
|
200
|
+
code: "METADATA_INVALID_TYPE",
|
|
201
|
+
source: SOURCE,
|
|
202
|
+
message: `${ctx.label}: 'metadata.${key}' must be ${describeType(expected)}.`,
|
|
203
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path: `metadata.${key}` },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function describeType(t: "string" | "string[]" | "object"): string {
|
|
210
|
+
if (t === "string[]") return "an array of strings";
|
|
211
|
+
if (t === "object") return "an object";
|
|
212
|
+
return "a string";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `metadata.deprecated: { reason, replacedBy? }`.
|
|
217
|
+
*
|
|
218
|
+
* `replacedBy` is deliberately resolvable rather than free text, and its form
|
|
219
|
+
* follows the level: a module doc names another **module ref** (the `imports:`
|
|
220
|
+
* source grammar), a kind doc names an **alias-qualified kind** resolved through
|
|
221
|
+
* this file's own imports — the same grammar `kind:` / `extends:` use, so the
|
|
222
|
+
* replacement is a link a consumer can follow rather than a sentence they have to
|
|
223
|
+
* interpret.
|
|
224
|
+
*
|
|
225
|
+
* A kind whose replacement lives in a module this one does not import cannot be
|
|
226
|
+
* named; that case deprecates at module level with a module ref instead. Accepted
|
|
227
|
+
* over inventing a second grammar for it.
|
|
228
|
+
*/
|
|
229
|
+
function validateDeprecation(
|
|
230
|
+
metadata: Record<string, unknown>,
|
|
231
|
+
isModuleDoc: boolean,
|
|
232
|
+
ctx: DocContext,
|
|
233
|
+
registry: DefinitionRegistry,
|
|
234
|
+
aliases: AliasResolver,
|
|
235
|
+
out: AnalysisDiagnostic[],
|
|
236
|
+
): void {
|
|
237
|
+
const deprecated = metadata.deprecated;
|
|
238
|
+
if (deprecated === undefined) return;
|
|
239
|
+
|
|
240
|
+
const at = "metadata.deprecated";
|
|
241
|
+
const push = (
|
|
242
|
+
code: string,
|
|
243
|
+
message: string,
|
|
244
|
+
path = at,
|
|
245
|
+
severity = DiagnosticSeverity.Warning,
|
|
246
|
+
): void => {
|
|
247
|
+
out.push({
|
|
248
|
+
severity,
|
|
249
|
+
code,
|
|
250
|
+
source: SOURCE,
|
|
251
|
+
message: `${ctx.label}: ${message}`,
|
|
252
|
+
data: { resource: ctx.resource, filePath: ctx.filePath, path },
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
if (typeOf(deprecated) !== "object") {
|
|
257
|
+
push(
|
|
258
|
+
"INVALID_DEPRECATION",
|
|
259
|
+
`'${at}' must be an object with a 'reason' (and an optional 'replacedBy'). ` +
|
|
260
|
+
`A bare 'true' says a thing is deprecated without saying what to do instead.`,
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const block = deprecated as Record<string, unknown>;
|
|
266
|
+
const allowed = new Set(["reason", "replacedBy"]);
|
|
267
|
+
for (const key of Object.keys(block)) {
|
|
268
|
+
if (!allowed.has(key)) {
|
|
269
|
+
push("INVALID_DEPRECATION", `'${at}.${key}' is not a recognized key (reason, replacedBy).`, `${at}.${key}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (typeof block.reason !== "string" || block.reason.trim() === "") {
|
|
274
|
+
push(
|
|
275
|
+
"INVALID_DEPRECATION",
|
|
276
|
+
`'${at}.reason' is required and must be a non-empty string — it is what a consumer reads to know what to do instead.`,
|
|
277
|
+
`${at}.reason`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const replacedBy = block.replacedBy;
|
|
282
|
+
if (replacedBy === undefined) return;
|
|
283
|
+
const path = `${at}.replacedBy`;
|
|
284
|
+
if (typeof replacedBy !== "string" || replacedBy.trim() === "") {
|
|
285
|
+
push("INVALID_DEPRECATION", `'${path}' must be a non-empty string.`, path);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (isModuleDoc) {
|
|
290
|
+
// A module is replaced by another module, addressed the way an import is.
|
|
291
|
+
// Catching alias form here is worth a dedicated message: it is the natural
|
|
292
|
+
// mistake, and it would otherwise be stored as an unresolvable ref.
|
|
293
|
+
if (ALIAS_KIND_RE.test(replacedBy)) {
|
|
294
|
+
push(
|
|
295
|
+
"INVALID_DEPRECATION",
|
|
296
|
+
`'${path}: ${replacedBy}' looks like a kind reference, but a module doc's replacement is a ` +
|
|
297
|
+
`module ref (e.g. 'oci://ghcr.io/acme/thing'). Deprecate the kind itself to point at another kind.`,
|
|
298
|
+
path,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Kind level: resolve through this file's imports, exactly as `extends` does.
|
|
305
|
+
if (!ALIAS_KIND_RE.test(replacedBy)) {
|
|
306
|
+
push(
|
|
307
|
+
"INVALID_DEPRECATION",
|
|
308
|
+
`'${path}: ${replacedBy}' must be an alias-qualified kind ("<Alias>.<Kind>", ` +
|
|
309
|
+
`e.g. 'Self.Migrations'), resolved via this file's imports.`,
|
|
310
|
+
path,
|
|
311
|
+
);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const prefix = replacedBy.slice(0, replacedBy.indexOf("."));
|
|
316
|
+
if (prefix !== TELO_BUILTIN_ALIAS && !aliases.hasAlias(prefix)) {
|
|
317
|
+
push(
|
|
318
|
+
"DEPRECATION_REPLACEMENT_UNRESOLVED",
|
|
319
|
+
`'${path}: ${replacedBy}' — alias '${prefix}' is not an import in this file's scope. ` +
|
|
320
|
+
`Declare the import or correct the alias.`,
|
|
321
|
+
path,
|
|
322
|
+
);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const canonical = aliases.resolveKind(replacedBy);
|
|
327
|
+
if (!canonical || !registry.resolve(canonical)) {
|
|
328
|
+
push(
|
|
329
|
+
"DEPRECATION_REPLACEMENT_UNRESOLVED",
|
|
330
|
+
`'${path}: ${replacedBy}' does not resolve to a known kind. A replacement a consumer ` +
|
|
331
|
+
`cannot follow is no better than none.`,
|
|
332
|
+
path,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|