diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f674135..769b2a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,12 @@ jobs: - name: Verify release build run: bun run verify:release + - name: Check repository contract conformance + run: bun run contracts:check + + - name: Scan the packed artifact + run: bun run artifact-scan + - name: Smoke CLI and MCP entrypoints run: | bun run smoke:cli diff --git a/.gitignore b/.gitignore index 26c8723..b3aca68 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist .secrets .connect *.log +banking-artifact-scan.tgz diff --git a/hasna.contract.json b/hasna.contract.json new file mode 100644 index 0000000..327e166 --- /dev/null +++ b/hasna.contract.json @@ -0,0 +1,65 @@ +{ + "$schema": "./node_modules/@hasna/contracts/src/hasna.contract.schema.json", + "schema": "hasna.service_contract.v1", + "name": "banking", + "class": "cli-with-store", + "contractVersion": "v1", + "kitVersion": "0.8.5", + "description": "Agent-safe banking control plane for Hasna infrastructure and AI agents. Ships intent, policy, and provider types plus CLI and MCP surfaces; production storage is supplied by the host through the BankingCoreStore interface.", + "bins": [ + "banking", + "banking-mcp" + ], + "hosting": [ + "user-hosted" + ], + "serviceSurfaces": [ + { + "name": "package-sdk", + "kind": "sdk", + "status": "supported", + "authMode": "none", + "exportSubpath": "." + }, + { + "name": "cli", + "kind": "cli", + "status": "supported", + "bin": "banking", + "authMode": "local-only" + }, + { + "name": "mcp", + "kind": "mcp", + "status": "supported", + "mcpBin": "banking-mcp", + "authMode": "local-only" + } + ], + "storage": { + "mode": "sqlite", + "engines": [ + "sqlite" + ], + "envPrefix": "HASNA_BANKING_", + "aliasEnvPrefix": "BANKING_", + "sqlitePath": "~/.hasna/banking/banking.db" + }, + "metadata": { + "release": { + "artifactScan": { + "script": "artifact-scan" + } + }, + "conformance": { + "waivedStorageEngines": [ + { + "engine": "postgres", + "reason": "The bundled SQLite adapter is a development fixture that defaults to an in-memory database; hosts supply production storage, PostgreSQL included, through the BankingCoreStore interface in src/core/store.ts.", + "reviewedBy": "platform-storage", + "expiresAt": "2027-01-01T00:00:00.000Z" + } + ] + } + } +} diff --git a/package.json b/package.json index 8df9bd4..b113e3f 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dist", "docs", "scripts/smoke-mercury-live.ts", + "hasna.contract.json", "README.md", "CHANGELOG.md", "LICENSE", @@ -45,11 +46,13 @@ "smoke:mcp": "bun run src/mcp/index.ts --help", "smoke:dist": "bun scripts/smoke-dist.ts", "smoke:mercury:live": "bun scripts/smoke-mercury-live.ts", + "contracts:check": "bunx @hasna/contracts@0.8.5 repo-conformance .", + "artifact-scan": "bun pm pack --ignore-scripts --quiet --filename banking-artifact-scan.tgz && bunx @hasna/contracts@0.8.5 artifact-scan banking-artifact-scan.tgz && rm -f banking-artifact-scan.tgz", "pack:check": "bun pm pack --dry-run --ignore-scripts", "verify:release": "bun run typecheck && bun test && bun run build && bun run smoke:dist && bun run pack:check", "dev:cli": "bun run src/cli/index.ts", "dev:mcp": "bun run src/mcp/index.ts", - "prepack": "bun run verify:release", + "prepack": "bun run verify:release && bun run artifact-scan", "prepublishOnly": "bun run verify:release" }, "keywords": [ diff --git a/tests/contract-manifest.test.ts b/tests/contract-manifest.test.ts new file mode 100644 index 0000000..cf52b46 --- /dev/null +++ b/tests/contract-manifest.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +interface ServiceContractManifest { + readonly schema?: string; + readonly name?: string; + readonly class?: string; + readonly contractVersion?: string; + readonly kitVersion?: string; + readonly bins?: readonly string[]; + readonly hosting?: readonly string[]; + readonly serviceSurfaces?: readonly { readonly kind?: string; readonly status?: string }[]; + readonly metadata?: { + readonly release?: { readonly artifactScan?: { readonly script?: string } }; + }; +} + +const manifest = JSON.parse( + readFileSync(join(root, "hasna.contract.json"), "utf8"), +) as ServiceContractManifest; + +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + bin?: Record; + files?: string[]; + scripts?: Record; +}; + +const scripts = packageJson.scripts ?? {}; + +// Subcommands @hasna/contracts 0.8.5 actually exposes. A script naming anything +// outside this list exits 1 with "unknown command" and can never pass. +const kitSubcommands = [ + "schemas", + "validate", + "conformance", + "no-cloud-scan", + "repo-conformance", + "vendor-kit", + "issue-key", + "artifact-scan", + "secure-local-store", +]; + +function kitInvocations(body: string): { spec: string; subcommand: string | undefined }[] { + return body + .split(/&&|\|\||;/) + .map((segment) => segment.trim().split(/\s+/).filter(Boolean)) + .filter((tokens) => tokens.includes("bunx") || tokens.includes("npx")) + .map((tokens) => { + const runnerIndex = tokens.findIndex((token) => token === "bunx" || token === "npx"); + const operands = tokens.slice(runnerIndex + 1).filter((token) => !token.startsWith("-")); + return { spec: operands[0] ?? "", subcommand: operands[1] }; + }) + .filter((invocation) => invocation.spec.startsWith("@hasna/contracts")); +} + +describe("@hasna/banking service contract manifest", () => { + test("declares the hasna.service_contract.v1 shape the conformance kit validates", () => { + expect(manifest.schema).toBe("hasna.service_contract.v1"); + expect(manifest.contractVersion).toBe("v1"); + // Lowercase dashed app short-name, not the scoped npm package name. + expect(manifest.name).toBe("banking"); + expect(["library", "cli-with-store", "service", "saas"]).toContain(manifest.class as string); + expect(manifest.kitVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(manifest.hosting).toContain("user-hosted"); + }); + + test("declares exactly the bins package.json ships", () => { + expect([...(manifest.bins ?? [])].sort()).toEqual(Object.keys(packageJson.bin ?? {}).sort()); + }); + + test("declares a supported surface for every entrypoint the package ships", () => { + const supported = (manifest.serviceSurfaces ?? []) + .filter((surface) => surface.status === "supported") + .map((surface) => surface.kind); + + expect(supported).toContain("cli"); + expect(supported).toContain("mcp"); + expect(supported).toContain("sdk"); + }); + + test("ships the manifest to consumers", () => { + expect(packageJson.files).toContain("hasna.contract.json"); + }); +}); + +describe("@hasna/banking release gate wiring", () => { + test("names a real package script as the packed-artifact scan", () => { + const declared = manifest.metadata?.release?.artifactScan?.script; + expect(declared).toBeDefined(); + expect(Object.keys(scripts)).toContain(declared as string); + }); + + test("reaches the packed-artifact scan from prepack", () => { + const declared = manifest.metadata?.release?.artifactScan?.script as string; + expect(scripts.prepack).toContain(`bun run ${declared}`); + }); + + test("scans a packed tarball rather than the source tree", () => { + const declared = manifest.metadata?.release?.artifactScan?.script as string; + const body = scripts[declared] ?? ""; + expect(body).toContain("bun pm pack"); + for (const invocation of kitInvocations(body)) { + expect(invocation.subcommand).toBe("artifact-scan"); + } + }); + + test("pins the contract kit in every script that runs it", () => { + for (const [name, body] of Object.entries(scripts)) { + for (const invocation of kitInvocations(body)) { + expect( + invocation.spec, + `script '${name}' must pin the kit version so the gate is reproducible`, + ).toMatch(/^@hasna\/contracts@\d+\.\d+\.\d+$/); + } + } + }); + + test("pins the same kit version the manifest tracks", () => { + for (const body of Object.values(scripts)) { + for (const invocation of kitInvocations(body)) { + expect(invocation.spec).toBe(`@hasna/contracts@${manifest.kitVersion}`); + } + } + }); + + test("invokes only subcommands the contract kit exposes", () => { + for (const [name, body] of Object.entries(scripts)) { + for (const invocation of kitInvocations(body)) { + expect( + kitSubcommands, + `script '${name}' invokes '${invocation.subcommand}', which the kit does not expose`, + ).toContain(invocation.subcommand as string); + } + } + }); + + test("checks repository conformance through the kit's repo-conformance command", () => { + const body = scripts["contracts:check"] ?? ""; + expect(kitInvocations(body).map((invocation) => invocation.subcommand)).toContain( + "repo-conformance", + ); + }); +}); + +describe("@hasna/banking continuous integration", () => { + const workflow = readFileSync(join(root, ".github", "workflows", "ci.yml"), "utf8"); + + test("enforces the conformance gate this repository declares", () => { + expect(workflow).toContain("bun run contracts:check"); + }); + + test("enforces the packed-artifact scan the release gate declares", () => { + const declared = manifest.metadata?.release?.artifactScan?.script as string; + expect(workflow).toContain(`bun run ${declared}`); + }); +});