From b6b5da3db9b348286a0467bec6df98a55db846e7 Mon Sep 17 00:00:00 2001 From: openboa Date: Wed, 9 Sep 2026 18:32:02 +0900 Subject: [PATCH 1/3] ci: adopt isolated central controls and repository-owned verification --- .github/CODEOWNERS | 18 +- .github/ci-policy.mjs | 389 ---------------------------------- .github/merge-policy.json | 20 +- .github/verify.mjs | 100 +++++++++ .github/verify.test.mjs | 38 ++++ .github/workflows/trusted.yml | 4 +- README.md | 12 ++ package.json | 2 +- 8 files changed, 177 insertions(+), 406 deletions(-) delete mode 100644 .github/ci-policy.mjs create mode 100644 .github/verify.mjs create mode 100644 .github/verify.test.mjs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bd5e415..9a65401 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,9 @@ -/AGENTS.md @openboa -/LICENSE @openboa -/README.md @openboa -/SECURITY.md @openboa-ai/security-maintainers -/.github/ @openboa -/.githooks/ @openboa-ai/security-maintainers -/iterations/ @openboa -/package.json @openboa -/package-lock.json @openboa +/AGENTS.md @openboa @SonSangjoon +/LICENSE @openboa @SonSangjoon +/README.md @openboa @SonSangjoon +/SECURITY.md @openboa-ai/security-maintainers @SonSangjoon +/.github/ @openboa @SonSangjoon +/.githooks/ @openboa-ai/security-maintainers @SonSangjoon +/iterations/ @openboa @SonSangjoon +/package.json @openboa @SonSangjoon +/package-lock.json @openboa @SonSangjoon diff --git a/.github/ci-policy.mjs b/.github/ci-policy.mjs deleted file mode 100644 index edbcfc9..0000000 --- a/.github/ci-policy.mjs +++ /dev/null @@ -1,389 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, statSync } from "node:fs"; -import { resolve } from "node:path"; -import { isDeepStrictEqual } from "node:util"; -const root = resolve(process.env.CI_POLICY_ROOT ?? "."); -const failures = []; -const TRUSTED_CONTROL_SHA = "f33da6bbcdfebd0693ff7673d750f369629e000e"; - -function fail(message) { - failures.push(message); -} - -function readJson(relativePath) { - try { - return JSON.parse(readFileSync(resolve(root, relativePath), "utf8")); - } catch (error) { - fail(`${relativePath} must be valid JSON: ${error.message}`); - return undefined; - } -} - -function equal(actual, expected) { - return isDeepStrictEqual(actual, expected); -} - -const trackedFiles = execFileSync("git", ["-C", root, "ls-files", "-z"], { - encoding: "utf8", -}) - .split("\0") - .filter(Boolean); -function trackedEntries(directory = ".") { - const prefix = directory === "." ? "" : `${directory.replace(/\/$/u, "")}/`; - const entries = new Set(); - for (const file of trackedFiles) { - if (!file.startsWith(prefix)) continue; - const remainder = file.slice(prefix.length); - if (!remainder) continue; - entries.add(remainder.split("/")[0]); - } - return [...entries].sort(); -} -function checkoutEntries(directory = ".") { - const entries = trackedEntries(directory); - if (directory === ".") entries.push(".git"); - return entries.sort(); -} - -const requiredFiles = [ - "README.md", - "AGENTS.md", - "SECURITY.md", - "LICENSE", - ".gitignore", - ".githooks/pre-commit", - "iterations/README.md", - "package.json", - "package-lock.json", - ".github/merge-policy.json", - ".github/workflows/trusted.yml", -]; -for (const relativePath of requiredFiles) { - if (!existsSync(resolve(root, relativePath))) fail(`${relativePath} is required`); -} - -for (const forbidden of [ - ".npmrc", - "npm-shrinkwrap.json", - "src", - "tests", - "integrations", - "reports", - "runtime-locks", - "docs", - "PLAN.md", - "harbor-requirements.in", - "harbor-requirements.txt", - "uv-requirements.txt", - ".gitleaksignore", -]) { - if (existsSync(resolve(root, forbidden))) fail(`${forbidden} must be absent`); -} - -const packageJson = readJson("package.json"); -if ( - packageJson?.name !== "coffee-chat-eval" || - packageJson?.version !== "0.1.0" || - packageJson?.license !== "MIT" || - JSON.stringify(packageJson?.scripts) !== - JSON.stringify({ - "hooks:install": "git config core.hooksPath .githooks", - verify: "node .github/ci-policy.mjs", - }) || - Object.keys(packageJson ?? {}).some( - (key) => - !["name", "version", "private", "license", "description", "scripts"].includes(key), - ) || - packageJson?.private !== true -) { - fail("package.json must remain the dependency-free verification skeleton"); -} - -const lock = readJson("package-lock.json"); -if ( - lock?.name !== "coffee-chat-eval" || - lock?.version !== "0.1.0" || - lock?.lockfileVersion !== 3 || - lock?.requires !== true || - JSON.stringify(lock?.packages) !== - JSON.stringify({ - "": { - name: "coffee-chat-eval", - version: "0.1.0", - license: "MIT", - }, - }) -) { - fail("package-lock.json must remain dependency-free and match package.json"); -} - -const workflowEntries = trackedEntries(".github/workflows"); -if (JSON.stringify(workflowEntries) !== JSON.stringify(["trusted.yml"])) { - fail("only the trusted workflow may be present"); -} - -const topLevel = checkoutEntries(); -const allowedTopLevel = new Set([ - ".editorconfig", - ".gitattributes", - ".git", - ".github", - ".githooks", - ".gitignore", - "AGENTS.md", - "LICENSE", - "README.md", - "SECURITY.md", - "iterations", - "package-lock.json", - "package.json", -]); -for (const entry of topLevel) { - if (!allowedTopLevel.has(entry)) fail(`unexpected top-level entry: ${entry}`); -} - -if (!equal(trackedEntries("iterations"), ["README.md"])) { - fail("iterations must contain only README.md until execution evidence exists"); -} - -const githubEntries = trackedEntries(".github"); -if ( - JSON.stringify(githubEntries) !== - JSON.stringify([ - "CODEOWNERS", - "PULL_REQUEST_TEMPLATE.md", - "ci-policy.mjs", - "dependabot.yml", - "merge-policy.json", - "workflows", - ]) -) { - fail(".github must contain only the declared policy and workflow files"); -} - -if (JSON.stringify(trackedEntries(".githooks")) !== JSON.stringify(["pre-commit"])) { - fail(".githooks must contain only the declared executable hook"); -} -const expectedHook = [ - "#!/bin/sh", - "set -eu", - "", - "scanner=${GITLEAKS_BIN:-gitleaks}", - 'if ! command -v "$scanner" >/dev/null 2>&1; then', - " printf '%s\\n' 'Gitleaks is required; install Gitleaks before committing.' >&2", - " exit 1", - "fi", - "", - "if [ -e .gitleaks.toml ] || [ -e .gitleaksignore ]; then", - " printf '%s\\n' 'Repository-local Gitleaks controls are not permitted.' >&2", - " exit 1", - "fi", - "unset GITLEAKS_CONFIG GITLEAKS_CONFIG_TOML", - '"$scanner" git --pre-commit --staged --gitleaks-ignore-path /dev/null \\', - " --ignore-gitleaks-allow --redact --no-banner .", - 'staged_dir="$(mktemp -d)"', - `trap 'rm -rf "$staged_dir"' EXIT HUP INT TERM`, - 'git checkout-index --all --prefix="$staged_dir/"', - '"$scanner" dir --gitleaks-ignore-path /dev/null --ignore-gitleaks-allow \\', - ' --redact --no-banner "$staged_dir"', - "", -].join("\n"); -const hookPath = resolve(root, ".githooks/pre-commit"); -if (readFileSync(hookPath, "utf8") !== expectedHook) { - fail(".githooks/pre-commit must remain the exact Gitleaks hook"); -} -if ((statSync(hookPath).mode & 0o111) === 0) { - fail(".githooks/pre-commit must remain executable"); -} - -if ( - readFileSync(resolve(root, ".gitignore"), "utf8") !== - `artifacts/ -node_modules/ -__pycache__/ -coverage/ -dist/ - -# Local credentials -.env -.env.* -!.env.example -credentials.json -secrets.json -*.private.pem -private-key.pem -*.private.key -private.key -private-key.key -id_rsa -id_dsa -id_ecdsa -id_ed25519 -tls.key -server.key -server-key.pem -*-private-key.pem -*-private-key.key -privkey*.pem -*.p12 -*.pfx -*.jks -` -) { - fail(".gitignore must preserve the credential and local-artifact ignore contract"); -} - -if ( - readFileSync(resolve(root, ".github/dependabot.yml"), "utf8") !== - `version: 2 - -updates: - - package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 5 - commit-message: - prefix: deps - allow: - - dependency-name: "*" - update-types: - - version-update:semver-minor - - version-update:semver-patch - groups: - security: - applies-to: security-updates - patterns: - - "*" - production: - applies-to: version-updates - dependency-type: production - update-types: [minor, patch] - development: - applies-to: version-updates - dependency-type: development - update-types: [minor, patch] - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 5 - commit-message: - prefix: deps - allow: - - dependency-name: "*" - update-types: - - version-update:semver-minor - - version-update:semver-patch - groups: - security: - applies-to: security-updates - patterns: - - "*" - versions: - applies-to: version-updates - update-types: [minor, patch] - patterns: - - "*" -` -) { - fail("Dependabot policy must remain bounded to approved update lanes"); -} -if ( - readFileSync(resolve(root, ".github/CODEOWNERS"), "utf8") !== - `/AGENTS.md @openboa -/LICENSE @openboa -/README.md @openboa -/SECURITY.md @openboa-ai/security-maintainers -/.github/ @openboa -/.githooks/ @openboa-ai/security-maintainers -/iterations/ @openboa -/package.json @openboa -/package-lock.json @openboa -` -) { - fail("CODEOWNERS must preserve the eval ownership routes"); -} - -const trustedWorkflowPath = resolve(root, ".github/workflows/trusted.yml"); -if (existsSync(trustedWorkflowPath)) { - const expectedTrustedWorkflow = `name: OpenBoa Coffee trusted gate - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: {} - -jobs: - trusted: - name: OpenBoa Coffee trusted required - permissions: - actions: read - contents: read - security-events: write - uses: openboa-ai/.github/.github/workflows/coffee-trusted-gate.yml@${TRUSTED_CONTROL_SHA} - with: - control_sha: ${TRUSTED_CONTROL_SHA} -`; - if (readFileSync(trustedWorkflowPath, "utf8") !== expectedTrustedWorkflow) { - fail("trusted wrapper must remain exact"); - } -} - -if ( - !equal(readJson(".github/merge-policy.json"), { - schema: "coffee-chat/merge-policy", - auto_merge: { - provider: "github-native", - required_checks: true, - verified_members_only: true, - }, - merge_method: "squash", - merge_queue: false, - required_events: ["pull_request"], - required_approvals: 0, - review_policy: { - default_required_approvals: 0, - sensitive_paths_use_protected_environment: true, - }, - eligible_author_associations: ["OWNER", "MEMBER"], - eligible_bot_logins: ["dependabot[bot]"], - protected_paths: [ - ".github/**", - ".githooks/**", - ".gitleaksignore", - ".gitleaks.toml", - "AGENTS.md", - "CODEOWNERS", - "README.md", - "SECURITY.md", - "iterations/**", - ".npmrc", - "npm-shrinkwrap.json", - "package-lock.json", - "package.json", - ], - required_checks: [ - { - context: "OpenBoa Coffee trusted required / OpenBoa Coffee trusted required", - integration_id: 15368, - }, - ], - sensitive_review: { - enforcement: "github_environment", - environment: "coffee-security", - required_approvals: 1, - prevent_self_review: false, - }, - }) -) { - fail("merge policy must preserve the exact GitHub-native and sensitive-review contract"); -} - -if (failures.length > 0) { - console.error(failures.map((failure) => `- ${failure}`).join("\n")); - process.exitCode = 1; -} else { - console.log("Coffee Chat Eval structure and policy passed."); -} diff --git a/.github/merge-policy.json b/.github/merge-policy.json index f8689da..d7e0279 100644 --- a/.github/merge-policy.json +++ b/.github/merge-policy.json @@ -7,14 +7,21 @@ }, "merge_method": "squash", "merge_queue": false, - "required_events": ["pull_request"], + "required_events": [ + "pull_request" + ], "required_approvals": 0, "review_policy": { "default_required_approvals": 0, "sensitive_paths_use_protected_environment": true }, - "eligible_author_associations": ["OWNER", "MEMBER"], - "eligible_bot_logins": ["dependabot[bot]"], + "eligible_author_associations": [ + "OWNER", + "MEMBER" + ], + "eligible_bot_logins": [ + "dependabot[bot]" + ], "protected_paths": [ ".github/**", ".githooks/**", @@ -28,7 +35,9 @@ ".npmrc", "npm-shrinkwrap.json", "package-lock.json", - "package.json" + "package.json", + "/.gitignore", + "/.gitattributes" ], "required_checks": [ { @@ -41,5 +50,6 @@ "environment": "coffee-security", "required_approvals": 1, "prevent_self_review": false - } + }, + "required_code_owner_reviews": 1 } diff --git a/.github/verify.mjs b/.github/verify.mjs new file mode 100644 index 0000000..9ad938c --- /dev/null +++ b/.github/verify.mjs @@ -0,0 +1,100 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +const root = resolve(process.env.CI_POLICY_ROOT ?? "."); +const failures = []; + +function fail(message) { + failures.push(message); +} + +function equal(actual, expected) { + return isDeepStrictEqual(actual, expected); +} + +const trackedFiles = execFileSync("git", ["-C", root, "ls-files", "-z"], { + encoding: "utf8", +}) + .split("\0") + .filter(Boolean); +function trackedEntries(directory = ".") { + const prefix = directory === "." ? "" : `${directory.replace(/\/$/u, "")}/`; + const entries = new Set(); + for (const file of trackedFiles) { + if (!file.startsWith(prefix)) continue; + const remainder = file.slice(prefix.length); + if (!remainder) continue; + entries.add(remainder.split("/")[0]); + } + return [...entries].sort(); +} +function checkoutEntries(directory = ".") { + const entries = trackedEntries(directory); + if (directory === ".") entries.push(".git"); + return entries.sort(); +} + +const requiredFiles = [ + "README.md", + "AGENTS.md", + "SECURITY.md", + "LICENSE", + ".gitignore", + ".githooks/pre-commit", + "iterations/README.md", + "package.json", + "package-lock.json", +]; +for (const relativePath of requiredFiles) { + if (!existsSync(resolve(root, relativePath))) fail(`${relativePath} is required`); +} + +for (const forbidden of [ + ".npmrc", + "npm-shrinkwrap.json", + "src", + "tests", + "integrations", + "reports", + "runtime-locks", + "docs", + "PLAN.md", + "harbor-requirements.in", + "harbor-requirements.txt", + "uv-requirements.txt", + ".gitleaksignore", +]) { + if (existsSync(resolve(root, forbidden))) fail(`${forbidden} must be absent`); +} + +const topLevel = checkoutEntries(); +const allowedTopLevel = new Set([ + ".editorconfig", + ".gitattributes", + ".git", + ".github", + ".githooks", + ".gitignore", + "AGENTS.md", + "LICENSE", + "README.md", + "SECURITY.md", + "iterations", + "package-lock.json", + "package.json", +]); +for (const entry of topLevel) { + if (!allowedTopLevel.has(entry)) fail(`unexpected top-level entry: ${entry}`); +} + +if (!equal(trackedEntries("iterations"), ["README.md"])) { + fail("iterations must contain only README.md until execution evidence exists"); +} + +if (failures.length > 0) { + console.error(failures.map((failure) => `- ${failure}`).join("\n")); + process.exitCode = 1; +} else { + console.log("Coffee Chat Eval structure verification passed."); +} diff --git a/.github/verify.test.mjs b/.github/verify.test.mjs new file mode 100644 index 0000000..2a15567 --- /dev/null +++ b/.github/verify.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; + +const source = resolve(import.meta.dirname, ".."); +function fixture(mutate = () => {}) { + const root = mkdtempSync(join(tmpdir(), "coffee-chat-eval-verify-")); + const env = { ...process.env, CI_POLICY_ROOT: root }; + delete env.GIT_INDEX_FILE; + try { + const files = execFileSync("git", ["ls-files", "-z"], { cwd: source, encoding: "utf8", env }).split("\0").filter(Boolean); + for (const path of files) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + cpSync(join(source, path), join(root, path)); + } + mutate(root); + execFileSync("git", ["init", "-q"], { cwd: root, env }); + execFileSync("git", ["add", "-f", "--all"], { cwd: root, env }); + return spawnSync(process.execPath, [join(source, ".github/verify.mjs")], { cwd: root, env, encoding: "utf8" }); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test("accepts the current repository contract", () => { + const result = fixture(); + assert.equal(result.status, 0, result.stderr); +}); + +test("rejects run evidence in the unevaluated skeleton", () => { + const result = fixture((root) => writeFileSync(join(root, "iterations/output.json"), "{}")); + assert.notEqual(result.status, 0, result.stdout); +}); +test("rejects missing iteration documentation", () => { + const result = fixture((root) => rmSync(join(root, "iterations/README.md"))); + assert.notEqual(result.status, 0, result.stdout); +}); diff --git a/.github/workflows/trusted.yml b/.github/workflows/trusted.yml index b63f0c6..1e507cf 100644 --- a/.github/workflows/trusted.yml +++ b/.github/workflows/trusted.yml @@ -13,6 +13,6 @@ jobs: actions: read contents: read security-events: write - uses: openboa-ai/.github/.github/workflows/coffee-trusted-gate.yml@f33da6bbcdfebd0693ff7673d750f369629e000e + uses: openboa-ai/.github/.github/workflows/coffee-trusted-gate.yml@5b4d641ad1951c529d0a88f08df2544d25c3811a with: - control_sha: f33da6bbcdfebd0693ff7673d750f369629e000e + control_sha: 5b4d641ad1951c529d0a88f08df2544d25c3811a diff --git a/README.md b/README.md index 7ba06b9..a3f02f7 100644 --- a/README.md +++ b/README.md @@ -143,3 +143,15 @@ preserved evidence. Evaluation definitions and documentation are MIT licensed, Copyright © 2026 Openboa AI. Private run evidence and any input content retain their applicable rights and must not be committed here. + +## Repository verification + +Run `npm ci --ignore-scripts --no-bin-links`, then `npm run verify`. +The checks and regressions in `.github/verify.mjs` and `.github/verify.test.mjs` +are owned here. The pinned central workflow owns security policy, execution +isolation and approval; it does not define this repository's product or data +layout. Existing CODEOWNERS routes and protected paths remain in force. + +The same verify command runs locally and in the isolated CI lane. A changed +central pin needs control review; never remove a required check to upgrade it. +Structural CI success does not establish benchmark validity or Product lift. diff --git a/package.json b/package.json index 01863bf..ee2471a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,6 @@ "description": "Clean-context evaluation runner and evidence store for Coffee Chat Product and Judge iterations.", "scripts": { "hooks:install": "git config core.hooksPath .githooks", - "verify": "node .github/ci-policy.mjs" + "verify": "node --test .github/verify.test.mjs && node .github/verify.mjs" } } From 8359d072050d1332298fa88cd6f233419b6d2be8 Mon Sep 17 00:00:00 2001 From: openboa Date: Wed, 9 Sep 2026 18:45:04 +0900 Subject: [PATCH 2/3] fix: preserve infrastructure directory boundaries --- .github/verify.mjs | 18 ++++++++++++++++++ .github/verify.test.mjs | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/.github/verify.mjs b/.github/verify.mjs index 9ad938c..3887b29 100644 --- a/.github/verify.mjs +++ b/.github/verify.mjs @@ -1,3 +1,4 @@ +import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; @@ -18,6 +19,23 @@ const trackedFiles = execFileSync("git", ["-C", root, "ls-files", "-z"], { }) .split("\0") .filter(Boolean); +// Infrastructure directories are not an escape hatch for Product/data artifacts. +// Central controls validate security semantics; this repository owns its layout. +assert.deepEqual( + trackedFiles.filter((path) => path.startsWith(".github/") || path.startsWith(".githooks/")).sort(), + [ + ".githooks/pre-commit", + ".github/CODEOWNERS", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/dependabot.yml", + ".github/merge-policy.json", + ".github/verify.mjs", + ".github/verify.test.mjs", + ".github/workflows/trusted.yml", + ], + "unexpected or missing infrastructure file", +); + function trackedEntries(directory = ".") { const prefix = directory === "." ? "" : `${directory.replace(/\/$/u, "")}/`; const entries = new Set(); diff --git a/.github/verify.test.mjs b/.github/verify.test.mjs index 2a15567..3664bc7 100644 --- a/.github/verify.test.mjs +++ b/.github/verify.test.mjs @@ -23,6 +23,13 @@ function fixture(mutate = () => {}) { } finally { rmSync(root, { recursive: true, force: true }); } } +for (const path of [".github/product-behavior.js", ".githooks/eval-results.json"]) { + test(`rejects unexpected infrastructure artifact: ${path}`, () => { + const result = fixture((root) => writeFileSync(join(root, path), "{}")); + assert.notEqual(result.status, 0, result.stdout); + }); +} + test("accepts the current repository contract", () => { const result = fixture(); assert.equal(result.status, 0, result.stderr); From 982e5dbf871e35d2a2205b0d35c474621f48726d Mon Sep 17 00:00:00 2001 From: openboa Date: Wed, 9 Sep 2026 19:06:23 +0900 Subject: [PATCH 3/3] fix: validate complete repository file paths --- .github/verify.mjs | 94 +++++++++-------------------------------- .github/verify.test.mjs | 11 +++++ 2 files changed, 32 insertions(+), 73 deletions(-) diff --git a/.github/verify.mjs b/.github/verify.mjs index 3887b29..ef087b6 100644 --- a/.github/verify.mjs +++ b/.github/verify.mjs @@ -1,8 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, lstatSync } from "node:fs"; import { resolve } from "node:path"; -import { isDeepStrictEqual } from "node:util"; const root = resolve(process.env.CI_POLICY_ROOT ?? "."); const failures = []; @@ -10,62 +9,35 @@ function fail(message) { failures.push(message); } -function equal(actual, expected) { - return isDeepStrictEqual(actual, expected); -} - const trackedFiles = execFileSync("git", ["-C", root, "ls-files", "-z"], { encoding: "utf8", }) .split("\0") .filter(Boolean); -// Infrastructure directories are not an escape hatch for Product/data artifacts. -// Central controls validate security semantics; this repository owns its layout. -assert.deepEqual( - trackedFiles.filter((path) => path.startsWith(".github/") || path.startsWith(".githooks/")).sort(), - [ - ".githooks/pre-commit", - ".github/CODEOWNERS", - ".github/PULL_REQUEST_TEMPLATE.md", - ".github/dependabot.yml", - ".github/merge-policy.json", - ".github/verify.mjs", - ".github/verify.test.mjs", - ".github/workflows/trusted.yml", - ], - "unexpected or missing infrastructure file", -); - -function trackedEntries(directory = ".") { - const prefix = directory === "." ? "" : `${directory.replace(/\/$/u, "")}/`; - const entries = new Set(); - for (const file of trackedFiles) { - if (!file.startsWith(prefix)) continue; - const remainder = file.slice(prefix.length); - if (!remainder) continue; - entries.add(remainder.split("/")[0]); - } - return [...entries].sort(); -} -function checkoutEntries(directory = ".") { - const entries = trackedEntries(directory); - if (directory === ".") entries.push(".git"); - return entries.sort(); -} - -const requiredFiles = [ - "README.md", +// Compare complete paths: a same-named directory is not an allowed file. +// This is the published repository layout, not central security policy. +assert.deepEqual(trackedFiles.slice().sort(), [ + ".editorconfig", + ".gitattributes", + ".githooks/pre-commit", + ".github/CODEOWNERS", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/dependabot.yml", + ".github/merge-policy.json", + ".github/verify.mjs", + ".github/verify.test.mjs", + ".github/workflows/trusted.yml", + ".gitignore", "AGENTS.md", - "SECURITY.md", "LICENSE", - ".gitignore", - ".githooks/pre-commit", + "README.md", + "SECURITY.md", "iterations/README.md", - "package.json", "package-lock.json", -]; -for (const relativePath of requiredFiles) { - if (!existsSync(resolve(root, relativePath))) fail(`${relativePath} is required`); + "package.json" +], "unexpected or missing repository file"); +for (const path of trackedFiles) { + assert.equal(lstatSync(resolve(root, path)).isFile(), true, `${path}: regular file required`); } for (const forbidden of [ @@ -86,30 +58,6 @@ for (const forbidden of [ if (existsSync(resolve(root, forbidden))) fail(`${forbidden} must be absent`); } -const topLevel = checkoutEntries(); -const allowedTopLevel = new Set([ - ".editorconfig", - ".gitattributes", - ".git", - ".github", - ".githooks", - ".gitignore", - "AGENTS.md", - "LICENSE", - "README.md", - "SECURITY.md", - "iterations", - "package-lock.json", - "package.json", -]); -for (const entry of topLevel) { - if (!allowedTopLevel.has(entry)) fail(`unexpected top-level entry: ${entry}`); -} - -if (!equal(trackedEntries("iterations"), ["README.md"])) { - fail("iterations must contain only README.md until execution evidence exists"); -} - if (failures.length > 0) { console.error(failures.map((failure) => `- ${failure}`).join("\n")); process.exitCode = 1; diff --git a/.github/verify.test.mjs b/.github/verify.test.mjs index 3664bc7..f30dad7 100644 --- a/.github/verify.test.mjs +++ b/.github/verify.test.mjs @@ -30,6 +30,17 @@ for (const path of [".github/product-behavior.js", ".githooks/eval-results.json" }); } +for (const path of ["LICENSE", "AGENTS.md", "SECURITY.md"]) { + test(`rejects a directory replacing the file: ${path}`, () => { + const result = fixture((root) => { + rmSync(join(root, path)); + mkdirSync(join(root, path)); + writeFileSync(join(root, path, "eval-results.json"), "{}"); + }); + assert.notEqual(result.status, 0, result.stdout); + }); +} + test("accepts the current repository contract", () => { const result = fixture(); assert.equal(result.status, 0, result.stderr);