2104 lines
84 KiB
TypeScript
2104 lines
84 KiB
TypeScript
/**
|
|
* Pin Validator (showcase-internal)
|
|
*
|
|
* Enforces showcase-internal pin discipline. The historical mode that
|
|
* compared `showcase/integrations/<slug>` against the external
|
|
* `examples/integrations/<source>` 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/<slug>/`:
|
|
* 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] <slug>: <dep> ...` 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: `==<version>`. 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": {
|
|
* "<slug>": { "<dep-name>": "<allowed-spec>" }
|
|
* }
|
|
* }
|
|
*
|
|
* `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<Record<string, Readonly<Record<string, string>>>>;
|
|
}
|
|
|
|
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<string, unknown>;
|
|
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<string, Record<string, string>> = {};
|
|
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<string, string> = {};
|
|
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<Record<string, readonly string[]>> = (() => {
|
|
const reverse: Record<string, string[]> = {};
|
|
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<typeof paths>,
|
|
): 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/<name>` rather than the ambiguous
|
|
// `integrations/<name>` (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/<anything>, copilotkit,
|
|
// @ag-ui/<anything>, ag-ui-<anything>, ag_ui_<anything>,
|
|
// @langchain/<anything>, langchain, langchain-<anything>, langgraph,
|
|
// langgraph-<anything>, langsmith, @mastra/<anything>, mastra, crewai,
|
|
// crewai-<anything>, pydantic-ai, pydantic-ai-<anything>, agno,
|
|
// llama-index, llama-index-<anything>, llama_index, llama_index_<anything>,
|
|
// llamaindex, google-adk, google-genai, strands-agents,
|
|
// strands-agents-<anything>, agent-framework, agent-framework-<anything>,
|
|
// @ai-sdk/<anything>, ai, @hashbrownai/<anything>, @anthropic-ai/<anything>,
|
|
// anthropic, openai, ag2, langroid, spring-ai, spring-ai-<anything>, and
|
|
// Spring's Maven coordinate form `org.springframework.ai:<artifact>` which
|
|
// appears in Java manifests as a colon-delimited group:artifact string.
|
|
const FRAMEWORK_PATTERNS: Array<RegExp> = [
|
|
// 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<string, unknown>` so every
|
|
// bucket lookup is typed as `unknown` and MUST be shape-checked by
|
|
// validateBucket below. Casting to a specific shape
|
|
// (`{ dependencies?: Record<string, unknown>; … }`) would imply
|
|
// pre-validated shape and invite callers to trust the cast without
|
|
// runtime checks.
|
|
const pkg: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
|
|
// 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<string, string> | 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<string, string> = {};
|
|
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<spec>` 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.<name>.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.<name>.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/<source>) and the showcase side
|
|
* (showcase/integrations/<slug>). 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. <root>/* — 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<string, { name: string; spec: string }>;
|
|
warnings: CanonicalizeWarning[];
|
|
} {
|
|
const out: Record<string, { name: string; spec: string }> = {};
|
|
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,
|
|
};
|