@telorun/analyzer 0.59.0 → 0.61.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 +9 -2
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +38 -10
- package/dist/artifact-layer-index.d.ts +13 -5
- package/dist/artifact-layer-index.d.ts.map +1 -1
- package/dist/artifact-layer-index.js +37 -13
- package/dist/artifact-selector.d.ts +10 -3
- package/dist/artifact-selector.d.ts.map +1 -1
- package/dist/artifact-selector.js +10 -1
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +44 -43
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +35 -0
- package/dist/flatten-for-analyzer.d.ts +4 -0
- package/dist/flatten-for-analyzer.d.ts.map +1 -1
- package/dist/flatten-for-analyzer.js +8 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/kernel-globals.d.ts +1 -1
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +15 -1
- package/dist/manifest-schemas.d.ts +407 -0
- package/dist/manifest-schemas.d.ts.map +1 -0
- package/dist/manifest-schemas.js +396 -0
- package/dist/module-file-claims.d.ts +13 -0
- package/dist/module-file-claims.d.ts.map +1 -1
- package/dist/module-file-claims.js +26 -2
- package/dist/module-library.d.ts +97 -0
- package/dist/module-library.d.ts.map +1 -0
- package/dist/module-library.js +162 -0
- package/dist/module-metadata-scope.d.ts +44 -0
- package/dist/module-metadata-scope.d.ts.map +1 -0
- package/dist/module-metadata-scope.js +82 -0
- package/dist/parse-loaded-file.d.ts.map +1 -1
- package/dist/parse-loaded-file.js +16 -0
- package/dist/release/bump-level.d.ts +46 -0
- package/dist/release/bump-level.d.ts.map +1 -0
- package/dist/release/bump-level.js +80 -0
- package/dist/release/changelog.d.ts +36 -0
- package/dist/release/changelog.d.ts.map +1 -0
- package/dist/release/changelog.js +53 -0
- package/dist/release/fragment.d.ts +48 -0
- package/dist/release/fragment.d.ts.map +1 -0
- package/dist/release/fragment.js +84 -0
- package/dist/release/index.d.ts +26 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +18 -0
- package/dist/release/ledger.d.ts +53 -0
- package/dist/release/ledger.d.ts.map +1 -0
- package/dist/release/ledger.js +109 -0
- package/dist/release/payload-digest.d.ts +63 -0
- package/dist/release/payload-digest.d.ts.map +1 -0
- package/dist/release/payload-digest.js +65 -0
- package/dist/release/release-plan.d.ts +128 -0
- package/dist/release/release-plan.d.ts.map +1 -0
- package/dist/release/release-plan.js +277 -0
- package/dist/release/version-stamp.d.ts +50 -0
- package/dist/release/version-stamp.d.ts.map +1 -0
- package/dist/release/version-stamp.js +119 -0
- package/dist/release/workspace-config.d.ts +41 -0
- package/dist/release/workspace-config.d.ts.map +1 -0
- package/dist/release/workspace-config.js +60 -0
- package/dist/schema-compat.js +2 -1
- package/dist/validate-module-artifact.d.ts.map +1 -1
- package/dist/validate-module-artifact.js +53 -1
- package/dist/validate-step-inputs.d.ts +1 -1
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +99 -8
- package/package.json +3 -3
- package/src/analyzer.ts +41 -10
- package/src/artifact-layer-index.ts +47 -14
- package/src/artifact-selector.ts +15 -4
- package/src/builtins.ts +45 -43
- package/src/cel-environment.ts +37 -0
- package/src/flatten-for-analyzer.ts +12 -0
- package/src/index.ts +19 -2
- package/src/kernel-globals.ts +23 -1
- package/src/manifest-schemas.ts +408 -0
- package/src/module-file-claims.ts +42 -2
- package/src/module-library.ts +208 -0
- package/src/module-metadata-scope.ts +88 -0
- package/src/parse-loaded-file.ts +16 -0
- package/src/release/bump-level.ts +95 -0
- package/src/release/changelog.ts +62 -0
- package/src/release/fragment.ts +130 -0
- package/src/release/index.ts +66 -0
- package/src/release/ledger.ts +142 -0
- package/src/release/payload-digest.ts +83 -0
- package/src/release/release-plan.ts +392 -0
- package/src/release/version-stamp.ts +142 -0
- package/src/release/workspace-config.ts +81 -0
- package/src/schema-compat.ts +1 -1
- package/src/validate-module-artifact.ts +56 -0
- package/src/validate-step-inputs.ts +109 -9
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing a module's one version into every manifest it owns.
|
|
3
|
+
*
|
|
4
|
+
* A module has a single version across `telo.yaml`, `nodejs/package.json` and
|
|
5
|
+
* `rust/Cargo.toml`. Three formats, one rule: find the scalar, splice over its
|
|
6
|
+
* span, touch nothing else. That is `yaml-source-edit.ts`'s primitive — the same
|
|
7
|
+
* one the quick fix, `telo migrate` and `telo upgrade`'s pin rewrite use — so a
|
|
8
|
+
* bump lands as a one-line diff instead of a re-serialized file that re-folds
|
|
9
|
+
* every block scalar in a 900-line manifest.
|
|
10
|
+
*
|
|
11
|
+
* Each stamp returns `undefined` when the file carries no version to write,
|
|
12
|
+
* which is not an error: a module may own only a `telo.yaml`, and 42 of the
|
|
13
|
+
* standard library's packages have no Rust crate. A file that *has* a version in
|
|
14
|
+
* a shape this cannot address is a hard error instead, because silently skipping
|
|
15
|
+
* it would publish an artifact whose manifests disagree about what it is.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { defaultCustomTags } from "@telorun/templating";
|
|
19
|
+
import { isScalar, parseAllDocuments, parseDocument, type Scalar } from "yaml";
|
|
20
|
+
import { applyTextEdits, renderFixReplacement } from "../yaml-source-edit.js";
|
|
21
|
+
|
|
22
|
+
export class VersionStampError extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Replace the scalar at `[start, end)` with `version`, re-quoted in the
|
|
25
|
+
* author's own style. */
|
|
26
|
+
function spliceScalar(text: string, node: Scalar, version: string, where: string): string {
|
|
27
|
+
const range = node.range;
|
|
28
|
+
if (!range) {
|
|
29
|
+
throw new VersionStampError(`${where}: the version scalar carries no source range.`);
|
|
30
|
+
}
|
|
31
|
+
const [start, end] = range;
|
|
32
|
+
const source = text.slice(start, end);
|
|
33
|
+
const replacement = renderFixReplacement(source, version);
|
|
34
|
+
if (replacement === undefined) {
|
|
35
|
+
throw new VersionStampError(
|
|
36
|
+
`${where}: the version is written as '${source}', which cannot be rewritten in place. ` +
|
|
37
|
+
`Write it as a plain or quoted scalar on one line.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return applyTextEdits(text, [{ start, end, newText: replacement }]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `metadata.version` on the **module doc** — the first document, the one whose
|
|
45
|
+
* kind is `Telo.Application` or `Telo.Library`. Deliberately not "any
|
|
46
|
+
* `metadata.version` in the file": a `Telo.Definition` further down may carry
|
|
47
|
+
* one, and the regex-replacement changie was configured with matched by line
|
|
48
|
+
* shape rather than by position, which is why it needed a hand-maintained count
|
|
49
|
+
* of how many lines it was allowed to hit.
|
|
50
|
+
*/
|
|
51
|
+
export function stampManifestVersion(
|
|
52
|
+
text: string,
|
|
53
|
+
version: string,
|
|
54
|
+
where: string,
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
57
|
+
const doc = docs[0];
|
|
58
|
+
if (!doc) return undefined;
|
|
59
|
+
const metadata = doc.get("metadata", true);
|
|
60
|
+
if (!metadata || typeof (metadata as { get?: unknown }).get !== "function") return undefined;
|
|
61
|
+
const node = (metadata as { get(key: string, keepScalar: boolean): unknown }).get(
|
|
62
|
+
"version",
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
if (!isScalar(node)) return undefined;
|
|
66
|
+
return spliceScalar(text, node, version, where);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the module doc's `metadata.version` without rewriting it. */
|
|
70
|
+
export function readManifestVersion(text: string): string | undefined {
|
|
71
|
+
const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
|
|
72
|
+
const first = docs[0]?.toJSON() as { kind?: unknown; metadata?: { version?: unknown } } | undefined;
|
|
73
|
+
if (first?.kind !== "Telo.Application" && first?.kind !== "Telo.Library") return undefined;
|
|
74
|
+
const version = first.metadata?.version;
|
|
75
|
+
return typeof version === "string" ? version : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The top-level `"version"` of a `package.json`.
|
|
80
|
+
*
|
|
81
|
+
* Parsed with the YAML reader rather than `JSON.parse`, because JSON is a YAML
|
|
82
|
+
* subset and this one needs the node's *source range* — `JSON.parse` discards it,
|
|
83
|
+
* and re-serializing with `JSON.stringify` would reformat a file whose
|
|
84
|
+
* indentation, key order and trailing newline are all conventions someone chose.
|
|
85
|
+
*/
|
|
86
|
+
export function stampPackageVersion(
|
|
87
|
+
text: string,
|
|
88
|
+
version: string,
|
|
89
|
+
where: string,
|
|
90
|
+
): string | undefined {
|
|
91
|
+
const doc = parseDocument(text);
|
|
92
|
+
const node = doc.get("version", true);
|
|
93
|
+
if (!isScalar(node)) return undefined;
|
|
94
|
+
return spliceScalar(text, node, version, where);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `version` in a `Cargo.toml`'s `[package]` table.
|
|
99
|
+
*
|
|
100
|
+
* Scanned rather than parsed: TOML is not YAML, adding a TOML parser to the
|
|
101
|
+
* browser-safe analyzer for one scalar is not a trade worth making, and the
|
|
102
|
+
* shape being addressed is the canonical one cargo itself writes. The scan is
|
|
103
|
+
* bounded to the `[package]` table so a `version` under `[dependencies.x]`
|
|
104
|
+
* cannot be hit, and a `[package]` whose version is not a simple quoted scalar
|
|
105
|
+
* is refused rather than guessed at.
|
|
106
|
+
*/
|
|
107
|
+
export function stampCrateVersion(
|
|
108
|
+
text: string,
|
|
109
|
+
version: string,
|
|
110
|
+
where: string,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
const table = /^[ \t]*\[package\][ \t]*$/m.exec(text);
|
|
113
|
+
if (!table) return undefined;
|
|
114
|
+
const bodyStart = table.index + table[0].length;
|
|
115
|
+
const next = /^[ \t]*\[/m.exec(text.slice(bodyStart));
|
|
116
|
+
const bodyEnd = next ? bodyStart + next.index : text.length;
|
|
117
|
+
|
|
118
|
+
const entry = /^([ \t]*version[ \t]*=[ \t]*)(".*?"|'.*?')[ \t]*$/m.exec(
|
|
119
|
+
text.slice(bodyStart, bodyEnd),
|
|
120
|
+
);
|
|
121
|
+
if (!entry) {
|
|
122
|
+
// A `[package]` with `version.workspace = true` inherits from the workspace
|
|
123
|
+
// and genuinely has nothing here to stamp; anything else is a shape this
|
|
124
|
+
// cannot address, and writing nothing would leave the crate behind.
|
|
125
|
+
if (/^[ \t]*version[ \t]*\.[ \t]*workspace[ \t]*=/m.test(text.slice(bodyStart, bodyEnd))) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
if (/^[ \t]*version[ \t]*=/m.test(text.slice(bodyStart, bodyEnd))) {
|
|
129
|
+
throw new VersionStampError(
|
|
130
|
+
`${where}: [package].version is not a quoted scalar on one line, so it cannot be ` +
|
|
131
|
+
`rewritten in place.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const start = bodyStart + entry.index + entry[1].length;
|
|
138
|
+
const quote = entry[2][0];
|
|
139
|
+
return applyTextEdits(text, [
|
|
140
|
+
{ start, end: start + entry[2].length, newText: `${quote}${version}${quote}` },
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `telo-workspace.yaml` — the release anchor.
|
|
3
|
+
*
|
|
4
|
+
* Every path the release system names — a module key, a ledger entry, a
|
|
5
|
+
* fragment's `modules:` — is relative to this file's directory. That is the
|
|
6
|
+
* whole of its job: **its location is the anchor**, and its one field names the
|
|
7
|
+
* subtrees that may hold modules, which is not derivable (a whole-tree scan
|
|
8
|
+
* would read every example and every cached `.telo/manifests` copy as a released
|
|
9
|
+
* module).
|
|
10
|
+
*
|
|
11
|
+
* The file is **optional and read only by `telo release`**. Nothing else — not
|
|
12
|
+
* `run`, `check`, `publish`, `install`, `upgrade`, `migrate`, `module`, and not
|
|
13
|
+
* the kernel — looks for it, so a single-manifest repo, a bare `examples/`
|
|
14
|
+
* directory and a third-party module checkout keep working with nothing added.
|
|
15
|
+
* Any field added later must be true of the whole tree, not derivable from it,
|
|
16
|
+
* and harmless by its absence.
|
|
17
|
+
*
|
|
18
|
+
* Parsing lives here, in the browser-safe half, because the editor answers
|
|
19
|
+
* "what does changing this library bump?" from the same model. Finding the file
|
|
20
|
+
* on disk is the CLI's half — this side takes text.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { parseDocument } from "yaml";
|
|
24
|
+
|
|
25
|
+
export const WORKSPACE_FILENAME = "telo-workspace.yaml";
|
|
26
|
+
|
|
27
|
+
export interface WorkspaceConfig {
|
|
28
|
+
/**
|
|
29
|
+
* Gitignore-style patterns, workspace-relative, naming the subtrees that may
|
|
30
|
+
* hold modules (`modules/*`, `apps/*`). A pattern names a place to look, never
|
|
31
|
+
* a module: what makes a directory a module is its `telo.yaml`.
|
|
32
|
+
*/
|
|
33
|
+
readonly modules: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class WorkspaceConfigError extends Error {}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse the marker file's text.
|
|
40
|
+
*
|
|
41
|
+
* Strict about its one field, because there is nothing here to be lenient with:
|
|
42
|
+
* an empty or absent `modules:` names no subtree, so discovery would find no
|
|
43
|
+
* module and every gate would silently pass over a whole repo.
|
|
44
|
+
*/
|
|
45
|
+
export function parseWorkspaceConfig(text: string, where: string): WorkspaceConfig {
|
|
46
|
+
let value: unknown;
|
|
47
|
+
try {
|
|
48
|
+
value = parseDocument(text).toJSON();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new WorkspaceConfigError(
|
|
51
|
+
`${where} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
55
|
+
throw new WorkspaceConfigError(`${where} must be a YAML mapping.`);
|
|
56
|
+
}
|
|
57
|
+
const record = value as Record<string, unknown>;
|
|
58
|
+
|
|
59
|
+
for (const key of Object.keys(record)) {
|
|
60
|
+
if (key !== "modules") {
|
|
61
|
+
throw new WorkspaceConfigError(
|
|
62
|
+
`${where}: unknown field '${key}'. The workspace marker carries only 'modules:' — ` +
|
|
63
|
+
`the subtrees that may hold modules.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const modules = record.modules;
|
|
69
|
+
if (!Array.isArray(modules) || modules.some((entry) => typeof entry !== "string")) {
|
|
70
|
+
throw new WorkspaceConfigError(
|
|
71
|
+
`${where}: 'modules' must be a list of path patterns, e.g. [modules/*, apps/*].`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (modules.length === 0) {
|
|
75
|
+
throw new WorkspaceConfigError(
|
|
76
|
+
`${where}: 'modules' is empty, so no directory can ever be discovered as a module. ` +
|
|
77
|
+
`List the subtrees that hold them, e.g. [modules/*, apps/*].`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return { modules: modules as string[] };
|
|
81
|
+
}
|
package/src/schema-compat.ts
CHANGED
|
@@ -3,7 +3,6 @@ import addFormats from "ajv-formats";
|
|
|
3
3
|
import {
|
|
4
4
|
isRefSentinel,
|
|
5
5
|
isTaggedSentinel,
|
|
6
|
-
ManifestRootSchema,
|
|
7
6
|
producedTypeOf,
|
|
8
7
|
} from "@telorun/templating";
|
|
9
8
|
import {
|
|
@@ -14,6 +13,7 @@ import {
|
|
|
14
13
|
valueTypeOf,
|
|
15
14
|
valueTypePlaceholder,
|
|
16
15
|
} from "@telorun/sdk";
|
|
16
|
+
import { ManifestRootSchema } from "./manifest-schemas.js";
|
|
17
17
|
import { registerTeloKeywords } from "./value-type-keyword.js";
|
|
18
18
|
|
|
19
19
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
ArtifactSelectorError,
|
|
6
6
|
PLATFORM_AXES,
|
|
7
7
|
selectorFromQualifiers,
|
|
8
|
+
selectorKey,
|
|
8
9
|
} from "./artifact-selector.js";
|
|
10
|
+
import { readLibraryCandidates, type LibraryCandidate } from "./module-library.js";
|
|
9
11
|
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
10
12
|
|
|
11
13
|
const SOURCE = "telo-analyzer";
|
|
@@ -38,10 +40,64 @@ export function validateModuleArtifact(manifests: ResourceManifest[]): AnalysisD
|
|
|
38
40
|
for (const manifest of manifests) {
|
|
39
41
|
validateLayerIndex(manifest, out);
|
|
40
42
|
validateControllerSelectors(manifest, out);
|
|
43
|
+
validateLibraryCandidates(manifest, out);
|
|
41
44
|
}
|
|
42
45
|
return out;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The `exports.code:` block on a `Telo.Library` doc.
|
|
50
|
+
*
|
|
51
|
+
* Reported here rather than left to the loader for the same reason a controller
|
|
52
|
+
* selector is: an entry that cannot be read names no entry point, so a
|
|
53
|
+
* consumer's bundle falls back to *inlining* the library — the module scope
|
|
54
|
+
* duplication this whole mechanism exists to remove — and it does so silently, on
|
|
55
|
+
* someone else's machine.
|
|
56
|
+
*/
|
|
57
|
+
function validateLibraryCandidates(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
|
|
58
|
+
// `Telo.Library` only. An application is a root with no importer, so it has no
|
|
59
|
+
// `exports:` block at all — and its schema is `additionalProperties: false`,
|
|
60
|
+
// so AJV already rejects the key by name in this same pass. A second
|
|
61
|
+
// diagnostic on that node would be two squiggles saying one thing.
|
|
62
|
+
if (manifest.kind !== "Telo.Library") return;
|
|
63
|
+
const metadata = manifest.metadata as { name?: string; source?: string } | undefined;
|
|
64
|
+
const { candidates, problems } = readLibraryCandidates(manifest);
|
|
65
|
+
const resource = { kind: manifest.kind, name: metadata?.name };
|
|
66
|
+
|
|
67
|
+
for (const problem of problems) {
|
|
68
|
+
out.push({
|
|
69
|
+
severity: DiagnosticSeverity.Error,
|
|
70
|
+
code: "LIBRARY_CANDIDATE_INVALID",
|
|
71
|
+
source: SOURCE,
|
|
72
|
+
message: `Telo.Library/${metadata?.name ?? "(unnamed)"}: ${problem.origin}: ${problem.detail}`,
|
|
73
|
+
data: { resource, filePath: metadata?.source, path: "exports/code" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// One specifier per selector: two candidates of one format claiming the same
|
|
78
|
+
// specifier leave the resolution ambiguous, and two specifiers for one format
|
|
79
|
+
// mean a consumer's import resolves by whichever candidate is read first.
|
|
80
|
+
const seen = new Map<string, LibraryCandidate>();
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
const key = selectorKey(candidate.selector);
|
|
83
|
+
const first = seen.get(key);
|
|
84
|
+
if (first) {
|
|
85
|
+
out.push({
|
|
86
|
+
severity: DiagnosticSeverity.Error,
|
|
87
|
+
code: "LIBRARY_CANDIDATE_DUPLICATE",
|
|
88
|
+
source: SOURCE,
|
|
89
|
+
message:
|
|
90
|
+
`Telo.Library/${metadata?.name ?? "(unnamed)"}: two 'exports.code' entries declare the ` +
|
|
91
|
+
`selector ${key} ('${first.specifier}' and '${candidate.specifier}'). A module has one ` +
|
|
92
|
+
`entry point per format — which is what makes "one specifier, one module scope" true.`,
|
|
93
|
+
data: { resource, filePath: metadata?.source, path: "exports/code" },
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
seen.set(key, candidate);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
45
101
|
/** `local_path` names the source `path=` was built from, so a working copy runs
|
|
46
102
|
* with no build step. It is inert in a published artifact — which ships no
|
|
47
103
|
* `src/` — and contributes nothing to the selector, so it never affects which
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
validateAgainstSchema,
|
|
10
10
|
} from "./schema-compat.js";
|
|
11
11
|
import { plainChainOf } from "@telorun/templating";
|
|
12
|
-
import { valueTypeOf } from "@telorun/sdk";
|
|
12
|
+
import { isLiveSlot, valueTypeOf } from "@telorun/sdk";
|
|
13
|
+
import { manifestFragmentOf } from "./manifest-schemas.js";
|
|
13
14
|
import {
|
|
14
15
|
analyzerContractScope,
|
|
15
16
|
containerOf,
|
|
@@ -25,7 +26,7 @@ export interface StepInputIssue {
|
|
|
25
26
|
message: string;
|
|
26
27
|
/** Set when the issue is a type-argument disagreement rather than a contract
|
|
27
28
|
* shape violation — the two read differently and deserve their own code. */
|
|
28
|
-
code?: "CEL_TYPE_ARGUMENT_MISMATCH";
|
|
29
|
+
code?: "CEL_TYPE_ARGUMENT_MISMATCH" | "LIVE_VALUE_RETRIED";
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
|
|
@@ -118,15 +119,30 @@ export function collectStepInputIssues(
|
|
|
118
119
|
// that silence is exactly where a stream of the wrong element used to
|
|
119
120
|
// flow. The comparison is covariant and gradual: an omitted argument is
|
|
120
121
|
// *any* in both directions, so only a definite conflict is reported.
|
|
121
|
-
|
|
122
|
+
// The roots a plain chain may name here, each paired with the schema it is
|
|
123
|
+
// navigated against. `steps.` is the step map (analyzer state, supplied by
|
|
124
|
+
// the caller). `inputs.` is the ENCLOSING kind's own declared inputType,
|
|
125
|
+
// which is how a value produced OUTSIDE this resource reaches a step at
|
|
126
|
+
// all: an HTTP route maps `request.body` into its handler's inputs, and the
|
|
127
|
+
// handler forwards `inputs.body` onward — the shape a live value most often
|
|
128
|
+
// arrives in, and the one covering only `steps.` missed entirely. A root
|
|
129
|
+
// this cannot resolve contributes nothing rather than guessing at a schema.
|
|
130
|
+
const roots: Array<[string, Record<string, any>]> = [];
|
|
131
|
+
if (stepContext) roots.push(["steps.", stepContext]);
|
|
132
|
+
const ownContract = resolveContract(
|
|
133
|
+
"inputType",
|
|
134
|
+
manifest,
|
|
135
|
+
contractScope.resolveIn(manifest.kind as string, readingModule),
|
|
136
|
+
contractScope,
|
|
137
|
+
);
|
|
138
|
+
if (ownContract) roots.push(["inputs.", ownContract.schema]);
|
|
139
|
+
|
|
140
|
+
if (roots.length > 0) {
|
|
122
141
|
for (const [inputName, inputValue] of Object.entries(values)) {
|
|
123
142
|
const chain = plainChainOf(inputValue);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// guessing at a root this does not hold would compare the wrong schema.
|
|
128
|
-
if (!chain?.startsWith("steps.")) continue;
|
|
129
|
-
const produced = navigateSchemaToExprPath(stepContext, chain.slice("steps.".length));
|
|
143
|
+
const root = chain ? roots.find(([prefix]) => chain.startsWith(prefix)) : undefined;
|
|
144
|
+
if (!chain || !root) continue;
|
|
145
|
+
const produced = navigateSchemaToExprPath(root[1], chain.slice(root[0].length));
|
|
130
146
|
const slotSchema = (contract.schema.properties as Record<string, any> | undefined)?.[
|
|
131
147
|
inputName
|
|
132
148
|
];
|
|
@@ -138,6 +154,28 @@ export function collectStepInputIssues(
|
|
|
138
154
|
// broad new Error-severity check hidden behind an argument-specific
|
|
139
155
|
// name. Both sides must declare a value type for the question to be
|
|
140
156
|
// about arguments at all.
|
|
157
|
+
// A LIVE value is consumed by reading, so it exists exactly once —
|
|
158
|
+
// that is what `live` says in the vocabulary, and re-attempting a
|
|
159
|
+
// dispatch that already read it re-sends nothing. Reported here rather
|
|
160
|
+
// than through a slot-specific annotation because both facts are
|
|
161
|
+
// already declared: the value's liveness by its value type, and the
|
|
162
|
+
// re-attempt by the retry policy. No kind is named.
|
|
163
|
+
if (isLiveSlot(produced)) {
|
|
164
|
+
const retry = declaredRetry(step, stepItemSchema, invokedManifest, invokedDef);
|
|
165
|
+
if (retry !== undefined) {
|
|
166
|
+
out.push({
|
|
167
|
+
path: `${stepPath}.${inputsField}.${inputName}`,
|
|
168
|
+
targetLabel: invokedName ?? invokedKind ?? "the invoked resource",
|
|
169
|
+
message:
|
|
170
|
+
`'${inputName}' is a live value, which is consumed by reading and so exists ` +
|
|
171
|
+
`once — but ${retry} re-attempts the dispatch, and a re-attempt would pass ` +
|
|
172
|
+
`nothing. Collect it to a value first, or chunk the work so each attempt ` +
|
|
173
|
+
`carries its own replayable piece.`,
|
|
174
|
+
code: "LIVE_VALUE_RETRIED",
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
141
179
|
if (!valueTypeOf(produced) || !valueTypeOf(slotSchema)) continue;
|
|
142
180
|
const { compatible, issues } = checkSchemaCompatibility(produced, slotSchema, (ref) =>
|
|
143
181
|
defs.schemaForId(ref),
|
|
@@ -170,3 +208,65 @@ export function collectStepInputIssues(
|
|
|
170
208
|
return out;
|
|
171
209
|
}
|
|
172
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Where a re-attempt is declared for this dispatch, described for a diagnostic,
|
|
213
|
+
* or undefined when none is.
|
|
214
|
+
*
|
|
215
|
+
* A field declares one when its schema was expanded from a shared retry fragment
|
|
216
|
+
* — the shape the author pointed at, rather than a marker they had to remember to
|
|
217
|
+
* write beside it. Which fragment also says WHERE the budget is, so the two
|
|
218
|
+
* spellings a kind may carry (a policy object, or the deprecated bare count) need
|
|
219
|
+
* no guessing between them and no rule about which one wins.
|
|
220
|
+
*
|
|
221
|
+
* Two sites are consulted because there are two real ones: the STEP's own policy
|
|
222
|
+
* — `retry` on the kernel-owned dispatch site — and the TARGET's, a field on an
|
|
223
|
+
* arbitrary kind, because `Http.Request` re-attempts inside its own `invoke()`
|
|
224
|
+
* where only it can tell a 429 from a 500. A live value is equally doomed by
|
|
225
|
+
* either. EVERY retry-bearing field at a site is checked, not the first, since
|
|
226
|
+
* `Http.Request` carries both spellings and property order must not decide which
|
|
227
|
+
* is seen.
|
|
228
|
+
*
|
|
229
|
+
* Only a STATICALLY KNOWN non-zero budget counts. An `attempts` written as CEL
|
|
230
|
+
* says nothing here, and guessing would report a conflict against a manifest that
|
|
231
|
+
* may never retry — the same posture the `use` case-map selector takes.
|
|
232
|
+
*/
|
|
233
|
+
function declaredRetry(
|
|
234
|
+
step: Record<string, any>,
|
|
235
|
+
stepItemSchema: Record<string, any> | undefined,
|
|
236
|
+
invokedManifest: Record<string, any> | undefined,
|
|
237
|
+
invokedDef: Record<string, any> | undefined,
|
|
238
|
+
): string | undefined {
|
|
239
|
+
for (const [field, budget] of retryFields(stepItemSchema)) {
|
|
240
|
+
if (budget(step?.[field]) > 0) return `the step's \`${field}\``;
|
|
241
|
+
}
|
|
242
|
+
for (const [field, budget] of retryFields(invokedDef?.schema as Record<string, any>)) {
|
|
243
|
+
if (budget(invokedManifest?.[field]) > 0) return `the target's \`${field}\``;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** How each shared retry fragment carries its budget. Keyed on fragment name —
|
|
249
|
+
* the analyzer's own built-ins, never a module's kind — so a kind that adopts a
|
|
250
|
+
* shape is covered without naming it here. */
|
|
251
|
+
const RETRY_BUDGET: Record<string, (value: unknown) => number> = {
|
|
252
|
+
RetryPolicy: (value) => {
|
|
253
|
+
if (!value || typeof value !== "object") return 0;
|
|
254
|
+
const attempts = (value as Record<string, unknown>).attempts;
|
|
255
|
+
return typeof attempts === "number" ? attempts : 0;
|
|
256
|
+
},
|
|
257
|
+
RetryAttempts: (value) => (typeof value === "number" ? value : 0),
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Every property of `schema` whose shape came from a retry fragment, paired with
|
|
261
|
+
* the reader for that fragment's budget. */
|
|
262
|
+
function retryFields(
|
|
263
|
+
schema: Record<string, any> | undefined,
|
|
264
|
+
): Array<[string, (value: unknown) => number]> {
|
|
265
|
+
if (!schema) return [];
|
|
266
|
+
const out: Array<[string, (value: unknown) => number]> = [];
|
|
267
|
+
for (const [key, sub] of gatherPropertySchemas(schema)) {
|
|
268
|
+
const budget = RETRY_BUDGET[manifestFragmentOf(sub) ?? ""];
|
|
269
|
+
if (budget) out.push([key, budget]);
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|