From 6f43ceff447ef3c49c191ee7c43d3781d6d83140 Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 29 Jul 2026 15:27:18 +0300 Subject: [PATCH 1/3] feat: Align open-security with @hasna/contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align open-security with @hasna/contracts Run the @hasna/contracts conformance check for this repo and fix every violation until it passes — ensure a valid hasna.contract.json, published_artifact_gate satisfied (artifact-scan wired into prepack), storage waiver where applicable, and all required metadata/scripts present. Keep changes minimal and conformant. Open a PR; do not merge. X-Factory-Run: run_6e30283fde98 X-Factory-Task: ed0d9396-105c-45fe-afee-e595d1f02c31 --- bun.lock | 2 +- hasna.contract.json | 9 +++++++++ package.json | 5 ++++- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 hasna.contract.json diff --git a/bun.lock b/bun.lock index f0c470a..21cadcd 100644 --- a/bun.lock +++ b/bun.lock @@ -3,7 +3,7 @@ "configVersion": 1, "workspaces": { "": { - "name": "@hasnaxyz/open-security", + "name": "@hasna/shield", "dependencies": { "@hasna/events": "^0.1.6", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/hasna.contract.json b/hasna.contract.json new file mode 100644 index 0000000..bb8e5b2 --- /dev/null +++ b/hasna.contract.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "package": "@hasna/shield", + "waivers": { + "storage": { + "reason": "Shield intentionally uses package-owned local SQLite storage." + } + } +} diff --git a/package.json b/package.json index 44a45fe..7a2a419 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ } }, "scripts": { + "contracts:check": "npx --yes @hasna/contracts check", "build": "bun run build:lib && bun run build:types && bun run build:cli && bun run build:mcp && bun run build:server && bun run build:sdk && bun run build:dashboard", "build:lib": "bun build src/index.ts --outdir dist --target node", + "artifact-scan": "bun scripts/no-cloud-artifact-scan.mjs", "build:types": "tsc -p tsconfig.build.json", "build:cli": "bun build src/cli/index.tsx --outfile dist/cli/index.js --target node", "build:mcp": "bun build src/mcp/index.ts --outfile dist/mcp/index.js --target node", @@ -37,7 +39,7 @@ "check:oss-policy": "bun run src/cli/index.tsx oss-secrets-policy . --strict", "no-cloud:source": "bun test src/no-cloud-boundary.test.ts", "no-cloud:pack": "bun run build && bun scripts/no-cloud-artifact-scan.mjs", - "prepack": "bun run build && bun scripts/no-cloud-artifact-scan.mjs", + "prepack": "bun run build && bun run artifact-scan", "prepublishOnly": "bun run check:secrets && bun run test && bun run build && bun scripts/no-cloud-artifact-scan.mjs", "test:coverage": "bun test --coverage", "typecheck": "tsc --noEmit", @@ -70,6 +72,7 @@ "sdk/dist", "dashboard/dist", "README.md", + "hasna.contract.json", "LICENSE" ], "publishConfig": { From 665d0d0524123ab81eddcce59ca1c828496a60bc Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 30 Jul 2026 01:29:36 +0300 Subject: [PATCH 2/3] fix: emit a valid hasna.service_contract.v1 manifest and a real contracts gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest added by this PR was not a hasna.service_contract.v1 document: all five required fields (schema, name, class, contractVersion, kitVersion) were absent, and the three fields it did write (version, package, waivers) are rejected as unknown keys by the strict schema. Because the file is listed in package.json `files`, npm publish shipped that invalid manifest to every consumer. - hasna.contract.json: real v1 shape for the cli-with-store class shield actually is — bins, user-hosted story, the local SQLite boundary (HASNA_SHIELD_, ~/.hasna/shield/shield.db), the cli/mcp/sdk surfaces it ships, and metadata.release.artifactScan pointing at the artifact-scan script prepack already reaches. The API surface is declared `deferred` with a reason: shield-serve does not answer GET /health, /ready, or /version, so declaring it supported would assert endpoints that do not exist. - Drop the storage waiver. It could never apply: sqlite is not in WAIVABLE_STORAGE_ENGINES, and a cli-with-store repo shipping shield-serve is refused a postgres waiver outright, so the declaration would be silently ignored while reading as an approved exception. - contracts:check invoked `@hasna/contracts check`, which is not a subcommand in any published version — the gate could only ever fail with "unknown command". It now runs the documented repo self-check (`contracts repo-conformance .`), and a new contracts:manifest script runs `contracts validate hasna.contract.json`. Both resolve the binary from a pinned @hasna/contracts devDependency so the gate is reproducible from the lockfile instead of from whatever a package runner resolves at run time. - Enforce contracts:manifest in CI and cover the manifest in the test suite (src/contract-manifest.test.ts) against the real ServiceContractManifestSchema, so the repo cannot drift from the kit it claims to track. 7 of the 8 new assertions fail on the manifest this commit replaces. - docs/contract-conformance.md records the four capability gaps that keep contracts:check non-zero (no PostgreSQL engine, no health topology, no self-host artifact, hand-written SDK) instead of hiding them behind a waiver conformance would ignore. --- .github/workflows/ci.yml | 1 + bun.lock | 3 ++ docs/contract-conformance.md | 51 ++++++++++++++++++ hasna.contract.json | 60 +++++++++++++++++++-- package.json | 4 +- src/contract-manifest.test.ts | 98 +++++++++++++++++++++++++++++++++++ 6 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 docs/contract-conformance.md create mode 100644 src/contract-manifest.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00688b4..dce87d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,5 +14,6 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 - run: bun install --frozen-lockfile --ignore-scripts - run: bun run check:secrets + - run: bun run contracts:manifest - run: bun run typecheck - run: bun run test diff --git a/bun.lock b/bun.lock index 21cadcd..099fae8 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "zod": "^3.24.4", }, "devDependencies": { + "@hasna/contracts": "0.8.5", "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.2.12", "@types/express": "^5.0.2", @@ -28,6 +29,8 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@hasna/contracts": ["@hasna/contracts@0.8.5", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/contracts-cli.js" } }, "sha512-Iyk5srD6J3R7ci2g0gkyt+MCa6Da/lghPosGtgWBRgtJeGYCIAAnHF1yweoXnnqGAWZajFuDgJEWDVH11qpUhA=="], + "@hasna/events": ["@hasna/events@0.1.6", "", { "dependencies": { "commander": "^13.1.0" }, "bin": { "events": "dist/cli/index.js", "hasna-events": "dist/cli/index.js" } }, "sha512-rKLxjBIat45UxciUwh9hKM63l/mcurJXAhgVkYHvrsGnOK9HwExrwgOlylNzDBDb+huiQw9DWdLb31q5oWDfIw=="], "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], diff --git a/docs/contract-conformance.md b/docs/contract-conformance.md new file mode 100644 index 0000000..89ad94a --- /dev/null +++ b/docs/contract-conformance.md @@ -0,0 +1,51 @@ +# Service Contract Conformance Status + +Shield tracks the Hasna Service Contract v1 published by `@hasna/contracts`. The +declaration lives in [`hasna.contract.json`](../hasna.contract.json), which is +shipped inside the published tarball (`package.json` → `files`), so it is read by +consumers and tooling, not just by CI. + +## Gates + +| Command | What it checks | Enforced | +| --- | --- | --- | +| `bun run contracts:manifest` | `hasna.contract.json` is a valid `hasna.service_contract.v1` manifest | Yes — CI step, plus `src/contract-manifest.test.ts` in the test suite | +| `bun run contracts:check` | Full repo self-check (`contracts repo-conformance .`) | Not yet — see the open gates below | + +Both scripts resolve the `contracts` binary from the pinned +`@hasna/contracts` devDependency, so the gate is reproducible from the lockfile +rather than from whatever version a package runner resolves at run time. + +## Declared surfaces + +`shield` is a `cli-with-store` repo: a CLI over a package-owned local SQLite +store at `~/.hasna/shield/shield.db`, plus an MCP server and a REST/dashboard +server. + +- `cli` — supported (`shield`) +- `mcp` — supported (`shield-mcp`) +- `sdk` — supported (`./sdk` export, `@hasna/shield-sdk`) +- `api` — **deferred**. `shield-serve` answers `/api/*` on localhost but does not + serve the contract topology (`GET /health`, `GET /ready`, `GET /version`) and + publishes no OpenAPI document. + +## Open gates + +`bun run contracts:check` currently exits non-zero. These are real capability +gaps, not manifest defects, and each needs product work rather than a metadata +edit. They are recorded here rather than papered over with a waiver, because a +`cli-with-store` repo that ships `-serve` is not eligible for either a +storage-engine waiver (`WAIVABLE_STORAGE_ENGINES` excludes `sqlite`, and the +serve bin disqualifies the `postgres` waiver) or a service-surface waiver +(library-only). A waiver declared here would be silently ignored by conformance +while reading like an approved exception. + +| Check | Gap | What closes it | +| --- | --- | --- | +| `storage_capabilities` | Shield has no PostgreSQL backend. `src/db` is `bun:sqlite` throughout, and `resolveStorageMode()` rejects every non-local mode. `src/db/pg-migrations.ts` holds translated DDL but nothing executes it. | Implement the PostgreSQL engine behind the existing db layer, then declare `storage.engines` and `storage.pgTestGate` (a live-PG proof command). | +| `surface_matrix`, `service_api_topology` | No `GET /health`, `GET /ready`, or `GET /version` on `shield-serve`, so the API surface cannot be declared `supported`. | Serve the three contract endpoints, then promote the `shield-api` surface to `supported`. | +| `self_host_artifact` | No `Dockerfile` or compose file for `shield-serve`. | Add one self-host deployment artifact. | +| `surface_bindings` | The SDK is hand-written (`sdk/src/client.ts`), so `generatedFrom` cannot honestly point at an OpenAPI document. | Publish an OpenAPI document from `shield-serve` and generate the SDK client from it. | + +Until those land, `contracts:check` is the honest status report and +`contracts:manifest` is the enforced gate. diff --git a/hasna.contract.json b/hasna.contract.json index bb8e5b2..b592ca1 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -1,9 +1,59 @@ { - "version": 1, - "package": "@hasna/shield", - "waivers": { - "storage": { - "reason": "Shield intentionally uses package-owned local SQLite storage." + "schema": "hasna.service_contract.v1", + "name": "shield", + "class": "cli-with-store", + "contractVersion": "v1", + "kitVersion": "0.8.5", + "description": "AI-powered security scanner for git repos. Ships the shield CLI, an MCP server, and a REST/dashboard server over a package-owned local SQLite store.", + "bins": [ + "shield", + "shield-mcp", + "shield-serve" + ], + "hosting": [ + "user-hosted" + ], + "storage": { + "mode": "sqlite", + "envPrefix": "HASNA_SHIELD_", + "sqlitePath": "~/.hasna/shield/shield.db" + }, + "serviceSurfaces": [ + { + "name": "shield-cli", + "kind": "cli", + "status": "supported", + "bin": "shield", + "authMode": "local-only" + }, + { + "name": "shield-mcp", + "kind": "mcp", + "status": "supported", + "mcpBin": "shield-mcp", + "authMode": "local-only" + }, + { + "name": "shield-sdk", + "kind": "sdk", + "status": "supported", + "exportSubpath": "./sdk", + "authMode": "local-only" + }, + { + "name": "shield-api", + "kind": "api", + "status": "deferred", + "bin": "shield-serve", + "authMode": "local-only", + "deferReason": "shield-serve exposes /api/* over localhost only and does not yet serve the contract topology (GET /health, GET /ready, GET /version) or publish an OpenAPI document. Declaring it supported would assert endpoints this build does not answer." + } + ], + "metadata": { + "release": { + "artifactScan": { + "script": "artifact-scan" + } } } } diff --git a/package.json b/package.json index 7a2a419..926e7a5 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ } }, "scripts": { - "contracts:check": "npx --yes @hasna/contracts check", + "contracts:manifest": "contracts validate hasna.contract.json", + "contracts:check": "contracts repo-conformance .", "build": "bun run build:lib && bun run build:types && bun run build:cli && bun run build:mcp && bun run build:server && bun run build:sdk && bun run build:dashboard", "build:lib": "bun build src/index.ts --outdir dist --target node", "artifact-scan": "bun scripts/no-cloud-artifact-scan.mjs", @@ -60,6 +61,7 @@ "zod": "^3.24.4" }, "devDependencies": { + "@hasna/contracts": "0.8.5", "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.2.12", "@types/express": "^5.0.2", diff --git a/src/contract-manifest.test.ts b/src/contract-manifest.test.ts new file mode 100644 index 0000000..21745d1 --- /dev/null +++ b/src/contract-manifest.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { ServiceContractManifestSchema } from "@hasna/contracts/schemas"; + +/** + * hasna.contract.json is shipped inside the published tarball (package.json + * `files`), so an invalid manifest is not a local lint problem — it reaches + * every consumer and every tool that reads the contract. These assertions run + * against the real `@hasna/contracts` schema rather than a local copy so the + * repo cannot drift away from the kit it claims to track. + */ + +const repoRoot = join(import.meta.dir, ".."); + +function readJson(relativePath: string): Record { + return JSON.parse(readFileSync(join(repoRoot, relativePath), "utf-8")) as Record; +} + +const rawManifest = readJson("hasna.contract.json"); +const pkg = readJson("package.json") as { + bin?: Record; + files?: string[]; + scripts?: Record; + exports?: Record; +}; + +describe("hasna.contract.json", () => { + test("validates against the hasna.service_contract.v1 schema", () => { + const result = ServiceContractManifestSchema.safeParse(rawManifest); + const issues = result.success + ? [] + : result.error.issues.map((issue) => `${issue.path.join(".") || ""} ${issue.message}`); + expect(issues).toEqual([]); + expect(result.success).toBe(true); + }); + + test("declares the app identity the published package uses", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + expect(manifest.schema).toBe("hasna.service_contract.v1"); + expect(manifest.name).toBe("shield"); + expect(manifest.contractVersion).toBe("v1"); + expect(manifest.kitVersion).toBe("0.8.5"); + }); + + test("declared bins match package.json bin", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + expect([...manifest.bins].sort()).toEqual(Object.keys(pkg.bin ?? {}).sort()); + }); + + test("declares the local SQLite storage boundary shield actually owns", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + expect(manifest.class).toBe("cli-with-store"); + expect(manifest.storage?.mode).toBe("sqlite"); + expect(manifest.storage?.envPrefix).toBe("HASNA_SHIELD_"); + expect(manifest.storage?.sqlitePath).toEndWith(".db"); + }); + + test("every supported surface binds to a real package entrypoint", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + const bins = Object.keys(pkg.bin ?? {}); + const exportSubpaths = Object.keys(pkg.exports ?? {}); + const supported = manifest.serviceSurfaces.filter((surface) => surface.status === "supported"); + // cli, mcp, and sdk are the three surfaces shield ships today; the API + // surface is declared `deferred` until shield-serve answers the contract + // topology, so it is deliberately absent here. + expect(supported.map((surface) => surface.kind).sort()).toEqual(["cli", "mcp", "sdk"]); + for (const surface of supported) { + if (surface.bin) expect(bins).toContain(surface.bin); + if (surface.mcpBin) expect(bins).toContain(surface.mcpBin); + if (surface.exportSubpath) expect(exportSubpaths).toContain(surface.exportSubpath); + } + }); + + test("the declared release gate names a real script that prepack reaches", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + const scriptName = manifest.metadata?.release?.artifactScan?.script; + expect(scriptName).toBeTruthy(); + const scripts = pkg.scripts ?? {}; + expect(Object.keys(scripts)).toContain(scriptName!); + expect(scripts.prepack ?? "").toContain(scriptName!); + }); + + test("is shipped in the published tarball", () => { + expect(pkg.files ?? []).toContain("hasna.contract.json"); + }); + + test("claims no conformance waiver it is not eligible for", () => { + const manifest = ServiceContractManifestSchema.parse(rawManifest); + // A `cli-with-store` repo shipping `shield-serve` may not waive a storage + // engine or a service surface (WAIVABLE_STORAGE_ENGINES excludes sqlite, + // and surface waivers are library-only), so a declared waiver here would + // be silently ignored by conformance while reading as an approved + // exception. + expect(manifest.metadata?.conformance?.waivedStorageEngines ?? []).toEqual([]); + expect(manifest.metadata?.conformance?.waivedSurfaces ?? []).toEqual([]); + }); +}); From 511956557fe4d94199dde0e1465dd628634d868c Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Thu, 30 Jul 2026 01:50:23 +0300 Subject: [PATCH 3/3] fix(contracts): declare the SQLite store shield actually owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `storage.sqlitePath` named `~/.hasna/shield/shield.db`, which is not the store: `getDbPath()` resolves `~/.hasna/security/shield.db`, and the `~/.hasna/shield` path is a legacy migration source that is copied FROM and never written to. The manifest ships inside the published tarball, so fleet tooling that reads `hasna.contract.json` to locate each app's SQLite file got the wrong answer for shield alone — a backup pass would open a file that exists on no current install and report an empty success, and a restore written there is silently ignored whenever the real store already exists. The test named for that boundary asserted only `toEndWith(".db")`, and the schema checks nothing more, so an arbitrary wrong directory passed both gates. It now resolves the path the CLI would use under a throwaway HOME with no `SECURITY_DB` or storage-mode override and compares it to the declaration, so the two cannot drift apart in either direction. - hasna.contract.json: sqlitePath -> ~/.hasna/security/shield.db - docs/contract-conformance.md: same correction, and say why the legacy path is not the store - src/db/database.ts: export `resolveDbPath()` so the declaration can be checked against the resolver without opening the shared connection --- docs/contract-conformance.md | 6 ++-- hasna.contract.json | 2 +- src/contract-manifest.test.ts | 54 +++++++++++++++++++++++++++++++++-- src/db/database.ts | 11 +++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/contract-conformance.md b/docs/contract-conformance.md index 89ad94a..867ca79 100644 --- a/docs/contract-conformance.md +++ b/docs/contract-conformance.md @@ -19,8 +19,10 @@ rather than from whatever version a package runner resolves at run time. ## Declared surfaces `shield` is a `cli-with-store` repo: a CLI over a package-owned local SQLite -store at `~/.hasna/shield/shield.db`, plus an MCP server and a REST/dashboard -server. +store at `~/.hasna/security/shield.db`, plus an MCP server and a REST/dashboard +server. That is the path `getDbPath()` resolves and the path `postinstall` +creates; `~/.hasna/shield/shield.db` is a legacy location shield migrates *from* +and never writes to, so it must not be declared as the store. - `cli` — supported (`shield`) - `mcp` — supported (`shield-mcp`) diff --git a/hasna.contract.json b/hasna.contract.json index b592ca1..4f33f5f 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -16,7 +16,7 @@ "storage": { "mode": "sqlite", "envPrefix": "HASNA_SHIELD_", - "sqlitePath": "~/.hasna/shield/shield.db" + "sqlitePath": "~/.hasna/security/shield.db" }, "serviceSurfaces": [ { diff --git a/src/contract-manifest.test.ts b/src/contract-manifest.test.ts index 21745d1..b34656c 100644 --- a/src/contract-manifest.test.ts +++ b/src/contract-manifest.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; import { join } from "path"; import { ServiceContractManifestSchema } from "@hasna/contracts/schemas"; +import { resolveDbPath } from "./db/database.js"; /** * hasna.contract.json is shipped inside the published tarball (package.json @@ -25,6 +27,50 @@ const pkg = readJson("package.json") as { exports?: Record; }; +const STORE_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "SECURITY_DB", + "HASNA_SHIELD_STORAGE_MODE", + "HASNA_SECURITY_STORAGE_MODE", +] as const; + +/** + * Resolves the store the way the shield CLI does — throwaway HOME, no + * `SECURITY_DB` override, no storage-mode override, and a working directory with + * neither a `.security` nor a `.shield` folder so the project-local branches of + * `getDbPath()` do not win — then reports it in the manifest's own `~`-relative + * form. Comparing the declared `storage.sqlitePath` against this is the only + * assertion that fails when the manifest names a directory shield does not + * actually write to; the schema itself only checks the `.db` suffix. + */ +function resolveStorePathUnderTempHome(): string { + const saved = Object.fromEntries(STORE_ENV_KEYS.map((key) => [key, process.env[key]])); + const originalCwd = process.cwd(); + const root = mkdtempSync(join(tmpdir(), "shield-contract-store-")); + const home = join(root, "home"); + const workDir = join(root, "work"); + try { + mkdirSync(home, { recursive: true }); + mkdirSync(workDir, { recursive: true }); + for (const key of STORE_ENV_KEYS) delete process.env[key]; + process.env.HOME = home; + process.chdir(workDir); + + const resolved = resolveDbPath(); + expect(resolved.startsWith(home + "/")).toBe(true); + return "~" + resolved.slice(home.length); + } finally { + process.chdir(originalCwd); + for (const key of STORE_ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); + } +} + describe("hasna.contract.json", () => { test("validates against the hasna.service_contract.v1 schema", () => { const result = ServiceContractManifestSchema.safeParse(rawManifest); @@ -53,7 +99,11 @@ describe("hasna.contract.json", () => { expect(manifest.class).toBe("cli-with-store"); expect(manifest.storage?.mode).toBe("sqlite"); expect(manifest.storage?.envPrefix).toBe("HASNA_SHIELD_"); - expect(manifest.storage?.sqlitePath).toEndWith(".db"); + // The schema only checks the `.db` suffix, so a suffix assertion would let + // any wrong directory ship. Resolve the store the way the CLI does and + // compare: fleet tooling reads this value to find shield's SQLite file, and + // `~/.hasna/shield/shield.db` is a legacy migration source, not the store. + expect(manifest.storage?.sqlitePath).toBe(resolveStorePathUnderTempHome()); }); test("every supported surface binds to a real package entrypoint", () => { diff --git a/src/db/database.ts b/src/db/database.ts index c817f15..7ccb4e1 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -47,6 +47,17 @@ function getDbPath(): string { return dbPath; } +/** + * Resolves — and prepares, exactly as `getDb()` would — the SQLite file this + * store lives in, without opening a connection or touching the singleton. + * `hasna.contract.json` declares that location to fleet tooling, so the + * contract test asserts the declaration against this resolver instead of a + * hardcoded string that could silently drift from `getDbPath()`. + */ +export function resolveDbPath(): string { + return getDbPath(); +} + let _postInitCallbacks: Array<() => void> = []; let _initialized = false;