scripts.migrate_legacy_story_ids
Reference › scripts › migrate_legacy_story_ids
scripts/migrate_legacy_story_ids.py rewrites legacy requirement ids of the form REQ-NNN-USn-MMM to the canonical REQ-NNN-US-NNN-MMM, together with the matching spec headings. A human runs it against the repository; it has no internal imports.
Options: --root (default the repository root), --apply (write changes; the default is a dry run) and --report-collisions. canonical_story pads the story number to three digits, migrate_text rewrites every legacy id and heading in a text, iter_candidate_files lists the in-scope files in sorted order (with is_excluded and _matches applying the exclusion globs), and run returns the files changed, the substitutions and the skipped files. The safety check is find_collisions: it maps each canonical target to the distinct legacy spellings that reach it. If any collision is not in KNOWN_BENIGN_COLLISIONS, a real migration refuses to run, because it would merge two distinct requirement ids into one; the message directs the user to --report-collisions. That option prints every collision tagged known-benign or unrecognised and exits 1 if any is unrecognised.
At a glance
| Source | scripts/migrate_legacy_story_ids.py |
| Lines of code | 431 |
| Classes | 1 |
| Functions | 9 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | none |
Class diagram
Dependency diagram
No internal imports and no importers.
API
Constants
| Name | Type | Value |
|---|---|---|
EXCLUDED_DIR_PARTS |
frozenset[str] |
frozenset({'.git', '.venv', 'worktrees', 'node_modules', '__pycache__', '.cache… |
EXCLUDED_GLOBS |
tuple[str, ...] |
('plans/ast-*.json', 'tests/scripts/test_migrate_legacy_story_ids.py', 'tests/s… |
INCLUDED_SUFFIXES |
frozenset[str] |
frozenset({'.py', '.md', '.pl', '.json', '.yaml', '.yml', '.toml', '.txt', '.nd… |
KNOWN_BENIGN_COLLISIONS |
frozenset[tuple[str, str, str]] |
frozenset({('007', '001', '001'), ('999', '001', '001')}) |
Classes
ManglingError
Bases: RuntimeError · Line 200
Raised when a migration would change anything other than a story id.
Functions
| Function | Signature | Summary |
|---|---|---|
canonical_story |
canonical_story(raw: str) -> str |
"1"/"01"/"001" -> "001"; "10" -> "010". |
skeleton |
skeleton(text: str) -> str |
text with every story id and story heading blanked to a sentinel. |
migrate_text |
migrate_text(text: str) -> tuple[str, int] |
Rewrite every legacy id and spec heading in text. |
is_excluded |
is_excluded(path: Path, root: Path | None = None) -> bool |
True when path is derived, foreign, or a non-text artefact. |
_matches |
_matches(rel: Path, pattern: str) -> bool |
One glob test, using the matcher whose semantics the pattern implies. |
iter_candidate_files |
iter_candidate_files(root: Path) -> list[Path] |
Every in-scope file under root, sorted for deterministic output. |
find_collisions |
find_collisions(root: Path) -> dict[tuple[str, str, str], set[str]] |
Map each canonical target to the distinct legacy ids that reach it. |
run |
run(root: Path, apply: bool) -> tuple[int, int, int] |
Migrate under root. |
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Migrate legacy story ids (``REQ-NNN-USn-MMM``) to canonical ``REQ-NNN-US-NNN-MMM``.
Canonical grammar (``docs/req-id-grammar.md``): the story segment is ``US-``
followed by exactly three digits. Three LEGACY shapes exist in the corpus and
all three are rewritten:
REQ-002-USn-001 -> REQ-002-US-001-001 (n = 1 digit, e.g. US1)
REQ-001-USnn-004 -> REQ-001-US-010-004 (nn = 2 digits, e.g. US10)
REQ-004-USnnn-001 -> REQ-004-US-001-001 (nnn = 3 digits, dash missing)
NOTE: the left column deliberately uses the ``USn`` PLACEHOLDER spelling, not
a literal legacy id. Written literally, this migrator rewrites its own
documentation on the next run and the examples degrade into ``X -> X``
identities that teach nothing. That happened once, on 2026-08-19, and was
caught by reading the diff rather than by any check — the safety invariants
cannot help here, because an illustrative id is structurally indistinguishable
from a real one. ``docs/req-id-grammar.md`` treats these placeholder tokens
the same way, and ``check_req_format.py``'s ``PLACEHOLDER`` pattern exempts
them for exactly this reason.
Measured 2026-08-19 before migration: 61,700 one-digit, 7,992 two-digit and
13,444 three-digit occurrences (83,136 total) against 1,925 already-canonical.
WHY THIS IS NOT A ONE-LINE SED
------------------------------
A naive ``s/US([0-9])-/US-00\\1-/`` matches only the 1-digit shape. It leaves
21,436 two- and three-digit ids behind while *reporting success*, and the
survivors are the ones a human is least likely to notice. Every shape is
handled here by a single regex with an explicit ``{1,3}`` width and a
normalise-then-pad step, so a legacy id cannot be silently skipped.
Equally important, ``US-001`` (already canonical) must NOT match. The pattern
requires a digit immediately after ``US``, which a canonical id never has.
COLLISIONS
----------
Two legacy ids can normalise onto one canonical id (``US1`` and ``US001`` both
-> ``US-001``). Measured across the whole corpus: 1,851 distinct legacy ids
collapse to 1,849 canonical, i.e. exactly 2 collisions, both verified benign —
``REQ-007-US-001-001`` and ``REQ-999-US-001-001`` occur only as synthetic strings
inside parser tests, never as real requirement definitions. ``--report-collisions``
recomputes this rather than trusting the note: a NEW collision between two real
requirements would silently merge them, so the migration refuses to run if one
appears that is not in ``KNOWN_BENIGN_COLLISIONS``.
SCOPE
-----
Derived artefacts (``plans/ast-*.json``, ``.cache/``) are NOT rewritten — they
are regenerated from source afterwards, so editing them would be both wasted
work and a second source of truth. ``.git``/``.venv``/``worktrees``/
``node_modules`` are never touched.
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# A legacy story segment: "US" followed immediately by 1-3 digits, then "-".
# The digit immediately after "US" is what distinguishes legacy from canonical
# ("US-001" has a dash there and therefore cannot match).
LEGACY_ID_RE = re.compile(r"\bREQ-(\d{3})-US(\d{1,3})-(\d+)\b")
# The same story segment in a spec heading ("## US1: ..." / "### US10").
# MULTILINE so it applies across a whole document in one pass; a canonical
# "## US-004" cannot match because a digit must follow "US" directly.
LEGACY_HEADING_RE = re.compile(r"^(#{2,4}\s+)US(\d{1,3})\b", re.MULTILINE)
# Directories whose contents are derived, foreign, or per-run state; never
# rewritten. ``pipeline_checkpoints`` is gitignored runtime state (0 tracked
# files, 331 live dirs measured 2026-08-19): its ids are a snapshot of a past
# run, so rewriting them edits history while changing nothing a reader
# depends on.
EXCLUDED_DIR_PARTS: frozenset[str] = frozenset(
{
".git",
".venv",
"worktrees",
"node_modules",
"__pycache__",
".cache",
".mypy_cache",
"pipeline_checkpoints",
# Historical run records. Same reasoning as pipeline_checkpoints: a log
# states what a past run DID emit, so rewriting it makes the record
# lie about the run it describes.
"logs",
# Runtime scratch + sibling worktree checkouts. `.claude/` (dotted) is
# machine state; `claude/` (undotted) is production source and IS in
# scope.
".claude",
}
)
# Derived AST snapshots — regenerated from source after the migration — plus
# the two test files that must keep genuine legacy examples forever in order to
# prove legacy is still recognised.
#
# A DETECTOR's test needs a positive fixture, and migrating that fixture does
# not make the corpus cleaner — it makes the detector untested. The first
# migration run proved this the hard way: it rewrote the legacy fixture in
# test_check_req_format.py, leaving ``test_legacy_form_counted`` and
# ``test_canonical_form_not_counted`` holding IDENTICAL input while asserting
# opposite results. A contradiction, reached silently, by a codemod whose
# invariants were all satisfied — because "did I destroy a negative fixture?"
# is a question about MEANING, which no byte-level invariant can ask.
EXCLUDED_GLOBS: tuple[str, ...] = (
"plans/ast-*.json",
"tests/scripts/test_migrate_legacy_story_ids.py",
"tests/scripts/test_migrate_legacy_story_ids_coverage.py",
"tests/scripts/test_check_req_format.py",
# ── added 2026-08-23, each measured, each enumerated ──────────────────
#
# reports/* — the corpus guard's OWN OUTPUT, and the reason it could
# never go green again once it had failed once. The assertion in
# tests/scripts/test_migrate_legacy_story_ids.py names up to 25
# offenders IN ITS MESSAGE; run_tests.py records that message into
# reports/run-tests-latest.json; the next scan reads those quoted ids
# back as fresh offenders. Measured: all 58 occurrences in that file sit
# inside the recorded failure text, and nowhere else. A guard whose
# failure manufactures its own next failure is not a ratchet, it is a
# latch. reports/ is test OUTPUT — nothing in it is source to migrate.
"reports/*",
#
# plans/analysis/*.json — a DERIVED register. plans/analysis/README.md
# states the pipeline: journal.jsonl (per-round swarm output) ->
# build_register.py -> register.json -> render_report.py. Its legacy ids
# are QUOTED EVIDENCE of what an agent reported seeing in the sources it
# read. Rewriting them would be wrong twice: build_register.py reverts
# it on the next regeneration, and a rewritten quote claims the agent
# saw something it did not. Same class as plans/ast-*.json above.
"plans/analysis/*.json",
#
# plugins/orchestrata/** — the GENERATED mirror of claude/ and .claude/,
# written by claude/scripts/gen_plugin.py. `.claude` is already excluded
# via EXCLUDED_DIR_PARTS, so before this the scan skipped the SOURCE
# hook and reported only its byte-identical generated copy — the one
# place a fix is forbidden, because gen_plugin.py overwrites it. The two
# ids involved are illustrative examples inside comments -- a sample
# `grep -rn` invocation quoting a legacy-shaped id -- the same kind of
# legitimate legacy-id quotation the two test files above are excluded
# for. (Written masked here on purpose: quoting one literally would make
# THIS file an offender, which is how the first draft of this comment
# tripped the very guard it documents.)
#
# Scoped to plugins/orchestrata/ and NOT plugins/ or a `plugins`
# EXCLUDED_DIR_PARTS entry: either of those also swallows
# tests/plugins/ (hand-written suite plugins) and opencode/plugins/,
# blinding the guard to two real source trees.
"plugins/orchestrata/**",
)
# Extensions worth rewriting. Binary and lockfiles are excluded by omission.
# ``.mmd`` (Mermaid) earns its place the hard way: the first sweep omitted it
# and left 94 legacy ids across 5 architecture diagrams in plans/contracts/ --
# real source that a suffix allowlist silently skipped while reporting
# success. When adding a scope rule, enumerate what it EXCLUDES, not only what
# it includes.
INCLUDED_SUFFIXES: frozenset[str] = frozenset(
{
".py",
".md",
".pl",
".json",
".yaml",
".yml",
".toml",
".txt",
".ndjson",
".mmd",
".sh",
".cfg",
".ini",
}
)
# (spec, canonical_story, seq) tuples where two legacy spellings legitimately
# collapse onto one canonical id. Verified 2026-08-19: both are synthetic
# strings inside parser tests, not competing requirement definitions.
KNOWN_BENIGN_COLLISIONS: frozenset[tuple[str, str, str]] = frozenset(
{("007", "001", "001"), ("999", "001", "001")}
)
def canonical_story(raw: str) -> str:
"""``"1"``/``"01"``/``"001"`` -> ``"001"``; ``"10"`` -> ``"010"``.
Strips leading zeros then pads to three, so every legacy width lands on
one canonical spelling.
"""
return str(int(raw)).zfill(3)
class ManglingError(RuntimeError):
"""Raised when a migration would change anything other than a story id.
This is a stop-the-world condition: it means the rewrite touched bytes it
had no business touching, and the file must NOT be written.
"""
# Matches a story id in EITHER spelling (legacy ``US1``/``US10``/``US001`` or
# canonical ``US-001``), used only to blank ids out for the safety invariant.
_ANY_ID_RE = re.compile(r"\bREQ-\d{3}-US-?\d{1,3}-\d+\b")
_ANY_HEADING_RE = re.compile(r"^(#{2,4}\s+)US-?\d{1,3}\b", re.MULTILINE)
_ID_SENTINEL = "\x00ID\x00"
_HEADING_SENTINEL = "\x00US\x00"
def skeleton(text: str) -> str:
"""``text`` with every story id and story heading blanked to a sentinel.
Two texts with equal skeletons differ ONLY in story ids. This is the
safety invariant: a correct migration must leave the skeleton untouched,
which is a far stronger guarantee than any list of cases I thought to
test -- it catches a mangled neighbouring token, a swallowed character,
a dropped line, or a mauled encoding, whether or not it was anticipated.
"""
blanked = _ANY_ID_RE.sub(_ID_SENTINEL, text)
return _ANY_HEADING_RE.sub(lambda m: m.group(1) + _HEADING_SENTINEL, blanked)
def migrate_text(text: str) -> tuple[str, int]:
"""Rewrite every legacy id and spec heading in ``text``.
Returns the new text and the number of substitutions made.
Raises:
ManglingError: the rewrite altered something other than a story id.
"""
count = 0
def _id(match: re.Match[str]) -> str:
nonlocal count
count += 1
spec, story, seq = match.groups()
return f"REQ-{spec}-US-{canonical_story(story)}-{seq}"
result = LEGACY_ID_RE.sub(_id, text)
def _heading(match: re.Match[str]) -> str:
nonlocal count
count += 1
return f"{match.group(1)}US-{canonical_story(match.group(2))}"
# Applied directly: LEGACY_HEADING_RE requires a DIGIT immediately after
# "US", which a canonical "US-004" heading never has, so canonical
# headings cannot match and need no second filter. Routing this through
# _ANY_HEADING_RE instead handed _heading a one-group match and raised
# IndexError on group(2) -- caught by the heading sample before any file
# was written.
result = LEGACY_HEADING_RE.sub(_heading, result)
# Invariant 1: nothing outside a story id may change.
if skeleton(result) != skeleton(text):
raise ManglingError(
"migration altered content outside a story id -- refusing to write"
)
# Invariant 2: the rewrite only ever LENGTHENS (US1->US-001 adds 3 chars,
# US10->US-010 adds 2, US001->US-001 adds 1). A shorter result means
# something was deleted.
if len(result) < len(text):
raise ManglingError(
f"migration shortened content by {len(text) - len(result)} char(s) "
"-- refusing to write"
)
# Invariant 3: line count is preserved exactly.
if result.count("\n") != text.count("\n"):
raise ManglingError("migration changed the line count -- refusing to write")
return result, count
def is_excluded(path: Path, root: Path | None = None) -> bool:
"""True when ``path`` is derived, foreign, or a non-text artefact.
``root`` anchors the derived-artefact globs. It defaults to the repo so
callers scanning this tree need not pass it, but it MUST be honoured for
any other tree: resolving against a hardcoded repo root raises ValueError
the moment ``path`` lives elsewhere, which is precisely what a temp-tree
test exposed before this migration ever touched real files.
"""
if any(part in EXCLUDED_DIR_PARTS for part in path.parts):
return True
if path.suffix.lower() not in INCLUDED_SUFFIXES:
return True
base = root if root is not None else REPO_ROOT
try:
rel = path.relative_to(base) if path.is_absolute() else path
except ValueError:
rel = Path(path.name) if path.parent == path.parent.parent else path
return any(_matches(rel, pattern) for pattern in EXCLUDED_GLOBS)
def _matches(rel: Path, pattern: str) -> bool:
"""One glob test, using the matcher whose semantics the pattern implies.
``Path.match`` does NOT treat ``**`` as recursive and matches from the
RIGHT, so a pattern that merely LOOKS recursive is silently inert -- the
failure class this module's own suffix-allowlist comment warns about.
Measured 2026-08-23 on Python 3.14.6, against the real candidates:
plugins/orchestrata/** .match -> matches NOTHING
plugins/orchestrata/** .full_match -> matches the mirror, recursively
plugins/** .match -> matches tests/plugins/... and
opencode/plugins/... but NOT
plugins/orchestrata/...
That third line is why this is a function and not a one-character edit:
the naive recursive-looking pattern excludes precisely the two trees that
must stay in scope, and none of the one that should leave it.
``full_match`` is used ONLY for patterns containing ``**``. Every pattern
without it keeps ``match``'s existing right-anchored behaviour, so this is
additive -- no pattern already in EXCLUDED_GLOBS changes meaning.
"""
if "**" in pattern:
return rel.full_match(pattern)
return rel.match(pattern)
def iter_candidate_files(root: Path) -> list[Path]:
"""Every in-scope file under ``root``, sorted for deterministic output."""
return sorted(
p for p in root.rglob("*") if p.is_file() and not is_excluded(p, root)
)
def find_collisions(root: Path) -> dict[tuple[str, str, str], set[str]]:
"""Map each canonical target to the distinct legacy ids that reach it.
Only entries with more than one source are returned — those are the ids
where migration MERGES two spellings and therefore needs a human verdict.
"""
targets: dict[tuple[str, str, str], set[str]] = defaultdict(set)
for path in iter_candidate_files(root):
try:
text = path.read_text(encoding="utf-8")
except OSError, UnicodeDecodeError:
continue
for match in LEGACY_ID_RE.finditer(text):
spec, story, seq = match.groups()
targets[(spec, canonical_story(story), seq)].add(match.group(0))
return {k: v for k, v in targets.items() if len(v) > 1}
def run(root: Path, apply: bool) -> tuple[int, int, int]:
"""Migrate under ``root``. Returns (files_changed, substitutions, skipped)."""
files_changed = substitutions = skipped = 0
for path in iter_candidate_files(root):
try:
original = path.read_text(encoding="utf-8")
except OSError, UnicodeDecodeError:
skipped += 1
continue
migrated, count = migrate_text(original)
if count == 0 or migrated == original:
continue
files_changed += 1
substitutions += count
if apply:
try:
path.write_text(migrated, encoding="utf-8")
except OSError:
skipped += 1
return files_changed, substitutions, skipped
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="migrate_legacy_story_ids",
description="Rewrite legacy REQ-NNN-USn-MMM ids to canonical REQ-NNN-US-NNN-MMM.",
)
parser.add_argument("--root", default=str(REPO_ROOT), help="Tree to migrate.")
parser.add_argument(
"--apply", action="store_true", help="Write changes (default: dry run)."
)
parser.add_argument(
"--report-collisions",
action="store_true",
help="List canonical ids reachable from more than one legacy spelling.",
)
args = parser.parse_args(argv)
root = Path(args.root).resolve()
collisions = find_collisions(root)
unknown = {k: v for k, v in collisions.items() if k not in KNOWN_BENIGN_COLLISIONS}
if args.report_collisions:
print(f"collisions: {len(collisions)} ({len(unknown)} unrecognised)")
for key, sources in sorted(collisions.items()):
tag = "KNOWN-BENIGN" if key in KNOWN_BENIGN_COLLISIONS else "UNRECOGNISED"
print(f" [{tag}] REQ-{key[0]}-US-{key[1]}-{key[2]} <- {sorted(sources)}")
return 1 if unknown else 0
if unknown:
print(
f"REFUSING: {len(unknown)} unrecognised collision(s) would MERGE two "
f"distinct requirement ids into one. Re-run with --report-collisions, "
f"verify each is benign, then add it to KNOWN_BENIGN_COLLISIONS.",
file=sys.stderr,
)
for key, sources in sorted(unknown.items()):
print(
f" REQ-{key[0]}-US-{key[1]}-{key[2]} <- {sorted(sources)}",
file=sys.stderr,
)
return 2
files_changed, substitutions, skipped = run(root, apply=args.apply)
mode = "APPLIED" if args.apply else "DRY RUN"
print(
f"{mode}: {substitutions} substitution(s) across {files_changed} file(s); "
f"{skipped} skipped; {len(collisions)} known-benign collision(s)."
)
if not args.apply:
print("Re-run with --apply to write.")
return 0
if __name__ == "__main__":
raise SystemExit(main())