diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c3c8b54..4eb5035 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -105,6 +105,11 @@ the target journey, contract, evidence, governance, and DX-measurement system. its bounded human-review integrity chain: packet binding, episode/delivery normalization, and review application. Non-facade consumers import its public `index.mjs`; historical flat paths remain compatibility entrypoints. +- `scripts/harness-component-snapshot/` owns the versioned, read-only Qoder + project component snapshot, validation, bounded diff, and non-authorizing + rollback-reference contracts. It composes only allowlisted public + `agent-customize` project collections, keeps its direct CLI capability-local, + and does not claim runtime activation or mutation authority. - `schemas/` is a target owner, not yet created, for versioned public runtime contracts consumed by multiple repo surfaces or packaged hosts; see the directory-structure ADR for adoption criteria. Capability-private schemas stay under `scripts//`. ## AI Directory Routing diff --git a/docs/specs/2026-08-02-harness-component-snapshot-v1.md b/docs/specs/2026-08-02-harness-component-snapshot-v1.md new file mode 100644 index 0000000..1f7c766 --- /dev/null +++ b/docs/specs/2026-08-02-harness-component-snapshot-v1.md @@ -0,0 +1,198 @@ +# Freeze Project Harness Components for Qoder + +## Traceability + +- Spec ID: `harness-component-snapshot-v1` +- Roadmap: `LC-02` +- Status: Implemented +- AI involvement: Codex (GPT 5.6 Sol), implementation; Codex (GPT 5.6 Terra), independent review + +## Intent + +Give maintainers and later loop capabilities a deterministic, privacy-safe way +to identify the exact project-owned Qoder Rules, Skills, Hooks, Commands, and +Workflows that existed at one observation boundary. A versioned snapshot and +bounded diff must distinguish component identity, content revision, and +activation evidence so later evaluation or intervention work can bind to facts +without treating configured presence as observed use. + +This is the first read-only `LC-02` slice. It establishes one complete host +contract before generalizing across providers. + +## Acceptance Scenarios + +- **HCS-AC-1 (bounded Qoder inventory):** Given a workspace, snapshot creation + uses Qoder's public project inventory for Rules, Skills, Hooks, and Commands, + plus only `.qoder/workflows/` and `.agents/workflows/` for Workflows. User, + plugin, inherited, runtime-cache, Memory, MCP, and subagent assets are out of + scope and are neither collected nor completeness gates. The Qoder inventory + request explicitly allowlists only Rules, Skills, Hooks, and Commands. Every + supported kind reports `observed` coverage even when its result is empty. + Malformed Hook JSON, user-home/project-root aliasing, unsupported + symlinks, or a collector depth/count/probe overflow fails closed instead of + silently claiming complete observation. +- **HCS-AC-2 (stable component identity):** Every component receives a stable, + provider-, scope-, and population-qualified ID from `qoder`, `project`, a + privacy-safe population reference, its kind, and a normalized + workspace-relative route. Hook identity adds a digest of its event, matcher, + and handler type plus an ordinal only for duplicate routing identities; + declaration position belongs to revision evidence. Collector/component output + ordering and host path separators do not affect identity. The safe default + binds population to a hash of the canonical workspace boundary; callers use + an explicit opaque population key when the same project must retain identity + across relocation, drive letters, or operating systems. +- **HCS-AC-3 (separate evidence dimensions):** Content revision, identity, and + activation are independent fields. File content, a bounded Skill tree, or a + bounded hook configuration plus its workspace script determines a SHA-256 + revision. Hook command/script content and declaration order do not enter its + routing identity; a routing tuple change is an identity change. Activation + remains explicitly `unknown` with `unavailable` runtime evidence. File-backed + provenance is `observed` and never claims execution. +- **HCS-AC-4 (privacy-safe frozen contract):** `HarnessComponentSnapshotV1` is + deeply frozen, canonically ordered, versioned, content-addressed, and contains + no raw component content, command text, secret values, or absolute workspace + or user-home paths. Symlink or relative-path escapes fail closed. Evidence + reads reuse one canonical workspace boundary scoped to the current snapshot; + they do not retain caller paths in a process-lifetime cache. +- **HCS-AC-5 (minimal graph):** Each component has one typed `declared-in` + relationship to its privacy-safe workspace artifact reference. Relationship + validation rejects unknown component IDs, absolute targets, and mismatched + provenance instead of inferring semantic dependencies. +- **HCS-AC-6 (tamper-safe validation):** Validation recomputes component + identity, relationship integrity, rollback references, ordering, and the + snapshot digest. Stale or edited snapshots fail with stable diagnostic codes. +- **HCS-AC-7 (bounded diff):** Two valid snapshots from the same provider and + scope and exact population reference produce deterministic `added`, `removed`, `changed`, and `unchanged` + counts. Revision changes are named `content`; route identity changes appear + as one removal and one addition. Activation and provenance remain fixed, + explicit evidence dimensions in this v1 producer and therefore cannot be + reported as changed without a future contract revision. Returned entries + obey an explicit bounded limit and report truncation. Different populations + fail closed. +- **HCS-AC-8 (non-authorizing rollback reference):** Every component exposes a + parseable reference binding provider, scope, population-qualified component + ID, and revision. + Resolution requires a valid matching snapshot and returns + `mutationAuthorized: false`; it does not read prior content or execute a + restore. +- **HCS-AC-9 (public automation surface):** A capability-owned public + `index.mjs` exports create, validate, diff, parse, and rollback-resolution + functions. A strict `cli.mjs` exposes `create`, optional opaque + `--population-key`, `validate`, `diff`, and `resolve` with parser-safe JSON + stdout and usage/runtime failures on stderr. Runtime diagnostics expose a + stable code without echoing caller-selected paths or untrusted snapshot + fields. Public API failures use stable sanitized messages without appending + filesystem or parser error details. Global and leaf help remain help-only + human text and do not inspect a workspace. +- **HCS-AC-10 (portable evidence):** Fixtures cover Windows, macOS, and Linux + route forms, stable input ordering, provider/scope isolation, secret and home + sentinels, hook activation, tampering, bounded diff states, and rollback + resolution. Package verification and the full test suite remain green apart + from already-known environment-specific failures recorded as evidence. + +## Non-goals + +- Supporting providers other than Qoder, user/global assets, installed plugins, + inherited assets, MCPs, Memories, or subagents. +- Inferring runtime activation, observed use, causal impact, semantic + dependencies, or cross-component invocation from configured presence. +- Storing raw component bodies, hook commands, environment values, absolute + homes, transcripts, prompts, or private Memory content. +- Creating a graph database, trace runner, scheduler, evaluator, automatic + reviewer, mutation planner, apply command, or rollback executor. +- Registering a new root `better-harness` command. The v1 slice remains an + atomic capability-owned direct CLI; root command naming, audience, and + discovery integration are an explicit follow-up decision. +- Reusing `plugin-lifecycle` identity, contract, runtime, or host-support APIs. + A future adapter may project accepted lifecycle observations into this + component contract only after their evidence semantics are reviewed. + +## Plan and Tasks + +1. Add `scripts/harness-component-snapshot/` as the atomic owner, with a + versioned capability-private contract, canonical hashing, path safety, + snapshot assembly, bounded diff, rollback-reference resolution, public + exports, and a direct CLI. +2. Compose Qoder Rules, Skills, Hooks, and Commands through an explicit project + collection allowlist on `scripts/agent-customize/index.mjs`. Add a narrow + Qoder workflow collector locally because the existing cross-provider + practices inventory groups unrelated host workflow roots and has no + workflow-only public API. +3. Use one snapshot-scoped canonical workspace read context, workspace-relative + artifact references, population references, and SHA-256 digests only. Keep + activation unavailable and provenance file-backed; make unsupported or + escaped evidence a hard failure. +4. Add fixture-driven contract and CLI tests. Test route normalization as a pure + boundary so Windows/macOS/Linux behavior is reproducible on every CI host. +5. Run focused tests, documentation-link validation, package verification, and + the full suite. Perform a Change Traceability Review Readiness Check before + handoff. + +### Boundary with the plugin lifecycle control plane + +The merged plugin lifecycle control plane describes installation, discovery, +enablement, version relation, and read-only mutation plans for the Better +Harness plugin itself across multiple hosts. This spec instead snapshots +project-owned Qoder harness assets. It does not reuse plugin identity, expose +host-support profiles, claim lifecycle/runtime state, or modify the root CLI +registry. The only intentional conceptual seam is a future explicit adapter +from accepted host evidence to the component snapshot contract. + +## Test and Review Evidence + +- **HCS-AC-1..5:** `node --test test/harness-component-snapshot.test.mjs` + validates inventory filtering, IDs, revisions, graph edges, privacy, deep + freezing, ordering, and path fixtures. +- **HCS-AC-6..8:** The same focused test injects stale digests, edited fields, + unknown relationships, population mismatch, all diff states, limits, and + rollback-reference mismatches. +- **HCS-AC-9:** `node --test test/harness-component-snapshot-cli.test.mjs` + verifies help, strict parsing, JSON stdout, path-safe stderr failures, and all + four direct CLI operations. Contract tests also verify that malformed Hook + input cannot expose parser details, caller paths, or private sentinels through + the public API error message. +- **HCS-AC-10:** `node --test test/doc-link-graph.test.mjs`, + `npm run pack:verify`, and `npm test` provide documentation, distribution, and + repository-wide regression evidence. +- **Observed evidence (2026-08-05):** On the rebased final tree, the focused + contract/CLI run passed 21 tests with 0 failures and 0 skips; documentation- + link validation passed 6/6. The repository-wide run passed 1,288, failed 0, + and skipped 1 existing platform-specific case. Package verification passed + with 458 npm and 480 runtime-zip entries. An independent Terra contract and + adversarial review ended with no P1/P2 findings after the Qoder collection + allowlist, path-safe CLI diagnostics, tilde-home alias guard, architecture + routing, and Windows drive-relative route fixes were applied. Copilot review + follow-up additionally replaced per-read workspace-root resolution with a + snapshot-scoped boundary and removed parser/filesystem details from public + Hook configuration errors. +- **Risk — privacy:** A route or source field could expose an absolute home. + Mitigation: accept only normalized workspace-relative routes and serialize + no inventory path fields. +- **Risk — false activation:** Configured assets could be presented as used. + Mitigation: freeze `activation.state = unknown` and + `activation.evidenceState = unavailable` in this version. +- **Risk — unstable hook identity:** Hook array reordering changes registration + positions but retains each distinct routing identity; revisions change because + order can affect execution. Editing the event, matcher, or handler type is an + identity change. Inserting or removing indistinguishable duplicate routing + declarations may renumber only those duplicates. Tests separately cover + irrelevant collector ordering and meaningful hook declaration reordering. +- **Risk — project relocation:** Default population identity deliberately binds + to the canonical workspace boundary so unrelated projects with identical + files cannot cross-diff or resolve rollback references. Repositories that + relocate or compare across operating systems must provide the same opaque + population key; only its domain-separated digest enters artifacts. +- **Risk — incomplete rollback:** A privacy-safe snapshot cannot itself restore + omitted source bytes. The v1 reference is resolvable and non-authorizing; + executable restore remains gated on a future content-store or Git provenance + contract. +- **Risk — boundedness:** Snapshot assembly rejects more than 20,000 component + descriptors before starting evidence reads and processes accepted populations + with eight workers. All evidence workers share one canonical workspace root + scoped to that snapshot, so an input near the limit cannot create unbounded + root-resolution I/O, process-lifetime cache growth, or in-flight content + buffers. +- **Risk — root CLI drift:** The merged lifecycle work established a richer root + command registry after this slice was designed. Mitigation: keep the v1 + entrypoint capability-local and leave root registry/help untouched until a + separately reviewed command name, audience, and discovery contract is chosen. diff --git a/scripts/agent-customize/inventory.mjs b/scripts/agent-customize/inventory.mjs index 277d469..d77d49e 100644 --- a/scripts/agent-customize/inventory.mjs +++ b/scripts/agent-customize/inventory.mjs @@ -8,6 +8,9 @@ import { collectProviderInventory } from "./providers/index.mjs"; export async function collectAgentCustomizeInventory(options = {}) { const provider = String(options.provider ?? "cursor").toLowerCase(); + if (options.projectCollections !== undefined && provider !== "qoder") { + throw new TypeError("projectCollections is supported only for the Qoder provider"); + } return collectProviderInventory(provider, { ...options, ...normalizedHostHomeOptions(options, provider), diff --git a/scripts/agent-customize/providers/qoder.mjs b/scripts/agent-customize/providers/qoder.mjs index b781320..66df158 100644 --- a/scripts/agent-customize/providers/qoder.mjs +++ b/scripts/agent-customize/providers/qoder.mjs @@ -471,24 +471,43 @@ async function collectQoderUserHooks(qoderHome) { ].sort(sortByName); } -async function collectQoderWorkspacePrimitives(workspace, sharedClientCacheRoot, includeRuntime = true) { +const QODER_PROJECT_COLLECTIONS = Object.freeze(["rules", "skills", "hooks", "commands", "agents", "mcps"]); + +function normalizeProjectCollections(value) { + if (value === undefined) return new Set(QODER_PROJECT_COLLECTIONS); + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + throw new TypeError("projectCollections must be an array of supported Qoder project collection names"); + } + const collections = new Set(value); + if (collections.size !== value.length || [...collections].some((item) => !QODER_PROJECT_COLLECTIONS.includes(item))) { + throw new TypeError("projectCollections must contain unique supported Qoder project collection names"); + } + return collections; +} + +async function collectQoderWorkspacePrimitives(workspace, sharedClientCacheRoot, options = {}) { const sourceLabel = await workspaceSourceLabel(workspace); const qoderRoot = path.join(workspace, ".qoder"); const agentRoot = path.join(workspace, ".agents"); - const slugs = qoderWorkspaceSlugs(workspace); - const settingsHooks = [ - ...(await collectHooksFromFile(path.join(qoderRoot, "settings.json"), "project", sourceLabel, workspace)), - ...(await collectHooksFromFile(path.join(qoderRoot, "settings.local.json"), "project", sourceLabel, workspace)), - ]; + const collections = options.projectCollections ?? new Set(QODER_PROJECT_COLLECTIONS); + const includes = (collection) => collections.has(collection); + const settingsHooks = includes("hooks") + ? [ + ...(await collectHooksFromFile(path.join(qoderRoot, "settings.json"), "project", sourceLabel, workspace)), + ...(await collectHooksFromFile(path.join(qoderRoot, "settings.local.json"), "project", sourceLabel, workspace)), + ] + : []; const projectQoderMcpPath = path.join(qoderRoot, "mcp.json"); const projectFallbackMcpPath = path.join(workspace, ".mcp.json"); - const configuredMcps = await firstConfiguredMcpSource([ - { filePath: projectQoderMcpPath, rootForEvidence: workspace }, - { filePath: projectFallbackMcpPath, rootForEvidence: workspace }, - ], "project", sourceLabel); + const configuredMcps = includes("mcps") + ? await firstConfiguredMcpSource([ + { filePath: projectQoderMcpPath, rootForEvidence: workspace }, + { filePath: projectFallbackMcpPath, rootForEvidence: workspace }, + ], "project", sourceLabel) + : { items: [], source: null }; let runtimeMcps = []; - if (includeRuntime) { - for (const slug of slugs) { + if (includes("mcps") && options.includeRuntime !== false) { + for (const slug of qoderWorkspaceSlugs(workspace)) { runtimeMcps = await collectQoderRuntimeMcpItems( path.join(sharedClientCacheRoot, "projects", slug, "mcps"), "project", @@ -498,20 +517,30 @@ async function collectQoderWorkspacePrimitives(workspace, sharedClientCacheRoot, if (runtimeMcps.length > 0) break; } } - const skills = await uniqueAssetsByRealPath([ - ...(await collectSkillFiles(path.join(qoderRoot, "skills"), "project", sourceLabel, workspace)), - ...(await collectSkillFiles(path.join(agentRoot, "skills"), "project", sourceLabel, workspace)), - ]); - const subagents = await uniqueAssetsByRealPath([ - ...(await collectMarkdownItems(path.join(qoderRoot, "agents"), "subagent", "project", sourceLabel, workspace)), - ...(await collectMarkdownItems(path.join(agentRoot, "agents"), "subagent", "project", sourceLabel, workspace)), - ]); + const skills = includes("skills") + ? await uniqueAssetsByRealPath([ + ...(await collectSkillFiles(path.join(qoderRoot, "skills"), "project", sourceLabel, workspace)), + ...(await collectSkillFiles(path.join(agentRoot, "skills"), "project", sourceLabel, workspace)), + ]) + : []; + const subagents = includes("agents") + ? await uniqueAssetsByRealPath([ + ...(await collectMarkdownItems(path.join(qoderRoot, "agents"), "subagent", "project", sourceLabel, workspace)), + ...(await collectMarkdownItems(path.join(agentRoot, "agents"), "subagent", "project", sourceLabel, workspace)), + ]) + : []; return { skills: skills.sort(sortByName), subagents: subagents.sort(sortByName), - rules: await collectRuleSources(qoderWorkspaceRuleSources(workspace, qoderRoot, sourceLabel)), - commands: await collectMarkdownItems(path.join(qoderRoot, "commands"), "command", "project", sourceLabel, workspace), - hooks: [...settingsHooks, ...(await collectHookItems(qoderRoot, "project", sourceLabel, workspace))].sort(sortByName), + rules: includes("rules") + ? await collectRuleSources(qoderWorkspaceRuleSources(workspace, qoderRoot, sourceLabel)) + : [], + commands: includes("commands") + ? await collectMarkdownItems(path.join(qoderRoot, "commands"), "command", "project", sourceLabel, workspace) + : [], + hooks: includes("hooks") + ? [...settingsHooks, ...(await collectHookItems(qoderRoot, "project", sourceLabel, workspace))].sort(sortByName) + : [], mcps: enrichConfiguredMcpItems(configuredMcps.items, runtimeMcps), mcpSource: configuredMcps.source, runtimeMcps, @@ -537,6 +566,7 @@ export async function collectQoderCustomizeInventory(options = {}) { const sharedClientCacheRoot = resolveQoderSharedClientCacheRoot(options); const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); const includeUserHome = options.includeUserHome !== false; + const projectCollections = normalizeProjectCollections(options.projectCollections); const installState = includeUserHome ? await readQoderInstalledPluginState({ ...options, qoderHome, workspace }) : { records: [], source: "not-authorized", indexFiles: [] }; @@ -549,7 +579,10 @@ export async function collectQoderCustomizeInventory(options = {}) { : includeGlobalHooks ? collectQoderUserHooks(qoderHome).then((hooks) => ({ ...emptyPrimitives(), hooks })) : emptyPrimitives(), - collectQoderWorkspacePrimitives(workspace, sharedClientCacheRoot, includeUserHome), + collectQoderWorkspacePrimitives(workspace, sharedClientCacheRoot, { + includeRuntime: includeUserHome, + projectCollections, + }), ]); const installedPluginIds = new Set(plugins.map((plugin) => plugin.id)); return { diff --git a/scripts/harness-component-snapshot/README.md b/scripts/harness-component-snapshot/README.md new file mode 100644 index 0000000..2012339 --- /dev/null +++ b/scripts/harness-component-snapshot/README.md @@ -0,0 +1,30 @@ +# Harness Component Snapshot + +This capability owns the read-only `HarnessComponentSnapshotV1` contract for +the first LC-02 slice. It snapshots project-owned Qoder Rules, Skills, Hooks, +Commands, and Workflows as stable identities and privacy-safe revisions. + +The public import surface is `index.mjs`. Each direct CLI operation writes one +JSON document to stdout; global and leaf `--help` return help text without +reading a workspace: + +```text +node scripts/harness-component-snapshot/cli.mjs create --workspace . --provider qoder +node scripts/harness-component-snapshot/cli.mjs create --workspace --population-key +node scripts/harness-component-snapshot/cli.mjs validate --snapshot before.json +node scripts/harness-component-snapshot/cli.mjs diff --before before.json --after after.json --limit 200 +node scripts/harness-component-snapshot/cli.mjs resolve --snapshot before.json --reference +``` + +`resolve` only proves that an exact component revision exists in the supplied +valid snapshot. Its result always sets `mutationAuthorized` to `false`; this +capability neither stores source bodies nor restores files. + +Without `--population-key`, the population reference is a privacy-safe digest +of the canonical workspace boundary, so two unrelated workspaces cannot be +cross-diffed accidentally. Use the same opaque key only when two locations are +known to represent the same project population. The key itself is never emitted. + +The contract, acceptance scenarios, privacy boundary, and lifecycle-control +plane separation are recorded in +[Freeze Project Harness Components for Qoder](../../docs/specs/2026-08-02-harness-component-snapshot-v1.md). diff --git a/scripts/harness-component-snapshot/cli.mjs b/scripts/harness-component-snapshot/cli.mjs new file mode 100644 index 0000000..89d8e65 --- /dev/null +++ b/scripts/harness-component-snapshot/cli.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + HarnessComponentSnapshotError, + createHarnessComponentSnapshot, + diffHarnessComponentSnapshots, + parseRollbackReference, + resolveHarnessComponentRollbackReference, + validateHarnessComponentSnapshot, +} from "./index.mjs"; + +const HELP = `Harness Component Snapshot v1 + +Usage: + node scripts/harness-component-snapshot/cli.mjs create [--workspace ] [--provider qoder] [--population-key ] + node scripts/harness-component-snapshot/cli.mjs validate --snapshot + node scripts/harness-component-snapshot/cli.mjs diff --before --after [--limit ] + node scripts/harness-component-snapshot/cli.mjs resolve --snapshot --reference + +Operational commands emit one JSON document on stdout; help emits this text. +`; + +const COMMANDS = new Set(["create", "validate", "diff", "resolve"]); + +class UsageError extends Error { + constructor(message) { + super(message); + this.code = "INVALID_USAGE"; + this.exitCode = 64; + } +} + +function parseOptions(argv, allowed) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (!flag.startsWith("--") || !allowed.has(flag)) throw new UsageError(`unknown option: ${flag}`); + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) throw new UsageError(`missing value for ${flag}`); + if (Object.hasOwn(options, flag)) throw new UsageError(`duplicate option: ${flag}`); + options[flag] = value; + index += 1; + } + return options; +} + +function requireOption(options, flag) { + if (!options[flag]) throw new UsageError(`missing required option: ${flag}`); + return options[flag]; +} + +async function readSnapshot(filePath) { + let value; + try { + value = JSON.parse(await readFile(path.resolve(filePath), "utf8")); + } catch { + throw new HarnessComponentSnapshotError("SNAPSHOT_READ_FAILED", "snapshot JSON could not be read or parsed"); + } + validateHarnessComponentSnapshot(value); + return value; +} + +export async function runHarnessComponentSnapshotCli(argv) { + if (argv.length === 0 || (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h"))) { + process.stdout.write(HELP); + return; + } + const [command, ...rest] = argv; + if (!COMMANDS.has(command)) throw new UsageError(`unknown command: ${command}`); + if (rest.length === 1 && (rest[0] === "--help" || rest[0] === "-h")) { + process.stdout.write(HELP); + return; + } + let output; + if (command === "create") { + const options = parseOptions(rest, new Set(["--workspace", "--provider", "--population-key"])); + output = await createHarnessComponentSnapshot({ + workspace: options["--workspace"] ?? process.cwd(), + provider: options["--provider"] ?? "qoder", + populationKey: options["--population-key"], + }); + } else if (command === "validate") { + const options = parseOptions(rest, new Set(["--snapshot"])); + const snapshot = await readSnapshot(requireOption(options, "--snapshot")); + output = { kind: "HarnessComponentSnapshotValidationV1", valid: true, snapshotDigest: snapshot.snapshotDigest }; + } else if (command === "diff") { + const options = parseOptions(rest, new Set(["--before", "--after", "--limit"])); + const before = await readSnapshot(requireOption(options, "--before")); + const after = await readSnapshot(requireOption(options, "--after")); + output = diffHarnessComponentSnapshots(before, after, { limit: options["--limit"] }); + } else if (command === "resolve") { + const options = parseOptions(rest, new Set(["--snapshot", "--reference"])); + const snapshot = await readSnapshot(requireOption(options, "--snapshot")); + const reference = requireOption(options, "--reference"); + parseRollbackReference(reference); + output = resolveHarnessComponentRollbackReference(snapshot, reference); + } + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + runHarnessComponentSnapshotCli(process.argv.slice(2)).catch((error) => { + const code = error.code ?? "SNAPSHOT_FAILED"; + const message = error instanceof UsageError + ? "invalid component snapshot arguments" + : "component snapshot operation failed"; + process.stderr.write(`${code}: ${message}\n`); + process.exitCode = error.exitCode ?? 1; + }); +} diff --git a/scripts/harness-component-snapshot/contract.mjs b/scripts/harness-component-snapshot/contract.mjs new file mode 100644 index 0000000..20e8cf3 --- /dev/null +++ b/scripts/harness-component-snapshot/contract.mjs @@ -0,0 +1,308 @@ +import { createHash } from "node:crypto"; + +export const SNAPSHOT_KIND = "HarnessComponentSnapshotV1"; +export const SNAPSHOT_SCHEMA_VERSION = 1; +export const DIFF_KIND = "HarnessComponentDiffV1"; +export const DIFF_SCHEMA_VERSION = 1; +export const COMPONENT_KINDS = Object.freeze(["rule", "skill", "hook", "command", "workflow"]); +export const MAX_DIFF_LIMIT = 1000; +const POPULATION_REF_RE = /^hcp:qoder:project:sha256:[a-f0-9]{64}$/u; + +export class HarnessComponentSnapshotError extends Error { + constructor(code, message) { + super(message); + this.name = "HarnessComponentSnapshotError"; + this.code = code; + } +} + +export function fail(code, message) { + throw new HarnessComponentSnapshotError(code, message); +} + +export function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function canonicalValue(value) { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalValue(value[key])]), + ); + } + return value; +} + +export function canonicalStringify(value) { + return JSON.stringify(canonicalValue(value)); +} + +export function compareCodeUnits(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function contentRevision(value) { + return `sha256:${sha256(value)}`; +} + +export function populationRefFromKey(key) { + if (typeof key !== "string" || !key.trim() || key.length > 512 || key.includes("\0")) { + fail("INVALID_POPULATION_KEY", "population key must be a non-empty opaque string of at most 512 characters"); + } + const digest = sha256(`better-harness:harness-component-population:v1\0explicit\0${key.normalize("NFC")}`); + return `hcp:qoder:project:sha256:${digest}`; +} + +export function validatePopulationRef(value) { + if (!POPULATION_REF_RE.test(String(value ?? ""))) { + fail("INVALID_POPULATION_REF", "population reference is not a qoder project SHA-256 reference"); + } + return value; +} + +function isSha256(value) { + return /^sha256:[a-f0-9]{64}$/u.test(String(value ?? "")); +} + +function portableSegments(route) { + const value = String(route ?? "").normalize("NFC").replaceAll("\\", "/"); + if (!value || value.includes("\0") || value.startsWith("/") || /^[A-Za-z]:/u.test(value) || value.startsWith("//")) { + fail("UNSAFE_COMPONENT_ROUTE", "component routes must be non-empty workspace-relative paths"); + } + const segments = value.split("/").filter((segment) => segment && segment !== "."); + if (segments.length === 0 || segments.some((segment) => segment === "..")) { + fail("UNSAFE_COMPONENT_ROUTE", "component routes cannot escape the workspace"); + } + return segments; +} + +export function normalizeComponentRoute(route) { + return portableSegments(route).join("/"); +} + +export function encodeComponentRoute(route) { + return portableSegments(route).map((segment) => encodeURIComponent(segment)).join("/"); +} + +export function artifactRefForRoute(route) { + return `workspace:${encodeComponentRoute(route)}`; +} + +export function componentIdFor({ provider, scope, populationRef, kind, route }) { + if (provider !== "qoder") fail("UNSUPPORTED_PROVIDER", `unsupported snapshot provider: ${provider}`); + if (scope !== "project") fail("UNSUPPORTED_SCOPE", `unsupported snapshot scope: ${scope}`); + validatePopulationRef(populationRef); + if (!COMPONENT_KINDS.includes(kind)) fail("UNSUPPORTED_COMPONENT_KIND", `unsupported component kind: ${kind}`); + const populationDigest = populationRef.slice("hcp:qoder:project:sha256:".length); + return `hcs:${provider}:${scope}:${populationDigest}:${kind}:${encodeComponentRoute(route)}`; +} + +export function componentIdentityDigest(component) { + return contentRevision(canonicalStringify({ + id: component.id, + provider: component.provider, + scope: component.scope, + populationRef: component.populationRef, + kind: component.kind, + route: component.route, + })); +} + +export function rollbackReferenceFor(component) { + return `harness-component:v1:${encodeURIComponent(component.id)}?revision=${encodeURIComponent(component.revision)}`; +} + +export function parseRollbackReference(reference) { + const prefix = "harness-component:v1:"; + const raw = String(reference ?? ""); + if (!raw.startsWith(prefix)) fail("INVALID_ROLLBACK_REFERENCE", "rollback reference has an unsupported scheme or version"); + const separator = raw.indexOf("?revision=", prefix.length); + if (separator === -1) fail("INVALID_ROLLBACK_REFERENCE", "rollback reference is missing its revision"); + let componentId; + let revision; + try { + componentId = decodeURIComponent(raw.slice(prefix.length, separator)); + revision = decodeURIComponent(raw.slice(separator + "?revision=".length)); + } catch { + fail("INVALID_ROLLBACK_REFERENCE", "rollback reference encoding is invalid"); + } + if (!componentId || !isSha256(revision)) { + fail("INVALID_ROLLBACK_REFERENCE", "rollback reference component or revision is invalid"); + } + const componentMatch = componentId.match(/^hcs:qoder:project:([a-f0-9]{64}):(rule|skill|hook|command|workflow):(.+)$/u); + if (!componentMatch) { + fail("INVALID_ROLLBACK_REFERENCE", "rollback reference component identity is invalid"); + } + let route; + try { + route = componentMatch[3].split("/").map((segment) => decodeURIComponent(segment)).join("/"); + } catch { + fail("INVALID_ROLLBACK_REFERENCE", "rollback reference component route encoding is invalid"); + } + const populationRef = `hcp:qoder:project:sha256:${componentMatch[1]}`; + if (componentIdFor({ provider: "qoder", scope: "project", populationRef, kind: componentMatch[2], route }) !== componentId) { + fail("INVALID_ROLLBACK_REFERENCE", "rollback reference component identity is not canonical"); + } + if (rollbackReferenceFor({ id: componentId, revision }) !== raw) { + fail("INVALID_ROLLBACK_REFERENCE", "rollback reference is not canonically encoded"); + } + return deepFreeze({ componentId, revision }); +} + +function snapshotPayload(snapshot) { + return { + kind: snapshot.kind, + schemaVersion: snapshot.schemaVersion, + provider: snapshot.provider, + scope: snapshot.scope, + populationRef: snapshot.populationRef, + coverage: snapshot.coverage, + components: snapshot.components, + relationships: snapshot.relationships, + }; +} + +export function snapshotDigestFor(snapshot) { + return contentRevision(canonicalStringify(snapshotPayload(snapshot))); +} + +function assertObject(value, code, label) { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(code, `${label} must be an object`); +} + +function assertExactKeys(value, keys, code, label) { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (canonicalStringify(actual) !== canonicalStringify(expected)) { + fail(code, `${label} fields do not match the v1 contract`); + } +} + +function validateComponent(component, snapshot) { + assertObject(component, "INVALID_COMPONENT", "component"); + assertExactKeys(component, [ + "id", "provider", "scope", "populationRef", "kind", "route", "identityDigest", "revision", + "activation", "provenance", "rollbackReference", + ], "INVALID_COMPONENT", "component"); + if (component.provider !== snapshot.provider || component.scope !== snapshot.scope + || component.populationRef !== snapshot.populationRef) { + fail("COMPONENT_POPULATION_MISMATCH", `component ${component.id} does not belong to the snapshot population`); + } + const normalizedRoute = normalizeComponentRoute(component.route); + if (normalizedRoute !== component.route) fail("NON_CANONICAL_COMPONENT_ROUTE", `component ${component.id} route is not canonical`); + const expectedId = componentIdFor(component); + if (component.id !== expectedId) fail("COMPONENT_ID_MISMATCH", `component identity does not match its fields: ${component.id}`); + if (component.identityDigest !== componentIdentityDigest(component)) { + fail("COMPONENT_IDENTITY_DIGEST_MISMATCH", `component identity digest is stale: ${component.id}`); + } + if (!isSha256(component.revision)) fail("INVALID_COMPONENT_REVISION", `component revision is invalid: ${component.id}`); + + assertObject(component.activation, "INVALID_ACTIVATION_EVIDENCE", `component ${component.id} activation`); + assertExactKeys(component.activation, ["state", "evidenceState", "reason"], "INVALID_ACTIVATION_EVIDENCE", "activation"); + if (component.activation.state !== "unknown" || component.activation.evidenceState !== "unavailable" + || component.activation.reason !== "runtime-observation-not-collected") { + fail("INVALID_ACTIVATION_EVIDENCE", `component ${component.id} overstates activation evidence`); + } + + assertObject(component.provenance, "INVALID_PROVENANCE", `component ${component.id} provenance`); + assertExactKeys(component.provenance, ["state", "source", "artifactRef"], "INVALID_PROVENANCE", "provenance"); + const expectedProvenanceSource = component.kind === "workflow" + ? "qoder-project-workflow-scan" + : "qoder-project-inventory"; + if (component.provenance.state !== "observed" || component.provenance.source !== expectedProvenanceSource) { + fail("INVALID_PROVENANCE", `component ${component.id} provenance is unsupported`); + } + const hookSuffix = /#hook\/[a-f0-9]{16}\/\d+$/u; + if (component.kind === "hook" && !hookSuffix.test(component.route)) { + fail("INVALID_HOOK_ROUTE", `component ${component.id} hook route is invalid`); + } + const artifactRoute = component.kind === "hook" ? component.route.replace(hookSuffix, "") : component.route; + if (component.provenance.artifactRef !== artifactRefForRoute(artifactRoute)) { + fail("PROVENANCE_ARTIFACT_MISMATCH", `component ${component.id} provenance does not match its route`); + } + const parsedRollback = parseRollbackReference(component.rollbackReference); + if (parsedRollback.componentId !== component.id || parsedRollback.revision !== component.revision) { + fail("ROLLBACK_REFERENCE_MISMATCH", `component ${component.id} rollback reference is stale`); + } +} + +function validateCoverage(snapshot) { + if (!Array.isArray(snapshot.coverage) || snapshot.coverage.length !== COMPONENT_KINDS.length) { + fail("INVALID_COVERAGE", "snapshot coverage must declare every v1 component kind"); + } + const expected = COMPONENT_KINDS.map((kind) => ({ + kind, + state: "observed", + count: snapshot.components.filter((component) => component.kind === kind).length, + })); + if (canonicalStringify(snapshot.coverage) !== canonicalStringify(expected)) { + fail("INVALID_COVERAGE", "snapshot coverage is stale or out of canonical order"); + } +} + +function validateRelationships(snapshot) { + if (!Array.isArray(snapshot.relationships)) fail("INVALID_RELATIONSHIPS", "snapshot relationships must be an array"); + const byId = new Map(snapshot.components.map((component) => [component.id, component])); + for (const relationship of snapshot.relationships) { + assertObject(relationship, "INVALID_RELATIONSHIP", "relationship"); + assertExactKeys( + relationship, + ["type", "sourceComponentId", "targetArtifactRef"], + "INVALID_RELATIONSHIP", + "relationship", + ); + if (relationship.type !== "declared-in") { + fail("UNSUPPORTED_RELATIONSHIP_TYPE", `unsupported relationship type: ${relationship.type}`); + } + if (!byId.has(relationship.sourceComponentId)) { + fail("UNKNOWN_RELATIONSHIP_SOURCE", `unknown relationship source: ${relationship.sourceComponentId}`); + } + if (!String(relationship.targetArtifactRef).startsWith("workspace:")) { + fail("UNSAFE_RELATIONSHIP_TARGET", "relationship targets must be workspace artifact references"); + } + } + const expected = snapshot.components.map((component) => ({ + type: "declared-in", + sourceComponentId: component.id, + targetArtifactRef: component.provenance.artifactRef, + })); + if (canonicalStringify(snapshot.relationships) !== canonicalStringify(expected)) { + fail("INVALID_RELATIONSHIPS", "snapshot relationships are stale or out of canonical order"); + } +} + +export function validateHarnessComponentSnapshot(snapshot) { + assertObject(snapshot, "INVALID_SNAPSHOT", "snapshot"); + assertExactKeys(snapshot, [ + "kind", "schemaVersion", "provider", "scope", "populationRef", "coverage", "components", "relationships", "snapshotDigest", + ], "INVALID_SNAPSHOT", "snapshot"); + if (snapshot.kind !== SNAPSHOT_KIND || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) { + fail("UNSUPPORTED_SNAPSHOT_VERSION", "snapshot kind or schema version is unsupported"); + } + if (snapshot.provider !== "qoder" || snapshot.scope !== "project") { + fail("UNSUPPORTED_SNAPSHOT_POPULATION", "v1 supports only qoder project snapshots"); + } + validatePopulationRef(snapshot.populationRef); + if (!Array.isArray(snapshot.components)) fail("INVALID_COMPONENTS", "snapshot components must be an array"); + const sortedIds = snapshot.components.map((component) => component.id).sort(compareCodeUnits); + const actualIds = snapshot.components.map((component) => component.id); + if (new Set(actualIds).size !== actualIds.length) fail("DUPLICATE_COMPONENT_ID", "snapshot component IDs must be unique"); + if (canonicalStringify(actualIds) !== canonicalStringify(sortedIds)) { + fail("NON_CANONICAL_COMPONENT_ORDER", "snapshot components are not in canonical order"); + } + for (const component of snapshot.components) validateComponent(component, snapshot); + validateCoverage(snapshot); + validateRelationships(snapshot); + const expectedDigest = snapshotDigestFor(snapshot); + if (snapshot.snapshotDigest !== expectedDigest) fail("SNAPSHOT_DIGEST_MISMATCH", "snapshot content does not match its digest"); + return snapshot; +} diff --git a/scripts/harness-component-snapshot/diff.mjs b/scripts/harness-component-snapshot/diff.mjs new file mode 100644 index 0000000..75f6a03 --- /dev/null +++ b/scripts/harness-component-snapshot/diff.mjs @@ -0,0 +1,79 @@ +import { + DIFF_KIND, + DIFF_SCHEMA_VERSION, + MAX_DIFF_LIMIT, + canonicalStringify, + compareCodeUnits, + contentRevision, + deepFreeze, + fail, + validateHarnessComponentSnapshot, +} from "./contract.mjs"; + +function boundedLimit(value) { + const limit = value === undefined ? 200 : Number(value); + if (!Number.isInteger(limit) || limit < 0 || limit > MAX_DIFF_LIMIT) { + fail("INVALID_DIFF_LIMIT", `diff limit must be an integer from 0 to ${MAX_DIFF_LIMIT}`); + } + return limit; +} + +function dimensions(before, after) { + const changed = []; + if (before.revision !== after.revision) changed.push("content"); + return changed; +} + +function entryFor(componentId, before, after) { + if (!before) return { componentId, status: "added", afterRevision: after.revision }; + if (!after) return { componentId, status: "removed", beforeRevision: before.revision }; + const changedDimensions = dimensions(before, after); + if (changedDimensions.length === 0) { + return { componentId, status: "unchanged", revision: before.revision }; + } + return { + componentId, + status: "changed", + changedDimensions, + beforeRevision: before.revision, + afterRevision: after.revision, + }; +} + +export function diffHarnessComponentSnapshots(before, after, options = {}) { + validateHarnessComponentSnapshot(before); + validateHarnessComponentSnapshot(after); + if (before.provider !== after.provider || before.scope !== after.scope + || before.populationRef !== after.populationRef) { + fail("SNAPSHOT_POPULATION_MISMATCH", "snapshots must have the same provider, scope, and population reference"); + } + const limit = boundedLimit(options.limit); + const beforeById = new Map(before.components.map((component) => [component.id, component])); + const afterById = new Map(after.components.map((component) => [component.id, component])); + const componentIds = [...new Set([...beforeById.keys(), ...afterById.keys()])] + .sort(compareCodeUnits); + const allEntries = componentIds.map((componentId) => entryFor( + componentId, + beforeById.get(componentId), + afterById.get(componentId), + )); + const counts = Object.fromEntries(["added", "removed", "changed", "unchanged"] + .map((status) => [status, allEntries.filter((entry) => entry.status === status).length])); + const diff = { + kind: DIFF_KIND, + schemaVersion: DIFF_SCHEMA_VERSION, + provider: before.provider, + scope: before.scope, + populationRef: before.populationRef, + beforeSnapshotDigest: before.snapshotDigest, + afterSnapshotDigest: after.snapshotDigest, + counts, + limit, + totalEntries: allEntries.length, + truncated: allEntries.length > limit, + entries: allEntries.slice(0, limit), + diffDigest: "", + }; + diff.diffDigest = contentRevision(canonicalStringify({ ...diff, diffDigest: undefined })); + return deepFreeze(diff); +} diff --git a/scripts/harness-component-snapshot/index.mjs b/scripts/harness-component-snapshot/index.mjs new file mode 100644 index 0000000..1932f10 --- /dev/null +++ b/scripts/harness-component-snapshot/index.mjs @@ -0,0 +1,18 @@ +export { + COMPONENT_KINDS, + DIFF_KIND, + DIFF_SCHEMA_VERSION, + HarnessComponentSnapshotError, + MAX_DIFF_LIMIT, + SNAPSHOT_KIND, + SNAPSHOT_SCHEMA_VERSION, + artifactRefForRoute, + componentIdFor, + normalizeComponentRoute, + populationRefFromKey, + parseRollbackReference, + validateHarnessComponentSnapshot, +} from "./contract.mjs"; +export { createHarnessComponentSnapshot, deriveHarnessComponentPopulationRef } from "./snapshot.mjs"; +export { diffHarnessComponentSnapshots } from "./diff.mjs"; +export { resolveHarnessComponentRollbackReference } from "./rollback.mjs"; diff --git a/scripts/harness-component-snapshot/rollback.mjs b/scripts/harness-component-snapshot/rollback.mjs new file mode 100644 index 0000000..0e8d6a4 --- /dev/null +++ b/scripts/harness-component-snapshot/rollback.mjs @@ -0,0 +1,27 @@ +import { + deepFreeze, + fail, + parseRollbackReference, + validateHarnessComponentSnapshot, +} from "./contract.mjs"; + +export function resolveHarnessComponentRollbackReference(snapshot, reference) { + validateHarnessComponentSnapshot(snapshot); + const parsed = parseRollbackReference(reference); + const component = snapshot.components.find((candidate) => candidate.id === parsed.componentId); + if (!component) fail("ROLLBACK_COMPONENT_NOT_FOUND", "rollback reference does not resolve in this snapshot"); + if (component.revision !== parsed.revision || component.rollbackReference !== reference) { + fail("ROLLBACK_REVISION_MISMATCH", "rollback reference revision does not match this snapshot"); + } + return deepFreeze({ + kind: "HarnessComponentRollbackResolutionV1", + schemaVersion: 1, + snapshotDigest: snapshot.snapshotDigest, + componentId: component.id, + revision: component.revision, + artifactRef: component.provenance.artifactRef, + resolved: true, + mutationAuthorized: false, + mutationReason: "snapshot-reference-does-not-authorize-restore", + }); +} diff --git a/scripts/harness-component-snapshot/snapshot.mjs b/scripts/harness-component-snapshot/snapshot.mjs new file mode 100644 index 0000000..51ea684 --- /dev/null +++ b/scripts/harness-component-snapshot/snapshot.mjs @@ -0,0 +1,540 @@ +import { lstat, readFile, readdir, realpath, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { collectAgentCustomizeInventory } from "../agent-customize/index.mjs"; +import { expandHome, isDirectory, normalizeWorkspace, pathExists } from "../session-analysis/index.mjs"; +import { + COMPONENT_KINDS, + SNAPSHOT_KIND, + SNAPSHOT_SCHEMA_VERSION, + artifactRefForRoute, + canonicalStringify, + componentIdFor, + componentIdentityDigest, + compareCodeUnits, + contentRevision, + deepFreeze, + fail, + normalizeComponentRoute, + rollbackReferenceFor, + sha256, + snapshotDigestFor, + populationRefFromKey, + validatePopulationRef, + validateHarnessComponentSnapshot, +} from "./contract.mjs"; + +const COLLECTION_KINDS = Object.freeze([ + ["rules", "rule"], + ["skills", "skill"], + ["hooks", "hook"], + ["commands", "command"], +]); +const WORKFLOW_ROOTS = Object.freeze([ + [".qoder", "workflows"], + [".agents", "workflows"], +]); +const MAX_SKILL_FILES = 5000; +const MAX_SKILL_DEPTH = 12; +const MAX_SKILL_BYTES = 64 * 1024 * 1024; +const MAX_PROBE_DEPTH = 32; +const MAX_PROBE_ENTRIES = 100_000; +const MAX_HOOK_DECLARATIONS = 5000; +const MAX_SNAPSHOT_COMPONENTS = 20_000; +const COMPONENT_READ_CONCURRENCY = 8; +const MAX_WORKFLOW_FILES = 5000; +const MAX_WORKFLOW_DEPTH = 4; +const MAX_COMPONENT_FILE_BYTES = 16 * 1024 * 1024; + +function populationRefFromCanonicalWorkspace(canonicalWorkspace) { + const portableWorkspace = canonicalWorkspace.normalize("NFC").replaceAll("\\", "/"); + const pathIdentity = process.platform === "win32" ? portableWorkspace.toLowerCase() : portableWorkspace; + const digest = sha256(`better-harness:harness-component-population:v1\0workspace-path\0${process.platform}\0${pathIdentity}`); + return `hcp:qoder:project:sha256:${digest}`; +} + +export async function deriveHarnessComponentPopulationRef(options = {}) { + if (options.populationKey !== undefined) return populationRefFromKey(options.populationKey); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + const canonicalWorkspace = await realpath(workspace).catch(() => undefined); + if (!canonicalWorkspace) fail("WORKSPACE_NOT_FOUND", "snapshot workspace does not exist"); + return populationRefFromCanonicalWorkspace(canonicalWorkspace); +} + +async function canonicalPath(value) { + return realpath(path.resolve(value)).catch(() => path.resolve(value)); +} + +async function createWorkspaceReadContext(workspace, options = {}) { + const resolvedWorkspace = path.resolve(workspace); + const canonicalWorkspace = await realpath(resolvedWorkspace).catch(() => undefined); + if (!canonicalWorkspace && options.requireCanonical) { + fail("WORKSPACE_NOT_FOUND", "snapshot workspace does not exist"); + } + const root = canonicalWorkspace ?? resolvedWorkspace; + return Object.freeze({ workspace: resolvedWorkspace, root }); +} + +async function assertSafeProjectReadTarget(readContext, target) { + const stats = await lstat(target).catch(() => undefined); + if (!stats) return; + const resolvedTarget = await realpath(target).catch(() => undefined); + if (!resolvedTarget) fail("UNRESOLVED_PROJECT_READ_TARGET", "project evidence target cannot be resolved safely"); + const relative = path.relative(readContext.root, resolvedTarget); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + fail("PROJECT_READ_OUTSIDE_WORKSPACE", "project evidence target resolves outside the workspace"); + } +} + +async function assertProjectReadBoundaries(readContext) { + const { workspace } = readContext; + const qoderRoot = path.join(workspace, ".qoder"); + const targets = [ + path.join(workspace, ".git"), + path.join(workspace, ".git", "config"), + path.join(workspace, ".agents"), + qoderRoot, + path.join(workspace, "AGENTS.md"), + path.join(workspace, "DESIGN.md"), + path.join(qoderRoot, "settings.json"), + path.join(qoderRoot, "settings.local.json"), + path.join(qoderRoot, "hooks.json"), + ]; + for (const target of targets) await assertSafeProjectReadTarget(readContext, target); +} + +async function assertProjectDoesNotAliasQoderHome(readContext) { + const { workspace } = readContext; + const environmentHome = process.env.QODER_HOME; + const environmentBase = path.basename(String(environmentHome ?? "")).toLowerCase(); + const qoderHome = environmentHome && [".qoder", "qoder"].includes(environmentBase) + ? expandHome(environmentHome) + : path.join(os.homedir(), ".qoder"); + const [projectRoot, userRoot] = await Promise.all([ + canonicalPath(path.join(workspace, ".qoder")), + canonicalPath(qoderHome), + ]); + if (path.relative(readContext.root, userRoot) === "" || path.relative(projectRoot, userRoot) === "") { + fail("AMBIGUOUS_QODER_SCOPE", "workspace boundary aliases the Qoder user asset home"); + } +} + +async function probeCollectorRoot(root, options) { + if (!(await pathExists(root))) return []; + await assertSafeProjectReadTarget(options.readContext, root); + const matches = []; + let entryCount = 0; + + async function visit(directory, depth) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => compareCodeUnits(left.name, right.name)); + for (const entry of entries) { + entryCount += 1; + if (entryCount > MAX_PROBE_ENTRIES) { + fail("INVENTORY_PROBE_BOUNDS_EXCEEDED", `${options.label} completeness probe exceeded ${MAX_PROBE_ENTRIES} entries`); + } + const entryPath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + fail("UNSUPPORTED_INVENTORY_SYMLINK", `${options.label} contains a symbolic link that the v1 inventory cannot prove`); + } + if (entry.isDirectory()) { + if (depth >= MAX_PROBE_DEPTH) { + fail("INVENTORY_PROBE_BOUNDS_EXCEEDED", `${options.label} completeness probe exceeded depth ${MAX_PROBE_DEPTH}`); + } + await visit(entryPath, depth + 1); + continue; + } + if (!entry.isFile()) { + fail("UNSUPPORTED_INVENTORY_ENTRY", `${options.label} contains an unsupported filesystem entry`); + } + if (!options.match(entryPath)) continue; + if (depth > options.collectorDepth) { + fail("INVENTORY_DEPTH_EXCEEDED", `${options.label} contains a supported asset beyond collector depth ${options.collectorDepth}`); + } + matches.push(entryPath); + if (matches.length > options.limit) { + fail("INVENTORY_LIMIT_EXCEEDED", `${options.label} contains more than ${options.limit} supported assets`); + } + } + } + + await visit(root, 0); + return matches; +} + +async function validateHookJson(readContext, filePath) { + let value; + try { + const evidence = await workspaceFileEvidence(readContext, filePath); + value = JSON.parse(evidence.content.toString("utf8")); + } catch { + fail("INVALID_HOOK_CONFIG", "cannot read or parse Qoder hook configuration"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail("INVALID_HOOK_CONFIG", `Qoder hook configuration ${path.basename(filePath)} must be an object`); + } + if (value.hooks === undefined) return 0; + if (!value.hooks || typeof value.hooks !== "object" || Array.isArray(value.hooks)) { + fail("INVALID_HOOK_CONFIG", `Qoder hook configuration ${path.basename(filePath)} has an invalid hooks object`); + } + let count = 0; + for (const recordsValue of Object.values(value.hooks)) { + const records = Array.isArray(recordsValue) ? recordsValue : [recordsValue]; + for (const record of records) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + fail("INVALID_HOOK_CONFIG", `Qoder hook configuration ${path.basename(filePath)} has an invalid hook record`); + } + const hooks = Array.isArray(record.hooks) ? record.hooks : [record]; + if (hooks.some((hook) => !hook || typeof hook !== "object" || Array.isArray(hook))) { + fail("INVALID_HOOK_CONFIG", `Qoder hook configuration ${path.basename(filePath)} has an invalid hook declaration`); + } + count += hooks.length; + if (count > MAX_HOOK_DECLARATIONS) { + fail("INVENTORY_LIMIT_EXCEEDED", `Qoder hook configuration contains more than ${MAX_HOOK_DECLARATIONS} declarations`); + } + } + } + return count; +} + +async function assertQoderInventoryComplete(readContext) { + const { workspace } = readContext; + await assertProjectDoesNotAliasQoderHome(readContext); + await assertProjectReadBoundaries(readContext); + const qoderRoot = path.join(workspace, ".qoder"); + const agentRoot = path.join(workspace, ".agents"); + const markdown = (filePath) => /\.(?:md|mdc)$/iu.test(path.basename(filePath)); + const skill = (filePath) => path.basename(filePath) === "SKILL.md"; + const json = (filePath) => path.extname(filePath).toLowerCase() === ".json"; + const hookFiles = await probeCollectorRoot(path.join(qoderRoot, "hooks"), { + readContext, + label: "Qoder project Hooks", + collectorDepth: 2, + limit: 500, + match: json, + }); + await Promise.all([ + probeCollectorRoot(path.join(qoderRoot, "rules"), { + readContext, + label: "Qoder project Rules", + collectorDepth: 4, + limit: 5000, + match: markdown, + }), + probeCollectorRoot(path.join(qoderRoot, "skills"), { + readContext, + label: "Qoder project Skills", + collectorDepth: 5, + limit: 5000, + match: skill, + }), + probeCollectorRoot(path.join(agentRoot, "skills"), { + readContext, + label: "shared project Skills", + collectorDepth: 5, + limit: 5000, + match: skill, + }), + probeCollectorRoot(path.join(qoderRoot, "commands"), { + readContext, + label: "Qoder project Commands", + collectorDepth: 4, + limit: 5000, + match: markdown, + }), + ]); + const directHookFiles = [path.join(qoderRoot, "settings.json"), path.join(qoderRoot, "settings.local.json"), path.join(qoderRoot, "hooks.json")]; + let hookCount = 0; + for (const filePath of [...directHookFiles, ...hookFiles]) { + if (await pathExists(filePath)) hookCount += await validateHookJson(readContext, filePath); + if (hookCount > MAX_HOOK_DECLARATIONS) { + fail("INVENTORY_LIMIT_EXCEEDED", `Qoder project contains more than ${MAX_HOOK_DECLARATIONS} hook declarations`); + } + } +} + +async function workspaceFileEvidence(readContext, filePath) { + if (typeof filePath !== "string" || !filePath.trim()) { + fail("MISSING_COMPONENT_FILE", "component evidence does not identify a file"); + } + const resolvedInput = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(readContext.workspace, filePath); + const candidate = await realpath(resolvedInput).catch(() => undefined); + if (!candidate) fail("UNREADABLE_COMPONENT_FILE", "component evidence file cannot be resolved"); + const relative = path.relative(readContext.root, candidate); + if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + fail("COMPONENT_FILE_OUTSIDE_WORKSPACE", "component evidence resolves outside the workspace"); + } + const route = normalizeComponentRoute(relative); + const fileStats = await stat(candidate).catch(() => undefined); + if (!fileStats?.isFile()) fail("UNREADABLE_COMPONENT_FILE", "component evidence target is not a regular file"); + if (fileStats.size > MAX_COMPONENT_FILE_BYTES) { + fail("COMPONENT_FILE_LIMIT_EXCEEDED", `component evidence file exceeds ${MAX_COMPONENT_FILE_BYTES} bytes`); + } + const content = await readFile(candidate); + if (content.byteLength > MAX_COMPONENT_FILE_BYTES) { + fail("COMPONENT_FILE_LIMIT_EXCEEDED", `component evidence file exceeds ${MAX_COMPONENT_FILE_BYTES} bytes`); + } + return { route, content }; +} + +function hookSemanticDigest(item) { + return sha256(canonicalStringify({ + event: String(item.step ?? "unknown").normalize("NFC"), + matcher: String(item.matcher ?? "").normalize("NFC"), + handlerType: String(item.handlerType ?? "unknown").normalize("NFC"), + })).slice(0, 16); +} + +function hookRoute(route, item) { + const identityOrdinal = Number(item.identityOrdinal); + if (!Number.isInteger(identityOrdinal) || identityOrdinal < 0) { + fail("INVALID_HOOK_IDENTITY_ORDINAL", "hook evidence is missing its duplicate identity ordinal"); + } + return `${route}#hook/${hookSemanticDigest(item)}/${identityOrdinal}`; +} + +function hooksWithIdentityOrdinals(items) { + const groups = new Map(); + for (const item of items) { + if (!Number.isInteger(Number(item.registrationIndex)) || Number(item.registrationIndex) < 0 + || !Number.isInteger(Number(item.hookIndex)) || Number(item.hookIndex) < 0) { + fail("INVALID_HOOK_COORDINATES", "hook evidence is missing stable registration coordinates"); + } + const fileKey = String(item.evidence?.relativePath ?? item.filePath ?? "").normalize("NFC").replaceAll("\\", "/"); + const key = `${fileKey}\0${hookSemanticDigest(item)}`; + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + const ordinals = new Map(); + for (const group of groups.values()) { + group.sort((left, right) => { + const registrationDelta = Number(left.registrationIndex) - Number(right.registrationIndex); + return registrationDelta || Number(left.hookIndex) - Number(right.hookIndex); + }); + group.forEach((item, index) => ordinals.set(item, index)); + } + return items.map((item) => ({ ...item, identityOrdinal: ordinals.get(item) })); +} + +async function skillTreeRevision(readContext, entryFilePath) { + const entryEvidence = await workspaceFileEvidence(readContext, entryFilePath); + const resolvedEntry = path.isAbsolute(entryFilePath) + ? path.resolve(entryFilePath) + : path.resolve(readContext.workspace, entryFilePath); + const skillRoot = path.dirname(await realpath(resolvedEntry)); + const entries = []; + let totalBytes = 0; + + async function visit(directory, depth) { + if (depth > MAX_SKILL_DEPTH) { + fail("SKILL_REVISION_BOUNDS_EXCEEDED", `skill tree exceeds maximum depth ${MAX_SKILL_DEPTH}`); + } + const children = await readdir(directory, { withFileTypes: true }); + children.sort((left, right) => compareCodeUnits(left.name, right.name)); + for (const child of children) { + const childPath = path.join(directory, child.name); + if (child.isSymbolicLink()) fail("UNSUPPORTED_SKILL_SYMLINK", "skill trees cannot contain symbolic links in v1"); + if (child.isDirectory()) { + await visit(childPath, depth + 1); + continue; + } + if (!child.isFile()) fail("UNSUPPORTED_SKILL_ENTRY", "skill trees can contain only directories and regular files"); + if (entries.length >= MAX_SKILL_FILES) { + fail("SKILL_REVISION_BOUNDS_EXCEEDED", `skill tree exceeds maximum file count ${MAX_SKILL_FILES}`); + } + const evidence = await workspaceFileEvidence(readContext, childPath); + totalBytes += evidence.content.byteLength; + if (totalBytes > MAX_SKILL_BYTES) { + fail("SKILL_REVISION_BOUNDS_EXCEEDED", `skill tree exceeds maximum byte count ${MAX_SKILL_BYTES}`); + } + entries.push({ + route: normalizeComponentRoute(path.relative(skillRoot, await realpath(childPath))), + revision: contentRevision(evidence.content), + }); + } + } + + await visit(skillRoot, 0); + if (!entries.some((entry) => entry.route === path.basename(entryEvidence.route))) { + fail("SKILL_ENTRYPOINT_MISSING", "skill tree does not contain its entrypoint"); + } + entries.sort((left, right) => compareCodeUnits(left.route, right.route)); + return contentRevision(canonicalStringify(entries)); +} + +async function hookContentRevision(readContext, item) { + const configurationDigest = String(item.configurationDigest ?? ""); + if (!/^[a-f0-9]{64}$/u.test(configurationDigest)) { + fail("HOOK_REVISION_UNAVAILABLE", "hook evidence is missing a bounded configuration digest"); + } + const evidence = { + configurationRevision: `sha256:${configurationDigest}`, + position: { + registrationIndex: Number(item.registrationIndex), + hookIndex: Number(item.hookIndex), + }, + }; + if (item.scriptPath) { + const script = await workspaceFileEvidence(readContext, item.scriptPath); + evidence.script = { artifactRef: artifactRefForRoute(script.route), revision: contentRevision(script.content) }; + } + return contentRevision(canonicalStringify(evidence)); +} + +async function componentFromItem(readContext, populationRef, kind, item) { + const filePath = item.filePath ?? item.evidence?.path ?? item.path; + const evidence = await workspaceFileEvidence(readContext, filePath); + const route = kind === "hook" ? hookRoute(evidence.route, item) : evidence.route; + const revision = kind === "hook" + ? await hookContentRevision(readContext, item) + : kind === "skill" + ? await skillTreeRevision(readContext, filePath) + : contentRevision(evidence.content); + const component = { + id: componentIdFor({ provider: "qoder", scope: "project", populationRef, kind, route }), + provider: "qoder", + scope: "project", + populationRef, + kind, + route, + identityDigest: "", + revision, + activation: { + state: "unknown", + evidenceState: "unavailable", + reason: "runtime-observation-not-collected", + }, + provenance: { + state: "observed", + source: kind === "workflow" ? "qoder-project-workflow-scan" : "qoder-project-inventory", + artifactRef: artifactRefForRoute(evidence.route), + }, + rollbackReference: "", + }; + component.identityDigest = componentIdentityDigest(component); + component.rollbackReference = rollbackReferenceFor(component); + return component; +} + +async function collectWorkflowItemsWithContext(readContext, options = {}) { + const items = []; + for (const segments of WORKFLOW_ROOTS) { + const root = path.join(readContext.workspace, ...segments); + const files = await probeCollectorRoot(root, { + readContext, + label: `${segments.join("/")} project Workflows`, + collectorDepth: options.maxDepth ?? MAX_WORKFLOW_DEPTH, + limit: options.maxFiles ?? MAX_WORKFLOW_FILES, + match: (filePath) => /\.(?:md|ya?ml|json)$/iu.test(path.basename(filePath)), + }); + items.push(...files.map((filePath) => ({ filePath }))); + } + return items; +} + +export async function collectWorkflowItems(workspace, options = {}) { + const readContext = await createWorkspaceReadContext(workspace); + return collectWorkflowItemsWithContext(readContext, options); +} + +function coverageFor(components) { + return COMPONENT_KINDS.map((kind) => ({ + kind, + state: "observed", + count: components.filter((component) => component.kind === kind).length, + })); +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapper(items[index], index); + } + } + + const workerCount = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; +} + +async function assembleHarnessComponentSnapshotWithContext({ provider, populationRef: requestedPopulationRef, inventory, workflowItems }, readContext) { + const populationRef = validatePopulationRef(requestedPopulationRef); + if (inventory.provider !== provider) fail("INVENTORY_PROVIDER_MISMATCH", "inventory provider does not match the snapshot provider"); + + const descriptors = []; + for (const [collection, kind] of COLLECTION_KINDS) { + const projectItems = (inventory.manage?.[collection] ?? []).filter((item) => item.scope === "project"); + const identifiedItems = kind === "hook" ? hooksWithIdentityOrdinals(projectItems) : projectItems; + for (const item of identifiedItems) { + descriptors.push({ kind, item }); + } + } + for (const item of workflowItems) descriptors.push({ kind: "workflow", item }); + if (descriptors.length > MAX_SNAPSHOT_COMPONENTS) { + fail("SNAPSHOT_COMPONENT_LIMIT_EXCEEDED", `snapshot exceeds ${MAX_SNAPSHOT_COMPONENTS} components`); + } + + const components = (await mapWithConcurrency( + descriptors, + COMPONENT_READ_CONCURRENCY, + ({ kind, item }) => componentFromItem(readContext, populationRef, kind, item), + )) + .sort((left, right) => compareCodeUnits(left.id, right.id)); + const ids = components.map((component) => component.id); + if (new Set(ids).size !== ids.length) fail("DUPLICATE_COMPONENT_ID", "collected component IDs are not unique"); + const relationships = components.map((component) => ({ + type: "declared-in", + sourceComponentId: component.id, + targetArtifactRef: component.provenance.artifactRef, + })); + const snapshot = { + kind: SNAPSHOT_KIND, + schemaVersion: SNAPSHOT_SCHEMA_VERSION, + provider, + scope: "project", + populationRef, + coverage: coverageFor(components), + components, + relationships, + snapshotDigest: "", + }; + snapshot.snapshotDigest = snapshotDigestFor(snapshot); + validateHarnessComponentSnapshot(snapshot); + return deepFreeze(snapshot); +} + +export async function assembleHarnessComponentSnapshot(options) { + const readContext = await createWorkspaceReadContext(options.workspace); + return assembleHarnessComponentSnapshotWithContext(options, readContext); +} + +export async function createHarnessComponentSnapshot(options = {}) { + const provider = String(options.provider ?? "qoder").toLowerCase(); + if (provider !== "qoder") fail("UNSUPPORTED_PROVIDER", `unsupported snapshot provider: ${provider}`); + const workspace = normalizeWorkspace(options.workspace ?? process.cwd()); + if (!(await isDirectory(workspace))) fail("WORKSPACE_NOT_FOUND", "snapshot workspace must be an existing directory"); + const readContext = await createWorkspaceReadContext(workspace, { requireCanonical: true }); + await assertQoderInventoryComplete(readContext); + const [inventory, workflowItems] = await Promise.all([ + collectAgentCustomizeInventory({ + provider, + workspace, + includeUserHome: false, + projectCollections: COLLECTION_KINDS.map(([collection]) => collection), + }), + collectWorkflowItemsWithContext(readContext), + ]); + const populationRef = options.populationKey === undefined + ? populationRefFromCanonicalWorkspace(readContext.root) + : populationRefFromKey(options.populationKey); + return assembleHarnessComponentSnapshotWithContext({ provider, populationRef, inventory, workflowItems }, readContext); +} diff --git a/test/fixtures/harness-component-snapshot/project/.agents/skills/handoff/SKILL.md b/test/fixtures/harness-component-snapshot/project/.agents/skills/handoff/SKILL.md new file mode 100644 index 0000000..3798127 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.agents/skills/handoff/SKILL.md @@ -0,0 +1,6 @@ +--- +name: handoff +description: Prepare a bounded handoff. +--- + +Record evidence without copying private content. diff --git a/test/fixtures/harness-component-snapshot/project/.agents/workflows/handoff.json b/test/fixtures/harness-component-snapshot/project/.agents/workflows/handoff.json new file mode 100644 index 0000000..64ddfd7 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.agents/workflows/handoff.json @@ -0,0 +1,4 @@ +{ + "name": "handoff", + "privateExample": "/Users/FixtureOwner/PRIVATE-HOME-SENTINEL" +} diff --git a/test/fixtures/harness-component-snapshot/project/.qoder/commands/review.md b/test/fixtures/harness-component-snapshot/project/.qoder/commands/review.md new file mode 100644 index 0000000..6ac612d --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.qoder/commands/review.md @@ -0,0 +1,3 @@ +# Review + +Review the selected change and report evidence. diff --git a/test/fixtures/harness-component-snapshot/project/.qoder/rules/review.md b/test/fixtures/harness-component-snapshot/project/.qoder/rules/review.md new file mode 100644 index 0000000..b894431 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.qoder/rules/review.md @@ -0,0 +1,3 @@ +# Review rule + +Never print PRIVATE-HOME-SENTINEL/C:/Users/FixtureOwner/secret.txt. diff --git a/test/fixtures/harness-component-snapshot/project/.qoder/settings.json b/test/fixtures/harness-component-snapshot/project/.qoder/settings.json new file mode 100644 index 0000000..a18cf74 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.qoder/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "preToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "node scripts/check.mjs" + } + ] + } + ] + } +} diff --git a/test/fixtures/harness-component-snapshot/project/.qoder/skills/verify/SKILL.md b/test/fixtures/harness-component-snapshot/project/.qoder/skills/verify/SKILL.md new file mode 100644 index 0000000..c5ba659 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.qoder/skills/verify/SKILL.md @@ -0,0 +1,6 @@ +--- +name: verify +description: Verify one bounded change. +--- + +Run focused tests before the full suite. diff --git a/test/fixtures/harness-component-snapshot/project/.qoder/skills/verify/references/check.md b/test/fixtures/harness-component-snapshot/project/.qoder/skills/verify/references/check.md new file mode 100644 index 0000000..e904751 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.qoder/skills/verify/references/check.md @@ -0,0 +1,3 @@ +# Check reference + +Keep PRIVATE-HOME-SENTINEL/C:/Users/FixtureOwner/private.txt out of public artifacts. diff --git a/test/fixtures/harness-component-snapshot/project/.qoder/workflows/review.yml b/test/fixtures/harness-component-snapshot/project/.qoder/workflows/review.yml new file mode 100644 index 0000000..6732b28 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/.qoder/workflows/review.yml @@ -0,0 +1,3 @@ +name: review +steps: + - run: PRIVATE-HOME-SENTINEL diff --git a/test/fixtures/harness-component-snapshot/project/AGENTS.md b/test/fixtures/harness-component-snapshot/project/AGENTS.md new file mode 100644 index 0000000..0d6d810 --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/AGENTS.md @@ -0,0 +1,3 @@ +# Fixture instructions + +Validate before reporting completion. diff --git a/test/fixtures/harness-component-snapshot/project/scripts/check.mjs b/test/fixtures/harness-component-snapshot/project/scripts/check.mjs new file mode 100644 index 0000000..85f2d3b --- /dev/null +++ b/test/fixtures/harness-component-snapshot/project/scripts/check.mjs @@ -0,0 +1 @@ +process.stdout.write("PRIVATE-HOME-SENTINEL\n"); diff --git a/test/harness-component-snapshot-cli.test.mjs b/test/harness-component-snapshot-cli.test.mjs new file mode 100644 index 0000000..81d613f --- /dev/null +++ b/test/harness-component-snapshot-cli.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const ROOT = process.cwd(); +const CLI = path.join(ROOT, "scripts", "harness-component-snapshot", "cli.mjs"); +const FIXTURE = path.join(ROOT, "test", "fixtures", "harness-component-snapshot", "project"); + +async function fixtureWorkspace(t) { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-component-cli-")); + const workspace = path.join(root, "workspace"); + await cp(FIXTURE, workspace, { recursive: true }); + t.after(() => rm(root, { recursive: true, force: true })); + return { root, workspace }; +} + +function run(args) { + return spawnSync(process.execPath, [CLI, ...args], { cwd: ROOT, encoding: "utf8" }); +} + +test("direct CLI provides global and leaf help without performing work", () => { + for (const args of [["--help"], ["create", "--help"], ["validate", "--help"], ["diff", "--help"], ["resolve", "--help"]]) { + const result = run(args); + assert.equal(result.status, 0, `${args.join(" ")} failed: ${result.stderr}`); + assert.match(result.stdout, /create.*validate.*diff.*resolve/su); + assert.equal(result.stderr, ""); + } + + for (const args of [["bogus", "--help"], ["--help", "trailing"], ["create", "--help", "trailing"]]) { + const result = run(args); + assert.equal(result.status, 64, `${args.join(" ")} unexpectedly succeeded`); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, "INVALID_USAGE: invalid component snapshot arguments\n"); + } + + const privatePath = path.join(os.tmpdir(), "PRIVATE-USAGE-PATH-SENTINEL", "snapshot.json"); + for (const args of [[privatePath], ["validate", privatePath]]) { + const result = run(args); + assert.equal(result.status, 64); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, "INVALID_USAGE: invalid component snapshot arguments\n"); + assert.equal(result.stderr.includes(privatePath), false); + } +}); + +test("direct CLI creates parser-safe private JSON and rejects unknown options", async (t) => { + const { workspace } = await fixtureWorkspace(t); + const created = run([ + "create", + "--workspace", + workspace, + "--provider", + "qoder", + "--population-key", + "portable-cli-project", + ]); + assert.equal(created.status, 0, created.stderr); + assert.equal(created.stderr, ""); + const snapshot = JSON.parse(created.stdout); + assert.equal(snapshot.kind, "HarnessComponentSnapshotV1"); + assert.equal(created.stdout.includes(workspace), false); + assert.equal(created.stdout.includes("PRIVATE-HOME-SENTINEL"), false); + assert.equal(created.stdout.includes("portable-cli-project"), false); + + const invalid = run(["create", "--workspace", workspace, "--unknown", "value"]); + assert.equal(invalid.status, 64); + assert.equal(invalid.stdout, ""); + assert.match(invalid.stderr, /^INVALID_USAGE:/u); +}); + +test("direct CLI validates, diffs, and resolves snapshot artifacts", async (t) => { + const { root, workspace } = await fixtureWorkspace(t); + const created = run(["create", "--workspace", workspace]); + assert.equal(created.status, 0, created.stderr); + const beforePath = path.join(root, "before.json"); + const afterPath = path.join(root, "after.json"); + await writeFile(beforePath, created.stdout, "utf8"); + await writeFile(afterPath, created.stdout, "utf8"); + + const validated = run(["validate", "--snapshot", beforePath]); + assert.equal(validated.status, 0, validated.stderr); + assert.equal(JSON.parse(validated.stdout).valid, true); + + const diffed = run(["diff", "--before", beforePath, "--after", afterPath, "--limit", "1"]); + assert.equal(diffed.status, 0, diffed.stderr); + const diff = JSON.parse(diffed.stdout); + assert.equal(diff.counts.changed, 0); + assert.equal(diff.truncated, true); + assert.equal(diff.entries.length, 1); + + const snapshot = JSON.parse(created.stdout); + const resolved = run([ + "resolve", + "--snapshot", + beforePath, + "--reference", + snapshot.components[0].rollbackReference, + ]); + assert.equal(resolved.status, 0, resolved.stderr); + assert.equal(JSON.parse(resolved.stdout).mutationAuthorized, false); + + const tampered = JSON.parse(created.stdout); + tampered.components[0].route = "changed.md"; + const tamperedPath = path.join(root, "tampered.json"); + await writeFile(tamperedPath, JSON.stringify(tampered), "utf8"); + const rejected = run(["validate", "--snapshot", tamperedPath]); + assert.equal(rejected.status, 1); + assert.equal(rejected.stdout, ""); + assert.equal(rejected.stderr, "COMPONENT_ID_MISMATCH: component snapshot operation failed\n"); + assert.equal(rejected.stderr.includes("changed.md"), false); + + const privateMissingPath = path.join(root, "PRIVATE-PATH-SENTINEL", "missing.json"); + const missing = run(["validate", "--snapshot", privateMissingPath]); + assert.equal(missing.status, 1); + assert.equal(missing.stdout, ""); + assert.equal(missing.stderr, "SNAPSHOT_READ_FAILED: component snapshot operation failed\n"); + assert.equal(missing.stderr.includes(privateMissingPath), false); +}); diff --git a/test/harness-component-snapshot.test.mjs b/test/harness-component-snapshot.test.mjs new file mode 100644 index 0000000..1e6f3c0 --- /dev/null +++ b/test/harness-component-snapshot.test.mjs @@ -0,0 +1,556 @@ +import assert from "node:assert/strict"; +import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { collectAgentCustomizeInventory } from "../scripts/agent-customize/index.mjs"; +import { + HarnessComponentSnapshotError, + componentIdFor, + createHarnessComponentSnapshot, + diffHarnessComponentSnapshots, + normalizeComponentRoute, + parseRollbackReference, + populationRefFromKey, + resolveHarnessComponentRollbackReference, + validateHarnessComponentSnapshot, +} from "../scripts/harness-component-snapshot/index.mjs"; +import { + assembleHarnessComponentSnapshot, + collectWorkflowItems, +} from "../scripts/harness-component-snapshot/snapshot.mjs"; + +const FIXTURE = path.join(process.cwd(), "test", "fixtures", "harness-component-snapshot", "project"); +const TEST_POPULATION_REF = populationRefFromKey("harness-component-snapshot-test"); + +async function fixtureWorkspace(t) { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-component-snapshot-")); + const workspace = path.join(root, "workspace"); + await cp(FIXTURE, workspace, { recursive: true }); + t.after(() => rm(root, { recursive: true, force: true })); + return workspace; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function assertDeepFrozen(value) { + if (!value || typeof value !== "object") return; + assert.equal(Object.isFrozen(value), true); + for (const child of Object.values(value)) assertDeepFrozen(child); +} + +test("Qoder project snapshot is deterministic, complete, private, and deeply frozen", async (t) => { + const workspace = await fixtureWorkspace(t); + const first = await createHarnessComponentSnapshot({ workspace }); + const second = await createHarnessComponentSnapshot({ workspace }); + + assert.deepEqual(first, second); + assert.equal(validateHarnessComponentSnapshot(first), first); + assertDeepFrozen(first); + assert.deepEqual( + Object.fromEntries(first.coverage.map((row) => [row.kind, row.count])), + { rule: 2, skill: 2, hook: 1, command: 1, workflow: 2 }, + ); + assert.ok(first.components.every((component) => component.provider === "qoder" && component.scope === "project")); + assert.ok(first.components.every((component) => component.activation.state === "unknown")); + assert.ok(first.components.every((component) => component.activation.evidenceState === "unavailable")); + assert.ok(first.components.every((component) => component.provenance.state === "observed")); + assert.ok(first.components.every((component) => component.provenance.source === ( + component.kind === "workflow" ? "qoder-project-workflow-scan" : "qoder-project-inventory" + ))); + assert.deepEqual( + first.relationships, + first.components.map((component) => ({ + type: "declared-in", + sourceComponentId: component.id, + targetArtifactRef: component.provenance.artifactRef, + })), + ); + + const serialized = JSON.stringify(first); + assert.equal(serialized.includes(workspace), false); + assert.equal(serialized.includes("PRIVATE-HOME-SENTINEL"), false); + assert.equal(serialized.includes("FixtureOwner"), false); + assert.equal(serialized.includes("preToolUse"), false); + assert.equal(serialized.includes("Write"), false); + assert.equal(serialized.includes("command"), true, "the component kind remains visible"); + assert.ok(first.components.every((component) => /^sha256:[a-f0-9]{64}$/u.test(component.revision))); +}); + +test("out-of-scope Qoder project assets are not collected or completeness gates", async (t) => { + const workspace = await fixtureWorkspace(t); + const privateAgent = path.join(workspace, ".qoder", "agents", "a", "b", "c", "d", "e", "private.md"); + await mkdir(path.dirname(privateAgent), { recursive: true }); + await writeFile(privateAgent, "# PRIVATE-SUBAGENT-SENTINEL\n", "utf8"); + await writeFile(path.join(workspace, ".qoder", "mcp.json"), JSON.stringify({ + mcpServers: { + private: { command: "PRIVATE-MCP-SENTINEL" }, + }, + }), "utf8"); + + const inventory = await collectAgentCustomizeInventory({ + provider: "qoder", + workspace, + includeUserHome: false, + projectCollections: ["rules", "skills", "hooks", "commands"], + }); + assert.deepEqual(inventory.manage.subagents, []); + assert.deepEqual(inventory.manage.mcps, []); + + const snapshot = await createHarnessComponentSnapshot({ workspace }); + const serialized = JSON.stringify(snapshot); + assert.equal(serialized.includes("PRIVATE-SUBAGENT-SENTINEL"), false); + assert.equal(serialized.includes("PRIVATE-MCP-SENTINEL"), false); + + await assert.rejects( + () => collectAgentCustomizeInventory({ + provider: "qoder", + workspace, + includeUserHome: false, + projectCollections: ["rules", "rules"], + }), + (error) => error instanceof TypeError && /unique supported/u.test(error.message), + ); + await assert.rejects( + () => collectAgentCustomizeInventory({ provider: "cursor", workspace, projectCollections: [] }), + (error) => error instanceof TypeError && /only for the Qoder provider/u.test(error.message), + ); +}); + +test("collector output order is irrelevant while Hook routing identity stays explicit", async (t) => { + const workspace = await fixtureWorkspace(t); + const inventory = await collectAgentCustomizeInventory({ + provider: "qoder", + workspace, + includeUserHome: false, + }); + const reversed = { + ...inventory, + manage: Object.fromEntries(Object.entries(inventory.manage) + .map(([key, values]) => [key, [...values].reverse()])), + }; + const workflowItems = [ + { filePath: path.join(workspace, ".agents", "workflows", "handoff.json") }, + { filePath: path.join(workspace, ".qoder", "workflows", "review.yml") }, + ]; + const normal = await createHarnessComponentSnapshot({ workspace }); + const reordered = await assembleHarnessComponentSnapshot({ + workspace, + provider: "qoder", + populationRef: normal.populationRef, + inventory: reversed, + workflowItems: workflowItems.reverse(), + }); + assert.deepEqual(reordered, normal); + const hook = normal.components.find((component) => component.kind === "hook"); + assert.match(hook.route, /#hook\/[a-f0-9]{16}\/0$/u); +}); + +test("component population limit is checked before any evidence file is read", async (t) => { + const workspace = await fixtureWorkspace(t); + const missingFile = path.join(workspace, "must-not-be-read.md"); + const inventory = { + provider: "qoder", + manage: { + rules: Array.from({ length: 20_001 }, () => ({ scope: "project", filePath: missingFile })), + skills: [], + hooks: [], + commands: [], + }, + }; + + await assert.rejects( + () => assembleHarnessComponentSnapshot({ + workspace, + provider: "qoder", + populationRef: TEST_POPULATION_REF, + inventory, + workflowItems: [], + }), + (error) => error instanceof HarnessComponentSnapshotError + && error.code === "SNAPSHOT_COMPONENT_LIMIT_EXCEEDED", + ); +}); + +test("reordering distinct hook declarations keeps identity and changes ordered revisions", async (t) => { + const workspace = await fixtureWorkspace(t); + const settingsPath = path.join(workspace, ".qoder", "settings.json"); + const settings = JSON.parse(await readFile(settingsPath, "utf8")); + settings.hooks.preToolUse.push({ + matcher: "Bash", + hooks: [{ type: "command", command: "node scripts/check.mjs" }], + }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf8"); + const before = await createHarnessComponentSnapshot({ workspace }); + settings.hooks.preToolUse.reverse(); + await writeFile(settingsPath, JSON.stringify(settings, null, 2), "utf8"); + const after = await createHarnessComponentSnapshot({ workspace }); + const beforeIds = before.components.filter((component) => component.kind === "hook").map((component) => component.id); + const afterIds = after.components.filter((component) => component.kind === "hook").map((component) => component.id); + assert.deepEqual(afterIds, beforeIds); + const diff = diffHarnessComponentSnapshots(before, after, { limit: 100 }); + assert.equal(diff.counts.added, 0); + assert.equal(diff.counts.removed, 0); + assert.equal(diff.counts.changed, 2); +}); + +test("portable component routes keep identity independent from workspace and host separators", () => { + assert.equal(normalizeComponentRoute(".qoder\\skills\\verify\\SKILL.md"), ".qoder/skills/verify/SKILL.md"); + assert.equal(normalizeComponentRoute(".qoder/skills/verify/SKILL.md"), ".qoder/skills/verify/SKILL.md"); + const identity = componentIdFor({ + provider: "qoder", + scope: "project", + populationRef: TEST_POPULATION_REF, + kind: "skill", + route: ".qoder\\skills\\verify\\SKILL.md", + }); + assert.match(identity, /^hcs:qoder:project:[a-f0-9]{64}:skill:\.qoder\/skills\/verify\/SKILL\.md$/u); + assert.throws(() => normalizeComponentRoute("C:\\Users\\owner\\skill.md"), { code: "UNSAFE_COMPONENT_ROUTE" }); + assert.throws(() => normalizeComponentRoute("C:private-skill.md"), { code: "UNSAFE_COMPONENT_ROUTE" }); + assert.throws(() => normalizeComponentRoute("/Users/owner/skill.md"), { code: "UNSAFE_COMPONENT_ROUTE" }); + assert.throws(() => normalizeComponentRoute("../outside.md"), { code: "UNSAFE_COMPONENT_ROUTE" }); + assert.throws(() => componentIdFor({ provider: "claude", scope: "project", populationRef: TEST_POPULATION_REF, kind: "skill", route: "skill.md" }), { + code: "UNSUPPORTED_PROVIDER", + }); + assert.throws(() => componentIdFor({ provider: "qoder", scope: "user", populationRef: TEST_POPULATION_REF, kind: "skill", route: "skill.md" }), { + code: "UNSUPPORTED_SCOPE", + }); +}); + +test("population references reject cross-project reuse and preserve explicit relocation", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-component-population-")); + const firstWorkspace = path.join(root, "first"); + const secondWorkspace = path.join(root, "second"); + await Promise.all([ + cp(FIXTURE, firstWorkspace, { recursive: true }), + cp(FIXTURE, secondWorkspace, { recursive: true }), + ]); + t.after(() => rm(root, { recursive: true, force: true })); + + const first = await createHarnessComponentSnapshot({ workspace: firstWorkspace }); + const second = await createHarnessComponentSnapshot({ workspace: secondWorkspace }); + assert.notEqual(first.populationRef, second.populationRef); + assert.throws(() => diffHarnessComponentSnapshots(first, second), { code: "SNAPSHOT_POPULATION_MISMATCH" }); + assert.throws( + () => resolveHarnessComponentRollbackReference(second, first.components[0].rollbackReference), + { code: "ROLLBACK_COMPONENT_NOT_FOUND" }, + ); + + const explicitFirst = await createHarnessComponentSnapshot({ workspace: firstWorkspace, populationKey: "portable-project" }); + const explicitSecond = await createHarnessComponentSnapshot({ workspace: secondWorkspace, populationKey: "portable-project" }); + assert.deepEqual(explicitFirst, explicitSecond); + assert.equal(JSON.stringify(explicitFirst).includes("portable-project"), false); +}); + +test("workspace evidence roots are scoped to each snapshot assembly", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-component-read-context-")); + const firstWorkspace = path.join(root, "first"); + const secondWorkspace = path.join(root, "second"); + const workspaceAlias = path.join(root, "workspace"); + await Promise.all([ + cp(FIXTURE, firstWorkspace, { recursive: true }), + cp(FIXTURE, secondWorkspace, { recursive: true }), + ]); + await writeFile(path.join(secondWorkspace, ".qoder", "workflows", "review.yml"), "name: changed\n", "utf8"); + t.after(() => rm(root, { recursive: true, force: true })); + + try { + await symlink(firstWorkspace, workspaceAlias, process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { + t.skip(`workspace alias unavailable: ${error.code}`); + return; + } + throw error; + } + + const options = { + workspace: workspaceAlias, + provider: "qoder", + populationRef: TEST_POPULATION_REF, + inventory: { + provider: "qoder", + manage: { rules: [], skills: [], hooks: [], commands: [] }, + }, + workflowItems: [{ filePath: path.join(workspaceAlias, ".qoder", "workflows", "review.yml") }], + }; + const first = await assembleHarnessComponentSnapshot(options); + await rm(workspaceAlias, { recursive: true, force: true }); + await symlink(secondWorkspace, workspaceAlias, process.platform === "win32" ? "junction" : "dir"); + const second = await assembleHarnessComponentSnapshot(options); + + assert.equal(first.components[0].id, second.components[0].id); + assert.notEqual(first.components[0].revision, second.components[0].revision); +}); + +test("inventory completeness fails closed on malformed, deep, and truncated sources", async (t) => { + const malformedWorkspace = await fixtureWorkspace(t); + const privateParserSentinel = `PRIVATE-HOOK-PARSER-SENTINEL:${malformedWorkspace}`; + await writeFile(path.join(malformedWorkspace, ".qoder", "settings.json"), privateParserSentinel, "utf8"); + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace: malformedWorkspace }), + (error) => { + assert.ok(error instanceof HarnessComponentSnapshotError); + assert.equal(error.code, "INVALID_HOOK_CONFIG"); + assert.equal(error.message, "cannot read or parse Qoder hook configuration"); + assert.equal(error.message.includes(malformedWorkspace), false); + assert.equal(error.message.includes(privateParserSentinel), false); + return true; + }, + ); + + const deepWorkspace = await fixtureWorkspace(t); + const deepCommand = path.join(deepWorkspace, ".qoder", "commands", "a", "b", "c", "d", "e"); + await mkdir(deepCommand, { recursive: true }); + await writeFile(path.join(deepCommand, "deep.md"), "# Deep\n", "utf8"); + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace: deepWorkspace }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "INVENTORY_DEPTH_EXCEEDED", + ); + + const workflowWorkspace = await fixtureWorkspace(t); + await writeFile(path.join(workflowWorkspace, ".qoder", "workflows", "second.yml"), "name: second\n", "utf8"); + await assert.rejects( + () => collectWorkflowItems(workflowWorkspace, { maxFiles: 1 }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "INVENTORY_LIMIT_EXCEEDED", + ); + const deepWorkflow = path.join(workflowWorkspace, ".qoder", "workflows", "a", "b", "c", "d", "e"); + await mkdir(deepWorkflow, { recursive: true }); + await writeFile(path.join(deepWorkflow, "deep.yml"), "name: deep\n", "utf8"); + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace: workflowWorkspace }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "INVENTORY_DEPTH_EXCEEDED", + ); +}); + +test("workspace scope rejects user-home aliases and regular files", async (t) => { + const workspace = await fixtureWorkspace(t); + const previousQoderHome = process.env.QODER_HOME; + process.env.QODER_HOME = path.join(workspace, ".qoder"); + t.after(() => { + if (previousQoderHome === undefined) delete process.env.QODER_HOME; + else process.env.QODER_HOME = previousQoderHome; + }); + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "AMBIGUOUS_QODER_SCOPE", + ); + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace: path.join(workspace, ".qoder") }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "AMBIGUOUS_QODER_SCOPE", + ); + + const filePath = path.join(path.dirname(workspace), "not-a-workspace.txt"); + await writeFile(filePath, "file\n", "utf8"); + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace: filePath }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "WORKSPACE_NOT_FOUND", + ); +}); + +test("tilde QODER_HOME aliases cannot masquerade as project scope", async (t) => { + const root = await mkdtemp(path.join(os.tmpdir(), "better-harness-component-home-alias-")); + const qoderHome = path.join(root, ".qoder"); + await cp(FIXTURE, qoderHome, { recursive: true }); + const previousQoderHome = process.env.QODER_HOME; + const originalHomedir = os.homedir; + process.env.QODER_HOME = "~/.qoder"; + os.homedir = () => root; + t.after(async () => { + os.homedir = originalHomedir; + if (previousQoderHome === undefined) delete process.env.QODER_HOME; + else process.env.QODER_HOME = previousQoderHome; + await rm(root, { recursive: true, force: true }); + }); + + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace: qoderHome }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "AMBIGUOUS_QODER_SCOPE", + ); +}); + +test("direct project control-file symlinks cannot escape before inventory reads", async (t) => { + const workspace = await fixtureWorkspace(t); + const outside = path.join(path.dirname(workspace), "outside-settings.json"); + const settingsPath = path.join(workspace, ".qoder", "settings.json"); + await writeFile(outside, JSON.stringify({ private: "PRIVATE-HOME-SENTINEL" }), "utf8"); + await rm(settingsPath); + try { + await symlink(outside, settingsPath, "file"); + } catch (error) { + if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { + t.skip(`symlink unavailable: ${error.code}`); + return; + } + throw error; + } + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "PROJECT_READ_OUTSIDE_WORKSPACE", + ); +}); + +test("collector root junctions cannot hide an external incomplete population", async (t) => { + const workspace = await fixtureWorkspace(t); + const outsideRoot = path.join(path.dirname(workspace), "outside-rules"); + const rulesRoot = path.join(workspace, ".qoder", "rules"); + await mkdir(outsideRoot, { recursive: true }); + await writeFile(path.join(outsideRoot, "private.txt"), "PRIVATE-HOME-SENTINEL\n", "utf8"); + await rm(rulesRoot, { recursive: true, force: true }); + try { + await symlink(outsideRoot, rulesRoot, process.platform === "win32" ? "junction" : "dir"); + } catch (error) { + if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { + t.skip(`junction/symlink unavailable: ${error.code}`); + return; + } + throw error; + } + await assert.rejects( + () => createHarnessComponentSnapshot({ workspace }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "PROJECT_READ_OUTSIDE_WORKSPACE", + ); +}); + +test("content revision changes without changing identity or activation", async (t) => { + const workspace = await fixtureWorkspace(t); + const before = await createHarnessComponentSnapshot({ workspace }); + const skillPath = path.join(workspace, ".qoder", "skills", "verify", "SKILL.md"); + await writeFile(skillPath, `${await readFile(skillPath, "utf8")}\nRun the portable smoke test.\n`, "utf8"); + const after = await createHarnessComponentSnapshot({ workspace }); + const beforeSkill = before.components.find((component) => component.route === ".qoder/skills/verify/SKILL.md"); + const afterSkill = after.components.find((component) => component.route === beforeSkill.route); + + assert.equal(afterSkill.id, beforeSkill.id); + assert.equal(afterSkill.identityDigest, beforeSkill.identityDigest); + assert.notEqual(afterSkill.revision, beforeSkill.revision); + assert.deepEqual(afterSkill.activation, beforeSkill.activation); + const diff = diffHarnessComponentSnapshots(before, after); + assert.deepEqual( + diff.entries.find((entry) => entry.componentId === beforeSkill.id).changedDimensions, + ["content"], + ); +}); + +test("Skill resources and Hook scripts participate in their component revision", async (t) => { + const workspace = await fixtureWorkspace(t); + const before = await createHarnessComponentSnapshot({ workspace }); + const skill = before.components.find((component) => component.route === ".qoder/skills/verify/SKILL.md"); + const hook = before.components.find((component) => component.kind === "hook"); + + const referencePath = path.join(workspace, ".qoder", "skills", "verify", "references", "check.md"); + await writeFile(referencePath, `${await readFile(referencePath, "utf8")}\nAdditional check.\n`, "utf8"); + const afterReference = await createHarnessComponentSnapshot({ workspace }); + const skillAfterReference = afterReference.components.find((component) => component.id === skill.id); + const hookAfterReference = afterReference.components.find((component) => component.id === hook.id); + assert.notEqual(skillAfterReference.revision, skill.revision); + assert.equal(hookAfterReference.revision, hook.revision); + + const scriptPath = path.join(workspace, "scripts", "check.mjs"); + await writeFile(scriptPath, `${await readFile(scriptPath, "utf8")}\n`, "utf8"); + const afterScript = await createHarnessComponentSnapshot({ workspace }); + const hookAfterScript = afterScript.components.find((component) => component.id === hook.id); + assert.notEqual(hookAfterScript.revision, hookAfterReference.revision); + assert.equal(hookAfterScript.id, hook.id); +}); + +test("bounded diff reports added, removed, changed, and unchanged independently", async (t) => { + const workspace = await fixtureWorkspace(t); + const before = await createHarnessComponentSnapshot({ workspace }); + const skillPath = path.join(workspace, ".qoder", "skills", "verify", "SKILL.md"); + await writeFile(skillPath, `${await readFile(skillPath, "utf8")}\nChanged.\n`, "utf8"); + await rm(path.join(workspace, ".qoder", "rules", "review.md")); + await writeFile(path.join(workspace, ".qoder", "workflows", "release.yml"), "name: release\n", "utf8"); + const after = await createHarnessComponentSnapshot({ workspace }); + const full = diffHarnessComponentSnapshots(before, after, { limit: 100 }); + + assert.equal(full.counts.added, 1); + assert.equal(full.counts.removed, 1); + assert.equal(full.counts.changed, 1); + assert.ok(full.counts.unchanged > 0); + assert.deepEqual(new Set(full.entries.map((entry) => entry.status)), new Set(["added", "removed", "changed", "unchanged"])); + const bounded = diffHarnessComponentSnapshots(before, after, { limit: 2 }); + assert.equal(bounded.entries.length, 2); + assert.equal(bounded.totalEntries, full.totalEntries); + assert.equal(bounded.truncated, true); + assert.throws(() => diffHarnessComponentSnapshots(before, after, { limit: 1001 }), { code: "INVALID_DIFF_LIMIT" }); +}); + +test("validation and diff fail closed for stale, tampered, and mismatched snapshots", async (t) => { + const workspace = await fixtureWorkspace(t); + const snapshot = await createHarnessComponentSnapshot({ workspace }); + + const stale = clone(snapshot); + stale.components[0].revision = `sha256:${"0".repeat(64)}`; + assert.throws(() => validateHarnessComponentSnapshot(stale), { code: "ROLLBACK_REFERENCE_MISMATCH" }); + + const relationship = clone(snapshot); + relationship.relationships[0].sourceComponentId = "hcs:qoder:project:rule:missing.md"; + assert.throws(() => validateHarnessComponentSnapshot(relationship), { code: "UNKNOWN_RELATIONSHIP_SOURCE" }); + + const population = clone(snapshot); + population.provider = "claude"; + assert.throws(() => diffHarnessComponentSnapshots(snapshot, population), { code: "UNSUPPORTED_SNAPSHOT_POPULATION" }); + + const outside = path.join(path.dirname(workspace), "outside.md"); + await writeFile(outside, "outside\n", "utf8"); + const inventory = { + provider: "qoder", + manage: { rules: [{ scope: "project", filePath: outside }], skills: [], hooks: [], commands: [] }, + }; + await assert.rejects( + () => assembleHarnessComponentSnapshot({ + workspace, + provider: "qoder", + populationRef: snapshot.populationRef, + inventory, + workflowItems: [], + }), + (error) => error instanceof HarnessComponentSnapshotError && error.code === "COMPONENT_FILE_OUTSIDE_WORKSPACE", + ); + + const relativeInventory = { + provider: "qoder", + manage: { + rules: [{ scope: "project", filePath: "AGENTS.md" }], + skills: [], + hooks: [], + commands: [], + }, + }; + const relative = await assembleHarnessComponentSnapshot({ + workspace, + provider: "qoder", + populationRef: snapshot.populationRef, + inventory: relativeInventory, + workflowItems: [], + }); + assert.equal(relative.components[0].route, "AGENTS.md"); +}); + +test("rollback references resolve exact revisions without authorizing mutation", async (t) => { + const workspace = await fixtureWorkspace(t); + const snapshot = await createHarnessComponentSnapshot({ workspace }); + const component = snapshot.components[0]; + assert.deepEqual(parseRollbackReference(component.rollbackReference), { + componentId: component.id, + revision: component.revision, + }); + const resolved = resolveHarnessComponentRollbackReference(snapshot, component.rollbackReference); + assert.equal(resolved.resolved, true); + assert.equal(resolved.mutationAuthorized, false); + assert.equal(resolved.componentId, component.id); + assert.equal(Object.hasOwn(resolved, "content"), false); + + assert.throws( + () => parseRollbackReference(`harness-component:v1:not-a-component?revision=sha256%3A${"0".repeat(64)}`), + { code: "INVALID_ROLLBACK_REFERENCE" }, + ); + + const alternate = component.revision.endsWith("0") ? "1" : "0"; + const foreign = component.rollbackReference.slice(0, -1) + alternate; + assert.throws(() => resolveHarnessComponentRollbackReference(snapshot, foreign), { code: "ROLLBACK_REVISION_MISMATCH" }); +});