@telorun/analyzer 0.31.0 → 0.33.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.
@@ -0,0 +1,92 @@
1
+ /** Inline module integrity — the `#sha256-<base64url>` fragment carried on a
2
+ * remote import ref. Browser-safe: uses Web Crypto (`crypto.subtle`) and
3
+ * `btoa`, both globals in Node and the browser. No Node built-ins.
4
+ *
5
+ * The fragment is authoritative across every transport: a source's `read()`
6
+ * hashes the fetched bytes and compares against it before the manifest is
7
+ * parsed or cached. A mismatch is a terminal error — never a cache miss. */
8
+ /** Only a `#<alg>-<base64url>` suffix is treated as integrity; other `#`
9
+ * fragments (rare in module refs) pass through untouched. `sha256` is the
10
+ * only algorithm accepted today; the prefix leaves room to migrate. */
11
+ const INTEGRITY_FRAGMENT = /#(sha256-[A-Za-z0-9_+/=-]+)$/;
12
+ /** A failed integrity/tamper check — always terminal, never best-effort. A
13
+ * distinct type so a caller doing best-effort network handling (e.g. the
14
+ * bundle extractor warning-and-skipping on a fetch blip) can still let a
15
+ * tamper error propagate rather than swallow it. */
16
+ export class IntegrityError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "IntegrityError";
20
+ }
21
+ }
22
+ /** Split a trailing integrity fragment off a ref/URL. Returns the bare ref in
23
+ * `base` (safe to build fetch URLs and cache paths from) and the fragment in
24
+ * `integrity` (e.g. `sha256-<base64url>`), or `undefined` when absent. */
25
+ export function splitIntegrity(ref) {
26
+ const match = ref.match(INTEGRITY_FRAGMENT);
27
+ if (!match)
28
+ return { base: ref };
29
+ return { base: ref.slice(0, match.index), integrity: match[1] };
30
+ }
31
+ /** Attach an integrity hash to a ref as a `#<alg>-...` fragment. No-op when the
32
+ * hash is absent/non-string or the ref already carries a fragment (an
33
+ * author-authored pin is never overwritten). Inverse of `splitIntegrity`;
34
+ * used to fold the object form's `integrity:` sibling into the source string. */
35
+ export function foldIntegrity(source, integrity) {
36
+ return typeof integrity === "string" && !source.includes("#")
37
+ ? `${source}#${integrity}`
38
+ : source;
39
+ }
40
+ function toBase64Url(bytes) {
41
+ let binary = "";
42
+ for (let i = 0; i < bytes.length; i++)
43
+ binary += String.fromCharCode(bytes[i]);
44
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45
+ }
46
+ /** Normalize an encoded digest to unpadded base64url so a standard-base64 or
47
+ * padded input still compares equal to our canonical form. */
48
+ function normalizeDigest(value) {
49
+ return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
50
+ }
51
+ /** SHA-256 of `bytes` as unpadded base64url — the canonical inline-hash form. */
52
+ export async function sha256Base64Url(bytes) {
53
+ // Copy into a plain ArrayBuffer: a Uint8Array may be backed by a
54
+ // SharedArrayBuffer, which `crypto.subtle.digest` does not accept.
55
+ const buffer = new ArrayBuffer(bytes.byteLength);
56
+ new Uint8Array(buffer).set(bytes);
57
+ const digest = await crypto.subtle.digest("SHA-256", buffer);
58
+ return toBase64Url(new Uint8Array(digest));
59
+ }
60
+ /** Hash `bytes` and compare against `integrity` (`<alg>-<digest>`). Throws a
61
+ * terminal error on mismatch or an unsupported algorithm. `describe` names the
62
+ * artifact in the error (e.g. the module ref) so the failure is actionable. */
63
+ export async function verifyIntegrity(bytes, integrity, describe) {
64
+ const dash = integrity.indexOf("-");
65
+ const algorithm = dash > 0 ? integrity.slice(0, dash) : "";
66
+ const expected = dash > 0 ? integrity.slice(dash + 1) : "";
67
+ if (algorithm !== "sha256") {
68
+ throw new IntegrityError(`Unsupported integrity algorithm '${algorithm || integrity}' for ${describe}. ` +
69
+ `Only sha256 is supported (sha256-<base64url>).`);
70
+ }
71
+ const actual = await sha256Base64Url(bytes);
72
+ if (actual !== normalizeDigest(expected)) {
73
+ throw new IntegrityError(`Integrity check failed for ${describe}: expected sha256-${normalizeDigest(expected)}, ` +
74
+ `got sha256-${actual}. The fetched bytes do not match the recorded hash — ` +
75
+ `the module may have been tampered with or republished.`);
76
+ }
77
+ }
78
+ /** The single verified network read for remote manifests: fetch `fetchUrl`,
79
+ * verify the raw bytes against `integrity` (when pinned), and return both the
80
+ * bytes and the decoded text. The one choke point every network `ManifestSource`
81
+ * routes through, so verification cannot drift between them. `describe` names
82
+ * the artifact in error messages. */
83
+ export async function verifiedFetch(fetchUrl, integrity, describe) {
84
+ const response = await fetch(fetchUrl);
85
+ if (!response.ok) {
86
+ throw new Error(`Failed to fetch manifest ${describe}: ${response.status} ${response.statusText} (${fetchUrl})`);
87
+ }
88
+ const bytes = new Uint8Array(await response.arrayBuffer());
89
+ if (integrity)
90
+ await verifyIntegrity(bytes, integrity, describe);
91
+ return { bytes, text: new TextDecoder().decode(bytes) };
92
+ }
@@ -0,0 +1,21 @@
1
+ /** A parsed registry module reference. `modulePath` is `namespace/name`,
2
+ * `version` has any leading `v` stripped, and `integrity` carries the inline
3
+ * `sha256-<base64url>` hash when the ref was pinned. */
4
+ export interface ParsedModuleRef {
5
+ modulePath: string;
6
+ version: string;
7
+ integrity?: string;
8
+ }
9
+ /** True when `url` has the bare registry-ref shape `namespace/name@version`
10
+ * (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
11
+ * The integrity fragment, if any, does not affect the classification.
12
+ *
13
+ * A registry ref never carries a `scheme://` — that guard is what keeps an
14
+ * `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
15
+ * scheme-owning transport claims it instead. */
16
+ export declare function isRegistryRef(url: string): boolean;
17
+ /** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
18
+ * source of truth shared by the registry source, the kernel manifest cache,
19
+ * and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
20
+ export declare function parseModuleRef(ref: string): ParsedModuleRef;
21
+ //# sourceMappingURL=module-ref.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"module-ref.d.ts","sourceRoot":"","sources":["../../src/sources/module-ref.ts"],"names":[],"mappings":"AAEA;;yDAEyD;AACzD,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;iDAMiD;AACjD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CASlD;AAED;;2EAE2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAgB3D"}
@@ -0,0 +1,36 @@
1
+ import { splitIntegrity } from "./integrity.js";
2
+ /** True when `url` has the bare registry-ref shape `namespace/name@version`
3
+ * (no scheme of any kind, no leading `/` or `.`, contains both `@` and `/`).
4
+ * The integrity fragment, if any, does not affect the classification.
5
+ *
6
+ * A registry ref never carries a `scheme://` — that guard is what keeps an
7
+ * `oci://…@ver` (or future `s3://…`) ref from being misrouted here, so a
8
+ * scheme-owning transport claims it instead. */
9
+ export function isRegistryRef(url) {
10
+ const { base } = splitIntegrity(url);
11
+ return (!base.includes("://") &&
12
+ !base.startsWith("/") &&
13
+ !base.startsWith(".") &&
14
+ base.includes("@") &&
15
+ base.includes("/"));
16
+ }
17
+ /** Canonical parser for `namespace/name@version[#sha256-...]` refs. The single
18
+ * source of truth shared by the registry source, the kernel manifest cache,
19
+ * and the CLI (install / upgrade / bundle). Throws on a malformed ref. */
20
+ export function parseModuleRef(ref) {
21
+ const { base, integrity } = splitIntegrity(ref);
22
+ const atIdx = base.lastIndexOf("@");
23
+ if (atIdx <= 0 || atIdx === base.length - 1) {
24
+ throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
25
+ }
26
+ const modulePath = base.slice(0, atIdx);
27
+ if (!modulePath.includes("/")) {
28
+ throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
29
+ }
30
+ const rawVersion = base.slice(atIdx + 1);
31
+ const version = rawVersion.startsWith("v") ? rawVersion.slice(1) : rawVersion;
32
+ if (!version) {
33
+ throw new Error(`Invalid module reference '${ref}', expected namespace/name@version`);
34
+ }
35
+ return { modulePath, version, integrity };
36
+ }
@@ -10,6 +10,5 @@ export declare class RegistrySource implements ManifestSource {
10
10
  resolveRelative(base: string, relative: string): string;
11
11
  private toRegistryModuleBase;
12
12
  private toRegistryUrl;
13
- private parseModuleRef;
14
13
  }
15
14
  //# sourceMappingURL=registry-source.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry-source.d.ts","sourceRoot":"","sources":["../../src/sources/registry-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAI7E,qBAAa,cAAe,YAAW,cAAc;IACvC,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;IA4BxE,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
+ {"version":3,"file":"registry-source.d.ts","sourceRoot":"","sources":["../../src/sources/registry-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAM7E,qBAAa,cAAe,YAAW,cAAc;IACvC,OAAO,CAAC,WAAW;gBAAX,WAAW,SAAuB;IAEtD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAuBxE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAMvD,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,aAAa;CAGtB"}
@@ -1,4 +1,6 @@
1
1
  import { DEFAULT_MANIFEST_FILENAME } from "../types.js";
2
+ import { splitIntegrity, verifiedFetch } from "./integrity.js";
3
+ import { isRegistryRef, parseModuleRef } from "./module-ref.js";
2
4
  const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
3
5
  export class RegistrySource {
4
6
  registryUrl;
@@ -6,20 +8,12 @@ export class RegistrySource {
6
8
  this.registryUrl = registryUrl;
7
9
  }
8
10
  supports(url) {
9
- return (!url.startsWith("http://") &&
10
- !url.startsWith("https://") &&
11
- !url.startsWith("/") &&
12
- !url.startsWith(".") &&
13
- url.includes("@") &&
14
- url.includes("/"));
11
+ return isRegistryRef(url);
15
12
  }
16
13
  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
- const text = await response.text();
14
+ const { base, integrity } = splitIntegrity(moduleRef);
15
+ const fetchUrl = this.toRegistryUrl(base);
16
+ const { text } = await verifiedFetch(fetchUrl, integrity, base);
23
17
  // Some object-storage backends (e.g. Cloudflare R2 / S3) surface auth or
24
18
  // permission failures by returning a 200 status with an XML error body.
25
19
  // Catch this here so the loader produces a precise error rather than
@@ -30,7 +24,7 @@ export class RegistrySource {
30
24
  const detail = codeMatch && messageMatch
31
25
  ? `${codeMatch[1]}: ${messageMatch[1]}`
32
26
  : text.slice(0, 200);
33
- throw new Error(`Registry returned a non-manifest response for ${moduleRef} ` +
27
+ throw new Error(`Registry returned a non-manifest response for ${base} ` +
34
28
  `(URL: ${fetchUrl}): ${detail}`);
35
29
  }
36
30
  return { text, source: fetchUrl };
@@ -41,27 +35,11 @@ export class RegistrySource {
41
35
  return new URL(relative, baseWithSlash).href;
42
36
  }
43
37
  toRegistryModuleBase(moduleRef) {
44
- const parsed = this.parseModuleRef(moduleRef);
38
+ const parsed = parseModuleRef(moduleRef);
45
39
  const normalizedBase = this.registryUrl.replace(/\/+$/, "");
46
40
  return `${normalizedBase}/${parsed.modulePath}/${parsed.version}`;
47
41
  }
48
42
  toRegistryUrl(moduleRef) {
49
43
  return `${this.toRegistryModuleBase(moduleRef)}/${DEFAULT_MANIFEST_FILENAME}`;
50
44
  }
51
- parseModuleRef(moduleRef) {
52
- const atIdx = moduleRef.lastIndexOf("@");
53
- if (atIdx <= 0 || atIdx === moduleRef.length - 1) {
54
- throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
55
- }
56
- const modulePath = moduleRef.slice(0, atIdx);
57
- if (!modulePath.includes("/")) {
58
- throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
59
- }
60
- const rawVersion = moduleRef.slice(atIdx + 1);
61
- const version = rawVersion.startsWith("v") ? rawVersion.substring(1) : rawVersion;
62
- if (!version) {
63
- throw new Error(`Invalid module reference '${moduleRef}', expected namespace/name@version`);
64
- }
65
- return { modulePath, version };
66
- }
67
45
  }
@@ -0,0 +1,20 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { type AnalysisDiagnostic } from "./types.js";
3
+ /**
4
+ * Warns when a library exports a kind (via `exports.kinds`) whose local
5
+ * `Telo.Definition` carries no `metadata.description`.
6
+ *
7
+ * The description is the primary human text the federated-discovery hub embeds
8
+ * for semantic search (`search_resources`), so an exported kind without one is
9
+ * undiscoverable by meaning. A warning (not an error) so the stdlib backfill is
10
+ * incremental and CI isn't blocked mid-migration.
11
+ *
12
+ * Scope: only kinds a library *exports* and *defines locally* are checked.
13
+ * Re-exported kinds (`exports.kinds: [Alias.Kind]`) belong to their owning
14
+ * module and are skipped; an exported name that isn't a local Telo.Definition
15
+ * (e.g. a Telo.Abstract) is skipped too. The check keys off the root library's
16
+ * own module doc, which is only present when that library is analyzed directly
17
+ * — so importing an under-described library never leaks warnings to its consumer.
18
+ */
19
+ export declare function validateKindDescriptions(manifests: ResourceManifest[]): AnalysisDiagnostic[];
20
+ //# sourceMappingURL=validate-kind-descriptions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-kind-descriptions.d.ts","sourceRoot":"","sources":["../src/validate-kind-descriptions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAIzE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,kBAAkB,EAAE,CA2C5F"}
@@ -0,0 +1,65 @@
1
+ import { DiagnosticSeverity } from "./types.js";
2
+ const SOURCE = "telo-analyzer";
3
+ /**
4
+ * Warns when a library exports a kind (via `exports.kinds`) whose local
5
+ * `Telo.Definition` carries no `metadata.description`.
6
+ *
7
+ * The description is the primary human text the federated-discovery hub embeds
8
+ * for semantic search (`search_resources`), so an exported kind without one is
9
+ * undiscoverable by meaning. A warning (not an error) so the stdlib backfill is
10
+ * incremental and CI isn't blocked mid-migration.
11
+ *
12
+ * Scope: only kinds a library *exports* and *defines locally* are checked.
13
+ * Re-exported kinds (`exports.kinds: [Alias.Kind]`) belong to their owning
14
+ * module and are skipped; an exported name that isn't a local Telo.Definition
15
+ * (e.g. a Telo.Abstract) is skipped too. The check keys off the root library's
16
+ * own module doc, which is only present when that library is analyzed directly
17
+ * — so importing an under-described library never leaks warnings to its consumer.
18
+ */
19
+ export function validateKindDescriptions(manifests) {
20
+ const diagnostics = [];
21
+ // Local Telo.Definition docs keyed by `<module>/<name>`.
22
+ const definitions = new Map();
23
+ for (const m of manifests) {
24
+ if (m.kind !== "Telo.Definition")
25
+ continue;
26
+ const name = m.metadata?.name;
27
+ const mod = m.metadata?.module;
28
+ if (name && mod)
29
+ definitions.set(`${mod}/${name}`, m);
30
+ }
31
+ for (const m of manifests) {
32
+ if (m.kind !== "Telo.Library")
33
+ continue;
34
+ const moduleName = m.metadata?.name;
35
+ if (!moduleName)
36
+ continue;
37
+ const exportedKinds = m.exports?.kinds;
38
+ if (!Array.isArray(exportedKinds))
39
+ continue;
40
+ for (const entry of exportedKinds) {
41
+ // Re-export (`Alias.Kind`) — the owning module owns its description.
42
+ if (typeof entry !== "string" || entry.includes("."))
43
+ continue;
44
+ const def = definitions.get(`${moduleName}/${entry}`);
45
+ if (!def)
46
+ continue; // not a local Telo.Definition (e.g. an abstract)
47
+ const description = def.metadata?.description;
48
+ if (typeof description === "string" && description.trim() !== "")
49
+ continue;
50
+ diagnostics.push({
51
+ severity: DiagnosticSeverity.Warning,
52
+ code: "KIND_MISSING_DESCRIPTION",
53
+ source: SOURCE,
54
+ message: `${moduleName}.${entry}: exported kind has no 'metadata.description'. Add a one-line ` +
55
+ `description — it is the primary text indexed for semantic discovery (search_resources).`,
56
+ data: {
57
+ resource: { kind: "Telo.Definition", name: entry },
58
+ filePath: def.metadata?.source,
59
+ path: "metadata.description",
60
+ },
61
+ });
62
+ }
63
+ }
64
+ return diagnostics;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -42,13 +42,13 @@
42
42
  "ajv-formats": "^3.0.1",
43
43
  "jsonpath-plus": "^10.3.0",
44
44
  "yaml": "^2.8.3",
45
- "@telorun/templating": "0.10.0"
45
+ "@telorun/templating": "0.10.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^20.0.0",
49
49
  "typescript": "^5.0.0",
50
50
  "vitest": "^2.1.8",
51
- "@telorun/sdk": "0.38.0"
51
+ "@telorun/sdk": "0.41.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "@telorun/sdk": "*"