diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61b77f850..57e0e38d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,6 +308,9 @@ jobs: - name: Audit CI path filters run: npm run audit:ci-paths + - name: Audit declared facts + run: npm run audit:facts + - name: Audit agent surfaces run: npm run audit:agents diff --git a/AGENTS.md b/AGENTS.md index 7aef50bf7..0f0c9f24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This document provides an overview for AI agents working across the OpenIAP mono | Docs Patterns | [`knowledge/internal/05-docs-patterns.md`](knowledge/internal/05-docs-patterns.md) | | Git & Deployment | [`knowledge/internal/06-git-deployment.md`](knowledge/internal/06-git-deployment.md) | | Docs Consistency / SSOT | [`knowledge/internal/07-docs-consistency.md`](knowledge/internal/07-docs-consistency.md) (run `bun audit:docs` before pushing API/Type doc edits) | +| Fact Graph (Declared Facts) | [`knowledge/internal/08-fact-graph.md`](knowledge/internal/08-fact-graph.md) (run `bun audit:facts` after bumping a tool version or runner image) | ## Monorepo Structure @@ -370,3 +371,4 @@ All comprehensive rules are documented in [`knowledge/internal/`](knowledge/inte 5. **05-docs-patterns.md** - React modal patterns, component organization 6. **06-git-deployment.md** - Commit format, deployment workflows 7. **07-docs-consistency.md** - Docs/API/type consistency audits +8. **08-fact-graph.md** - Declared-fact registry and drift audit diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index 23cc2b3df..01c8dca15 100644 --- a/knowledge/_agent-context/context.md +++ b/knowledge/_agent-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated shared context for AI assistants** -> Last updated: 2026-08-18T17:50:47.669Z +> Last updated: 2026-08-20T15:02:26.238Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -2806,6 +2806,110 @@ bun run audit:docs Exit code 1 means at least one drift; 0 means clean. +--- + + + +# Fact Graph — Declared-Fact Consistency + +One cross-cutting scalar (a tool version, a runner image) gets declared in +many files. When someone bumps most of them, the leftover breaks — usually in +the one lane nobody runs until a release. This system makes that class of +drift fail CI instead. + +Real incidents this system would have caught (all shipped 2026-08-20): + +- `release-godot.yml` still on `macos-15` after six other release lanes moved + to `macos-26` — surfaced as a 9-minute runner wait during a live release. +- `Example/project.godot` declaring Godot 4.5 features while the Makefile and + every CI lane pinned 4.7.1 — the editor rewrote the file on every open. + +## Model + +`scripts/facts.mjs` is the registry. Each **fact** declares: + +- `values` — named roles for the values that may legitimately coexist + (`{ current: "4.7.1", minimum: "4.3" }`). One role means uniformity. +- `scanners` — regexes with one capture group, run over file sets. + +`scripts/audit-facts.mjs` enforces two rules: + +1. Every occurrence a scanner finds must be one of the declared values. +2. Every declared value must still occur somewhere. + +Rule 2 is what makes bumps atomic: change the registry and every stale +occurrence fails; change a file and the unregistered value fails. There is +deliberately **no per-site list** — an unlisted site cannot drift silently +because the scanner sees it anyway. + +`DERIVED` relations express one declaration computed from another +(`project.godot` features = major.minor of `godot.version.current`) instead of +duplicating the value. + +## Querying impact + +`bun run graph:impact ` answers "what does bumping this touch?" +before you start: every declaring file with line numbers, declarations derived +from the fact, and the CI jobs that run when those files change (via the same +path-filter model `audit-ci-path-filters` proves against CI). Read-only — +`--list` names the registered facts. + +## Authority direction + +The registry is authoritative; files follow it. When the audit fails, the fix +is to finish the bump — never to edit the registry to match a stray file +unless the stray file is the intended new value. + +## Boundaries (do not absorb these) + +| Domain | Owner | +| ----------------------------------- | --------------------------------------------- | +| Generated type files source→targets | `packages/gql/generated-sync-manifest.mjs` | +| Package/spec version floor | `openiap-versions.json` + release-state audit | +| API surface parity across languages | `scripts/audit-non-godot-parity.mjs` | +| Change→job routing | `scripts/audit-ci-path-filters.mjs` | + +The fact graph holds scalar declarations only, and it is deliberately +**additive**: it changes no existing guard. Where a parity-audit needle pins +the same scalar today, both guards run — they cannot contradict each other, +since both compare against the same files, but a bump touches both until the +consolidation phase below. Removing the single CI step disables the whole +system; nothing else depends on it. + +## Authoring rules + +- Anchor patterns to structural keys (`java-version:`), never bare numbers. +- A deliberately divergent value (Node 20 for builds, 24 for npm publish) is + either two roles in one fact or out of scope — never an unexplained skip. +- **Every new fact ships with a planted-violation test** in + `scripts/audit-facts.test.mjs`: edit a real file in memory, assert the audit + reports it. A guard that has never seen its bug fire is unverified + (the release-sync guard shipped broken exactly this way). + +## Limits + +Agreement is not correctness: `supported_platforms` was consistent across all +four copies and every copy was wrong, because Godot never read the key. The +fact graph catches drift between declarations; it cannot tell whether the +declaration means anything. Semantic validity stays with tests and e2e. + +Coverage is bounded by the scanners: a declaration in a file no scanner +reads, or in a shape no pattern captures, is invisible. "An unlisted site +cannot drift silently" holds within scanned files only — when a fact grows a +new home (a shell script embedding a version, a new manifest), extend the +scanner in the same change. + +## Roadmap + +1. **Done** — toolchain facts (Xcode, macOS image, JDK, Bun, Godot) plus the + Example-project derivation. +2. Consolidate: move parity-audit needles that assert scalar pins into the + registry, shrinking `audit-non-godot-parity.mjs` toward behavior-only + assertions. Opt-in, after the registry has caught real drift in practice. +3. Derive CI path-filter expectations from a package→path→job edge list + instead of asserting them post hoc. + + --- diff --git a/knowledge/internal/08-fact-graph.md b/knowledge/internal/08-fact-graph.md new file mode 100644 index 000000000..aba8c06ea --- /dev/null +++ b/knowledge/internal/08-fact-graph.md @@ -0,0 +1,98 @@ +# Fact Graph — Declared-Fact Consistency + +One cross-cutting scalar (a tool version, a runner image) gets declared in +many files. When someone bumps most of them, the leftover breaks — usually in +the one lane nobody runs until a release. This system makes that class of +drift fail CI instead. + +Real incidents this system would have caught (all shipped 2026-08-20): + +- `release-godot.yml` still on `macos-15` after six other release lanes moved + to `macos-26` — surfaced as a 9-minute runner wait during a live release. +- `Example/project.godot` declaring Godot 4.5 features while the Makefile and + every CI lane pinned 4.7.1 — the editor rewrote the file on every open. + +## Model + +`scripts/facts.mjs` is the registry. Each **fact** declares: + +- `values` — named roles for the values that may legitimately coexist + (`{ current: "4.7.1", minimum: "4.3" }`). One role means uniformity. +- `scanners` — regexes with one capture group, run over file sets. + +`scripts/audit-facts.mjs` enforces two rules: + +1. Every occurrence a scanner finds must be one of the declared values. +2. Every declared value must still occur somewhere. + +Rule 2 is what makes bumps atomic: change the registry and every stale +occurrence fails; change a file and the unregistered value fails. There is +deliberately **no per-site list** — an unlisted site cannot drift silently +because the scanner sees it anyway. + +`DERIVED` relations express one declaration computed from another +(`project.godot` features = major.minor of `godot.version.current`) instead of +duplicating the value. + +## Querying impact + +`bun run graph:impact ` answers "what does bumping this touch?" +before you start: every declaring file with line numbers, declarations derived +from the fact, and the CI jobs that run when those files change (via the same +path-filter model `audit-ci-path-filters` proves against CI). Read-only — +`--list` names the registered facts. + +## Authority direction + +The registry is authoritative; files follow it. When the audit fails, the fix +is to finish the bump — never to edit the registry to match a stray file +unless the stray file is the intended new value. + +## Boundaries (do not absorb these) + +| Domain | Owner | +| ----------------------------------- | --------------------------------------------- | +| Generated type files source→targets | `packages/gql/generated-sync-manifest.mjs` | +| Package/spec version floor | `openiap-versions.json` + release-state audit | +| API surface parity across languages | `scripts/audit-non-godot-parity.mjs` | +| Change→job routing | `scripts/audit-ci-path-filters.mjs` | + +The fact graph holds scalar declarations only, and it is deliberately +**additive**: it changes no existing guard. Where a parity-audit needle pins +the same scalar today, both guards run — they cannot contradict each other, +since both compare against the same files, but a bump touches both until the +consolidation phase below. Removing the single CI step disables the whole +system; nothing else depends on it. + +## Authoring rules + +- Anchor patterns to structural keys (`java-version:`), never bare numbers. +- A deliberately divergent value (Node 20 for builds, 24 for npm publish) is + either two roles in one fact or out of scope — never an unexplained skip. +- **Every new fact ships with a planted-violation test** in + `scripts/audit-facts.test.mjs`: edit a real file in memory, assert the audit + reports it. A guard that has never seen its bug fire is unverified + (the release-sync guard shipped broken exactly this way). + +## Limits + +Agreement is not correctness: `supported_platforms` was consistent across all +four copies and every copy was wrong, because Godot never read the key. The +fact graph catches drift between declarations; it cannot tell whether the +declaration means anything. Semantic validity stays with tests and e2e. + +Coverage is bounded by the scanners: a declaration in a file no scanner +reads, or in a shape no pattern captures, is invisible. "An unlisted site +cannot drift silently" holds within scanned files only — when a fact grows a +new home (a shell script embedding a version, a new manifest), extend the +scanner in the same change. + +## Roadmap + +1. **Done** — toolchain facts (Xcode, macOS image, JDK, Bun, Godot) plus the + Example-project derivation. +2. Consolidate: move parity-audit needles that assert scalar pins into the + registry, shrinking `audit-non-godot-parity.mjs` toward behavior-only + assertions. Opt-in, after the registry has caught real drift in practice. +3. Derive CI path-filter expectations from a package→path→job edge list + instead of asserting them post hoc. diff --git a/package.json b/package.json index c0c3efca6..adba724fb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "deploy": "./scripts/deploy.sh", "deploy:kit": "cd packages/kit && npx convex deploy", "prepare": "husky", - "audit:release-sync": "node --test scripts/audit-release-sync-script.test.mjs && node scripts/audit-release-sync-script.mjs" + "audit:release-sync": "node --test scripts/audit-release-sync-script.test.mjs && node scripts/audit-release-sync-script.mjs", + "audit:facts": "node --test scripts/audit-facts.test.mjs scripts/graph-impact.test.mjs && node scripts/audit-facts.mjs", + "graph:impact": "node scripts/graph-impact.mjs" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/scripts/audit-facts.mjs b/scripts/audit-facts.mjs new file mode 100644 index 000000000..d0047de1f --- /dev/null +++ b/scripts/audit-facts.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// Enforce the declared-fact registry in scripts/facts.mjs: every occurrence a +// scanner finds must be one of the fact's declared values, and every declared +// value must still occur — a bumped fact with stale occurrences fails, and so +// does a dead declaration. See knowledge/internal/08-fact-graph.md. + +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { FACTS, DERIVED } from "./facts.mjs"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const WORKFLOW_GLOB = "/*.{yml,yaml}"; + +function listRepoDir(dir) { + return readdirSync(join(REPO_ROOT, dir)); +} + +export function expandFiles(specs, listDir = listRepoDir) { + const files = []; + for (const spec of specs) { + if (!spec.endsWith(WORKFLOW_GLOB)) { + files.push(spec); + continue; + } + const dir = spec.slice(0, -WORKFLOW_GLOB.length); + for (const entry of listDir(dir)) { + if (entry.endsWith(".yml") || entry.endsWith(".yaml")) { + files.push(`${dir}/${entry}`); + } + } + } + return files; +} + +// Every occurrence of a fact's shape, as {file, line, value} — shared by the +// audit and the impact query so they can never disagree about what exists. +export function scanFact(fact, readFile) { + const occurrences = []; + const missing = []; + for (const scanner of fact.scanners) { + for (const file of expandFiles(scanner.files)) { + const text = readFile(file); + if (text === null) { + missing.push(file); + continue; + } + for (const match of text.matchAll(scanner.pattern)) { + occurrences.push({ + file, + line: text.slice(0, match.index).split("\n").length, + value: match[1], + }); + } + } + } + return { occurrences, missing }; +} + +export function auditFacts(readFile) { + const failures = []; + + for (const fact of FACTS) { + const allowed = new Map( + Object.entries(fact.values).map(([role, value]) => [value, role]), + ); + const seen = new Set(); + + const { occurrences, missing } = scanFact(fact, readFile); + for (const file of missing) { + failures.push(`${fact.key}: scanned file is missing: ${file}`); + } + for (const { file, line, value } of occurrences) { + if (!allowed.has(value)) { + failures.push( + `${fact.key}: ${file}:${line} declares "${value}" but the ` + + `registry allows ${JSON.stringify(fact.values)}`, + ); + } + seen.add(value); + } + + for (const [value, role] of allowed) { + if (!seen.has(value)) { + failures.push( + `${fact.key}: declared ${role}="${value}" no longer occurs anywhere — ` + + `update or remove it from scripts/facts.mjs`, + ); + } + } + } + + for (const relation of DERIVED) { + const fact = FACTS.find((entry) => entry.key === relation.from.fact); + const expected = relation.derive(fact.values[relation.from.value]); + const text = readFile(relation.file); + if (text === null) { + failures.push(`${relation.key}: file is missing: ${relation.file}`); + continue; + } + const match = relation.pattern.exec(text); + if (!match) { + failures.push( + `${relation.key}: ${relation.file} does not match ${relation.pattern}`, + ); + } else if (match[1] !== expected) { + failures.push( + `${relation.key}: ${relation.file} declares "${match[1]}" but ` + + `${relation.from.fact}.${relation.from.value} derives "${expected}"`, + ); + } + } + + return failures; +} + +export function readRepoFile(file) { + try { + return readFileSync(join(REPO_ROOT, file), "utf8"); + } catch { + return null; + } +} + +if (fileURLToPath(import.meta.url) === process.argv[1]) { + const failures = auditFacts(readRepoFile); + if (failures.length) { + console.error("Declared-fact audit failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); + } + console.log( + `Declared-fact audit passed (${FACTS.length} facts, ${DERIVED.length} derived).`, + ); +} diff --git a/scripts/audit-facts.test.mjs b/scripts/audit-facts.test.mjs new file mode 100644 index 000000000..8d5048fa8 --- /dev/null +++ b/scripts/audit-facts.test.mjs @@ -0,0 +1,101 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { auditFacts, expandFiles, readRepoFile } from "./audit-facts.mjs"; + +// Overlay one edited file on top of the real tree, so every planted violation +// is exercised against the actual registry and scanners. +function overlaying(file, edit) { + return (path) => { + const text = readRepoFile(path); + return path === file && text !== null ? edit(text) : text; + }; +} + +test("the committed tree passes", () => { + assert.deepEqual(auditFacts(readRepoFile), []); +}); + +test("catches a runner image left behind on a bump", () => { + // The exact drift shipped in release-godot.yml until 2026-08-20. + const failures = auditFacts( + overlaying(".github/workflows/release-godot.yml", (text) => + text.replace("runs-on: macos-26", "runs-on: macos-15"), + ), + ); + assert.equal(failures.length, 1); + assert.match( + failures[0], + /runner\.macos-image: .*release-godot.*"macos-15"/u, + ); +}); + +test("catches a stale Xcode pin", () => { + const failures = auditFacts( + overlaying(".github/workflows/codeql.yml", (text) => + text.replace("XCODE_VERSION: 26.6", "XCODE_VERSION: 16.4"), + ), + ); + assert.equal(failures.length, 1); + assert.match(failures[0], /toolchain\.xcode: .*"16\.4"/u); +}); + +test("catches a partial Godot bump as a third value", () => { + const failures = auditFacts( + overlaying("libraries/godot-iap/Makefile", (text) => + text.replace("GODOT_VERSION ?= 4.7.1", "GODOT_VERSION ?= 4.8.0"), + ), + ); + assert.ok( + failures.some((entry) => /godot\.version: .*"4\.8\.0"/u.test(entry)), + ); +}); + +test("catches the example project lagging the current editor", () => { + // The exact drift shipped in Example/project.godot until 2026-08-20. + const failures = auditFacts( + overlaying("libraries/godot-iap/Example/project.godot", (text) => + text.replace('PackedStringArray("4.7"', 'PackedStringArray("4.5"'), + ), + ); + assert.equal(failures.length, 1); + assert.match( + failures[0], + /godot\.example-features: .*"4\.5".*derives "4\.7"/u, + ); +}); + +test("catches a declared value that no longer occurs", () => { + // Erase every JDK declaration; the registry entry is then dead. + const failures = auditFacts((path) => { + const text = readRepoFile(path); + return text === null + ? null + : text.replace(/java-version:\s*["']?17["']?/g, ""); + }); + assert.ok( + failures.some((entry) => + /toolchain\.jdk: declared pinned="17" no longer occurs/u.test(entry), + ), + ); +}); + +test("the minimum and current Godot versions coexist without a finding", () => { + // 4.3-stable and 4.7.1-stable live in the same workflows by design. + const failures = auditFacts(readRepoFile).filter((entry) => + entry.startsWith("godot.version"), + ); + assert.deepEqual(failures, []); +}); + +test("the workflow glob sees .yaml files, which Actions also loads", () => { + const files = expandFiles([".github/workflows/*.{yml,yaml}"], () => [ + "ci.yml", + "sneaky.yaml", + "notes.md", + ]); + assert.deepEqual(files, [ + ".github/workflows/ci.yml", + ".github/workflows/sneaky.yaml", + ]); +}); diff --git a/scripts/facts.mjs b/scripts/facts.mjs new file mode 100644 index 000000000..75e0db974 --- /dev/null +++ b/scripts/facts.mjs @@ -0,0 +1,87 @@ +// Declared-fact registry: the single place a cross-cutting scalar (a tool +// version, a runner image) is written down. Scanners find every occurrence of +// the fact's shape; the audit requires each occurrence to be one of the +// declared values and each declared value to still occur somewhere. There is +// deliberately no per-site list to maintain — an unlisted site cannot drift +// silently, because the scanner sees it anyway. +// +// Authoring rules live in knowledge/internal/08-fact-graph.md. The short form: +// values here are authoritative; files follow. Multiple values in one fact +// mean deliberately coexisting roles (current vs minimum), not drift. + +// GitHub Actions loads both extensions, so the scanner must too. +const WORKFLOWS = ".github/workflows/*.{yml,yaml}"; + +export const FACTS = Object.freeze([ + { + key: "toolchain.xcode", + values: { pinned: "26.6" }, + scanners: [ + { + files: [WORKFLOWS], + pattern: /(?:XCODE_VERSION|xcode-version):\s*["']?([\d.]+)/g, + }, + ], + }, + { + key: "runner.macos-image", + values: { hosted: "macos-26" }, + scanners: [{ files: [WORKFLOWS], pattern: /\b(macos-\d+)\b/g }], + }, + { + key: "toolchain.jdk", + values: { pinned: "17" }, + scanners: [{ files: [WORKFLOWS], pattern: /java-version:\s*["']?(\d+)/g }], + }, + { + key: "toolchain.bun", + values: { pinned: "1.3.13" }, + scanners: [ + { files: [WORKFLOWS], pattern: /bun-version:\s*["']?([\d.]+)/g }, + { + files: ["package.json"], + pattern: /"packageManager":\s*"bun@([\d.]+)"/g, + }, + ], + }, + { + key: "godot.version", + // current builds the artifacts; minimum is the oldest supported editor. + values: { current: "4.7.1", minimum: "4.3" }, + scanners: [ + { + files: [WORKFLOWS, "libraries/godot-iap/Makefile"], + pattern: /\b(\d+\.\d+(?:\.\d+)?)-stable\b/g, + }, + { + files: [".github/workflows/ci-godot-iap.yml"], + pattern: /^\s*version:\s*([\d.]+)\s*$/gm, + }, + { + files: ["libraries/godot-iap/Makefile"], + pattern: /^GODOT_VERSION \?= ([\d.]+)$/gm, + }, + { + files: [ + WORKFLOWS, + "libraries/godot-iap/Makefile", + "libraries/godot-iap/addons/godot-iap/bin/godot_iap.gdextension", + "libraries/godot-iap/.claude/guides/03-ios-plugin.md", + ], + pattern: /compatibility_minimum = "([\d.]+)"/g, + }, + ], + }, +]); + +// Relations that derive one declaration from another instead of equating them. +export const DERIVED = Object.freeze([ + { + key: "godot.example-features", + // The example project's feature tag is the current editor's major.minor. + file: "libraries/godot-iap/Example/project.godot", + pattern: /config\/features=PackedStringArray\("([\d.]+)"/, + from: { fact: "godot.version", value: "current" }, + derive: (version) => version.split(".").slice(0, 2).join("."), + }, +]); diff --git a/scripts/graph-impact.mjs b/scripts/graph-impact.mjs new file mode 100644 index 000000000..21e134dcc --- /dev/null +++ b/scripts/graph-impact.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node +// Read-only impact query over the fact graph: given a fact key, report every +// file that declares it, every declaration derived from it, and the CI jobs +// that run when those files change. Sources are the fact registry +// (scripts/facts.mjs) and the path-filter model that audit-ci-path-filters +// already proves against CI — this tool writes nothing and asserts nothing. +// +// bun run graph:impact godot.version +// bun run graph:impact --list + +import { fileURLToPath } from "node:url"; + +import { FACTS, DERIVED } from "./facts.mjs"; +import { scanFact, readRepoFile } from "./audit-facts.mjs"; +import { selectJobs } from "./audit-ci-path-filters.mjs"; + +export function impact(key, readFile = readRepoFile) { + const fact = FACTS.find((entry) => entry.key === key); + if (!fact) return null; + + const { occurrences } = scanFact(fact, readFile); + + const derived = DERIVED.filter((entry) => entry.from.fact === key).map( + (entry) => ({ + key: entry.key, + file: entry.file, + value: entry.derive(fact.values[entry.from.value]), + }), + ); + + const files = [ + ...new Set([ + ...occurrences.map((entry) => entry.file), + ...derived.map((entry) => entry.file), + ]), + ].sort(); + + return { fact, occurrences, derived, files, jobs: selectJobs(files) }; +} + +function render(result) { + const { fact, occurrences, derived, files, jobs } = result; + const roles = Object.entries(fact.values) + .map(([role, value]) => `${role}=${value}`) + .join(", "); + const lines = [`${fact.key} (${roles})`, "", "Declarations:"]; + + const byFile = new Map(); + for (const entry of occurrences) { + if (!byFile.has(entry.file)) byFile.set(entry.file, []); + byFile.get(entry.file).push(`${entry.line}:${entry.value}`); + } + for (const [file, hits] of [...byFile].sort()) { + lines.push(` ${file} (${hits.join(", ")})`); + } + + if (derived.length) { + lines.push("", "Derived:"); + for (const entry of derived) { + lines.push(` ${entry.file} -> "${entry.value}" (${entry.key})`); + } + } + + lines.push( + "", + `Bump checklist: edit scripts/facts.mjs, then every file above (${files.length}), then run:`, + " bun run audit:facts", + "", + `CI jobs that run on these files (${jobs.length}):`, + ); + for (const job of jobs) lines.push(` ${job}`); + return lines.join("\n"); +} + +if (fileURLToPath(import.meta.url) === process.argv[1]) { + const key = process.argv[2]; + + if (!key || key === "--list") { + console.log("Facts:"); + for (const fact of FACTS) { + const roles = Object.entries(fact.values) + .map(([role, value]) => `${role}=${value}`) + .join(", "); + console.log(` ${fact.key} (${roles})`); + } + process.exit(key ? 0 : 1); + } + + const result = impact(key); + if (!result) { + console.error(`Unknown fact: ${key}`); + console.error(`Known: ${FACTS.map((entry) => entry.key).join(", ")}`); + process.exit(1); + } + console.log(render(result)); +} diff --git a/scripts/graph-impact.test.mjs b/scripts/graph-impact.test.mjs new file mode 100644 index 000000000..87802f4bd --- /dev/null +++ b/scripts/graph-impact.test.mjs @@ -0,0 +1,30 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { impact } from "./graph-impact.mjs"; +import { FACTS } from "./facts.mjs"; + +test("godot.version impact spans declarations, derivation, and CI jobs", () => { + const result = impact("godot.version"); + assert.ok(result.files.includes("libraries/godot-iap/Makefile")); + assert.ok(result.files.includes("libraries/godot-iap/Example/project.godot")); + assert.deepEqual(result.derived, [ + { + key: "godot.example-features", + file: "libraries/godot-iap/Example/project.godot", + value: "4.7", + }, + ]); + assert.ok(result.jobs.length > 0); +}); + +test("an unknown fact returns null instead of an empty impact", () => { + assert.equal(impact("toolchain.unknown"), null); +}); + +test("every registered fact resolves to at least one declaration", () => { + for (const fact of FACTS) { + const result = impact(fact.key); + assert.ok(result.occurrences.length > 0, `${fact.key} has no declarations`); + } +});