Files
copilotkit--copilotkit/showcase/scripts/audit.ts
T
2026-07-13 12:58:18 +08:00

1870 lines
72 KiB
TypeScript

/**
* Showcase Audit CLI
*
* Walks showcase/integrations/* and emits a human-readable coverage report
* comparing declared demos vs. e2e spec files vs. QA markdown, plus
* deployment status and examples/integrations provenance.
*
* Usage:
* npx tsx showcase/scripts/audit.ts
* npx tsx showcase/scripts/audit.ts --json # machine-readable output
* npx tsx showcase/scripts/audit.ts --slug <slug> # single package
* npx tsx showcase/scripts/audit.ts --json --slug <slug> # single package, JSON
*
* Output sections (printed in this order):
* 1. Per-package summary table. Columns render as:
* slug | demos | specs | qa | deployed | examples src
* The last column is addressable via the filter key `examples-src`
* (hyphenated) but its rendered header label is `examples src`
* (space) to keep the table visually consistent.
* 2. Coverage anomalies (count mismatches, undeployed, missing examples source)
* 3. Overall health (pass/fail counts + suggestions)
*
* Exit codes:
* 0 — no anomalies found (warnings, if any, are informational by default)
* 1 — one or more anomalies (deployed=false, count mismatches,
* empty packages dir, etc.)
* 2 — invalid content / user input (bad args, unknown slug)
* 3 — unreadable (packages dir missing, not-a-directory, or fs failure)
* 4 — unexpected internal error (uncaught exception)
* 5 — --strict and warnings present (default run treats warnings
* as informational)
*
* YAML parsing is delegated to lib/manifest.ts.
*
* Testability:
* All I/O is parameterised by an `AuditConfig` object so tests can point
* at fixture trees. When running as a CLI, the config is derived from
* env var `SHOWCASE_AUDIT_ROOT` (for tests) or, by default, the
* ancestor `showcase/` directory of this script.
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import {
parseManifest,
type Manifest,
type ParsedManifest,
} from "./lib/manifest.js";
import { BORN_IN_SHOWCASE, SLUG_TO_EXAMPLES } from "./lib/slug-map.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Slug map + born-in-showcase set live in ./lib/slug-map.ts.
// Manifest types + parseManifest live in ./lib/manifest.ts.
// Both are re-exported at the bottom of this file so callers can import
// them from audit.ts.
/**
* Thrown when the packages dir cannot be read (EACCES, ENOTDIR, etc.).
* Distinct from generic Error so main()'s top-level catch can map it to
* EXIT_UNREADABLE (3) rather than EXIT_INTERNAL (4).
*
* Uses the ES2022 `Error({ cause })` pattern so callers can still reach
* the original ErrnoException (with `.code`, `.errno`, `.syscall` etc.)
* via `err.cause`. Forwarding just `cause.message` would drop those
* fields.
*/
class UnreadableDirError extends Error {
constructor(
public readonly dir: string,
cause: unknown,
) {
const baseMsg = cause instanceof Error ? cause.message : String(cause);
const code =
cause instanceof Error
? (cause as NodeJS.ErrnoException).code
: undefined;
// Prepend errno code when present and not already embedded in the
// underlying message (Node's fs errors typically already include it,
// but custom Errors thrown by stubs/tests may not).
const msg =
code && !baseMsg.includes(code) ? `${code}: ${baseMsg}` : baseMsg;
super(`could not read ${dir}: ${msg}`, { cause });
this.name = "UnreadableDirError";
}
}
/**
* Dependency-injected paths. In CLI mode these are derived from the
* script's location (or SHOWCASE_AUDIT_ROOT env var for tests). In unit
* tests, callers pass explicit paths pointing at a fixture tree.
*/
interface AuditConfig {
packagesDir: string;
examplesIntegrationsDir: string;
repoRoot: string;
}
// Exit-code constants — see the module header JSDoc for the full
// contract. We keep them in one place so the internals stay in sync with
// the CLI HELP_TEXT and the module docstring. Declared here (above the
// type definitions) so AuditReport.exitCode can derive its literal union
// from `typeof EXIT_*` rather than hard-coding the numbers, preventing
// drift between the runtime constants and the type.
const EXIT_OK = 0 as const;
const EXIT_ANOMALIES = 1 as const;
const EXIT_INVALID_CONTENT = 2 as const;
const EXIT_UNREADABLE = 3 as const;
const EXIT_INTERNAL = 4 as const;
const EXIT_WARNINGS = 5 as const;
/**
* Tagged union describing a package-level anomaly. `buildReport`
* switches on `kind` to classify packages into anomaly buckets.
*
* `not-deployed.state` uses a string union (`"unset" | "explicit-false"`)
* rather than raw `null | false` runtime values — the string encoding
* is self-documenting at consumption sites (`state === "unset"` vs the
* easy-to-misread `state === null`) and decouples the anomaly shape from
* the underlying manifest field encoding. Callers read the boolean
* directly through `p.manifest.manifest.deployed` when they need the
* raw value.
*/
type Anomaly =
| { kind: "missing-manifest" }
| { kind: "malformed-manifest"; subkind: "syntax" | "shape"; error: string }
| { kind: "unreadable-manifest"; error: string }
| { kind: "unreadable-dir"; dir: string; error: string }
| {
kind: "count-mismatch";
dimension: "spec" | "qa";
expected: number;
actual: number;
}
| { kind: "not-deployed"; state: "unset" | "explicit-false" }
| { kind: "missing-examples" }
| {
kind: "unreadable-examples";
slug: string;
candidates: readonly string[];
}
| {
// Mapped slug whose candidate path(s) exist on disk but are NOT
// directories (regular file / symlink-to-file / socket / FIFO).
// This is a misconfiguration — the integrations dir has a stray
// entry masquerading as the provenance target. Surfaced as its
// own Anomaly variant so downstream consumers can route it
// distinctly from `missing-examples` (content absent) and
// `unreadable-examples` (I/O failure).
kind: "mapped-candidate-not-directory";
slug: string;
candidates: readonly string[];
};
/**
* Per-dimension count state. Distinguishes "count=0 because empty" from
* "count=0 because unreadable" so table rendering and parity checks
* don't collapse the two into phantom mismatches.
*
* This is the sole discriminated union for count outcomes: countFiles
* returns it directly. Anything storing a count state uses this shape.
*/
type CountState =
| { state: "ok"; count: number }
| { state: "missing" } // no count field; countValue() returns 0, countLabel() returns "0"
| { state: "unreadable"; error: string };
interface PackageAudit {
slug: string;
/**
* Full tagged-union ParsedManifest variant. Keeping the whole
* variant (not just `.kind`) preserves the correlation between the
* manifest outcome and the derived fields (`demosDeclared`): downstream
* consumers that need to, e.g., echo the underlying malformed error or
* assert on the parsed manifest can reach through `audit.manifest.error`
* or `audit.manifest.manifest` without needing a second lookup table.
*
* Note: the `deployed` boolean is NOT duplicated on PackageAudit —
* consumers read it via `p.manifest.kind === "ok" ? p.manifest.manifest.deployed : undefined`.
* Two sources of truth invite drift.
*/
manifest: ParsedManifest;
demosDeclared: number;
spec: CountState;
qa: CountState;
examplesSource: string | null; // relative path from repo root, or null
anomalies: readonly Anomaly[];
/**
* Runtime diagnostics that don't rise to the level of an anomaly but
* callers (JSON consumers, CI dashboards) may want to surface. Each
* entry is a human-readable string written to stderr as well.
*/
warnings: readonly string[];
}
/**
* Literal union of the exit codes `main()` can assign. Derived from the
* EXIT_* constants so adding a new exit code (or retiring one) only
* requires changes in one place.
*/
type AuditExitCode =
| typeof EXIT_OK
| typeof EXIT_ANOMALIES
| typeof EXIT_INVALID_CONTENT
| typeof EXIT_UNREADABLE
| typeof EXIT_INTERNAL
| typeof EXIT_WARNINGS;
interface AuditReport {
/**
* Top-level scalars for programmatic consumers. `hasAnomalies` mirrors
* `totals.withAnomalies > 0`; `hasWarnings` mirrors
* `packages.some(p => p.warnings.length > 0)` so consumers can
* ratchet on stale-mapping / statSync-race diagnostics without
* re-walking every package. `exitCode` is the exit code `main()` will
* actually use (see EXIT_ANOMALIES / EXIT_WARNINGS).
*
* These are explicitly derived values — exposed as getters on the live
* report object so they can't fall out of sync with the underlying
* packages / anomalies arrays. JSON serialization walks own-enumerable
* properties by default, so buildReport materializes these to a plain
* object shape via a per-field Object.defineProperty call that's both
* enumerable and computed-on-read; see buildReport for the wiring.
*/
readonly hasAnomalies: boolean;
readonly hasWarnings: boolean;
readonly exitCode: AuditExitCode;
readonly packages: readonly PackageAudit[];
/**
* Per-bucket lists. Buckets deliberately overlap: a single package
* with both a count-mismatch and a not-deployed state appears in
* BOTH `countMismatches` AND `notDeployed`. `totals.withAnomalies` is
* the unique-package count (not the sum of bucket lengths).
*
* Entries are slug strings (not live PackageAudit references) to
* prevent downstream consumers from mutating the audit state by
* accident. Each field is `readonly string[]` so a consumer holding
* the report reference cannot mutate the audit state.
*/
readonly anomalies: {
readonly countMismatches: readonly string[];
readonly notDeployed: readonly string[];
readonly missingExamples: readonly string[];
readonly missingManifest: readonly string[];
readonly malformedManifest: readonly string[];
readonly unreadable: readonly string[];
};
readonly totals: {
readonly total: number;
readonly clean: number;
readonly withAnomalies: number;
};
}
// ---------------------------------------------------------------------------
// Config construction
// ---------------------------------------------------------------------------
/**
* Build an AuditConfig for real CLI execution. Honors `SHOWCASE_AUDIT_ROOT`
* to allow test subprocesses to point at a fixture tree. When unset,
* derives paths by walking up from this script's location:
* __dirname → showcase/scripts/
* showcaseRoot = __dirname/.. → showcase/
* repoRoot = showcaseRoot/.. → repo root
* Each step is a single `..` applied to the previous resolved path.
*
* Note: `path.resolve` normalizes path segments (resolving `..` and
* collapsing `.`) but does NOT canonicalize symlinks. If any segment of
* the input path is a symlink, the returned path still contains that
* symlink. Use `fs.realpathSync` to fully canonicalize. For our
* purposes this is fine — readdir/statSync transparently follow
* symlinks on access.
*/
function buildCliConfig(): AuditConfig {
const envRoot = process.env.SHOWCASE_AUDIT_ROOT;
if (envRoot && envRoot.length > 0) {
// Validate the env-var path up front. Without this, a bogus value
// (typo, stale fixture, file-typed path) flows through and surfaces
// as a confusing downstream error about the derived `<root>/packages`
// path — the operator has no hint that SHOWCASE_AUDIT_ROOT itself is
// the problem. We stat() here and throw UnreadableDirError, which
// main()'s top-level catch maps to EXIT_UNREADABLE (3).
try {
const st = fs.statSync(envRoot);
if (!st.isDirectory()) {
throw new UnreadableDirError(
envRoot,
new Error(`SHOWCASE_AUDIT_ROOT=${envRoot} is not a directory`),
);
}
} catch (e) {
if (e instanceof UnreadableDirError) {
throw e;
}
const code =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
const msg =
code === "ENOENT"
? `SHOWCASE_AUDIT_ROOT=${envRoot} does not exist`
: `SHOWCASE_AUDIT_ROOT=${envRoot} is unreadable`;
throw new UnreadableDirError(envRoot, new Error(msg, { cause: e }));
}
// Tests: SHOWCASE_AUDIT_ROOT=/tmp/fixture → /tmp/fixture/packages,
// /tmp/fixture/examples/integrations, repoRoot = /tmp/fixture.
return {
packagesDir: path.join(envRoot, "integrations"),
examplesIntegrationsDir: path.join(envRoot, "examples", "integrations"),
repoRoot: envRoot,
};
}
const showcaseRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(showcaseRoot, "..");
return {
packagesDir: path.join(showcaseRoot, "integrations"),
examplesIntegrationsDir: path.join(repoRoot, "examples", "integrations"),
repoRoot,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* List showcase package slugs. Throws UnreadableDirError on fs failures
* so main() can map them to exit code 3 rather than silently collapsing
* to "empty packages dir" (exit 1). Missing dir also throws — callers
* upstream check existence before invoking this.
*/
function listShowcasePackageSlugs(cfg: AuditConfig): string[] {
try {
return fs
.readdirSync(cfg.packagesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.sort();
} catch (e) {
throw new UnreadableDirError(cfg.packagesDir, e);
}
}
/**
* Distinguishes four outcomes for a package's manifest.yaml by
* returning ParsedManifest from lib/manifest.ts directly:
* - missing → file does not exist
* - malformed → file exists but YAML parse or shape validation failed
* (subkind: "syntax" | "shape")
* - unreadable → file exists but readFileSync threw (EACCES, I/O race)
* - ok → file parsed and validated successfully
*
* Downstream buildReport switches on ALL four variants rather than
* collapsing `unreadable` into `malformed` with a prefix, so the cause
* is preserved for structured consumers and CI bucket routing.
*
* Delegates to lib/manifest.ts :: parseManifest so audit.ts, validate-parity.ts,
* and capture-previews.ts all apply identical YAML-shape validation rules.
*/
function readManifest(slug: string, cfg: AuditConfig): ParsedManifest {
const p = path.join(cfg.packagesDir, slug, "manifest.yaml");
// Pass slug so parseManifest can enforce the slug-mismatch guard:
// a manifest whose declared `slug:` disagrees with the directory that
// holds it is flagged as malformed rather than silently keying a
// copy-paste/rename mistake into the wrong package downstream.
return parseManifest(p, slug);
}
/**
* Count files in a directory matching a predicate. Distinguishes three
* outcomes so callers can surface genuine errors:
* - ok → read succeeded; count is accurate
* - missing → directory doesn't exist (legitimate zero)
* - unreadable → readdir threw (permission, I/O); callers should emit
* an anomaly to avoid silent drops.
*
* Returns the public `CountState` shape directly so callers don't have
* to bridge through an intermediate representation.
*/
function countFiles(
dir: string,
extFilter: (name: string) => boolean,
): CountState {
// Use statSync + errno branching instead of `fs.existsSync`. existsSync
// returns false for every statSync failure (ENOENT, EACCES, EPERM,
// ENOTDIR, EIO, ELOOP, …), so an unreadable dir would silently
// classify as `missing` (legitimate zero) and trigger phantom
// count-mismatch anomalies downstream. Branching on err.code lets
// ENOENT keep the "missing" classification while non-ENOENT errno
// conditions propagate as "unreadable" — the caller (auditPackage)
// turns that into an `unreadable-dir` anomaly. Mirrors probeDir in
// validate-parity.ts.
try {
fs.statSync(dir);
} catch (e) {
const code =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
if (code === "ENOENT") return { state: "missing" };
const msg = e instanceof Error ? e.message : String(e);
return { state: "unreadable", error: msg };
}
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const count = entries.filter((d) => d.isFile() && extFilter(d.name)).length;
return { state: "ok", count };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Do NOT write to stderr here — the caller (auditPackage) pushes an
// `unreadable-dir` anomaly which is rendered by renderAnomalySection
// (single source of truth). Writing here would double-emit.
return { state: "unreadable", error: msg };
}
}
/**
* Numeric view of a CountState for programmatic consumers. Returns
* `null` for the "unreadable" state so callers cannot mistake an
* unknowable count for a real zero; "missing" maps to 0 because an
* absent directory is a legitimate zero. Display callers should prefer
* `countLabel` which emits "?" for unreadable.
*/
function countValue(s: CountState): number | null {
switch (s.state) {
case "ok":
return s.count;
case "missing":
return 0;
case "unreadable":
return null;
}
}
/** Rendered view of a CountState for the summary table. */
function countLabel(s: CountState): string {
switch (s.state) {
case "ok":
return String(s.count);
case "missing":
return "0";
case "unreadable":
return "?";
}
}
/**
* Structured return of {@link resolveExamplesSource}. The `source` field
* carries the resolved path (or null when nothing matched); the tagged
* booleans are classification signals consumed by {@link auditPackage}
* to route each "no resolved path" case to the correct anomaly variant:
*
* - `unreadableForSlug: true` — for a mapped slug, every candidate
* existed on disk but every stat call failed with a non-ENOENT error
* (EACCES/EIO/ELOOP/EPERM/...). Infrastructure failure; routes to
* `unreadable-examples`.
* - `nonDirectoryForSlug: true` — for a mapped slug, at least one
* candidate path exists but is not a directory. Misconfiguration;
* routes to `mapped-candidate-not-directory`.
* - Both false with `source: null` — benign stale mapping, ENOENT
* TOCTOU race, or unmapped miss. Routes to `missing-examples`.
*
* Invariant: classification is driven exclusively by these structured
* booleans. Callers must NEVER substring-match the human-readable
* warning text to decide between anomaly variants — the sink wording
* is free to change (typo fix, i18n, docstring edit) without altering
* routing.
*/
interface ExamplesSourceResult {
readonly source: string | null;
readonly unreadableForSlug: boolean;
/**
* True when at least one mapped candidate exists on disk but is not
* a directory (regular file / symlink-to-file / socket / FIFO). Drives
* the `mapped-candidate-not-directory` anomaly in auditPackage. Only
* set for mapped slugs; unmapped slugs are tracked on the warnings
* sink alone since they aren't routed through a dedicated anomaly.
*/
readonly nonDirectoryForSlug: boolean;
}
/**
* Resolve a showcase slug to its examples/integrations counterpart.
* Returns a structured {@link ExamplesSourceResult} — the `source` is
* null if no candidate exists (which is OK for born-in-showcase
* packages) and `unreadableForSlug` is the classification signal used
* by {@link auditPackage} to distinguish infrastructure failures from
* stale mappings.
*
* statSync is wrapped in try/catch — between existsSync and statSync
* there's a real (if rare) race window on network filesystems, and we
* don't want a TOCTOU race to crash the whole audit. Diagnostic strings
* for statSync failures and stale SLUG_TO_EXAMPLES entries are appended
* to the caller-supplied `warnings` sink. The caller is responsible for
* forwarding them to stderr and/or recording them on the PackageAudit —
* findExamplesSource does NOT touch global state (stdout/stderr).
*
* The `warnings` sink is optional — consumers (tests, ad-hoc scripts)
* that only care about the "found or not found" outcome can omit it,
* in which case warnings are discarded.
*/
function findExamplesSource(
slug: string,
cfg: AuditConfig,
warnings?: string[],
): ExamplesSourceResult {
return resolveExamplesSource(slug, SLUG_TO_EXAMPLES[slug], cfg, warnings);
}
/**
* Pure inner of findExamplesSource — the `mapped` argument is injected
* explicitly so tests can exercise multi-candidate fallback paths
* without relying on a specific SLUG_TO_EXAMPLES shape. Production
* callers should use findExamplesSource; tests that need deterministic
* multi-candidate behavior reach for this helper.
*
* Returns a structured {@link ExamplesSourceResult}: the `source` path
* on a hit, `null` + `unreadableForSlug: true` when all mapped
* candidates existed but every stat failed, and `null` +
* `unreadableForSlug: false` for a benign stale mapping or unmapped
* miss.
*/
function resolveExamplesSource(
slug: string,
mapped: readonly string[] | undefined,
cfg: AuditConfig,
warnings?: string[],
): ExamplesSourceResult {
const sink = warnings ?? [];
const candidates = mapped ?? [slug];
// Track outcomes per-candidate so we can distinguish "the mapped dirs
// don't exist" (stale mapping) from "they all exist but we couldn't
// read ANY of them" (permissions / I/O) — the latter is a CRITICAL
// warning because we literally cannot tell whether the provenance
// link is satisfied.
let unreadableCount = 0;
let existedCount = 0;
// Count of mapped candidates that exist on disk but are not
// directories (regular file / symlink-to-file / socket / FIFO). A
// mapped slug with nonDirCount > 0 and no successful directory hit
// routes to a distinct `mapped-candidate-not-directory` anomaly
// rather than silently degrading to `missing-examples`.
let nonDirCount = 0;
for (const candidate of candidates) {
const full = path.join(cfg.examplesIntegrationsDir, candidate);
// Do NOT gate on `fs.existsSync(full)`. existsSync returns false for
// every statSync failure — including EACCES/EPERM/EIO on a parent
// dir — not just ENOENT. With the old existsSync pre-check, an
// EACCES'd candidate was silently skipped (not counted in
// existedCount, not counted in unreadableCount), and when ALL
// mapped candidates were EACCES'd, the resolver returned
// `unreadableForSlug: false` and the package silently degraded to
// `missing-examples`. The fix: let statSync inside the try block
// be the sole source of truth. ENOENT → `continue` (absent);
// EACCES/other errno → increment unreadableCount AND push sink
// diagnostic so the infrastructure failure is visible.
let st: fs.Stats;
try {
st = fs.statSync(full);
} catch (e) {
const errCode =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
const msg = e instanceof Error ? e.message : String(e);
if (errCode === "ENOENT") {
// Absent candidate — stale mapping or never-existed. Do NOT
// bump existedCount or unreadableCount; classification will
// route to missing-examples (benign).
continue;
}
// Real I/O / permission failure (EACCES / EIO / ELOOP / EPERM /
// EMFILE / ENOTDIR / ...) — record on the warnings sink so it
// doesn't disappear silently, treat as "existed but unreadable"
// so `unreadableForSlug` fires when all mapped candidates land
// here.
existedCount++;
unreadableCount++;
sink.push(`audit: warning: statSync(${full}) failed: ${msg}`);
continue;
}
// stat succeeded — the path resolves to something (dir or not).
existedCount++;
if (st.isDirectory()) {
return {
source: path.relative(cfg.repoRoot, full),
unreadableForSlug: false,
nonDirectoryForSlug: false,
};
}
// Candidate exists but is not a directory (regular file /
// symlink-to-file / socket / FIFO). For BOTH mapped and unmapped
// slugs this is a misconfiguration — a stray entry in the
// integrations dir masquerading as the provenance target.
// Surface a per-candidate "exists but is not a directory"
// warning so operators see exactly which path is wrong, and for
// mapped slugs bump nonDirCount so auditPackage can route the
// `mapped-candidate-not-directory` anomaly instead of silently
// degrading to `missing-examples`.
sink.push(
`audit: warning: candidate path ${full} exists but is not a directory`,
);
if (mapped) nonDirCount++;
}
// Critical: mapped slug with multiple candidates that ALL exist but
// ALL failed with fs errors. We can't tell whether the provenance is
// satisfied — elevate to an ERROR warning so CI / JSON consumers can
// route this differently from a benign "no matching dir". The
// structured `unreadableForSlug: true` return is the classification
// signal consumed by auditPackage — no string-substring scanning.
if (mapped && existedCount > 0 && unreadableCount === existedCount) {
sink.push(
`audit: ERROR: all candidates unreadable for slug "${slug}" (category: unreadable-candidates) → [${mapped.join(", ")}]`,
);
return {
source: null,
unreadableForSlug: true,
nonDirectoryForSlug: false,
};
}
// Mapped slug whose candidate(s) existed-but-weren't-a-directory
// (and none of them was a successful dir hit). Route to the
// `mapped-candidate-not-directory` anomaly so this misconfiguration
// is visible downstream rather than silently degrading to
// `missing-examples`. The per-candidate "exists but is not a
// directory" warnings pushed above already carry the specific paths.
if (mapped && nonDirCount > 0) {
return {
source: null,
unreadableForSlug: false,
nonDirectoryForSlug: true,
};
}
// If the slug was *explicitly* mapped but none of its mapped
// candidates exist, the map is out of sync with the filesystem. Warn
// (via the sink) rather than error: missing examples counterparts are
// reported as audit anomalies downstream, not blocking failures.
// Fallback (unmapped slug → [slug]) is intentionally NOT warned —
// that's the normal "no mapping needed" path.
if (mapped) {
sink.push(
`audit: warning: SLUG_TO_EXAMPLES entry "${slug}" → [${mapped.join(", ")}] has no matching directory under ${cfg.examplesIntegrationsDir}`,
);
}
return {
source: null,
unreadableForSlug: false,
nonDirectoryForSlug: false,
};
}
function auditPackage(slug: string, cfg: AuditConfig): PackageAudit {
const manifestRes = readManifest(slug, cfg);
const pkgDir = path.join(cfg.packagesDir, slug);
const e2eDir = path.join(pkgDir, "tests", "e2e");
const qaDir = path.join(pkgDir, "qa");
const specRes = countFiles(e2eDir, (n) => n.endsWith(".spec.ts"));
const qaRes = countFiles(qaDir, (n) => n.endsWith(".md"));
// findExamplesSource records stale SLUG_TO_EXAMPLES / statSync-race
// warnings on this explicit sink. Callers (main, CI) forward it to
// stderr; JSON consumers read it off `audit.warnings`. The tuple
// result carries structured `unreadableForSlug` and
// `nonDirectoryForSlug` booleans consumed below for anomaly
// classification. Invariant: auditPackage NEVER reads the
// human-readable warning text to decide between anomaly variants —
// classification is driven exclusively by those structured signals.
const warnings: string[] = [];
const examplesResult = findExamplesSource(slug, cfg, warnings);
const examplesSource = examplesResult.source;
// Pull demosDeclared directly from the validated manifest
// (parseManifest guarantees demos is an array of objects and deployed,
// if present, is a real boolean — so the string "yes"/"no" footgun and
// the `.length === 4` footgun on a string demos are both ruled out).
// `deployed` is intentionally NOT duplicated on PackageAudit; consumers
// read it through `p.manifest.kind === "ok" ? p.manifest.manifest.deployed : undefined`
// so the manifest variant is the single source of truth.
const demosDeclared =
manifestRes.kind === "ok" ? manifestRes.manifest.demos.length : 0;
// Accumulate anomalies in a local array, then hand the frozen snapshot
// to the PackageAudit below. Deriving the final shape in one place
// keeps invariant checks (freeze, read-only array type, no downstream
// push) local and explicit — rather than mutating the record
// incrementally as the function walked.
const anomalies: Anomaly[] = [];
// Read-error anomalies propagate regardless of manifest state —
// unreadable dirs are infrastructure failures, not content failures.
if (specRes.state === "unreadable") {
anomalies.push({
kind: "unreadable-dir",
dir: e2eDir,
error: specRes.error,
});
}
if (qaRes.state === "unreadable") {
anomalies.push({
kind: "unreadable-dir",
dir: qaDir,
error: qaRes.error,
});
}
switch (manifestRes.kind) {
case "missing":
anomalies.push({ kind: "missing-manifest" });
break;
case "malformed":
anomalies.push({
kind: "malformed-manifest",
subkind: manifestRes.subkind,
error: manifestRes.error,
});
break;
case "unreadable":
anomalies.push({
kind: "unreadable-manifest",
error: manifestRes.error,
});
break;
case "ok": {
const manifest = manifestRes.manifest;
// Only report count-parity anomalies when we actually managed to
// read the directories — otherwise we'd double-report (unreadable
// + phantom mismatch). When the state is "ok" the count is a real
// number; "missing" implies count=0 which IS a legitimate data
// point for parity comparison.
//
// Informational-only demos (e.g. cli-start entries with a
// `command:` field) live in the registry but have no on-disk
// folder + no spec/qa file to audit. Exclude them from the
// count-mismatch denominator so audit.ts agrees with
// validate-parity.ts on which packages are "clean" — otherwise
// a package that's clean per parity would spuriously flag a
// count mismatch here. Mirrors the `!d.command` filter in
// validate-parity.ts :: auditPackage (~line 723, the
// `auditableDemos` derivation). `demosDeclared` on the
// PackageAudit still carries the RAW manifest count (summary
// table + JSON consumers depend on that); only the parity
// comparison uses the filtered count.
const auditableDemosDeclared = manifest.demos.filter(
(d) => !(d as { command?: string }).command,
).length;
const specCount = countValue(specRes);
if (specCount !== null && specCount !== auditableDemosDeclared) {
anomalies.push({
kind: "count-mismatch",
dimension: "spec",
expected: auditableDemosDeclared,
actual: specCount,
});
}
const qaCount = countValue(qaRes);
if (qaCount !== null && qaCount !== auditableDemosDeclared) {
anomalies.push({
kind: "count-mismatch",
dimension: "qa",
expected: auditableDemosDeclared,
actual: qaCount,
});
}
if (manifest.deployed !== true) {
anomalies.push({
kind: "not-deployed",
// String encoding is self-documenting at consumption sites —
// callers read the raw boolean off the manifest variant when
// they need it.
state: manifest.deployed === false ? "explicit-false" : "unset",
});
}
// Born-in-showcase packages have no Dojo counterpart by design;
// skip the "missing examples source" check for them.
if (examplesSource === null && !BORN_IN_SHOWCASE.has(slug)) {
// Three distinct failure modes for a mapped slug with no
// resolved directory, ordered by specificity:
//
// 1. `unreadable-examples` — all mapped candidates existed
// but every stat failed with a non-ENOENT error
// (EACCES/EIO/ELOOP/EPERM/...). Infrastructure failure;
// we cannot tell whether provenance is satisfied.
// 2. `mapped-candidate-not-directory` — at least one mapped
// candidate exists but is not a directory (stray file /
// symlink-to-file / socket / FIFO). Misconfiguration; the
// integrations dir has an entry masquerading as the
// provenance target.
// 3. `missing-examples` — the catch-all: stale mapping,
// benign TOCTOU race (ENOENT), or plain absence.
//
// Invariant: classification is driven exclusively by structured
// booleans on ExamplesSourceResult. Never substring-match
// warning text — sink wording is free to change without
// altering anomaly routing.
if (examplesResult.unreadableForSlug) {
anomalies.push({
kind: "unreadable-examples",
slug,
candidates: Object.freeze(
(SLUG_TO_EXAMPLES[slug] ?? [slug]).slice(),
) as readonly string[],
});
} else if (examplesResult.nonDirectoryForSlug) {
anomalies.push({
kind: "mapped-candidate-not-directory",
slug,
candidates: Object.freeze(
(SLUG_TO_EXAMPLES[slug] ?? [slug]).slice(),
) as readonly string[],
});
} else {
anomalies.push({ kind: "missing-examples" });
}
}
break;
}
}
// Freeze the mutable containers BEFORE handing them out — direct
// callers of auditPackage must not be able to push to a "readonly"
// array that isn't actually frozen at runtime (which would let
// downstream consumers silently corrupt audit state). `spec`, `qa`,
// and `manifest` are frozen here too so the readonly semantics
// advertised by PackageAudit hold for direct callers of auditPackage
// (which is exported from the bottom of this file). The nested
// demos array + its entries on the "ok" manifest variant are frozen
// by buildReport — auditPackage does not re-traverse them here so
// the freeze stays O(1) per package. buildReport's subsequent freeze
// loop is idempotent (Object.freeze on an already-frozen object is
// a no-op) and is kept as defense-in-depth for consumers that only
// go through buildReport.
Object.freeze(anomalies);
Object.freeze(warnings);
Object.freeze(specRes);
Object.freeze(qaRes);
Object.freeze(manifestRes);
return {
slug,
manifest: manifestRes,
demosDeclared,
spec: specRes,
qa: qaRes,
examplesSource,
anomalies,
warnings,
};
}
// ---------------------------------------------------------------------------
// Anomaly rendering (human-readable strings for the text report)
// ---------------------------------------------------------------------------
function anomalyMessage(a: Anomaly): string {
switch (a.kind) {
case "missing-manifest":
return "missing manifest.yaml";
case "malformed-manifest":
return `malformed manifest.yaml (${a.subkind}): ${a.error}`;
case "unreadable-manifest":
return `could not read manifest.yaml: ${a.error}`;
case "unreadable-dir":
return `could not read ${a.dir}: ${a.error}`;
case "count-mismatch":
return `${a.dimension} count (${a.actual}) != demos (${a.expected})`;
case "not-deployed":
// Render the string-union state as a familiar label so
// human-readable output doesn't change. `"explicit-false"` → "false"
// preserves the historical display; the anomaly itself carries the
// more explicit string for structured consumers.
return `deployed=${a.state === "explicit-false" ? "false" : "unset"}`;
case "missing-examples":
return "no examples/integrations counterpart";
case "unreadable-examples":
return `examples/integrations candidates unreadable for "${a.slug}" → [${a.candidates.join(", ")}]`;
case "mapped-candidate-not-directory":
return `examples/integrations candidate(s) for "${a.slug}" exist but are not directories → [${a.candidates.join(", ")}]`;
}
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
function padRight(s: string, w: number): string {
if (s.length >= w) return s;
return s + " ".repeat(w - s.length);
}
function padLeft(s: string, w: number): string {
if (s.length >= w) return s;
return " ".repeat(w - s.length) + s;
}
// Keyed schema for the package summary table. Defining the per-column
// key, label, value projection, and alignment once — instead of relying
// on positional-index coupling between the header array, the row array,
// and the fmtRow alignment callback — eliminates a class of "edit one
// list, forget the other two" bugs (e.g., adding a column that silently
// grows the divider but wraps values under the wrong header).
// Each column carries a stable `key` (machine-readable identifier used
// by `--columns=<csv>` to filter) alongside its display `label`.
// `as const` pins the tuple shape so `ColumnKey` below is a literal
// union of the declared keys — not `string`. parseArgs validates user
// input against that union at runtime, and ParsedArgs.columns carries
// the narrower type.
const TABLE_COLUMNS = [
{
key: "slug",
label: "slug",
align: "left",
value: (a: PackageAudit) => a.slug,
},
{
key: "demos",
label: "demos",
align: "right",
value: (a: PackageAudit) => String(a.demosDeclared),
},
{
key: "specs",
label: "specs",
align: "right",
value: (a: PackageAudit) => countLabel(a.spec),
},
{
key: "qa",
label: "qa",
align: "right",
value: (a: PackageAudit) => countLabel(a.qa),
},
{
key: "deployed",
label: "deployed",
align: "right",
value: (a: PackageAudit) => {
// Read deployed state through the manifest variant — single
// source of truth. No duplicate `deployed` field on PackageAudit.
if (a.manifest.kind !== "ok") return "?";
const d = a.manifest.manifest.deployed;
if (d === undefined) return "?";
return d ? "yes" : "no";
},
},
{
key: "examples-src",
label: "examples src",
align: "left",
value: (a: PackageAudit) => a.examplesSource ?? "—",
},
] as const satisfies ReadonlyArray<{
key: string;
label: string;
align: "left" | "right";
value: (a: PackageAudit) => string;
}>;
type ColumnKey = (typeof TABLE_COLUMNS)[number]["key"];
/**
* Resolve a user-supplied list of column keys to the subset of
* TABLE_COLUMNS to render, preserving declared column order. Returns
* `null` (untyped sentinel) if `keys` is undefined — i.e. "use all
* columns". parseArgs validates keys up-front so this helper can assume
* every entry is recognised.
*/
function selectColumns(
keys: readonly ColumnKey[] | null,
): ReadonlyArray<(typeof TABLE_COLUMNS)[number]> {
if (keys === null) return TABLE_COLUMNS;
const wanted = new Set<ColumnKey>(keys);
return TABLE_COLUMNS.filter((c) => wanted.has(c.key));
}
function renderTable(
audits: readonly PackageAudit[],
columns: ReadonlyArray<(typeof TABLE_COLUMNS)[number]> = TABLE_COLUMNS,
): string {
// Empty-list guard: no rows means nothing to align to but the header
// widths. Without this, `Math.max(h.length, ...[])` still works (the
// spread of an empty array disappears) but the table would consist of
// header + divider only, which the caller almost never actually wants.
// Short-circuit with a dedicated "(no packages)" note instead.
if (audits.length === 0) {
return " (no packages)";
}
const rows = audits.map((a) => columns.map((col) => col.value(a)));
const widths = columns.map((col, i) =>
Math.max(col.label.length, ...rows.map((r) => r[i].length)),
);
const fmtRow = (cells: readonly string[]) =>
" " +
cells
.map((c, i) =>
columns[i].align === "left"
? padRight(c, widths[i])
: padLeft(c, widths[i]),
)
.join(" ");
const headerRow = columns.map((col) => col.label);
const divider = " " + widths.map((w) => "-".repeat(w)).join(" ");
return [fmtRow(headerRow), divider, ...rows.map(fmtRow)].join("\n");
}
function renderAnomalySection(report: AuditReport): string {
const lines: string[] = [];
const {
countMismatches,
notDeployed,
missingExamples,
missingManifest,
malformedManifest,
unreadable,
} = report.anomalies;
const bySlug = new Map(report.packages.map((p) => [p.slug, p]));
lines.push("Coverage anomalies");
lines.push("------------------");
if (missingManifest.length > 0) {
lines.push("");
lines.push(" Missing manifest.yaml:");
for (const slug of missingManifest) lines.push(` - ${slug}`);
}
if (malformedManifest.length > 0) {
lines.push("");
lines.push(" Malformed manifest.yaml:");
for (const slug of malformedManifest) {
const p = bySlug.get(slug);
const reason =
p?.anomalies.find((a) => a.kind === "malformed-manifest") ?? null;
const msg = reason ? anomalyMessage(reason) : "malformed manifest.yaml";
lines.push(` - ${slug}: ${msg}`);
}
}
if (unreadable.length > 0) {
lines.push("");
lines.push(" Unreadable directories:");
for (const slug of unreadable) {
const p = bySlug.get(slug);
// Prefer the first I/O-category anomaly on the package — any of
// unreadable-dir / unreadable-manifest / unreadable-examples /
// mapped-candidate-not-directory may be present; render whichever
// we find first. `mapped-candidate-not-directory` is included here
// (rather than under missing-examples) because it's a
// misconfiguration of the integrations dir (a stray file where a
// directory should be), not a legitimately-absent provenance link.
const reason =
p?.anomalies.find(
(a) =>
a.kind === "unreadable-dir" ||
a.kind === "unreadable-manifest" ||
a.kind === "unreadable-examples" ||
a.kind === "mapped-candidate-not-directory",
) ?? null;
const msg = reason ? anomalyMessage(reason) : "could not read";
lines.push(` - ${slug}: ${msg}`);
}
}
if (countMismatches.length > 0) {
lines.push("");
lines.push(" Count mismatches (specs or qa differ from demos):");
for (const slug of countMismatches) {
const p = bySlug.get(slug);
if (!p) continue;
lines.push(
` - ${slug}: demos=${p.demosDeclared} specs=${countLabel(p.spec)} qa=${countLabel(p.qa)}`,
);
}
}
if (notDeployed.length > 0) {
lines.push("");
lines.push(" Not deployed (deployed != true):");
for (const slug of notDeployed) {
const p = bySlug.get(slug);
const deployed =
p?.manifest.kind === "ok" ? p.manifest.manifest.deployed : undefined;
// Human-readable label: the historical "false" / "unset" strings —
// not the internal Anomaly.state encoding.
const state = deployed === false ? "false" : "unset";
lines.push(` - ${slug} (${state})`);
}
}
if (missingExamples.length > 0) {
lines.push("");
lines.push(" No examples/integrations counterpart:");
for (const slug of missingExamples) {
lines.push(` - ${slug}`);
}
}
if (
missingManifest.length === 0 &&
malformedManifest.length === 0 &&
unreadable.length === 0 &&
countMismatches.length === 0 &&
notDeployed.length === 0 &&
missingExamples.length === 0
) {
lines.push("");
lines.push(" (none)");
}
return lines.join("\n");
}
function renderHealthSection(report: AuditReport): string {
const { total, clean, withAnomalies } = report.totals;
const lines: string[] = [];
lines.push("Overall health");
lines.push("--------------");
lines.push(` Packages total: ${total}`);
lines.push(` Clean: ${clean}`);
lines.push(` With anomalies: ${withAnomalies}`);
lines.push("");
if (withAnomalies === 0) {
lines.push(" All packages pass coverage audit.");
return lines.join("\n");
}
const suggestions: string[] = [];
if (report.anomalies.countMismatches.length > 0) {
suggestions.push(
"Align demos/specs/qa counts — each declared demo should have exactly one spec and one QA doc.",
);
}
if (report.anomalies.notDeployed.length > 0) {
suggestions.push(
"Mark packages as `deployed: true` once their Railway service is live.",
);
}
if (report.anomalies.missingExamples.length > 0) {
suggestions.push(
"Add the slug to SLUG_TO_EXAMPLES in showcase/scripts/lib/slug-map.ts, or add it to BORN_IN_SHOWCASE if the package has no Dojo counterpart.",
);
}
if (report.anomalies.missingManifest.length > 0) {
suggestions.push(
"Create a manifest.yaml for each package directory or remove the directory.",
);
}
if (report.anomalies.malformedManifest.length > 0) {
suggestions.push(
"Fix YAML syntax in malformed manifest.yaml files — see anomaly details above.",
);
}
if (report.anomalies.unreadable.length > 0) {
suggestions.push(
"Unreadable directories usually indicate a permission or filesystem issue — check the error detail above.",
);
}
lines.push(" Suggestions:");
for (const s of suggestions) lines.push(` - ${s}`);
return lines.join("\n");
}
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
/**
* Pure exit-code calculation. Extracted so it can be unit-tested
* independently of the auditPackage code path (which is otherwise
* tricky to drive into the "warnings without anomalies" quadrant using
* filesystem fixtures alone).
*
* Return type is narrowed to the three literal values this function
* can actually produce (0, 1, or 5). The wider AuditExitCode union
* covers values main() assigns on other control-flow paths (2, 3, 4)
* that do not go through this helper.
*
* Contract:
* - anomalies present → EXIT_ANOMALIES (1), regardless of strict/warnings
* - no anomalies, --strict, warnings present → EXIT_WARNINGS (5)
* - no anomalies, default OR strict-without-warnings → EXIT_OK (0)
*/
function computeExitCode(input: {
hasAnomalies: boolean;
hasWarnings: boolean;
strict: boolean;
}): typeof EXIT_OK | typeof EXIT_ANOMALIES | typeof EXIT_WARNINGS {
if (input.hasAnomalies) return EXIT_ANOMALIES;
if (input.strict && input.hasWarnings) return EXIT_WARNINGS;
return EXIT_OK;
}
/**
* Bucket names used by the `AuditReport.anomalies` object. Keeping this
* as a string-literal union — rather than free-form strings — lets
* `bucketFor` below drive an exhaustive `switch(a.kind)` that turns
* "adding a new Anomaly variant and forgetting to route it" into a
* compile-time error in buildReport, rather than the silent "invisible
* in every bucket" runtime footgun that hid `mapped-candidate-not-directory`
* for a full release cycle.
*/
type BucketName =
| "countMismatches"
| "notDeployed"
| "missingExamples"
| "missingManifest"
| "malformedManifest"
| "unreadable";
/**
* Map an Anomaly to its corresponding bucket in `AuditReport.anomalies`.
* The compiler enforces exhaustiveness via the `never` branch — a newly
* added Anomaly variant that is NOT routed here will fail the build
* instead of silently falling through unrouted (and thus unrendered by
* renderAnomalySection, a prior regression where a new Anomaly variant
* silently fell through every bucket).
*
* Routing rationale:
* - `mapped-candidate-not-directory` is a MISCONFIGURATION of the
* integrations directory (a stray file where a directory should be);
* it's closer to "infrastructure is wrong" than "content is absent",
* so it lands in `unreadable` alongside the other I/O-category
* anomalies rather than `missingExamples` (where it would imply the
* provenance is legitimately absent).
* - `missing-examples` is ONLY for actually-absent mapped candidates
* (stale SLUG_TO_EXAMPLES entry, never-existed path).
*/
function bucketFor(a: Anomaly): BucketName {
switch (a.kind) {
case "count-mismatch":
return "countMismatches";
case "not-deployed":
return "notDeployed";
case "missing-examples":
return "missingExamples";
case "missing-manifest":
return "missingManifest";
case "malformed-manifest":
return "malformedManifest";
case "unreadable-dir":
case "unreadable-manifest":
case "unreadable-examples":
case "mapped-candidate-not-directory":
return "unreadable";
default: {
// Exhaustiveness guard. If a new Anomaly kind is added above and
// not routed here, this assignment fails to compile — forcing
// the author to decide which bucket it belongs to (or add a new
// one) rather than silently producing an invisible anomaly.
const _exhaustive: never = a;
throw new Error(
`bucketFor: unrouted Anomaly kind ${JSON.stringify(_exhaustive)}`,
);
}
}
}
function buildReport(
slugs: string[],
cfg: AuditConfig,
opts: { strict?: boolean } = {},
): AuditReport {
const packages = slugs.map((s) => auditPackage(s, cfg));
// Classify via tagged-union `Anomaly.kind` through the exhaustive
// `bucketFor` helper — a new Anomaly variant that isn't routed fails
// to compile instead of silently disappearing from every bucket
// (the prior regression where a new kind fell through unrouted).
// Each bucket collects unique slugs; buckets deliberately overlap
// (a package with both a count mismatch and not-deployed appears in
// both), so we de-duplicate per-bucket via a Set.
//
// Invariant: auditPackage only emits a count-mismatch anomaly when
// the underlying count is readable (see `specCount !== null` /
// `qaCount !== null` guards in auditPackage). The presence of a
// `count-mismatch` anomaly in `p.anomalies` already implies the
// relevant dimension was readable, so no secondary suppression is
// needed here.
const bucketSets: Record<BucketName, Set<string>> = {
countMismatches: new Set(),
notDeployed: new Set(),
missingExamples: new Set(),
missingManifest: new Set(),
malformedManifest: new Set(),
unreadable: new Set(),
};
for (const p of packages) {
for (const a of p.anomalies) {
bucketSets[bucketFor(a)].add(p.slug);
}
// The `missing-examples` bucket derives, for packages with a
// READABLE manifest (`manifest.kind === "ok"`), from
// `examplesSource === null`. Packages whose manifest is missing,
// malformed, or unreadable are NOT double-counted here — they live
// in their own buckets (missingManifest / malformedManifest /
// unreadable) via the tagged-union routing above, and adding them
// to missingExamples as well would inflate the JSON consumers'
// anomaly totals. The explicit `manifest.kind === "ok"` guard
// below enforces that exclusion (earlier wording incorrectly
// claimed this bucket covered manifest-less packages too — it
// does not). Derived from `p.examplesSource === null &&
// p.manifest.kind === "ok"` to preserve the prior JSON contract.
if (
p.manifest.kind === "ok" &&
p.examplesSource === null &&
!BORN_IN_SHOWCASE.has(p.slug)
) {
// Only add to missingExamples when the package did NOT already get
// routed into unreadable via a mapped-candidate-not-directory or
// unreadable-examples Anomaly. This keeps the old "missing-examples
// vs unreadable" split stable for JSON consumers.
const routedElsewhere =
bucketSets.unreadable.has(p.slug) &&
p.anomalies.some(
(x) =>
x.kind === "mapped-candidate-not-directory" ||
x.kind === "unreadable-examples",
);
if (!routedElsewhere) {
bucketSets.missingExamples.add(p.slug);
}
}
}
const countMismatches = [...bucketSets.countMismatches];
// `deployed` is read through the manifest variant — the single
// source of truth. A package with no manifest or a malformed one is
// surfaced via its own anomaly and does not double-count here.
// (bucketFor routes `not-deployed` anomalies, which auditPackage
// only emits for ok-manifest packages with deployed !== true.)
const notDeployed = [...bucketSets.notDeployed];
const missingExamples = [...bucketSets.missingExamples];
const missingManifest = [...bucketSets.missingManifest];
// `malformedManifest` groups content-shape problems. `unreadable-manifest`
// is a distinct I/O condition classified under `unreadable` alongside
// spec/qa-dir read failures (infrastructure, not content). The
// `mapped-candidate-not-directory` variant is also routed
// here — it's a misconfiguration of the integrations dir, closer to
// unreadable than to missing.
const malformedManifest = [...bucketSets.malformedManifest];
const unreadable = [...bucketSets.unreadable];
const withAnomalies = packages.filter((p) => p.anomalies.length > 0).length;
// Deep-freeze audit records so downstream consumers can't accidentally
// mutate them. anomalies/warnings/spec/qa/manifest were already frozen
// by auditPackage (so direct callers see an immutable view);
// buildReport additionally freezes the record wrapper and the nested
// manifest.demos array + entries. The spec/qa/manifest freezes below
// are idempotent (Object.freeze on an already-frozen object is a
// no-op) and are kept as defense-in-depth — both for readers who only
// traverse buildReport's output and against future callers that might
// pre-populate a PackageAudit without going through auditPackage.
for (const p of packages) {
Object.freeze(p.spec);
Object.freeze(p.qa);
Object.freeze(p.manifest);
// The "ok" variant carries a nested Manifest object. Freeze the
// manifest AND its demos array (plus each demo entry) so callers
// holding a reference cannot rewrite `deployed` OR rearrange the
// demos list on a shared object. yaml.parse returns plain mutable
// arrays/objects, so we need to freeze them ourselves.
if (p.manifest.kind === "ok") {
const m = p.manifest.manifest;
if (m.demos) {
for (const d of m.demos) Object.freeze(d);
Object.freeze(m.demos);
}
Object.freeze(m);
}
Object.freeze(p);
}
const strict = opts.strict ?? false;
// hasAnomalies / hasWarnings / exitCode are derived from `packages`
// and `withAnomalies` — NOT cached snapshots. Defined as class
// getters so (a) the shape structurally matches AuditReport without
// any `as unknown as` cast and (b) there is only ONE source of truth
// for each scalar (the getter computation itself), not a parallel
// cached copy.
//
// JSON serialization: class getters are non-enumerable by default, so
// we opt them into JSON output via a toJSON() method that produces a
// plain object carrying the derived scalars alongside the data
// buckets. This preserves the external JSON contract (consumers see
// `hasAnomalies`, `hasWarnings`, `exitCode` as top-level fields).
const anomaliesBucket = Object.freeze({
countMismatches: Object.freeze(countMismatches) as readonly string[],
notDeployed: Object.freeze(notDeployed) as readonly string[],
missingExamples: Object.freeze(missingExamples) as readonly string[],
missingManifest: Object.freeze(missingManifest) as readonly string[],
malformedManifest: Object.freeze(malformedManifest) as readonly string[],
unreadable: Object.freeze(unreadable) as readonly string[],
});
const totals = Object.freeze({
total: packages.length,
clean: packages.length - withAnomalies,
withAnomalies,
});
class AuditReportImpl implements AuditReport {
readonly packages: readonly PackageAudit[];
readonly anomalies: AuditReport["anomalies"];
readonly totals: AuditReport["totals"];
constructor(
pkgs: readonly PackageAudit[],
a: AuditReport["anomalies"],
t: AuditReport["totals"],
) {
this.packages = pkgs;
this.anomalies = a;
this.totals = t;
}
get hasAnomalies(): boolean {
return this.totals.withAnomalies > 0;
}
get hasWarnings(): boolean {
return this.packages.some((p) => p.warnings.length > 0);
}
get exitCode(): AuditExitCode {
return computeExitCode({
hasAnomalies: this.hasAnomalies,
hasWarnings: this.hasWarnings,
strict,
});
}
toJSON(): {
hasAnomalies: boolean;
hasWarnings: boolean;
exitCode: AuditExitCode;
packages: readonly PackageAudit[];
anomalies: AuditReport["anomalies"];
totals: AuditReport["totals"];
} {
return {
hasAnomalies: this.hasAnomalies,
hasWarnings: this.hasWarnings,
exitCode: this.exitCode,
packages: this.packages,
anomalies: this.anomalies,
totals: this.totals,
};
}
}
const report = new AuditReportImpl(packages, anomaliesBucket, totals);
return Object.freeze(report);
}
interface ParsedArgs {
json: boolean;
slug: string | null;
strict: boolean;
/**
* Subset of column keys (see TABLE_COLUMNS) to render, in declared
* order. `null` means "render all columns" — distinct from `[]`
* (which would render NOTHING). parseArgs validates every supplied
* key against TABLE_COLUMNS up-front.
*/
columns: readonly ColumnKey[] | null;
help: boolean;
/**
* readonly so a caller walking the struct cannot silently push new
* errors to it. Mutation stays internal to parseArgs.
*/
errors: readonly string[];
}
// Flag-aware argv parser. Rejects `--slug --json` rather than silently
// consuming `--json` as the slug value. Rejects duplicate `--slug` or
// `--json` (e.g. `--json --json` or `--slug a --slug b`) rather than
// last-wins, since CI shell concatenation is a common source of
// accidental duplicates and "last wins" hides the user's first intent.
// Returns parse errors so the caller can distinguish invalid arguments
// (exit 2) from package anomalies (exit 1).
function parseArgs(argv: string[]): ParsedArgs {
let json = false;
let slug: string | null = null;
let help = false;
let strict = false;
let columns: ColumnKey[] | null = null;
const errors: string[] = [];
// Track which flags have already been set so duplicates surface as
// explicit errors instead of being silently overwritten.
let sawJson = false;
let sawSlug = false;
let sawStrict = false;
let sawColumns = false;
const validColumnKeys = new Set<ColumnKey>(TABLE_COLUMNS.map((c) => c.key));
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--json") {
if (sawJson) {
errors.push("--json specified more than once");
}
sawJson = true;
json = true;
} else if (a === "--strict") {
if (sawStrict) {
errors.push("--strict specified more than once");
}
sawStrict = true;
strict = true;
} else if (a === "--slug") {
const next = argv[i + 1];
if (!next || next.startsWith("-")) {
errors.push(
`--slug requires a value (not a flag like "${next ?? "(end of argv)"}")`,
);
} else {
if (sawSlug) {
errors.push(
`--slug specified more than once (first="${slug}", second="${next}")`,
);
}
sawSlug = true;
slug = next;
i++;
}
} else if (a.startsWith("--columns=")) {
if (sawColumns) {
errors.push("--columns specified more than once");
}
sawColumns = true;
const raw = a.slice("--columns=".length);
const parts = raw
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (parts.length === 0) {
errors.push("--columns requires at least one key");
} else {
const unknown = parts.filter(
(k): k is string => !(validColumnKeys as Set<string>).has(k),
);
if (unknown.length > 0) {
errors.push(
`--columns: unknown column key(s): ${unknown.join(", ")} (valid keys: ${[...validColumnKeys].join(", ")})`,
);
} else {
// Narrowed: every `parts` entry passed the validColumnKeys
// membership check, so the cast is sound (runtime-verified).
columns = parts as ColumnKey[];
}
}
} else if (a === "--help" || a === "-h") {
help = true;
} else {
errors.push(`unrecognised argument: ${a}`);
}
}
return { json, slug, strict, columns, help, errors };
}
const HELP_TEXT = [
"Usage: npx tsx showcase/scripts/audit.ts [options]",
"",
"Options:",
" --json Emit machine-readable JSON instead of a table",
" --slug <slug> Audit only the given showcase package slug",
" --strict Exit 5 if any warnings are present (default: warnings",
" are informational and do not affect exit code)",
" --columns=<csv> Render only the listed columns (comma-separated keys;",
" declared order preserved). Valid keys: slug, demos,",
" specs, qa, deployed, examples-src",
" -h, --help Show this help",
"",
"Examples:",
" npx tsx showcase/scripts/audit.ts",
" npx tsx showcase/scripts/audit.ts --json",
" npx tsx showcase/scripts/audit.ts --slug mastra",
" npx tsx showcase/scripts/audit.ts --json --slug mastra",
" npx tsx showcase/scripts/audit.ts --strict",
" npx tsx showcase/scripts/audit.ts --columns=slug,demos,deployed",
"",
"Output order: summary table → coverage anomalies → overall health.",
"",
"Exit codes:",
" 0 — no anomalies (warnings, if any, are informational by default)",
" 1 — anomalies found (see anomaly section, or empty packages dir)",
" 2 — invalid content / user input (bad args, unknown slug)",
" 3 — unreadable (packages path missing, not a directory, or fs failure)",
" 4 — unexpected internal error",
" 5 — warnings present with --strict (default: warnings don't change exit)",
].join("\n");
// Heuristic: treat TypeError / ReferenceError / RangeError as programmer
// bugs (broken invariant, likely worth a bug report), not as
// infrastructure failures. A Node ErrnoException (any Error carrying a
// `.code` string like "EACCES" / "ENOENT") is always a runtime I/O
// condition even if the instance resolves to TypeError via weird
// subclass drift — we bias the other way and treat `.code`-bearing
// errors as runtime, not programmer. Everything else that reaches the
// top-level catch is more likely an unhandled I/O or runtime condition.
// Both still land on EXIT_INTERNAL, but the diagnostic wording differs
// so the on-call reader can triage faster.
function isProgrammerBug(e: unknown): boolean {
// Errno-carrying errors (EACCES / ENOENT / EIO / ELOOP / etc.) are
// runtime conditions, not programmer bugs. The shape match is
// intentionally loose: we accept any Error instance that carries a
// string `.code`.
if (
e instanceof Error &&
typeof (e as NodeJS.ErrnoException).code === "string"
) {
return false;
}
return (
e instanceof TypeError ||
e instanceof ReferenceError ||
e instanceof RangeError
);
}
// All exit paths use `process.exitCode = N; return;` instead of
// `process.exit(N)` so that stdout has time to drain before the process
// terminates — `process.exit` is synchronous and can truncate
// buffered JSON output on fast exits (observed in CI logs under heavy
// load). The `return` statements terminate main(); the event loop
// drains and the process exits with the set code.
function main(): void {
try {
const parsed = parseArgs(process.argv.slice(2));
if (parsed.help) {
console.log(HELP_TEXT);
process.exitCode = 0;
return;
}
if (parsed.errors.length > 0) {
for (const err of parsed.errors) {
console.error(`audit: ${err}`);
}
console.error("");
console.error(HELP_TEXT);
process.exitCode = EXIT_INVALID_CONTENT;
return;
}
const cfg = buildCliConfig();
// Stat the packages path to distinguish every failure mode:
// - ENOENT → "packages dir does not exist"
// - non-dir → "packages path is not a directory"
// - any other errno (EACCES/EIO/ELOOP/EPERM/...) → "could not stat"
// with the errno string verbatim so operators see the real cause.
//
// An earlier revision pre-checked `fs.existsSync(cfg.packagesDir)`
// and short-circuited with "packages dir does not exist" for a
// false return. That was wrong: existsSync returns false for EVERY
// statSync failure (EACCES, EPERM, EIO, ELOOP, ENOTDIR, …), so
// EACCES surfaced as the misleading "does not exist" message —
// operators couldn't tell permission failure from actual absence.
// The pre-check has been removed; the statSync block below produces
// accurate errno-specific diagnostics. Without this explicit check,
// readdirSync's ENOTDIR would be caught and collapsed into "empty
// packages" (exit 1), masking the real cause.
try {
if (!fs.statSync(cfg.packagesDir).isDirectory()) {
console.error(
`audit: packages path is not a directory: ${cfg.packagesDir}`,
);
process.exitCode = EXIT_UNREADABLE;
return;
}
} catch (e) {
const code =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
const msg = e instanceof Error ? e.message : String(e);
if (code === "ENOENT") {
console.error(`audit: packages dir does not exist: ${cfg.packagesDir}`);
} else {
console.error(`audit: could not stat ${cfg.packagesDir}: ${msg}`);
}
process.exitCode = EXIT_UNREADABLE;
return;
}
const allSlugs = listShowcasePackageSlugs(cfg);
if (parsed.slug && !allSlugs.includes(parsed.slug)) {
console.error(`audit: unknown showcase package slug: ${parsed.slug}`);
console.error(
`audit: available slugs: ${allSlugs.join(", ") || "(none)"}`,
);
process.exitCode = EXIT_INVALID_CONTENT;
return;
}
const slugs = parsed.slug ? [parsed.slug] : allSlugs;
if (slugs.length === 0) {
console.error(
`audit: packages dir is empty: ${cfg.packagesDir} — nothing to audit`,
);
// Empty packages dir is a genuine anomaly (working-as-designed audit
// should have something to audit), so exit 1 not 2.
process.exitCode = EXIT_ANOMALIES;
return;
}
const report = buildReport(slugs, cfg, { strict: parsed.strict });
if (parsed.json) {
// In JSON mode, stdout carries the full report and the
// `packages[].warnings` array carries any per-package diagnostics.
// We deliberately suppress the stderr mirror of those warnings to
// avoid double-emitting the same information — JSON consumers read
// the structured field, and a redirected `2>/dev/null` JSON run
// stays machine-parseable.
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
} else {
// In text mode, forward each PackageAudit's warnings to stderr so a
// human reader watching the terminal still sees the stale
// SLUG_TO_EXAMPLES / statSync-race diagnostics that findExamplesSource
// recorded. JSON mode (above) has these on the structured record.
for (const p of report.packages) {
for (const w of p.warnings) {
process.stderr.write(w + "\n");
}
}
console.log("Per-package summary");
console.log("-------------------");
console.log(renderTable(report.packages, selectColumns(parsed.columns)));
console.log("");
console.log(renderAnomalySection(report));
console.log("");
console.log(renderHealthSection(report));
}
process.exitCode = report.exitCode;
return;
} catch (e) {
// UnreadableDirError is a known I/O condition, not a bug — map to
// EXIT_UNREADABLE (3) so CI can distinguish "permission denied on
// packages dir" from "undefined is not a function".
if (e instanceof UnreadableDirError) {
console.error(`audit: ${e.message}`);
process.exitCode = EXIT_UNREADABLE;
return;
}
// Programmer bugs (TypeError / ReferenceError / RangeError) and
// unhandled I/O/runtime errors both exit 4 but carry distinct
// diagnostic prefixes so the on-call reader can tell "fix the code"
// from "investigate the environment" at a glance.
const stack = e instanceof Error ? e.stack || e.message : String(e);
if (isProgrammerBug(e)) {
console.error(`audit: bug (programmer error): ${stack}`);
} else {
console.error(`audit: internal error: ${stack}`);
}
process.exitCode = EXIT_INTERNAL;
return;
}
}
/**
* Canonicalize a path for "is this the script being run?" comparison.
* Uses `fs.realpathSync` so a symlink to audit.ts (e.g. a globally
* linked CLI, or a node_modules symlink hop under pnpm) on either side
* of the comparison still matches the canonical source path.
*
* Returns a tagged-union result rather than throwing / exiting so that
* merely importing this module (which loads the `isMain` guard below
* AND ran a prior revision's top-level `process.exit` on realpath
* failure) never terminates the host process. The caller (the `isMain`
* block) decides whether a given realpath failure should propagate as
* an exit code or be treated as "not main" (silent).
*
* Failure modes:
* - ENOENT: benign fallback — some test harnesses hand a synthetic
* argv[0] that doesn't exist on disk. Reported as `ok: true` with
* the resolved (non-canonical) path so the comparison can still run.
* - Non-ENOENT errno errors (e.g. EACCES, ELOOP, EIO): reported as
* `ok: false` with the errno code. The caller emits the diagnostic
* and decides the exit semantics.
*/
type CanonicalizeResult =
| { ok: true; path: string }
| { ok: false; errno: string; message: string; resolved: string };
function canonicalizeForIsMain(p: string): CanonicalizeResult {
const resolved = path.resolve(p);
try {
return { ok: true, path: fs.realpathSync(resolved) };
} catch (e) {
const code =
e instanceof Error ? (e as NodeJS.ErrnoException).code : undefined;
if (code === "ENOENT") {
// Benign: synthetic argv[0] (test harness, etc.) — fall back to
// the resolved path so the string comparison can still run.
return { ok: true, path: resolved };
}
return {
ok: false,
errno: code ?? "UNKNOWN",
message: e instanceof Error ? e.message : String(e),
resolved,
};
}
}
// Only run when executed directly. Canonicalizes both sides via
// realpathSync to match across symlinks (tsx shim, pnpm hoisting,
// globally linked CLI, etc.). Importing this module (e.g. from tests
// or composed tooling) must NEVER terminate the host process — the
// realpath-failure handling below is therefore scoped to this guard
// and only propagates an exit signal when the SOURCE side fails (which
// is programmer error: the running script cannot locate its own file).
if (process.argv[1]) {
const sourceResult = canonicalizeForIsMain(fileURLToPath(import.meta.url));
if (!sourceResult.ok) {
// SOURCE-side failure: the running script cannot canonicalize its
// own file path. This is programmer / environment error — log and
// flag EXIT_UNREADABLE via `process.exitCode`, but do NOT execute
// main() (the audit would run against an unknown config root).
// `process.exitCode` is safe here because main() is skipped, so
// nothing downstream will overwrite it.
process.stderr.write(
`[canonicalizeForIsMain] realpath failed for ${sourceResult.resolved}: ${sourceResult.message}\n`,
);
process.exitCode = EXIT_UNREADABLE;
} else {
const argvResult = canonicalizeForIsMain(process.argv[1]);
// ARGV-side failure with SOURCE success: treat as "not main"
// (silent). A test harness importing this module will never match
// its own argv[0] against this script's canonical path, so
// suppressing the failure here is correct — and it's the condition
// that previously terminated importers via `process.exit`.
if (argvResult.ok && argvResult.path === sourceResult.path) {
main();
}
}
}
export {
auditPackage,
buildReport,
computeExitCode,
listShowcasePackageSlugs,
readManifest,
countFiles,
findExamplesSource,
resolveExamplesSource,
isProgrammerBug,
parseArgs,
anomalyMessage,
UnreadableDirError,
canonicalizeForIsMain,
BORN_IN_SHOWCASE,
SLUG_TO_EXAMPLES,
};
export type {
AuditReport,
PackageAudit,
AuditConfig,
Anomaly,
CountState,
ExamplesSourceResult,
Manifest,
ParsedManifest,
};