Skip to content
Open
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
21 changes: 21 additions & 0 deletions changelog.d/1014-vacuity-moved-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
### Guardrail changes: what each one does

- **Code a PR moves without git pairing it no longer lets a test of the moved code merge the PR (#1014).** Before, a file renamed and re-indented or edited, a function split out into another file or moved within its file, or moved code re-wrapped, read as added code: a test that ran the moved code merged the PR even when its real change was untested.
- The vacuity gate now also measures each file with every top-level statement the PR moved whole (a function, a class, a declaration, an `exports.x =`) measured against its source, from any app file at the base, a deleted one included. It pairs a statement first by its code, then by its declared name, the closest same-named pair first, so a same-named statement elsewhere never takes another's source. Each same-named pair is compared on its own, by character or, too large for that, by code token (an edited token costing only the characters it changes), however large the rest of the PR, and reading only code either way (formatting, comments and line breaks never decide which source it takes); two too large to compare even by token (what they don't share at their start and end is more than about 2,000 code tokens a side), or with one run of edited tokens too large to compare by character (more than about 2,000 code characters a side between two tokens they keep), make the PR wait, with the reason named; such a run is never weighed by its length. A file counts only where it counts both ways, so the pairing only ever takes measure away: a dead copy of an edited line's old text still never makes the edit count.
- Only formatting is no change in a moved statement: whitespace, semicolons, a comma before a closing bracket, and parentheses in two positions:
- around an arrow function's one parameter (`(u) =>`);
- around one expression with no `,` or `;` of its own that doesn't start with `{`, opened right after an assignment's `=`, `=>`, `return`, `throw`, `(`, `[`, `,` or `:`, or after `??` when the expression holds no `?`, `:`, `|`, `&` or `=` outside brackets, and closed right before `;`, `,`, `)`, `]`, `}` or the end of the file.
Every other parenthesis counts as code.
- A statement git pairs in place with a removed statement of the same name (or, unnamed, an unnamed one) that it keeps most of (a similarity of at least 0.8 by code characters) was edited where it was: it pairs with no other statement and is measured as git measured it, even when the PR removes a copy of its new text elsewhere. One that keeps less of it took that statement's place (a stale same-named function, every code line of it replaced by the one moved there) and pairs like any other. So does one the gate can't align with it, each pair on its own (what the two don't share at their start and end is more than about 2,000 code characters a side): it never counts as edited in place.
- A statement runs to its end, across a line its formatter wrapped. A line starting with `export` or `import` always starts a statement, and so does one starting with a keyword no expression goes on with (`const`, `let`, `var`, `enum`, `interface`, `declare`, `abstract`, `namespace`), whatever the line before ends with. A closing `}` that git keeps between a moved statement and the code written in its place doesn't stop the pairing.
- Every changed hunk, moved or not, is now aligned code only against code and comment only against comment. Before, a statement that dropped its comments as its untaken branch was edited could have its code aligned with the comment text, and a test that ran the statement merged the PR.
- Lines swapped inside a function that stays are not a move: reordering is a change, and a test that runs it counts as before.
- **Limit:** these still read as added code, as before:
- a statement renamed as it moved;
- a statement moved inside one that stays (a method within its class, a statement into another function);
- a statement re-wrapped with parentheses in any other position (`(a && b) || c`, `(x) ? y : z`, `? (y) :`, an IIFE written `}())`): those parentheses count as code;
- a statement that keeps its name but is rewritten too far to align with its old text: in a large statement (150 lines) two replaced lines are enough, and so is re-wrapping a 120-line statement on every line, or joining pairs of its lines, even with no other change;
- in a file whose brackets the gate's lexer can't balance, every statement that doesn't start on an `export` or `import` line (an `exports.x =` in CommonJS, say);
- a statement moved within its file where git pairs it with its own old text (moved below a neighbour it was above, both changed): it is measured as git measured it, so re-formatting it counts;
- a statement moved into a file in place of a stale same-named one whose first line is the same (`export function g(u) {` over `export function g(u) {`): git keeps that line, so the moved statement isn't wholly added and isn't paired.
- **Limit:** a statement rewritten in place with every code line of it new, to the exact text of a copy the same PR removes elsewhere, is paired with the copy as moved from there when it keeps less than that similarity of its old text, or is too large to align with it: a test that runs only it holds the PR, where before it merged. Where git keeps a line of it, it isn't paired and merges as before.
4 changes: 2 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,9 @@ That command mounts your Claude login. State and slot locks use the same named l
- `land_mode` is `pr` (the default) or `direct`. `direct` needs branch protection on the default branch; without it, the coordinator refuses to start. `direct` is outside the merge guarantees: a change lands after its tests and secret scan, without the merge decision. The policy is that it stays an explicit opt-in by the owner, on a single machine only.
- `protection.strict` is `false` (the default). Set it to `true` only to opt this repo in to strict.
2. In **`.worklane/tests.yaml`**, `runner.changed`, `runner.full` and `runner.one` are your real test commands, and `vacuity.test_globs` matches your test files.
- With a `vacuity` section, the merge gate holds any PR that changes app code (`vacuity.app_paths`) unless its own tests (the test files it adds or changes, and the `test:` files its task names) call an assertion and, run under Node's V8 coverage in a scratch worktree, run code the PR changed in at least one of its app files more often than loading them does. A change is measured by its characters, from git's histogram diff whatever your git config says: each changed hunk is aligned character by character (when it is large, its lines are paired by their code first), so code added in an untaken branch or the other arm of a `?:` on a changed line doesn't count, nor does a reflowed line. Code taken away counts only when the code on both its sides ran and lies in the same V8 block range in every test process that ran the file. It never counts when it crosses a block or clause boundary (a `case`, an `else`, a brace, `?:`, `||`, `&&`), holds a sub-statement or nested function (`if`, `for`, `while`, `do`, `with`, `=>`, `function`, `class`, `switch`, `try`), or was the last statement of a block or `case`: such a removal waits for you, even when a test does reach it. Lines removed are read as code or comment in the file they came from, so an edited comment line, JSDoc included, is no code change; unchanged lines a comment opened in one hunk turns to comment count as code taken away, and the other way round as code added. A change too large to align even line by line, or a large hunk whose changed lines don't pair, waits for you. Where V8 makes no block range of its own, a change counts when the code around it ran: an optional call's arguments, a default parameter, a destructuring default, the right side of `??=`, `||=` or `&&=`, and code in a string (`new Function('…')`); built code (`dist/`) is placed only by line, through its map, and code removed from it never counts. An import of an assertion module is not an assertion.
- With a `vacuity` section, the merge gate holds any PR that changes app code (`vacuity.app_paths`) unless its own tests (the test files it adds or changes, and the `test:` files its task names) call an assertion and, run under Node's V8 coverage in a scratch worktree, run code the PR changed in at least one of its app files more often than loading them does. A change is measured by its characters, from git's histogram diff whatever your git config says: each changed hunk is aligned character by character, code only against code and comment only against comment (when it is large, its lines are paired by their code first), so comments a change drops never stand in for code it changed, and code added in an untaken branch or the other arm of a `?:` on a changed line doesn't count, nor does a reflowed line. Code taken away counts only when the code on both its sides ran and lies in the same V8 block range in every test process that ran the file. It never counts when it crosses a block or clause boundary (a `case`, an `else`, a brace, `?:`, `||`, `&&`), holds a sub-statement or nested function (`if`, `for`, `while`, `do`, `with`, `=>`, `function`, `class`, `switch`, `try`), or was the last statement of a block or `case`: such a removal waits for you, even when a test does reach it. Lines removed are read as code or comment in the file they came from, so an edited comment line, JSDoc included, is no code change; unchanged lines a comment opened in one hunk turns to comment count as code taken away, and the other way round as code added. A change too large to align even line by line, or a large hunk whose changed lines don't pair, waits for you. Where V8 makes no block range of its own, a change counts when the code around it ran: an optional call's arguments, a default parameter, a destructuring default, the right side of `??=`, `||=` or `&&=`, and code in a string (`new Function('…')`); built code (`dist/`) is placed only by line, through its map, and code removed from it never counts. An import of an assertion module is not an assertion.
- Each test runs twice: as `runner.one` runs it, then with every test skipped (`--test-skip-pattern=.`), so only the test file and what it imports load. Then every module the tests loaded more often than that run did (an import inside a test's body, a copy with a `?query`, a module such a module imports) is loaded on its own, and its coverage is taken away as many times as the extra loads. Only code that ran more often in the first run than all that counts. Those loads share one time budget (120 s); past it, or if a module can't be loaded on its own, the PR waits for you. So what loading a module runs doesn't count, even in a file the PR adds and however a test's body imports it: its top level, a class's static fields, a helper it calls as it loads (`const T = build()`, tsc's `__decorate`, esbuild's `__name`), and a call at the test file's own top level, outside any test. Limits: work a module starts as it loads and that keeps running (a timer, an `async_hooks` or exit listener) runs more in the first run, as the tests do things or wait, and that counts; and code after a call that threw counts as run.
- The PR's lines are measured from where its branch meets your default branch's tip, fetched from the remote for the decision, so a merge of that branch into the PR, or a rebase onto it, never counts as the PR's change. If that fetch fails, or a merge brings in another branch, it waits for you, with the reason named. A renamed file's lines are the ones its rename changed, when git pairs the rename. Code moved in a way git doesn't pair (a file renamed and re-indented or edited, a function moved into another file or within its file, moved code re-wrapped) reads as added code, so a test that runs the moved code merges the PR even when its real change is untested: review such PRs yourself. A PR that deletes an app file, or renames one out of app code (to a data file, a test path, or out of `app_paths`), leaves no app code to measure, so it merges with no test and no run: review those yourself too. In a repo whose tests run built code (`dist/`), a PR that only removes code from app files it keeps waits for you. Blank lines, whitespace-only changes and comments are not changed code: a PR that changes nothing else in its app files waits for you.
- The PR's lines are measured from where its branch meets your default branch's tip, fetched from the remote for the decision, so a merge of that branch into the PR, or a rebase onto it, never counts as the PR's change. If that fetch fails, or a merge brings in another branch, it waits for you, with the reason named. A renamed file's lines are the ones its rename changed, when git pairs the rename. Code moved in a way git doesn't pair (a file renamed and re-indented or edited, a function moved into another file or within its file, moved code re-wrapped) is measured as if git had paired it, when what moved is a whole top-level statement (a function, a class, a declaration, an `exports.x =`) that keeps its code or its name (the closest same-named pair first, each pair compared on its own, by character or, too large for that, by code token (an edited token costing only the characters it changes), reading only code either way, so formatting, comments and line breaks never decide which source it takes; two too large to compare even by token (what they don't share at their start and end is more than about 2,000 code tokens a side), or with one run of edited tokens too large to compare by character (more than about 2,000 code characters a side between two tokens they keep), make the PR wait, with the reason named; such a run is never weighed by its length). In moved code only formatting is no change: whitespace, semicolons, a comma before a closing bracket, and parentheses in two positions: around an arrow function's one parameter, and around one expression with no `,` or `;` of its own (not starting with `{`) opened right after an assignment's `=`, `=>`, `return`, `throw`, `(`, `[`, `,` or `:`, or after `??` when the expression holds no `?`, `:`, `|`, `&` or `=` outside brackets, and closed right before `;`, `,`, `)`, `]`, `}` or the end. Every other parenthesis counts as code, so a moved statement re-wrapped with parentheses anywhere else (`(a && b) || c`, `? (y) :`) reads as added code, as before. A statement edited in place (git pairs it with a removed statement of the same name (or, unnamed, an unnamed one) that it keeps at least 0.8 of by code-character similarity) is measured as git measured it, never paired with other code, even a copy of its new text the PR removes elsewhere; one that keeps less, or that the gate can't align with that statement (what the two don't share at their start and end is more than about 2,000 code characters a side, each pair on its own), took that statement's place and pairs like any moved statement. So a statement moved within its file that git pairs with its own old text is measured as git measured it (its re-formatting counts); one moved into a file over a stale same-named one with the same first line isn't paired, since git keeps that line; and one rewritten in place with every code line new, past that similarity or too large to align, to the text of a copy the PR removes elsewhere is read as moved from there: a test that runs only it holds the PR. Some moved code still reads as added code, so a test that runs it merges the PR even when its real change is untested: a statement renamed as it moved, one moved inside a statement that stays (a method within its class, a statement into another function), one that keeps its name but is rewritten too far to align with its old text (in a 150-line statement, two replaced lines are enough; so is a 120-line statement re-wrapped on every line, or with pairs of its lines joined, even with no other change), and, in a file whose brackets the gate's lexer can't balance (a `/` it can't tell is a division or a regular expression), any statement that doesn't start on an `export` or `import` line. Review such PRs yourself. A PR that deletes an app file, or renames one out of app code (to a data file, a test path, or out of `app_paths`), leaves no app code to measure, so it merges with no test and no run: review those yourself too. In a repo whose tests run built code (`dist/`), a PR that only removes code from app files it keeps waits for you. Blank lines, whitespace-only changes and comments are not changed code: a PR that changes nothing else in its app files waits for you.
- Files with no runtime code don't need a test: types-only (`.d.ts`), and config, data, docs and assets (`.json`, `.yaml`, `.toml`, `.md`, `.css`, images). Any other kind counts as code.
- The run needs `vacuity.dynamic: true` and `runner.one` set to `node --test [options] {file}`, with only the test runner's own options: `--test-reporter=` a built-in reporter (`spec`, `tap`, `dot`, `junit`, `lcov`), `--test-reporter-destination=`, `--test-timeout=`, `--test-concurrency=`, `--test-name-pattern=`, `--test-skip-pattern=`, `--experimental-test-isolation=` (`--test-isolation=` on Node 23 and later), `--test-shard=`, `--test-force-exit`, `--test-only`, `--experimental-strip-types` and `--no-warnings`. Any other Node option can load code before the tests (`--import`, `--require`, `--env-file`, a config file) or change what runs, so it waits for you. The run never takes the engine's own `NODE_OPTIONS`. `worklane init` turns it on when your tests run with `node --test`. Other runners (tsx, vitest, jest) transform each module as it loads, so the code that runs is not the file's text and counts for nothing: with one of those, `dynamic` stays off, `worklane doctor` warns, and app-code PRs wait for you. JavaScript is measured as it is. TypeScript is measured under Node's own type stripping, which keeps every offset. A test that runs a build (`dist/`) counts for the source lines its source map (`<file>.js.map` beside it) maps the code that ran to; a bundle's other sources don't count. Every other case waits for you, with the reason named: `dynamic: false`, another runner, no test, no assertion, a test path that isn't plain (letters, digits, `.`, `_`, `-`, `/`), nothing to measure, coverage it can't map, a failed run, or one that never finishes.
- Without a `vacuity` section, every PR that changes code (any file but tests, docs, config, data and assets) waits for you too.
Expand Down
Loading
Loading