mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-12 04:20:06 +08:00
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:
co-authored by
Claude Opus 4.7
parent
79a3099a63
commit
a7e56382bf
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user