fix(cli): slice 12 BC-2 fixes — escape-band exact-[99.0.n] + findMission README gate

Addresses BLOCKING-CONCERN re-verdict on 79a3099a (guard qitem
qitem-20260516072314-9c3dd1e5; 2 specific findings).

BLOCK 1 — escape-band positional grammar under-enforced.
  Per `conventions/scope-and-versioning/README.md` §1 the non-release
  escape band is exactly <PFX>.99.0.<n> — the `0` segment after `99`
  is FIXED, not just "starts with 99". The BC-1 fix only checked
  "depth-3 starting with 99", which accepted OPR.99.7.8 and
  OPR.99.7.8.5 as conformant.

  Fix: extracted isValidMissionVerSegments(segs) shared between
  isMissionDotId and isSliceDotId's parent-version check. When the
  first segment is 99, require length===3 AND segs[1]==="0" AND
  segs[2] is a non-empty digit string. Both helpers now reject
  OPR.99.7.8 / OPR.99.7.8.1 while still accepting OPR.99.0.42 /
  OPR.99.0.42.7.

  Discriminators (5 new):
    - isMissionDotId rejects OPR.99.7.8 + OPR.99.1.0
    - isMissionDotId still accepts OPR.99.0.42 + OPR.99.0.1
    - isSliceDotId rejects OPR.99.7.8.1; accepts OPR.99.0.42.7
    - CLI: rig scope mission create bad-eb --id OPR.99.7.8 exits 1

BLOCK 2 — findMission did not share listMissions' README gate.
  After BC-1, listMissions skipped README-less directories per HG-8,
  but findMission still returned them as MissionInfo with
  readmePath:null. That left mission show / slice create / slice ship
  / slice move able to silently target scratch/junk dirs and bypass
  the lazy-parent-id-persist contract.

  Fix: findMission now throws a 3-part ScopeCliError when the target
  directory exists but has no README.md, naming the case explicitly
  and pointing operators at `rig scope mission create <name>` to
  declare the dir. All mutation surfaces inherit the guard via the
  shared findMission/findSlice path.

  Discriminators (6 new):
    - findMission(no-readme) throws
    - CLI: rig scope mission show no-readme → exits 1 with
      `not a declared mission` / `no README.md` in error.fact
    - CLI: rig scope slice create no-readme foo → exits 1; slices/01-foo
      NOT created on disk
    - CLI: rig scope slice ship 01-debt-foo no-readme → exits 1;
      source 01-debt-foo unmoved
    - CLI: rig scope slice move 01-debt-foo no-readme → exits 1;
      source unmoved

Verification:
- packages/cli npx tsc --noEmit: clean
- packages/cli npx vitest run on slice 12 files: 74/74 PASS
  (was 64/64 on 79a3099a; +10 BC-2 discriminators)

Stable mint path unchanged: inferMissionDotId already produces
OPR.99.0.<n>, so existing escape-band mission auto-mint flows through
the tightened validator without any new errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mvschwarz
2026-05-16 00:25:57 -07:00
co-authored by Claude Opus 4.7
parent 79a3099a63
commit a7e56382bf
5 changed files with 144 additions and 16 deletions
+23 -15
View File
@@ -64,41 +64,49 @@ export function parseDotId(
return { project, version: verSegs.join("."), n: Number(nSeg), m: Number(mSeg) };
}
/** Validate a mission-ver segments array against the §1 escape-band
* rule. The convention defines the non-release escape band as
* `<PFX>.99.0.<n>` — the `0` after `99` is FIXED. A version like
* `99.7.8` does NOT conform. Shared between isMissionDotId and the
* parent-version check inside isSliceDotId so both surfaces stay in
* lockstep. */
function isValidMissionVerSegments(segs: string[]): boolean {
if (segs.length < 2 || segs.length > 3) return false;
if (segs[0] === "99") {
// Escape band: exactly [99, "0", "<ordinal>"]. The `0` segment is
// fixed; `<ordinal>` must be a non-empty numeric string.
return segs.length === 3 && segs[1] === "0" && /^\d+$/.test(segs[2] ?? "");
}
return true;
}
/** Depth-based tier discriminator. Per §1 the positional grammar
* is fixed:
* mission = <PFX>.<ver> (ver = 2-3 numeric segments;
* escape band is 99.x.y → 3)
* escape band is exactly 99.0.n)
* slice = <PFX>.<ver>.<n> (3-4 numeric segments)
* sub-slice = <PFX>.<ver>.<n>.<m> (4-5 numeric segments)
* Where a mission shape OVERLAPS with a slice shape (e.g. release
* X.Y mission has 2 segments; release X.Y.Z mission has 3 segments
* which is also the slice shape for release X.Y missions) the tier
* is resolved by depth: mission caps at 3 numeric segments; slice
* always has 3-4; sub-slice has 4-5. Escape band cases are
* unambiguous because the 99-prefixed ver is exactly 3 segments. */
* always has 3-4; sub-slice has 4-5. Escape band is fully unambiguous
* because §1 fixes both the 99 marker AND the 0 segment that follows. */
export function isMissionDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "mission");
if (!parsed) return false;
const segs = parsed.version.split(".");
if (segs.length < 2 || segs.length > 3) return false;
// Escape-band mission must have exactly 3 ver segments where the
// first is "99" — guards against e.g. OPR.99.0 being mis-classified
// as a 3-segment release ver.
if (segs[0] === "99") return segs.length === 3;
return true;
return isValidMissionVerSegments(parsed.version.split("."));
}
export function isSliceDotId(raw: unknown): boolean {
if (typeof raw !== "string") return false;
const parsed = parseDotId(raw, "slice");
if (!parsed) return false;
const segs = parsed.version.split(".");
// Slice = mission-ver + 1 ordinal. Parent ver must itself be a
// valid mission shape.
if (segs.length < 2 || segs.length > 3) return false;
if (segs[0] === "99") return segs.length === 3;
return true;
// valid mission shape (including the escape-band exact-[99.0.n]
// rule — a parent like 99.7.8 is not a real mission).
return isValidMissionVerSegments(parsed.version.split("."));
}
/** Render a DotId back to its canonical dot-string. */
+12 -1
View File
@@ -180,7 +180,10 @@ function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
}
/** Resolve a mission by name OR path relative to the missions root.
* Throws ScopeCliError on miss. */
* Throws ScopeCliError on miss OR when the directory exists but
* lacks a README.md (consistent with listMissions' README gate per
* PRD §2.1 / HG-8 — README-less dirs are scratch/junk, not declared
* missions, so mutation commands must not silently target them). */
export function findMission(missionsRoot: string, identifier: string): MissionInfo {
const candidates = [
path.join(missionsRoot, identifier),
@@ -188,6 +191,14 @@ export function findMission(missionsRoot: string, identifier: string): MissionIn
];
for (const candidate of candidates) {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
const readmePath = path.join(candidate, "README.md");
if (!fs.existsSync(readmePath)) {
throw new ScopeCliError({
fact: `Directory "${identifier}" exists at ${candidate} but contains no README.md.`,
consequence: "It is not a declared mission. Command did not run.",
action: "Create it as a mission with: rig scope mission create " + identifier + ". Or add a README.md if the folder is intended to be a mission.",
});
}
return buildMissionInfo(missionsRoot, candidate);
}
}
+69
View File
@@ -382,6 +382,75 @@ describe("rig scope mission create (HG-14 + HG-15)", () => {
expect(rel.exitCode).toBe(0);
expect(JSON.parse(rel.stdout).mission.id).toBe("OPR.0.6.0");
});
it("BC-2 BLOCK 1 discriminator: rejects escape-band with non-zero middle segment (OPR.99.7.8)", async () => {
const r = await run([
"mission", "create", "bad-eb",
"--id", "OPR.99.7.8", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(JSON.parse(r.stdout).ok).toBe(false);
});
});
// ---------------------------------------------------------------------
// BC-2 BLOCK 2: README-less dirs are rejected at every mutation surface
// ---------------------------------------------------------------------
describe("BC-2 BLOCK 2 — README-less dirs are not declared missions at any mutation surface", () => {
let env: { root: string; missionsRoot: string };
beforeEach(() => {
env = seedSubstrate();
// Sneak a README-less directory into missions/ to verify every
// surface rejects it consistently with listMissions.
fs.mkdirSync(path.join(env.missionsRoot, "no-readme"), { recursive: true });
});
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
it("mission ls omits no-readme (already in HG-8 above; sanity check here)", async () => {
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
const parsed = JSON.parse(r.stdout);
expect(parsed.missions.map((m: { name: string }) => m.name)).not.toContain("no-readme");
});
it("mission show no-readme exits 1 with the 3-part 'not a declared mission' error", async () => {
const r = await run(["mission", "show", "no-readme", "--json"], env.missionsRoot);
expect(r.exitCode).toBe(1);
const parsed = JSON.parse(r.stdout);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/no README\.md|not a declared mission/);
});
it("slice create no-readme exits 1 + does NOT create slices/foo/", async () => {
const r = await run([
"slice", "create", "no-readme", "foo", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(path.join(env.missionsRoot, "no-readme", "slices", "01-foo"))).toBe(false);
});
it("slice ship to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
expect(fs.existsSync(srcAbs)).toBe(true);
const r = await run([
"slice", "ship", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
// Source unchanged.
expect(fs.existsSync(srcAbs)).toBe(true);
});
it("slice move to no-readme target exits 1; source slice unmoved", async () => {
const srcAbs = path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo");
const r = await run([
"slice", "move", "01-debt-foo", "no-readme",
"--mission", "backlog", "--json",
], env.missionsRoot);
expect(r.exitCode).toBe(1);
expect(fs.existsSync(srcAbs)).toBe(true);
});
});
// ---------------------------------------------------------------------
+15
View File
@@ -159,4 +159,19 @@ describe("BLOCK 1 — tier-aware validation (isMissionDotId / isSliceDotId)", ()
expect(id!.version).toBe("0.3.2");
expect(id!.n).toBeUndefined();
});
it("BC-2 BLOCK 1: escape band rejects [99, !0, n] mission ver (only [99, 0, n] is valid)", () => {
// §1 fixes the escape band as <PFX>.99.0.<n> — the `0` is FIXED.
// Previously the depth-3 check accepted OPR.99.7.8 as a mission;
// now require the middle 0.
expect(isMissionDotId("OPR.99.7.8")).toBe(false);
expect(isMissionDotId("OPR.99.1.0")).toBe(false);
expect(isMissionDotId("OPR.99.0.42")).toBe(true); // still valid
expect(isMissionDotId("OPR.99.0.1")).toBe(true); // still valid
});
it("BC-2 BLOCK 1: escape-band slice parent must also satisfy the [99,0,n] rule", () => {
expect(isSliceDotId("OPR.99.7.8.1")).toBe(false); // parent=99.7.8 invalid
expect(isSliceDotId("OPR.99.0.42.7")).toBe(true); // parent=99.0.42 valid
});
});
+25
View File
@@ -182,6 +182,31 @@ describe("findSlice + resolution variants", () => {
});
});
describe("BC-2 BLOCK 2 — findMission rejects README-less directories", () => {
let root: string;
let missionsRoot: string;
beforeEach(() => {
root = mktemp();
missionsRoot = path.join(root, "missions");
fs.mkdirSync(missionsRoot, { recursive: true });
writeFile(
path.join(missionsRoot, "release-0.3.2", "README.md"),
"---\nid: OPR.0.3.2\n---\n",
);
// README-less directory next to a real mission — scratch/junk.
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
});
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
it("findMission throws 3-part error when target directory has no README.md", () => {
expect(() => findMission(missionsRoot, "no-readme")).toThrow(/not a declared mission|no README/);
});
it("findMission still resolves declared missions normally", () => {
expect(findMission(missionsRoot, "release-0.3.2").name).toBe("release-0.3.2");
});
});
describe("ensureMissionId", () => {
let root: string;
let missionsRoot: string;