@telorun/analyzer 0.12.0 → 0.13.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 (67) hide show
  1. package/README.md +2 -2
  2. package/dist/analysis-registry.d.ts +12 -0
  3. package/dist/analysis-registry.d.ts.map +1 -1
  4. package/dist/analysis-registry.js +15 -0
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +131 -85
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +25 -0
  9. package/dist/cel-environment.d.ts +1 -1
  10. package/dist/cel-environment.d.ts.map +1 -1
  11. package/dist/cel-environment.js +40 -2
  12. package/dist/definition-registry.d.ts +12 -1
  13. package/dist/definition-registry.d.ts.map +1 -1
  14. package/dist/definition-registry.js +20 -1
  15. package/dist/dependency-graph.d.ts.map +1 -1
  16. package/dist/dependency-graph.js +41 -62
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/kernel-globals.d.ts +1 -1
  21. package/dist/kernel-globals.d.ts.map +1 -1
  22. package/dist/kernel-globals.js +19 -1
  23. package/dist/manifest-visitor.d.ts +109 -0
  24. package/dist/manifest-visitor.d.ts.map +1 -0
  25. package/dist/manifest-visitor.js +110 -0
  26. package/dist/reference-field-map.d.ts +1 -0
  27. package/dist/reference-field-map.d.ts.map +1 -1
  28. package/dist/reference-field-map.js +1 -1
  29. package/dist/schema-compat.d.ts +14 -0
  30. package/dist/schema-compat.d.ts.map +1 -1
  31. package/dist/schema-compat.js +38 -2
  32. package/dist/validate-cel-context.d.ts +14 -0
  33. package/dist/validate-cel-context.d.ts.map +1 -1
  34. package/dist/validate-cel-context.js +38 -0
  35. package/dist/validate-nested-inline.d.ts +30 -0
  36. package/dist/validate-nested-inline.d.ts.map +1 -0
  37. package/dist/validate-nested-inline.js +129 -0
  38. package/dist/validate-references.d.ts.map +1 -1
  39. package/dist/validate-references.js +117 -160
  40. package/dist/validate-unused-declarations.d.ts +25 -0
  41. package/dist/validate-unused-declarations.d.ts.map +1 -0
  42. package/dist/validate-unused-declarations.js +91 -0
  43. package/package.json +2 -2
  44. package/src/analysis-registry.ts +20 -0
  45. package/src/analyzer.ts +217 -158
  46. package/src/builtins.ts +25 -0
  47. package/src/cel-environment.ts +42 -1
  48. package/src/definition-registry.ts +20 -1
  49. package/src/dependency-graph.ts +37 -52
  50. package/src/index.ts +11 -0
  51. package/src/kernel-globals.ts +22 -1
  52. package/src/manifest-visitor.ts +251 -0
  53. package/src/reference-field-map.ts +1 -1
  54. package/src/schema-compat.ts +38 -2
  55. package/src/validate-cel-context.ts +50 -0
  56. package/src/validate-nested-inline.ts +158 -0
  57. package/src/validate-references.ts +168 -211
  58. package/src/validate-unused-declarations.ts +95 -0
  59. package/dist/adapters/http-adapter.d.ts +0 -10
  60. package/dist/adapters/http-adapter.d.ts.map +0 -1
  61. package/dist/adapters/http-adapter.js +0 -18
  62. package/dist/adapters/node-adapter.d.ts +0 -17
  63. package/dist/adapters/node-adapter.d.ts.map +0 -1
  64. package/dist/adapters/node-adapter.js +0 -71
  65. package/dist/adapters/registry-adapter.d.ts +0 -15
  66. package/dist/adapters/registry-adapter.d.ts.map +0 -1
  67. package/dist/adapters/registry-adapter.js +0 -53
@@ -0,0 +1,95 @@
1
+ import type { Environment } from "@marcbachmann/cel-js";
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
+ import { extractAccessChains, INDEX_SEGMENT, walkCelExpressions } from "@telorun/templating";
4
+ import { type AnalysisDiagnostic, DiagnosticSeverity } from "./types.js";
5
+
6
+ const SOURCE = "telo-analyzer";
7
+
8
+ /** Module-doc namespaces whose entries are consumed via `<ns>.<name>` CEL
9
+ * access. One table drives the whole check — adding a namespace is an entry,
10
+ * not a branch. */
11
+ const NAMESPACES = ["variables", "secrets", "ports"] as const;
12
+
13
+ /**
14
+ * Warn about declared `variables` / `secrets` / `ports` entries that no CEL
15
+ * expression references. A declared-but-unconsumed entry is dead weight at
16
+ * best and misleading at worst (an unbound `ports` entry makes a runner
17
+ * advertise a port the app never listens on).
18
+ *
19
+ * Generic across all three namespaces. References are collected from every CEL
20
+ * expression (both `${{ … }}` and `!cel`, via `walkCelExpressions`) by
21
+ * extracting member-access chains: a `<ns>.<name>` chain marks `<name>` used.
22
+ * Dynamic access (`<ns>[expr]`, or the namespace passed whole, e.g.
23
+ * `keys(variables)`) yields a chain that stops at the namespace root — that
24
+ * can't be attributed to a name, so the whole namespace is conservatively
25
+ * suppressed to avoid false positives.
26
+ *
27
+ * Application-only: an Application's `variables` / `secrets` / `ports` flow
28
+ * exclusively through CEL (into resource fields, or into `Telo.Import` inputs),
29
+ * so unreferenced means dead. A `Telo.Library`'s `variables` / `secrets` are a
30
+ * public input contract consumed by its controllers — invisible to CEL
31
+ * analysis — so they are deliberately not flagged.
32
+ */
33
+ export function validateUnusedDeclarations(
34
+ manifests: ResourceManifest[],
35
+ celEnv: Environment,
36
+ ): AnalysisDiagnostic[] {
37
+ const moduleManifest = manifests.find((m) => m.kind === "Telo.Application") as
38
+ | Record<string, any>
39
+ | undefined;
40
+ if (!moduleManifest) return [];
41
+
42
+ const declared = new Map<string, string[]>();
43
+ for (const ns of NAMESPACES) {
44
+ const block = moduleManifest[ns];
45
+ if (block && typeof block === "object" && !Array.isArray(block)) {
46
+ const names = Object.keys(block);
47
+ if (names.length > 0) declared.set(ns, names);
48
+ }
49
+ }
50
+ if (declared.size === 0) return [];
51
+
52
+ const used = new Map<string, Set<string>>(NAMESPACES.map((ns) => [ns, new Set<string>()]));
53
+ const suppressed = new Set<string>();
54
+
55
+ for (const m of manifests) {
56
+ walkCelExpressions(m, "", (expr, _path, engineName) => {
57
+ if (engineName !== "cel") return;
58
+ let ast: unknown;
59
+ try {
60
+ ast = celEnv.parse(expr).ast;
61
+ } catch {
62
+ return; // syntax errors are reported by the CEL engine pass
63
+ }
64
+ for (const chain of extractAccessChains(ast as Parameters<typeof extractAccessChains>[0])) {
65
+ const ns = chain[0];
66
+ if (!used.has(ns)) continue;
67
+ const member = chain[1];
68
+ // No static member after the namespace root — either the namespace is
69
+ // used whole (`keys(ports)` → ["ports"]) or accessed dynamically
70
+ // (`ports[x]` → ["ports", "[*]"]). Neither can be attributed to a
71
+ // declared name, so suppress the namespace rather than false-positive.
72
+ if (member === undefined || member === INDEX_SEGMENT) suppressed.add(ns);
73
+ else used.get(ns)!.add(member);
74
+ }
75
+ });
76
+ }
77
+
78
+ const diagnostics: AnalysisDiagnostic[] = [];
79
+ const filePath = (moduleManifest.metadata as { source?: string } | undefined)?.source;
80
+ for (const [ns, names] of declared) {
81
+ if (suppressed.has(ns)) continue;
82
+ const seen = used.get(ns)!;
83
+ for (const name of names) {
84
+ if (seen.has(name)) continue;
85
+ diagnostics.push({
86
+ severity: DiagnosticSeverity.Warning,
87
+ code: "UNUSED_DECLARATION",
88
+ source: SOURCE,
89
+ message: `${ns}.${name} is declared but never referenced in any CEL expression.`,
90
+ data: { filePath, path: `${ns}.${name}` },
91
+ });
92
+ }
93
+ }
94
+ return diagnostics;
95
+ }
@@ -1,10 +0,0 @@
1
- import { type ManifestAdapter } from "../types.js";
2
- export declare class HttpAdapter implements ManifestAdapter {
3
- supports(url: string): boolean;
4
- read(url: string): Promise<{
5
- text: string;
6
- source: string;
7
- }>;
8
- resolveRelative(base: string, relative: string): string;
9
- }
10
- //# sourceMappingURL=http-adapter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"http-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/http-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9E,qBAAa,WAAY,YAAW,eAAe;IACjD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAWlE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;CAIxD"}
@@ -1,18 +0,0 @@
1
- import { DEFAULT_MANIFEST_FILENAME } from "../types.js";
2
- export class HttpAdapter {
3
- supports(url) {
4
- return url.startsWith("http://") || url.startsWith("https://");
5
- }
6
- async read(url) {
7
- const fetchUrl = url.includes(".yaml") ? url : `${url}/${DEFAULT_MANIFEST_FILENAME}`;
8
- const response = await fetch(fetchUrl);
9
- if (!response.ok) {
10
- throw new Error(`Failed to fetch manifest from ${fetchUrl}: ${response.status} ${response.statusText}`);
11
- }
12
- return { text: await response.text(), source: fetchUrl };
13
- }
14
- resolveRelative(base, relative) {
15
- const baseDir = base.endsWith("/") ? base : base.slice(0, base.lastIndexOf("/") + 1);
16
- return new URL(relative, baseDir).href;
17
- }
18
- }
@@ -1,17 +0,0 @@
1
- import { type ManifestAdapter } from "../types.js";
2
- /** Node.js fs-based ManifestAdapter for local files. Not browser-compatible. */
3
- export declare class NodeAdapter implements ManifestAdapter {
4
- private readonly cwd;
5
- constructor(cwd?: string);
6
- supports(url: string): boolean;
7
- read(url: string): Promise<{
8
- text: string;
9
- source: string;
10
- }>;
11
- resolveRelative(base: string, relative: string): string;
12
- expandGlob(base: string, patterns: string[]): Promise<string[]>;
13
- resolveOwnerOf(fileUrl: string): Promise<string | null>;
14
- }
15
- /** @deprecated Use `new NodeAdapter(cwd)` instead */
16
- export declare function createNodeAdapter(cwd?: string): ManifestAdapter;
17
- //# sourceMappingURL=node-adapter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"node-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/node-adapter.ts"],"names":[],"mappings":"AAIA,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAM9E,gFAAgF;AAChF,qBAAa,WAAY,YAAW,eAAe;IACrC,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,GAAE,MAAsB;IAExD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAUxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IASlE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAKjD,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAc/D,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;CAoB9D;AAED,qDAAqD;AACrD,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAsB,GAAG,eAAe,CAE9E"}
@@ -1,71 +0,0 @@
1
- import * as fs from "fs/promises";
2
- import * as path from "path";
3
- import { fileURLToPath } from "url";
4
- import { minimatch } from "minimatch";
5
- import { DEFAULT_MANIFEST_FILENAME } from "../types.js";
6
- function toFilePath(url) {
7
- return url.startsWith("file://") ? fileURLToPath(url) : url;
8
- }
9
- /** Node.js fs-based ManifestAdapter for local files. Not browser-compatible. */
10
- export class NodeAdapter {
11
- cwd;
12
- constructor(cwd = process.cwd()) {
13
- this.cwd = cwd;
14
- }
15
- supports(url) {
16
- return (url.startsWith("file://") ||
17
- url.startsWith("/") ||
18
- url.startsWith("./") ||
19
- url.startsWith("../") ||
20
- (!url.includes("://") && !url.includes("@")));
21
- }
22
- async read(url) {
23
- const filePath = toFilePath(url);
24
- const stat = await fs.stat(filePath).catch(() => null);
25
- const resolvedPath = stat?.isDirectory() ? path.join(filePath, DEFAULT_MANIFEST_FILENAME) : filePath;
26
- const text = await fs.readFile(resolvedPath, "utf8");
27
- return { text, source: resolvedPath };
28
- }
29
- resolveRelative(base, relative) {
30
- const baseDir = path.dirname(path.resolve(this.cwd, toFilePath(base)));
31
- return path.resolve(baseDir, relative);
32
- }
33
- async expandGlob(base, patterns) {
34
- const baseDir = path.dirname(path.resolve(this.cwd, toFilePath(base)));
35
- const entries = await fs.readdir(baseDir, { recursive: true, encoding: "utf8" });
36
- const normalizedPatterns = patterns.map((p) => p.replace(/\\/g, "/").replace(/^\.\//, ""));
37
- const matched = [];
38
- for (const entry of entries) {
39
- const normalized = entry.replace(/\\/g, "/");
40
- if (normalizedPatterns.some((p) => minimatch(normalized, p))) {
41
- matched.push(path.resolve(baseDir, entry));
42
- }
43
- }
44
- return matched.sort();
45
- }
46
- async resolveOwnerOf(fileUrl) {
47
- const resolved = path.resolve(this.cwd, toFilePath(fileUrl));
48
- let dir = path.dirname(resolved);
49
- while (true) {
50
- const candidate = path.join(dir, DEFAULT_MANIFEST_FILENAME);
51
- if (candidate !== resolved) {
52
- try {
53
- await fs.access(candidate);
54
- return candidate;
55
- }
56
- catch {
57
- // telo.yaml not found at this level
58
- }
59
- }
60
- const parent = path.dirname(dir);
61
- if (parent === dir)
62
- break;
63
- dir = parent;
64
- }
65
- return null;
66
- }
67
- }
68
- /** @deprecated Use `new NodeAdapter(cwd)` instead */
69
- export function createNodeAdapter(cwd = process.cwd()) {
70
- return new NodeAdapter(cwd);
71
- }
@@ -1,15 +0,0 @@
1
- import { type ManifestAdapter } from "../types.js";
2
- export declare class RegistryAdapter implements ManifestAdapter {
3
- private registryUrl;
4
- constructor(registryUrl?: string);
5
- supports(url: string): boolean;
6
- read(moduleRef: string): Promise<{
7
- text: string;
8
- source: string;
9
- }>;
10
- resolveRelative(base: string, relative: string): string;
11
- private toRegistryModuleBase;
12
- private toRegistryUrl;
13
- private parseModuleRef;
14
- }
15
- //# sourceMappingURL=registry-adapter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"registry-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/registry-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAI9E,qBAAa,eAAgB,YAAW,eAAe;IACzC,OAAO,CAAC,WAAW;gBAAX,WAAW,SAAuB;IAEtD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAWxB,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAWxE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAMvD,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,cAAc;CAmBvB"}
@@ -1,53 +0,0 @@
1
- import { DEFAULT_MANIFEST_FILENAME } from "../types.js";
2
- const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
3
- export class RegistryAdapter {
4
- registryUrl;
5
- constructor(registryUrl = DEFAULT_REGISTRY_URL) {
6
- this.registryUrl = registryUrl;
7
- }
8
- supports(url) {
9
- return (!url.startsWith("http://") &&
10
- !url.startsWith("https://") &&
11
- !url.startsWith("/") &&
12
- !url.startsWith(".") &&
13
- url.includes("@") &&
14
- url.includes("/"));
15
- }
16
- async read(moduleRef) {
17
- const fetchUrl = this.toRegistryUrl(moduleRef);
18
- const response = await fetch(fetchUrl);
19
- if (!response.ok) {
20
- throw new Error(`Failed to fetch manifest ${moduleRef}: ${response.status} ${response.statusText}`);
21
- }
22
- return { text: await response.text(), source: fetchUrl };
23
- }
24
- resolveRelative(base, relative) {
25
- const baseUrl = this.supports(base) ? this.toRegistryModuleBase(base) : base;
26
- const baseWithSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
27
- return new URL(relative, baseWithSlash).href;
28
- }
29
- toRegistryModuleBase(moduleRef) {
30
- const parsed = this.parseModuleRef(moduleRef);
31
- const normalizedBase = this.registryUrl.replace(/\/+$/, "");
32
- return `${normalizedBase}/${parsed.modulePath}/${parsed.version}`;
33
- }
34
- toRegistryUrl(moduleRef) {
35
- return `${this.toRegistryModuleBase(moduleRef)}/${DEFAULT_MANIFEST_FILENAME}`;
36
- }
37
- parseModuleRef(moduleRef) {
38
- const atIdx = moduleRef.lastIndexOf("@");
39
- if (atIdx <= 0 || atIdx === moduleRef.length - 1) {
40
- throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
41
- }
42
- const modulePath = moduleRef.slice(0, atIdx);
43
- if (!modulePath.includes("/")) {
44
- throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
45
- }
46
- const rawVersion = moduleRef.slice(atIdx + 1);
47
- const version = rawVersion.startsWith("v") ? rawVersion.substring(1) : rawVersion;
48
- if (!version) {
49
- throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
50
- }
51
- return { modulePath, version };
52
- }
53
- }