Skip to content

fix(engine): keep a lower layer's rename when a higher layer adds to the label by its new name - #597

Merged
Vivswan merged 3 commits into
mainfrom
fix/engine-rename-fold
Nov 12, 2026
Merged

Vivswan merged 3 commits into
mainfrom
fix/engine-rename-fold

Conversation

@Vivswan

@Vivswan Vivswan commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Before

fleet.yml (lower)   {name: bug, new_name: defect, color: d73a4a}
repo.yml  (higher)  {name: defect, description: a defect}

render, deep -> pair found through new_name -> the higher name wins -> a label renaming "defect" to itself
check        -> no entry claims "bug"       -> delete "bug", create "defect"
== mode: render, layering: deep, layers: fleet.yml < repo.yml (exit 0, result: rendered)
labels:
  _undeclared: delete
  entries:
    - name: defect
      color: d73a4a
      description: a defect
      new_name: defect

== mode: check on the rendered file, the repository holds "bug" (exit 1, result: drift)
drift: labels[bug]: undeclared - not in the settings file, so apply will DELETE it; add it to the settings file to keep it
drift: labels[defect]: missing - declared in the settings file but not on the repo; apply will create it
-- writes: none

== mode: apply on the rendered file, the repository holds "bug" (exit 0, result: applied)
labels: DELETED undeclared label "bug"
labels: created label "defect"
-- writes: 
   DELETE /repos/{repo}/labels/bug null
   POST /repos/{repo}/labels {"name":"defect","color":"d73a4a","description":"a defect"}

After

render, deep -> pair found through new_name -> the lower name is kept -> the rename, plus the description
check        -> the entry still claims "bug" -> one update
== mode: render, layering: deep, layers: fleet.yml < repo.yml (exit 0, result: rendered)
labels:
  _undeclared: delete
  entries:
    - name: bug
      color: d73a4a
      description: a defect
      new_name: defect

== mode: check on the rendered file, the repository holds "bug" (exit 1, result: drift)
drift: labels[bug]: should be named "defect" per the settings file; apply will rename it
drift: labels[defect].description: declared "a defect" != live "Something is not working"; apply will set the declared value
-- writes: none

== mode: apply on the rendered file, the repository holds "bug" (exit 0, result: applied)
labels: updated label "defect"
-- writes: 
   PATCH /repos/{repo}/labels/bug {"new_name":"defect","color":"d73a4a","description":"a defect"}

For the owner: this re-expects a row test/engine/layers.test.ts pinned on purpose, the self-rename {name: defect, new_name: defect}. Planned against a repository that holds bug, that shape deletes the label from every issue, since labels deletes undeclared entries by default.

How

  • Rule: a deep pair is written under the higher entry's key, unless that drops a key the lower entry claimed.
  • Guard: a higher rename to a third name, compared case-folded, stands as written.
  • Reach: only labels claims two keys per entry.
  • Shallow: unchanged, and the page now states that its swap drops the rename.
  • Fuzz oracle: an independent model of the fold, so its rule moves too.

Proof

  • Red, then green: the new plan test, from a delete and a create to one update.
  • Fuzz: seed 29 fails with the old oracle and passes 25/25 with the new one.
  • Gates: typecheck, lint, lint:arch, knip, check:compat, build:check.
Technical details

The captured blocks

  • Script: a scratch script builds a tree's bundle with bun run build:bundle and runs it with INPUT_MODE=render over the two layer files.
  • Check and apply: the rendered file then goes to the e2e runner's parseScenario and runScenario, with live_state.labels holding bug.
  • Trees: Before is origin/main at faf72da, After is this branch.
  • Lines shown: the stdout lines that name a label, and under -- writes the mock's request log without its GETs.
  • Control: fleet.yml alone renders {name: bug, new_name: defect} and applies one PATCH /repos/{repo}/labels/bug, on both trees.

Tests

  • Red: bun test test/engine/layers.test.ts -t "still renames the live label" on the unfixed fold gave deleting undeclared label "bug" and creating label "defect" where it expects updating label "defect".
  • Red, control row: the same test's one-layer row passed on the unfixed fold, so the assertion can pass.
  • Green: bun test test/engine test/e2e/generators/oracle.test.ts test/docs gives 930 pass, 0 fail.
  • Re-expected, engine: the row "the plain label above the rename, merged" expected [{name: defect, new_name: defect, color: ffffff}] and now expects [{name: bug, new_name: defect, color: ffffff}].
  • What that row guarded: it arrived with the deep directive (feat(engine)!: layer every list section by key with replace, shallow, and deep #287) in a table that asserts one label the planner creates once over an empty repository, and that assertion still runs for it.
  • Re-expected, oracle: the FOLD_CASES row "a label renaming into a name a higher entry declares is one resource" expected {name: Defect, new_name: defect, color: "222222"} and now expects {name: bug, new_name: defect, color: "222222"}.
  • Why the oracle moves: test/e2e/generators/oracle.ts models the fold in its own code, and that row runs both folds, so the two change together.
  • New row: {name: defect, new_name: flaw} over {name: bug, new_name: defect} stays {name: defect, new_name: flaw}, which pins the guard.

Fuzz

  • Red: FUZZ_SEED=29 bun test/e2e/fuzz.ts --iterations 25 on origin/main plus this branch's src/engine/layers.ts alone prints merge/valid/deep seed 2945009622 FAIL and exits 1.
  • Green: the same command on this branch prints merge/valid/deep ok and 25/25 iterations ok, and exits 0.
  • Breadth: a scratch script replays the layered stacks each soak draws for master seeds 1 to 600 and compares the oracle's fold with the engine's.
  • Breadth, counts: 122 seeds disagree with the old oracle, 0 with the new one, and 0 on origin/main.

Reach, per keyed list

Keyed list Keys one entry claims What the rule does there
labels two when the entry renames (new_name and name) keeps the lower name, as shown above
every other list section one never fires: the merged entry claims the one key both entries share
every nested list (a ruleset's rules, an environment's variables, secrets, branch policies, protection rules, reviewers) one never fires, for the same reason

Other stacks whose result changes

{bug -> defect} stands for {name: bug, new_name: defect}, and < reads "below".

Stack, lowest layer first main This head
{bug -> defect} < {name: defect, new_name: defect} {name: defect, new_name: defect}: deletes bug, creates defect {name: bug, new_name: defect}: one update
{bug -> defect} < {name: defect, new_name: Defect} {name: defect, new_name: Defect}: deletes bug, creates Defect {name: bug, new_name: Defect}: one update
{bug -> defect} < {name: defect, description: d} < {name: bug, _remove: true} refused: labels[0] carries _remove: true, but no lower layer declares an entry under its key no entry left, one removal notice
{bug -> defect} < {name: defect, description: d} < {name: bug, new_name: flaw} two entries: {name: defect, new_name: defect, description: d} and {name: bug, new_name: flaw} one entry: {name: bug, new_name: flaw, description: d}
{bug -> defect} < {name: defect, new_name: flaw} < {name: flaw, color: ffffff} {name: flaw, new_name: flaw, color: ffffff} {name: defect, new_name: flaw, color: ffffff}
{bug -> defect} < {name: defect, description: d} < {name: bug, color: ffffff} two entries: {name: defect, new_name: defect, description: d} and {name: bug, color: ffffff}: creates defect, updates bug one entry: {name: bug, new_name: defect, description: d, color: ffffff}: one update
{bug -> defect} < {name: defect, description: d} < {name: bug, new_name: bug} two entries: {name: defect, new_name: defect, description: d} and {name: bug, new_name: bug}: creates defect one entry: {name: bug, new_name: bug, description: d}: one update
{bug -> defect} < {name: defect, description: d} < {name: flaw, new_name: bug} two entries: {name: defect, new_name: defect, description: d} and {name: flaw, new_name: bug}: creates defect one entry: {name: flaw, new_name: bug, description: d}: one update
  • Reproduced: a scratch script folds each stack under deep with mergeLayers on both trees and plans the result against a repository that holds bug.
  • A third layer that still calls the label bug (the sixth row): main ended with two labels, defect created beside bug, and this head ends with one, bug renamed to defect.
  • The {name: flaw, color: ffffff} row, a repository that holds defect: main deletes defect and creates flaw, and this head plans one update.
  • Rename chain, unchanged: {bug -> defect} < {name: defect, new_name: flaw} renders {name: defect, new_name: flaw} on both revisions, so a repository that still holds bug gets a delete and a create.
  • Spelling: a higher {name: Defect, color: ffffff} over {bug -> defect} rendered name: Defect on main and renders name: bug here, and the page's spelling sentence now states that exception.

Why that table is the whole list

  • Enumeration: the script below folds every stack of two or three one-label layers over bug, defect, Defect, and flaw on both trees, under deep and under shallow.
  • Entries: a layer holds one of 4 plain labels or 16 renames, and above the lowest layer one of 4 removals.
  • Lowest layer: it takes no removal entry, because a removal there has nothing to remove and the fold refuses it.
  • Stack count: 20 x 24 + 20 x 24 x 24 = 12,000.
  • Counts: of those 12,000, shallow differs in 0 and deep in 1,388.
  • 1,144 of the 1,388: the headline pair, or the pair of the first two rows, at the second or third layer, where the two results differ only in one entry's name.
  • The other 244: a third layer that claims the old name, in exactly five forms, which are the removal row, the {name: bug, new_name: flaw} row, and the last three rows.
  • Nothing else: the script labels any other cause OUTSIDE THE TABLE, and it prints no such line.
  • Wider: the 2,400 stacks with a removal in the lowest layer add no difference under either directive.
  • Control: with one tree on both sides the script prints 0 for every difference.
mkdir /repo-main && git -C /repo archive origin/main | tar -x -C /repo-main   # origin/main's tree, beside the checkout
ln -s /repo/node_modules /repo-main/node_modules
bun enumerate.mts /repo-main /repo                                              # origin/main against this head
bun enumerate.mts /repo /repo                                                   # the control
$ bun enumerate.mts /repo-main /repo
no removal in the lowest layer: stacks: 12000
no removal in the lowest layer: shallow results that differ: 0
no removal in the lowest layer: deep results that differ: 1388
a removal in the lowest layer: stacks: 2400
a removal in the lowest layer: shallow results that differ: 0
a removal in the lowest layer: deep results that differ: 0
  44 x a third layer that claims the old name: plain(a)
  44 x a third layer that claims the old name: removal(a)
  60 x a third layer that claims the old name: rename(a->a)
  48 x a third layer that claims the old name: rename(a->c)
  48 x a third layer that claims the old name: rename(c->a)
  1144 x the pair alone: one entry's name differs

$ bun enumerate.mts /repo /repo
no removal in the lowest layer: stacks: 12000
no removal in the lowest layer: shallow results that differ: 0
no removal in the lowest layer: deep results that differ: 0
a removal in the lowest layer: stacks: 2400
a removal in the lowest layer: shallow results that differ: 0
a removal in the lowest layer: deep results that differ: 0
enumerate.mts
// Folds every stack of two or three one-label layers on two trees and sorts the stacks whose result differs.
// Roles in the output: a is the old name of the lower rename, b its target, c any other name.
// usage: bun enumerate.mts <tree A> <tree B>
import { resolve } from "node:path";

const [treeA = "", treeB = ""] = process.argv.slice(2);
const before = await import(resolve(treeA, "src/engine/layers.ts"));
const after = await import(resolve(treeB, "src/engine/layers.ts"));

type Label = { name: string; new_name?: string; _remove?: true; description?: string; color?: string };
const NAMES = ["bug", "defect", "Defect", "flaw"];
const EXTRA: Partial<Label>[] = [{}, { description: "d" }, { color: "ffffff" }];
const fold = (name: string): string => name.toLowerCase();
const claims = (label: Label): string[] => (label.new_name === undefined ? [label.name] : [label.new_name, label.name]).map(fold);

/** A layer's possible entries: 4 plain labels and 16 renames, then 4 removals. */
function entries(layer: number): { declared: Label[]; removals: Label[] } {
  const declared = NAMES.flatMap((name) => [{ name, ...EXTRA[layer] }, ...NAMES.map((target) => ({ name, new_name: target, ...EXTRA[layer] }))]);
  return { declared, removals: NAMES.map((name) => ({ name, _remove: true })) };
}

function folded(tree: typeof before, stack: Label[], layering: string): { text: string; labels: Label[] } {
  const layers = stack.map((label, index) => ({ name: `layer-${index}`, doc: { labels: [label] } }));
  const result = tree.mergeLayers(layers, { layering });
  if (result.isErr()) {
    return { text: `refused at ${result.error.layer}: ${result.error.code}`, labels: [] };
  }
  const labels: Label[] = result.value.settings.labels.entries;
  return { text: `${JSON.stringify(labels)} notices ${result.value.notices.length}`, labels };
}

/** An entry by role: plain(b), rename(a->c), removal(a). */
function inRoles(label: Label, a: string, b: string): string {
  const role = (name: string): string => (fold(name) === a ? "a" : fold(name) === b ? "b" : "c");
  const kind = label._remove === true ? "removal" : label.new_name === undefined ? "plain" : "rename";
  return `${kind}(${role(label.name)}${label.new_name === undefined ? "" : `->${role(label.new_name)}`})`;
}

/** Whether two results hold the same entries but for the `name` of exactly one. */
function oneNameDiffers(x: Label[], y: Label[]): boolean {
  const rest = (label: Label | undefined): string => JSON.stringify({ ...label, name: undefined });
  const renamed = x.filter((label, index) => label.name !== y[index]?.name);
  return x.length === y.length && renamed.length === 1 && x.every((label, index) => rest(label) === rest(y[index]));
}

/** Why a stack's deep result differs: the pair where the trees part, and what a third layer does after it. */
function cause(stack: Label[], x: Label[], y: Label[]): string {
  const [first, second, third] = stack as [Label, Label, Label | undefined];
  const pair = folded(before, [first, second], "deep");
  const partsAtSecond = pair.text !== folded(after, [first, second], "deep").text;
  const higher = partsAtSecond ? second : (third as Label);
  const lower = partsAtSecond ? first : pair.labels.find((label) => claims(label).some((key) => claims(higher).includes(key)));
  if (lower?.new_name === undefined || fold(lower.name) === fold(lower.new_name)) {
    return "OUTSIDE THE TABLE: the lower entry is no rename between two names";
  }
  const [b, a] = claims(lower) as [string, string];
  const meets = inRoles(higher, a, b);
  if (meets !== "plain(b)" && meets !== "rename(b->b)") {
    return `OUTSIDE THE TABLE: the trees part at ${meets}`;
  }
  if (partsAtSecond && third !== undefined && claims(third).includes(a)) {
    return `a third layer that claims the old name: ${inRoles(third, a, b)}`;
  }
  return oneNameDiffers(x, y) ? "the pair alone: one entry's name differs" : "OUTSIDE THE TABLE: more than one name differs";
}

const counts = new Map<string, number>();
const count = (key: string): void => void counts.set(key, (counts.get(key) ?? 0) + 1);
const lowest = entries(0);
for (const first of [...lowest.declared, ...lowest.removals]) {
  const group = first._remove === true ? "a removal in the lowest layer" : "no removal in the lowest layer";
  for (const second of Object.values(entries(1)).flat()) {
    for (const third of [undefined, ...Object.values(entries(2)).flat()]) {
      const stack = third === undefined ? [first, second] : [first, second, third];
      count(`${group}: stacks`);
      if (folded(before, stack, "shallow").text !== folded(after, stack, "shallow").text) {
        count(`${group}: shallow results that differ`);
      }
      const x = folded(before, stack, "deep");
      const y = folded(after, stack, "deep");
      if (x.text !== y.text) {
        count(`${group}: deep results that differ`);
        count(`${group}: deep, ${cause(stack, x.labels, y.labels)}`);
      }
    }
  }
}
for (const group of ["no removal in the lowest layer", "a removal in the lowest layer"]) {
  for (const what of ["stacks", "shallow results that differ", "deep results that differ"]) {
    console.log(`${group}: ${what}: ${counts.get(`${group}: ${what}`) ?? 0}`);
  }
}
for (const [key, total] of [...counts].filter(([key]) => key.includes(": deep, ")).sort()) {
  console.log(`  ${total} x ${key.split(": deep, ")[1]}`);
}

Other pages that state the old rule, left for a docs follow-up

  • docs/reference/architecture.md:171 and docs/upgrading/v2-to-v3/layering.md:180: both say the higher layer's value wins at every depth, with no exception for a label's name.

The rename test table's other rows

  • Stays, shallow (three rows): a swap writes the higher entry as written, which this change does not touch.
  • Stays, the rename above the plain label: the higher rename's two names already keep every key the pair claimed.
  • Stays, a lower rename two higher entries claim: it is not a one-to-one pair, so nothing merges.

The two docs items

  • Reviewers: the page said reviewers is not kept as written, while LISTS_AS_WRITTEN in src/engine/canonical.ts keeps it.
  • Reviewers, settled by history: the PR that wrote that sentence (feat(engine)!: layer environments, branches, and workflows by key through a {_layering, entries} wrapper #293) also added test/e2e/scenarios/render-environments-nested.yml, which pins reviewers in union order, so the page is the side corrected.
  • Refusal row: one new row for a ruleset that lists a bypass actor twice, which test/docs/guides.test.ts reproduces like every row of that table.

Not changed

  • Shallow: the same two layers under layering: shallow render {name: defect, description: a defect} and apply a DELETE of bug and a POST of defect, as on main.
  • Sections: nothing under src/sections/ changes, and the fold still reads a section only through keys() and keyField.

…the label by its new name

Under deep, a higher entry {name: defect, description: a defect} over a lower {name: bug, new_name: defect} pairs with it through the rename target, and the field-by-field merge let the higher name win. The folded entry renamed "defect" to itself and no longer claimed "bug", so a plan against a repository that holds "bug" deleted it as undeclared and created "defect", which takes the label off every issue.

A deep pair is now written under the higher entry's key unless that drops a key the lower entry claimed and the lower entry's key keeps every key the pair claimed. The stack above folds to {name: bug, new_name: defect, description: a defect} and plans one update. A higher entry that itself renames to a different name stands as written, and shallow still swaps the entry as written. Only labels claims two keys per entry, so no other keyed list changes.

The fuzz oracle models the fold independently, so its own statement of the rule moves with the engine's, and the row that pins both folds is re-expected with the engine's row.

docs/operate/layering.md states the rule and what shallow does with the same layers, says an environment's reviewers keep their written order in the rendered file (the canonical order never sorted them), and gains the refusal row for a ruleset that lists a bypass actor twice.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 02:32
@github-actions

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 6 warning(s).

File Size Tier Cap
src/engine/diff.ts:56 11 comment lines warn 10
src/github/api-error.ts:9 11 comment lines warn 10
src/problem.ts:73 18 comment lines warn 10
src/sections/contract/errors.ts:13 11 comment lines warn 10
src/sections/contract/module.ts:55 12 comment lines warn 10
src/sections/shared/list/decl.ts:265 14 comment lines warn 10

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

@github-actions

Copy link
Copy Markdown
Contributor

Member shape check

1 finding(s).

File Line Rule
test/sections/contract/validate-hook.test.ts 80 all-or-none

Add the blank lines, make the comment one line directly above the item, comment every field or none, or list the path in .member-shape-allow.local with a # reason.

4 generated file(s) skipped.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the change preserving label renames across deep layers.

No open issues 🎉

What changed in this PR

This PR preserves a lower layer's label rename when a higher layer adds fields using the new name. Under deep layering, this keeps apply from deleting and recreating the label, preserving its issue assignments.

Changes:

  • Preserve the original label name when needed to retain a paired entry's claimed keys, while respecting higher-layer renames.
  • Update the independent fold oracle and add regression coverage asserting one update instead of a delete and create.
  • Document deep versus shallow rename behavior, correct reviewer ordering guidance, and add a duplicate bypass-actor refusal example.
File Description
test/​engine/​layers.test.ts Tests preserved renames against live labels and higher-layer rename precedence.
test/​e2e/​generators/​oracle.ts Models key-preserving deep merges independently.
test/​e2e/​generators/​oracle.test.ts Updates the expected fold to retain the original label name.
src/​engine/​layers.ts Preserves claimed keys when merging one-to-one deep pairs.
docs/​operate/​layering.md Explains rename behavior, reviewer ordering, and duplicate actor validation.

Review effort: Balanced

…hich keyed lists keep their order

The page stated the exception to the rename rule by spelling, while the fold decides on folded keys: over {name: bug, new_name: defect}, a higher {name: defect, new_name: Defect} claims one name and renders {name: bug, new_name: Defect} under deep. The rule paragraph now says when the lower name is written, the paragraph after the example gives that stack and the rename on to a third name, and the spelling sentence names its one exception.

The canonical-order list and the sentence under it said every keyed list is sorted, directly above the lists kept as written. Both now except those lists.

The comment above mergedPair and the one above the fuzz oracle's helper keep the consequence for the planner and drop the selection the code shows. The oracle's helper is renamed pairKeepingNamesWhenItCan, since its fallback keeps the higher layer's key when no key field keeps every name.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 03:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are recorded for the deep-label layering fix.

No open issues 🎉

Review effort: Balanced

…e rule

Three statements on the page still read as having no exception: the scalar row and the deep row of the rules table, and the sentence that opens the null section. Each now carries one clause naming a label's name and the rename rule. The null section's lead-in sentence moves to its own line, so the paragraph stays at 70 words, and the formatter re-pads the other rows of the table without changing their words.
Copilot AI balanced review requested due to automatic review settings November 12, 2026 03:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are reported for the change preserving label renames during deep layering.

No open issues 🎉

Review effort: Balanced

@Vivswan
Vivswan marked this pull request as ready for review November 12, 2026 03:45
@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Nov 12, 2026
@Vivswan
Vivswan merged commit d3b5eca into main Nov 12, 2026
34 checks passed
@Vivswan
Vivswan deleted the fix/engine-rename-fold branch November 12, 2026 05:51
This was referenced Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants