diff --git a/docs/agent-guidance.md b/docs/agent-guidance.md index 63df3f659..74d7c5f35 100644 --- a/docs/agent-guidance.md +++ b/docs/agent-guidance.md @@ -93,6 +93,17 @@ and domain owner; the disposition does not waive that owner's runtime gates. `tests/pattern-catalogue.test.ts` checks completeness, CLI_SPEC command resolution and the banned-recipe scan. +A card's `example` field names a static example project under +`examples/patterns/` (today HACK-P01 and HACK-P02). The field is contributor +reference only and is not rendered, because the plugin ships without the +repository's `examples/` tree. `tests/pattern-examples.test.ts` checks that each +reference resolves and every fixture belongs to one card. It also validates +each fixture's `hack.config.json` against the project schema and parser, the +P01 Compose file against the `locked-v1` dependency cache contract, and the P02 +toolchain service against its card. Every `hack` command in a fixture README +must resolve in CLI_SPEC and name a declared service and profile. These checks +are static: they do not start containers or qualify the runtime gates. + Skill candidates and where their patterns live now: | Candidate | Decision | Patterns live in | Promotion owner and missing contract | diff --git a/examples/patterns/p01-dependency-bootstrap/.gitignore b/examples/patterns/p01-dependency-bootstrap/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml b/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml new file mode 100644 index 000000000..7ab4a4cdd --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/.hack/docker-compose.yml @@ -0,0 +1,57 @@ +name: pattern-p01 +services: + # One-shot initializer for the immutable dependency payload (HACK-P01). + install: + image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + platform: linux/arm64 + working_dir: /app + entrypoint: [] + command: ["bun", "install", "--frozen-lockfile"] + labels: + hack.dependencies.cache-volume: dependencies + hack.dependencies.lockfiles: bun.lock + hack.dependencies.runtime-files: package.json,packages/greeting/package.json,packages/greeting/index.js + hack.dependencies.cache-protocol: locked-v1 + hack.dependencies.cache-generation: "0" + hack.dependencies.cache-verify: '["bun", "-e", "require.resolve(\"greeting\")"]' + volumes: + - ..:/app + - dependencies:/app/node_modules + + # Worktree-specific generation: always runs, owns its own output volume. + generate: + image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + platform: linux/arm64 + working_dir: /app + command: ["bun", "scripts/generate.ts"] + depends_on: + install: + condition: service_completed_successfully + volumes: + - ..:/app + - dependencies:/app/node_modules:ro + - generated:/generated + + app: + image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + platform: linux/arm64 + working_dir: /app + command: ["bun", "src/server.ts"] + depends_on: + install: + condition: service_completed_successfully + generate: + condition: service_completed_successfully + volumes: + - ..:/app + - dependencies:/app/node_modules:ro + - generated:/generated:ro + healthcheck: + test: ["CMD", "bun", "-e", "const r = await fetch('http://127.0.0.1:3000/healthz'); process.exit(r.ok ? 0 : 1)"] + interval: 5s + timeout: 3s + retries: 12 + +volumes: + dependencies: {} + generated: {} diff --git a/examples/patterns/p01-dependency-bootstrap/.hack/hack.config.json b/examples/patterns/p01-dependency-bootstrap/.hack/hack.config.json new file mode 100644 index 000000000..f60f4a7c6 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/.hack/hack.config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schemas.hack/hack.config.schema.json", + "name": "pattern-p01", + "dev_host": "pattern-p01.hack.local", + "logs": { + "follow_backend": "compose", + "snapshot_backend": "compose", + "clear_on_down": false + } +} diff --git a/examples/patterns/p01-dependency-bootstrap/README.md b/examples/patterns/p01-dependency-bootstrap/README.md new file mode 100644 index 000000000..636ff42a7 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/README.md @@ -0,0 +1,64 @@ +# HACK-P01 example: one-shot dependency and generated-artifact bootstrap + +Minimal project for catalogue card HACK-P01 (tracked in HACK-1145). It shows the +part of the pattern Hack supports today. The card's disposition is **partial**; +see "What this example does not prove" below. + +## Layout + +- `install` is a one-shot initializer. It opts into the + `hack.dependencies.cache-protocol: locked-v1` label, uses a digest-pinned + installer image with an explicit platform, keeps the default (empty) + environment, and writes only the `dependencies` volume. +- `generate` is the worktree-specific generation step. It always runs, owns its + own `generated` volume, and publishes its output by rename. +- `app` starts only after both one-shot services exit successfully. It mounts + `dependencies` and `generated` read-only and has its own health check. + +Installation, generation and application readiness are separate steps. The +verifier label (`require.resolve("greeting")`) checks the real dependency the +application imports, not just a populated directory. + +Set `platform` to the platform of your Docker daemon before you run it. + +## Commands + +Start the project and wait for the detached startup result: + +```sh +hack up --json +``` + +Inspect service state, including the one-shot exits: + +```sh +hack ps --json +``` + +Read the initializer's bounded log: + +```sh +hack logs --no-follow --tail 100 install +``` + +Stop the project. Ordinary `hack down` keeps the dependency volume: + +```sh +hack down +``` + +If an initializer attempt fails or is interrupted, find the cause, then increment +`hack.dependencies.cache-generation` in `.hack/docker-compose.yml` and run +`hack up --json` again. Do not delete protocol markers to force reuse. + +## What this example does not prove + +- Atomic publication of worktree-specific generated artifacts across concurrent + writers. +- Installers that need credentials or environment values. `locked-v1` refuses + explicit `environment` and `env_file` on the initializer. +- Two-worktree or interrupted-writer qualification. Those runtime gates belong to + WU07/WU11. + +The static checks in `tests/pattern-examples.test.ts` validate this config and +its commands. They do not start containers. diff --git a/examples/patterns/p01-dependency-bootstrap/bun.lock b/examples/patterns/p01-dependency-bootstrap/bun.lock new file mode 100644 index 000000000..b86aec310 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "pattern-p01", + "dependencies": { + "greeting": "file:./packages/greeting", + }, + }, + }, + "packages": { + "greeting": ["greeting@file:packages/greeting", {}], + } +} diff --git a/examples/patterns/p01-dependency-bootstrap/package.json b/examples/patterns/p01-dependency-bootstrap/package.json new file mode 100644 index 000000000..fdcf95703 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/package.json @@ -0,0 +1,8 @@ +{ + "name": "pattern-p01", + "private": true, + "type": "module", + "dependencies": { + "greeting": "file:./packages/greeting" + } +} diff --git a/examples/patterns/p01-dependency-bootstrap/packages/greeting/index.js b/examples/patterns/p01-dependency-bootstrap/packages/greeting/index.js new file mode 100644 index 000000000..5efec645a --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/packages/greeting/index.js @@ -0,0 +1,4 @@ +/** Stand-in for an installed dependency that the application imports. */ +export function greet(name) { + return `hello, ${name}`; +} diff --git a/examples/patterns/p01-dependency-bootstrap/packages/greeting/package.json b/examples/patterns/p01-dependency-bootstrap/packages/greeting/package.json new file mode 100644 index 000000000..621d2ca46 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/packages/greeting/package.json @@ -0,0 +1,6 @@ +{ + "name": "greeting", + "version": "1.0.0", + "type": "module", + "main": "index.js" +} diff --git a/examples/patterns/p01-dependency-bootstrap/scripts/generate.ts b/examples/patterns/p01-dependency-bootstrap/scripts/generate.ts new file mode 100644 index 000000000..226717874 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/scripts/generate.ts @@ -0,0 +1,13 @@ +/** + * Worktree-specific generation step. It runs only after the shared dependency + * initializer succeeds and writes only to this instance's `generated` volume. + */ +import { rename, writeFile } from "node:fs/promises"; + +const target = "/generated/build-info.json"; +const pending = `${target}.pending`; +const payload = `${JSON.stringify({ generatedAt: new Date().toISOString() })}\n`; + +// Publish by rename so readers see a complete old or complete new file. +await writeFile(pending, payload); +await rename(pending, target); diff --git a/examples/patterns/p01-dependency-bootstrap/src/server.ts b/examples/patterns/p01-dependency-bootstrap/src/server.ts new file mode 100644 index 000000000..928100814 --- /dev/null +++ b/examples/patterns/p01-dependency-bootstrap/src/server.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import { greet } from "greeting"; + +const buildInfo = JSON.parse( + await readFile("/generated/build-info.json", "utf8") +) as { readonly generatedAt: string }; + +Bun.serve({ + port: 3000, + fetch(request) { + const { pathname } = new URL(request.url); + if (pathname === "/healthz") { + return new Response("ok"); + } + return Response.json({ + message: greet("pattern-p01"), + generatedAt: buildInfo.generatedAt, + }); + }, +}); diff --git a/examples/patterns/p02-portable-toolchain/.gitignore b/examples/patterns/p02-portable-toolchain/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml b/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml new file mode 100644 index 000000000..2012afece --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/.hack/docker-compose.yml @@ -0,0 +1,21 @@ +name: pattern-p02 +services: + # One-shot toolchain behind an opt-in profile (HACK-P02). There is no + # long-running application service in this project. + toolchain: + image: oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f + profiles: [toolchain] + init: true + working_dir: /app + cpus: 2 + mem_limit: 2g + environment: + BUN_INSTALL_CACHE_DIR: /cache/bun + volumes: + - ..:/app + - toolchain_dependencies:/app/node_modules + - toolchain_bun_cache:/cache/bun + +volumes: + toolchain_dependencies: {} + toolchain_bun_cache: {} diff --git a/examples/patterns/p02-portable-toolchain/.hack/hack.config.json b/examples/patterns/p02-portable-toolchain/.hack/hack.config.json new file mode 100644 index 000000000..cbd68d069 --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/.hack/hack.config.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://schemas.hack/hack.config.schema.json", + "name": "pattern-p02", + "dev_host": "pattern-p02.hack.local" +} diff --git a/examples/patterns/p02-portable-toolchain/README.md b/examples/patterns/p02-portable-toolchain/README.md new file mode 100644 index 000000000..f092d68d1 --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/README.md @@ -0,0 +1,48 @@ +# HACK-P02 example: portable toolchain without an always-on application + +Minimal project for catalogue card HACK-P02 (tracked in HACK-1146). It shows the +part of the pattern Hack supports today. The card's disposition is **partial**; +see "What this example does not prove" below. + +## Layout + +The project has no long-running application. `.hack/docker-compose.yml` declares +one one-shot `toolchain` service behind the opt-in `toolchain` profile: + +- a digest-pinned tool image, so every machine runs the same Bun version; +- explicit resource bounds (`cpus`, `mem_limit`); +- separate named volumes for installed dependencies and the Bun download cache; +- no Docker socket, user home, SSH agent or credential directory mounts, and no + `env_file`. Keep project secrets out of the image build context as well. + +## Commands + +Install dependencies into the toolchain's own volume: + +```sh +hack run --profile toolchain toolchain -- bun install --frozen-lockfile +``` + +Run the checks: + +```sh +hack run --profile toolchain toolchain -- bun test +``` + +The command's own exit status is the result. Check it directly; do not pipe the +output through a filter that can hide a failed check. + +The service is one-shot, so there is nothing to keep running. `hack down` keeps +the named cache volumes. + +## What this example does not prove + +- Cache reuse across worktrees with bounded eviction. +- A source receipt for linked worktrees, whose `.git` entry is a pointer file + rather than a directory. +- Owned cache accounting. Those runtime gates belong to WU11. + +A test-only source identity override must never reach release evidence. + +The static checks in `tests/pattern-examples.test.ts` validate this config and +its commands. They do not start containers. diff --git a/examples/patterns/p02-portable-toolchain/package.json b/examples/patterns/p02-portable-toolchain/package.json new file mode 100644 index 000000000..141597024 --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/package.json @@ -0,0 +1,8 @@ +{ + "name": "pattern-p02", + "private": true, + "type": "module", + "scripts": { + "check": "bun test" + } +} diff --git a/examples/patterns/p02-portable-toolchain/src/sum.test.ts b/examples/patterns/p02-portable-toolchain/src/sum.test.ts new file mode 100644 index 000000000..68391b96b --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/src/sum.test.ts @@ -0,0 +1,7 @@ +import { expect, test } from "bun:test"; +import { sum } from "./sum.ts"; + +test("sum adds every value", () => { + expect(sum([1, 2, 3])).toBe(6); + expect(sum([])).toBe(0); +}); diff --git a/examples/patterns/p02-portable-toolchain/src/sum.ts b/examples/patterns/p02-portable-toolchain/src/sum.ts new file mode 100644 index 000000000..c2b7f5431 --- /dev/null +++ b/examples/patterns/p02-portable-toolchain/src/sum.ts @@ -0,0 +1,4 @@ +/** Tiny library code so the toolchain service has something to check. */ +export function sum(values: readonly number[]): number { + return values.reduce((total, value) => total + value, 0); +} diff --git a/src/agents/pattern-catalogue.ts b/src/agents/pattern-catalogue.ts index 82e365656..2aa0b783a 100644 --- a/src/agents/pattern-catalogue.ts +++ b/src/agents/pattern-catalogue.ts @@ -10,6 +10,9 @@ * - Every `hack` invocation a card names resolves in CLI_SPEC on this branch. * - Unsafe recipes (broad process kills, kill-by-port, placeholder liveness, * secrets in argv) appear only inside negative-example blocks. + * - A card's `example` directory exists and is statically validated by + * tests/pattern-examples.test.ts (config schema, Compose contract, README + * commands); every directory under `examples/patterns/` belongs to one card. * * Consumer names stay in the Linear issues; the shipped reference records * only how many consumers a card generalizes from. @@ -55,6 +58,13 @@ export type PatternCard = { /** Contract Hack still lacks; `null` only for `supported` cards. */ readonly missingContract: string | null; readonly provenance: PatternProvenance; + /** + * Repository-relative directory of a static example project that realizes + * the supported part of the card, or `null` when none exists. Contributor + * reference only: it is not rendered into the packaged reference because the + * plugin ships without the repository's `examples/` tree. + */ + readonly example: string | null; }; /** The complete pattern register, in id order. */ @@ -85,6 +95,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "locked-v1 covers one immutable payload per volume. Atomic publication of worktree-specific generated artifacts, env-dependent installers, and two-worktree or interrupted-writer qualification remain open.", provenance: { consumers: 4, source: "HACK-P01 research register" }, + example: "examples/patterns/p01-dependency-bootstrap", }, { id: "HACK-P02", @@ -108,6 +119,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Cross-worktree cache reuse with bounded eviction, a source receipt for linked worktrees, and owned cache accounting remain open.", provenance: { consumers: 4, source: "HACK-P02 research register" }, + example: "examples/patterns/p02-portable-toolchain", }, { id: "HACK-P03", @@ -134,6 +146,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack has no credential availability, expiry or scope check and no machine-identity path for headless runners; the WU27 env contract owns both.", provenance: { consumers: 3, source: "HACK-P03 research register" }, + example: null, }, { id: "HACK-P04", @@ -157,6 +170,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Today's adoption is port-only. Health- and upstream-checked adoption with a recorded adopted-versus-owned state is the WU29 lifecycle ownership contract.", provenance: { consumers: 1, source: "HACK-P04 research register" }, + example: null, }, { id: "HACK-P05", @@ -179,6 +193,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack has no branch-scoped external claim/release contract with bounded retry, stop-during-retry and PID-reuse safety.", provenance: { consumers: 1, source: "HACK-P05 research register" }, + example: null, }, { id: "HACK-P06", @@ -202,6 +217,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack has no opt-in tunnel lifecycle with external-route readiness and owned-child shutdown.", provenance: { consumers: 1, source: "HACK-P06 research register" }, + example: null, }, { id: "HACK-P07", @@ -229,6 +245,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack has env overlays and value-free inspection but no laptop/headless capability contract or headless machine identity; WU27 and WU30 own them.", provenance: { consumers: 1, source: "HACK-P07 research register" }, + example: null, }, { id: "HACK-P08", @@ -251,6 +268,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack has no semantic no-op detection or atomic machine-local import contract for env hydration.", provenance: { consumers: 1, source: "HACK-P08 research register" }, + example: null, }, { id: "HACK-P09", @@ -276,6 +294,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Generation/run ownership, active references, holds and interrupted-reclamation recovery are not implemented for run workspaces.", provenance: { consumers: 1, source: "HACK-P09 research register" }, + example: null, }, { id: "HACK-P10", @@ -302,6 +321,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack does not yet distinguish L1, L2 and cold misses, detect a tampered cache, or emit stage timing and provenance receipts.", provenance: { consumers: 3, source: "HACK-P10 research register" }, + example: null, }, { id: "HACK-P11", @@ -330,6 +350,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "A guidance freeze during execution and a typed terminal result for resumed background operations remain open.", provenance: { consumers: 2, source: "HACK-P11 research register" }, + example: null, }, { id: "HACK-P12", @@ -352,6 +373,7 @@ export const PATTERN_CATALOGUE: readonly PatternCard[] = [ missingContract: "Hack has no multi-lane admission, lease or durable run-provenance contract.", provenance: { consumers: 2, source: "HACK-P12 research register" }, + example: null, }, ]; diff --git a/tests/helpers/json-schema-subset.ts b/tests/helpers/json-schema-subset.ts new file mode 100644 index 000000000..b19b7415e --- /dev/null +++ b/tests/helpers/json-schema-subset.ts @@ -0,0 +1,170 @@ +/** + * Minimal JSON Schema validator for the keyword subset that Hack's legacy + * project config schema (`renderProjectConfigSchemaJson`) uses. + * + * It is closed: a schema keyword outside the supported set is reported as + * `unsupported-keyword` instead of being ignored, so a schema change that this + * helper cannot evaluate fails the caller rather than passing silently. + * Issues carry a JSON pointer and a fixed code; instance values are never + * echoed. + */ + +export type SchemaIssue = { + readonly pointer: string; + readonly code: string; +}; + +const ANNOTATIONS = new Set(["$schema", "$id", "title", "description"]); +const SUPPORTED = new Set([ + "type", + "properties", + "additionalProperties", + "required", + "enum", + "items", + "anyOf", + "minItems", + "uniqueItems", + "minimum", +]); + +type Schema = Readonly>; + +function isSchema(value: unknown): value is Schema { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPlainObject( + value: unknown +): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function escapePointer(segment: string): string { + return segment.replaceAll("~", "~0").replaceAll("/", "~1"); +} + +function matchesType(value: unknown, type: unknown): boolean { + switch (type) { + case "string": + return typeof value === "string"; + case "boolean": + return typeof value === "boolean"; + case "number": + return typeof value === "number" && Number.isFinite(value); + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "object": + return isPlainObject(value); + case "array": + return Array.isArray(value); + case "null": + return value === null; + default: + return false; + } +} + +function sameJson(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +/** Validate `value` against `schema`; an empty result means valid. */ +export function validateJsonSchemaSubset(opts: { + readonly schema: unknown; + readonly value: unknown; +}): SchemaIssue[] { + return validateAt(opts.schema, opts.value, ""); +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: One switch over the closed keyword set keeps the subset auditable. +function validateAt( + schema: unknown, + value: unknown, + pointer: string +): SchemaIssue[] { + if (schema === true) { + return []; + } + if (schema === false) { + return [{ pointer, code: "forbidden" }]; + } + if (!isSchema(schema)) { + return [{ pointer, code: "invalid-schema" }]; + } + const issues: SchemaIssue[] = []; + for (const keyword of Object.keys(schema)) { + if (!(ANNOTATIONS.has(keyword) || SUPPORTED.has(keyword))) { + issues.push({ pointer, code: "unsupported-keyword" }); + } + } + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + if (!types.some((type) => matchesType(value, type))) { + return [...issues, { pointer, code: "type" }]; + } + } + if ( + Array.isArray(schema.enum) && + !schema.enum.some((entry) => sameJson(entry, value)) + ) { + issues.push({ pointer, code: "enum" }); + } + if ( + typeof schema.minimum === "number" && + typeof value === "number" && + value < schema.minimum + ) { + issues.push({ pointer, code: "minimum" }); + } + if (Array.isArray(schema.anyOf)) { + const matched = schema.anyOf.some( + (option) => validateAt(option, value, pointer).length === 0 + ); + if (!matched) { + issues.push({ pointer, code: "any-of" }); + } + } + if (Array.isArray(value)) { + if (typeof schema.minItems === "number" && value.length < schema.minItems) { + issues.push({ pointer, code: "min-items" }); + } + if ( + schema.uniqueItems === true && + new Set(value.map((entry) => JSON.stringify(entry))).size !== value.length + ) { + issues.push({ pointer, code: "unique-items" }); + } + if (schema.items !== undefined) { + value.forEach((entry, index) => { + issues.push(...validateAt(schema.items, entry, `${pointer}/${index}`)); + }); + } + } + if (isPlainObject(value)) { + const properties = isSchema(schema.properties) ? schema.properties : {}; + if (Array.isArray(schema.required)) { + for (const key of schema.required) { + if (typeof key === "string" && !Object.hasOwn(value, key)) { + issues.push({ + pointer: `${pointer}/${escapePointer(key)}`, + code: "required", + }); + } + } + } + for (const [key, entry] of Object.entries(value)) { + const childPointer = `${pointer}/${escapePointer(key)}`; + if (Object.hasOwn(properties, key)) { + issues.push(...validateAt(properties[key], entry, childPointer)); + } else if (schema.additionalProperties === false) { + issues.push({ pointer: childPointer, code: "additional-property" }); + } else if (schema.additionalProperties !== undefined) { + issues.push( + ...validateAt(schema.additionalProperties, entry, childPointer) + ); + } + } + } + return issues; +} diff --git a/tests/helpers/pattern-fixtures.ts b/tests/helpers/pattern-fixtures.ts new file mode 100644 index 000000000..84113d05b --- /dev/null +++ b/tests/helpers/pattern-fixtures.ts @@ -0,0 +1,302 @@ +import { readdir } from "node:fs/promises"; +import { join, normalize } from "node:path"; +import type { PatternCard } from "../../src/agents/pattern-catalogue.ts"; +import { builtinOptions, type CliSpec } from "../../src/cli/command.ts"; +import { isRecord } from "../../src/lib/guards.ts"; +import { extractHackInvocations } from "./cli-guidance-commands.ts"; + +/** + * Static checks for the HACK-P example fixtures under `examples/patterns/`. + * They read files only: no container runtime, no HOME, no network. Every + * finding is a fixed code (plus a card id or Compose service name the fixture + * authored); config values are never echoed. + */ + +export const PATTERN_EXAMPLES_DIR = "examples/patterns"; + +/** Files every example fixture must provide. */ +export const PATTERN_EXAMPLE_FILES = [ + "README.md", + ".hack/hack.config.json", + ".hack/docker-compose.yml", +] as const; + +/** + * Check that card `example` references resolve to fixture directories and + * that every fixture directory belongs to exactly one card. + */ +export async function exampleReferenceIssues(opts: { + readonly repoRoot: string; + readonly cards: readonly PatternCard[]; +}): Promise { + const issues: string[] = []; + const referenced = new Map(); + for (const card of opts.cards) { + if (card.example === null) { + continue; + } + const dir = normalize(card.example); + const parent = `${PATTERN_EXAMPLES_DIR}/`; + const name = dir.slice(parent.length); + if (!dir.startsWith(parent) || name.length === 0 || name.includes("/")) { + issues.push(`${card.id}: example-outside-patterns-dir`); + continue; + } + if (referenced.has(name)) { + issues.push(`${card.id}: example-shared-with-${referenced.get(name)}`); + continue; + } + referenced.set(name, card.id); + for (const file of PATTERN_EXAMPLE_FILES) { + if (!(await Bun.file(join(opts.repoRoot, dir, file)).exists())) { + issues.push(`${card.id}: missing-${file}`); + } + } + const readme = Bun.file(join(opts.repoRoot, dir, "README.md")); + if ((await readme.exists()) && !(await readme.text()).includes(card.id)) { + issues.push(`${card.id}: readme-missing-card-id`); + } + } + const entries = await readdir(join(opts.repoRoot, PATTERN_EXAMPLES_DIR), { + withFileTypes: true, + }); + for (const entry of entries) { + if (entry.isDirectory() && !referenced.has(entry.name)) { + issues.push("unreferenced-example-directory"); + } + } + return issues; +} + +type ComposeService = Readonly>; + +function composeServices( + compose: unknown +): Readonly> { + if (!(isRecord(compose) && isRecord(compose.services))) { + return {}; + } + const services: Record = {}; + for (const [name, service] of Object.entries(compose.services)) { + if (isRecord(service)) { + services[name] = service; + } + } + return services; +} + +function serviceProfiles(service: ComposeService): readonly string[] { + return Array.isArray(service.profiles) + ? service.profiles.filter( + (profile): profile is string => typeof profile === "string" + ) + : []; +} + +type Mount = { readonly source: string; readonly target: string }; + +function serviceMounts(service: ComposeService): Mount[] | null { + if (service.volumes === undefined) { + return []; + } + if (!Array.isArray(service.volumes)) { + return null; + } + const mounts: Mount[] = []; + for (const mount of service.volumes) { + if (typeof mount === "string") { + const [source = "", target = ""] = mount.split(":"); + mounts.push({ source, target }); + } else if ( + isRecord(mount) && + typeof mount.source === "string" && + typeof mount.target === "string" + ) { + mounts.push({ source: mount.source, target: mount.target }); + } else { + return null; + } + } + return mounts; +} + +const DIGEST_PINNED_IMAGE = /@sha256:[a-f0-9]{64}$/; +const DOCKER_SOCKET = /docker\.sock|\/var\/run\/docker|orbstack/i; +const HOME_SOURCE = /^(?:~|\$\{?HOME\}?|\/Users\/|\/home\/|\/root(?:\/|$))/; +const SSH_SOURCE = /SSH_AUTH_SOCK|\.ssh(?:\/|$)|ssh-agent/i; +const CREDENTIAL_SOURCE = + /\.(?:aws|config\/gcloud|kube|docker|npmrc|netrc|gnupg)(?:\/|$)|credentials/i; + +function mountIssues(opts: { + readonly mount: Mount; + readonly namedVolumes: ReadonlySet; +}): string[] { + const { source } = opts.mount; + if (DOCKER_SOCKET.test(source)) { + return ["forbidden-mount-docker-socket"]; + } + if (HOME_SOURCE.test(source)) { + return ["forbidden-mount-home"]; + } + if (SSH_SOURCE.test(source)) { + return ["forbidden-mount-ssh"]; + } + if (CREDENTIAL_SOURCE.test(source)) { + return ["forbidden-mount-credentials"]; + } + // The project checkout is the only permitted bind mount. + if (source === "..") { + return []; + } + return opts.namedVolumes.has(source) ? [] : ["cache-not-named-volume"]; +} + +/** + * HACK-P02 static contract for a portable toolchain service: one-shot behind + * an opt-in profile, digest-pinned image, explicit resource bounds, caches in + * named volumes, and no Docker socket, home, SSH agent or credential mounts. + * The project must not declare an always-on service. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: A flat list of independent card checks reads best in one place. +export function toolchainServiceIssues(opts: { + readonly compose: unknown; + readonly service: string; +}): string[] { + const services = composeServices(opts.compose); + const service = services[opts.service]; + if (service === undefined) { + return ["missing-service"]; + } + const issues: string[] = []; + for (const [name, entry] of Object.entries(services)) { + if (serviceProfiles(entry).length === 0) { + issues.push(`always-on-service-${name}`); + } + } + if ( + !( + typeof service.image === "string" && + DIGEST_PINNED_IMAGE.test(service.image) + ) + ) { + issues.push("image-not-digest-pinned"); + } + if (service.cpus === undefined) { + issues.push("missing-cpu-bound"); + } + if (service.mem_limit === undefined) { + issues.push("missing-memory-bound"); + } + if (service.restart !== undefined && service.restart !== "no") { + issues.push("restart-policy"); + } + if (service.ports !== undefined) { + issues.push("published-ports"); + } + if (service.env_file !== undefined) { + issues.push("env-file"); + } + if (service.privileged === true) { + issues.push("privileged"); + } + const compose = opts.compose; + const namedVolumes = new Set( + isRecord(compose) && isRecord(compose.volumes) + ? Object.keys(compose.volumes) + : [] + ); + const mounts = serviceMounts(service); + if (mounts === null) { + issues.push("unreadable-mounts"); + } else { + for (const mount of mounts) { + issues.push(...mountIssues({ mount, namedVolumes })); + } + if (!mounts.some((mount) => namedVolumes.has(mount.source))) { + issues.push("no-named-cache-volume"); + } + } + return [...new Set(issues)]; +} + +const SERVICE_COMMANDS: ReadonlySet = new Set(["run", "exec", "logs"]); + +/** + * Check that every `hack run|exec|logs ` in a README names a Compose + * service, and that each `--profile` it passes is one of that service's + * profiles (a profiled service also needs its profile on the command line). + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Token walking keeps option arity and positional selection in one pass. +export function readmeServiceIssues(opts: { + readonly cli: CliSpec; + readonly readme: string; + readonly compose: unknown; +}): string[] { + const services = composeServices(opts.compose); + const issues: string[] = []; + for (const invocation of extractHackInvocations(opts.readme)) { + const tokens = invocation.split(/\s+/).slice(1); + const name = tokens[0]; + if (name === undefined || !SERVICE_COMMANDS.has(name)) { + continue; + } + const command = opts.cli.commands.find((entry) => entry.name === name); + if (!command) { + issues.push(`unknown-command-${name}`); + continue; + } + const options = [ + ...builtinOptions(), + ...opts.cli.globalOptions, + ...command.options, + ]; + const profiles: string[] = []; + const positionals: string[] = []; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + if (token === "--") { + break; + } + if (!token.startsWith("--")) { + positionals.push(token); + continue; + } + const [flag = "", inline] = token.split("=", 2); + const option = options.find((entry) => entry.long === flag); + if (option === undefined || option.type === "boolean") { + continue; + } + const value = inline ?? tokens[index + 1] ?? ""; + if (inline === undefined) { + index += 1; + } + if (option.long === "--profile") { + profiles.push(...value.split(",")); + } + } + const serviceName = positionals[0]; + if (serviceName === undefined) { + if (name !== "logs") { + issues.push(`${name}-missing-service`); + } + continue; + } + const service = services[serviceName]; + if (service === undefined) { + issues.push(`${name}-unknown-service`); + continue; + } + const declared = serviceProfiles(service); + if (profiles.some((profile) => !declared.includes(profile))) { + issues.push(`${name}-undeclared-profile`); + } + if ( + name !== "logs" && + declared.length > 0 && + !profiles.some((profile) => declared.includes(profile)) + ) { + issues.push(`${name}-missing-profile`); + } + } + return issues; +} diff --git a/tests/helpers/pattern-recipes.ts b/tests/helpers/pattern-recipes.ts new file mode 100644 index 000000000..fe447a022 --- /dev/null +++ b/tests/helpers/pattern-recipes.ts @@ -0,0 +1,52 @@ +import { + NEGATIVE_EXAMPLE_END, + NEGATIVE_EXAMPLE_START, +} from "../../src/agents/pattern-catalogue.ts"; + +/** + * Banned-recipe scan shared by the pattern catalogue and its example fixtures. + * Findings are closed codes; the scanned text is never echoed. + */ + +/** Recipes that must never appear outside a negative-example block. */ +export const BANNED_RECIPES: readonly (readonly [string, RegExp])[] = [ + ["broad-process-kill", /\b(?:pkill|killall)\b/], + ["kill-by-port", /\blsof\b[^\n|]*-t[^\n|]*\|\s*xargs\s+(?:-\S+\s+)*kill\b/], + ["placeholder-liveness-tail", /\btail\s+-f\s+\/dev\/null\b/], + ["placeholder-liveness-sleep", /\bsleep\s+infinity\b/], + [ + "argv-secret-flag", + /--(?:password|passwd|token|secret|api-key|apikey|access-key)(?:=|\s+)\S/i, + ], + [ + "argv-secret-assignment", + /\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)=\S/, + ], + ["argv-env-value", /\bhack\s+env\s+(?:set|add)\s+\S+\s+\S+/], +]; + +function stripNegativeExamples(text: string): string { + let result = ""; + let rest = text; + for (;;) { + const start = rest.indexOf(NEGATIVE_EXAMPLE_START); + if (start === -1) { + return result + rest; + } + const end = rest.indexOf(NEGATIVE_EXAMPLE_END, start); + if (end === -1) { + // An unterminated block exempts nothing. + return result + rest; + } + result += rest.slice(0, start); + rest = rest.slice(end + NEGATIVE_EXAMPLE_END.length); + } +} + +/** Codes of banned recipes found outside negative-example blocks. */ +export function bannedRecipeFindings(text: string): string[] { + const scanned = stripNegativeExamples(text); + return BANNED_RECIPES.filter(([, pattern]) => pattern.test(scanned)).map( + ([code]) => code + ); +} diff --git a/tests/pattern-catalogue.test.ts b/tests/pattern-catalogue.test.ts index 96e6df6b9..4aca9e395 100644 --- a/tests/pattern-catalogue.test.ts +++ b/tests/pattern-catalogue.test.ts @@ -14,6 +14,7 @@ import { extractHackInvocations, findGuidanceCommandIssues, } from "./helpers/cli-guidance-commands.ts"; +import { bannedRecipeFindings } from "./helpers/pattern-recipes.ts"; const EXPECTED_IDS = Array.from( { length: 12 }, @@ -37,48 +38,6 @@ function catalogueIdIssues(cards: readonly PatternCard[]): string[] { ]; } -/** Recipes that must never appear outside a negative-example block. */ -const BANNED_RECIPES: readonly (readonly [string, RegExp])[] = [ - ["broad-process-kill", /\b(?:pkill|killall)\b/], - ["kill-by-port", /\blsof\b[^\n|]*-t[^\n|]*\|\s*xargs\s+(?:-\S+\s+)*kill\b/], - ["placeholder-liveness-tail", /\btail\s+-f\s+\/dev\/null\b/], - ["placeholder-liveness-sleep", /\bsleep\s+infinity\b/], - [ - "argv-secret-flag", - /--(?:password|passwd|token|secret|api-key|apikey|access-key)(?:=|\s+)\S/i, - ], - [ - "argv-secret-assignment", - /\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY)=\S/, - ], - ["argv-env-value", /\bhack\s+env\s+(?:set|add)\s+\S+\s+\S+/], -]; - -function stripNegativeExamples(text: string): string { - let result = ""; - let rest = text; - for (;;) { - const start = rest.indexOf(NEGATIVE_EXAMPLE_START); - if (start === -1) { - return result + rest; - } - const end = rest.indexOf(NEGATIVE_EXAMPLE_END, start); - if (end === -1) { - // An unterminated block exempts nothing. - return result + rest; - } - result += rest.slice(0, start); - rest = rest.slice(end + NEGATIVE_EXAMPLE_END.length); - } -} - -function bannedRecipeFindings(text: string): string[] { - const scanned = stripNegativeExamples(text); - return BANNED_RECIPES.filter(([, pattern]) => pattern.test(scanned)).map( - ([code]) => code - ); -} - test("the catalogue holds exactly the twelve HACK-P cards in order", () => { expect(catalogueIdIssues(PATTERN_CATALOGUE)).toEqual([]); expect(PATTERN_CATALOGUE.map((card) => card.id)).toEqual(EXPECTED_IDS); diff --git a/tests/pattern-examples.test.ts b/tests/pattern-examples.test.ts new file mode 100644 index 000000000..a9ec9d6ab --- /dev/null +++ b/tests/pattern-examples.test.ts @@ -0,0 +1,462 @@ +import { afterEach, expect, test } from "bun:test"; +import { cp, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { YAML } from "bun"; + +import { + PATTERN_CATALOGUE, + type PatternCard, +} from "../src/agents/pattern-catalogue.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { resolveDependencyCacheOverride } from "../src/lib/dependency-cache.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { + findProjectContextAtRoot, + readProjectConfig, +} from "../src/lib/project.ts"; +import { renderProjectConfigSchemaJson } from "../src/templates.ts"; +import { + extractHackInvocations, + findGuidanceCommandIssues, +} from "./helpers/cli-guidance-commands.ts"; +import { validateJsonSchemaSubset } from "./helpers/json-schema-subset.ts"; +import { + exampleReferenceIssues, + readmeServiceIssues, + toolchainServiceIssues, +} from "./helpers/pattern-fixtures.ts"; +import { bannedRecipeFindings } from "./helpers/pattern-recipes.ts"; + +/** + * Static validation of the HACK-P01/P02 example fixtures. Nothing here starts + * containers or contacts Docker; the dependency-cache resolution runs against + * a temporary copy so generated `.hack/.internal` files never land in the + * repository. + */ + +const REPO_ROOT = join(import.meta.dir, ".."); +const P01_DIR = "examples/patterns/p01-dependency-bootstrap"; +const P02_DIR = "examples/patterns/p02-portable-toolchain"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +function card(id: string): PatternCard { + const found = PATTERN_CATALOGUE.find((entry) => entry.id === id); + if (!found) { + throw new Error("missing card"); + } + return found; +} + +function exampleDir(id: string): string { + const example = card(id).example; + if (example === null) { + throw new Error("card has no example"); + } + return join(REPO_ROOT, example); +} + +async function readText(dir: string, file: string): Promise { + return await Bun.file(join(dir, file)).text(); +} + +async function readCompose(dir: string): Promise> { + const parsed: unknown = YAML.parse( + await readText(dir, ".hack/docker-compose.yml") + ); + if (!isRecord(parsed)) { + throw new Error("compose root must be a mapping"); + } + return parsed; +} + +async function readConfigJson(dir: string): Promise> { + const parsed: unknown = JSON.parse( + await readText(dir, ".hack/hack.config.json") + ); + if (!isRecord(parsed)) { + throw new Error("config root must be an object"); + } + return parsed; +} + +function projectSchema(): unknown { + return JSON.parse(renderProjectConfigSchemaJson()); +} + +/** Structured clone so mutations never touch the parsed fixture. */ +function clone(value: T): T { + return structuredClone(value); +} + +function service( + compose: Record, + name: string +): Record { + const services = compose.services; + const entry = isRecord(services) ? services[name] : undefined; + if (!isRecord(entry)) { + throw new Error("missing service"); + } + return entry; +} + +async function copyExample(id: string): Promise { + const root = await mkdtemp(join(tmpdir(), "hack-pattern-example-")); + roots.push(root); + const target = join(root, "project"); + await cp(exampleDir(id), target, { recursive: true }); + return target; +} + +async function resolveP01Cache(opts: { + readonly projectRoot: string; + readonly compose: unknown; +}) { + const projectDir = join(opts.projectRoot, ".hack"); + const composeFile = join(projectDir, "docker-compose.yml"); + await Bun.write(composeFile, YAML.stringify(opts.compose)); + return await resolveDependencyCacheOverride({ + projectRoot: opts.projectRoot, + projectDir, + composeFile, + projectName: "pattern-p01", + }); +} + +test("P01 and P02 reference their example fixtures and every fixture belongs to a card", async () => { + expect(card("HACK-P01").example).toBe(P01_DIR); + expect(card("HACK-P02").example).toBe(P02_DIR); + expect( + await exampleReferenceIssues({ + repoRoot: REPO_ROOT, + cards: PATTERN_CATALOGUE, + }) + ).toEqual([]); +}); + +test("negative control: a broken, shared or escaping example reference fails", async () => { + const withExample = (id: string, example: string | null) => + PATTERN_CATALOGUE.map((entry) => + entry.id === id ? { ...entry, example } : entry + ); + expect( + await exampleReferenceIssues({ + repoRoot: REPO_ROOT, + cards: withExample("HACK-P01", "examples/patterns/missing"), + }) + ).toEqual([ + "HACK-P01: missing-README.md", + "HACK-P01: missing-.hack/hack.config.json", + "HACK-P01: missing-.hack/docker-compose.yml", + "unreferenced-example-directory", + ]); + expect( + await exampleReferenceIssues({ + repoRoot: REPO_ROOT, + cards: withExample("HACK-P03", P01_DIR), + }) + ).toEqual(["HACK-P03: example-shared-with-HACK-P01"]); + expect( + await exampleReferenceIssues({ + repoRoot: REPO_ROOT, + cards: withExample("HACK-P02", "examples/basic"), + }) + ).toEqual([ + "HACK-P02: example-outside-patterns-dir", + "unreferenced-example-directory", + ]); + expect( + await exampleReferenceIssues({ + repoRoot: REPO_ROOT, + cards: withExample("HACK-P02", "examples/patterns/../basic"), + }) + ).toEqual([ + "HACK-P02: example-outside-patterns-dir", + "unreferenced-example-directory", + ]); +}); + +test("each fixture's hack.config.json validates against the project schema and parser", async () => { + for (const id of ["HACK-P01", "HACK-P02"]) { + const dir = exampleDir(id); + const config = await readConfigJson(dir); + expect( + validateJsonSchemaSubset({ schema: projectSchema(), value: config }), + id + ).toEqual([]); + + const context = await findProjectContextAtRoot({ projectRoot: dir }); + expect(context?.projectDirName, id).toBe(".hack"); + if (!context) { + throw new Error("fixture is not a legacy project root"); + } + const parsed = await readProjectConfig(context); + expect(parsed.parseError, id).toBeUndefined(); + expect(parsed.name, id).toBe(config.name as string); + expect((await readCompose(dir)).name, id).toBe(config.name); + } +}); + +test("negative control: breaking a fixture config fails schema or parser validation", async () => { + const schema = projectSchema(); + const p01 = await readConfigJson(exampleDir("HACK-P01")); + + const { dev_host: _removedHost, ...withoutHost } = clone(p01); + expect(validateJsonSchemaSubset({ schema, value: withoutHost })).toEqual([ + { pointer: "/dev_host", code: "required" }, + ]); + + const badBackend = clone(p01); + badBackend.logs = { follow_backend: "syslog" }; + expect(validateJsonSchemaSubset({ schema, value: badBackend })).toEqual([ + { pointer: "/logs/follow_backend", code: "enum" }, + ]); + + const badName = clone(p01); + badName.name = 42; + expect(validateJsonSchemaSubset({ schema, value: badName })).toEqual([ + { pointer: "/name", code: "type" }, + ]); + + // The validator refuses keywords it cannot evaluate instead of passing. + expect( + validateJsonSchemaSubset({ + schema: { type: "string", pattern: "^x$" }, + value: "x", + }) + ).toEqual([{ pointer: "", code: "unsupported-keyword" }]); + + // On disk: a corrupted copy is reported by the runtime parser. + const copy = await copyExample("HACK-P02"); + await Bun.write(join(copy, ".hack/hack.config.json"), "{ not json"); + const context = await findProjectContextAtRoot({ projectRoot: copy }); + if (!context) { + throw new Error("copied fixture is not a legacy project root"); + } + expect((await readProjectConfig(context)).parseError).toBeDefined(); +}); + +test("P01 compose passes the locked-v1 dependency cache contract", async () => { + const projectRoot = await copyExample("HACK-P01"); + const compose = await readCompose(projectRoot); + const resolution = await resolveP01Cache({ projectRoot, compose }); + + expect(resolution.progressServices).toEqual(["install"]); + expect(resolution.sharingDisabledReason).toBeUndefined(); + expect(resolution.fingerprint).toMatch(/^[a-f0-9]{16}$/); + expect( + resolution.volumes.map((volume) => ({ + logicalName: volume.logicalName, + services: volume.services, + })) + ).toEqual([{ logicalName: "dependencies", services: ["install"] }]); + expect( + resolution.inputs.map((input) => relative(projectRoot, input)) + ).toEqual([ + "bun.lock", + "package.json", + "packages/greeting/index.js", + "packages/greeting/package.json", + ]); + + // Generation is a separate always-run step with its own output volume, and + // the application waits for both one-shot steps. + const generate = service(compose, "generate"); + const app = service(compose, "app"); + expect(generate.labels).toBeUndefined(); + expect(generate.volumes).toContain("generated:/generated"); + expect(service(compose, "install").volumes).not.toContain( + "generated:/generated" + ); + expect(app.depends_on).toEqual({ + install: { condition: "service_completed_successfully" }, + generate: { condition: "service_completed_successfully" }, + }); + expect(app.volumes).toContain("generated:/generated:ro"); + expect(app.healthcheck).toBeDefined(); +}); + +test("negative control: breaking the P01 compose contract is refused", async () => { + const projectRoot = await copyExample("HACK-P01"); + const original = await readCompose(projectRoot); + const mutations: Readonly< + Record) => void> + > = { + "writable consumer mount": (compose) => { + service(compose, "app").volumes = [ + "..:/app", + "dependencies:/app/node_modules", + "generated:/generated:ro", + ]; + }, + "missing completion gate": (compose) => { + service(compose, "generate").depends_on = undefined; + }, + "initializer environment": (compose) => { + service(compose, "install").environment = { NODE_ENV: "production" }; + }, + "unresolved installer image": (compose) => { + service(compose, "install").image = "oven/bun:${BUN_TAG}"; + }, + "string initializer command": (compose) => { + service(compose, "install").command = "bun install --frozen-lockfile"; + }, + "named cache volume": (compose) => { + compose.volumes = { + dependencies: { name: "shared-dependencies" }, + generated: {}, + }; + }, + }; + // Baseline: the unmodified copy resolves, so each refusal is the mutation's. + expect( + (await resolveP01Cache({ projectRoot, compose: original })).progressServices + ).toEqual(["install"]); + for (const [name, mutate] of Object.entries(mutations)) { + const compose = clone(original); + mutate(compose); + let message = ""; + try { + await resolveP01Cache({ projectRoot, compose }); + } catch (error: unknown) { + message = error instanceof Error ? error.message : "non-error"; + } + expect(message, name).toMatch(/dependency cache/i); + } +}); + +test("P02 compose is a profiled, pinned, bounded one-shot toolchain", async () => { + const compose = await readCompose(exampleDir("HACK-P02")); + expect(toolchainServiceIssues({ compose, service: "toolchain" })).toEqual([]); +}); + +test("negative control: breaking the P02 toolchain contract fails", async () => { + const original = await readCompose(exampleDir("HACK-P02")); + const check = (mutate: (compose: Record) => void) => { + const compose = clone(original); + mutate(compose); + return toolchainServiceIssues({ compose, service: "toolchain" }); + }; + const addMount = (mount: string) => (compose: Record) => { + const toolchain = service(compose, "toolchain"); + toolchain.volumes = [...(toolchain.volumes as unknown[]), mount]; + }; + expect(check(addMount("/var/run/docker.sock:/var/run/docker.sock"))).toEqual([ + "forbidden-mount-docker-socket", + ]); + expect(check(addMount("~/.config:/root/.config"))).toEqual([ + "forbidden-mount-home", + ]); + expect(check(addMount("$SSH_AUTH_SOCK:/ssh-agent"))).toEqual([ + "forbidden-mount-ssh", + ]); + expect(check(addMount("/opt/cache:/cache/other"))).toEqual([ + "cache-not-named-volume", + ]); + expect( + check((compose) => { + service(compose, "toolchain").profiles = undefined; + }) + ).toEqual(["always-on-service-toolchain"]); + expect( + check((compose) => { + service(compose, "toolchain").image = "oven/bun:latest"; + }) + ).toEqual(["image-not-digest-pinned"]); + expect( + check((compose) => { + const toolchain = service(compose, "toolchain"); + toolchain.cpus = undefined; + toolchain.mem_limit = undefined; + }) + ).toEqual(["missing-cpu-bound", "missing-memory-bound"]); + expect( + check((compose) => { + service(compose, "toolchain").env_file = [".env"]; + }) + ).toEqual(["env-file"]); + expect( + check((compose) => { + (compose.services as Record).app = { + image: "nginx", + }; + }) + ).toEqual(["always-on-service-app"]); +}); + +test("every hack command in the fixture READMEs resolves in CLI_SPEC and names real services", async () => { + for (const id of ["HACK-P01", "HACK-P02"]) { + const dir = exampleDir(id); + const readme = await readText(dir, "README.md"); + const compose = await readCompose(dir); + const invocations = extractHackInvocations(readme); + expect(invocations.length, id).toBeGreaterThan(0); + expect( + findGuidanceCommandIssues({ cli: CLI_SPEC, rendered: readme }), + id + ).toEqual([]); + expect(readmeServiceIssues({ cli: CLI_SPEC, readme, compose }), id).toEqual( + [] + ); + // The fixture exercises each command family its card names. + for (const command of card(id).supportedCommands) { + const prefix = command.split(" <")[0] ?? command; + expect( + invocations.some((invocation) => invocation.startsWith(prefix)), + `${id}: ${prefix}` + ).toBe(true); + } + expect(bannedRecipeFindings(readme), id).toEqual([]); + expect( + bannedRecipeFindings(await readText(dir, ".hack/docker-compose.yml")), + id + ).toEqual([]); + } +}); + +test("negative control: an invalid README command or service reference fails", async () => { + const dir = exampleDir("HACK-P02"); + const readme = await readText(dir, "README.md"); + const compose = await readCompose(dir); + const withLine = (line: string) => `${readme}\n\`${line}\`\n`; + + expect( + findGuidanceCommandIssues({ + cli: CLI_SPEC, + rendered: withLine( + "hack run --profile toolchain --fast toolchain -- bun test" + ), + }).length + ).toBe(1); + expect( + readmeServiceIssues({ + cli: CLI_SPEC, + compose, + readme: withLine("hack run --profile other toolchain -- bun test"), + }) + ).toEqual(["run-undeclared-profile", "run-missing-profile"]); + expect( + readmeServiceIssues({ + cli: CLI_SPEC, + compose, + readme: withLine("hack run toolchain -- bun test"), + }) + ).toEqual(["run-missing-profile"]); + expect( + readmeServiceIssues({ + cli: CLI_SPEC, + compose, + readme: withLine("hack logs --no-follow --tail 100 nope"), + }) + ).toEqual(["logs-unknown-service"]); + expect( + bannedRecipeFindings(`${readme}\n command: sleep infinity\n`) + ).toEqual(["placeholder-liveness-sleep"]); +});