diff --git a/examples/governance.ts b/examples/governance.ts new file mode 100644 index 0000000..9066aed --- /dev/null +++ b/examples/governance.ts @@ -0,0 +1,36 @@ +// The same policy as governance.yml, as data in TypeScript. The type gives +// completion and a compile error on a misspelt key; the helper is the part +// YAML cannot express without anchors. By default warden folds this file to +// its value without running it (--config-mode fold); it is typed JSON. +import type { GovernanceConfig } from "@intentius/forgejo-warden"; + +const protectedMain = { + ruleName: "main", + requiredApprovals: 1, + enableStatusCheck: true, + statusCheckContexts: ["ci"], + dismissStaleApprovals: true, +}; + +const service = (name: string) => ({ + hasWiki: false, + hasPullRequests: true, + allowSquashMerge: true, + topics: ["service", name], + branchProtection: [protectedMain], +}); + +export default { + orgs: { + "my-org": { + settings: { + description: "Engineering", + visibility: "limited", + }, + repos: { + api: service("api"), + web: service("web"), + }, + }, + }, +} satisfies GovernanceConfig; diff --git a/examples/governance.yml b/examples/governance.yml new file mode 100644 index 0000000..cbe4cc6 --- /dev/null +++ b/examples/governance.yml @@ -0,0 +1,36 @@ +# The same policy as governance.ts, in YAML. Either file loads to the same +# object; src/config/load.test.ts asserts it. +orgs: + my-org: + settings: + description: Engineering + visibility: limited + repos: + api: + hasWiki: false + hasPullRequests: true + allowSquashMerge: true + topics: + - service + - api + branchProtection: + - ruleName: main + requiredApprovals: 1 + enableStatusCheck: true + statusCheckContexts: + - ci + dismissStaleApprovals: true + web: + hasWiki: false + hasPullRequests: true + allowSquashMerge: true + topics: + - service + - web + branchProtection: + - ruleName: main + requiredApprovals: 1 + enableStatusCheck: true + statusCheckContexts: + - ci + dismissStaleApprovals: true diff --git a/examples/not-data.ts b/examples/not-data.ts new file mode 100644 index 0000000..ca84ce1 --- /dev/null +++ b/examples/not-data.ts @@ -0,0 +1,11 @@ +// A policy that is code, not data: it reads the environment. Folding refuses +// it with the line; running it accepts whatever the environment said. +export default { + orgs: { + "my-org": { + repos: { + api: { hasWiki: process.env.WIKI === "1" }, + }, + }, + }, +}; diff --git a/package-lock.json b/package-lock.json index 9986ecb..47caf79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@intentius/chant": "^0.71.1", + "@intentius/tsad-reference": "^1.4.0", "yaml": "^2.9.0" }, "bin": { @@ -519,6 +520,22 @@ "typescript": "^5.9.3" } }, + "node_modules/@intentius/tsad-conformance": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@intentius/tsad-conformance/-/tsad-conformance-1.4.0.tgz", + "integrity": "sha512-UVPYJg6ild60OChgUoL008cqtZwc5XuzvQ2Om4s3lTIa+ZUoCzTvisphrwlVFwCNx1J/OciyBvP/9E1kQPLrkA==", + "license": "Apache-2.0" + }, + "node_modules/@intentius/tsad-reference": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@intentius/tsad-reference/-/tsad-reference-1.4.0.tgz", + "integrity": "sha512-Lt3bbio2QPR2qPRHJX4Lfr78YioVvE0qFtLslKBB+C6b1nGPvQQL+iAVcTRKZXNUx7+TvN0oMQMe+XRqOaT83w==", + "license": "Apache-2.0", + "dependencies": { + "@intentius/tsad-conformance": "^1.4.0", + "typescript": "^5.9.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", diff --git a/package.json b/package.json index 1fd1ea9..69538d2 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ }, "dependencies": { "@intentius/chant": "^0.71.1", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "@intentius/tsad-reference": "^1.4.0" }, "devDependencies": { "@intentius/chant-lexicon-github": "^0.71.1", diff --git a/src/cli.ts b/src/cli.ts index 2f04481..c6f01d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,9 +13,8 @@ * 3 runtime error. */ -import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; -import { parse as parseYaml } from "yaml"; +import { loadGovernanceConfig, type ConfigMode } from "./config/load.js"; import { createClient } from "./auth/client.js"; import { runReconcile, type Cycle } from "./reconcile/runner.js"; import { CYCLE_REGISTRY } from "./cli/registry.js"; @@ -37,6 +36,8 @@ export class CliError extends Error { export interface ReconcileArgs { config: string; + /** How a `.ts` policy is evaluated: folded without running (default), imported, or both and compared. */ + configMode: ConfigMode; mode: "dry-run" | "apply"; cycles: string[]; baseUrl: string | undefined; @@ -49,6 +50,7 @@ export interface ReconcileArgs { const KNOWN_FLAGS = new Set([ "--config", + "--config-mode", "--mode", "--cycles", "--base-url", @@ -62,6 +64,7 @@ const KNOWN_FLAGS = new Set([ export function parseReconcileArgs(argv: string[]): ReconcileArgs { const args: ReconcileArgs = { config: "", + configMode: "fold", mode: "dry-run", cycles: [], baseUrl: undefined, @@ -86,6 +89,12 @@ export function parseReconcileArgs(argv: string[]): ReconcileArgs { case "--config": args.config = need(++i, flag); break; + case "--config-mode": { + const v = argv[++i]; + if (v !== "fold" && v !== "run" && v !== "check") throw new CliError(2, `--config-mode must be "fold", "run" or "check", got: ${v ?? "(missing)"}`); + args.configMode = v; + break; + } case "--mode": { const v = argv[++i]; if (v !== "dry-run" && v !== "apply") throw new CliError(2, `--mode must be "dry-run" or "apply", got: ${v ?? "(missing)"}`); @@ -146,15 +155,6 @@ function errMsg(err: unknown): string { return err instanceof Error ? err.message : String(err); } -function loadConfig(path: string): GovernanceConfig { - const text = readFileSync(path, "utf-8"); - const raw = path.toLowerCase().endsWith(".json") ? JSON.parse(text) : parseYaml(text); - if (!raw || typeof raw !== "object" || typeof (raw as { orgs?: unknown }).orgs !== "object") { - throw new Error("config must be an object with an `orgs` map"); - } - return raw as GovernanceConfig; -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -170,7 +170,7 @@ async function runReconcileCommand(argv: string[]): Promise { let config: GovernanceConfig; try { - config = loadConfig(args.config); + config = await loadGovernanceConfig(args.config, args.configMode); } catch (err) { die(2, `invalid governance config "${args.config}": ${errMsg(err)}`); } diff --git a/src/config/load.test.ts b/src/config/load.test.ts new file mode 100644 index 0000000..dc4f357 --- /dev/null +++ b/src/config/load.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { resolve } from "node:path"; +import { loadGovernanceConfig, GovernanceConfigError } from "./load.js"; + +const ex = (name: string) => resolve(import.meta.dirname, "..", "..", "examples", name); + +describe("loadGovernanceConfig", () => { + it("a .ts policy folded, a .ts policy run, and the .yml load to the same object", async () => { + const yml = await loadGovernanceConfig(ex("governance.yml")); + const folded = await loadGovernanceConfig(ex("governance.ts"), "fold"); + const ran = await loadGovernanceConfig(ex("governance.ts"), "run"); + expect(folded).toEqual(yml); + expect(ran).toEqual(yml); + expect(folded.orgs["my-org"].repos?.api.branchProtection?.[0].ruleName).toBe("main"); + }); + + it("check mode passes when folding and running agree", async () => { + await expect(loadGovernanceConfig(ex("governance.ts"), "check")).resolves.toBeTruthy(); + }); + + it("a policy that reads the environment is refused by fold, with the line, and accepted by run", async () => { + await expect(loadGovernanceConfig(ex("not-data.ts"))).rejects.toThrow(GovernanceConfigError); + await expect(loadGovernanceConfig(ex("not-data.ts"))).rejects.toThrow(/not data/); + await expect(loadGovernanceConfig(ex("not-data.ts"))).rejects.toThrow(/\d+:\d+/); + const ran = await loadGovernanceConfig(ex("not-data.ts"), "run"); + expect(ran.orgs["my-org"].repos?.api.hasWiki).toBe(false); + }); + + it("an undefined property is absent, the way JSON leaves it, so selective-by-omission holds", async () => { + const dir = resolve(import.meta.dirname, "..", "..", "examples"); + const { writeFileSync, rmSync } = await import("node:fs"); + const p = resolve(dir, "tmp-undefined.ts"); + writeFileSync(p, 'const on = false;\nexport default { orgs: { o: { repos: { r: { hasWiki: on ? true : undefined } } } } };\n'); + try { + const folded = await loadGovernanceConfig(p, "fold"); + expect("hasWiki" in (folded.orgs.o.repos?.r ?? {})).toBe(false); + } finally { + rmSync(p); + } + }); + + it("refuses a config with no orgs map", async () => { + await expect(loadGovernanceConfig(ex("governance.yml").replace("governance.yml", "../package.json"))).rejects.toThrow(/orgs/); + }); +}); diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..c6573e5 --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,111 @@ +/** + * The governance policy, loaded from YAML, JSON, or TypeScript. + * + * A `.ts` policy is data: an object literal typed by `GovernanceConfig`, + * exported as `default`. By default it is *folded*, reduced + * to its value by `@intentius/tsad-reference` without being run, so the plan + * is a function of the file and nothing else and no code executes to produce + * it. `run` mode imports the file instead, for a user who wants typed JSON and + * does not care how it was evaluated; `check` mode does both and refuses if + * they differ, which is the guarantee folding provides made visible. + * + * Selective-by-omission survives either way: an `undefined`-valued property + * is treated as absent, the same as JSON would leave it. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse as parseYaml } from "yaml"; +import type { GovernanceConfig } from "./types.js"; + +export type ConfigMode = "fold" | "run" | "check"; + +export class GovernanceConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "GovernanceConfigError"; + } +} + +/** Every `.ts` file under the policy's directory, keyed relative to it, so the policy may import siblings. */ +function projectFiles(root: string): Map { + const files = new Map(); + const walk = (dir: string): void => { + for (const name of readdirSync(dir)) { + if (name === "node_modules" || name.startsWith(".")) continue; + const abs = join(dir, name); + if (statSync(abs).isDirectory()) walk(abs); + else if (name.endsWith(".ts") && !name.endsWith(".d.ts") && !name.endsWith(".test.ts")) { + files.set(relative(root, abs).split("\\").join("/"), readFileSync(abs, "utf-8")); + } + } + }; + walk(root); + return files; +} + +function policyOf(exports: Record, where: string): unknown { + // `export default` is the idiom and folds under the data-host profile + // (spec 1.2, S-ExportDefault); `export const policy` is accepted too. + if ("default" in exports) return exports.default; + if ("policy" in exports) return exports.policy; + throw new GovernanceConfigError(`${where} must export the policy as \`export default\``); +} + +/** Fold the policy with the reference evaluator: no execution, a located refusal if the file is not data. */ +async function foldPolicy(path: string): Promise { + // The data-host profile (spec 1.2, F-Profile-DataHost): no runtime, and a + // default export is the declarator named `default`, which is the idiom. + const { foldProject, EMPTY_HOST } = await import("@intentius/tsad-reference"); + const root = dirname(resolve(path)); + const key = relative(root, resolve(path)).split("\\").join("/"); + const verdicts = foldProject(projectFiles(root), { ...EMPTY_HOST, profile: "data-host" }).verdicts; + const verdict = verdicts.get(key); + if (!verdict) throw new GovernanceConfigError(`${path}: not found among the project's files`); + if (verdict.kind === "run") { + throw new GovernanceConfigError(`${path} is not data (${verdict.rule}): ${verdict.reason}`); + } + return policyOf(Object.fromEntries(verdict.exports), path); +} + +/** Import the policy: whatever the file does, its export is the policy. */ +async function runPolicy(path: string): Promise { + const mod = (await import(pathToFileURL(resolve(path)).href)) as Record; + return policyOf(mod, path); +} + +/** JSON's view of a value: `undefined` properties absent, keys sorted, so two loads compare as the policy the cycles will read. */ +function canonical(value: unknown): string { + const sort = (v: unknown): unknown => { + if (v === null || typeof v !== "object") return v; + if (Array.isArray(v)) return v.map(sort); + return Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, sort((v as Record)[k])])); + }; + return JSON.stringify(sort(JSON.parse(JSON.stringify(value)))); +} + +function assertShape(raw: unknown, path: string): GovernanceConfig { + if (!raw || typeof raw !== "object" || typeof (raw as { orgs?: unknown }).orgs !== "object") { + throw new GovernanceConfigError(`${path}: config must be an object with an \`orgs\` map`); + } + // The cycles read through JSON's view of the policy, where an undefined + // property is absent. Normalise once here so fold and run agree by construction. + return JSON.parse(JSON.stringify(raw)) as GovernanceConfig; +} + +export async function loadGovernanceConfig(path: string, mode: ConfigMode = "fold"): Promise { + const lower = path.toLowerCase(); + if (lower.endsWith(".ts")) { + if (mode === "run") return assertShape(await runPolicy(path), path); + const folded = await foldPolicy(path); + if (mode === "check") { + const ran = await runPolicy(path); + if (canonical(folded) !== canonical(ran)) { + throw new GovernanceConfigError(`${path}: folding and running the policy disagree, so the file is not data; the run result is ${canonical(ran)} and the fold is ${canonical(folded)}`); + } + } + return assertShape(folded, path); + } + const text = readFileSync(path, "utf-8"); + return assertShape(lower.endsWith(".json") ? JSON.parse(text) : parseYaml(text), path); +} diff --git a/src/index.ts b/src/index.ts index 12dbd26..aae3975 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,3 +9,9 @@ // Forgejo REST client export { createClient, ForgejoApiError } from "./auth/client.js"; export type { ForgejoClient, ForgejoClientOptions } from "./auth/client.js"; + +// The governance policy: its types, for `satisfies GovernanceConfig` in a +// `.ts` policy, and the loader that folds, runs, or checks one. +export type * from "./config/types.js"; +export { loadGovernanceConfig, GovernanceConfigError } from "./config/load.js"; +export type { ConfigMode } from "./config/load.js"; diff --git a/tsconfig.types.json b/tsconfig.types.json new file mode 100644 index 0000000..2fa1923 --- /dev/null +++ b/tsconfig.types.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist/types", + "rootDir": "src" + }, + "include": [ + "src/index.ts" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts" + ] +}