From 88a749bb4682aef21b12b8cf1acf35ec4f36e931 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:36:00 -0600 Subject: [PATCH 1/3] Accept governance.ts beside governance.yml, folded by the typescript-as-data reference (#32) A .ts policy is an object literal typed by GovernanceConfig, exported as `policy`. By default it is folded to its value by @intentius/tsad-reference without being run (--config-mode fold); run mode imports it instead; check mode does both and refuses a file on which they differ. The loader normalises through JSON so an undefined property is absent on either path, which is what selective-by-omission reads. The package now exports its policy types (dist/types via tsc) and the loader from a library entry (dist/index.js), and both bundles mark the reference external so its TypeScript dependency is not bundled. examples/governance.{ts,yml} are the same policy twice, asserted equal in src/config/load.test.ts, with helpers.ts for the shared parts and not-data.ts for the file fold refuses and run accepts. Two limits of the subset surfaced writing the example and are filed in the spec repository: a default export does not fold (F-Scan), and a same-file arrow-function helper called as a value does not fold; a helper in a sibling file does. --- examples/governance.ts | 23 +++++++++ examples/governance.yml | 36 +++++++++++++ examples/helpers.ts | 22 ++++++++ examples/not-data.ts | 11 ++++ package.json | 17 ++++-- src/cli.ts | 24 ++++----- src/config/load.test.ts | 45 ++++++++++++++++ src/config/load.ts | 112 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 6 +++ tsconfig.types.json | 18 +++++++ 10 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 examples/governance.ts create mode 100644 examples/governance.yml create mode 100644 examples/helpers.ts create mode 100644 examples/not-data.ts create mode 100644 src/config/load.test.ts create mode 100644 src/config/load.ts create mode 100644 tsconfig.types.json diff --git a/examples/governance.ts b/examples/governance.ts new file mode 100644 index 0000000..9e6f04d --- /dev/null +++ b/examples/governance.ts @@ -0,0 +1,23 @@ +// The same policy as governance.yml, as data in TypeScript. The type gives +// completion and a compile error on a misspelt key; the helper in helpers.ts +// 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. The export is named because the statically evaluable subset admits +// named exports only (spec F-Scan); `policy` is the name warden looks for. +import type { GovernanceConfig } from "@intentius/forgejo-warden"; +import { service } from "./helpers.ts"; + +export const policy = { + 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/helpers.ts b/examples/helpers.ts new file mode 100644 index 0000000..2b0f7df --- /dev/null +++ b/examples/helpers.ts @@ -0,0 +1,22 @@ +// Helpers the policy calls. A project-local function folds by interpretation +// when its body is one expression or consts and a return (spec S-FnBody), and +// a call into a sibling file is the form the subset admits (the import names +// the .ts extension so node can run the file too); a same-file arrow +// const called as a value is not (yet) data. +export function service(name: string) { + return { + hasWiki: false, + hasPullRequests: true, + allowSquashMerge: true, + topics: ["service", name], + branchProtection: [protectedMain], + }; +} + +export const protectedMain = { + 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..634b234 --- /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 const policy = { + orgs: { + "my-org": { + repos: { + api: { hasWiki: process.env.WIKI === "1" }, + }, + }, + }, +}; diff --git a/package.json b/package.json index 09e04fb..d67b1a4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@intentius/forgejo-warden", "version": "0.3.0", "type": "module", - "description": "Keep your Forgejo org and repos in declared state — reconcile, guardrails, drift correction", + "description": "Keep your Forgejo org and repos in declared state \u2014 reconcile, guardrails, drift correction", "license": "Apache-2.0", "repository": { "type": "git", @@ -29,12 +29,14 @@ "test:e2e:run": "vitest run --config vitest.e2e.config.ts", "e2e:up": "bash e2e/bootstrap.sh", "e2e:down": "docker compose -f e2e/docker-compose.yml down -v", - "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --banner:js=\"import{createRequire as __cr}from'module';const require=__cr(import.meta.url);\" --outfile=dist/cli.js && chmod +x dist/cli.js", - "prepublishOnly": "npm run build" + "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --banner:js=\"import{createRequire as __cr}from'module';const require=__cr(import.meta.url);\" --external:@intentius/tsad-reference --outfile=dist/cli.js && chmod +x dist/cli.js && esbuild src/index.ts --bundle --platform=node --format=esm --external:@intentius/chant --external:@intentius/tsad-reference --banner:js=\"import{createRequire as __cr}from'module';const require=__cr(import.meta.url);\" --outfile=dist/index.js && npm run build:types", + "prepublishOnly": "npm run build", + "build:types": "tsc -p tsconfig.types.json" }, "dependencies": { "@intentius/chant": "^0.56.0", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "@intentius/tsad-reference": "^1.1.0" }, "devDependencies": { "@intentius/chant-lexicon-github": "^0.56.0", @@ -43,5 +45,12 @@ "sentences": "^0.1.1", "typescript": "^5.9.3", "vitest": "^4.1.9" + }, + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "default": "./dist/index.js" + } } } 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..5e7d122 --- /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 const policy = { 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..12a645a --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,112 @@ +/** + * The governance policy, loaded from YAML, JSON, or TypeScript. + * + * A `.ts` policy is data: an object literal typed by `GovernanceConfig`, + * exported as `policy` (a default export does not fold; the subset admits named + * exports only). 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 { + // Named, not default: the statically evaluable subset admits named exports + // only (spec F-Scan), so `export const policy` is the form that folds. A + // default export is accepted in run mode for a file that was never meant to + // fold. + if ("policy" in exports) return exports.policy; + if ("default" in exports) return exports.default; + throw new GovernanceConfigError(`${where} must export the policy as \`export const policy\``); +} + +/** Fold the policy with the reference evaluator: no execution, a located refusal if the file is not data. */ +async function foldPolicy(path: string): Promise { + const { foldProject } = await import("@intentius/tsad-reference"); + const root = dirname(resolve(path)); + const key = relative(root, resolve(path)).split("\\").join("/"); + const verdicts = foldProject(projectFiles(root)).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" + ] +} From e92b9fe21e72fe22925d3007985848c4af5ff581 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:02:23 -0600 Subject: [PATCH 2/3] governance.ts is the idiom: export default and a same-file helper, under the data-host profile of spec 1.2 The two limits the first example worked around are gone: spec 1.2 admits export default in the data-host profile (S-ExportDefault) and binds a same-file arrow helper as a project-local function (S-LocalFunction). The loader folds under that profile now, the example is the file anyone would write first, and helpers.ts is no longer needed. --- examples/governance.ts | 27 ++++++++++++++++++++------- examples/helpers.ts | 22 ---------------------- examples/not-data.ts | 2 +- package.json | 2 +- src/config/load.test.ts | 2 +- src/config/load.ts | 19 +++++++++---------- 6 files changed, 32 insertions(+), 42 deletions(-) delete mode 100644 examples/helpers.ts diff --git a/examples/governance.ts b/examples/governance.ts index 9e6f04d..9066aed 100644 --- a/examples/governance.ts +++ b/examples/governance.ts @@ -1,13 +1,26 @@ // The same policy as governance.yml, as data in TypeScript. The type gives -// completion and a compile error on a misspelt key; the helper in helpers.ts -// 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. The export is named because the statically evaluable subset admits -// named exports only (spec F-Scan); `policy` is the name warden looks for. +// 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"; -import { service } from "./helpers.ts"; -export const policy = { +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: { diff --git a/examples/helpers.ts b/examples/helpers.ts deleted file mode 100644 index 2b0f7df..0000000 --- a/examples/helpers.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Helpers the policy calls. A project-local function folds by interpretation -// when its body is one expression or consts and a return (spec S-FnBody), and -// a call into a sibling file is the form the subset admits (the import names -// the .ts extension so node can run the file too); a same-file arrow -// const called as a value is not (yet) data. -export function service(name: string) { - return { - hasWiki: false, - hasPullRequests: true, - allowSquashMerge: true, - topics: ["service", name], - branchProtection: [protectedMain], - }; -} - -export const protectedMain = { - ruleName: "main", - requiredApprovals: 1, - enableStatusCheck: true, - statusCheckContexts: ["ci"], - dismissStaleApprovals: true, -}; diff --git a/examples/not-data.ts b/examples/not-data.ts index 634b234..ca84ce1 100644 --- a/examples/not-data.ts +++ b/examples/not-data.ts @@ -1,6 +1,6 @@ // 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 const policy = { +export default { orgs: { "my-org": { repos: { diff --git a/package.json b/package.json index d67b1a4..84c3916 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "@intentius/chant": "^0.56.0", "yaml": "^2.9.0", - "@intentius/tsad-reference": "^1.1.0" + "@intentius/tsad-reference": "^1.2.0" }, "devDependencies": { "@intentius/chant-lexicon-github": "^0.56.0", diff --git a/src/config/load.test.ts b/src/config/load.test.ts index 5e7d122..dc4f357 100644 --- a/src/config/load.test.ts +++ b/src/config/load.test.ts @@ -30,7 +30,7 @@ describe("loadGovernanceConfig", () => { 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 const policy = { orgs: { o: { repos: { r: { hasWiki: on ? true : undefined } } } } };\n'); + 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); diff --git a/src/config/load.ts b/src/config/load.ts index 12a645a..c6573e5 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -2,8 +2,7 @@ * The governance policy, loaded from YAML, JSON, or TypeScript. * * A `.ts` policy is data: an object literal typed by `GovernanceConfig`, - * exported as `policy` (a default export does not fold; the subset admits named - * exports only). By default it is *folded*, reduced + * 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 @@ -46,21 +45,21 @@ function projectFiles(root: string): Map { } function policyOf(exports: Record, where: string): unknown { - // Named, not default: the statically evaluable subset admits named exports - // only (spec F-Scan), so `export const policy` is the form that folds. A - // default export is accepted in run mode for a file that was never meant to - // fold. - if ("policy" in exports) return exports.policy; + // `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; - throw new GovernanceConfigError(`${where} must export the policy as \`export const policy\``); + 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 { - const { foldProject } = await import("@intentius/tsad-reference"); + // 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)).verdicts; + 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") { From ebbfbaf3a4089908368572ca3d20bbcf5f7860d4 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:06:35 -0600 Subject: [PATCH 3/3] deps: @intentius/tsad-reference ^1.4.0, now on npm, and the lockfile that names it The package is published, so npm ci works on this branch; the lockfile carries the real resolution rather than a packed tarball. --- package-lock.json | 17 +++++++++++++++++ package.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 34521d0..89e1190 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@intentius/chant": "^0.56.0", + "@intentius/tsad-reference": "^1.4.0", "yaml": "^2.9.0" }, "bin": { @@ -518,6 +519,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 84c3916..5a619b1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "@intentius/chant": "^0.56.0", "yaml": "^2.9.0", - "@intentius/tsad-reference": "^1.2.0" + "@intentius/tsad-reference": "^1.4.0" }, "devDependencies": { "@intentius/chant-lexicon-github": "^0.56.0",