mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-10-03 08:58:29 +08:00
merge: slice 12 — rig scope CLI primitive (release-0.3.2)
Tree-shaped `rig scope <tier> <verb>` (mission + slice tiers; sub-slice ad-hoc); mission-create first-class; mints stable dot-IDs per the scope-and-versioning convention; git-mv history-preserving ship/move/close; lazy parent-ID adoption on create/ship/move. Triple-gate: driver + 2 guard cycles (BC-1, BC-2) -> guard ACCEPT @a7e56382-> velocity-qa CLEAR @a7e56382(sustained git-backed dogfood; evidence under missions/release-0.3.2/dogfood-evidence/). Base 86da3ce8; clean --no-ff; 2 inherited host preflight/ABI failures unrelated. Local main only; origin push deferred to the founder-gated release lifecycle.
This commit is contained in:
@@ -35,7 +35,7 @@
|
||||
"scripts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/",
|
||||
"build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/",
|
||||
"postinstall": "node scripts/check-abi.mjs",
|
||||
"prepublishOnly": "bash ../../scripts/build-package.sh",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
// release-0.3.2 slice 12 — `rig scope` CLI primitive.
|
||||
//
|
||||
// Command grammar: rig scope <tier> <verb>. v0 ships `mission` +
|
||||
// `slice` tiers; `project` and `sub-slice` are reserved per the
|
||||
// substrate convention `conventions/scope-and-versioning/README.md`
|
||||
// (stage: provisional). The CLI mints stable dot-IDs into created
|
||||
// mission/slice frontmatter per §1 of that convention.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
|
||||
import {
|
||||
CLOSE_REASONS,
|
||||
MISSION_TEMPLATE_KINDS,
|
||||
SLICE_TEMPLATE_KINDS,
|
||||
ScopeCliError,
|
||||
type CloseReason,
|
||||
type MissionTemplateKind,
|
||||
type SliceTemplateKind,
|
||||
type SliceState,
|
||||
} from "../lib/scope/types.js";
|
||||
import {
|
||||
DEFAULT_PROJECT_PREFIX,
|
||||
inferMissionDotId,
|
||||
isMissionDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
sliceIdFromMission,
|
||||
} from "../lib/scope/dot-id.js";
|
||||
import {
|
||||
ensureMissionIdPersisted,
|
||||
findMission,
|
||||
findSlice,
|
||||
listMissions,
|
||||
listSlices,
|
||||
moveSlice,
|
||||
nextSliceNN,
|
||||
pad2,
|
||||
resolveMissionsRoot,
|
||||
splitFrontmatter,
|
||||
todayDateISO,
|
||||
updateFrontmatter,
|
||||
} from "../lib/scope/scope-fs.js";
|
||||
import {
|
||||
renderMissionTemplate,
|
||||
renderSliceTemplate,
|
||||
titleFromSlug,
|
||||
} from "../lib/scope/templates.js";
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
interface Stdout {
|
||||
write: (text: string) => void;
|
||||
}
|
||||
|
||||
function makeStdout(): Stdout {
|
||||
return { write: (text: string) => process.stdout.write(text) };
|
||||
}
|
||||
|
||||
function emit(out: Stdout, payload: unknown, json: boolean, lines?: string[]): void {
|
||||
if (json) {
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (lines) {
|
||||
for (const line of lines) out.write(line + "\n");
|
||||
return;
|
||||
}
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function fail(err: unknown, json: boolean, out: Stdout): never {
|
||||
if (err instanceof ScopeCliError) {
|
||||
if (json) {
|
||||
out.write(JSON.stringify({
|
||||
ok: false,
|
||||
error: { fact: err.fact, consequence: err.consequence, action: err.action },
|
||||
}, null, 2) + "\n");
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
|
||||
}
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function slugify(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
interface RootOpts {
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
function getOpts(cmd: Command): RootOpts {
|
||||
// commander v13 attaches opts on the parent.
|
||||
let walker: Command | null = cmd;
|
||||
while (walker) {
|
||||
const o = walker.opts() as RootOpts;
|
||||
if (o.workspace) return o;
|
||||
walker = walker.parent;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice ls
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceLsCommand(): Command {
|
||||
const cmd = new Command("ls")
|
||||
.description("List slices in a mission (or across all missions)")
|
||||
.option("--mission <name>", "Restrict to a single mission")
|
||||
.option("--state <state>", "Filter: active | closed | shipped | all", "active")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
const state = (opts.state as SliceState) ?? "active";
|
||||
if (!["active", "closed", "shipped", "all"].includes(state)) {
|
||||
fail(new ScopeCliError({
|
||||
fact: `Unknown --state value "${state}".`,
|
||||
consequence: "Command did not run.",
|
||||
action: "Pick one of: active, closed, shipped, all.",
|
||||
}), json, out);
|
||||
}
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const missions = opts.mission
|
||||
? [findMission(missionsRoot, opts.mission)]
|
||||
: listMissions(missionsRoot);
|
||||
const rows: unknown[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const mission of missions) {
|
||||
const slices = listSlices(mission, state);
|
||||
for (const slice of slices) {
|
||||
rows.push({
|
||||
mission: mission.name,
|
||||
name: slice.name,
|
||||
nn: slice.nn,
|
||||
slug: slice.slug,
|
||||
id: slice.id,
|
||||
status: slice.status,
|
||||
path: slice.absPath,
|
||||
});
|
||||
lines.push(`${mission.name}/${slice.name} ${slice.id ?? "—"} ${slice.status ?? "—"}`);
|
||||
}
|
||||
}
|
||||
emit(out, { ok: true, count: rows.length, slices: rows }, json, lines.length === 0 ? ["(no slices)"] : lines);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice show
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceShowCommand(): Command {
|
||||
return new Command("show")
|
||||
.description("Inspect a single slice (frontmatter + README + children)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative-to-substrate, or NN-slug)")
|
||||
.option("--mission <name>", "Hint mission when path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const readme = slice.readmePath ? fs.readFileSync(slice.readmePath, "utf8") : null;
|
||||
const children = fs.readdirSync(slice.absPath, { withFileTypes: true })
|
||||
.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file" as const }));
|
||||
const payload = {
|
||||
ok: true,
|
||||
slice: {
|
||||
mission: slice.missionName,
|
||||
name: slice.name,
|
||||
id: slice.id,
|
||||
status: slice.status,
|
||||
path: slice.absPath,
|
||||
frontmatter: slice.frontmatter,
|
||||
readme,
|
||||
children,
|
||||
},
|
||||
};
|
||||
if (json) {
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
} else {
|
||||
out.write(`Slice: ${slice.missionName}/${slice.name}\n`);
|
||||
out.write(` id: ${slice.id ?? "—"}\n`);
|
||||
out.write(` status: ${slice.status ?? "—"}\n`);
|
||||
out.write(` path: ${slice.absPath}\n`);
|
||||
out.write(` children: ${children.length}\n`);
|
||||
if (readme) {
|
||||
out.write("\n--- README ---\n");
|
||||
out.write(readme);
|
||||
if (!readme.endsWith("\n")) out.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceCreateCommand(): Command {
|
||||
return new Command("create")
|
||||
.description("Create a new slice in a mission")
|
||||
.argument("<mission>", "Mission name")
|
||||
.argument("<slug>", "Short slug (becomes the folder name's suffix)")
|
||||
.option("--template <kind>", `Template: ${SLICE_TEMPLATE_KINDS.join(" | ")}`, "placeholder")
|
||||
.option("--title <text>", "Display title (defaults to titlecased slug)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, rawSlug: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const kind = opts.template as SliceTemplateKind;
|
||||
if (!SLICE_TEMPLATE_KINDS.includes(kind)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Unknown --template kind "${kind}".`,
|
||||
consequence: "Slice not created.",
|
||||
action: `Pick one of: ${SLICE_TEMPLATE_KINDS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
const slug = slugify(rawSlug);
|
||||
if (!slug) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slug "${rawSlug}" reduces to empty after slugification.`,
|
||||
consequence: "Slice not created.",
|
||||
action: "Pick a slug containing letters or digits.",
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
// 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);
|
||||
if (fs.existsSync(sliceAbs)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice folder ${sliceAbs} already exists.`,
|
||||
consequence: "Refusing to overwrite.",
|
||||
action: "Pick a different slug, or rm -rf the existing folder first.",
|
||||
});
|
||||
}
|
||||
const id = sliceIdFromMission(missionId, nn);
|
||||
const title = opts.title ?? titleFromSlug(slug);
|
||||
const body = renderSliceTemplate(kind, {
|
||||
id,
|
||||
slice_number: pad2(nn),
|
||||
slug,
|
||||
mission: mission.name,
|
||||
title,
|
||||
created_date: todayDateISO(),
|
||||
});
|
||||
fs.mkdirSync(sliceAbs, { recursive: true });
|
||||
const readmePath = path.join(sliceAbs, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
const payload = {
|
||||
ok: true,
|
||||
slice: {
|
||||
mission: mission.name,
|
||||
name: sliceFolder,
|
||||
id,
|
||||
path: sliceAbs,
|
||||
readmePath,
|
||||
template: kind,
|
||||
},
|
||||
};
|
||||
emit(out, payload, json, [
|
||||
`Created ${mission.name}/slices/${sliceFolder}`,
|
||||
` id: ${id}`,
|
||||
` template: ${kind}`,
|
||||
` path: ${sliceAbs}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice ship
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceShipCommand(): Command {
|
||||
return new Command("ship")
|
||||
.description("Ship a slice to a release mission (preserves git history)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.argument("<release-mission>", "Target release mission name")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, releaseMission: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const target = findMission(missionsRoot, releaseMission);
|
||||
const targetSlicesDir = path.join(target.absPath, "slices");
|
||||
fs.mkdirSync(targetSlicesDir, { recursive: true });
|
||||
const newNN = nextSliceNN(target.absPath);
|
||||
const slug = slice.slug ?? slugify(slice.name);
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const targetId = ensureMissionIdPersisted(target, missionsRoot);
|
||||
const newSliceId = sliceIdFromMission(targetId, newNN);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
updateFrontmatter(newReadme, {
|
||||
id: newSliceId,
|
||||
mission: target.name,
|
||||
status: `shipped-to-${target.name}`,
|
||||
"shipped-on": todayDateISO(),
|
||||
"shipped-from": slice.missionName,
|
||||
});
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
shipped: {
|
||||
from: { mission: slice.missionName, name: slice.name, id: slice.id },
|
||||
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
|
||||
git: { usedGit, repoRoot },
|
||||
},
|
||||
}, json, [
|
||||
`Shipped ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
|
||||
` id: ${newSliceId}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice close
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceCloseCommand(): Command {
|
||||
return new Command("close")
|
||||
.description("Close a slice (move to <mission>/closed/, update status)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.requiredOption("--reason <reason>", `Closure reason: ${CLOSE_REASONS.join(" | ")}`)
|
||||
.option("--note <text>", "Optional closure note")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const reason = opts.reason as CloseReason;
|
||||
if (!CLOSE_REASONS.includes(reason)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Unknown --reason "${reason}".`,
|
||||
consequence: "Slice not closed.",
|
||||
action: `Pick one of: ${CLOSE_REASONS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const mission = findMission(missionsRoot, slice.missionName);
|
||||
const closedDir = path.join(mission.absPath, "closed");
|
||||
fs.mkdirSync(closedDir, { recursive: true });
|
||||
const destName = slice.name;
|
||||
const destAbs = path.join(closedDir, destName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
const updates: Record<string, unknown> = {
|
||||
status: `closed-${reason}`,
|
||||
"closed-on": todayDateISO(),
|
||||
};
|
||||
if (opts.note) updates["closure-note"] = opts.note;
|
||||
updateFrontmatter(newReadme, updates);
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
closed: {
|
||||
mission: slice.missionName,
|
||||
name: destName,
|
||||
id: slice.id,
|
||||
reason,
|
||||
note: opts.note ?? null,
|
||||
path: destAbs,
|
||||
git: { usedGit, repoRoot },
|
||||
},
|
||||
}, json, [
|
||||
`Closed ${slice.missionName}/${slice.name} → ${slice.missionName}/closed/${destName}`,
|
||||
` reason: ${reason}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope slice move
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildSliceMoveCommand(): Command {
|
||||
return new Command("move")
|
||||
.description("Move a slice between missions (re-numbers in destination)")
|
||||
.argument("<slice-path>", "Slice path (absolute, relative, or NN-slug)")
|
||||
.argument("<dest-mission>", "Destination mission name")
|
||||
.option("--mission <name>", "Hint source mission when slice-path is just NN-slug")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (slicePath: string, destMission: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
const target = findMission(missionsRoot, destMission);
|
||||
const targetSlicesDir = path.join(target.absPath, "slices");
|
||||
fs.mkdirSync(targetSlicesDir, { recursive: true });
|
||||
const newNN = nextSliceNN(target.absPath);
|
||||
const slug = slice.slug ?? slugify(slice.name);
|
||||
const newName = `${pad2(newNN)}-${slug}`;
|
||||
const destAbs = path.join(targetSlicesDir, newName);
|
||||
const { usedGit, repoRoot } = moveSlice(slice.absPath, destAbs);
|
||||
const targetId = ensureMissionIdPersisted(target, missionsRoot);
|
||||
const newSliceId = sliceIdFromMission(targetId, newNN);
|
||||
const newReadme = path.join(destAbs, "README.md");
|
||||
if (fs.existsSync(newReadme)) {
|
||||
updateFrontmatter(newReadme, {
|
||||
id: newSliceId,
|
||||
mission: target.name,
|
||||
"moved-on": todayDateISO(),
|
||||
"moved-from": slice.missionName,
|
||||
});
|
||||
}
|
||||
emit(out, {
|
||||
ok: true,
|
||||
moved: {
|
||||
from: { mission: slice.missionName, name: slice.name, id: slice.id },
|
||||
to: { mission: target.name, name: newName, id: newSliceId, path: destAbs },
|
||||
git: { usedGit, repoRoot },
|
||||
},
|
||||
}, json, [
|
||||
`Moved ${slice.missionName}/${slice.name} → ${target.name}/slices/${newName}`,
|
||||
` id: ${newSliceId}`,
|
||||
` git: ${usedGit ? "git mv" : "fs.rename (not in a git repo)"}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// rig scope mission ls / show / create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function buildMissionLsCommand(): Command {
|
||||
return new Command("ls")
|
||||
.description("List missions (top-level folders with README.md)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const missions = listMissions(missionsRoot);
|
||||
const rows = missions.map((m) => ({
|
||||
name: m.name,
|
||||
id: m.id,
|
||||
path: m.absPath,
|
||||
activeSliceCount: m.activeSliceCount,
|
||||
closedSliceCount: m.closedSliceCount,
|
||||
}));
|
||||
emit(out, { ok: true, count: rows.length, missions: rows }, json,
|
||||
rows.length === 0
|
||||
? ["(no missions)"]
|
||||
: rows.map((r) => `${r.name} ${r.id ?? "—"} active=${r.activeSliceCount} closed=${r.closedSliceCount}`),
|
||||
);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionShowCommand(): Command {
|
||||
return new Command("show")
|
||||
.description("Inspect a single mission")
|
||||
.argument("<mission>", "Mission name")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (missionName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const mission = findMission(missionsRoot, missionName);
|
||||
const readme = mission.readmePath ? fs.readFileSync(mission.readmePath, "utf8") : null;
|
||||
const slices = listSlices(mission, "all").map((s) => ({
|
||||
name: s.name, id: s.id, status: s.status, nn: s.nn,
|
||||
}));
|
||||
const payload = {
|
||||
ok: true,
|
||||
mission: {
|
||||
name: mission.name,
|
||||
id: mission.id,
|
||||
path: mission.absPath,
|
||||
activeSliceCount: mission.activeSliceCount,
|
||||
closedSliceCount: mission.closedSliceCount,
|
||||
frontmatter: mission.frontmatter,
|
||||
readme,
|
||||
slices,
|
||||
},
|
||||
};
|
||||
if (json) {
|
||||
out.write(JSON.stringify(payload, null, 2) + "\n");
|
||||
} else {
|
||||
out.write(`Mission: ${mission.name}\n`);
|
||||
out.write(` id: ${mission.id ?? "—"}\n`);
|
||||
out.write(` active slices: ${mission.activeSliceCount}\n`);
|
||||
out.write(` closed slices: ${mission.closedSliceCount}\n`);
|
||||
out.write(` path: ${mission.absPath}\n`);
|
||||
if (readme) {
|
||||
out.write("\n--- README ---\n");
|
||||
out.write(readme);
|
||||
if (!readme.endsWith("\n")) out.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionCreateCommand(): Command {
|
||||
return new Command("create")
|
||||
.description("Create a new mission (mints a stable dot-ID into frontmatter)")
|
||||
.argument("<name>", "Mission folder name (e.g., release-0.4.0, backlog-foo)")
|
||||
.option("--template <kind>", `Template: ${MISSION_TEMPLATE_KINDS.join(" | ")} (auto when name matches release-X.Y.Z)`, "")
|
||||
.option("--id <dot-id>", "Explicit dot-ID. Overrides name-pattern inference.")
|
||||
.option("--title <text>", "Display title (defaults to titlecased name)")
|
||||
.option("--json", "Machine-readable output")
|
||||
.action(async (rawName: string, opts, command) => {
|
||||
const out = makeStdout();
|
||||
const json = Boolean(opts.json);
|
||||
try {
|
||||
const name = rawName.trim();
|
||||
if (!name || /[\\/\s]/.test(name)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Invalid mission name "${rawName}".`,
|
||||
consequence: "Mission not created.",
|
||||
action: "Pick a name with no whitespace or path separators.",
|
||||
});
|
||||
}
|
||||
const missionsRoot = resolveMissionsRoot({ override: getOpts(command).workspace });
|
||||
const absPath = path.join(missionsRoot, name);
|
||||
if (fs.existsSync(absPath)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission folder ${absPath} already exists.`,
|
||||
consequence: "Refusing to overwrite.",
|
||||
action: "Pick a different name, or use `rig scope mission show <name>` to inspect the existing mission.",
|
||||
});
|
||||
}
|
||||
// Resolve template kind: explicit > release-pattern auto > placeholder.
|
||||
const isReleaseName = /^release-\d+\.\d+(?:\.\d+)?$/.test(name);
|
||||
let templateKind: MissionTemplateKind = opts.template as MissionTemplateKind;
|
||||
if (!templateKind) templateKind = isReleaseName ? "release" : "placeholder";
|
||||
if (!MISSION_TEMPLATE_KINDS.includes(templateKind)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Unknown --template kind "${templateKind}".`,
|
||||
consequence: "Mission not created.",
|
||||
action: `Pick one of: ${MISSION_TEMPLATE_KINDS.join(", ")}.`,
|
||||
});
|
||||
}
|
||||
// Mint the dot-ID.
|
||||
let id: string;
|
||||
if (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}" 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;
|
||||
} else if (isReleaseName) {
|
||||
id = inferMissionDotId(name, null);
|
||||
} else {
|
||||
const peers = listMissions(missionsRoot);
|
||||
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
|
||||
id = inferMissionDotId(name, ordinal);
|
||||
}
|
||||
// Scaffold the mission directory + README.
|
||||
fs.mkdirSync(absPath, { recursive: true });
|
||||
fs.mkdirSync(path.join(absPath, "slices"), { recursive: true });
|
||||
const title = opts.title ?? titleFromSlug(name.replace(/^release-/, ""));
|
||||
const releaseVersion = isReleaseName ? name.replace(/^release-/, "") : "";
|
||||
const body = renderMissionTemplate(templateKind, {
|
||||
id,
|
||||
slug: name,
|
||||
mission: name,
|
||||
title,
|
||||
created_date: todayDateISO(),
|
||||
release_version: releaseVersion,
|
||||
});
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
fs.writeFileSync(readmePath, body, "utf8");
|
||||
emit(out, {
|
||||
ok: true,
|
||||
mission: {
|
||||
name,
|
||||
id,
|
||||
template: templateKind,
|
||||
path: absPath,
|
||||
readmePath,
|
||||
},
|
||||
}, json, [
|
||||
`Created mission ${name}`,
|
||||
` id: ${id}`,
|
||||
` template: ${templateKind}`,
|
||||
` path: ${absPath}`,
|
||||
]);
|
||||
} catch (err) {
|
||||
fail(err, json, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Aggregate
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function scopeCommand(): Command {
|
||||
const cmd = new Command("scope")
|
||||
.description("Scope tree primitive: missions, slices, sub-slices (per conventions/scope-and-versioning)")
|
||||
.option("--workspace <path>", "Override workspace root (otherwise inferred from cwd or $OPENRIG_WORK_ROOT)");
|
||||
|
||||
const slice = new Command("slice").description("Slice-tier commands");
|
||||
slice.addCommand(buildSliceLsCommand());
|
||||
slice.addCommand(buildSliceShowCommand());
|
||||
slice.addCommand(buildSliceCreateCommand());
|
||||
slice.addCommand(buildSliceShipCommand());
|
||||
slice.addCommand(buildSliceCloseCommand());
|
||||
slice.addCommand(buildSliceMoveCommand());
|
||||
cmd.addCommand(slice);
|
||||
|
||||
const mission = new Command("mission").description("Mission-tier commands");
|
||||
mission.addCommand(buildMissionLsCommand());
|
||||
mission.addCommand(buildMissionShowCommand());
|
||||
mission.addCommand(buildMissionCreateCommand());
|
||||
cmd.addCommand(mission);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Re-exports for tests.
|
||||
export { DEFAULT_PROJECT_PREFIX, splitFrontmatter };
|
||||
@@ -17,6 +17,7 @@ import { attachCommand } from "./commands/attach.js";
|
||||
import { bindCommand } from "./commands/bind.js";
|
||||
import { adoptCommand, type AdoptDeps } from "./commands/adopt.js";
|
||||
import { bundleCommand } from "./commands/bundle.js";
|
||||
import { scopeCommand } from "./commands/scope.js";
|
||||
import { upCommand } from "./commands/up.js";
|
||||
import { downCommand } from "./commands/down.js";
|
||||
import { psCommand } from "./commands/ps.js";
|
||||
@@ -182,6 +183,8 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps));
|
||||
program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps));
|
||||
program.addCommand(seatCommand(depsOverride?.seatDeps));
|
||||
// release-0.3.2 slice 12 — rig scope CLI primitive.
|
||||
program.addCommand(scopeCommand());
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Target
|
||||
|
||||
[What is being deprecated]
|
||||
|
||||
## Current state
|
||||
|
||||
[How it works today; what depends on it]
|
||||
|
||||
## Migration
|
||||
|
||||
[Path off the deprecated thing]
|
||||
|
||||
## Removal
|
||||
|
||||
[When/how the deprecated thing gets deleted]
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Current state
|
||||
|
||||
[How the code/system looks today]
|
||||
|
||||
## Cost
|
||||
|
||||
[What this debt costs us — surface friction, bug class, perf, etc]
|
||||
|
||||
## Proposal
|
||||
|
||||
[How to pay it down]
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Repro
|
||||
|
||||
[Steps to reproduce]
|
||||
|
||||
## Expected
|
||||
|
||||
[Expected behavior]
|
||||
|
||||
## Actual
|
||||
|
||||
[Actual behavior]
|
||||
|
||||
## Impact
|
||||
|
||||
[Who is affected; severity]
|
||||
|
||||
## Fix proposal
|
||||
|
||||
[Optional: thinking on the fix]
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
id: {{id}}
|
||||
mission: {{mission}}
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Mission — {{title}}
|
||||
|
||||
## Purpose
|
||||
|
||||
[Why this mission exists and what success looks like]
|
||||
|
||||
## Slices
|
||||
|
||||
[List notable slices and their state]
|
||||
|
||||
## Status
|
||||
|
||||
[Where the mission is right now]
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
id: {{id}}
|
||||
mission: {{mission}}
|
||||
release: {{release_version}}
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Release {{release_version}} — {{title}}
|
||||
|
||||
## Theme
|
||||
|
||||
[What this release is about — the coherent bet]
|
||||
|
||||
## Scope
|
||||
|
||||
[What ships in v{{release_version}} vs what waits]
|
||||
|
||||
## Slices
|
||||
|
||||
[Ordering + dependencies; PROGRESS.md tracks state]
|
||||
|
||||
## Acceptance
|
||||
|
||||
[Release-level done definition]
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Goal
|
||||
|
||||
[1-2 sentence goal.]
|
||||
|
||||
## Source material
|
||||
|
||||
- [Paths or refs]
|
||||
|
||||
## Status
|
||||
|
||||
- TODO: [next steps]
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [Cross-slice / cross-release]
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [3-5 bullets]
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Goal
|
||||
|
||||
[What this feature ships and why now]
|
||||
|
||||
## Scope
|
||||
|
||||
[In / out for v0]
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [3-5 bullets]
|
||||
|
||||
## Risks
|
||||
|
||||
[Known unknowns]
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
id: {{id}}
|
||||
slice: {{slice_number}}-{{slug}}
|
||||
mission: {{mission}}
|
||||
status: placeholder
|
||||
stage: wip
|
||||
created: {{created_date}}
|
||||
---
|
||||
|
||||
# Slice {{slice_number}} — {{title}}
|
||||
|
||||
## Question
|
||||
|
||||
[The thing we are trying to learn]
|
||||
|
||||
## Source
|
||||
|
||||
[Where the answer is most likely to come from]
|
||||
|
||||
## Findings
|
||||
|
||||
[Captured as work progresses]
|
||||
@@ -0,0 +1,173 @@
|
||||
// release-0.3.2 slice 12 — dot-ID grammar per
|
||||
// `openrig-work/conventions/scope-and-versioning/README.md` §1.
|
||||
//
|
||||
// Grammar (v0 single-project):
|
||||
// project = <PFX> (2-3 letter; v0 hardcodes "OPR")
|
||||
// mission = <PFX>.<ver>
|
||||
// slice = <PFX>.<ver>.<n>
|
||||
// sub = <PFX>.<ver>.<n>.<m>
|
||||
//
|
||||
// Version inference from mission folder name:
|
||||
// release-X.Y[.Z] → "X.Y[.Z]" (release train)
|
||||
// anything else → "99.0.<m>" escape band (uniform-numeric;
|
||||
// NO alpha — convention §1 explicit)
|
||||
//
|
||||
// The escape-band <m> is assigned by scanning peer non-release missions
|
||||
// for an existing id and picking max+1. Releases keep their semver.
|
||||
|
||||
import type { DotId } from "./types.js";
|
||||
import { ScopeCliError } from "./types.js";
|
||||
|
||||
/** Default project prefix for the OpenRig single-project workspace.
|
||||
* Multi-project work waits for a real second project (convention §1). */
|
||||
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. 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. 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(".");
|
||||
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) };
|
||||
}
|
||||
|
||||
/** 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 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 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;
|
||||
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;
|
||||
// Slice = mission-ver + 1 ordinal. Parent ver must itself be a
|
||||
// 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. */
|
||||
export function formatDotId(id: DotId): string {
|
||||
const parts = [id.project, id.version];
|
||||
if (id.n !== undefined) parts.push(String(id.n));
|
||||
if (id.m !== undefined) parts.push(String(id.m));
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
/** Compose a slice ID by appending an ordinal to a parent mission ID. */
|
||||
export function sliceIdFromMission(missionId: string, n: number): string {
|
||||
return `${missionId}.${n}`;
|
||||
}
|
||||
|
||||
/** Infer the (project, version) pair from a mission folder name when
|
||||
* no explicit id is supplied. Release missions use their semver; any
|
||||
* other name falls into the escape band where the caller supplies the
|
||||
* next ordinal via the second arg. */
|
||||
export function inferMissionDotId(
|
||||
missionFolderName: string,
|
||||
escapeBandOrdinal: number | null,
|
||||
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
|
||||
): string {
|
||||
const releaseMatch = RELEASE_NAME_RE.exec(missionFolderName);
|
||||
if (releaseMatch) {
|
||||
return `${projectPrefix}.${releaseMatch[1]}`;
|
||||
}
|
||||
if (escapeBandOrdinal === null) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission "${missionFolderName}" doesn't match the release-X.Y[.Z] pattern and no escape-band ordinal was supplied.`,
|
||||
consequence: "No dot-ID could be inferred for this mission.",
|
||||
action: "Pass an explicit ordinal, or rename the mission to release-X.Y.Z, or supply --id on the CLI.",
|
||||
});
|
||||
}
|
||||
return `${projectPrefix}.99.0.${escapeBandOrdinal}`;
|
||||
}
|
||||
|
||||
/** Pick the next escape-band ordinal by scanning peer mission IDs.
|
||||
* Looks for `<PFX>.99.0.<m>` shapes and returns max(m)+1, starting
|
||||
* at 1 when no peer exists. */
|
||||
export function nextEscapeBandOrdinal(
|
||||
existingIds: ReadonlyArray<string | null>,
|
||||
projectPrefix: string = DEFAULT_PROJECT_PREFIX,
|
||||
): number {
|
||||
let max = 0;
|
||||
const re = new RegExp(`^${projectPrefix}\\.99\\.0\\.(\\d+)$`);
|
||||
for (const id of existingIds) {
|
||||
if (!id) continue;
|
||||
const m = re.exec(id);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// release-0.3.2 slice 12 — filesystem + git helpers shared across
|
||||
// `rig scope` verbs. Mission/slice discovery, frontmatter parse/write,
|
||||
// auto-numbering, git mv wrapper. Keeps slice/mission command files
|
||||
// thin.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
|
||||
import type {
|
||||
MissionInfo,
|
||||
SliceInfo,
|
||||
SliceState,
|
||||
} from "./types.js";
|
||||
import { ScopeCliError } from "./types.js";
|
||||
import {
|
||||
DEFAULT_PROJECT_PREFIX,
|
||||
inferMissionDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
} from "./dot-id.js";
|
||||
|
||||
const FRONTMATTER_DELIM = "---\n";
|
||||
const SLICE_DIRNAME_RE = /^(\d+)-(.+)$/;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Frontmatter parse + write
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Split a markdown file into [frontmatter, body]. Returns `[{}, content]`
|
||||
* when no frontmatter delimiter is present. */
|
||||
export function splitFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
} {
|
||||
if (!content.startsWith(FRONTMATTER_DELIM)) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
const rest = content.slice(FRONTMATTER_DELIM.length);
|
||||
const endIdx = rest.indexOf(`\n${FRONTMATTER_DELIM.trim()}\n`);
|
||||
if (endIdx === -1) {
|
||||
// Allow trailing `---` without a final newline (last char of file).
|
||||
const endIdx2 = rest.indexOf(`\n---`);
|
||||
if (endIdx2 === -1) return { frontmatter: {}, body: content };
|
||||
const raw = rest.slice(0, endIdx2);
|
||||
return { frontmatter: parseYamlSafely(raw), body: rest.slice(endIdx2 + 4).replace(/^\n/, "") };
|
||||
}
|
||||
const raw = rest.slice(0, endIdx);
|
||||
const body = rest.slice(endIdx + `\n${FRONTMATTER_DELIM.trim()}\n`.length);
|
||||
return { frontmatter: parseYamlSafely(raw), body };
|
||||
}
|
||||
|
||||
function parseYamlSafely(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = YAML.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-stitch frontmatter + body into a single markdown string. */
|
||||
export function joinFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
body: string,
|
||||
): string {
|
||||
const yaml = YAML.stringify(frontmatter, { lineWidth: 0 }).trim();
|
||||
const trailing = body.startsWith("\n") ? "" : "\n";
|
||||
return `---\n${yaml}\n---\n${trailing}${body}`;
|
||||
}
|
||||
|
||||
/** Read + parse frontmatter from a markdown file. Returns `{}` if the
|
||||
* file is missing OR has no frontmatter (graceful for placeholder
|
||||
* missions that pre-date the convention). */
|
||||
export function readFrontmatter(absPath: string): Record<string, unknown> {
|
||||
try {
|
||||
return splitFrontmatter(fs.readFileSync(absPath, "utf8")).frontmatter;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Update specific keys in a markdown file's frontmatter. Existing
|
||||
* unknown keys are preserved. Generates minimal frontmatter when
|
||||
* absent. */
|
||||
export function updateFrontmatter(
|
||||
absPath: string,
|
||||
updates: Record<string, unknown>,
|
||||
): void {
|
||||
const original = fs.existsSync(absPath) ? fs.readFileSync(absPath, "utf8") : "";
|
||||
const { frontmatter, body } = splitFrontmatter(original);
|
||||
const merged = { ...frontmatter, ...updates };
|
||||
fs.writeFileSync(absPath, joinFrontmatter(merged, body), "utf8");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Mission discovery
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Locate the missions root for the working substrate. Resolution
|
||||
* order:
|
||||
* 1. Explicit override (env or arg)
|
||||
* 2. $OPENRIG_WORK_ROOT/missions
|
||||
* 3. Search up from cwd for a `missions/` folder
|
||||
*/
|
||||
export function resolveMissionsRoot(opts: {
|
||||
override?: string | null;
|
||||
cwd?: string;
|
||||
} = {}): string {
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const fromOverride = opts.override ?? process.env.OPENRIG_WORK_ROOT;
|
||||
if (fromOverride) {
|
||||
const candidate = path.isAbsolute(fromOverride) ? fromOverride : path.resolve(cwd, fromOverride);
|
||||
const missions = path.join(candidate, "missions");
|
||||
if (fs.existsSync(missions) && fs.statSync(missions).isDirectory()) return missions;
|
||||
if (path.basename(candidate) === "missions" && fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
let dir = cwd;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const candidate = path.join(dir, "missions");
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Could not locate a missions/ root from cwd ${cwd}.`,
|
||||
consequence: "No mission tree to operate on.",
|
||||
action: "cd into the substrate workspace, or set OPENRIG_WORK_ROOT=/path/to/substrate/shared-docs/openrig-work.",
|
||||
});
|
||||
}
|
||||
|
||||
/** List mission folders under the missions root. A mission is any
|
||||
* 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 });
|
||||
const out: MissionInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const absPath = path.join(missionsRoot, entry.name);
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
if (!hasReadme) continue;
|
||||
const frontmatter = readFrontmatter(readmePath);
|
||||
const slicesDir = path.join(absPath, "slices");
|
||||
const closedDir = path.join(absPath, "closed");
|
||||
const activeSliceCount = countSliceDirs(slicesDir);
|
||||
const closedSliceCount = countSliceDirs(closedDir);
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
out.push({
|
||||
name: entry.name,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id,
|
||||
activeSliceCount,
|
||||
closedSliceCount,
|
||||
});
|
||||
}
|
||||
// Stable order: by mission name.
|
||||
out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
function countSliceDirs(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && SLICE_DIRNAME_RE.test(e.name))
|
||||
.length;
|
||||
}
|
||||
|
||||
function pickIdFromFrontmatter(fm: Record<string, unknown>): string | null {
|
||||
const candidate = fm.id ?? fm.dotId;
|
||||
return typeof candidate === "string" && candidate.length > 0 ? candidate : null;
|
||||
}
|
||||
|
||||
/** Resolve a mission by name OR path relative to the missions root.
|
||||
* 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),
|
||||
path.isAbsolute(identifier) ? identifier : path.resolve(missionsRoot, identifier),
|
||||
];
|
||||
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);
|
||||
}
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Mission "${identifier}" not found under ${missionsRoot}.`,
|
||||
consequence: "Command did not run.",
|
||||
action: "List available missions with: rig scope mission ls",
|
||||
});
|
||||
}
|
||||
|
||||
function buildMissionInfo(missionsRoot: string, absPath: string): MissionInfo {
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
|
||||
return {
|
||||
name: path.basename(absPath),
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
id: pickIdFromFrontmatter(frontmatter),
|
||||
activeSliceCount: countSliceDirs(path.join(absPath, "slices")),
|
||||
closedSliceCount: countSliceDirs(path.join(absPath, "closed")),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Slice discovery
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function listSlices(
|
||||
mission: MissionInfo,
|
||||
state: SliceState,
|
||||
): SliceInfo[] {
|
||||
const dirs: Array<{ root: string; bucket: "active" | "closed" }> = [];
|
||||
if (state === "active" || state === "shipped" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "slices"), bucket: "active" });
|
||||
}
|
||||
if (state === "closed" || state === "all") {
|
||||
dirs.push({ root: path.join(mission.absPath, "closed"), bucket: "closed" });
|
||||
}
|
||||
const out: SliceInfo[] = [];
|
||||
for (const { root } of dirs) {
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const sliceInfo = buildSliceInfo(mission, root, entry.name);
|
||||
out.push(sliceInfo);
|
||||
}
|
||||
}
|
||||
if (state === "active") {
|
||||
// active = not in closed/, but also not shipped status.
|
||||
return out.filter((s) => {
|
||||
const st = (s.status ?? "").toLowerCase();
|
||||
return !st.startsWith("closed") && !st.startsWith("shipped");
|
||||
});
|
||||
}
|
||||
if (state === "shipped") {
|
||||
return out.filter((s) => (s.status ?? "").toLowerCase().startsWith("shipped"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildSliceInfo(mission: MissionInfo, sliceRoot: string, dirName: string): SliceInfo {
|
||||
const m = SLICE_DIRNAME_RE.exec(dirName);
|
||||
const nn = m ? Number(m[1]) : null;
|
||||
const slug = m ? m[2]! : null;
|
||||
const absPath = path.join(sliceRoot, dirName);
|
||||
const readmePath = path.join(absPath, "README.md");
|
||||
const hasReadme = fs.existsSync(readmePath);
|
||||
const frontmatter = hasReadme ? readFrontmatter(readmePath) : {};
|
||||
const id = pickIdFromFrontmatter(frontmatter);
|
||||
const status = typeof frontmatter.status === "string" ? (frontmatter.status as string).toLowerCase() : null;
|
||||
return {
|
||||
name: dirName,
|
||||
absPath,
|
||||
readmePath: hasReadme ? readmePath : null,
|
||||
frontmatter,
|
||||
nn,
|
||||
slug,
|
||||
missionName: mission.name,
|
||||
id,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a slice path (absolute, relative-to-substrate, or
|
||||
* relative-to-mission) into a SliceInfo. */
|
||||
export function findSlice(
|
||||
missionsRoot: string,
|
||||
slicePath: string,
|
||||
hintMission?: string | null,
|
||||
): SliceInfo {
|
||||
const candidates: string[] = [];
|
||||
if (path.isAbsolute(slicePath)) {
|
||||
candidates.push(slicePath);
|
||||
} else {
|
||||
candidates.push(path.resolve(missionsRoot, "..", slicePath));
|
||||
candidates.push(path.resolve(missionsRoot, slicePath));
|
||||
if (hintMission) {
|
||||
candidates.push(path.join(missionsRoot, hintMission, "slices", slicePath));
|
||||
candidates.push(path.join(missionsRoot, hintMission, "closed", slicePath));
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||||
// Walk up to find the owning mission.
|
||||
const owningMissionPath = findOwningMission(missionsRoot, candidate);
|
||||
if (!owningMissionPath) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice path "${slicePath}" resolved to ${candidate} but no parent mission was found.`,
|
||||
consequence: "Cannot determine mission context for this slice.",
|
||||
action: "Ensure the slice lives under <missionsRoot>/<mission>/{slices,closed}/.",
|
||||
});
|
||||
}
|
||||
const mission = buildMissionInfo(missionsRoot, owningMissionPath);
|
||||
const sliceRoot = path.dirname(candidate);
|
||||
return buildSliceInfo(mission, sliceRoot, path.basename(candidate));
|
||||
}
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Slice "${slicePath}" not found.`,
|
||||
consequence: "Command did not run.",
|
||||
action: "Check the path. List slices in a mission with: rig scope slice ls --mission <name>",
|
||||
});
|
||||
}
|
||||
|
||||
function findOwningMission(missionsRoot: string, slicePath: string): string | null {
|
||||
let dir = path.dirname(slicePath);
|
||||
while (dir.startsWith(missionsRoot) && dir !== missionsRoot) {
|
||||
if (path.dirname(dir) === missionsRoot) return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Auto-numbering
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Find the next available NN for a mission's slices/ folder. Scans
|
||||
* BOTH slices/ AND closed/ so numbers are never reused (§3.2). */
|
||||
export function nextSliceNN(missionAbsPath: string): number {
|
||||
let max = 0;
|
||||
for (const subdir of ["slices", "closed"]) {
|
||||
const root = path.join(missionAbsPath, subdir);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const m = SLICE_DIRNAME_RE.exec(entry.name);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
/** Mint or read the dot-ID for a mission. If the mission's README has
|
||||
* an `id:` field, return it. Otherwise infer from the folder name
|
||||
* (release-X.Y.Z → OPR.X.Y.Z; otherwise escape band). */
|
||||
export function ensureMissionId(
|
||||
mission: MissionInfo,
|
||||
missionsRoot: string,
|
||||
): string {
|
||||
if (mission.id) return mission.id;
|
||||
// Try release-pattern first; if it matches, no peer scan needed.
|
||||
try {
|
||||
const id = inferMissionDotId(mission.name, null);
|
||||
return id;
|
||||
} catch {
|
||||
// Fall through to escape-band path.
|
||||
}
|
||||
const peers = listMissions(missionsRoot).filter((m) => m.name !== mission.name);
|
||||
const ordinal = nextEscapeBandOrdinal(peers.map((p) => p.id));
|
||||
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
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Return the git toplevel for a path, or null if not inside a repo. */
|
||||
export function gitTopLevel(absPath: string): string | null {
|
||||
const dir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory() ? absPath : path.dirname(absPath);
|
||||
try {
|
||||
const out = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Refuse to move a path with uncommitted local changes. Catches the
|
||||
* "dirty working tree" risk in §11.1 / §3.4 — `git mv` would silently
|
||||
* carry along the user's in-progress edits. */
|
||||
export function assertCleanWorkingTree(repoRoot: string, relPath: string): void {
|
||||
let status: string;
|
||||
try {
|
||||
status = execFileSync(
|
||||
"git",
|
||||
["-C", repoRoot, "status", "--porcelain", "--", relPath],
|
||||
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
throw new ScopeCliError({
|
||||
fact: `git status failed for ${relPath} in ${repoRoot}: ${(err as Error).message}`,
|
||||
consequence: "Could not verify the slice's working-tree state. Move aborted.",
|
||||
action: "Check the repository at " + repoRoot + " and retry.",
|
||||
});
|
||||
}
|
||||
if (status.trim().length > 0) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Working tree under ${relPath} has uncommitted changes:\n${status.trimEnd()}`,
|
||||
consequence: "Refusing to git mv — your local edits would silently move with the slice.",
|
||||
action: "Commit (git commit -m '...') or stash (git stash) and retry.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a directory using `git mv` when inside a repo; fall back to
|
||||
* `fs.renameSync` with a warning when not. Returns true if git was
|
||||
* used. */
|
||||
export function moveSlice(srcAbs: string, destAbs: string, opts: {
|
||||
/** Stage the move (default true). git mv stages by default; we
|
||||
* preserve that semantic. */
|
||||
commit?: boolean;
|
||||
} = {}): { usedGit: boolean; repoRoot: string | null } {
|
||||
if (!fs.existsSync(srcAbs)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Source path ${srcAbs} does not exist.`,
|
||||
consequence: "Move did not run.",
|
||||
action: "Verify the slice path and retry.",
|
||||
});
|
||||
}
|
||||
if (fs.existsSync(destAbs)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `Destination path ${destAbs} already exists.`,
|
||||
consequence: "Refusing to overwrite an existing slice.",
|
||||
action: "Pick a different destination, or remove the existing folder first.",
|
||||
});
|
||||
}
|
||||
fs.mkdirSync(path.dirname(destAbs), { recursive: true });
|
||||
const repoRoot = gitTopLevel(srcAbs);
|
||||
if (!repoRoot) {
|
||||
fs.renameSync(srcAbs, destAbs);
|
||||
return { usedGit: false, repoRoot: null };
|
||||
}
|
||||
// Normalize symlinks (macOS /var/folders → /private/var/folders) so
|
||||
// path.relative produces a path INSIDE the repo, not a ../../escape.
|
||||
const realSrcAbs = fs.realpathSync(srcAbs);
|
||||
const realDestParent = fs.realpathSync(path.dirname(destAbs));
|
||||
const realDestAbs = path.join(realDestParent, path.basename(destAbs));
|
||||
const srcRel = path.relative(repoRoot, realSrcAbs);
|
||||
assertCleanWorkingTree(repoRoot, srcRel);
|
||||
const destRel = path.relative(repoRoot, realDestAbs);
|
||||
try {
|
||||
execFileSync("git", ["-C", repoRoot, "mv", srcRel, destRel], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ScopeCliError({
|
||||
fact: `git mv ${srcRel} ${destRel} failed: ${(err as Error).message}`,
|
||||
consequence: "Move aborted; source unchanged.",
|
||||
action: "Inspect the repo state and retry.",
|
||||
});
|
||||
}
|
||||
if (opts.commit) {
|
||||
// Future hook; v0 doesn't auto-commit.
|
||||
}
|
||||
return { usedGit: true, repoRoot };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Misc
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export function todayDateISO(): string {
|
||||
const d = new Date();
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
export { DEFAULT_PROJECT_PREFIX };
|
||||
@@ -0,0 +1,82 @@
|
||||
// release-0.3.2 slice 12 — template loader. Templates live as
|
||||
// markdown files alongside the source so they can be edited like any
|
||||
// other doc. The build copies them to dist/ via tsconfig
|
||||
// rootDir/files behavior — but since .md isn't a .ts file, we read
|
||||
// them directly via fileURLToPath to remain robust across local dev
|
||||
// and the published package layout.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { MissionTemplateKind, SliceTemplateKind } from "./types.js";
|
||||
import { ScopeCliError } from "./types.js";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Candidate template roots, in order: source tree (dev), dist (built).
|
||||
* We resolve at call time so the first existing directory wins. */
|
||||
function candidateRoots(): string[] {
|
||||
return [
|
||||
// Dev: packages/cli/src/lib/scope/ → ../scope-templates
|
||||
path.resolve(here, "..", "scope-templates"),
|
||||
// Built: dist/lib/scope/ → ../../src/lib/scope-templates (one level up further if
|
||||
// dist lands at packages/cli/dist/lib/scope).
|
||||
path.resolve(here, "..", "..", "lib", "scope-templates"),
|
||||
// Fallback: source tree relative to compiled dist when source is co-shipped.
|
||||
path.resolve(here, "..", "..", "..", "src", "lib", "scope-templates"),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveTemplate(filename: string): string {
|
||||
for (const root of candidateRoots()) {
|
||||
const candidate = path.join(root, filename);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `Template ${filename} could not be located.`,
|
||||
consequence: "Cannot scaffold the new artifact.",
|
||||
action: "Reinstall @openrig/cli, or run from a checkout with packages/cli/src/lib/scope-templates/ present.",
|
||||
});
|
||||
}
|
||||
|
||||
export interface RenderOpts {
|
||||
id: string;
|
||||
slice_number?: string; // zero-padded; only for slice templates
|
||||
slug: string;
|
||||
mission: string;
|
||||
title: string;
|
||||
created_date: string;
|
||||
release_version?: string;
|
||||
}
|
||||
|
||||
function applyPlaceholders(content: string, opts: RenderOpts): string {
|
||||
return content
|
||||
.replace(/\{\{id\}\}/g, opts.id)
|
||||
.replace(/\{\{slice_number\}\}/g, opts.slice_number ?? "")
|
||||
.replace(/\{\{slug\}\}/g, opts.slug)
|
||||
.replace(/\{\{mission\}\}/g, opts.mission)
|
||||
.replace(/\{\{title\}\}/g, opts.title)
|
||||
.replace(/\{\{created_date\}\}/g, opts.created_date)
|
||||
.replace(/\{\{release_version\}\}/g, opts.release_version ?? "");
|
||||
}
|
||||
|
||||
export function renderSliceTemplate(kind: SliceTemplateKind, opts: RenderOpts): string {
|
||||
const filename = `${kind}.md`;
|
||||
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
|
||||
return applyPlaceholders(raw, opts);
|
||||
}
|
||||
|
||||
export function renderMissionTemplate(kind: MissionTemplateKind, opts: RenderOpts): string {
|
||||
const filename = kind === "release" ? "mission-release.md" : "mission-placeholder.md";
|
||||
const raw = fs.readFileSync(resolveTemplate(filename), "utf8");
|
||||
return applyPlaceholders(raw, opts);
|
||||
}
|
||||
|
||||
/** Convert a folder-slug to a title-cased display name. */
|
||||
export function titleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map((word) => word[0]!.toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// release-0.3.2 slice 12 — scope CLI primitive types.
|
||||
//
|
||||
// Aligned with the substrate convention at
|
||||
// `openrig-work/conventions/scope-and-versioning/README.md` (stage:
|
||||
// provisional, authored 2026-05-15). The CLI mints stable dot-IDs
|
||||
// into created mission/slice frontmatter per §1 of that convention.
|
||||
|
||||
/** Scope tiers. v0 ships `mission` + `slice`; `project` and `sub-slice`
|
||||
* exist in the grammar but have no CLI verbs yet. */
|
||||
export type ScopeTier = "project" | "mission" | "slice" | "sub-slice";
|
||||
|
||||
/** Stable reference ID parts. `OPR.0.3.2.12` → project="OPR",
|
||||
* version="0.3.2", n=12. Escape-band non-release missions look like
|
||||
* `OPR.99.0.1` → version="99.0.1", n=undefined. */
|
||||
export interface DotId {
|
||||
project: string; // 2-3 letter project prefix
|
||||
version: string; // semver-shaped (release) or escape-band (99.x.y)
|
||||
n?: number; // slice ordinal
|
||||
m?: number; // sub-slice ordinal
|
||||
}
|
||||
|
||||
export interface MissionInfo {
|
||||
/** Mission folder name (e.g., "release-0.3.2", "backlog"). */
|
||||
name: string;
|
||||
/** Absolute path to the mission folder. */
|
||||
absPath: string;
|
||||
/** Path to the mission's README.md, if present. */
|
||||
readmePath: string | null;
|
||||
/** Parsed frontmatter from the README. */
|
||||
frontmatter: Record<string, unknown>;
|
||||
/** Minted dot-ID for this mission (read from frontmatter if present,
|
||||
* otherwise inferred from the folder name pattern). */
|
||||
id: string | null;
|
||||
activeSliceCount: number;
|
||||
closedSliceCount: number;
|
||||
}
|
||||
|
||||
export interface SliceInfo {
|
||||
name: string; // folder name like "12-rig-slice-cli-primitive"
|
||||
absPath: string;
|
||||
readmePath: string | null;
|
||||
frontmatter: Record<string, unknown>;
|
||||
/** "NN-slug" → NN. */
|
||||
nn: number | null;
|
||||
/** "NN-slug" → slug. */
|
||||
slug: string | null;
|
||||
missionName: string;
|
||||
/** Minted dot-ID, parent.NN form. */
|
||||
id: string | null;
|
||||
/** Frontmatter `status` field, lowercased. */
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
/** 3-part error shape per `building-agent-software` skill §3.6. */
|
||||
export class ScopeCliError extends Error {
|
||||
readonly fact: string;
|
||||
readonly consequence: string;
|
||||
readonly action: string;
|
||||
constructor(opts: { fact: string; consequence: string; action: string }) {
|
||||
super(`${opts.fact}\n${opts.consequence}\n${opts.action}`);
|
||||
this.name = "ScopeCliError";
|
||||
this.fact = opts.fact;
|
||||
this.consequence = opts.consequence;
|
||||
this.action = opts.action;
|
||||
}
|
||||
}
|
||||
|
||||
export type SliceState = "active" | "closed" | "shipped" | "all";
|
||||
export type SliceTemplateKind =
|
||||
| "placeholder"
|
||||
| "bug-fix"
|
||||
| "backlog-deprecation"
|
||||
| "backlog-tech-debt"
|
||||
| "release-feature"
|
||||
| "research";
|
||||
export const SLICE_TEMPLATE_KINDS: ReadonlyArray<SliceTemplateKind> = [
|
||||
"placeholder",
|
||||
"bug-fix",
|
||||
"backlog-deprecation",
|
||||
"backlog-tech-debt",
|
||||
"release-feature",
|
||||
"research",
|
||||
];
|
||||
|
||||
export type MissionTemplateKind = "placeholder" | "release";
|
||||
export const MISSION_TEMPLATE_KINDS: ReadonlyArray<MissionTemplateKind> = [
|
||||
"placeholder",
|
||||
"release",
|
||||
];
|
||||
|
||||
export type CloseReason = "wontfix" | "deferred" | "superseded" | "stale";
|
||||
export const CLOSE_REASONS: ReadonlyArray<CloseReason> = [
|
||||
"wontfix",
|
||||
"deferred",
|
||||
"superseded",
|
||||
"stale",
|
||||
];
|
||||
@@ -0,0 +1,565 @@
|
||||
// release-0.3.2 slice 12 — `rig scope` command-surface integration
|
||||
// tests. Drives the commander tree end-to-end with a tmp substrate
|
||||
// fixture so every HG-N gate has direct coverage.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { Command } from "commander";
|
||||
|
||||
import { scopeCommand } from "../src/commands/scope.js";
|
||||
import { readFrontmatter } from "../src/lib/scope/scope-fs.js";
|
||||
|
||||
function mktemp(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-cmd-"));
|
||||
}
|
||||
|
||||
function writeFile(p: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
}
|
||||
|
||||
function seedSubstrate(): { root: string; missionsRoot: string } {
|
||||
const root = mktemp();
|
||||
const missionsRoot = path.join(root, "openrig-work", "missions");
|
||||
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.email", "t@e.com"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.name", "T"], { stdio: "ignore" });
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "01-existing", "README.md"),
|
||||
"---\nid: OPR.0.3.2.1\nstatus: active\n---\n# existing\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "README.md"),
|
||||
"---\nid: OPR.99.0.1\n---\n# backlog\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "slices", "01-debt-foo", "README.md"),
|
||||
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# debt\n",
|
||||
);
|
||||
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
|
||||
return { root, missionsRoot };
|
||||
}
|
||||
|
||||
interface CaptureResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function run(args: string[], workspace: string): Promise<CaptureResult> {
|
||||
const stdoutBuf: string[] = [];
|
||||
const stderrBuf: string[] = [];
|
||||
const origWrite = process.stdout.write.bind(process.stdout);
|
||||
const origErrWrite = process.stderr.write.bind(process.stderr);
|
||||
const origExit = process.exit;
|
||||
let exitCode = 0;
|
||||
process.stdout.write = ((chunk: unknown) => { stdoutBuf.push(String(chunk)); return true; }) as typeof process.stdout.write;
|
||||
process.stderr.write = ((chunk: unknown) => { stderrBuf.push(String(chunk)); return true; }) as typeof process.stderr.write;
|
||||
process.exit = ((code?: number) => {
|
||||
exitCode = code ?? 0;
|
||||
throw new Error(`__EXIT__${exitCode}`);
|
||||
}) as typeof process.exit;
|
||||
const program = new Command();
|
||||
program.addCommand(scopeCommand());
|
||||
program.exitOverride();
|
||||
try {
|
||||
await program.parseAsync(["node", "rig", "scope", ...args, "--workspace", path.dirname(workspace)]);
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
if (!msg.startsWith("__EXIT__")) {
|
||||
stderrBuf.push(msg + "\n");
|
||||
}
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
process.stderr.write = origErrWrite;
|
||||
process.exit = origExit;
|
||||
}
|
||||
return {
|
||||
exitCode,
|
||||
stdout: stdoutBuf.join(""),
|
||||
stderr: stderrBuf.join(""),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-1 + HG-9: rig scope slice ls --json + state filter
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice ls", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("HG-1 + HG-9: --json returns active slices from a specific mission", async () => {
|
||||
const r = await run(["slice", "ls", "--mission", "release-0.3.2", "--state", "active", "--json"], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.slices.map((s: { name: string }) => s.name)).toEqual(["01-existing"]);
|
||||
expect(parsed.slices[0].id).toBe("OPR.0.3.2.1");
|
||||
});
|
||||
|
||||
it("returns slices from all missions when --mission is omitted", async () => {
|
||||
const r = await run(["slice", "ls", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-2: rig scope slice show
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice show (HG-2)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("renders frontmatter + README + children", async () => {
|
||||
const r = await run([
|
||||
"slice", "show", "01-existing",
|
||||
"--mission", "release-0.3.2",
|
||||
"--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.slice.id).toBe("OPR.0.3.2.1");
|
||||
expect(parsed.slice.readme).toContain("# existing");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-3 + HG-4 + HG-15: rig scope slice create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice create", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("HG-3: auto-finds next NN, never reuses numbers", async () => {
|
||||
// Add a closed slice 04 so existing NNs are 01 + 04. Next must be 05.
|
||||
writeFile(
|
||||
path.join(env.missionsRoot, "release-0.3.2", "closed", "04-old", "README.md"),
|
||||
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\n",
|
||||
);
|
||||
const r = await run([
|
||||
"slice", "create", "release-0.3.2", "new-thing", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.slice.name).toMatch(/^05-/);
|
||||
});
|
||||
|
||||
it("HG-4: --template populates the template", async () => {
|
||||
const r = await run([
|
||||
"slice", "create", "release-0.3.2", "perf-fix",
|
||||
"--template", "bug-fix", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
const readme = fs.readFileSync(path.join(parsed.slice.path, "README.md"), "utf8");
|
||||
expect(readme).toMatch(/## Repro/);
|
||||
expect(readme).toMatch(/## Expected/);
|
||||
});
|
||||
|
||||
it("HG-15: created slice frontmatter has a conformant dot-ID", async () => {
|
||||
const r = await run([
|
||||
"slice", "create", "release-0.3.2", "tdd-foo", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
const fm = readFrontmatter(path.join(parsed.slice.path, "README.md"));
|
||||
expect(fm.id).toBe("OPR.0.3.2.2");
|
||||
expect(typeof fm.id === "string" && /^OPR\.0\.3\.2\.\d+$/.test(fm.id as string)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-5 + HG-9: rig scope slice ship — git mv + frontmatter update
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice ship (HG-5)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("git mv + frontmatter status: shipped-to-release-X.Y", 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);
|
||||
expect(parsed.shipped.git.usedGit).toBe(true);
|
||||
// Source removed; target present.
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
|
||||
expect(fs.existsSync(parsed.shipped.to.path)).toBe(true);
|
||||
const fm = readFrontmatter(path.join(parsed.shipped.to.path, "README.md"));
|
||||
expect(fm.status).toBe("shipped-to-release-0.3.2");
|
||||
expect(fm.mission).toBe("release-0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-6: rig scope slice close --reason
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice close (HG-6)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("moves to closed/ + sets status: closed-<reason>", async () => {
|
||||
const r = await run([
|
||||
"slice", "close", "01-debt-foo",
|
||||
"--mission", "backlog",
|
||||
"--reason", "wontfix",
|
||||
"--note", "obsolete by design",
|
||||
"--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "slices", "01-debt-foo"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo"))).toBe(true);
|
||||
const fm = readFrontmatter(path.join(env.missionsRoot, "backlog", "closed", "01-debt-foo", "README.md"));
|
||||
expect(fm.status).toBe("closed-wontfix");
|
||||
expect(fm["closure-note"]).toBe("obsolete by design");
|
||||
});
|
||||
|
||||
it("rejects unknown --reason values", async () => {
|
||||
const r = await run([
|
||||
"slice", "close", "01-debt-foo",
|
||||
"--mission", "backlog",
|
||||
"--reason", "fishy",
|
||||
"--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-7: rig scope slice move
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope slice move (HG-7)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("moves between missions; auto-renumber; frontmatter.mission updates", 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);
|
||||
expect(parsed.moved.to.name).toMatch(/^02-/); // 01 was taken
|
||||
expect(parsed.moved.to.id).toBe("OPR.0.3.2.2");
|
||||
const fm = readFrontmatter(path.join(parsed.moved.to.path, "README.md"));
|
||||
expect(fm.mission).toBe("release-0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-8: rig scope mission ls
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope mission ls (HG-8)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("--json lists missions with id + slice counts", async () => {
|
||||
const r = await run(["mission", "ls", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
const names = parsed.missions.map((m: { name: string }) => m.name).sort();
|
||||
expect(names).toEqual(["backlog", "release-0.3.2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig scope mission show", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("renders frontmatter + README + slice inventory", async () => {
|
||||
const r = await run(["mission", "show", "release-0.3.2", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.id).toBe("OPR.0.3.2");
|
||||
expect(parsed.mission.slices.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-14 + HG-15: rig scope mission create (PROMOTED to v0 per Amendment §B)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("rig scope mission create (HG-14 + HG-15)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("HG-14: mission create is invocable with the SAME pattern as slice create", async () => {
|
||||
// Symmetry test: both verbs take (positional name) + (--template) +
|
||||
// (--json) and emit { ok, [tier]: { name, id, template, path } }.
|
||||
const r = await run([
|
||||
"mission", "create", "release-0.4.0", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.mission.name).toBe("release-0.4.0");
|
||||
expect(parsed.mission.path).toBeTruthy();
|
||||
expect(parsed.mission.template).toBe("release");
|
||||
});
|
||||
|
||||
it("HG-15: created mission frontmatter has a conformant dot-ID per §1", async () => {
|
||||
const r = await run(["mission", "create", "release-0.5.0", "--json"], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.id).toBe("OPR.0.5.0");
|
||||
const fm = readFrontmatter(path.join(parsed.mission.path, "README.md"));
|
||||
expect(fm.id).toBe("OPR.0.5.0");
|
||||
});
|
||||
|
||||
it("HG-15: non-release names get an escape-band ID (uniform-numeric)", async () => {
|
||||
const r = await run([
|
||||
"mission", "create", "experiments-foo", "--json",
|
||||
], env.missionsRoot);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.mission.id).toMatch(/^OPR\.99\.0\.\d+$/);
|
||||
});
|
||||
|
||||
it("HG-15: accepts an explicit --id override and rejects malformed ones", async () => {
|
||||
const ok = await run([
|
||||
"mission", "create", "weird-thing",
|
||||
"--id", "OPR.99.0.42", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(JSON.parse(ok.stdout).mission.id).toBe("OPR.99.0.42");
|
||||
|
||||
const bad = await run([
|
||||
"mission", "create", "another-weird-thing",
|
||||
"--id", "OPR.A.1", "--json",
|
||||
], 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");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-10: 3-part error shape
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("3-part error shape (HG-10)", () => {
|
||||
let env: { root: string; missionsRoot: string };
|
||||
beforeEach(() => { env = seedSubstrate(); });
|
||||
afterEach(() => { fs.rmSync(env.root, { recursive: true, force: true }); });
|
||||
|
||||
it("ship to nonexistent target mission yields fact/consequence/action", async () => {
|
||||
const r = await run([
|
||||
"slice", "ship", "01-debt-foo", "release-9.9.9",
|
||||
"--mission", "backlog", "--json",
|
||||
], env.missionsRoot);
|
||||
expect(r.exitCode).toBe(1);
|
||||
const parsed = JSON.parse(r.stdout);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error.fact).toBeTruthy();
|
||||
expect(parsed.error.consequence).toBeTruthy();
|
||||
expect(parsed.error.action).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// HG-12: --help exists for every command
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe("--help is present on every command (HG-12)", () => {
|
||||
it("every tier and verb has help text", () => {
|
||||
const cmd = scopeCommand();
|
||||
expect(cmd.description()).toBeTruthy();
|
||||
const tiers = cmd.commands;
|
||||
expect(tiers.map((c) => c.name()).sort()).toEqual(["mission", "slice"]);
|
||||
for (const tier of tiers) {
|
||||
expect(tier.description()).toBeTruthy();
|
||||
for (const verb of tier.commands) {
|
||||
expect(verb.description()).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// release-0.3.2 slice 12 — dot-ID grammar unit tests.
|
||||
//
|
||||
// Aligned with `openrig-work/conventions/scope-and-versioning/README.md` §1.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatDotId,
|
||||
inferMissionDotId,
|
||||
isConformantDotId,
|
||||
isMissionDotId,
|
||||
isSliceDotId,
|
||||
nextEscapeBandOrdinal,
|
||||
parseDotId,
|
||||
sliceIdFromMission,
|
||||
} from "../src/lib/scope/dot-id.js";
|
||||
|
||||
describe("dot-ID — parse + format", () => {
|
||||
it("parses a slice ID like OPR.0.3.2.12", () => {
|
||||
const id = parseDotId("OPR.0.3.2.12");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.project).toBe("OPR");
|
||||
expect(id!.version).toBe("0.3.2.12");
|
||||
});
|
||||
|
||||
it("parses an escape-band ID like OPR.99.0.1", () => {
|
||||
const id = parseDotId("OPR.99.0.1");
|
||||
expect(id).not.toBeNull();
|
||||
expect(id!.project).toBe("OPR");
|
||||
expect(id!.version).toBe("99.0.1");
|
||||
});
|
||||
|
||||
it("rejects alpha segments (§1 explicit: uniform-numeric escape band)", () => {
|
||||
expect(parseDotId("OPR.A.1")).toBeNull();
|
||||
expect(parseDotId("OPR.0.3.A")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed prefixes", () => {
|
||||
expect(parseDotId("opr.0.3.2")).toBeNull();
|
||||
expect(parseDotId("O.0.3.2")).toBeNull();
|
||||
expect(parseDotId("OPRX.0.3.2")).toBeNull();
|
||||
});
|
||||
|
||||
it("formats a DotId back to canonical string", () => {
|
||||
expect(formatDotId({ project: "OPR", version: "0.3.2" })).toBe("OPR.0.3.2");
|
||||
expect(formatDotId({ project: "OPR", version: "0.3.2", n: 12 })).toBe("OPR.0.3.2.12");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferMissionDotId — release pattern + escape band", () => {
|
||||
it("infers OPR.X.Y.Z from release-X.Y.Z folder names", () => {
|
||||
expect(inferMissionDotId("release-0.3.2", null)).toBe("OPR.0.3.2");
|
||||
expect(inferMissionDotId("release-0.4.0", null)).toBe("OPR.0.4.0");
|
||||
expect(inferMissionDotId("release-1.0", null)).toBe("OPR.1.0");
|
||||
});
|
||||
|
||||
it("requires an escape-band ordinal for non-release names", () => {
|
||||
expect(() => inferMissionDotId("backlog", null)).toThrow();
|
||||
expect(inferMissionDotId("backlog", 5)).toBe("OPR.99.0.5");
|
||||
expect(inferMissionDotId("bug-fix", 3)).toBe("OPR.99.0.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextEscapeBandOrdinal", () => {
|
||||
it("returns 1 when no peer IDs exist", () => {
|
||||
expect(nextEscapeBandOrdinal([])).toBe(1);
|
||||
expect(nextEscapeBandOrdinal([null, null])).toBe(1);
|
||||
});
|
||||
|
||||
it("returns max+1 across existing escape-band peers", () => {
|
||||
expect(nextEscapeBandOrdinal(["OPR.99.0.1", "OPR.99.0.3", null])).toBe(4);
|
||||
});
|
||||
|
||||
it("ignores non-matching IDs (releases) when picking ordinal", () => {
|
||||
expect(nextEscapeBandOrdinal(["OPR.0.3.2", "OPR.99.0.2"])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sliceIdFromMission", () => {
|
||||
it("appends the slice ordinal to the mission ID", () => {
|
||||
expect(sliceIdFromMission("OPR.0.3.2", 12)).toBe("OPR.0.3.2.12");
|
||||
expect(sliceIdFromMission("OPR.99.0.1", 4)).toBe("OPR.99.0.1.4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isConformantDotId", () => {
|
||||
it("accepts conformant IDs across all tiers", () => {
|
||||
expect(isConformantDotId("OPR.0.3.2")).toBe(true);
|
||||
expect(isConformantDotId("OPR.0.3.2.12")).toBe(true);
|
||||
expect(isConformantDotId("OPR.99.0.1")).toBe(true);
|
||||
});
|
||||
it("rejects non-conformant inputs", () => {
|
||||
expect(isConformantDotId("")).toBe(false);
|
||||
expect(isConformantDotId(42)).toBe(false);
|
||||
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();
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
// release-0.3.2 slice 12 — scope-fs helpers + frontmatter parser tests.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
ensureMissionId,
|
||||
findMission,
|
||||
findSlice,
|
||||
listMissions,
|
||||
listSlices,
|
||||
moveSlice,
|
||||
nextSliceNN,
|
||||
readFrontmatter,
|
||||
resolveMissionsRoot,
|
||||
splitFrontmatter,
|
||||
updateFrontmatter,
|
||||
} from "../src/lib/scope/scope-fs.js";
|
||||
|
||||
function mktemp(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "rig-scope-test-"));
|
||||
}
|
||||
|
||||
function writeFile(p: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
}
|
||||
|
||||
describe("frontmatter parser", () => {
|
||||
it("returns {} when there is no frontmatter delimiter", () => {
|
||||
const { frontmatter, body } = splitFrontmatter("# heading\n\nbody");
|
||||
expect(frontmatter).toEqual({});
|
||||
expect(body).toBe("# heading\n\nbody");
|
||||
});
|
||||
|
||||
it("parses simple key/value pairs", () => {
|
||||
const src = "---\nid: OPR.0.3.2.12\nstatus: active\n---\nbody";
|
||||
const { frontmatter, body } = splitFrontmatter(src);
|
||||
expect(frontmatter).toEqual({ id: "OPR.0.3.2.12", status: "active" });
|
||||
expect(body).toBe("body");
|
||||
});
|
||||
|
||||
it("preserves unknown keys on update", () => {
|
||||
const dir = mktemp();
|
||||
const p = path.join(dir, "README.md");
|
||||
writeFile(p, "---\nstatus: active\ncustom: keep-me\n---\nbody\n");
|
||||
updateFrontmatter(p, { status: "shipped" });
|
||||
const fm = readFrontmatter(p);
|
||||
expect(fm.status).toBe("shipped");
|
||||
expect(fm.custom).toBe("keep-me");
|
||||
});
|
||||
|
||||
it("generates minimal frontmatter when absent", () => {
|
||||
const dir = mktemp();
|
||||
const p = path.join(dir, "README.md");
|
||||
writeFile(p, "body only\n");
|
||||
updateFrontmatter(p, { id: "OPR.0.3.2" });
|
||||
const fm = readFrontmatter(p);
|
||||
expect(fm.id).toBe("OPR.0.3.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissionsRoot", () => {
|
||||
it("uses an explicit override path when present", () => {
|
||||
const root = mktemp();
|
||||
fs.mkdirSync(path.join(root, "missions"));
|
||||
expect(resolveMissionsRoot({ override: root })).toBe(path.join(root, "missions"));
|
||||
});
|
||||
|
||||
it("throws ScopeCliError when no missions/ root is found", () => {
|
||||
const empty = mktemp();
|
||||
expect(() => resolveMissionsRoot({ override: empty, cwd: empty })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMissions + listSlices + nextSliceNN", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
// mission with 2 active + 1 closed slice
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n# release-0.3.2\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "01-foo", "README.md"),
|
||||
"---\nid: OPR.0.3.2.1\nstatus: active\n---\nbody\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "02-bar", "README.md"),
|
||||
"---\nid: OPR.0.3.2.2\nstatus: active\n---\nbody\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "closed", "04-baz", "README.md"),
|
||||
"---\nid: OPR.0.3.2.4\nstatus: closed-stale\n---\nbody\n",
|
||||
);
|
||||
// mission with no README — should be skipped as "no slice count basis"
|
||||
fs.mkdirSync(path.join(missionsRoot, "no-readme"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lists missions sorted by name with slice counts; SKIPS dirs without README.md (HG-8)", () => {
|
||||
const missions = listMissions(missionsRoot);
|
||||
// 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");
|
||||
expect(active.map((s) => s.name).sort()).toEqual(["01-foo", "02-bar"]);
|
||||
const closed = listSlices(mission, "closed");
|
||||
expect(closed.map((s) => s.name)).toEqual(["04-baz"]);
|
||||
});
|
||||
|
||||
it("nextSliceNN skips numbers already used in slices/ AND closed/ (HG-3)", () => {
|
||||
const missionAbs = path.join(missionsRoot, "release-0.3.2");
|
||||
// existing: 01, 02, 04 → next should be 5 (not 3 — numbers never reused).
|
||||
expect(nextSliceNN(missionAbs)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSlice + resolution variants", () => {
|
||||
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---\nbody",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "slices", "07-target", "README.md"),
|
||||
"---\nid: OPR.0.3.2.7\nstatus: active\n---\nbody",
|
||||
);
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("resolves a mission-relative path with --mission hint", () => {
|
||||
const slice = findSlice(missionsRoot, "07-target", "release-0.3.2");
|
||||
expect(slice.name).toBe("07-target");
|
||||
expect(slice.id).toBe("OPR.0.3.2.7");
|
||||
});
|
||||
|
||||
it("resolves an absolute path", () => {
|
||||
const abs = path.join(missionsRoot, "release-0.3.2", "slices", "07-target");
|
||||
const slice = findSlice(missionsRoot, abs);
|
||||
expect(slice.missionName).toBe("release-0.3.2");
|
||||
});
|
||||
|
||||
it("3-part error when slice not found (HG-10)", () => {
|
||||
expect(() => findSlice(missionsRoot, "99-missing", "release-0.3.2")).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("returns existing frontmatter id when present", () => {
|
||||
writeFile(
|
||||
path.join(missionsRoot, "weird-name", "README.md"),
|
||||
"---\nid: OPR.99.0.7\n---\n",
|
||||
);
|
||||
const m = findMission(missionsRoot, "weird-name");
|
||||
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.99.0.7");
|
||||
});
|
||||
|
||||
it("derives from release-X.Y.Z pattern when no id present", () => {
|
||||
writeFile(path.join(missionsRoot, "release-0.4.0", "README.md"), "");
|
||||
const m = findMission(missionsRoot, "release-0.4.0");
|
||||
expect(ensureMissionId(m, missionsRoot)).toBe("OPR.0.4.0");
|
||||
});
|
||||
|
||||
it("falls into escape band for non-release names", () => {
|
||||
writeFile(path.join(missionsRoot, "backlog", "README.md"), "");
|
||||
const m = findMission(missionsRoot, "backlog");
|
||||
const id = ensureMissionId(m, missionsRoot);
|
||||
expect(id).toMatch(/^OPR\.99\.0\.\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// git mv path — uses real git in a tmp repo (HG-5, HG-11)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function initRepo(root: string): void {
|
||||
execFileSync("git", ["-C", root, "init", "-q"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.email", "test@example.com"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "config", "user.name", "Tester"], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "commit", "--allow-empty", "-m", "init", "-q"], { stdio: "ignore" });
|
||||
}
|
||||
|
||||
describe("moveSlice — git mv preserves history (HG-5) + refuses dirty tree (HG-11)", () => {
|
||||
let root: string;
|
||||
let missionsRoot: string;
|
||||
beforeEach(() => {
|
||||
root = mktemp();
|
||||
missionsRoot = path.join(root, "missions");
|
||||
initRepo(root);
|
||||
fs.mkdirSync(missionsRoot, { recursive: true });
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "README.md"),
|
||||
"---\nid: OPR.99.0.1\n---\n# backlog\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "backlog", "slices", "01-foo", "README.md"),
|
||||
"---\nid: OPR.99.0.1.1\nstatus: active\n---\n# foo\n",
|
||||
);
|
||||
writeFile(
|
||||
path.join(missionsRoot, "release-0.3.2", "README.md"),
|
||||
"---\nid: OPR.0.3.2\n---\n# release\n",
|
||||
);
|
||||
execFileSync("git", ["-C", root, "add", "."], { stdio: "ignore" });
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "seed", "-q"], { stdio: "ignore" });
|
||||
});
|
||||
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
it("uses git mv inside a repo and preserves the file via git log --follow", () => {
|
||||
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
|
||||
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "01-foo");
|
||||
const result = moveSlice(src, dest);
|
||||
expect(result.usedGit).toBe(true);
|
||||
execFileSync("git", ["-C", root, "commit", "-m", "ship", "-q"], { stdio: "ignore" });
|
||||
const log = execFileSync("git", [
|
||||
"-C", root, "log", "--follow", "--pretty=format:%s", "--", path.relative(root, path.join(dest, "README.md")),
|
||||
], { encoding: "utf8" });
|
||||
expect(log).toMatch(/seed/);
|
||||
expect(log).toMatch(/ship/);
|
||||
});
|
||||
|
||||
it("refuses to move when slice has uncommitted local edits (HG-11)", () => {
|
||||
const src = path.join(missionsRoot, "backlog", "slices", "01-foo");
|
||||
fs.writeFileSync(path.join(src, "README.md"), "dirty\n", "utf8");
|
||||
const dest = path.join(missionsRoot, "release-0.3.2", "slices", "02-foo");
|
||||
expect(() => moveSlice(src, dest)).toThrow(/uncommitted/);
|
||||
});
|
||||
|
||||
it("falls back to fs.rename outside a git repo", () => {
|
||||
const dir = mktemp();
|
||||
const src = path.join(dir, "src");
|
||||
const dest = path.join(dir, "dest");
|
||||
fs.mkdirSync(src);
|
||||
fs.writeFileSync(path.join(src, "README.md"), "body");
|
||||
const result = moveSlice(src, dest);
|
||||
expect(result.usedGit).toBe(false);
|
||||
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