mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-12 04:20:06 +08:00
fix(cli): slice 12 BC fixes — tier-aware ID + README gate + persist parent-id + symlink test
Addresses BLOCKING-CONCERN verdict on 0b77cba4 (guard qitem
qitem-20260516071333-0a9e7184; 4 specific findings, each with fix
recipe + make-it-fail-first discriminator).
BLOCK 1 — mission --id validation is not tier-aware.
Added isMissionDotId + isSliceDotId helpers that enforce the
positional depth invariants:
mission = 2-3 numeric segments; escape-band is exactly 3 with
first segment 99
slice = 3-4 numeric segments; same escape-band exact-3 rule
on the parent-ver portion
sub-slice = parseDotId(s, "sub-slice") peels n + m
parseDotId now accepts an optional `tier` arg ("mission"|"slice"|
"sub-slice"); without it returns all segments as version
(backward-compat). mission --id validation switched from the
tier-agnostic isConformantDotId to isMissionDotId so a slice-shaped
--id (OPR.0.3.2.12) is rejected with a 3-part error that names
mission-shape vs slice-shape explicitly.
Discriminators:
- parseDotId("OPR.0.3.2.12", "slice") yields version=0.3.2, n=12
- parseDotId("OPR.0.3.2", "mission") yields version=0.3.2, n=undefined
- isMissionDotId rejects OPR.0.3.2.12 / OPR.99.0.1.5 / OPR.A.1 /
OPR.99.0 (escape band must be exactly 3 segments)
- CLI: `rig scope mission create wrong-tier --id OPR.0.3.2.12`
exits 1 with `error.fact: not a mission-tier dot-ID`
BLOCK 2 — mission ls included no-README dirs contrary to HG-8.
listMissions now skips directories without a README.md per PRD §2.1.
The existing fixture test's assertion was inverted (it asserted the
no-README dir WAS listed); corrected and added a fresh discriminator
test seeding 3 dirs / 1 without README → only 2 listed.
BLOCK 3 — lazy parent-id adoption was persisted only for slice create.
Added ensureMissionIdPersisted(mission, missionsRoot) helper that
returns the mission id and, when mission.readmePath exists and
mission.id is absent, writes it back to the target README in the
same call. Threaded through slice create + slice ship + slice move
so every child-mint site adopts the parent's id consistently
(per convention §1 lazy-adoption rule). Sibling missions without
id remain untouched — narrow create-triggered, not mass migrate.
Discriminators (3 new tests):
- slice ship to an id-less release-0.3.2 → target README gains
`id: OPR.0.3.2`; child id = OPR.0.3.2.NN
- slice move with the same setup
- sibling mission "untouched-mission" without id remains
byte-for-byte identical after the ship completes
BLOCK 4 — macOS /var realpath proof was overclaimed.
Added a focused symlink regression test that:
1. mktemp a parallel symlink tree pointing at the real repo
2. asserts realpathSync(linkRoot) !== linkRoot (covers the
/var↔/private/var class AND any other path-resolving symlink)
3. invokes moveSlice with src/dest paths flowing through the
symlinked tree
4. asserts result.usedGit is true, result.repoRoot equals the
git-returned realpath, and git log --follow on the dest
picks up the seed history
The test fails if scope-fs.ts is reverted to compute path.relative
against an un-normalized repo root.
Verification:
- packages/cli npx tsc --noEmit: clean
- packages/cli npx vitest run on slice 12 files: 64/64 PASS
(was 49/49 on 0b77cba4; +15 BC discriminators)
- packages/cli full vitest: 1018/1020 PASS — same 2 pre-existing
preflight failures present on main; not introduced here
No production behavior regression: existing scope tests still pass
verbatim. New behavior is strictly additive (tier-aware validation
on a path that was previously over-permissive) or strictly more
correct (mission ls README gate; persisted parent-id) or strictly
more covered (symlink discriminator).
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
0b77cba4c5
commit
79a3099a63
@@ -23,12 +23,12 @@ import {
|
||||
import {
|
||||
DEFAULT_PROJECT_PREFIX,
|
||||
inferMissionDotId,
|
||||
isConformantDotId,
|
||||
isMissionDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
sliceIdFromMission,
|
||||
} from "../lib/scope/dot-id.js";
|
||||
import {
|
||||
ensureMissionId,
|
||||
ensureMissionIdPersisted,
|
||||
findMission,
|
||||
findSlice,
|
||||
listMissions,
|
||||
@@ -248,7 +248,11 @@ function buildSliceCreateCommand(): Command {
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
const missionId = ensureMissionId(mission, missionsRoot);
|
||||
// Persist the parent mission's id back into its README at the
|
||||
// SAME moment we mint the child's id (per convention §1
|
||||
// lazy-adoption rule + guard BC BLOCK 3 — every child-mint site
|
||||
// must persist the parent's id, not just slice create).
|
||||
const missionId = ensureMissionIdPersisted(mission, missionsRoot);
|
||||
const nn = nextSliceNN(mission.absPath);
|
||||
const sliceFolder = `${pad2(nn)}-${slug}`;
|
||||
const sliceAbs = path.join(mission.absPath, "slices", sliceFolder);
|
||||
@@ -272,11 +276,6 @@ function buildSliceCreateCommand(): Command {
|
||||
fs.mkdirSync(sliceAbs, { recursive: true });
|
||||
const readmePath = path.join(sliceAbs, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
// If the mission has no `id:` yet, mint it now so future slice
|
||||
// creates short-circuit to the cached value (auto-adopt).
|
||||
if (mission.readmePath && !mission.id) {
|
||||
updateFrontmatter(mission.readmePath, { id: missionId });
|
||||
}
|
||||
const payload = {
|
||||
ok: true,
|
||||
slice: {
|
||||
@@ -325,7 +324,7 @@ function buildSliceShipCommand(): Command {
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const targetId = ensureMissionId(target, missionsRoot);
|
||||
const targetId = ensureMissionIdPersisted(target, missionsRoot);
|
||||
const newSliceId = sliceIdFromMission(targetId, newNN);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
@@ -443,7 +442,7 @@ function buildSliceMoveCommand(): Command {
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const targetId = ensureMissionId(target, missionsRoot);
|
||||
const targetId = ensureMissionIdPersisted(target, missionsRoot);
|
||||
const newSliceId = sliceIdFromMission(targetId, newNN);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
@@ -595,11 +594,16 @@ function buildMissionCreateCommand(): Command {
|
||||
// Mint the dot-ID.
|
||||
let id: string;
|
||||
if (opts.id) {
|
||||
if (!isConformantDotId(opts.id)) {
|
||||
// Tier-aware validation per guard BC verdict (BLOCK 1).
|
||||
// A mission ID has 2-3 numeric segments after the prefix
|
||||
// (release X.Y or X.Y.Z; escape-band 99.x.y). Reject
|
||||
// slice-shaped IDs (4 segments) so the parent identity stays
|
||||
// unambiguous.
|
||||
if (!isMissionDotId(opts.id)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Supplied --id "${opts.id}" does not match the §1 grammar (<PFX>.<ver>[.n][.m]).`,
|
||||
consequence: "Mission not created.",
|
||||
action: "Use a conformant dot-ID like OPR.0.3.2 or OPR.99.0.1.",
|
||||
fact: `Supplied --id "${opts.id}" is not a mission-tier dot-ID.`,
|
||||
consequence: "Mission not created. A mission ID has the shape <PFX>.<ver> (2-3 numeric segments), not a slice shape <PFX>.<ver>.<n>.",
|
||||
action: "Use a mission-shaped dot-ID like OPR.0.3.2 (release) or OPR.99.0.1 (escape band). For slice IDs, scope automatically mints them when you create a slice.",
|
||||
});
|
||||
}
|
||||
id = opts.id;
|
||||
|
||||
@@ -24,31 +24,81 @@ export const DEFAULT_PROJECT_PREFIX = "OPR";
|
||||
|
||||
const RELEASE_NAME_RE = /^release-(\d+\.\d+(?:\.\d+)?)$/;
|
||||
// Strict positional grammar: every segment is a number except for the
|
||||
// 2-3 letter project prefix at position 0.
|
||||
const DOT_ID_RE = /^([A-Z]{2,3})\.(\d+(?:\.\d+){0,3})$/;
|
||||
// 2-3 letter project prefix at position 0. Accepts 2-5 numeric
|
||||
// segments after the prefix (mission has 2-3, slice has 3-4, sub-slice
|
||||
// has 4-5). Tier-aware parse helpers below split version/n/m correctly.
|
||||
const DOT_ID_RE = /^([A-Z]{2,3})\.(\d+(?:\.\d+){1,4})$/;
|
||||
|
||||
/** Parse a dot-ID string into structured parts; returns null when the
|
||||
* string doesn't conform to the §1 positional grammar. */
|
||||
export function parseDotId(raw: string): DotId | null {
|
||||
* string doesn't conform to the §1 positional grammar. When `tier`
|
||||
* is supplied, the trailing segments are peeled off into `n` (slice)
|
||||
* or `n` + `m` (sub-slice). With no tier, all numeric segments are
|
||||
* returned as `version` for backwards compatibility. */
|
||||
export function parseDotId(
|
||||
raw: string,
|
||||
tier?: "mission" | "slice" | "sub-slice",
|
||||
): DotId | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
const m = DOT_ID_RE.exec(trimmed);
|
||||
if (!m) return null;
|
||||
const project = m[1]!;
|
||||
const segments = m[2]!.split(".");
|
||||
// version is "ver" portion (first 1-3 numeric segments depending on
|
||||
// whether the ID is for a mission / slice / sub-slice). We treat
|
||||
// version as the prefix that excludes the last segment for slice IDs
|
||||
// and the last two for sub-slice IDs. Without context, we cannot
|
||||
// distinguish "0.3.2" mission from "0.3.2 slice (with empty n)". The
|
||||
// caller asks for `expected: "mission" | "slice" | "sub-slice"` if
|
||||
// it cares. By default we return all segments as `version`.
|
||||
return {
|
||||
project,
|
||||
version: segments.join("."),
|
||||
n: undefined,
|
||||
m: undefined,
|
||||
};
|
||||
if (!tier) {
|
||||
return { project, version: segments.join("."), n: undefined, m: undefined };
|
||||
}
|
||||
if (tier === "mission") {
|
||||
return { project, version: segments.join("."), n: undefined, m: undefined };
|
||||
}
|
||||
if (tier === "slice") {
|
||||
if (segments.length < 3) return null; // slice = ver(>=2) + n
|
||||
const verSegs = segments.slice(0, -1);
|
||||
const nSeg = segments[segments.length - 1]!;
|
||||
return { project, version: verSegs.join("."), n: Number(nSeg), m: undefined };
|
||||
}
|
||||
// sub-slice
|
||||
if (segments.length < 4) return null;
|
||||
const verSegs = segments.slice(0, -2);
|
||||
const nSeg = segments[segments.length - 2]!;
|
||||
const mSeg = segments[segments.length - 1]!;
|
||||
return { project, version: verSegs.join("."), n: Number(nSeg), m: Number(mSeg) };
|
||||
}
|
||||
|
||||
/** 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)
|
||||
* 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. */
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Render a DotId back to its canonical dot-string. */
|
||||
@@ -106,7 +156,10 @@ export function nextEscapeBandOrdinal(
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
/** Does this candidate string parse as a valid §1 dot-ID? */
|
||||
/** Does this candidate string parse as a valid §1 dot-ID at ANY
|
||||
* tier? Prefer the tier-specific isMissionDotId/isSliceDotId at use
|
||||
* sites where the tier is known — depth alone doesn't disambiguate
|
||||
* mission vs slice for release IDs with overlapping shapes. */
|
||||
export function isConformantDotId(raw: unknown): boolean {
|
||||
return typeof raw === "string" && parseDotId(raw) !== null;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,9 @@ export function resolveMissionsRoot(opts: {
|
||||
}
|
||||
|
||||
/** List mission folders under the missions root. A mission is any
|
||||
* top-level folder containing a README.md. */
|
||||
* top-level folder containing a README.md (per IMPLEMENTATION-PRD
|
||||
* §2.1 / HG-8). Directories without a README.md are skipped — they
|
||||
* represent scratch/junk, not declared missions. */
|
||||
export function listMissions(missionsRoot: string): MissionInfo[] {
|
||||
if (!fs.existsSync(missionsRoot)) return [];
|
||||
const entries = fs.readdirSync(missionsRoot, { withFileTypes: true });
|
||||
@@ -143,7 +145,8 @@ export function listMissions(missionsRoot: string): MissionInfo[] {
|
||||
const absPath = path.join(missionsRoot, entry.name);
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
|
||||
if (!hasReadme) continue;
|
||||
const frontmatter = readFrontmatter(readmePath);
|
||||
const slicesDir = path.join(absPath, "slices");
|
||||
const closedDir = path.join(absPath, "closed");
|
||||
const activeSliceCount = countSliceDirs(slicesDir);
|
||||
@@ -364,6 +367,27 @@ export function ensureMissionId(
|
||||
return inferMissionDotId(mission.name, ordinal);
|
||||
}
|
||||
|
||||
/** Same as ensureMissionId, but ALSO writes the inferred id back into
|
||||
* the mission's README frontmatter when it was absent. Use this at
|
||||
* every site that mints a child id (slice create / ship / move target)
|
||||
* — per the convention's lazy-adoption rule: when a child is created
|
||||
* under a pre-existing parent with no id, assign the parent's id
|
||||
* on-demand at the same time. Narrow, single-parent, create-triggered;
|
||||
* never mass-migration. */
|
||||
export function ensureMissionIdPersisted(
|
||||
mission: MissionInfo,
|
||||
missionsRoot: string,
|
||||
): string {
|
||||
const id = ensureMissionId(mission, missionsRoot);
|
||||
if (!mission.id && mission.readmePath) {
|
||||
updateFrontmatter(mission.readmePath, { id });
|
||||
// Reflect the write on the in-memory MissionInfo so subsequent
|
||||
// callers in the same command don't re-mint a different ordinal.
|
||||
mission.id = id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Git move
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -353,6 +353,104 @@ describe("rig scope mission create (HG-14 + HG-15)", () => {
|
||||
], env.missionsRoot);
|
||||
expect(bad.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("BLOCK 1 discriminator: mission create rejects slice-depth --id (OPR.0.3.2.12)", async () => {
|
||||
// Per guard BC verdict: a 4-segment slice-shape must NOT pass
|
||||
// mission --id validation. Without tier-aware validation this
|
||||
// would succeed and create a mission with a slice-shaped id.
|
||||
const r = await run([
|
||||
"mission", "create", "wrong-tier",
|
||||
"--id", "OPR.0.3.2.12", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toMatch(/not a mission-tier dot-ID/);
|
||||
});
|
||||
|
||||
it("BLOCK 1 follow-up: mission create still accepts valid mission shapes (escape band + release)", async () => {
|
||||
const eb = await run([
|
||||
"mission", "create", "exp-band",
|
||||
"--id", "OPR.99.0.42", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(eb.exitCode).toBe(0);
|
||||
expect(JSON.parse(eb.stdout).mission.id).toBe("OPR.99.0.42");
|
||||
|
||||
const rel = await run([
|
||||
"mission", "create", "release-0.6.0", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(rel.exitCode).toBe(0);
|
||||
expect(JSON.parse(rel.stdout).mission.id).toBe("OPR.0.6.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// BLOCK 3 discriminator: ship + move persist target.id back to target README
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("BLOCK 3 — ship/move persist target mission id into target README", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
|
||||
beforeEach(() => {
|
||||
env = seedSubstrate();
|
||||
// Replace release-0.3.2/README.md with an ID-LESS variant so we
|
||||
// can observe the lazy-adopt-and-persist behavior.
|
||||
fs.writeFileSync(
|
||||
path.join(env.missionsRoot, "release-0.3.2", "README.md"),
|
||||
"# release-0.3.2\n\n(no id frontmatter yet)\n",
|
||||
"utf8",
|
||||
);
|
||||
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", env.root, "commit", "-m", "strip-target-id", "-q"], { stdio: "ignore" });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("slice ship: target mission gains a persisted id; child id = targetId.NN", async () => {
|
||||
const r = await run([
|
||||
"slice", "ship", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
// Target README now has id: OPR.0.3.2 written back.
|
||||
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
|
||||
expect(targetFm.id).toBe("OPR.0.3.2");
|
||||
// Child id matches parent.NN.
|
||||
expect(parsed.shipped.to.id).toBe("OPR.0.3.2.2");
|
||||
});
|
||||
|
||||
it("slice move: target mission gains a persisted id; child id = targetId.NN", async () => {
|
||||
const r = await run([
|
||||
"slice", "move", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const targetFm = readFrontmatter(path.join(env.missionsRoot, "release-0.3.2", "README.md"));
|
||||
expect(targetFm.id).toBe("OPR.0.3.2");
|
||||
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
|
||||
});
|
||||
|
||||
it("sibling missions without id remain UNCHANGED after a ship/move (narrow lazy adoption, not mass-migrate)", async () => {
|
||||
// Spawn an unrelated id-less mission alongside; assert it survives
|
||||
// untouched through a ship to a different mission.
|
||||
fs.mkdirSync(path.join(env.missionsRoot, "untouched-mission"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(env.missionsRoot, "untouched-mission", "README.md"),
|
||||
"# untouched\n",
|
||||
"utf8",
|
||||
);
|
||||
execFileSync("git", ["-C", env.root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", env.root, "commit", "-m", "seed-sibling", "-q"], { stdio: "ignore" });
|
||||
const before = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
|
||||
await run([
|
||||
"slice", "ship", "01-debt-foo", "release-0.3.2",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
const after = fs.readFileSync(path.join(env.missionsRoot, "untouched-mission", "README.md"), "utf8");
|
||||
expect(after).toBe(before);
|
||||
expect(readFrontmatter(path.join(env.missionsRoot, "untouched-mission", "README.md")).id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
formatDotId,
|
||||
inferMissionDotId,
|
||||
isConformantDotId,
|
||||
isMissionDotId,
|
||||
isSliceDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
parseDotId,
|
||||
sliceIdFromMission,
|
||||
@@ -92,3 +94,69 @@ describe("isConformantDotId", () => {
|
||||
expect(isConformantDotId("opr.0.3.2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BLOCK 1 — tier-aware validation (isMissionDotId / isSliceDotId)", () => {
|
||||
it("isMissionDotId accepts release-shaped IDs (2-3 numeric segments)", () => {
|
||||
expect(isMissionDotId("OPR.0.3.2")).toBe(true);
|
||||
expect(isMissionDotId("OPR.1.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("isMissionDotId accepts the escape-band shape (99.x.y, exactly 3 segments)", () => {
|
||||
expect(isMissionDotId("OPR.99.0.1")).toBe(true);
|
||||
expect(isMissionDotId("OPR.99.0.42")).toBe(true);
|
||||
});
|
||||
|
||||
it("isMissionDotId REJECTS slice-shaped IDs (4+ numeric segments) — guard BC discriminator", () => {
|
||||
// The crux of BLOCK 1: a slice-depth ID like OPR.0.3.2.12 must
|
||||
// not pass mission validation, even though it parses as a valid
|
||||
// dot-ID at SOME tier.
|
||||
expect(isMissionDotId("OPR.0.3.2.12")).toBe(false);
|
||||
expect(isMissionDotId("OPR.99.0.1.5")).toBe(false);
|
||||
});
|
||||
|
||||
it("isMissionDotId rejects malformed inputs (alpha / wrong prefix shape)", () => {
|
||||
expect(isMissionDotId("OPR.A.1")).toBe(false);
|
||||
expect(isMissionDotId("opr.0.3.2")).toBe(false);
|
||||
expect(isMissionDotId(42 as unknown)).toBe(false);
|
||||
expect(isMissionDotId("OPR.99.0")).toBe(false); // escape band must be exactly 3
|
||||
});
|
||||
|
||||
it("isSliceDotId accepts slice-shaped IDs (3-4 numeric segments)", () => {
|
||||
expect(isSliceDotId("OPR.0.3.2.12")).toBe(true); // release-X.Y.Z slice
|
||||
expect(isSliceDotId("OPR.1.0.5")).toBe(true); // release-X.Y slice
|
||||
expect(isSliceDotId("OPR.99.0.1.7")).toBe(true); // escape-band slice
|
||||
});
|
||||
|
||||
it("isSliceDotId admits the inherent depth-3 overlap (release X.Y.Z mission shape = slice for an X.Y parent)", () => {
|
||||
// The convention's positional grammar can't distinguish a
|
||||
// release-X.Y.Z mission from a slice of a release-X.Y mission
|
||||
// by depth alone. Both shapes are accepted by isSliceDotId; the
|
||||
// CLI disambiguates by tier at use sites (mission create uses
|
||||
// isMissionDotId; slice IDs are minted from a parent + ordinal,
|
||||
// never user-typed).
|
||||
expect(isSliceDotId("OPR.0.3.2")).toBe(true); // release X.Y.Z OR slice of X.Y
|
||||
// Escape-band IDs are UNAMBIGUOUS by depth: an escape-band mission
|
||||
// is depth-3 (OPR.99.0.1); an escape-band slice is depth-4
|
||||
// (OPR.99.0.1.5). Depth-3 with leading 99 is mission-only.
|
||||
expect(isSliceDotId("OPR.99.0.1")).toBe(false);
|
||||
// The strictly-impossible-slice case is depth 2 (X.Y only) — no
|
||||
// parent can have a sub-NN-segment of "Y".
|
||||
expect(isSliceDotId("OPR.1.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("parseDotId(s, 'slice') peels the last segment off as `n`", () => {
|
||||
// Per guard discriminator: parsing OPR.0.3.2.12 as a slice must
|
||||
// yield version=0.3.2 and n=12, not version=0.3.2.12.
|
||||
const id = parseDotId("OPR.0.3.2.12", "slice");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.version).toBe("0.3.2");
|
||||
expect(id!.n).toBe(12);
|
||||
});
|
||||
|
||||
it("parseDotId(s, 'mission') keeps all numeric segments as version", () => {
|
||||
const id = parseDotId("OPR.0.3.2", "mission");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.version).toBe("0.3.2");
|
||||
expect(id!.n).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,15 +109,29 @@ describe("listMissions + listSlices + nextSliceNN", () => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lists missions sorted by name with slice counts (HG-8)", () => {
|
||||
it("lists missions sorted by name with slice counts; SKIPS dirs without README.md (HG-8)", () => {
|
||||
const missions = listMissions(missionsRoot);
|
||||
expect(missions.map((m) => m.name)).toEqual(["no-readme", "release-0.3.2"]);
|
||||
// no-readme/ has no README.md and must NOT appear (HG-8 / PRD §2.1).
|
||||
expect(missions.map((m) => m.name)).toEqual(["release-0.3.2"]);
|
||||
const r = missions.find((m) => m.name === "release-0.3.2")!;
|
||||
expect(r.activeSliceCount).toBe(2);
|
||||
expect(r.closedSliceCount).toBe(1);
|
||||
expect(r.id).toBe("OPR.0.3.2");
|
||||
});
|
||||
|
||||
it("HG-8 discriminator: 3 dirs / 1 without README → exactly 2 missions listed", () => {
|
||||
// Seed a third mission alongside the existing fixture for a clear
|
||||
// make-it-fail-first signal per guard BC verdict.
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "README.md"),
|
||||
"---\nid: OPR.99.0.1\n---\n# backlog\n",
|
||||
);
|
||||
const missions = listMissions(missionsRoot);
|
||||
expect(missions.map((m) => m.name).sort()).toEqual(["backlog", "release-0.3.2"]);
|
||||
// The README-less directory survives on disk but does not show up.
|
||||
expect(fs.existsSync(path.join(missionsRoot, "no-readme"))).toBe(true);
|
||||
});
|
||||
|
||||
it("listSlices active filter excludes closed/shipped (HG-1)", () => {
|
||||
const mission = findMission(missionsRoot, "release-0.3.2");
|
||||
const active = listSlices(mission, "active");
|
||||
@@ -268,4 +282,33 @@ describe("moveSlice — git mv preserves history (HG-5) + refuses dirty tree (HG
|
||||
expect(fs.existsSync(dest)).toBe(true);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("BLOCK 4 discriminator: handles symlinked src/dest paths without escaping the repo (macOS /var↔/private/var class)", () => {
|
||||
// Build a parallel tree of symlinks pointing at the real repo
|
||||
// tree. Without realpath normalization inside moveSlice, path.relative
|
||||
// would resolve to ../.. and git mv would reject with "outside
|
||||
// repository". With normalization, git mv succeeds and history
|
||||
// follows the move via --follow.
|
||||
const linkParent = mktemp();
|
||||
const linkRoot = path.join(linkParent, "linked-substrate");
|
||||
fs.symlinkSync(root, linkRoot);
|
||||
// Sanity: the symlinked tree resolves to a different absolute path
|
||||
// than the real tree (covers the /var↔/private/var class on macOS
|
||||
// and any other path-resolving symlink class on other OSes).
|
||||
expect(fs.realpathSync(linkRoot)).not.toBe(linkRoot);
|
||||
const src = path.join(linkRoot, "missions", "backlog", "slices", "01-foo");
|
||||
const dest = path.join(linkRoot, "missions", "release-0.3.2", "slices", "01-foo");
|
||||
const result = moveSlice(src, dest);
|
||||
expect(result.usedGit).toBe(true);
|
||||
expect(result.repoRoot).toBe(fs.realpathSync(root)); // git returned the realpath
|
||||
// git mv staged a rename; history follows.
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "ship-via-symlink", "-q"], { stdio: "ignore" });
|
||||
const log = execFileSync("git", [
|
||||
"-C", root, "log", "--follow", "--pretty=format:%s", "--",
|
||||
"missions/release-0.3.2/slices/01-foo/README.md",
|
||||
], { encoding: "utf8" });
|
||||
expect(log).toMatch(/seed/);
|
||||
expect(log).toMatch(/ship-via-symlink/);
|
||||
fs.rmSync(linkParent, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user