@telorun/analyzer 0.56.1 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
1
  import AjvModule from "ajv";
2
2
  import addFormats from "ajv-formats";
3
- import { isRefSentinel, isTaggedSentinel, ManifestRootSchema, normalizeRefSlots } from "@telorun/templating";
3
+ import {
4
+ INCLUDE_BYTES_ENGINE,
5
+ INCLUDE_ENGINE_NAMES,
6
+ isRefSentinel,
7
+ isTaggedSentinel,
8
+ ManifestRootSchema,
9
+ normalizeRefSlots,
10
+ } from "@telorun/templating";
4
11
  import { binaryKeyword, isBinarySlot } from "./binary-slot.js";
5
12
 
6
13
  const Ajv = (AjvModule as any).default ?? AjvModule;
@@ -564,6 +571,17 @@ export function substituteCelFields(
564
571
  if (isRefSentinel(data)) {
565
572
  return data;
566
573
  }
574
+ // A file embed's type is a CONSTANT of the tag, not a function of the slot:
575
+ // `!include-text` always produces a string and `!include-bytes` always
576
+ // produces bytes. Collapsing them to a slot-shaped placeholder like a CEL
577
+ // expression would make every slot accept both, so a byte embed at a
578
+ // `type: string` field passed `telo check` and failed at resource creation —
579
+ // and the reverse (text at an `x-telo-binary` slot) did too. Substituting the
580
+ // real type lets AJV and the `x-telo-binary` keyword reject both directions
581
+ // statically, with no new diagnostic code.
582
+ if (isTaggedSentinel(data) && INCLUDE_ENGINE_NAMES.has(data.engine)) {
583
+ return data.engine === INCLUDE_BYTES_ENGINE ? new Uint8Array() : "";
584
+ }
567
585
  if (isTaggedSentinel(data)) {
568
586
  mark();
569
587
  return celPlaceholderForSchema(resolved);
package/src/types.ts CHANGED
@@ -31,6 +31,36 @@ export type PositionIndex = Map<string, Range>;
31
31
 
32
32
  /** LSP-compatible Diagnostic shape. range is optional because parsed YAML may not carry
33
33
  * position info when only the parsed object (not raw text) is available. */
34
+ /** A mechanically applicable repair, carried unchanged from whatever produced
35
+ * it (a templating engine, a kind-name suggestion) to every consumer: CLI
36
+ * JSON, IDE CodeActions, an agent applying it without re-deriving it from
37
+ * prose.
38
+ *
39
+ * `replacement` is the **whole** value at the diagnostic's `path`, corrected —
40
+ * never a fragment — so applying it needs no knowledge of the language inside.
41
+ * There is deliberately no sub-range: carrying one beside a whole-value
42
+ * replacement gives the field two readings, and the minimal-edit reading
43
+ * (splice `replacement` at `range`) produces garbage because the two measure
44
+ * different strings.
45
+ *
46
+ * One shape rather than one per producer: a `fix` field beside a
47
+ * `suggestedKind` field beside a CEL-specific one would leave every host
48
+ * wiring a separate action path for what is the same gesture. */
49
+ export interface DiagnosticFix {
50
+ readonly replacement: string;
51
+ }
52
+
53
+ /** The `data` stamp diagnostics carry. Loose by design — passes bolt their own
54
+ * keys on — but the fields every consumer reads are declared. */
55
+ export interface DiagnosticData {
56
+ resource?: { kind: string; name: string };
57
+ filePath?: string;
58
+ /** Dotted path of the offending value within its resource. */
59
+ path?: string;
60
+ fix?: DiagnosticFix;
61
+ [key: string]: unknown;
62
+ }
63
+
34
64
  export interface AnalysisDiagnostic {
35
65
  range?: Range;
36
66
  severity?: DiagnosticSeverity;
@@ -42,6 +72,13 @@ export interface AnalysisDiagnostic {
42
72
  data?: unknown;
43
73
  }
44
74
 
75
+ /** Single reader for a diagnostic's fix, so no consumer re-derives the shape
76
+ * by hand-casting `data`. */
77
+ export function diagnosticFix(d: AnalysisDiagnostic): DiagnosticFix | undefined {
78
+ const fix = (d.data as DiagnosticData | undefined)?.fix;
79
+ return fix && typeof fix.replacement === "string" ? fix : undefined;
80
+ }
81
+
45
82
  export interface ManifestSource {
46
83
  supports(url: string): boolean;
47
84
  read(url: string): Promise<{ text: string; source: string }>;
@@ -0,0 +1,70 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { INCLUDE_ENGINE_NAMES, walkCelExpressions } from "@telorun/templating";
3
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
4
+
5
+ const SOURCE = "telo-analyzer";
6
+
7
+ /**
8
+ * Docs that are never instantiated as resources.
9
+ *
10
+ * `Telo.Application` / `Telo.Library` are module declarations, and `Telo.Import`
11
+ * is a dependency edge — none of the three reaches a controller's `create()`.
12
+ * `Telo.Definition` and `Telo.Abstract` are deliberately absent: a definition's
13
+ * template body (`resources:` / `invoke:` / `run:` / `provide:`) DOES become
14
+ * resources, so an embed there resolves normally.
15
+ */
16
+ const NEVER_INSTANTIATED: ReadonlySet<string> = new Set([
17
+ "Telo.Application",
18
+ "Telo.Library",
19
+ "Telo.Import",
20
+ ]);
21
+
22
+ /**
23
+ * An `!include-text` / `!include-bytes` in a doc that is never instantiated is
24
+ * never read.
25
+ *
26
+ * The two tags resolve when the resource owning them is created — deferred so
27
+ * that loading a manifest does not pull payload layers, which is the property
28
+ * the artifact spec protects by giving `telo.yaml` a layer of its own. The cost
29
+ * of that choice is this dead spot: a doc with no `create()` has no moment at
30
+ * which the file would be read, so the value stays an unresolved marker and
31
+ * whatever reads it sees a sentinel object instead of the file's contents.
32
+ *
33
+ * Nothing else would report it. There is no controller to validate against a
34
+ * schema and no runtime consumer to fail, so the manifest ships looking correct
35
+ * — exactly the silent-no-op failure mode that makes a descriptive field worth
36
+ * checking. Hence an error at the one place that can see it.
37
+ *
38
+ * Deliberately narrow: it reports only where the argument is complete ("this
39
+ * doc is never instantiated"). A JSON-Schema region *inside* a definition —
40
+ * `schema:`, `inputType:` — is equally unreadable, but that is a general
41
+ * question about tags in schema metadata rather than one about these two, and
42
+ * no pass answers it for any tag today.
43
+ */
44
+ export function validateIncludePlacement(manifests: ResourceManifest[]): AnalysisDiagnostic[] {
45
+ const out: AnalysisDiagnostic[] = [];
46
+ for (const manifest of manifests) {
47
+ if (!NEVER_INSTANTIATED.has(manifest.kind)) continue;
48
+ const name = (manifest.metadata as { name?: string } | undefined)?.name;
49
+ const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
50
+ walkCelExpressions(manifest, "", (source, path, engineName) => {
51
+ if (!INCLUDE_ENGINE_NAMES.has(engineName)) return;
52
+ out.push({
53
+ severity: DiagnosticSeverity.Error,
54
+ code: "INCLUDE_OUTSIDE_RESOURCE",
55
+ source: SOURCE,
56
+ message:
57
+ `${manifest.kind}${name ? `/${name}` : ""}: \`!${engineName} ${source}\` at '${path}' is ` +
58
+ `never read — a ${manifest.kind} doc is not instantiated, and a file embed is resolved ` +
59
+ `when the resource holding it is created. Move it onto the resource that needs the ` +
60
+ `file, or read the file at runtime with Fs.File.`,
61
+ data: {
62
+ resource: { kind: manifest.kind, name: name ?? "" },
63
+ filePath,
64
+ path,
65
+ },
66
+ });
67
+ });
68
+ }
69
+ return out;
70
+ }
@@ -238,6 +238,7 @@ function checkCatchesCoverage(
238
238
  filePath: string | undefined,
239
239
  arrayPath: string,
240
240
  env: Environment,
241
+ handler: { kind: string; name?: string } | null,
241
242
  ): AnalysisDiagnostic[] {
242
243
  const diagnostics: AnalysisDiagnostic[] = [];
243
244
  const declaredCodes = new Set(union.codes.keys());
@@ -288,16 +289,20 @@ function checkCatchesCoverage(
288
289
  }
289
290
 
290
291
  if (!hasCatchAll) {
291
- for (const code of declaredCodes) {
292
- if (!covered.has(code)) {
293
- diagnostics.push({
294
- severity: DiagnosticSeverity.Error,
295
- code: "UNCOVERED_THROW_CODE",
296
- source: SOURCE,
297
- message: `Code '${code}' is declared by the handler but not covered by any catches: entry (no matching \`when:\` and no catch-all).`,
298
- data: { resource, filePath, path: arrayPath },
299
- });
300
- }
292
+ // One diagnostic per block, not per code: every uncovered code sits at the
293
+ // same `catches:` array, and one catch-all answers all of them at once. A
294
+ // diagnostic each repeated the same location and the same fix N times.
295
+ const uncovered = [...declaredCodes].filter((c) => !covered.has(c)).sort();
296
+ if (uncovered.length > 0) {
297
+ diagnostics.push({
298
+ severity: DiagnosticSeverity.Error,
299
+ code: "UNCOVERED_THROW_CODE",
300
+ source: SOURCE,
301
+ message:
302
+ `handler ${handler?.name ? `\`!ref ${handler.name}\`` : `\`${handler?.kind ?? "?"}\``} can throw ${uncovered.length} code${uncovered.length === 1 ? "" : "s"} that no catches: entry handles: ${uncovered.map((c) => `'${c}'`).join(", ")}. ` +
303
+ `Give each a matching \`when:\` (e.g. \`when: !cel "error.code == '${uncovered[0]}'"\`), or add a catch-all entry — one with no \`when:\`, placed last.`,
304
+ data: { resource, filePath, path: arrayPath, uncovered },
305
+ });
301
306
  }
302
307
  }
303
308
 
@@ -552,7 +557,7 @@ export function validateThrowsCoverage(
552
557
  const handlerRef = resolveHandlerRef(siblingData[catchesFor]);
553
558
  const union = handlerRefUnion(handlerRef, manifests, resolveCtx, scopeResolver);
554
559
  diagnostics.push(
555
- ...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env),
560
+ ...checkCatchesCoverage(entries, union, resource, filePath, arrayPath, env, handlerRef),
556
561
  );
557
562
  diagnostics.push(
558
563
  ...checkTypedErrorData(entries, union, resource, filePath, arrayPath, env),