Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist
.secrets
.connect
*.log
banking-artifact-scan.tgz
65 changes: 65 additions & 0 deletions hasna.contract.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
}
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dist",
"docs",
"scripts/smoke-mercury-live.ts",
"hasna.contract.json",
"README.md",
"CHANGELOG.md",
"LICENSE",
Expand All @@ -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": [
Expand Down
161 changes: 161 additions & 0 deletions tests/contract-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
files?: string[];
scripts?: Record<string, string>;
};

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}`);
});
});
Loading