@telorun/analyzer 0.71.0 → 0.72.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.
Files changed (66) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +42 -2
  3. package/dist/catch-scope.d.ts +72 -0
  4. package/dist/catch-scope.d.ts.map +1 -0
  5. package/dist/catch-scope.js +102 -0
  6. package/dist/deprecation.d.ts +21 -0
  7. package/dist/deprecation.d.ts.map +1 -0
  8. package/dist/deprecation.js +26 -0
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/manifest-visitor.d.ts +17 -1
  13. package/dist/manifest-visitor.d.ts.map +1 -1
  14. package/dist/manifest-visitor.js +5 -1
  15. package/dist/migrations/report.d.ts +1 -1
  16. package/dist/migrations/report.d.ts.map +1 -1
  17. package/dist/migrations/report.js +5 -0
  18. package/dist/ref-slot.d.ts +15 -0
  19. package/dist/ref-slot.d.ts.map +1 -1
  20. package/dist/ref-slot.js +7 -0
  21. package/dist/resolve-throws-union.d.ts +29 -1
  22. package/dist/resolve-throws-union.d.ts.map +1 -1
  23. package/dist/resolve-throws-union.js +111 -16
  24. package/dist/schema-compat.d.ts.map +1 -1
  25. package/dist/schema-compat.js +13 -1
  26. package/dist/schema-error-report.d.ts.map +1 -1
  27. package/dist/schema-error-report.js +48 -4
  28. package/dist/schema-keywords.d.ts.map +1 -1
  29. package/dist/schema-keywords.js +3 -1
  30. package/dist/schema-walk.d.ts +27 -0
  31. package/dist/schema-walk.d.ts.map +1 -1
  32. package/dist/schema-walk.js +44 -0
  33. package/dist/telo-version.d.ts +1 -1
  34. package/dist/telo-version.js +1 -1
  35. package/dist/types.d.ts +17 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +11 -0
  38. package/dist/validate-identifier-names.d.ts +2 -2
  39. package/dist/validate-identifier-names.d.ts.map +1 -1
  40. package/dist/validate-identifier-names.js +22 -7
  41. package/dist/validate-ref-slots.d.ts +1 -1
  42. package/dist/validate-ref-slots.d.ts.map +1 -1
  43. package/dist/validate-ref-slots.js +34 -0
  44. package/dist/validate-references.d.ts.map +1 -1
  45. package/dist/validate-references.js +27 -3
  46. package/dist/validate-throws-coverage.d.ts.map +1 -1
  47. package/dist/validate-throws-coverage.js +236 -85
  48. package/package.json +2 -2
  49. package/src/analyzer.ts +54 -1
  50. package/src/catch-scope.ts +157 -0
  51. package/src/deprecation.ts +36 -0
  52. package/src/index.ts +8 -1
  53. package/src/manifest-visitor.ts +19 -2
  54. package/src/migrations/report.ts +5 -1
  55. package/src/ref-slot.ts +19 -0
  56. package/src/resolve-throws-union.ts +139 -21
  57. package/src/schema-compat.ts +13 -0
  58. package/src/schema-error-report.ts +50 -6
  59. package/src/schema-keywords.ts +4 -1
  60. package/src/schema-walk.ts +56 -0
  61. package/src/telo-version.ts +1 -1
  62. package/src/types.ts +18 -0
  63. package/src/validate-identifier-names.ts +28 -9
  64. package/src/validate-ref-slots.ts +41 -1
  65. package/src/validate-references.ts +33 -3
  66. package/src/validate-throws-coverage.ts +333 -92
@@ -53,10 +53,42 @@ const UNION_KEYWORDS = new Set(["anyOf", "oneOf"]);
53
53
  * further in. These are what make a branch implausible. */
54
54
  const SHAPE_KEYWORDS = new Set(["required", "type", "additionalProperties", "enum", "const"]);
55
55
 
56
+ /** Keywords that exist to DISCRIMINATE, so a mismatch is positive evidence that
57
+ * the value is not this branch — at any depth, not only at the union node.
58
+ *
59
+ * Depth is otherwise the tiebreak, and it inverts exactly here: a branch that
60
+ * agreed on the discriminator and failed one constraint reported at the union
61
+ * node loses to every branch that disagreed about the discriminator one level
62
+ * in. That is how a `capability: Telo.Service` document declaring a forbidden
63
+ * key was reported as `/capability must be equal to constant` — naming neither
64
+ * the key at fault nor a branch the value could ever have been. */
65
+ const DISCRIMINATOR_KEYWORDS = new Set(["const", "enum"]);
66
+
67
+ /** RFC 6901 escapes: `~1` is a literal `/` in the key, `~0` a literal `~`.
68
+ * Decoded wherever a segment is shown or matched, because encoded it is not the
69
+ * key the manifest holds — a content map (`application/json`) anchors nowhere
70
+ * and reads wrong in the sentence. `~0` is expanded LAST, or `~01` would decode
71
+ * to `/` instead of the literal `~1` it encodes. */
72
+ function unescapeSegment(part: string): string {
73
+ return part.replace(/~1/g, "/").replace(/~0/g, "~");
74
+ }
75
+
76
+ /** The whole pointer, segment by segment, for prose that quotes it verbatim. */
77
+ function unescapePointer(pointer: string): string {
78
+ return pointer
79
+ .split("/")
80
+ .map((segment, i) => (i === 0 ? segment : unescapeSegment(segment)))
81
+ .join("/");
82
+ }
83
+
56
84
  /* ------------------------------------------------------------------ prose */
57
85
 
58
86
  export function formatSingleError(err: AjvErrorLike): string {
59
- const p = err.instancePath || "/";
87
+ // Unescaped for the same reason the path is: a reader acting on this sentence
88
+ // needs the key the manifest holds (`application/json`), not its RFC 6901
89
+ // encoding. Reporting one form in `path` and the other in `message` made one
90
+ // diagnostic disagree with itself.
91
+ const p = unescapePointer(err.instancePath || "") || "/";
60
92
  const params = err.params ?? {};
61
93
  switch (err.keyword) {
62
94
  case "additionalProperties":
@@ -67,6 +99,16 @@ export function formatSingleError(err: AjvErrorLike): string {
67
99
  return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
68
100
  case "type":
69
101
  return `${p} must be ${params.type}${describeActual(err)}`;
102
+ // A `false` schema at a property is how a branch forbids a key it otherwise
103
+ // declares. AJV's own text ("boolean schema is false") describes the schema
104
+ // rather than the value, and the path is the only part a reader can act on.
105
+ case "false schema":
106
+ return `${p} is not allowed here`;
107
+ // `not:` says a forbidden shape matched, and AJV reports nothing about the
108
+ // inner schema — so the honest message says where, and no more. A branch
109
+ // that wants to name the key writes `properties: { <key>: false }` instead.
110
+ case "not":
111
+ return `${p} matches a shape that is not allowed here`;
70
112
  default:
71
113
  return `${p} ${err.message ?? "is invalid"}`;
72
114
  }
@@ -130,11 +172,13 @@ function describeAlternatives(errors: AjvErrorLike[], unionInstancePath: string)
130
172
  }
131
173
 
132
174
  /** Is this branch a plausible reading of the value — does it accept the value's
133
- * shape at the union node itself, and only disagree further in? */
175
+ * shape at the union node itself, and agree with every discriminator it pins? */
134
176
  function isPlausible(errors: AjvErrorLike[], unionInstancePath: string): boolean {
135
- return !errors.some(
136
- (e) => (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(e.keyword ?? ""),
137
- );
177
+ return !errors.some((e) => {
178
+ const keyword = e.keyword ?? "";
179
+ if (DISCRIMINATOR_KEYWORDS.has(keyword)) return true;
180
+ return (e.instancePath || "") === unionInstancePath && SHAPE_KEYWORDS.has(keyword);
181
+ });
138
182
  }
139
183
 
140
184
  /**
@@ -392,7 +436,7 @@ export function ajvErrorToPath(err: AjvErrorLike): string {
392
436
  let result = "";
393
437
  for (const part of parts) {
394
438
  if (/^\d+$/.test(part)) result += `[${part}]`;
395
- else result += result ? `.${part}` : part;
439
+ else result += result ? `.${unescapeSegment(part)}` : unescapeSegment(part);
396
440
  }
397
441
  if (err.keyword === "required" && err.params?.missingProperty) {
398
442
  const missing = err.params.missingProperty as string;
@@ -386,7 +386,10 @@ export const TELO_SCHEMA_ANNOTATIONS: Record<
386
386
  },
387
387
  "x-telo-catches-for": {
388
388
  title: "Catches for",
389
- description: "Names the field whose failures this branch list handles.",
389
+ description:
390
+ "Whose failures this catch list renders: a sibling field naming the handler, " +
391
+ "or the EMPTY string for everything this resource drives — a scope-level list, " +
392
+ "which owes coverage of nothing itself and answers for every site it encloses.",
390
393
  type: "string",
391
394
  },
392
395
  };
@@ -9,6 +9,8 @@
9
9
  * scope rule is consumed by the IDE, which must not pull the pass in behind it.
10
10
  */
11
11
  import { MANIFEST_SCHEMA_URI, ManifestRootSchema } from "./manifest-schemas.js";
12
+ import { readRefSlot, type RefSlot } from "./ref-slot.js";
13
+ import { readStepSlot, type StepSlot } from "./step-slot.js";
12
14
 
13
15
  /** Resolve a local `$ref` (only `#/$defs/<name>` form) against the root schema.
14
16
  * Non-refs and unresolved refs pass through unchanged. */
@@ -142,3 +144,57 @@ export function walkStepArray(
142
144
  }
143
145
  });
144
146
  }
147
+ /** A slot through which a resource drives another. */
148
+ export type DrivenSlot =
149
+ | { kind: "step"; slot: StepSlot; data: unknown[]; path: string }
150
+ | { kind: "ref"; slot: RefSlot; data: unknown; path: string };
151
+
152
+ /**
153
+ * Every slot of one resource through which it drives another, schema and data in
154
+ * tandem.
155
+ *
156
+ * One traversal with two consumers — the inherited union here, and the catch
157
+ * scope enclosure in `validate-throws-coverage.ts` — because both ask the same
158
+ * structural question and two copies would eventually disagree about where the
159
+ * walk stops. It terminates on the manifest's own depth, and it stops AT a step
160
+ * slot (that traversal owns everything below it, `try`/`catch` subtraction
161
+ * included) and AT a reference slot (a resolved ref is a leaf, `{kind, name}`,
162
+ * with nothing beneath it to visit).
163
+ */
164
+ export function forEachDrivenSlot(
165
+ schema: unknown,
166
+ data: unknown,
167
+ visit: (slot: DrivenSlot) => void,
168
+ path = "",
169
+ ): void {
170
+ if (!schema || typeof schema !== "object" || data === undefined || data === null) return;
171
+ const node = schema as Record<string, any>;
172
+
173
+ const stepSlot = readStepSlot(node);
174
+ if (stepSlot) {
175
+ if (Array.isArray(data)) visit({ kind: "step", slot: stepSlot, data, path });
176
+ return;
177
+ }
178
+
179
+ const refSlot = readRefSlot(node);
180
+ if (refSlot) {
181
+ visit({ kind: "ref", slot: refSlot, data, path });
182
+ return;
183
+ }
184
+
185
+ const props = node.properties as Record<string, any> | undefined;
186
+ if (props && typeof data === "object" && !Array.isArray(data)) {
187
+ const obj = data as Record<string, unknown>;
188
+ for (const [key, propSchema] of Object.entries(props)) {
189
+ if (obj[key] === undefined) continue;
190
+ forEachDrivenSlot(propSchema, obj[key], visit, path ? `${path}.${key}` : key);
191
+ }
192
+ }
193
+
194
+ if (node.items && Array.isArray(data)) {
195
+ for (const [i, item] of data.entries()) {
196
+ forEachDrivenSlot(node.items, item, visit, `${path}[${i}]`);
197
+ }
198
+ }
199
+ }
200
+
@@ -6,4 +6,4 @@
6
6
  // written against, and every kernel in every language reports the same scale.
7
7
 
8
8
  /** The surface generation this analyzer implements. */
9
- export const TELO_SURFACE_VERSION = "0.87.0";
9
+ export const TELO_SURFACE_VERSION = "0.88.0";
package/src/types.ts CHANGED
@@ -11,6 +11,19 @@ export const DiagnosticSeverity = {
11
11
  } as const;
12
12
  export type DiagnosticSeverity = (typeof DiagnosticSeverity)[keyof typeof DiagnosticSeverity];
13
13
 
14
+ /** Matches LSP DiagnosticTag values exactly.
15
+ * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnosticTag
16
+ *
17
+ * Declared whole rather than trimmed to what Telo emits today, for the reason
18
+ * the severity ladder above is: it is someone else's closed vocabulary, and a
19
+ * partial copy of one is what drifts. `Unnecessary` (rendered faded) has an
20
+ * obvious future consumer in the unused-declaration checks. */
21
+ export const DiagnosticTag = {
22
+ Unnecessary: 1,
23
+ Deprecated: 2,
24
+ } as const;
25
+ export type DiagnosticTag = (typeof DiagnosticTag)[keyof typeof DiagnosticTag];
26
+
14
27
  /** Default entry-point filename when a directory is given instead of a file. */
15
28
  export const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
16
29
 
@@ -70,6 +83,11 @@ export interface AnalysisDiagnostic {
70
83
  /** e.g. "telo-analyzer" */
71
84
  source?: string;
72
85
  message: string;
86
+ /** What KIND of thing this is, orthogonal to how loudly it asks to be dealt
87
+ * with. A deprecation is warning-grade *and* a deprecation; severity alone
88
+ * can only say the first, which is why an editor renders a deprecated symbol
89
+ * struck through rather than merely yellow. */
90
+ tags?: DiagnosticTag[];
73
91
  /** Telo-specific extras such as { resource: { kind, name }, path } */
74
92
  data?: unknown;
75
93
  }
@@ -1,8 +1,13 @@
1
- import type { ResourceManifest } from "@telorun/sdk";
1
+ import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
2
 
3
- import type { AliasResolver } from "./alias-resolver.js";
3
+ import {
4
+ moduleScopedDefResolver,
5
+ type AliasResolver,
6
+ type ModuleScopes,
7
+ } from "./alias-resolver.js";
4
8
  import type { CallGraph } from "./call-graph.js";
5
9
  import type { DefinitionRegistry } from "./definition-registry.js";
10
+ import { inheritedCapability, type DefResolver } from "./extends-resolution.js";
6
11
  import {
7
12
  TYPE_LEVEL_DOC_KINDS,
8
13
  checkName,
@@ -44,8 +49,10 @@ export function validateIdentifierNames(
44
49
  aliases: AliasResolver,
45
50
  rootModules: Set<string>,
46
51
  graph: CallGraph,
52
+ scopes: ModuleScopes,
47
53
  ): AnalysisDiagnostic[] {
48
54
  const out: AnalysisDiagnostic[] = [];
55
+ const resolveDef = moduleScopedDefResolver<ResourceDefinition>(registry, aliases, scopes);
49
56
 
50
57
  for (const manifest of manifests) {
51
58
  const metadata = manifest.metadata as Record<string, unknown> | undefined;
@@ -65,7 +72,7 @@ export function validateIdentifierNames(
65
72
  const ownModule = metadata?.module as string | undefined;
66
73
  if (ownModule && !rootModules.has(ownModule)) continue;
67
74
 
68
- const level = levelFor(manifest, registry, aliases);
75
+ const level = levelFor(manifest, resolveDef, ownModule);
69
76
  push(out, checkName(name, level, surfaceFor(manifest.kind)), {
70
77
  kind: manifest.kind,
71
78
  name,
@@ -126,20 +133,32 @@ export function validateIdentifierNames(
126
133
  * a resource. Capability-driven rather than by kind name, so no resource kind
127
134
  * is hardcoded here.
128
135
  *
136
+ * **The capability is the INHERITED one**, not the leaf declaration. Capability
137
+ * is inherited and immutable along `extends` — a kind omits it to take its
138
+ * ancestor's — so reading the leaf answers `undefined` for every kind that does,
139
+ * and the fallback then calls a type a value. That is not hypothetical: it is
140
+ * exactly `Type.JsonSchema`, a pure alias of the built-in (`extends:
141
+ * Telo.JsonSchema`, no `capability:` of its own), so a shape declared through
142
+ * the deprecated spelling was reported as miscased while the identical
143
+ * declaration written as `kind: Telo.JsonSchema` was not. Renaming on that
144
+ * report is worse than the warning: `extends:` between two named shapes is not a
145
+ * reference slot, so the inheritance edge does not move with the name.
146
+ *
129
147
  * An unresolvable kind falls back to value level — the honest default, since
130
148
  * `UNDEFINED_KIND` already reports the real problem and guessing type level
131
149
  * would stack a case error on top of it.
132
150
  */
133
151
  function levelFor(
134
152
  manifest: ResourceManifest,
135
- registry: DefinitionRegistry,
136
- aliases: AliasResolver,
153
+ resolveDef: DefResolver & { in(kind: string, module?: string): ResourceDefinition | undefined },
154
+ ownModule: string | undefined,
137
155
  ): NameLevel {
138
156
  if (TYPE_LEVEL_DOC_KINDS.has(manifest.kind as string)) return "type";
139
- // The root resolver is the right one unconditionally: every manifest
140
- // reaching here belongs to a root module, the others having been skipped.
141
- const canonical = aliases.resolveKind(manifest.kind as string) ?? (manifest.kind as string);
142
- return registry.resolve(canonical)?.capability === "Telo.Type" ? "type" : "value";
157
+ // The top-level lookup is in the module that WROTE the `kind:` — a root one,
158
+ // the others having been skipped above while the chain walk re-scopes at
159
+ // every hop, since an `extends` alias belongs to the file declaring it.
160
+ const def = resolveDef.in(manifest.kind as string, ownModule);
161
+ return inheritedCapability(def, resolveDef) === "Telo.Type" ? "type" : "value";
143
162
  }
144
163
 
145
164
  /** The noun phrase a diagnostic uses as its subject. */
@@ -38,7 +38,9 @@ export interface RefSlotIssue {
38
38
  | "X_TELO_REF_MISSING_USE"
39
39
  | "X_TELO_REF_MISSING_KIND"
40
40
  | "X_TELO_REF_USE_CONFLICT"
41
- | "X_TELO_REF_DYNAMIC_SELECTOR";
41
+ | "X_TELO_REF_DYNAMIC_SELECTOR"
42
+ | "X_TELO_REF_UNKNOWN_KEY"
43
+ | "X_TELO_REF_INVALID_THROWS_THROUGH";
42
44
  /** The definition (schema issues) or resource (selector issues) at fault. */
43
45
  manifest: ResourceManifest;
44
46
  /** Schema path of the slot (schema issues) or concrete value path of the
@@ -140,9 +142,47 @@ function checkAnnotation(
140
142
  }
141
143
  }
142
144
 
145
+ // `throwsThrough` is read as `=== true`, so anything else is silently absent —
146
+ // and absent means the declaring resource's catch scope stops enclosing what
147
+ // it holds, so every route under it starts reporting UNCOVERED_THROW_CODE with
148
+ // nothing naming the cause. The same failure `X_TELO_REF_INVALID_USE` exists
149
+ // to prevent, one key over.
150
+ if (obj.throwsThrough !== undefined && typeof obj.throwsThrough !== "boolean") {
151
+ issues.push({
152
+ code: "X_TELO_REF_INVALID_THROWS_THROUGH",
153
+ manifest,
154
+ path,
155
+ message:
156
+ `x-telo-ref at '${path}' declares 'throwsThrough: ${JSON.stringify(obj.throwsThrough)}', ` +
157
+ `which is not a boolean. Only 'true' declares that throws from this slot's target ` +
158
+ `surface through the declaring resource; anything else reads as absent, which ` +
159
+ `silently stops its catch list from enclosing what it holds.`,
160
+ });
161
+ }
162
+
163
+ // Closed, for the reason the token sets are: a misspelled key is indexed by
164
+ // nothing and read by nothing, so it validates, ships, and does exactly what
165
+ // omitting it would.
166
+ for (const key of Object.keys(obj)) {
167
+ if (REF_ANNOTATION_KEYS.has(key)) continue;
168
+ issues.push({
169
+ code: "X_TELO_REF_UNKNOWN_KEY",
170
+ manifest,
171
+ path,
172
+ message:
173
+ `x-telo-ref at '${path}' declares unrecognized key '${key}'. Known keys: ` +
174
+ `${[...REF_ANNOTATION_KEYS].sort().join(", ")}. An unrecognized key is read by nothing, ` +
175
+ `so it has exactly the effect of leaving it out.`,
176
+ });
177
+ }
178
+
143
179
  return declaredUses(use);
144
180
  }
145
181
 
182
+ /** Every key the structured `x-telo-ref` form accepts — the write side of
183
+ * `readRefSlot`'s read side. Adding one belongs in both. */
184
+ const REF_ANNOTATION_KEYS = new Set(["kind", "use", "inputs", "throwsThrough"]);
185
+
146
186
  /** True when a node is a reference slot: it carries `x-telo-ref` directly or on
147
187
  * an `anyOf`/`oneOf` branch. */
148
188
  function carriesRefAnnotation(obj: Record<string, unknown>): boolean {
@@ -562,14 +562,44 @@ export function validateReferences(
562
562
 
563
563
  for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
564
564
  if (fieldValue == null) continue;
565
- const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
565
+ // CEL leaves become schema-shaped placeholders first, exactly as the
566
+ // sibling-ref branch below does and for the same reason: a slot
567
+ // anchored at a shared value-shape is overwhelmingly written as
568
+ // expressions, so validating it raw reports every one of them as a
569
+ // type error and the check fires only on the literal case nobody
570
+ // writes. Omitting it here made one annotation mean two different
571
+ // things depending on which branch resolved it — a `when:` typed
572
+ // `boolean` accepted a `!cel` at a route's inline slot and rejected
573
+ // the identical expression at a slot anchored on the carrier that
574
+ // declares that very shape.
575
+ const substituted = substituteCelFields(
576
+ fieldValue,
577
+ subSchema as Record<string, any>,
578
+ );
579
+ // Anchored at the offending node INSIDE the value, not at the slot:
580
+ // a `returns:` list is an array of entries, and reporting every one
581
+ // of its issues on the `returns:` line puts three diagnostics on one
582
+ // line and none on the entry that is wrong.
583
+ const issues = registry.validateResourceConfig(
584
+ substituted,
585
+ subSchema as Record<string, any>,
586
+ );
566
587
  for (const issue of issues) {
567
588
  diagnostics.push({
568
589
  severity: DiagnosticSeverity.Error,
569
590
  code: "DEPENDENT_SCHEMA_MISMATCH",
570
591
  source: SOURCE,
571
- message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
572
- data: { resource: resourceData, filePath, path: concretePath },
592
+ message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue.message}`,
593
+ data: {
594
+ resource: resourceData,
595
+ filePath,
596
+ // An index-first sub-path (`[0].content`) joins with no dot.
597
+ path: !issue.path
598
+ ? concretePath
599
+ : issue.path.startsWith("[")
600
+ ? `${concretePath}${issue.path}`
601
+ : `${concretePath}.${issue.path}`,
602
+ },
573
603
  });
574
604
  }
575
605
  }