diff --git a/tools/substrate-claim-checker/README.md b/tools/substrate-claim-checker/README.md index 4eb252260..2cca76553 100644 --- a/tools/substrate-claim-checker/README.md +++ b/tools/substrate-claim-checker/README.md @@ -1,11 +1,19 @@ # substrate-claim-checker -V0 of the substrate-claim-checker per the verify-then-claim discipline -memo (`memory/feedback_verify_then_claim_discipline_dominant_failure_mode_substrate_authoring_otto_2026_05_03.md`). +Substrate-claim-checker per the verify-then-claim discipline memo +(`memory/feedback_verify_then_claim_discipline_dominant_failure_mode_substrate_authoring_otto_2026_05_03.md`). -Catches one class of drift today: **count drift** between narrative -claims (e.g. "18+ drift instances", "13-row table", "5 procedure -skills") and the actual count of structured rows the claims reference. +Catches two of the seven sub-classes B-0170 names: + +- **Count drift** (v0.4.4) — between narrative claims (e.g. "18+ drift + instances", "13-row table", "5 procedure skills") and the actual + count of structured rows the claims reference. Implemented in + `check-counts.ts`. +- **Existence drift** (v0.5) — claims that a file or directory exists + when it doesn't on disk. Implemented in `check-existence.ts`. + +The remaining 5 sub-classes (semantic-equivalence, empirical-output, +convention, path-form, self-recursive) are deferred to v0.6+. ## Usage @@ -85,3 +93,57 @@ Per the verify-then-claim memo's mechanization-path section: These will land in subsequent PRs once the tool's check-types are mature enough to trust as gates. + +## v0.5 — `check-existence.ts` (existence drift) + +The `check-existence.ts` tool is the second sub-class checker, covering the **existence drift** sub-class (per the verify-then-claim memo's 7-class taxonomy). + +### What it catches + +Claims that a file or directory exists when it doesn't: + +- Backtick-quoted paths: `` `path/to/X.md` `` +- Markdown link targets: `[text](relative/path)` — relative paths only + +### Resolution order + +For each path claim, tries three candidate roots: + +1. The file's own directory (cross-references within the same dir) +2. The parent directory (bare-filename references for files in subdirs) +3. The repository root (repo-relative paths) + +Stops on first hit. Emits a finding only if NO candidate root resolves. + +### Future-state context detection + +Claims marked future-state are exempt: + +- `(proposed)`, `(planned)`, `(future)`, `(would be)`, `(not yet)`, `(tbd)`, `(deferred)`, `(pending)` +- Phrasings: "would be", "will be", "to be authored", "not yet exists", "doesn't yet exist", "future-state", "row deliverable", "I'm guessing", "concretely something like", "will probably", "lower confidence" + +Detected within the line + ±1 line context window. + +### Skipped automatically + +- Glob patterns (`*`, `?`, `[...]`) — not real paths +- URLs (http://, https://, mailto:) — not file-system +- Anchor-only links (`#section`) — same-page anchors +- Absolute paths (`/etc/...`) — system paths, out of repo scope +- Short strings (<3 chars) — unlikely to be paths +- Placeholders (`<...>`, `{...}`, `XXX`) +- Fenced code blocks — example paths in code shouldn't false-positive + +### Known limitations (v0.5) + +- Calibration-delta tables that cite path-forms as discussion topics (not claims of existence) may false-positive. Mitigated by future-marker context detection but imperfect. +- Section-level future-state markers (e.g., a section header `## (Proposed) X`) don't propagate to claims further down. Use inline markers per claim or per paragraph. + +### Usage + +``` +bun tools/substrate-claim-checker/check-existence.ts +bun tools/substrate-claim-checker/check-existence.ts ... +``` + +Exit codes match check-counts.ts: `0` clean, `1` drift detected or input error. diff --git a/tools/substrate-claim-checker/check-existence.test.ts b/tools/substrate-claim-checker/check-existence.test.ts new file mode 100644 index 000000000..8d76336b9 --- /dev/null +++ b/tools/substrate-claim-checker/check-existence.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findPathClaims, looksLikePath, isFutureStateContext, checkFile } from "./check-existence.ts"; + +describe("looksLikePath", () => { + test("recognizes relative paths", () => { + expect(looksLikePath("docs/research/foo.md")).toBe(true); + expect(looksLikePath("./tools/setup")).toBe(true); + expect(looksLikePath("../foo.md")).toBe(true); + }); + + test("rejects URLs and anchors", () => { + expect(looksLikePath("https://example.com")).toBe(false); + expect(looksLikePath("#section")).toBe(false); + expect(looksLikePath("mailto:x@y")).toBe(false); + }); + + test("rejects placeholders", () => { + expect(looksLikePath("path//foo")).toBe(false); + expect(looksLikePath("foo/{name}/bar")).toBe(false); + expect(looksLikePath("XXX")).toBe(false); // bare placeholder + expect(looksLikePath("TBD")).toBe(false); // bare placeholder + }); + + test("accepts legitimate filenames containing placeholder words", () => { + // path/XXX or docs/TODO.md should NOT be rejected by the placeholder + // filter; only WHOLE-STRING placeholders are rejected. Reviewer-flagged + // false-positive class on PR #1298. + expect(looksLikePath("docs/TODO.md")).toBe(true); + expect(looksLikePath("notes/tbd-changes.md")).toBe(true); + }); + + test("rejects strings with spaces", () => { + expect(looksLikePath("foo bar")).toBe(false); + }); + + test("rejects absolute paths", () => { + expect(looksLikePath("/etc/hosts")).toBe(false); + }); + + test("rejects too-short", () => { + expect(looksLikePath("ab")).toBe(false); + }); + + test("recognizes file-with-extension only", () => { + expect(looksLikePath("foo.md")).toBe(true); + expect(looksLikePath("config.json")).toBe(true); + }); +}); + +describe("isFutureStateContext", () => { + test("detects (proposed) marker", () => { + expect(isFutureStateContext("foo `bar.md` *(proposed)*", "", "")).toBe(true); + }); + + test("detects 'would be'", () => { + expect(isFutureStateContext("the file would be at `bar.md`", "", "")).toBe(true); + }); + + test("detects 'not yet exists'", () => { + expect(isFutureStateContext("`bar.md` not yet exists", "", "")).toBe(true); + }); + + test("rejects current-state claim", () => { + expect(isFutureStateContext("the file `bar.md` contains X", "", "")).toBe(false); + }); + + test("checks neighboring lines", () => { + expect(isFutureStateContext("`bar.md`", "(proposed)", "")).toBe(true); + }); +}); + +describe("findPathClaims", () => { + test("finds backtick-quoted paths", () => { + const lines = ["See `docs/research/foo.md` for details."]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(1); + expect(claims[0]?.path).toBe("docs/research/foo.md"); + expect(claims[0]?.line).toBe(1); + }); + + test("finds markdown link targets", () => { + const lines = ["See [the doc](docs/research/foo.md) for details."]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(1); + expect(claims[0]?.path).toBe("docs/research/foo.md"); + }); + + test("skips fenced code blocks", () => { + const lines = [ + "Real path: `docs/foo.md`", + "```bash", + "ls `path/inside/fence.md`", + "```", + "After: `docs/bar.md`", + ]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(2); + expect(claims.map((c) => c.path).sort()).toEqual(["docs/bar.md", "docs/foo.md"]); + }); + + test("skips URLs in markdown links", () => { + const lines = ["[link](https://example.com/foo)"]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(0); + }); + + test("strips anchor from link target", () => { + const lines = ["[link](docs/foo.md#section)"]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(1); + expect(claims[0]?.path).toBe("docs/foo.md"); + }); +}); + +describe("checkFile", () => { + test("reports drift for a missing path claim", () => { + const dir = mkdtempSync(join(tmpdir(), "check-existence-")); + const tmpFile = join(dir, "test.md"); + try { + writeFileSync( + tmpFile, + `# Test\n\nSee \`docs/this/path/does/not/exist/${Date.now()}.md\` for details.\n`, + ); + const result = checkFile(tmpFile); + expect(result.ok).toBe(true); + expect(result.findings.length).toBeGreaterThan(0); + expect(result.findings[0]?.pathClaim).toContain("does/not/exist"); + } finally { + try { unlinkSync(tmpFile); } catch {} + try { rmdirSync(dir); } catch {} + } + }); + + test("returns ok=false for missing input file", () => { + const dir = mkdtempSync(join(tmpdir(), "check-existence-")); + try { + const result = checkFile(join(dir, "nonexistent.md")); + expect(result.ok).toBe(false); + expect(result.findings).toEqual([]); + } finally { + try { rmdirSync(dir); } catch {} + } + }); + + test("returns ok=false for input that is a directory", () => { + const dir = mkdtempSync(join(tmpdir(), "check-existence-")); + try { + const result = checkFile(dir); + expect(result.ok).toBe(false); + expect(result.findings).toEqual([]); + } finally { + try { rmdirSync(dir); } catch {} + } + }); + + test("clean file produces no findings", () => { + const dir = mkdtempSync(join(tmpdir(), "check-existence-")); + const tmpFile = join(dir, "clean.md"); + try { + writeFileSync(tmpFile, "# Test\n\nA simple memo with no path claims at all.\n"); + const result = checkFile(tmpFile); + expect(result.ok).toBe(true); + expect(result.findings).toEqual([]); + } finally { + try { unlinkSync(tmpFile); } catch {} + try { rmdirSync(dir); } catch {} + } + }); + + test("future-state context exempts the claim", () => { + const dir = mkdtempSync(join(tmpdir(), "check-existence-")); + const tmpFile = join(dir, "future.md"); + try { + writeFileSync( + tmpFile, + `# Test\n\nThe \`docs/proposed-path-${Date.now()}.md\` file would be created when this lands.\n`, + ); + const result = checkFile(tmpFile); + expect(result.ok).toBe(true); + expect(result.findings).toEqual([]); + } finally { + try { unlinkSync(tmpFile); } catch {} + try { rmdirSync(dir); } catch {} + } + }); +}); + +describe("looksLikePath - version-number rejection", () => { + test("rejects version numbers", () => { + expect(looksLikePath("v0.69.4")).toBe(false); + expect(looksLikePath("10.0.203")).toBe(false); + expect(looksLikePath("1.2.3")).toBe(false); + expect(looksLikePath("1.2.3-rc1")).toBe(false); + }); + + test("accepts known-extension paths even without slash", () => { + expect(looksLikePath("config.toml")).toBe(true); + expect(looksLikePath("README.md")).toBe(true); + expect(looksLikePath("script.sh")).toBe(true); + }); + + test("rejects unknown-extension single-component", () => { + expect(looksLikePath("foo.zzzz")).toBe(false); + expect(looksLikePath("file.unknownext")).toBe(false); + }); +}); + +describe("looksLikePath - cross-platform absolute paths", () => { + test("rejects POSIX absolute", () => { + expect(looksLikePath("/etc/hosts")).toBe(false); + expect(looksLikePath("/usr/local/bin/foo")).toBe(false); + }); + + test("rejects Windows drive paths even on POSIX", () => { + // path.isAbsolute returns false for these on POSIX, so we need + // explicit regex checks. + expect(looksLikePath("C:\\Windows\\System32\\foo.dll")).toBe(false); + expect(looksLikePath("D:/Users/foo/bar.txt")).toBe(false); + expect(looksLikePath("c:/lower/case/drive.md")).toBe(false); + }); + + test("rejects Windows UNC paths", () => { + expect(looksLikePath("\\\\server\\share\\foo.md")).toBe(false); + }); +}); + +describe("findPathClaims - angle-bracket link targets", () => { + test("strips angle-brackets from link target", () => { + const lines = ["See [spec]() for details."]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(1); + expect(claims[0]?.path).toBe("docs/foo.md"); + }); + + test("handles angle-brackets with anchors", () => { + const lines = ["See [spec]()"]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(1); + expect(claims[0]?.path).toBe("docs/foo.md"); + }); + + test("regular links still work alongside angle-bracket variant", () => { + const lines = [ + "[a](docs/normal.md) and [b]()", + ]; + const claims = findPathClaims(lines); + expect(claims).toHaveLength(2); + expect(claims.map((c) => c.path).sort()).toEqual(["docs/angled.md", "docs/normal.md"]); + }); +}); diff --git a/tools/substrate-claim-checker/check-existence.ts b/tools/substrate-claim-checker/check-existence.ts new file mode 100644 index 000000000..d841468e7 --- /dev/null +++ b/tools/substrate-claim-checker/check-existence.ts @@ -0,0 +1,326 @@ +#!/usr/bin/env bun +/** + * substrate-claim-checker / check-existence.ts (v0.5.0) + * + * Existence-drift sub-class checker — catches claims that a file or + * directory exists when it doesn't. Per the verify-then-claim memo, + * one of 7 sub-classes B-0170 v1+ should mechanize. + */ + +import { readFileSync, statSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +interface Finding { + file: string; + line: number; + pathClaim: string; + reason: string; +} + +interface PathClaim { + line: number; + path: string; + raw: string; +} + +function statExists(p: string): { + exists: boolean; + isFile: boolean; + isDirectory: boolean; + errorCode?: string; +} { + try { + const s = statSync(p); + return { exists: true, isFile: s.isFile(), isDirectory: s.isDirectory() }; + } catch (e: unknown) { + const err = e as NodeJS.ErrnoException; + // ENOENT means definitively not present. Other errors (EACCES, + // EPERM, ELOOP, ENAMETOOLONG, ...) mean we couldn't tell — surface + // the error code so the caller can avoid emitting a false-positive + // existence-drift finding for unreadable-but-extant paths. + if (err.code === "ENOENT") { + return { exists: false, isFile: false, isDirectory: false }; + } + return { + exists: false, + isFile: false, + isDirectory: false, + errorCode: err.code ?? "EUNKNOWN", + }; + } +} + +function findRepoRoot(startPath: string): string { + let cur = isAbsolute(startPath) ? startPath : resolve(startPath); + if (statExists(cur).isFile) cur = dirname(cur); + while (cur !== "/" && cur !== "") { + const gitPath = join(cur, ".git"); + if (statExists(gitPath).exists) return cur; + const parent = dirname(cur); + if (parent === cur) break; + cur = parent; + } + return process.cwd(); +} + +function looksLikePath(s: string): boolean { + if (s.includes(" ")) return false; + if (/<[^>]+>/.test(s)) return false; + if (/\{[^}]+\}/.test(s)) return false; + // Reject only when the WHOLE string is a placeholder token (so + // legitimate filenames like docs/TODO.md or notes/tbd-changes.md + // still get checked). + if (/^(XXX+|YYY+|TODO|TBD)$/i.test(s)) return false; + if (s.length < 3) return false; + if (s.startsWith("http://") || s.startsWith("https://")) return false; + if (s.startsWith("#")) return false; + if (s.startsWith("mailto:")) return false; + // Reject absolute paths in any platform notation. `path.isAbsolute()` + // is platform-specific (returns false for `C:\foo` on POSIX), so we + // add explicit cross-platform regex checks for Windows drive paths + // and UNC paths. + if (isAbsolute(s)) return false; + if (/^[A-Za-z]:[\\/]/.test(s)) return false; // Windows drive (C:\, C:/) + if (/^\\\\/.test(s)) return false; // Windows UNC (\\server\share) + if (/^\$/.test(s)) return false; + // Reject version-number-shaped strings (e.g. v0.69.4, 10.0.203, + // 1.2.3-rc1) — these have dot-separated parts that look like file + // extensions but are NOT paths. Require either at least one path + // separator, OR a known doc/code/config extension. Drops the loose + // `\.[a-z0-9]{1,5}$` extension match that produced false-positives + // on PR #1298 review (e.g., `v0.69.4` was treated as a path). + const knownExt = + /\.(md|markdown|ts|tsx|js|jsx|fs|fsi|fsx|fsproj|csproj|sln|sh|bash|zsh|yaml|yml|json|toml|tla|alloy|lean|lean4|py|rs|go|java|kt|kts|c|h|cpp|cc|hpp|fish|gradle|conf|ini|cfg|env|html|css|scss|less|sql|graphql|proto)$/i; + if (/^\.{0,2}\//.test(s)) return true; + if (s.includes("/") && !/^\d+(\.\d+)+(-[\w.]+)?$/.test(s)) return true; + if (knownExt.test(s)) return true; + return false; +} + +function isFutureStateContext(line: string, contextBefore: string, contextAfter: string): boolean { + const window = `${contextBefore} ${line} ${contextAfter}`.toLowerCase(); + const futureMarkers = [ + "(proposed)", "(planned)", "(future)", "(would be)", "(not yet)", + "(tbd)", "(deferred)", "(pending)", + "would be", "will be", "to be authored", "to be built", + "not yet exists", "not yet built", "not yet shipped", "not yet implemented", + "doesn't yet exist", "does not yet exist", "doesn't exist yet", + "future-state", "row deliverable", + "i'm guessing", "guessing the path", + "concretely something like", "something like", + "do not yet exist", "does not exist yet", + "will probably", "would probably", + "lower confidence", "low confidence", + "**later**:", "**soon**:", + "optionally mechanize", "eventual mechanization", + "mechanization path", "future mechanization", + "could become", "would become", "may need", + "if implemented", "when implemented", + "**proposed**", "proposed, not yet", + ]; + for (const marker of futureMarkers) { + if (window.includes(marker)) return true; + } + return false; +} + +function findPathClaims(lines: string[]): PathClaim[] { + const claims: PathClaim[] = []; + let inFence = false; + let fenceChar = ""; + let fenceLen = 0; + const backtickRe = /`([^`\n]+?)`/g; + // Markdown link target regex. Supports balanced parens inside the + // target (so `[text](docs/api(v2).md)` captures `docs/api(v2).md`). + // The target is one or more groups of either non-paren chars or + // a balanced (...) pair (one level of nesting; two-level nesting is + // out of scope for v0.5). + const linkRe = /\[([^\]\n]+?)\]\(((?:[^()\n]|\([^()\n]*\))+)\)/g; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/); + if (fenceMatch) { + const delim = fenceMatch[2] ?? ""; + const tail = fenceMatch[3] ?? ""; + if (!inFence) { + inFence = true; + fenceChar = delim[0] ?? ""; + fenceLen = delim.length; + } else if (delim[0] === fenceChar && delim.length >= fenceLen && tail.trim() === "") { + inFence = false; + fenceChar = ""; + fenceLen = 0; + } + continue; + } + if (inFence) continue; + + backtickRe.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = backtickRe.exec(line)) !== null) { + const candidate = m[1] ?? ""; + if (looksLikePath(candidate)) { + claims.push({ line: i + 1, path: candidate, raw: m[0] }); + } + } + + linkRe.lastIndex = 0; + while ((m = linkRe.exec(line)) !== null) { + let target = m[2] ?? ""; + // CommonMark allows angle-bracket-wrapped link destinations + // (e.g. `[spec]()`). Strip the brackets so + // the target shape matches the rest of the pipeline. + if (target.startsWith("<") && target.endsWith(">")) { + target = target.slice(1, -1); + } + // Strip URL anchor (#...) and query string (?...) BEFORE + // path-shape validation. Otherwise the validation can pass on + // the dirty target but produce a different (cleaned) string + // for path-resolution, leading to inconsistent state. + const cleanTarget = target.split("#")[0]?.split("?")[0] ?? ""; + if (cleanTarget && looksLikePath(cleanTarget)) { + claims.push({ line: i + 1, path: cleanTarget, raw: m[0] }); + } + } + } + return claims; +} + +interface CheckResult { + findings: Finding[]; + ok: boolean; +} + +function checkFile(filePath: string): CheckResult { + let content: string; + try { + content = readFileSync(filePath, "utf8"); + } catch (e: unknown) { + const err = e as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + console.error(`error: input file not found: ${filePath}`); + } else if (err.code === "EISDIR") { + console.error(`error: not a regular file (directory): ${filePath}`); + } else { + const msg = err instanceof Error ? err.message : String(err); + console.error(`error: read failed for ${filePath}: ${msg}`); + } + return { findings: [], ok: false }; + } + + const lines = content.split("\n"); + const repoRoot = findRepoRoot(filePath); + const claims = findPathClaims(lines); + const findings: Finding[] = []; + + // Skip glob patterns — they're not real paths + const isGlob = (p: string) => /[*?]/.test(p) || /\[.+\]/.test(p); + + // Three candidate roots, tried in priority order: + // 1. File's own directory (cross-references within the same dir) + // 2. Parent directory (bare-filename references when file is in a subdir + // like memory/architectural-intent-guesses/) + // 3. Repository root (repo-relative paths) + // Each candidate root MUST be inside repoRoot (security: don't probe + // /etc/, /tmp/, or other system paths if a malicious claim escapes). + // Stops on first hit. Reports relative paths (relative to repoRoot) + // in finding reasons so logs don't leak local/CI absolute paths. + const fileDir = isAbsolute(filePath) ? dirname(filePath) : resolve(dirname(filePath)); + const fileParentDir = dirname(fileDir); + const allCandidates = [fileDir, fileParentDir, repoRoot]; + + // Cross-platform containment: a path P is inside repoRoot iff + // path.relative(repoRoot, P) returns a string that doesn't start with + // ".." or hit the absolute-root case. Works on POSIX (sep="/") and + // Windows (sep="\") because relative() handles separators per-platform. + const isInsideRepo = (p: string): boolean => { + if (p === repoRoot) return true; + const rel = relative(repoRoot, p); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); + }; + + // Filter to candidates inside repoRoot. + const candidateRoots = allCandidates.filter(isInsideRepo); + + const toRelative = (absPath: string): string => { + if (absPath === repoRoot) return "."; + const rel = relative(repoRoot, absPath); + return rel === "" ? "." : rel; + }; + + for (const claim of claims) { + if (isGlob(claim.path)) continue; // skip glob patterns + + const lineText = lines[claim.line - 1] ?? ""; + const before = lines[claim.line - 2] ?? ""; + const after = lines[claim.line] ?? ""; + if (isFutureStateContext(lineText, before, after)) continue; + + let isResolvedClaim = false; + let unreadableButExtant = false; // EACCES/EPERM/etc. — can't tell + const triedRelative: string[] = []; + for (const root of candidateRoots) { + const absPath = isAbsolute(claim.path) ? claim.path : join(root, claim.path); + // Reject claims that resolve outside repo (security: don't traverse out) + if (!isInsideRepo(absPath)) continue; + triedRelative.push(toRelative(absPath)); + const stat = statExists(absPath); + if (stat.exists) { + isResolvedClaim = true; + break; + } + if (stat.errorCode && stat.errorCode !== "ENOENT") { + // Path may exist but is unreadable — don't emit false-positive + unreadableButExtant = true; + } + } + if (!isResolvedClaim && !unreadableButExtant) { + findings.push({ + file: filePath, + line: claim.line, + pathClaim: claim.path, + reason: `path does not exist (tried relative-to-repo: ${triedRelative.join(", ")})`, + }); + } + } + return { findings, ok: true }; +} + +export { findPathClaims, looksLikePath, isFutureStateContext, checkFile }; +export type { Finding, PathClaim }; + +export function main(): number { + const args = process.argv.slice(2); + if (args.length === 0) { + console.error("usage: bun tools/substrate-claim-checker/check-existence.ts [ ...]"); + return 1; + } + let totalFindings = 0; + let inputErrors = 0; + for (const arg of args) { + const { findings, ok } = checkFile(arg); + if (!ok) { + inputErrors++; + continue; + } + for (const f of findings) { + console.log(`${f.file}:${f.line}: existence drift — claim "${f.pathClaim}" — ${f.reason}`); + totalFindings++; + } + } + if (inputErrors > 0) { + console.error(`\n${inputErrors} input error(s).`); + return 1; + } + if (totalFindings > 0) { + console.log(`\n${totalFindings} existence-drift finding(s).`); + return 1; + } + console.log("no existence drift detected."); + return 0; +} + +if (import.meta.main) { + process.exit(main()); +}