/** * Pin Validator (showcase-internal) * * Enforces showcase-internal pin discipline. The historical mode that * compared `showcase/integrations/` against the external * `examples/integrations/` Dojo has been REMOVED: showcase and * the external Dojo are intentionally divergent products on independent * release cadences and a cross-product pin-parity gate was backwards. * * For each showcase package at `showcase/integrations//`: * 1. Read its dependency files (`package.json`, `requirements.txt`, * `pyproject.toml`). * 2. Every `@copilotkit/*` dep must pin to the canonical version * declared in `showcase/scripts/showcase-canonical-pins.json` * (`canonicalCopilotKitVersion`), OR to the per-slug per-dep value * listed under `overrides[slug]`. * 3. Every other framework / SDK dep (the FRAMEWORK_PATTERNS set — * mastra, langchain, langgraph, crewai, agno, llama-index, etc.) * must be an EXACT pin (no `^`/`~`/`>=`/`latest`/`next`/dist-tags/ * `workspace:*`/URLs). * 4. Emit `[FAIL] : ...` for each violation. * * Definition of "exact pin": * - npm: bare semver (`1.2.3`, `1.2.3-beta.1`). NO `^`, `~`, `>=`, `*`, * `latest`, `next`, `workspace:*`, URLs, or git refs. * - Python: `==`. NO `>=`, `~=`, `*`, or unpinned names. * * Usage: * npx tsx showcase/scripts/validate-pins.ts * * Exit codes: * 0 — no FAIL violations (WARN/SKIP are non-fatal) * 1 — one or more FAIL violations (pin drift detected) * 2 — internal error (crash, unexpected exception). Distinct from 1 so * CI callers can distinguish "pin drift" from "validator broken". * Note: validate-parity.ts uses a different exit-code taxonomy * (2=invalid-input, 3=unreadable, 4=internal); the tools are * intentionally not aligned on code 2. * 3 — unreadable input (e.g. VALIDATE_PINS_REPO_ROOT points at a * non-directory or a path the process cannot access). Distinct from * 2 so CI callers can route permissions/misconfig alerts separately * from true crashes. * * Output routing: * - [OK] and [SKIP] lines go to stdout. * - [FAIL] and [WARN] lines go to stderr (per Unix convention). * * --- NOTE: SLUG_MAP / examples helpers --- * * The slug-map tables (`SLUG_MAP`, `FALLBACK_MAP`, `BORN_IN_SHOWCASE`) * and the `resolveExampleDir` / `collectDojoDeps` helpers are NO LONGER * USED by `validateAll` — the new invariant is showcase-internal. They * are still EXPORTED so other consumers (audit.ts provenance, tests, * future tooling that wants to peek at the live external Dojo) can * import them. Removing those exports is out of scope for Phase 1. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { BORN_IN_SHOWCASE, FALLBACK_MAP, SLUG_MAP } from "./lib/slug-map.js"; // SLUG_MAP / FALLBACK_MAP / BORN_IN_SHOWCASE are the single source of // truth shared by audit.ts, validate-parity.ts, and this file. Re-exported // at the bottom of this file so tests importing from "../validate-pins.js" // see the same frozen tables. const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Exit-code constants. See the module docstring for the full taxonomy. // `as const` narrows each to its literal numeric type; `PinsExitCode` is // the closed union so callers (and `process.exitCode` assignments in // this file) cannot accidentally drift to an unrelated number. Adding // a new exit code is a deliberate edit to both the constant and the // union — a pure `const X = 4` cannot participate in the union. const EXIT_OK = 0 as const; const EXIT_DRIFT = 1 as const; const EXIT_INTERNAL = 2 as const; const EXIT_UNREADABLE = 3 as const; type PinsExitCode = | typeof EXIT_OK | typeof EXIT_DRIFT | typeof EXIT_INTERNAL | typeof EXIT_UNREADABLE; /** * Thrown when the repo-root input is present but unreadable or * structurally wrong (e.g. points at a file, EACCES, ENOTDIR). The * top-level catch uses `instanceof` to route these to EXIT_UNREADABLE * instead of EXIT_INTERNAL so CI callers can distinguish a permissions * misconfig from a true validator crash. */ class UnreadableInputError extends Error { /** * Optionally carries the partial report built up to the point the * infra error was observed. The top-level catch uses this to print * the FAIL/WARN/SKIP lines for slugs processed before the error so * operators don't lose diagnostic output when a single slug has an * infra problem mid-run. undefined for infra errors raised before * the slug loop started (e.g. bad VALIDATE_PINS_REPO_ROOT, missing * packages dir) where no report exists yet. */ readonly partialReport?: Report; constructor(message: string, partialReport?: Report) { super(message); this.name = "UnreadableInputError"; this.partialReport = partialReport; } } // REPO_ROOT resolution allows tests to override via env var. The override // must be an absolute path pointing at an existing directory; a relative // or non-existent override silently yielding an empty scan would turn a // misconfiguration into a false green. // // Implementation note: `fs.existsSync` collapses ENOENT (does not // exist) with EACCES (exists but unreadable by the current process) // into a single false result, which produces a misleading "does not // exist" error when the real problem is a permissions gap. Use // `fs.statSync` with errno inspection so the message names the right // failure mode. function computeRepoRoot(): string { const override = process.env.VALIDATE_PINS_REPO_ROOT; if (override) { if (!path.isAbsolute(override)) { throw new Error( `VALIDATE_PINS_REPO_ROOT must be an absolute path; got: ${override}`, ); } let st: fs.Stats; try { st = fs.statSync(override); } catch (e) { const err = e as NodeJS.ErrnoException; if (err && err.code === "ENOENT") { // ENOENT on the override is a bad-input/configuration error, // not a validator crash — route to EXIT_UNREADABLE (3) so CI // callers can distinguish misconfig from true internal errors. throw new UnreadableInputError( `VALIDATE_PINS_REPO_ROOT does not exist on disk: ${override}`, ); } if (err && err.code === "EACCES") { throw new UnreadableInputError( `VALIDATE_PINS_REPO_ROOT exists but is not readable (permission denied): ${override}`, ); } if (err && err.code === "ENOTDIR") { throw new UnreadableInputError( `VALIDATE_PINS_REPO_ROOT path component is not a directory: ${override}`, ); } // Surface the underlying error message so the caller sees the // actual failure rather than a generic wrapper. Any stat failure // on the override path is an infra-class problem (the caller // configured a path we can't access), so route through // UnreadableInputError → EXIT_UNREADABLE (3) instead of letting // a plain Error misroute to EXIT_INTERNAL (2). const msg = err && err.message ? err.message : String(e); throw new UnreadableInputError( `VALIDATE_PINS_REPO_ROOT stat failed: ${override}: ${msg}`, ); } // Override must be a directory — a file override would let the // rest of the validator run with a bogus REPO_ROOT and produce // misleading "nothing found" output rather than an immediate error. if (!st.isDirectory()) { throw new UnreadableInputError( `VALIDATE_PINS_REPO_ROOT is not a directory: ${override}`, ); } return override; } return path.resolve(__dirname, "..", ".."); } function paths() { const repoRoot = computeRepoRoot(); return { REPO_ROOT: repoRoot, // EXAMPLES_DIR is retained for the legacy `resolveExampleDir` / // `collectDojoDeps` helpers (still exported for non-validateAll // consumers). validateAll no longer reads it. EXAMPLES_DIR: path.join(repoRoot, "examples", "integrations"), PACKAGES_DIR: path.join(repoRoot, "showcase", "integrations"), CANONICAL_PINS_FILE: path.join( repoRoot, "showcase", "scripts", "showcase-canonical-pins.json", ), }; } // --------------------------------------------------------------------------- // Canonical pins config // --------------------------------------------------------------------------- /** * Schema for `showcase/scripts/showcase-canonical-pins.json`: * { * "canonicalCopilotKitVersion": "1.59.2", * "overrides": { * "": { "": "" } * } * } * * `canonicalCopilotKitVersion` is the canonical pin for every * `@copilotkit/*` dep across all showcase integrations. `overrides[slug]` * is a per-slug map of dep-name → allowed verbatim spec used when a * specific integration legitimately deviates (e.g. a `pkg.pr.new` URL * during a runtime upgrade, or a legacy stable for a harness fixture). */ export interface CanonicalPins { canonicalCopilotKitVersion: string; overrides: Readonly>>>; } class CanonicalPinsError extends Error { constructor(message: string) { super(message); this.name = "CanonicalPinsError"; } } export function loadCanonicalPins(file: string): CanonicalPins { let raw: string; try { raw = fs.readFileSync(file, "utf-8"); } catch (e) { const err = e as NodeJS.ErrnoException; if (err && err.code === "ENOENT") { throw new UnreadableInputError( `canonical pins file not found at ${file}`, ); } const msg = err && err.message ? err.message : String(e); throw new UnreadableInputError( `canonical pins file read failed (${err?.code ?? "unknown"}): ${file}: ${msg}`, ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (e) { const msg = e instanceof Error ? e.message : String(e); throw new CanonicalPinsError(`${file}: JSON syntax error: ${msg}`); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new CanonicalPinsError(`${file}: expected top-level object`); } const obj = parsed as Record; const ver = obj.canonicalCopilotKitVersion; if (typeof ver !== "string" || ver.length === 0) { throw new CanonicalPinsError( `${file}: 'canonicalCopilotKitVersion' must be a non-empty string`, ); } if (!isExactSpec(ver)) { throw new CanonicalPinsError( `${file}: 'canonicalCopilotKitVersion' must itself be an exact pin (got ${JSON.stringify(ver)})`, ); } const ovRaw = obj.overrides; const overrides: Record> = {}; if (ovRaw !== undefined) { if (typeof ovRaw !== "object" || ovRaw === null || Array.isArray(ovRaw)) { throw new CanonicalPinsError(`${file}: 'overrides' must be an object`); } for (const [slug, depsRaw] of Object.entries(ovRaw)) { if ( typeof depsRaw !== "object" || depsRaw === null || Array.isArray(depsRaw) ) { throw new CanonicalPinsError( `${file}: 'overrides.${slug}' must be an object`, ); } const inner: Record = {}; for (const [dep, spec] of Object.entries(depsRaw)) { if (typeof spec !== "string" || spec.length === 0) { throw new CanonicalPinsError( `${file}: 'overrides.${slug}.${dep}' must be a non-empty string`, ); } inner[dep] = spec; } overrides[slug] = Object.freeze(inner); } } return Object.freeze({ canonicalCopilotKitVersion: ver, overrides: Object.freeze(overrides), }); } // --------------------------------------------------------------------------- // Slug resolution // --------------------------------------------------------------------------- // Reverse of SLUG_MAP: showcase slug → examples dir name(s). // Precomputed at module load so each slug lookup is O(1) rather than // a linear scan of SLUG_MAP. Frozen so runtime mutation attempts throw // — the tables are meant to be effectively constant. const REVERSE_MAP: Readonly> = (() => { const reverse: Record = {}; for (const [example, slug] of SLUG_MAP) { if (!reverse[slug]) reverse[slug] = []; reverse[slug].push(example); } // Freeze inner arrays first, then outer record. for (const k of Object.keys(reverse)) Object.freeze(reverse[k]); return Object.freeze(reverse); })(); export interface ResolveResult { exampleDir: string | null; // If a FALLBACK_MAP entry existed but pointed to a missing dir, the // caller should emit a distinct WARN with this path for diagnostics. missingFallbackTarget?: string; } function resolveExampleDirDetailed( showcaseSlug: string, pathsOverride?: ReturnType, ): ResolveResult { if (BORN_IN_SHOWCASE.has(showcaseSlug)) return { exampleDir: null }; // Accept an optional pre-computed `paths()` so validateAll can // compute it ONCE per run rather than re-validating // VALIDATE_PINS_REPO_ROOT per slug. Direct callers (tests, ad-hoc // use) may omit it and pay the per-call cost. const { EXAMPLES_DIR, REPO_ROOT } = pathsOverride ?? paths(); // Strategy: explicit-fallback > reverse-SLUG_MAP > direct-name-match. // Each strategy can "fall through" if its candidate dir does not // exist on disk, so that a stale FALLBACK_MAP entry doesn't block a // later strategy from resolving correctly. // // Use `fs.statSync` + catch-ENOENT rather than `fs.existsSync` so // that a permission error (EACCES) does not silently collapse to the // "not present" branch. EACCES means "there is something there, but // this process can't read it" — treating it as "absent" hides real // misconfiguration. Other errors re-throw so they're surfaced at the // top level rather than quietly skipped. const existsAsDir = (p: string): boolean => { try { return fs.statSync(p).isDirectory(); } catch (e) { const err = e as NodeJS.ErrnoException; if (err && err.code === "ENOENT") return false; // EACCES / ENOTDIR / EIO / ELOOP / etc. mean "there is something // at this path, but we can't read it". Route through // UnreadableInputError so the top-level catch maps this to // EXIT_UNREADABLE (3) — a permissions/infra misconfig — rather // than EXIT_INTERNAL (2) which signals a validator crash. const code = err && err.code ? err.code : "unknown"; const msg = err && err.message ? err.message : String(e); throw new UnreadableInputError( `cannot stat candidate example dir (${code}): ${p}: ${msg}`, ); } }; // Strategy 1 — explicit fallback (documents SLUG_MAP staleness). const fallback = FALLBACK_MAP[showcaseSlug]; let missingFallbackTarget: string | undefined; if (fallback) { const dir = path.join(EXAMPLES_DIR, fallback); if (existsAsDir(dir)) return { exampleDir: dir }; // Display relative to REPO_ROOT so the WARN line reads // `examples/integrations/` rather than the ambiguous // `integrations/` (which hides where the missing dir is). missingFallbackTarget = path.relative(REPO_ROOT, dir); } // Strategy 2 — reverse-map lookup from SLUG_MAP. const candidates = REVERSE_MAP[showcaseSlug] || []; for (const cand of candidates) { const dir = path.join(EXAMPLES_DIR, cand); if (existsAsDir(dir)) return { exampleDir: dir, missingFallbackTarget }; } // Strategy 3 — direct name match (common case: showcase slug === // examples dir name). const direct = path.join(EXAMPLES_DIR, showcaseSlug); if (existsAsDir(direct)) return { exampleDir: direct, missingFallbackTarget }; return { exampleDir: null, missingFallbackTarget }; } function resolveExampleDir(showcaseSlug: string): string | null { return resolveExampleDirDetailed(showcaseSlug).exampleDir; } // --------------------------------------------------------------------------- // Dependency extraction // --------------------------------------------------------------------------- // Heuristic: what counts as an agent framework / SDK that must be pinned. // Applied as regex match (mostly anchored) against dependency NAMES only; // versions are compared via exact string match per the // INTEGRATION-CHECKLIST rule. // // Expected match set (non-exhaustive sanity list, with concrete examples // rather than glob-like notation): @copilotkit/, copilotkit, // @ag-ui/, ag-ui-, ag_ui_, // @langchain/, langchain, langchain-, langgraph, // langgraph-, langsmith, @mastra/, mastra, crewai, // crewai-, pydantic-ai, pydantic-ai-, agno, // llama-index, llama-index-, llama_index, llama_index_, // llamaindex, google-adk, google-genai, strands-agents, // strands-agents-, agent-framework, agent-framework-, // @ai-sdk/, ai, @hashbrownai/, @anthropic-ai/, // anthropic, openai, ag2, langroid, spring-ai, spring-ai-, and // Spring's Maven coordinate form `org.springframework.ai:` which // appears in Java manifests as a colon-delimited group:artifact string. const FRAMEWORK_PATTERNS: Array = [ // CopilotKit SDK /^@copilotkit\//, /^copilotkit$/, // AG-UI /^@ag-ui\//, /^ag-ui[-_]/, /^ag_ui[-_]/, // LangChain / LangGraph /^@langchain\//, /^langchain$/, /^langchain-/, /^langgraph$/, /^langgraph-/, /^langgraph_/, /^langsmith$/, // Mastra /^@mastra\//, /^mastra$/, // CrewAI /^crewai$/, /^crewai-/, // Pydantic AI /^pydantic-ai$/, /^pydantic-ai-/, // Agno /^agno$/, // LlamaIndex (dash and underscore forms) /^llama-index$/, /^llama-index-/, /^llama_index$/, /^llama_index_/, /^llamaindex$/, // Google ADK / GenAI /^google-adk$/, /^google-genai$/, // Strands /^strands-agents$/, /^strands-agents-/, // Microsoft Agent Framework /^agent-framework$/, /^agent-framework-/, // AI SDK (Vercel) /^@ai-sdk\//, /^ai$/, // Hashbrown / A2UI renderers travel with CopilotKit /^@hashbrownai\//, // Anthropic / OpenAI SDKs used directly by agents /^@anthropic-ai\//, /^anthropic$/, /^openai$/, // Other frameworks that show up in born-in-showcase packages /^ag2$/, /^ag2-/, /^langroid$/, /^langroid-/, // Spring AI (Java coordinates appear with these prefixes) /^spring-ai$/, /^spring-ai-/, // Maven coordinate form for Spring AI: `group:artifact` with group // prefix `org.springframework.ai`. Matches `org.springframework.ai:foo` // for any artifact `foo`. /^org\.springframework\.ai:/, ]; function isFrameworkDep(name: string): boolean { return FRAMEWORK_PATTERNS.some((re) => re.test(name)); } export interface DepMap { [name: string]: string; // name -> raw version specifier string } /** * Extended parse result: includes the DepMap plus advisory diagnostics. * Callers use these to surface WARN lines even when the parse did not * outright fail. Tests still assert against the returned DepMap shape. */ export interface ParseResult { deps: DepMap; /** * Entries the parser intentionally skipped (e.g. Poetry git-only deps, * inline tables with no `version`, malformed requirements.txt lines). * Non-fatal but surface as [WARN] in validateAll so CI has a paper * trail. */ skipped: Array<{ name: string; reason: string }>; /** * Fully unparseable lines we dropped from requirements.txt. One entry * per dropped line. */ dropped: string[]; } /** * Parse a package.json into a DepMap. May throw on I/O failure or * malformed JSON; callers that tolerate partial failure should catch * and record the error. Runtime validates the parsed JSON is a plain * object (not null / array / scalar) before property access, and that * each entry in `dependencies` / `devDependencies` / `peerDependencies` * is a string. Non-string dep values throw. * * Note: dep values are validated as strings, but the shape of each * individual key (semver validity, registry name validity, etc.) is * NOT validated here — that is the caller's responsibility. * * @throws Error on fs.readFileSync / JSON.parse failure, when the * parsed value is not a plain object, or when any declared * dep value is not a string. */ function parsePackageJson(file: string): DepMap { const raw = fs.readFileSync(file, "utf-8"); const parsed: unknown = JSON.parse(raw); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error( `expected JSON object at top level, got ${ parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed }`, ); } // After the guard above we know `parsed` is a plain (non-null, // non-array) object. Treat it as `Record` so every // bucket lookup is typed as `unknown` and MUST be shape-checked by // validateBucket below. Casting to a specific shape // (`{ dependencies?: Record; … }`) would imply // pre-validated shape and invite callers to trust the cast without // runtime checks. const pkg: Record = parsed as Record; // Validate inner dep values are strings. A package.json with // non-string dep values (objects, numbers, nulls) is structurally // invalid per the npm schema; the JS spread below would otherwise // silently admit them into the DepMap and downstream comparisons // would throw or misbehave. const validateBucket = ( bucket: unknown, bucketName: string, ): Record | undefined => { if (bucket === undefined) return undefined; if ( typeof bucket !== "object" || bucket === null || Array.isArray(bucket) ) { throw new Error( `expected '${bucketName}' to be an object of name→string, got ${ bucket === null ? "null" : Array.isArray(bucket) ? "array" : typeof bucket }`, ); } const ok: Record = {}; for (const [k, v] of Object.entries(bucket)) { if (typeof v !== "string") { throw new Error( `expected '${bucketName}.${k}' to be a string, got ${typeof v}`, ); } ok[k] = v; } return ok; }; const deps = validateBucket(pkg.dependencies, "dependencies"); const peerDeps = validateBucket(pkg.peerDependencies, "peerDependencies"); const devDeps = validateBucket(pkg.devDependencies, "devDependencies"); // Merge dependencies, devDependencies, and peerDependencies. Frameworks // in JS apps often live in devDeps (e.g. Next.js starters), and pinning // rules apply to them all. On overlap, later spread wins: dev > peer > // runtime. That's fine because (a) the caller applies first-writer-wins // at the FILE level, and (b) in practice these rarely overlap within // one file. return { ...deps, ...peerDeps, ...devDeps, }; } /** * PEP 503 name normalization: lowercase, collapse runs of `-`, `_`, `.` * into a single `-`. Used to compare Python dep names across underscore / * hyphen spellings (e.g. `langgraph_checkpoint` vs `langgraph-checkpoint`). */ function canonicalizePythonName(name: string): string { return name.toLowerCase().replace(/[-_.]+/g, "-"); } /** * Returns true iff `spec` is a monorepo workspace reference that the * validator intentionally does NOT pin-check. Workspace refs (e.g. * `workspace:*`, `workspace:^`, `workspace:1.2.3`) are resolved by the * package manager against the local monorepo, not published — there is * no "pin" semantics to check. Handled out-of-band from isExactSpec * because isExactSpec merely classifies, while this classifies AND * indicates the caller should emit a [SKIP] rather than a [FAIL]. */ function isWorkspaceRef(spec: string): boolean { if (!spec) return false; return spec.trim().startsWith("workspace:"); } /** * Returns true iff `spec` is an EXACT pin per the INTEGRATION-CHECKLIST rule. * * Accepts: * - Bare semver-ish strings: "1.2.3", "0.2.14", "1.0.0-beta.1" * - PEP 440 forms: "1.2.3.post1", "1.2.3.dev1", "1.2.3rc1", "1.2.3a1", * "1.2.3b2" * - Python exact specs: "==1.2.3", "===1.2.3", "==0.2.14", "==1.2.3rc1" * * Rejects: * - Range operators: ^, ~, >=, <=, >, <, ~=, != * - X-ranges / wildcards: "1.x", "1.2.x", "1.2.*", "*", "X.X.X" * - Dist-tags: "latest", "next", "" (empty) * - Workspace/monorepo refs: "workspace:*", "workspace:^", "file:" * - URLs / git refs / paths * - Malformed Python `==` bodies without a full MAJOR.MINOR (e.g. `==0`). */ function isExactSpec(spec: string): boolean { if (!spec) return false; const trimmed = spec.trim(); if (!trimmed) return false; // Reject any wildcard marker anywhere in the string. `*`, `x`, or `X` // appearing as a version component (e.g. "1.x", "1.2.*") is never exact. // The Python `==` form also cannot contain wildcards. if (/(^|[.\-_+])[xX*]([.\-_+]|$)/.test(trimmed)) return false; if (/\*/.test(trimmed)) return false; // Python == / === exact form. // Body must match MAJOR.MINOR with optional numeric sub-segments, // an optional PEP 440 pre-release tag (`a1`, `b2`, `rc3`, etc.) // directly attached, an optional `.postN` / `.devN` segment, an // optional `-pre` / `+local` suffix. The regex is anchored end-to-end // so degenerate bodies like `==1.2.foo` (dotted letter segment that // is not a recognized PEP 440 keyword) or `==1.2abc!` (illegal // trailing punctuation) are rejected — a non-anchored `^\d+\.\d+` // or a too-permissive tail like `(?:[-+.A-Za-z0-9]+)*` would accept // those and mis-classify them as exact pins. const pyMatch = trimmed.match(/^={2,3}\s*(\S+)$/); if (pyMatch) { return /^\d+\.\d+(?:\.\d+)*(?:[A-Za-z]+\d*)?(?:\.(?:post|dev)\d*)?(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/.test( pyMatch[1], ); } // Anything starting with a range operator is NOT exact. if (/^[\^~<>!]/.test(trimmed)) return false; if (/^(>=|<=|==|~=|!=)/.test(trimmed)) return false; // Tags, workspace refs, URLs, paths. if (/^[A-Za-z]/.test(trimmed)) { // Starts with a letter: dist-tag like "latest" or "next", or // "workspace:*", "file:...", "github:user/repo", etc. return false; } // Bare version must start with a digit and contain no ranges/spaces. if (!/^\d/.test(trimmed)) return false; if (/\s/.test(trimmed)) return false; if (/[|]{1,2}/.test(trimmed)) return false; // "1.2.3 || 2.0.0" // Comma-joined ranges (Poetry / PEP 440): "1.2.3,>=1.0" is composed // of two constraints and cannot be a single exact pin. if (/,/.test(trimmed)) return false; // Bare version shape: MAJOR.MINOR[.PATCH] with an optional // pre-release / build / PEP 440 suffix. Enforced by a concrete // semver-shape regex so only digit-dotted-digit forms (plus // permitted suffixes) pass, rejecting exotic bare-letter tails // like `1x`, `2X`, and `1e2`. // // MAJOR.MINOR is required for bare versions so bare-spec acceptance // is symmetric with the Python `==` form above (which rejects `==1`). // MAJOR-only like `"1"` is rejected on both paths, keeping // drift-report behavior consistent across ecosystems. if (!/^\d+\.\d+(?:\.\d+)?(?:[-+.][A-Za-z0-9.-]+)*$/.test(trimmed)) { return false; } return true; } // Parse a requirements.txt line. Strip comments, extras, env markers, // pip hash flags, index-url flags, and `--find-links` flags. // // Returns: // - `[name, versionSpec]` on a valid `name` form; `versionSpec` // MAY be an empty string when the line is name-only (e.g. // `langgraph`). The file-level walker is responsible for surfacing // these as `skipped[]` since an empty spec is not a pin. // - `null` when the line is unparseable (editable install, URL-only, // operator-leading, pure flag line, etc.). function parseRequirementsLine(line: string): [string, string] | null { // Strip trailing comments. const stripped = line.replace(/#.*$/, "").trim(); if (!stripped) return null; // Editable installs / URLs — not supported. if (/^-e\b/.test(stripped) || /^(https?|git\+)/.test(stripped)) return null; // Split on environment marker (;) and take the LHS. const lhs = stripped.split(";")[0].trim(); // Strip pip-install flags attached to a single line: // `--hash=sha256:...`, `--index-url=...`, `--extra-index-url=...`, // `--find-links=...`. These appear AFTER the spec. Single-pass // alternation avoids order-dependency between sequential replaces // (e.g. a `--extra-index-url=...` substring being partially consumed // by a naïve `--index-url=\S+` regex run first). const flagsStripped = lhs .replace(/\s+--(?:hash|index-url|extra-index-url|find-links)=\S+/g, "") .trim(); // Match: name [extras] version-spec // name characters: letters, digits, -, _, . const match = flagsStripped.match( /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*(.*)$/, ); if (!match) return null; const name = match[1]; const spec = (match[2] || "").trim(); return [name, spec]; } /** * Parse a requirements.txt file into a DepMap. Returns a ParseResult * with the DepMap plus: * - `skipped`: name-only requirements (e.g. `langgraph` with no * version spec) that the parser intentionally did NOT admit to the * DepMap. The file-level walker surfaces these as [WARN] so * operators see the manifest has an unpinned dep rather than the * entry being silently dropped. * - `dropped`: fully unparseable lines — caller surfaces as [WARN]. * * @throws Error on fs.readFileSync failure. */ function parseRequirementsTxtDetailed(file: string): ParseResult { const raw = fs.readFileSync(file, "utf-8"); const out: DepMap = {}; const skipped: Array<{ name: string; reason: string }> = []; const dropped: string[] = []; for (const line of raw.split(/\r?\n/)) { // Empty and comment lines are legitimate — don't flag them as dropped. const stripped = line.replace(/#.*$/, "").trim(); if (!stripped) continue; // Editable installs / URLs are valid requirements but we cannot // extract a pin from them; they're intentional non-deps, not drops. if (/^-e\b/.test(stripped) || /^(https?|git\+)/.test(stripped)) continue; const parsed = parseRequirementsLine(line); if (parsed) { // First-writer-wins within a file (a given dep may appear // multiple times with different pins; the earlier line wins). // NOTE: pip's own resolver does not define a "first vs last // writer" rule across identical lines — re-declaration within a // single requirements file is already ambiguous input, and real // installs are normally deduped upstream. We pick first-writer // here so the rule matches collectDepsFromDir's first-writer // file-level precedence (agent-scope wins over root-scope). If // a concrete case demands pip's actual semantics, replace this // block rather than layering an exception. const [name, spec] = parsed; if (!(name in out)) { if (!spec) { // Name-only line (e.g. `langgraph` with no spec): surface as // skipped since it's not pinning anything. See // INTEGRATION-CHECKLIST rule about exact pins. skipped.push({ name, reason: "name-only requirement (no version)", }); } else { out[name] = spec; } } } else { dropped.push(stripped); } } return { deps: out, skipped, dropped }; } /** * Thin compatibility wrapper: returns a DepMap for callers that do not * care about dropped-line or skipped-line diagnostics. Internally * delegates to the detailed form. * * NOTE: This wrapper does NOT tolerate silent data loss. If the * underlying detailed parse produces any `skipped[]` or `dropped[]` * entries, this wrapper THROWS so the caller is forced to switch to * `parseRequirementsTxtDetailed` rather than quietly losing those * diagnostics. Callers that may legitimately encounter skipped/dropped * entries MUST call `parseRequirementsTxtDetailed` directly. * * @throws Error on fs.readFileSync failure. * @throws Error when the file contains skipped or dropped entries * (use parseRequirementsTxtDetailed instead). */ function parseRequirementsTxt(file: string): DepMap { const detailed = parseRequirementsTxtDetailed(file); if (detailed.skipped.length > 0 || detailed.dropped.length > 0) { throw new Error( `parseRequirementsTxt: ${file} produced ${detailed.skipped.length} skipped and ` + `${detailed.dropped.length} dropped entries; use parseRequirementsTxtDetailed ` + `to access them instead of silently discarding.`, ); } return detailed.deps; } /** * Scan `raw` starting at `openBracketIdx` (which must point at a `[` * character) and return the index of the matching closing `]`, skipping * over any `]` or `[` embedded in single- or double-quoted strings. * Returns -1 if no matching bracket is found before end-of-string OR * before a new TOML table header (`\n[...]` at column 0). * * This exists because the PEP 621 and PEP 621-extras arrays can legally * contain entries like `"langchain[all]==1.2.3"` where the bracket * character appears inside a quoted string. A non-greedy `[\s\S]*?\]` * regex silently truncates such arrays at the first `]` and drops * everything after — a silent miss that makes the validator emit [OK] * against incomplete dependency sets. * * The scanner handles: * - Basic double-quoted strings: `"..."` (escape `\"` permitted). * - Basic single-quoted strings: `'...'` (escape `\'` permitted). * - TOML comments: `#` to end-of-line are ignored outside strings so * a `]` that appears inside a comment does NOT satisfy the search. * - Table header termination: a `\n[` at column 0 while still at * depth > 0 means the array was never closed before the next * table header — return -1 so the caller can throw. * * It does NOT handle TOML multi-line basic strings (`"""..."""`) or * nested arrays spanning multiple table bodies. A real TOML tokenizer * would be more correct; the tradeoff is accepted because our fixtures * are simple single-section arrays of strings. */ function findMatchingBracket(raw: string, openBracketIdx: number): number { let depth = 0; let i = openBracketIdx; while (i < raw.length) { const ch = raw[i]; // A new TOML table header `\n[` at depth >= 1 means the current // array was never closed. The opening `[` of the header does NOT // count as a nested-array bump — it's a new section starting. // Only trigger this when we've already consumed the opening // bracket (i > openBracketIdx) and the next char is `[`. if (ch === "\n" && depth >= 1 && i + 1 < raw.length && raw[i + 1] === "[") { return -1; } if (ch === '"') { // Skip basic-string. Escapes: `\\` and `\"`. i += 1; while (i < raw.length) { const c = raw[i]; if (c === "\\" && i + 1 < raw.length) { i += 2; continue; } if (c === '"') { i += 1; break; } i += 1; } continue; } if (ch === "'") { // Skip literal-string. i += 1; while (i < raw.length && raw[i] !== "'") i += 1; if (i < raw.length) i += 1; continue; } if (ch === "#") { // Skip to end-of-line. A `]` inside a comment must NOT close // the array. while (i < raw.length && raw[i] !== "\n") i += 1; continue; } if (ch === "[") { depth += 1; i += 1; continue; } if (ch === "]") { depth -= 1; if (depth === 0) return i; i += 1; continue; } i += 1; } return -1; } /** * Extract quoted-string entries out of a TOML array body (the text * between an opening `[` and its matching `]`), dispatching each entry * through `parseRequirementsLine` and merging into `out` (first-writer- * wins). Unparseable non-empty entries go into `dropped`. * * Name-only entries (e.g. bare `"langgraph"` in the array) are pushed * to `skipped[]` rather than silently admitted to `out` with an empty * spec. This mirrors `parseRequirementsTxtDetailed`'s file-level * handling so pyproject and requirements.txt report the same * diagnostics for the same input. */ function ingestArrayBody( body: string, out: DepMap, dropped: string[], skipped: Array<{ name: string; reason: string }>, ): void { const quoteRe = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; let m: RegExpExecArray | null; while ((m = quoteRe.exec(body))) { const entry = m[1] ?? m[2] ?? ""; const parsed = parseRequirementsLine(entry); if (parsed) { const [name, spec] = parsed; if (!(name in out)) { if (!spec) { // Name-only entry — not pinning anything. Surface as // skipped so a [WARN] is emitted, matching requirements.txt // handling. Without this, the DepMap silently gained an // entry with an empty spec and downstream error messages // read `(empty)` without explaining the cause. skipped.push({ name, reason: "name-only requirement (no version)", }); } else { out[name] = spec; } } } else if (entry.trim()) { dropped.push(entry); } } } /** * Extremely small pyproject.toml reader. Handles: * * - Top-level `[project]` dependencies array (PEP 621). * - Top-level `[project.optional-dependencies]` tables (PEP 621 extras) * — every subkey's array is scanned. * - Poetry `[tool.poetry.dependencies]` tables, including * `[tool.poetry.group..dependencies]`. * * We avoid adding a full TOML dependency by using targeted regexes. The * parser stops at the NEXT TOP-LEVEL table header (e.g. `[tool.foo]`) — * crucially NOT at dotted subtables like `[project.optional-dependencies]`, * which are children of `[project]`. * * @throws Error on fs.readFileSync failure, or when a top-level * `dependencies = [` array in `[project]` is opened but a * matching `]` is never found by the quote-aware scanner. */ function parsePyprojectTomlDetailed(file: string): ParseResult { const raw = fs.readFileSync(file, "utf-8"); const out: DepMap = {}; const skipped: Array<{ name: string; reason: string }> = []; const dropped: string[] = []; // --- PEP 621: [project] table --- // Find the [project] section body, stopping at the next header of any // kind — whether a plain table like `[tool]` or a dotted table like // `[tool.poetry]` / `[project.optional-dependencies]`. Dotted // subtables under `[project]` (e.g. `[project.optional-dependencies]`) // are handled by separate scanners that run against the raw file, so // it is safe — and in fact required — to terminate [project] body at // the FIRST subsequent `[...]` header. Otherwise `dependencies = [` // keys inside Poetry group subtables can leak into PEP 621 parsing. const projectBodyRe = /(?:^|\n)\[project\][^\n]*\n([\s\S]*?)(?=\n\[[^\]\n]+\]|\n*$)/; const projectMatch = raw.match(projectBodyRe); if (projectMatch) { const section = projectMatch[1]; // Find `dependencies = [` using a regex anchored to a line boundary // so we don't accidentally match `optional-dependencies = [` or // `dev-dependencies = [`. We need the POSITION of the opening `[` // so we can hand it to `findMatchingBracket`, which understands // quoted-string embedded brackets (e.g. `"langchain[all]==1.2.3"`). const depsKeyRe = /(?:^|\n)(dependencies\s*=\s*)\[/; const km = depsKeyRe.exec(section); if (km) { const bracketIdx = km.index + km[0].length - 1; const closeIdx = findMatchingBracket(section, bracketIdx); if (closeIdx < 0) { throw new Error( `malformed pyproject.toml: [project] 'dependencies = [' opened but never closed (missing matching ']')`, ); } const body = section.slice(bracketIdx + 1, closeIdx); ingestArrayBody(body, out, dropped, skipped); } } // --- PEP 621: [project.optional-dependencies] subsections --- // Under PEP 621, optional dependencies are declared as: // [project.optional-dependencies] // extra_a = ["foo==1.0", "bar==2.0"] // extra_b = ["baz==3.0"] // Each key's value is a string array; the array entries use the same // requirements.txt grammar as [project].dependencies. We scan each // key's array separately so optional-only framework deps are also // subject to pin drift checks. const optHeaderRe = /(?:^|\n)\[project\.optional-dependencies\][^\n]*\n/g; let optHdr: RegExpExecArray | null; while ((optHdr = optHeaderRe.exec(raw))) { const bodyStart = optHdr.index + optHdr[0].length; const rest = raw.slice(bodyStart); const nextHeader = rest.match(/\n\[/); const body = nextHeader ? rest.slice(0, nextHeader.index) : rest; // Walk subkey assignments `extra_name = [`. Use a regex to locate // each opening `[` but hand the closing-bracket search off to the // quote-aware scanner so embedded `]` characters (e.g. inside // `"langchain[all]==1.2.3"`) don't truncate the array and silently // drop subsequent entries. const subkeyKeyRe = /([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*\[/g; let sm: RegExpExecArray | null; while ((sm = subkeyKeyRe.exec(body))) { const bracketIdx = sm.index + sm[0].length - 1; const closeIdx = findMatchingBracket(body, bracketIdx); if (closeIdx < 0) { // Unterminated extras array is genuinely malformed TOML — the // array's contents are truncated, so we cannot faithfully // report what was declared. Throw a parseError so the caller // surfaces a FAIL; downgrading to `dropped[]` (WARN) lets // silent data loss pass CI. throw new Error( `malformed pyproject.toml: [project.optional-dependencies].${sm[1]} opened '[' but never closed (missing ']')`, ); } const arrBody = body.slice(bracketIdx + 1, closeIdx); ingestArrayBody(arrBody, out, dropped, skipped); // Advance past the close so the next subkey is found after it. subkeyKeyRe.lastIndex = closeIdx + 1; } } // --- Poetry: [tool.poetry.dependencies] AND // [tool.poetry.group..dependencies] tables --- // // Poetry supports grouped dev/agent/etc. dependency sections under // `[tool.poetry.group.*.dependencies]`. Missing these sections causes the // validator to silently skip group-pinned frameworks, so we walk each // matching table header. // // Poetry version-string semantics: a bare version like `"1.2.3"` means // caret (`^1.2.3`) in Poetry — NOT an exact pin. We prefix such values // with `^` before storing so downstream `isExactSpec` correctly rejects // them. Tradeoff: the stored spec no longer textually matches the raw // pyproject.toml token, which is visible in FAIL messages that read // e.g. "pinned to ^1.2.3" when the file says `"1.2.3"`. This is // accurate (the effective spec IS `^1.2.3` per Poetry rules), but can // confuse an operator grepping the source — hence the explicit note. // Operator-prefixed strings (`^`, `~`, `>=`, `==`, ...) are stored // verbatim. const poetryHeaderRe = /(?:^|\n)\[tool\.poetry(?:\.group\.[A-Za-z0-9_-]+)?\.dependencies\][^\n]*\n/g; let headerMatch: RegExpExecArray | null; while ((headerMatch = poetryHeaderRe.exec(raw))) { const bodyStart = headerMatch.index + headerMatch[0].length; // Body ends at the next table header ([something]) or end of file. const rest = raw.slice(bodyStart); const nextHeader = rest.match(/\n\[/); const body = nextHeader ? rest.slice(0, nextHeader.index) : rest; for (const rawLine of body.split(/\r?\n/)) { const line = rawLine.replace(/#.*$/, "").trim(); if (!line) continue; // key = value (value may be a string or an inline table like // `{ version = "^1.0", extras = [...] }`) const kvMatch = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*(.*)$/); if (!kvMatch) continue; const name = kvMatch[1]; // "python" is the interpreter constraint, not a dependency. if (name === "python") continue; let value = kvMatch[2].trim(); let spec = ""; if (value.startsWith("{")) { // Inline table. Pull `version = "..."` or `version = '...'` out // of it; if absent (e.g. git-only / path-only / branch-only), // record as skipped. TOML permits both single and double quotes // for basic strings and the stdlib / Poetry itself both accept // either form. const vm = value.match(/version\s*=\s*(?:"([^"]*)"|'([^']*)')/); if (vm) { spec = vm[1] ?? vm[2] ?? ""; } else if (/\bgit\s*=/.test(value)) { skipped.push({ name, reason: "Poetry git-only dep (no version)", }); continue; } else if (/\bpath\s*=/.test(value)) { skipped.push({ name, reason: "Poetry path-only dep (no version)", }); continue; } else { skipped.push({ name, reason: "Poetry inline table missing version", }); continue; } } else if (value.startsWith("[")) { // Array-form Poetry dep: `foo = ["^1.0", "^2.0"]` expresses a // multi-constraint OR. There is no single "pinned version" for // such a declaration, so the validator cannot meaningfully // compare it to an exact Dojo pin. Surface as skipped so a // [WARN] is emitted — silently dropping these would let pin // drift slip through undetected. skipped.push({ name, reason: "Poetry array-form dep (multi-constraint, not an exact pin)", }); continue; } else if (value.startsWith('"') || value.startsWith("'")) { const q = value[0]; const end = value.indexOf(q, 1); if (end > 0) { spec = value.slice(1, end); } else { // Opening quote but no matching closing quote before // end-of-line — the string is unterminated. Record as a // distinct `unterminated string` reason so the WARN line // names the actual fault rather than conflating with the // empty-version-string branch below. skipped.push({ name, reason: "Poetry unterminated string value", }); continue; } } else { // Not a string — skip (booleans, numbers, etc.). Record the // dep in `skipped[]` (rather than silently dropping) so // operators reading the WARN output see a malformed value // rather than a seemingly-clean file. const rawType = /^(true|false)\b/.test(value) ? "boolean" : /^-?\d/.test(value) ? "number" : value === "" || value.startsWith("\n") ? "empty" : typeof value; skipped.push({ name, reason: `Poetry non-string dep value (got ${rawType})`, }); continue; } // Trim at parse time so `isExactSpec` and downstream comparisons // don't have to worry about quoted whitespace (e.g. `" 1.2.3"`). spec = spec.trim(); // Empty spec (e.g. `foo = ""`) is malformed — record as skipped // so the caller can surface a [WARN]. Without this, an empty // string silently stored would later be rendered as "(empty)" // in error messages without explaining WHY the spec was empty. if (!spec) { skipped.push({ name, reason: "Poetry empty version string" }); continue; } // Poetry bare-version semantics: `"1.2.3"` means `^1.2.3`. Prefix // with `^` so it is correctly classified as non-exact by // `isExactSpec`. Anything already starting with an operator // character is stored verbatim. // // Caveat: a value that starts with a digit may still be a // composed range that Poetry stores in a single string — e.g. // `"1.2.3,>=1.0"` (comma-joined), `"1.2.3 || 2.0.0"` (pipe OR), // or a space-separated range. Prefixing any of these with `^` // produces a nonsense spec (e.g. `^1.2.3 || 2.0.0`). Only // prefix when the value is a lone bare version — no commas, // whitespace, `||` OR, or any range/PEP 440 operator character // (`<`, `>`, `~`, `^`, `!`). Everything else is stored // verbatim; `isExactSpec` will classify it correctly on the // range-operator / comma / whitespace / pipe paths. if ( /^\d/.test(spec) && !spec.includes(",") && !spec.includes(" ") && !spec.includes("||") && !/[<>~^!]/.test(spec) ) { spec = "^" + spec; } // First-writer-wins within this file (top-level deps declared before // group deps keep their spec). if (!(name in out)) out[name] = spec; } } // Defensive guard: a file that *declares* a `dependencies = …` key // (under `[project]` or `[tool.poetry*]`) but produced ZERO extracted // entries AND no skipped/dropped diagnostics almost certainly means // the regex-based parser could not destructure that declaration — // e.g. `dependencies = "malformed-string"` (wrong TOML type), an // inline-table form we don't support, or some other shape the // targeted regexes miss. Silently accepting this produces a false- // clean [OK] for a file the validator never actually inspected, so // we throw to route the file to `parseErrors` → FAIL. // // IMPORTANT: we deliberately do NOT throw on files that merely lack // a `dependencies` declaration (tool-only configs like `[tool.black]`, // `[project]` metadata blocks with only `name`/`version`/`authors`, // hatch/uv/maturin configs without PEP 621 deps, etc.). Those produce // an empty DepMap as the correct, intended result. const declaresDependencies = /(?:^|\n)\s*dependencies\s*=/.test(raw); if ( declaresDependencies && Object.keys(out).length === 0 && skipped.length === 0 && dropped.length === 0 ) { throw new Error( `pyproject.toml produced empty DepMap — a 'dependencies = …' key is declared but the regex parser could not extract any entries (likely malformed TOML or unsupported form).`, ); } return { deps: out, skipped, dropped }; } /** * Thin compatibility wrapper: returns a DepMap for callers that do not * care about skipped-dep diagnostics. Internally delegates to the * detailed form. * * NOTE: Like `parseRequirementsTxt`, this wrapper THROWS if the * underlying detailed parse produces any `skipped[]` or `dropped[]` * entries, so that silent data loss cannot occur via the wrapper path. * Callers that may legitimately encounter skipped/dropped entries MUST * call `parsePyprojectTomlDetailed` directly. * * @throws Error on fs.readFileSync failure, or on the specific narrow * malformation: a top-level `[project] dependencies = [` array * opened but never closed. This parser is NOT a general TOML * validator — many other forms of malformed TOML will produce * an empty DepMap rather than an exception. * @throws Error when the file contains skipped or dropped entries * (use parsePyprojectTomlDetailed instead). */ function parsePyprojectToml(file: string): DepMap { const detailed = parsePyprojectTomlDetailed(file); if (detailed.skipped.length > 0 || detailed.dropped.length > 0) { throw new Error( `parsePyprojectToml: ${file} produced ${detailed.skipped.length} skipped and ` + `${detailed.dropped.length} dropped entries; use parsePyprojectTomlDetailed ` + `to access them instead of silently discarding.`, ); } return detailed.deps; } // --------------------------------------------------------------------------- // Find Dojo-side dependency files for a given example directory. // --------------------------------------------------------------------------- /** * Shape returned by `collectDepsFromDir` — used for both the Dojo side * (examples/integrations/) and the showcase side * (showcase/integrations/). Both sides walk the same * DEP_FILE_CANDIDATES list; the structure of the result is identical. * * `DojoDepSources` and `ShowcaseDepSources` are exported as aliases for * call-site readability, but they are structurally the same type and * callers can freely pass one where the other is expected. */ export interface DepSources { // All absolute paths to dependency files that contributed deps. files: string[]; // Invariant: there is no merged `deps` field. Callers that need a // cross-ecosystem view must compose it explicitly at the call site // (e.g. `{ ...pythonDeps, ...jsDeps }`) and pick a precedence that // matches their ecosystem. The comparator in `validateAll` reads // `jsDeps` and `pythonDeps` directly. // // JS deps only (from package.json files). Kept separate from // pythonDeps because npm names are case-sensitive and hyphen- // sensitive; applying PEP 503 canonicalization would merge distinct // npm packages. Keeping JS and Python in separate maps also // prevents a cross-ecosystem name collision (e.g. `openai` present // in both `package.json` and `requirements.txt`) from erasing one // side's spec in the comparator. jsDeps: DepMap; // Python deps only (from requirements.txt / pyproject.toml). // Subject to PEP 503 canonicalization (case-insensitive, with `-`, // `_`, `.` treated as equivalent). pythonDeps: DepMap; // Non-fatal parse errors accumulated during collection. Caller should // surface these rather than silently emit [OK]. // `infra: true` means the error is a permissions/filesystem problem // (EACCES/ENOTDIR/EIO/…) rather than a content-parse failure. The // caller (validateAll) uses this to route infra errors through // UnreadableInputError → EXIT_UNREADABLE (3) instead of landing them // in report.fail as EXIT_DRIFT (1). parseErrors: Array<{ file: string; message: string; infra?: boolean }>; // Count of files this collector attempted to read — used to // distinguish "no files found" from "all files parse-errored". filesAttempted: number; // Dep diagnostics forwarded from parsers (git-only Poetry deps, // unparseable requirements lines). skipped: Array<{ file: string; name: string; reason: string }>; dropped: Array<{ file: string; line: string }>; } // Named aliases for call-site readability. Structurally identical to // `DepSources`. export type DojoDepSources = DepSources; // Common candidate list for dep file discovery. Order IS precedence: // earlier entries are walked first, and because `collectDepsFromDir` // applies first-writer-wins at the file level, the earlier file's spec // for a shared dep name beats any later file's spec for the same name. // // Explicit precedence order (most-specific → least-specific): // 1. apps/agent/* — agent-scope manifests win over anything else // 2. agent/* — short-form `agent/` variant // 3. apps/web/* — showcase packages that only ship a web app // 4. apps/app/* — starter layouts using `apps/app/` // 5. /* — catch-all fallback const DEP_FILE_CANDIDATES = [ "apps/agent/package.json", "apps/agent/pyproject.toml", "apps/agent/requirements.txt", "agent/package.json", "agent/pyproject.toml", "agent/requirements.txt", "apps/web/package.json", "apps/web/pyproject.toml", "apps/web/requirements.txt", "apps/app/package.json", "apps/app/pyproject.toml", "apps/app/requirements.txt", // Root-level files fill in anything not declared above. "package.json", "requirements.txt", "pyproject.toml", ]; function isPythonManifest(abs: string): boolean { return abs.endsWith("requirements.txt") || abs.endsWith("pyproject.toml"); } /** * Common collector used by both Dojo-side and showcase-side. Walks * DEP_FILE_CANDIDATES in order, applying first-writer-wins at the file * level. Parse errors are accumulated rather than thrown so one bad * sibling doesn't abort the whole run. * * @throws Error on an unrecognized dep file path. This should be * unreachable because DEP_FILE_CANDIDATES is a closed list; the * throw exists to catch programmer error if someone adds a new * file to DEP_FILE_CANDIDATES without wiring up a parser here. */ function collectDepsFromDir(rootDir: string): DepSources { const result: DepSources = { files: [], jsDeps: {}, pythonDeps: {}, parseErrors: [], filesAttempted: 0, skipped: [], dropped: [], }; for (const rel of DEP_FILE_CANDIDATES) { const abs = path.join(rootDir, rel); // Prefer fs.statSync so an EACCES (unreadable) candidate is // distinguished from ENOENT (not present). `fs.existsSync` returns // false in both cases, which silently ignores broken permissions // — a missing framework dep file would then never be flagged. try { fs.statSync(abs); } catch (e) { const err = e as NodeJS.ErrnoException; // A falsy throw (`throw null`, `throw undefined`, `throw ""`) carries // zero diagnostic info. Do not collapse into the ENOENT branch — // treat as unreadable so the caller routes through // UnreadableInputError → EXIT_UNREADABLE (3). Collapsing a falsy // throw into "candidate absent" would hide fs-layer failures and // produce a false green. Log a stderr breadcrumb AND classify as // infra so operators see what actually went wrong. if (!err) { console.error( `[validate-pins] statSync on ${abs} threw a falsy value (${String(e)}); treating as unreadable input`, ); result.parseErrors.push({ file: abs, message: `stat failed with falsy throw: ${String(e)}`, infra: true, }); continue; } if (err.code === "ENOENT") continue; // EACCES / EIO / ELOOP / etc. — the candidate is "probably // there" but we can't read it. Surface as a parse error so the // caller emits a FAIL rather than silently passing over it. result.parseErrors.push({ file: abs, message: `stat failed (${err.code ?? "unknown"}): ${err.message ?? String(e)}`, infra: true, }); continue; } // Determine which parser to use BEFORE the try block so that an // unrecognized extension is treated as a programmer bug (throws out // of this function) rather than silently absorbed as a "successful // parse of empty deps". This is a closed-list guarantee: every // entry in DEP_FILE_CANDIDATES must have a parser here. let parser: "package.json" | "requirements.txt" | "pyproject.toml"; if (abs.endsWith("package.json")) { parser = "package.json"; } else if (abs.endsWith("requirements.txt")) { parser = "requirements.txt"; } else if (abs.endsWith("pyproject.toml")) { parser = "pyproject.toml"; } else { throw new Error( `collectDepsFromDir: no parser for dep file ${abs}. DEP_FILE_CANDIDATES and parser dispatch are out of sync.`, ); } result.filesAttempted += 1; let parsed: DepMap = {}; let skipped: Array<{ name: string; reason: string }> = []; let dropped: string[] = []; try { if (parser === "package.json") { parsed = parsePackageJson(abs); } else if (parser === "requirements.txt") { const detailed = parseRequirementsTxtDetailed(abs); parsed = detailed.deps; skipped = detailed.skipped; dropped = detailed.dropped; } else { const detailed = parsePyprojectTomlDetailed(abs); parsed = detailed.deps; skipped = detailed.skipped; dropped = detailed.dropped; } } catch (e) { const msg = e instanceof Error ? e.message : String(e); // Record only — a single downstream [FAIL] line per parse error is // emitted by validateAll with slug context. Immediate // console.error here would cause duplicate output. // // Detect infra errno codes bubbling up from readFileSync (which is // called unguarded inside parsePackageJson / parseRequirementsTxt / // parsePyprojectToml). Without this classification an EACCES on // the content-only read would be treated as a parse error → // report.fail → EXIT_DRIFT (1), but the correct routing is // UnreadableInputError → EXIT_UNREADABLE (3). The stat guard // above only catches errno at stat time; TOCTOU or a 0000-mode // file on a readable parent (owner can chmod but not read the // file content, e.g. group read on a file you don't own) slips // past stat and errors on read instead. const errno = (e as NodeJS.ErrnoException | undefined)?.code; const isInfra = errno === "EACCES" || errno === "EIO" || errno === "ENOTDIR" || errno === "ELOOP" || errno === "EPERM"; result.parseErrors.push({ file: abs, message: msg, ...(isInfra ? { infra: true } : {}), }); continue; } result.files.push(abs); for (const s of skipped) { result.skipped.push({ file: abs, name: s.name, reason: s.reason }); } for (const d of dropped) { result.dropped.push({ file: abs, line: d }); } const fromPython = isPythonManifest(abs); for (const [name, spec] of Object.entries(parsed)) { // First-writer-wins: agent-side files (walked first) take precedence // over root files. This matches the intent documented above. // Track JS vs Python in separate maps at parse time so a // cross-ecosystem name collision (e.g. `openai` on both sides) // cannot obliterate one side's spec. A shared map would require // deriving JS deps via `diffMaps(deps, pythonDeps)`, which drops // the JS dep ENTIRELY whenever its name also appears in // `pythonDeps` — unacceptable because both sides must be // compared independently. if (fromPython) { if (!(name in result.pythonDeps)) { result.pythonDeps[name] = spec; } } else { if (!(name in result.jsDeps)) { result.jsDeps[name] = spec; } } } } return result; } // The Dojo examples have varied layouts. We walk app-specific paths FIRST // so that their specs take precedence over the root package.json, which // often pins older / generic versions. This implements first-writer-wins // at the file level: the first file that declares a dep wins. function collectDojoDeps(exampleDir: string): DojoDepSources { return collectDepsFromDir(exampleDir); } // --------------------------------------------------------------------------- // Showcase-side dep collection // --------------------------------------------------------------------------- // Structurally identical to `DepSources` — kept as a named alias for // readability at call sites so the showcase vs dojo side remains // textually distinct. export type ShowcaseDepSources = DepSources; function collectShowcaseDeps(packageDir: string): ShowcaseDepSources { return collectDepsFromDir(packageDir); } /** * Build a canonicalized lookup for a DepMap. Keeps the original name for * error messages but keys by canonical name so `foo_bar` and `foo-bar` * collide. * * When two entries canonicalize to the same key with DIFFERENT specs * (e.g. `langgraph_checkpoint==1.0.0` alongside * `langgraph-checkpoint==2.0.0` in the same requirements.txt) the * first-writer's entry is retained for comparison and a warning is * surfaced so operators see the conflict rather than silently losing * one of the pins. Same-spec collisions are considered redundant and * dropped silently. */ interface CanonicalizeWarning { key: string; firstName: string; firstSpec: string; dupName: string; dupSpec: string; } function canonicalizeDepMap( deps: DepMap, isPython: boolean, ): { out: Record; warnings: CanonicalizeWarning[]; } { const out: Record = {}; const warnings: CanonicalizeWarning[] = []; for (const [name, spec] of Object.entries(deps)) { const key = isPython ? canonicalizePythonName(name) : name; // First-writer-wins within this function too (keep name as it was // originally declared). If a later entry collides on the canonical // key with a DIFFERENT spec, surface it as a warning — a silent // drop would hide a real drift signal (two entries for the same // distribution, both pinned, to different versions). const prior = out[key]; if (prior === undefined) { out[key] = { name, spec }; continue; } if (prior.spec !== spec) { warnings.push({ key, firstName: prior.name, firstSpec: prior.spec, dupName: name, dupSpec: spec, }); } } return { out, warnings }; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- // The four buckets are appended-to only while building the report in // validateAll(); nothing should mutate them once they're returned to // the printer. Marking them readonly string[] prevents drive-by mutation // at read sites without paying the cost of a full PackageIssue tagged // union + renderer refactor (deferred — the buckets are stringly-typed // but that is a self-contained simplification rather than a correctness // issue today). The mutation sites inside validateAll type as // `string[]` via the inner `ReportBuilder` so .push() still works. interface Report { readonly fail: readonly string[]; readonly warn: readonly string[]; readonly skip: readonly string[]; readonly ok: readonly string[]; } // Internal mutable shape used only while building the Report. // Narrowed to `Report` (readonly) on return. interface ReportBuilder { fail: string[]; warn: string[]; skip: string[]; ok: string[]; } function validateAll(): Report { const report: ReportBuilder = { fail: [], warn: [], skip: [], ok: [] }; // Compute paths ONCE per run. `paths()` re-validates the // VALIDATE_PINS_REPO_ROOT env var every call, so invoking it per // slug turns every iteration into an fs.statSync call that has // already been performed. const resolvedPaths = paths(); const { PACKAGES_DIR, REPO_ROOT, CANONICAL_PINS_FILE } = resolvedPaths; // Load the canonical pins config FIRST so a misconfigured/missing // file fails loudly before we waste time scanning integrations. // loadCanonicalPins() throws UnreadableInputError on read errors // (missing file → EXIT_UNREADABLE) and CanonicalPinsError on // schema/syntax problems (top-level catch routes to EXIT_INTERNAL, // since a corrupt config is a validator-side bug not pin drift). const canonical = loadCanonicalPins(CANONICAL_PINS_FILE); // Missing packages dir must not produce a silent pass. If the validator // can't see any packages, it has nothing to check, which is almost // certainly a path misconfiguration. Route this as EXIT_UNREADABLE (3) // so it's classed with other repo-structure problems rather than with // real drift findings — mixing the two buckets makes CI triage harder. // // Use `fs.statSync` + catch-ENOENT rather than `fs.existsSync` so a // permission error (EACCES) is not silently collapsed into "not // present" and the packages dir not being a directory (i.e. it's a // file) is caught before readdirSync throws a less-obvious error. let packagesStat: fs.Stats; try { packagesStat = fs.statSync(PACKAGES_DIR); } catch (e) { const err = e as NodeJS.ErrnoException; if (err && err.code === "ENOENT") { throw new UnreadableInputError( `Packages dir not found at ${PACKAGES_DIR}`, ); } // EACCES / ENOTDIR / EIO / etc. — packages dir exists but is // unreadable by this process. Route through UnreadableInputError // so the top-level catch maps this to EXIT_UNREADABLE (3) // rather than collapsing to EXIT_DRIFT (1) via report.fail. const msg = err && err.message ? err.message : String(e); throw new UnreadableInputError( `Packages dir stat failed (${err?.code ?? "unknown"}): ${PACKAGES_DIR}: ${msg}`, ); } if (!packagesStat.isDirectory()) { throw new UnreadableInputError( `Packages dir is not a directory: ${PACKAGES_DIR}`, ); } // readdirSync can fail independently of the statSync above (TOCTOU: // perms or mount state can change between the two calls, and EIO / // ENOTDIR / EACCES are all observable here). Without this wrapper the // error propagates to the top-level catch as a generic Error and is // misrouted to EXIT_INTERNAL (2). We convert infra-class errno codes // into UnreadableInputError so they route to EXIT_UNREADABLE (3), // preserving the same taxonomy as the statSync branch above. let slugs: string[]; try { slugs = fs .readdirSync(PACKAGES_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) // `_shared` is the shared-code directory (cvdiag emitters/schema staged // into each integration via the per-integration `_shared` symlink), not // a showcase package: it has no dependency files and must not be pin- // audited. Dot-directories (e.g. a local `.pytest_cache`) are build/test // artifacts, never package slugs, so they are excluded too. .filter((d) => d.name !== "_shared" && !d.name.startsWith(".")) .map((d) => d.name) .sort(); } catch (e) { const err = e as NodeJS.ErrnoException; const code = err?.code ?? "unknown"; const msg = err?.message ?? String(e); // All readdir failures here are infra-class (we already passed the // stat + isDirectory guard above, so the dir did exist and was a // directory). Route to EXIT_UNREADABLE (3) so CI distinguishes // perms/mount state from a validator crash. throw new UnreadableInputError( `Packages dir readdir failed (${code}): ${PACKAGES_DIR}: ${msg}`, ); } // Empty packages dir is the same class of error as missing — the // validator produced no results, so we fail loudly rather than exit 0. // Class this as EXIT_UNREADABLE (3): it's a repo-structure / // configuration problem (wrong VALIDATE_PINS_REPO_ROOT, bad checkout), // not actual pin drift. Keeping it out of report.fail preserves the // contract that EXIT_DRIFT (1) means "real drift findings". if (slugs.length === 0) { throw new UnreadableInputError( `No showcase packages discovered under ${PACKAGES_DIR} — is VALIDATE_PINS_REPO_ROOT pointing at the right tree?`, ); } // Accumulates the first infra-class parseError observed during the // slug loop. We deliberately do NOT throw mid-loop — doing so would // orphan the report entries for preceding slugs AND the remaining // content parseErrors for the current slug, which hides real drift // behind a single infra misconfig. Throw at the end instead, with // the partial report attached so the top-level catch can print it. let pendingInfraError: UnreadableInputError | undefined; for (const slug of slugs) { const pkgDir = path.join(PACKAGES_DIR, slug); const showcase = collectShowcaseDeps(pkgDir); const slugOverrides = canonical.overrides[slug] ?? {}; // Parse errors: surface as FAIL so the process exits non-zero. A // silent WARN lets broken manifests slip through CI with [OK]. // // EXCEPTION: infra errors (stat EACCES/ENOTDIR/…) are NOT drift — // they are permissions/filesystem misconfig. Route them through // UnreadableInputError so the top-level catch maps to // EXIT_UNREADABLE (3) rather than report.fail → EXIT_DRIFT (1). // // CRITICAL: do NOT throw mid-loop here. Accumulate the first infra // error into `pendingInfraError` and let the slug loop finish so // every OTHER slug still contributes to the report. The top-level // catch will print the (partial) report before setting // EXIT_UNREADABLE, so operators see the full FAIL/WARN/SKIP for // every slug that was processed. Content-parse errors for THIS // slug's remaining files still land in report.fail below. for (const pe of showcase.parseErrors) { if (pe.infra && pendingInfraError === undefined) { pendingInfraError = new UnreadableInputError( `${slug}: unreadable input at ${path.relative(REPO_ROOT, pe.file)}: ${pe.message}`, ); } } // Skip content-parse fail emission for entries tagged as infra — // those are routed through pendingInfraError and the top-level // catch, not report.fail. for (const pe of showcase.parseErrors) { if (pe.infra) continue; report.fail.push( `[FAIL] ${slug}: parse error in ${path.relative(REPO_ROOT, pe.file)}: ${pe.message}`, ); } // Pre-seed pkgHadViolation from parseErrors. Otherwise a slug with // a mix of valid + parse-errored files would have one or more // [FAIL] lines AND still receive an [OK] line at the end (because // `pkgHadViolation` was only set from the per-dep loop). A slug // must not appear in both report.ok and report.fail. let pkgHadParseError = false; if (showcase.parseErrors.length > 0) { pkgHadParseError = true; } // Skipped deps (e.g. Poetry git-only) — WARN only. for (const s of showcase.skipped) { report.warn.push( `[WARN] ${slug}: skipped ${s.name} in ${path.relative(REPO_ROOT, s.file)}: ${s.reason}`, ); } // Dropped requirements.txt lines (unparseable but not fatal) — WARN. for (const d of showcase.dropped) { report.warn.push( `[WARN] ${slug}: dropped unparseable line '${d.line}' in ${path.relative(REPO_ROOT, d.file)}`, ); } // Distinguish "genuinely no files" from "files existed but all // parse-errored". A showcase package with zero dep files is // structurally wrong — it cannot demonstrate a framework // integration without declared runtime dependencies. Emit [FAIL] // so CI catches it rather than silently marking the package OK. if (showcase.files.length === 0) { if (showcase.filesAttempted === 0) { report.fail.push( `[FAIL] ${slug}: no dependency files found in showcase package`, ); } // else: all attempted files parse-errored; FAIL already emitted. continue; } // Canonicalize separately for JS and Python (npm names are // case-sensitive; PEP 503 normalization applies only to Python). const scPythonCanonResult = canonicalizeDepMap( showcase.pythonDeps, /* isPython */ true, ); const scJsRawResult = canonicalizeDepMap( showcase.jsDeps, /* isPython */ false, ); const scPythonCanon = scPythonCanonResult.out; const scJsRaw = scJsRawResult.out; // Surface canonical-key collisions with DIFFERENT specs as WARN — // two pinned entries for the same distribution at different versions // within one file is a real drift signal that must not be silently // dropped by first-writer-wins. const emitCanonicalWarnings = (warns: CanonicalizeWarning[]) => { for (const w of warns) { report.warn.push( `[WARN] ${slug}: duplicate entries for canonical key '${w.key}' with different specs ` + `(${w.firstName}=${w.firstSpec || "(empty)"} vs ${w.dupName}=${w.dupSpec || "(empty)"})`, ); } }; emitCanonicalWarnings(scPythonCanonResult.warnings); emitCanonicalWarnings(scJsRawResult.warnings); let pkgHadViolation = pkgHadParseError; // Build the iteration set (Python + JS) with the same shape so a // single loop applies the invariant. Sort within each ecosystem // for stable, hash-friendly FAIL output. const iterations: Array<{ key: string; entry: { name: string; spec: string }; isPython: boolean; }> = []; for (const key of Object.keys(scPythonCanon).sort()) { iterations.push({ key, entry: scPythonCanon[key], isPython: true }); } for (const key of Object.keys(scJsRaw).sort()) { iterations.push({ key, entry: scJsRaw[key], isPython: false }); } for (const { entry, isPython } of iterations) { const displayName = entry.name; const spec = entry.spec; // Per-slug override resolution is done by the raw declared dep // name (the way it appears in package.json / requirements.txt / // pyproject.toml), so overrides authors can read & write the // canonical-pins file directly. For Python, we ALSO match on the // PEP 503 canonicalized name so e.g. `langgraph_checkpoint` in // an override file aligns with a `langgraph-checkpoint` dep. const overrideSpec = slugOverrides[displayName] ?? (isPython ? slugOverrides[canonicalizePythonName(displayName)] : undefined); // Workspace refs have no pin semantics — skip, don't FAIL. Mirror // the historical behavior so workspace-resolved deps in showcase // packages don't trip the validator. if (isWorkspaceRef(spec)) { report.skip.push( `[SKIP] ${slug}: ${displayName} workspace ref (${spec})`, ); continue; } // @copilotkit/* canonical-version check. Applies to JS deps // only (the @-prefixed scope is npm-native; Python copilotkit // packages, if any, fall through to the framework-exact-pin // check below). if (!isPython && displayName.startsWith("@copilotkit/")) { if (overrideSpec !== undefined) { // Override accepts the spec verbatim — does NOT require it // to be an exact pin (this is how the `pkg.pr.new` URL // case is allowed for built-in-agent). if (spec !== overrideSpec) { report.fail.push( `[FAIL] ${slug}: ${displayName} pinned to ${spec || "(empty)"}, ` + `override expects ${overrideSpec}`, ); pkgHadViolation = true; } continue; } if (spec !== canonical.canonicalCopilotKitVersion) { report.fail.push( `[FAIL] ${slug}: ${displayName} pinned to ${spec || "(empty)"}, ` + `canonical is ${canonical.canonicalCopilotKitVersion}`, ); pkgHadViolation = true; } continue; } // Detect framework deps. For Python, canonicalize the name // before matching FRAMEWORK_PATTERNS so PEP 503 variants are // recognized. const detectionName = isPython ? canonicalizePythonName(displayName) : displayName; if (!isFrameworkDep(detectionName)) continue; // Non-@copilotkit framework dep: must be EXACTLY pinned (or // match a per-slug override verbatim). The override permits // non-semver values (URLs, dist-tags) where a deliberate // exception is documented; the canonical config schema requires // an explicit override entry per slug+dep, so this is not a // silent escape hatch. if (overrideSpec !== undefined) { if (spec !== overrideSpec) { report.fail.push( `[FAIL] ${slug}: ${displayName} pinned to ${spec || "(empty)"}, ` + `override expects ${overrideSpec}`, ); pkgHadViolation = true; } continue; } if (!isExactSpec(spec)) { report.fail.push( `[FAIL] ${slug}: ${displayName} is not an exact pin ` + `(${spec || "(empty)"})`, ); pkgHadViolation = true; } } if (!pkgHadViolation) { report.ok.push(`[OK] ${slug}`); } } // If an infra error was observed mid-loop, throw it NOW that the // slug loop has fully finished. Attach the partial report so the // top-level catch can print every FAIL/WARN/SKIP line for slugs // that completed, then exit 3 (EXIT_UNREADABLE). This preserves the // "infra error → exit 3" contract while no longer discarding drift // findings for unaffected slugs. if (pendingInfraError !== undefined) { // Rebuild a fresh UnreadableInputError carrying the final report // — the one we captured earlier was constructed before all slugs // had been processed, so its partialReport would be stale. throw new UnreadableInputError(pendingInfraError.message, report); } return report; } /** * Print a report. [OK] and [SKIP] go to stdout; [FAIL] and [WARN] go to * stderr per Unix convention so CI logs, grep `|`, and humans can * distinguish. */ function printReport(report: Report): void { for (const line of report.ok) console.log(line); for (const line of report.skip) console.log(line); for (const line of report.warn) console.error(line); for (const line of report.fail) console.error(line); const summary = `Summary: OK=${report.ok.length} ` + `SKIP=${report.skip.length} ` + `WARN=${report.warn.length} ` + `FAIL=${report.fail.length}`; // Summary to stdout — it's an informational line, not an error. console.log(""); console.log(summary); } // Exit codes are set via `process.exitCode` rather than `process.exit(N)` // so that stdout/stderr have time to drain before the process // terminates. `process.exit` is synchronous and can truncate output — // the hash-based ratchet in CI compares full summary/table output and a // truncated line would silently change the hash. Mirrors the audit.ts // pattern. // Typed setter for `process.exitCode` — forces every exit-code // assignment in this file through the closed `PinsExitCode` union, so // a typo like `process.exitCode = 4` becomes a compile error. function setExitCode(code: PinsExitCode): void { process.exitCode = code; } function main(): void { const report = validateAll(); printReport(report); setExitCode(report.fail.length > 0 ? EXIT_DRIFT : EXIT_OK); } /** * Returns true iff `argv1` refers to the same file as `scriptPath` * (which should be the caller's `import.meta.url`-derived file path, * e.g. via `fileURLToPath(import.meta.url)`). Uses strict * resolve-then-equal instead of substring match, so paths that merely * contain "validate-pins" (test harnesses, worker processes) do NOT * trigger `main()` on import. * * On a `path.resolve` failure (bizarre non-string input) we log to * stderr and set `process.exitCode = 2` so the caller sees a non-zero * exit; we still return false so main() doesn't run. A bare `catch {}` * would silently skip main() AND exit 0, masking bugs. */ function isMainPath(argv1: string | undefined, scriptPath: string): boolean { if (!argv1) return false; try { return path.resolve(argv1) === path.resolve(scriptPath); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error(`[isMainPath] path.resolve failed: ${msg}`); setExitCode(EXIT_INTERNAL); return false; } } // Only run main when invoked directly (not when imported for tests). // Top-level try/catch distinguishes "pin drift" (exit 1, legitimate) from // "validator crashed" (exit 2, needs investigation). validate-parity.ts // shares the "top-level try/catch routes crashes to a distinct exit // code" pattern, but its exit-code numbering differs — see the header // docstring for details. if (isMainPath(process.argv[1], __filename)) { try { main(); } catch (e) { const msg = e instanceof Error ? e.stack || e.message : String(e); if (e instanceof UnreadableInputError) { // Print the partial report FIRST (if present) so operators see // FAIL/WARN/SKIP for every slug that was processed before the // infra error. Without this, a single infra error would orphan // all already-collected report entries and produce a bare error // line with no diagnostic context. if (e.partialReport) { printReport(e.partialReport); } console.error(`[UNREADABLE INPUT] validate-pins: ${msg}`); setExitCode(EXIT_UNREADABLE); } else { console.error(`[INTERNAL ERROR] validate-pins crashed: ${msg}`); setExitCode(EXIT_INTERNAL); } } } // Re-export the drift-comparison core so downstream consumers (the // showcase-harness pin-drift probe driver, future CLI flags) can reach the // ratchet logic through the same entry point as the rest of validate-pins. // The core module is the single source of truth for the comparison that // used to live only in `.github/workflows/showcase_validate.yml` shell. export { computePinDrift, PinDriftBaselineError, } from "./validate-pins-core.js"; export type { PinDriftInput, PinDriftResult, PinDriftStatus, } from "./validate-pins-core.js"; export { resolveExampleDir, resolveExampleDirDetailed, collectShowcaseDeps, collectDojoDeps, parsePackageJson, parseRequirementsLine, parseRequirementsTxt, parseRequirementsTxtDetailed, parsePyprojectToml, parsePyprojectTomlDetailed, canonicalizePythonName, isExactSpec, isFrameworkDep, isMainPath, validateAll, printReport, FALLBACK_MAP, BORN_IN_SHOWCASE, };