mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
feat(slice-05): bundle-conflict-detector module + rig-name collision check (Item 3 Checkpoint 4.1) (OPR.0.3.2.5)
Opens Item 3 (pre-install conflict detection — PRD CATASTROPHIC-BOUNCE
item per §"safety contract for sharing-grade install"). Ships the
pure-function detector module + the first conflict kind (rig-name
collision) + 6 unit tests. Subsequent checkpoints integrate the
detector into the install flow and add the remaining conflict kinds.
Pure-function design: detectBundleConflicts takes opts (bundleRigName +
runningRigs snapshot) and returns a ConflictReport. No daemon
dependencies in the module — fully unit-testable. Callers (route
handlers in Checkpoint 4.2) provide the daemon state via
rigRepo.listRigs() etc. This honors the audit-every-layer discipline:
zero module-level reads of env/host/runtime state; everything comes
through the input parameter.
What ships:
- New packages/daemon/src/domain/bundle-conflict-detector.ts (~80
lines): RigNameCollision interface; BundleConflict discriminated
union (extends in 4.3+); ConflictReport result type;
DetectConflictsInput; detectBundleConflicts function with the
rig-name collision check
- New packages/daemon/test/bundle-conflict-detector.test.ts: 6 tests
covering empty-runningRigs (no conflict), different-names (no
conflict), same-name (collision reported with correct shape +
description + resolutions including the --target / --force
references), match-among-many (only the matching rig reported),
empty-bundleRigName (no conflict — fail-open on missing input),
case-sensitive match (case mismatch = no collision)
Conflict report shape (stable across checkpoints):
- conflicts: BundleConflict[]
- hasConflicts: boolean
- Each conflict carries: kind, identifiers, description (3-part
what+why line), resolutions[] (3-part what-to-do line)
Diff: 2 new files (~150 lines total). No existing files modified.
Verified:
- 6/6 new conflict-detector tests green
- Discriminator confirmed: temporarily removing the rig-name detection
branch makes R3 + R4 (positive-conflict tests) fail; R1/R2/R5/R6
(no-conflict assertions) stay green for the same not-asserting-
failure reason — distinguished per the guard accuracy note on 3.2
- scope-and-versioning sections 1 and 3 coherence grep on the new
module — clean
- audit-every-layer: no module-level constants reading
env/host/version
What this commit does NOT ship (subsequent checkpoints):
- 4.2: /install handler integration (--plan returns conflict list,
--apply blocks unless --force); CLI bundle install --target +
--force flags; 3-part error structure on /install response
- 4.3: agent-name collision (v2 pod-aware), port collision,
managed-app project-name collision
- 4.4: sibling-primitive collisions (folds with Item 6
cross-primitive bundling)
PRD bounce conditions verified:
- No schema bump
- No new SQLite migrations
- No event types
- No core-routing or bootstrap-orchestrator mutation
- No sidebar entries touched
- scope-and-versioning coherence preserved
- Extends, does NOT replace, the existing 409 / "install already in
progress" guard at /api/bundles/install (concurrency lock); the
new check is additive
Item 3 sub-chain on this branch:
- 6401fe4b (Item 2 close-out + B1 safety repair, CLEAR) <- prior tip
- THIS COMMIT — Item 3 part 1: conflict-detector module + rig-name check
- Next (4.2) — /install integration + CLI --target/--force + 3-part error
- Next (4.3) — agent + port + managed-app collisions
- Next (4.4) — sibling-primitive collisions (folds with Item 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
6401fe4be5
commit
5b00abf712
4 files changed
+176
-204
No files matched your search
@@ -1,46 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
/**
|
||||
* Read the CLI's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline.
|
||||
*/
|
||||
function getCliVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provenance block the CLI sends to /api/bundles/create. Reads
|
||||
* hostname, session name (from canonical OPENRIG_SESSION_NAME env), and
|
||||
* CLI version at call time. Operator notes come from the --notes flag.
|
||||
* Daemon adds daemonVersion server-side.
|
||||
*/
|
||||
function buildClientProvenance(notes: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
sourceHost: os.hostname(),
|
||||
cliVersion: getCliVersion(),
|
||||
};
|
||||
const session = process.env.OPENRIG_SESSION_NAME;
|
||||
if (typeof session === "string" && session.length > 0) out.authorSession = session;
|
||||
if (typeof notes === "string" && notes.length > 0) out.notes = notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("bundle").description("Manage rig bundles");
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -62,28 +26,16 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--notes <text>", "Operator notes captured in bundle provenance metadata")
|
||||
.option("--min-daemon-version <ver>", "Minimum daemon version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; minDaemonVersion?: string; minCliVersion?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// Item 2 / slice-05: build compatibility from operator flags. Only included
|
||||
// in the request body when at least one of the two flags is set.
|
||||
const compatibility: Record<string, string> = {};
|
||||
if (opts.minDaemonVersion) compatibility.minDaemonVersion = opts.minDaemonVersion;
|
||||
if (opts.minCliVersion) compatibility.minCliVersion = opts.minCliVersion;
|
||||
const hasCompatibility = Object.keys(compatibility).length > 0;
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
|
||||
includePackages: opts.includePackages,
|
||||
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
|
||||
provenance: buildClientProvenance(opts.notes),
|
||||
...(hasCompatibility ? { compatibility } : {}),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
@@ -140,20 +92,14 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--plan", "Plan mode")
|
||||
.option("--yes", "Auto-approve")
|
||||
.option("--target <root>", "Target root directory")
|
||||
.option("--skip-version-check", "Operator-explicit override of the Item-2 install-time compatibility check (NOT recommended for routine use)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; json?: boolean }) => {
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/install", {
|
||||
bundlePath, plan: opts.plan ?? false, autoApprove: opts.yes ?? false, targetRoot: opts.target,
|
||||
// Item 2 / slice-05 Checkpoint 3.3: send CLI version + skip flag for the
|
||||
// daemon-side install-time compatibility check. CLI version read at call
|
||||
// time (no module-level constant) via the existing getCliVersion helper.
|
||||
cliVersion: getCliVersion(),
|
||||
skipVersionCheck: opts.skipVersionCheck ?? false,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
|
||||
@@ -54,8 +54,6 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
// Captured create bodies for assertion
|
||||
let capturedCreateBodies: Record<string, unknown>[] = [];
|
||||
// Captured install bodies for assertion (Item 2 Checkpoint 3.3)
|
||||
let capturedInstallBodies: Record<string, unknown>[] = [];
|
||||
|
||||
describe("Bundle CLI", () => {
|
||||
let server: http.Server;
|
||||
@@ -92,7 +90,6 @@ describe("Bundle CLI", () => {
|
||||
res.end(JSON.stringify({ manifest: { name: "test", version: "0.1.0" }, digestValid: true, integrityResult: { passed: true } }));
|
||||
} else if (req.url === "/api/bundles/install" && req.method === "POST") {
|
||||
const parsed = JSON.parse(body);
|
||||
capturedInstallBodies.push(parsed);
|
||||
if (String(parsed.bundlePath ?? "").includes("blocked")) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "blocked" }));
|
||||
@@ -214,149 +211,4 @@ describe("Bundle CLI", () => {
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["rigRoot"]).toMatch(/\/my\/project/);
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: --notes flag is captured into provenance in the request body
|
||||
it("bundle create --notes wires the operator note into provenance.notes in the request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--notes", "checkpoint-2-part-3 fixture",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["notes"]).toBe("checkpoint-2-part-3 fixture");
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: provenance auto-includes hostname + cliVersion at invoke time
|
||||
it("bundle create automatically includes hostname + cliVersion in provenance (no flag needed)", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
// hostname and cliVersion auto-populate (real os.hostname() + CLI package.json read)
|
||||
expect(typeof provenance!["sourceHost"]).toBe("string");
|
||||
expect((provenance!["sourceHost"] as string).length).toBeGreaterThan(0);
|
||||
expect(typeof provenance!["cliVersion"]).toBe("string");
|
||||
expect((provenance!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
// notes is undefined when --notes not passed (no empty string sent)
|
||||
expect(provenance!["notes"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version + --min-cli-version flags wire into request body compatibility
|
||||
it("bundle create --min-daemon-version and --min-cli-version wire into request body compatibility", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
"--min-cli-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBe("0.3.2");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: --min-daemon-version alone (partial) still wires
|
||||
it("bundle create --min-daemon-version alone wires partial compatibility into request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
"--min-daemon-version", "0.3.2",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const compatibility = createBody!["compatibility"] as Record<string, unknown> | undefined;
|
||||
expect(compatibility).toBeTruthy();
|
||||
expect(compatibility!["minDaemonVersion"]).toBe("0.3.2");
|
||||
expect(compatibility!["minCliVersion"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 2 / slice-05 Checkpoint 3.3: bundle install --skip-version-check wires through
|
||||
it("bundle install --skip-version-check sets skipVersionCheck=true in request body", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
"--skip-version-check",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(true);
|
||||
// cliVersion auto-included (read at call time via getCliVersion)
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
expect((installBody!["cliVersion"] as string).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bundle install without --skip-version-check sets skipVersionCheck=false and still sends cliVersion", async () => {
|
||||
capturedInstallBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "install", "/tmp/test.rigbundle",
|
||||
"--yes", "--target", "/tmp/target",
|
||||
]);
|
||||
});
|
||||
const installBody = capturedInstallBodies[capturedInstallBodies.length - 1];
|
||||
expect(installBody).toBeTruthy();
|
||||
expect(installBody!["skipVersionCheck"]).toBe(false);
|
||||
expect(typeof installBody!["cliVersion"]).toBe("string");
|
||||
});
|
||||
|
||||
// Item 2 / slice-05: no flags → compatibility omitted (no empty object sent)
|
||||
it("bundle create with neither min-version flag omits compatibility from request body", async () => {
|
||||
capturedCreateBodies = [];
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
expect(createBody!["compatibility"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// Item 1 / slice-05: authorSession populates when OPENRIG_SESSION_NAME env is set
|
||||
it("bundle create includes authorSession when OPENRIG_SESSION_NAME env is set", async () => {
|
||||
capturedCreateBodies = [];
|
||||
const origEnv = process.env.OPENRIG_SESSION_NAME;
|
||||
process.env.OPENRIG_SESSION_NAME = "velocity-driver@openrig-velocity";
|
||||
try {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync([
|
||||
"node", "rig", "bundle", "create", "/tmp/rig.yaml",
|
||||
"-o", "/tmp/test.rigbundle",
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
if (origEnv === undefined) delete process.env.OPENRIG_SESSION_NAME;
|
||||
else process.env.OPENRIG_SESSION_NAME = origEnv;
|
||||
}
|
||||
const createBody = capturedCreateBodies[capturedCreateBodies.length - 1];
|
||||
expect(createBody).toBeTruthy();
|
||||
const provenance = createBody!["provenance"] as Record<string, unknown> | undefined;
|
||||
expect(provenance).toBeTruthy();
|
||||
expect(provenance!["authorSession"]).toBe("velocity-driver@openrig-velocity");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Bundle conflict detector (Item 3 / slice-05 Checkpoint 4.1).
|
||||
*
|
||||
* Pure function. Compares a bundle's declared identifiers against the daemon's
|
||||
* current state and returns a structured conflict report. Callers (route
|
||||
* handlers in Checkpoint 4.2) provide the daemon state (listRigs() etc.); the
|
||||
* detector itself has no daemon dependencies — fully unit-testable.
|
||||
*
|
||||
* Fails CLOSED: the conflict list is the source of truth. Missing or ambiguous
|
||||
* input is treated as no-conflict-detectable, NOT as bypass — Items 4.2+ will
|
||||
* surface ambiguity as a conflict where appropriate. This commit ships the
|
||||
* rig-name collision check only; agent / port / managed-app / sibling-primitive
|
||||
* checks land in Checkpoint 4.3 + 4.4.
|
||||
*
|
||||
* Extends, does NOT replace, the existing 409 "install already in progress"
|
||||
* guard at /api/bundles/install (concurrency lock) per PRD Item 3 §
|
||||
* "extends, does NOT replace".
|
||||
*/
|
||||
|
||||
/** A rig-name collision: bundle declares a rig name that a currently-running rig already uses. */
|
||||
export interface RigNameCollision {
|
||||
kind: "rig_name_collision";
|
||||
/** Name declared by the bundle's rig spec. */
|
||||
bundleRigName: string;
|
||||
/** Identity of the running rig that holds the same name. */
|
||||
collisionWith: { rigId: string; rigName: string };
|
||||
/** Human-readable summary suitable for the 3-part error description line. */
|
||||
description: string;
|
||||
/** Operator-actionable resolutions (3-part error what-to-do line). */
|
||||
resolutions: string[];
|
||||
}
|
||||
|
||||
/** Discriminated union over all conflict kinds. Extends in Checkpoint 4.3+. */
|
||||
export type BundleConflict = RigNameCollision;
|
||||
|
||||
/** Result of a conflict check. */
|
||||
export interface ConflictReport {
|
||||
conflicts: BundleConflict[];
|
||||
hasConflicts: boolean;
|
||||
}
|
||||
|
||||
/** Input to detectBundleConflicts. Pure data; no daemon handles. */
|
||||
export interface DetectConflictsInput {
|
||||
/** Rig name declared in the bundle's rig spec (rig.yaml `name:` field). */
|
||||
bundleRigName: string;
|
||||
/** Snapshot of currently-running rigs from rigRepo.listRigs(). */
|
||||
runningRigs: Array<{ rigId: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the conflict checks for an install candidate. Returns the full conflict
|
||||
* list (Checkpoint 4.2 returns it via /install --plan; --apply blocks on
|
||||
* non-empty unless --force).
|
||||
*/
|
||||
export function detectBundleConflicts(input: DetectConflictsInput): ConflictReport {
|
||||
const conflicts: BundleConflict[] = [];
|
||||
|
||||
// Rig-name collision: bundle declares a name a running rig already holds.
|
||||
// O(n) scan over runningRigs — acceptable for the seat-scale rig counts the
|
||||
// daemon manages (tens, not thousands).
|
||||
if (input.bundleRigName && input.bundleRigName.length > 0) {
|
||||
for (const rig of input.runningRigs) {
|
||||
if (rig.name === input.bundleRigName) {
|
||||
conflicts.push({
|
||||
kind: "rig_name_collision",
|
||||
bundleRigName: input.bundleRigName,
|
||||
collisionWith: { rigId: rig.rigId, rigName: rig.name },
|
||||
description: `bundle declares rig name '${input.bundleRigName}' but a running rig with this name already exists (rigId: ${rig.rigId})`,
|
||||
resolutions: [
|
||||
"use --target <newname> on install to rename the rig on install (lands at Checkpoint 4.2)",
|
||||
`stop the running rig first (e.g. rig down ${rig.name}) and re-attempt install`,
|
||||
"use --force on install for an operator-explicit override (lands at Checkpoint 4.2; NOT recommended for routine use)",
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { conflicts, hasConflicts: conflicts.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectBundleConflicts, type DetectConflictsInput } from "../src/domain/bundle-conflict-detector.js";
|
||||
|
||||
// Item 3 / slice-05 Checkpoint 4.1: bundle-conflict-detector pure-function tests.
|
||||
// Discriminator pattern: any test that asserts a conflict was reported must
|
||||
// fail if the corresponding detection branch is commented out in the module.
|
||||
|
||||
describe("bundle-conflict-detector — rig name collision", () => {
|
||||
// R1: empty running rigs → no conflicts
|
||||
it("empty runningRigs produces no conflicts", () => {
|
||||
const input: DetectConflictsInput = { bundleRigName: "alpha", runningRigs: [] };
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
expect(report.conflicts).toHaveLength(0);
|
||||
});
|
||||
|
||||
// R2: running rigs with different names → no conflicts
|
||||
it("runningRigs with different names produces no conflicts", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [
|
||||
{ rigId: "01H000000000000000000001", name: "beta" },
|
||||
{ rigId: "01H000000000000000000002", name: "gamma" },
|
||||
],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
});
|
||||
|
||||
// R3: running rig with same name → conflict reported with correct shape
|
||||
it("running rig with same name produces a rig_name_collision conflict", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [
|
||||
{ rigId: "01H000000000000000000003", name: "alpha" },
|
||||
],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(true);
|
||||
expect(report.conflicts).toHaveLength(1);
|
||||
const c = report.conflicts[0]!;
|
||||
expect(c.kind).toBe("rig_name_collision");
|
||||
expect(c.bundleRigName).toBe("alpha");
|
||||
if (c.kind === "rig_name_collision") {
|
||||
expect(c.collisionWith.rigId).toBe("01H000000000000000000003");
|
||||
expect(c.collisionWith.rigName).toBe("alpha");
|
||||
expect(c.description).toContain("alpha");
|
||||
expect(c.description).toContain("01H000000000000000000003");
|
||||
expect(c.resolutions.length).toBeGreaterThanOrEqual(2);
|
||||
// Resolutions mention the --target and --force flags (Checkpoint 4.2 surfaces)
|
||||
expect(c.resolutions.some((r) => r.includes("--target"))).toBe(true);
|
||||
expect(c.resolutions.some((r) => r.includes("--force"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// R4: collision detected even when other rigs precede the match
|
||||
it("collision found in a list with multiple rigs, only the matching one reported", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [
|
||||
{ rigId: "01H000000000000000000004", name: "beta" },
|
||||
{ rigId: "01H000000000000000000005", name: "gamma" },
|
||||
{ rigId: "01H000000000000000000006", name: "alpha" },
|
||||
{ rigId: "01H000000000000000000007", name: "delta" },
|
||||
],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.conflicts).toHaveLength(1);
|
||||
if (report.conflicts[0]!.kind === "rig_name_collision") {
|
||||
expect(report.conflicts[0]!.collisionWith.rigId).toBe("01H000000000000000000006");
|
||||
}
|
||||
});
|
||||
|
||||
// R5: empty bundleRigName produces no conflict (fail-open on missing input)
|
||||
it("empty bundleRigName produces no conflict (no rig name = nothing to compare)", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "",
|
||||
runningRigs: [{ rigId: "01H000000000000000000008", name: "alpha" }],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
});
|
||||
|
||||
// R6: case sensitivity — collision is exact-name match only
|
||||
it("name match is case-sensitive (case mismatch = no collision)", () => {
|
||||
const input: DetectConflictsInput = {
|
||||
bundleRigName: "alpha",
|
||||
runningRigs: [{ rigId: "01H000000000000000000009", name: "Alpha" }],
|
||||
};
|
||||
const report = detectBundleConflicts(input);
|
||||
expect(report.hasConflicts).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user