From 05580a577a78ac00fb424c2073c3d67b6864ec8a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 23:31:42 +0900 Subject: [PATCH 1/5] feat: add the declared-fact registry and drift audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five toolchain facts (Xcode, macOS runner image, JDK, Bun, Godot) were each declared in 4-16 files, and every incident on 2026-08-20 was one of those copies lagging a bump: release-godot.yml alone on macos-15, the Godot example project alone on 4.5. scripts/facts.mjs now declares each fact once with named roles (current vs minimum lets 4.7.1-stable and 4.3-stable coexist), and scripts/audit-facts.mjs scans for every occurrence rather than enumerating sites — an unlisted site cannot drift silently, a bumped registry fails every stale copy, and a dead declared value fails too. project.godot's feature tag is derived from the current Godot version instead of being pinned separately. Every fact ships with a planted-violation test, including replays of both incidents; the parity audit's twelve scalar pin needles move out so each fact has one owner. Model, authority direction, boundaries, and limits (agreement is not correctness — supported_platforms was consistent and wrong) are documented in knowledge/internal/08-fact-graph.md. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 3 + AGENTS.md | 2 + knowledge/_agent-context/context.md | 87 ++++++++++++++++++++- knowledge/internal/08-fact-graph.md | 79 ++++++++++++++++++++ package.json | 3 +- scripts/audit-facts.mjs | 112 ++++++++++++++++++++++++++++ scripts/audit-facts.test.mjs | 89 ++++++++++++++++++++++ scripts/audit-non-godot-parity.mjs | 12 --- scripts/facts.mjs | 86 +++++++++++++++++++++ 9 files changed, 459 insertions(+), 14 deletions(-) create mode 100644 knowledge/internal/08-fact-graph.md create mode 100644 scripts/audit-facts.mjs create mode 100644 scripts/audit-facts.test.mjs create mode 100644 scripts/facts.mjs 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..f8ebc7594 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-20T14:31:29.297Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -2806,6 +2806,91 @@ 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. + +## 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. A fact pinned here must not +also be pinned as a parity-audit needle — one owner per fact. + +## 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. + +## Roadmap + +1. **Done** — toolchain facts (Xcode, macOS image, JDK, Bun, Godot) plus the + Example-project derivation. +2. Absorb parity-audit needles that assert scalar pins, shrinking + `audit-non-godot-parity.mjs` toward behavior-only assertions. +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..f1e3d5177 --- /dev/null +++ b/knowledge/internal/08-fact-graph.md @@ -0,0 +1,79 @@ +# 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. + +## 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. A fact pinned here must not +also be pinned as a parity-audit needle — one owner per fact. + +## 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. + +## Roadmap + +1. **Done** — toolchain facts (Xcode, macOS image, JDK, Bun, Godot) plus the + Example-project derivation. +2. Absorb parity-audit needles that assert scalar pins, shrinking + `audit-non-godot-parity.mjs` toward behavior-only assertions. +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..3c54f2e58 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "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 && node scripts/audit-facts.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..0a872742e --- /dev/null +++ b/scripts/audit-facts.mjs @@ -0,0 +1,112 @@ +#!/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)), ".."); + +function expandFiles(specs) { + const files = []; + for (const spec of specs) { + if (!spec.endsWith("/*.yml")) { + files.push(spec); + continue; + } + const dir = spec.slice(0, -"/*.yml".length); + for (const entry of readdirSync(join(REPO_ROOT, dir))) { + if (entry.endsWith(".yml")) files.push(`${dir}/${entry}`); + } + } + return files; +} + +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(); + + for (const scanner of fact.scanners) { + for (const file of expandFiles(scanner.files)) { + const text = readFile(file); + if (text === null) { + failures.push(`${fact.key}: scanned file is missing: ${file}`); + continue; + } + for (const match of text.matchAll(scanner.pattern)) { + const value = match[1]; + if (!allowed.has(value)) { + const line = text.slice(0, match.index).split("\n").length; + 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..a5ed3a975 --- /dev/null +++ b/scripts/audit-facts.test.mjs @@ -0,0 +1,89 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { auditFacts, 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, []); +}); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 0d3c9e546..51a83c4d1 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -6029,8 +6029,6 @@ function checkFrameworkDependencyHygiene() { expectIncludes( ".github/workflows/ci.yml", [ - "runs-on: macos-26", - "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6043,8 +6041,6 @@ function checkFrameworkDependencyHygiene() { expectIncludes( xcodeReleaseWorkflow, [ - "runs-on: macos-26", - "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6060,8 +6056,6 @@ function checkFrameworkDependencyHygiene() { ".github/workflows/release-expo.yml", [ "Expo SDK 57's expo-modules-jsi package declares Swift tools 6.2", - "runs-on: macos-26", - 'XCODE_VERSION: "26.6"', "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6076,8 +6070,6 @@ function checkFrameworkDependencyHygiene() { ".github/workflows/ci-maui-iap.yml", [ "app-store-artifact:", - "runs-on: macos-26", - 'APP_STORE_XCODE_VERSION: "26.6"', 'APP_STORE_SDK_VERSION: "26.5"', 'APP_STORE_LD_VERSION: "1267.0"', "maxim-lobanov/setup-xcode@", @@ -6090,8 +6082,6 @@ function checkFrameworkDependencyHygiene() { expectIncludes( ".github/workflows/release-maui.yml", [ - "runs-on: macos-26", - 'APP_STORE_XCODE_VERSION: "26.6"', 'APP_STORE_SDK_VERSION: "26.5"', 'APP_STORE_LD_VERSION: "1267.0"', "maxim-lobanov/setup-xcode@", @@ -8984,8 +8974,6 @@ function checkXcode27StoreKitCoverage() { "openiap-versions.json", '".github/workflows/release-flutter.yml"', "apple-cocoapods:", - "runs-on: macos-26", - "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], diff --git a/scripts/facts.mjs b/scripts/facts.mjs new file mode 100644 index 000000000..a7b302aa4 --- /dev/null +++ b/scripts/facts.mjs @@ -0,0 +1,86 @@ +// 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. + +const WORKFLOWS = ".github/workflows/*.yml"; + +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("."), + }, +]); From 39f5e0b642b99e16755721492411e33088225db1 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 23:38:26 +0900 Subject: [PATCH 2/5] refactor: make the fact graph purely additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hyo asked for the introduction to carry no side effects on existing systems, so the parity audit keeps its twelve scalar pin needles and both guards run side by side — they compare against the same files, so they cannot disagree, at the cost of one extra touchpoint per bump until the opt-in consolidation phase. The whole system now disables by removing a single CI step: everything else is new files and additive doc rows. Co-Authored-By: Claude Opus 5 --- knowledge/_agent-context/context.md | 15 ++++++++++----- knowledge/internal/08-fact-graph.md | 13 +++++++++---- scripts/audit-non-godot-parity.mjs | 12 ++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index f8ebc7594..a9bb453b1 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-20T14:31:29.297Z +> Last updated: 2026-08-20T14:38:26.298Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -2861,8 +2861,12 @@ unless the stray file is the intended new value. | 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. A fact pinned here must not -also be pinned as a parity-audit needle — one owner per fact. +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 @@ -2885,8 +2889,9 @@ declaration means anything. Semantic validity stays with tests and e2e. 1. **Done** — toolchain facts (Xcode, macOS image, JDK, Bun, Godot) plus the Example-project derivation. -2. Absorb parity-audit needles that assert scalar pins, shrinking - `audit-non-godot-parity.mjs` toward behavior-only assertions. +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 index f1e3d5177..4b33585af 100644 --- a/knowledge/internal/08-fact-graph.md +++ b/knowledge/internal/08-fact-graph.md @@ -49,8 +49,12 @@ unless the stray file is the intended new value. | 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. A fact pinned here must not -also be pinned as a parity-audit needle — one owner per fact. +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 @@ -73,7 +77,8 @@ declaration means anything. Semantic validity stays with tests and e2e. 1. **Done** — toolchain facts (Xcode, macOS image, JDK, Bun, Godot) plus the Example-project derivation. -2. Absorb parity-audit needles that assert scalar pins, shrinking - `audit-non-godot-parity.mjs` toward behavior-only assertions. +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/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 51a83c4d1..0d3c9e546 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -6029,6 +6029,8 @@ function checkFrameworkDependencyHygiene() { expectIncludes( ".github/workflows/ci.yml", [ + "runs-on: macos-26", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6041,6 +6043,8 @@ function checkFrameworkDependencyHygiene() { expectIncludes( xcodeReleaseWorkflow, [ + "runs-on: macos-26", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6056,6 +6060,8 @@ function checkFrameworkDependencyHygiene() { ".github/workflows/release-expo.yml", [ "Expo SDK 57's expo-modules-jsi package declares Swift tools 6.2", + "runs-on: macos-26", + 'XCODE_VERSION: "26.6"', "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], @@ -6070,6 +6076,8 @@ function checkFrameworkDependencyHygiene() { ".github/workflows/ci-maui-iap.yml", [ "app-store-artifact:", + "runs-on: macos-26", + 'APP_STORE_XCODE_VERSION: "26.6"', 'APP_STORE_SDK_VERSION: "26.5"', 'APP_STORE_LD_VERSION: "1267.0"', "maxim-lobanov/setup-xcode@", @@ -6082,6 +6090,8 @@ function checkFrameworkDependencyHygiene() { expectIncludes( ".github/workflows/release-maui.yml", [ + "runs-on: macos-26", + 'APP_STORE_XCODE_VERSION: "26.6"', 'APP_STORE_SDK_VERSION: "26.5"', 'APP_STORE_LD_VERSION: "1267.0"', "maxim-lobanov/setup-xcode@", @@ -8974,6 +8984,8 @@ function checkXcode27StoreKitCoverage() { "openiap-versions.json", '".github/workflows/release-flutter.yml"', "apple-cocoapods:", + "runs-on: macos-26", + "XCODE_VERSION: 26.6", "maxim-lobanov/setup-xcode@", "xcode-version: ${{ env.XCODE_VERSION }}", ], From 5ca75d77ccdac05ce7002154b22d5ed18abfff4a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 20 Aug 2026 23:49:04 +0900 Subject: [PATCH 3/5] feat: add the read-only graph impact query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun run graph:impact answers 'what does bumping this touch' before the work starts: declaring files with line numbers, derived declarations, and the CI jobs those files trigger — computed with the same selectJobs model the path-filter audit already proves against CI, so the answer cannot drift from what CI actually does. The audit and the query share one scanner (scanFact), so they cannot disagree about what exists either. Read-only; still nothing outside the additive tool surface. Co-Authored-By: Claude Opus 5 --- knowledge/_agent-context/context.md | 10 ++- knowledge/internal/08-fact-graph.md | 8 +++ package.json | 3 +- scripts/audit-facts.mjs | 53 ++++++++++------ scripts/graph-impact.mjs | 96 +++++++++++++++++++++++++++++ scripts/graph-impact.test.mjs | 30 +++++++++ 6 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 scripts/graph-impact.mjs create mode 100644 scripts/graph-impact.test.mjs diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index a9bb453b1..b3394bd96 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-20T14:38:26.298Z +> Last updated: 2026-08-20T14:49:04.488Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -2846,6 +2846,14 @@ because the scanner sees it anyway. (`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 diff --git a/knowledge/internal/08-fact-graph.md b/knowledge/internal/08-fact-graph.md index 4b33585af..497941e19 100644 --- a/knowledge/internal/08-fact-graph.md +++ b/knowledge/internal/08-fact-graph.md @@ -34,6 +34,14 @@ because the scanner sees it anyway. (`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 diff --git a/package.json b/package.json index 3c54f2e58..adba724fb 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "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:facts": "node --test scripts/audit-facts.test.mjs && node scripts/audit-facts.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 index 0a872742e..7d800949d 100644 --- a/scripts/audit-facts.mjs +++ b/scripts/audit-facts.mjs @@ -27,6 +27,30 @@ function expandFiles(specs) { 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 = []; @@ -36,25 +60,18 @@ export function auditFacts(readFile) { ); const seen = new Set(); - for (const scanner of fact.scanners) { - for (const file of expandFiles(scanner.files)) { - const text = readFile(file); - if (text === null) { - failures.push(`${fact.key}: scanned file is missing: ${file}`); - continue; - } - for (const match of text.matchAll(scanner.pattern)) { - const value = match[1]; - if (!allowed.has(value)) { - const line = text.slice(0, match.index).split("\n").length; - failures.push( - `${fact.key}: ${file}:${line} declares "${value}" but the ` + - `registry allows ${JSON.stringify(fact.values)}`, - ); - } - seen.add(value); - } + 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) { 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`); + } +}); From 43e461482243760ca7849957af6d1b62e2faacf7 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 21 Aug 2026 00:00:26 +0900 Subject: [PATCH 4/5] fix: scan .yaml workflows too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions loads both extensions, so a future .yaml workflow could declare a toolchain value the scanner never sees — the silent-pass class this design exists to prevent. The directory lister is injectable so the test can plant a phantom .yaml without touching the real tree. Co-Authored-By: Claude Opus 5 --- scripts/audit-facts.mjs | 18 +++++++++++++----- scripts/audit-facts.test.mjs | 14 +++++++++++++- scripts/facts.mjs | 3 ++- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/scripts/audit-facts.mjs b/scripts/audit-facts.mjs index 7d800949d..d0047de1f 100644 --- a/scripts/audit-facts.mjs +++ b/scripts/audit-facts.mjs @@ -12,16 +12,24 @@ import { FACTS, DERIVED } from "./facts.mjs"; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -function expandFiles(specs) { +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("/*.yml")) { + if (!spec.endsWith(WORKFLOW_GLOB)) { files.push(spec); continue; } - const dir = spec.slice(0, -"/*.yml".length); - for (const entry of readdirSync(join(REPO_ROOT, dir))) { - if (entry.endsWith(".yml")) files.push(`${dir}/${entry}`); + 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; diff --git a/scripts/audit-facts.test.mjs b/scripts/audit-facts.test.mjs index a5ed3a975..8d5048fa8 100644 --- a/scripts/audit-facts.test.mjs +++ b/scripts/audit-facts.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { auditFacts, readRepoFile } from "./audit-facts.mjs"; +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. @@ -87,3 +87,15 @@ test("the minimum and current Godot versions coexist without a finding", () => { ); 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 index a7b302aa4..75e0db974 100644 --- a/scripts/facts.mjs +++ b/scripts/facts.mjs @@ -9,7 +9,8 @@ // values here are authoritative; files follow. Multiple values in one fact // mean deliberately coexisting roles (current vs minimum), not drift. -const WORKFLOWS = ".github/workflows/*.yml"; +// GitHub Actions loads both extensions, so the scanner must too. +const WORKFLOWS = ".github/workflows/*.{yml,yaml}"; export const FACTS = Object.freeze([ { From 6b0e5dddb63c8114ed80de6eac7f0a5a74f2b623 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 21 Aug 2026 00:03:28 +0900 Subject: [PATCH 5/5] docs: bound the no-drift claim to scanner coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged that the guarantee reads wider than it is — a declaration in a file no scanner reads is invisible — and the thread was auto-resolved as outdated by an adjacent edit before it was addressed. Verification for this head also replayed the audit against real history: the incident tree (e814bea3) yields exactly its three true findings, the pre-migration tree (f51aab31) yields twenty, aligned main yields zero. Co-Authored-By: Claude Opus 5 --- knowledge/_agent-context/context.md | 8 +++++++- knowledge/internal/08-fact-graph.md | 6 ++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/knowledge/_agent-context/context.md b/knowledge/_agent-context/context.md index b3394bd96..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-20T14:49:04.488Z +> Last updated: 2026-08-20T15:02:26.238Z > > Canonical file: `knowledge/_agent-context/context.md` @@ -2893,6 +2893,12 @@ 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 diff --git a/knowledge/internal/08-fact-graph.md b/knowledge/internal/08-fact-graph.md index 497941e19..aba8c06ea 100644 --- a/knowledge/internal/08-fact-graph.md +++ b/knowledge/internal/08-fact-graph.md @@ -81,6 +81,12 @@ 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