Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions emrg/server/evolution_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,9 @@ Every merged PR must trace back to the reason it exists. The chain has three lin
rant (entry `progress`: `issue #N`) → issue (stands on its own) → PR (Closes #N) → merge → issue closes → rant completed
```

**rant → issue.** Taking on a rant means first opening the issue that will finish it, and that issue is written for a reader who has never seen the record it came from — **it does not refer to it at all**: no timestamp, no *reported by the host*, no paraphrase of the report. Write out what was observed, where it was observed, what was expected instead, and what the fix must satisfy, so that reader can act on it without the record. A citation into the ledger is not context: the ledger is one file per installation, so a second reader cannot open what it names, and an issue that only makes sense beside it is incomplete however its first line reads. The moment is **taking it on**, not submitting: no agent is present when a rant lands from the GUI or CLI, so taking it on is the only moment that covers every rant. One rant is one issue by default — several rants are not bundled into one issue. Write the issue number into that rant's `progress` (through the tool, as `issue #N`) in that same step: the link is **one way** — the entry knows its issue, the issue does not know its entry — and that entry is where the pairing is durable, because the queue reading (R2) takes the issue from it. A rant this cycle decides **not** to do opens no issue: the reason goes into its progress and the rant is closed. The invariant is "every merged PR names its issue", not "every rant has an issue".
**rant → issue.** Taking on a rant means first opening the issue that will finish it, and that issue is written for a reader who has never seen the record it came from — **it does not refer to it at all**: no timestamp, no *reported by the host*, no paraphrase of the report. Write out what was observed, where it was observed, what was expected instead, and what the fix must satisfy, so that reader can act on it without the record. A citation into the ledger is not context: the ledger is one file per installation, so a second reader cannot open what it names, and an issue that only makes sense beside it is incomplete however its first line reads. The moment is **taking it on**, not submitting: no agent is present when a rant lands from the GUI or CLI, so taking it on is the only moment that covers every rant. **One rant is one issue, and one issue is one PR** — the chain stated as the single rule it is. Several rants are never bundled into one issue, and one rant is never carried by two. The cost is the reason: a PR here needs three approvals from three different cycles, so every stage a record is cut into spends three more votes on the same work while making no part of the review smaller. Write the issue number into that rant's `progress` (through the tool, as `issue #N`) in that same step: the link is **one way** — the entry knows its issue, the issue does not know its entry — and that entry is where the pairing is durable, because the queue reading (R2) takes the issue from it. A rant this cycle decides **not** to do opens no issue: the reason goes into its progress and the rant is closed. The invariant is "every merged PR names its issue", not "every rant has an issue".

**issue → PR.** An issue is **finished by exactly one PR**, and **the two name each other** — a PR that is rejected or asked to change is **updated in place, never replaced**. When a rant's acceptance items are independently verifiable they may be split across issues, and each issue's body says `Part: 1/2` so the reading can tell a deliberate split from a duplicate claim.
**issue → PR.** An issue is **finished by exactly one PR**, and **the two name each other** — a PR that is rejected or asked to change is **updated in place, never replaced**. An issue landed by two PRs is the same fault one link later, not a way to finish a large one: an issue too big for one reviewable PR was scoped wrong, and re-scoping it is the remedy. Independently verifiable acceptance items do not license a split either — an issue carried by two says nothing a reader can act on that one issue could not.

**PR → issue, at creation.** The `gh pr create` body carries `Closes #N` — GitHub's own closing keyword, and the token the link reading treats as a **claim** rather than a mention. A body that cites another number as evidence writes `see #N`, never `Closes`, or that issue is recorded as claimed by a PR that never finished it. **No issue number, no PR**: go back and open or find the issue first, never open the PR and backfill. In the same action, comment `Handled by #<PR>` on that issue, so both halves are born together rather than waiting for a later cycle to claim one of them.

Expand Down
37 changes: 11 additions & 26 deletions scripts/check-issue-links.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,8 @@
that change, and the state is kept because silently dropping it would
report those issues as if the line had never been written
origin-duplicate
two or more open issues declare the same rant origin and at least one
carries no `Part: n/N` — which is what makes a deliberate split of one rant
across several issues distinguishable from a duplicate claim of it
two or more open issues declare the same rant origin: one rant is one
issue, so a second claim of it cannot be told from a duplicate of the first

The last two read a **file**, not GitHub, and that difference is why they exist at all
----------------------------------------------------------------------------------------
Expand Down Expand Up @@ -411,13 +410,6 @@
r"(?P<ts>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)"
)

#: The escape hatch R5 defines for a rant carried by more than one issue: `Part: 1/3`. Two
#: open issues naming the same rant are the shape the one-rant-one-issue default forbids,
#: *unless* each says which part it is — then the reading can tell a deliberate split from
#: a duplicate claim, which is the whole reason the clause exists.
_PART = re.compile(r"(?i)\bpart:[ \t]*\d+[ \t]*/[ \t]*\d+")


def _without_code(text: str) -> str:
"""`text` with inline code spans and fenced blocks blanked out, same length."""
masked = _FENCED_BLOCK.sub(lambda m: " " * len(m.group(0)), text)
Expand Down Expand Up @@ -914,11 +906,10 @@ def judge_origins(
pruned the record at all, and whether the citation is newer than every record the ledger
ranks: the fact that decides whether this store could have held the handle, or whether it
was written on the machine that owns the ledger it resolves against.
* `origin-duplicate` — two or more **open** issues declare the same rant and at least
one of them carries no `Part: n/N`. R5's default is one rant, one issue; the split is
legitimate only when every part says which part it is, so an unlabelled one makes the
two indistinguishable from a duplicate claim — which is the reading `--json` and a
reviewer both need before they can tell deliberate from accidental.
* `origin-duplicate` — two or more **open** issues declare the same rant. One rant is one
issue, so every claimant after the first is indistinguishable from a duplicate of it, and
the reading says so about each rather than picking one to spare: folding them into the one
issue that finishes the rant is the remedy, and there is no label that substitutes for it.

The subject is counted by **issue, not by occurrence**: one issue that writes its
origin line twice (a body carrying the line at the top and again in a provenance
Expand All @@ -941,12 +932,10 @@ def judge_origins(
"""
faults: dict[int, tuple[str, str]] = {}
by_ts: dict[str, set[int]] = {}
labelled: dict[int, bool] = {}
store = _ledger_timestamps(rows)
for issue in issues:
number = int(issue["number"])
body = issue.get("body") or ""
labelled[number] = bool(_PART.search(_without_code(body)))
for ts in _origin_lines(body):
by_ts.setdefault(ts, set()).add(number)
if ts in store:
Expand All @@ -965,17 +954,13 @@ def judge_origins(
for ts, numbers in by_ts.items():
if len(numbers) < 2:
continue
unlabelled = sorted(n for n in numbers if not labelled[n])
if not unlabelled:
continue
for number in unlabelled:
for number in sorted(numbers):
others = sorted(n for n in numbers if n != number)
faults[number] = (
"origin-duplicate",
f"{_numbers(numbers)} name the same rant origin `{ts}` and "
f"{_numbers(unlabelled)} carry no `Part: n/N` - one rant is one issue by "
"default, so either fold these into the one issue that finishes the rant, "
"or label each with the part it is (`Part: 1/2`) so a reader can tell a "
"deliberate split from a duplicate claim",
f"{_numbers(numbers)} name the same rant origin `{ts}` - one rant is one "
f"issue, so {_numbers(others)} cannot be told from a duplicate of it. Fold "
"them into the one issue that finishes the rant; no label substitutes for that",
)
return faults

Expand Down
43 changes: 26 additions & 17 deletions tests/test_check_issue_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -2310,13 +2310,14 @@ def test_a_near_match_names_the_ledgers_own_spelling(mod, monkeypatch, capsys, t
assert RANT_TS in detail and "write it verbatim" in detail, detail


def test_two_unlabelled_issues_on_one_origin_are_a_duplicate(
def test_two_issues_on_one_origin_are_a_duplicate(
mod, monkeypatch, capsys, tmp_path
) -> None:
"""One rant, one issue: two unlabelled claims of the same handle cannot be told apart.
"""One rant, one issue: a second claim of the same handle cannot be told from a duplicate.

Both links are clean here, which is the point — without the origin clause this queue
reads `OK`, so the row's state is entirely the origin reading's verdict.
reads `OK`, so the row's state is entirely the origin reading's verdict. Neither row is
spared, because the reading cannot know which of the two a reader meant.
"""
_install(
mod,
Expand All @@ -2329,7 +2330,10 @@ def test_two_unlabelled_issues_on_one_origin_are_a_duplicate(
assert rc == 1, out
for subject in ("#10 issue ORIGIN-DUPLICATE", "#11 issue ORIGIN-DUPLICATE"):
detail = _detail(out, subject)
assert RANT_TS in detail and "Part:" in detail, detail
assert RANT_TS in detail, detail
assert "Fold them into the one issue" in detail, (
"the remedy is folding the claims into one issue, not labelling the parts"
)


def test_one_issue_writing_its_origin_twice_is_not_a_duplicate(
Expand All @@ -2341,8 +2345,8 @@ def test_one_issue_writing_its_origin_twice_is_not_a_duplicate(
section, or because an edit appended where it meant to replace — and that is one issue
declaring one origin. The rule it is measured against says "two or more open issues", so
a single issue can never be a duplicate of itself; grouping by occurrence made this queue
fail with a row reading *"#10 name the same rant origin ... and #10 #10 carry no
`Part: n/N`"*, three wrong numbers in the sentence a reader acts on.
fail with a row that named the same number twice, two wrong numbers in the sentence a
reader acts on.

The queue is otherwise clean (`_linked_issues` holds every link `ok` and the ledger holds
the timestamp), so `OK` here is the whole verdict: nothing but the origin reading can
Expand All @@ -2363,12 +2367,13 @@ def test_one_issue_writing_its_origin_twice_is_not_a_duplicate(
assert "DUPLICATE" not in out, out


def test_a_labelled_split_of_one_rant_is_allowed(mod, monkeypatch, capsys, tmp_path) -> None:
"""R5's escape hatch: a rant carried by two issues, each saying which part it is.
def test_a_labelled_split_of_one_rant_is_a_fault_too(mod, monkeypatch, capsys, tmp_path) -> None:
"""The marker that used to license a split no longer answers the reading.

Without this the reading would forbid the split the design explicitly permits when a
rant's requirements are independent — and a rule that cannot be satisfied is one a cycle
answers by editing the reading instead of the work.
A rant carried by two issues is one defect at either joint — two issues where one was
owed, or one issue owed two PRs later. Labelling the parts was how the old reading told a
deliberate split from a duplicate claim, so with splitting gone the label has no subject
left: the same two issues must fault exactly as the unlabelled pair does.
"""
_install(
mod,
Expand All @@ -2383,17 +2388,19 @@ def test_a_labelled_split_of_one_rant_is_allowed(mod, monkeypatch, capsys, tmp_p

rc, out = _run(mod, capsys, ["--rants", str(_ledger(tmp_path, RANT_TS))])

assert rc == 0, out
assert "OK" in out, out
assert rc == 1, out
for subject in ("#10 issue ORIGIN-DUPLICATE", "#11 issue ORIGIN-DUPLICATE"):
assert subject in out, out


def test_one_labelled_and_one_unlabelled_part_is_still_a_duplicate(
mod, monkeypatch, capsys, tmp_path
) -> None:
"""The escape hatch has to be used by **every** part, not by one of them.
"""A half-labelled pair was the shape that made the old rule depend on the reading order.

Otherwise "labelled" would depend on which issue a reader looked at first, and the
unlabelled one would still be indistinguishable from a duplicate claim.
It faults on both sides now, and the point of keeping the arm is that it holds the same
property for the new rule: neither claimant is excused, so the verdict cannot depend on
which issue a reader reached first.
"""
_install(
mod,
Expand All @@ -2410,7 +2417,9 @@ def test_one_labelled_and_one_unlabelled_part_is_still_a_duplicate(

assert rc == 1, out
assert "#11 issue ORIGIN-DUPLICATE" in out, out
assert "#10 issue ORIGIN-DUPLICATE" not in out, out
assert "#10 issue ORIGIN-DUPLICATE" in out, (
"the labelled half is not spared any more - see the docstring"
)


def test_a_queue_that_declares_no_origin_never_reads_the_ledger(
Expand Down
Loading