@miroshnik/elves 2026.1026.0 → 2026.1026.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  locale_default
4
- } from "../../spec/scripts/harness-bysg15km.js";
4
+ } from "../../spec/scripts/harness-xqp7tdyr.js";
5
5
 
6
6
  // skills/github/scripts/github.ts
7
7
  import { spawnSync } from "node:child_process";
@@ -2,10 +2,10 @@ import {
2
2
  invariant2,
3
3
  codeOnly2,
4
4
  envNamesInCode2
5
- } from "./harness-4hzx0w5b.js";
5
+ } from "./harness-q8q5bvat.js";
6
6
  import {
7
7
  locale_default
8
- } from "./harness-bysg15km.js";
8
+ } from "./harness-xqp7tdyr.js";
9
9
 
10
10
  // skills/spec/scripts/architecture.ts
11
11
  import { readdirSync, readFileSync } from "node:fs";
@@ -2,7 +2,7 @@
2
2
  import { readdirSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- var FRAGMENT = new Set(["eslint.ts", "eslint.mts", "eslint.mjs", "eslint.js"]);
5
+ var FRAGMENT = /^(?:[^.]+\.)?eslint\.(?:ts|mts|mjs|js)$/;
6
6
  var SKIP = new Set(["node_modules", ".git"]);
7
7
  var SKIP_REL = new Set(["tests/lib"]);
8
8
  function eslintFragments(root, dirs = ["tests"]) {
@@ -19,7 +19,7 @@ function eslintFragments(root, dirs = ["tests"]) {
19
19
  if (e.isDirectory()) {
20
20
  if (!SKIP.has(e.name) && !SKIP_REL.has(path.relative(root, full).split(path.sep).join("/")))
21
21
  walk(full);
22
- } else if (FRAGMENT.has(e.name))
22
+ } else if (FRAGMENT.test(e.name))
23
23
  out.push(full);
24
24
  }
25
25
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  locale_default
3
- } from "./harness-bysg15km.js";
3
+ } from "./harness-xqp7tdyr.js";
4
4
  var __create = Object.create;
5
5
  var __getProtoOf = Object.getPrototypeOf;
6
6
  var __defProp = Object.defineProperty;
@@ -15282,7 +15282,8 @@ function elementMarks(it, spec, key, tag) {
15282
15282
  const folder = spec.fileOf && callerFolder();
15283
15283
  if (!folder)
15284
15284
  return out;
15285
- const decision = path2.posix.basename(folder.rel);
15285
+ const topic = path2.posix.basename(folder.rel);
15286
+ const section = path2.posix.basename(folder.file).split(".")[0];
15286
15287
  const byFile = new Map;
15287
15288
  for (const item of spec.items) {
15288
15289
  const f = spec.fileOf(item);
@@ -15291,6 +15292,7 @@ function elementMarks(it, spec, key, tag) {
15291
15292
  }
15292
15293
  const shared = [];
15293
15294
  const missing = [];
15295
+ const unknown = [];
15294
15296
  for (const [file, keys] of byFile) {
15295
15297
  let text;
15296
15298
  try {
@@ -15303,7 +15305,18 @@ function elementMarks(it, spec, key, tag) {
15303
15305
  if (!text.includes("spec-"))
15304
15306
  continue;
15305
15307
  const shown = path2.isAbsolute(file) ? shownPath(file) : file;
15306
- const mine = marksIn2(shown, text).filter((m) => m.decision === decision);
15308
+ const all = marksIn2(shown, text);
15309
+ const known = all.some((m) => m.decision !== topic && m.decision !== section) ? decisionNames(folder.base) : null;
15310
+ for (const m of all) {
15311
+ if (!known || known.has(m.decision) || m.decision === topic || m.decision === section)
15312
+ continue;
15313
+ const at = `${m.file}:${m.line}`;
15314
+ if (seen.has(`mark\x00${at}`))
15315
+ continue;
15316
+ seen.add(`mark\x00${at}`);
15317
+ unknown.push(`${at}: spec-${m.kind}(${m.decision}) — no topic or section of tests/ is named «${m.decision}»: a topic's name takes the file out of all its sections, a section's — out of that section's invariants`);
15318
+ }
15319
+ const mine = all.filter((m) => m.decision === topic || m.decision === section);
15307
15320
  out.skipped.push(...mine.filter((m) => m.rule && m.rule !== spec.rule));
15308
15321
  const marks = mine.filter((m) => !m.rule || m.rule === spec.rule);
15309
15322
  if (!marks.length)
@@ -15328,11 +15341,34 @@ function elementMarks(it, spec, key, tag) {
15328
15341
  if (missing.length) {
15329
15342
  it(locale_default.fill(localeNames().spec.harness.elementFiles, { tag }), () => {
15330
15343
  throw new Error(missing.join(`
15344
+ `));
15345
+ });
15346
+ }
15347
+ if (unknown.length) {
15348
+ it(locale_default.fill(localeNames().spec.harness.markNames, { tag }), () => {
15349
+ throw new Error(unknown.join(`
15331
15350
  `));
15332
15351
  });
15333
15352
  }
15334
15353
  return out;
15335
15354
  }
15355
+ var decisionNamesAt = new Map;
15356
+ function decisionNames(base) {
15357
+ let names = decisionNamesAt.get(base);
15358
+ if (names)
15359
+ return names;
15360
+ names = new Set;
15361
+ for (const f of filesUnder("tests", base)) {
15362
+ if (!/\.(?:test|spec|e2e)\.[cm]?[jt]sx?$/.test(f))
15363
+ continue;
15364
+ const parts = f.split("/");
15365
+ if (parts.length > 3)
15366
+ names.add(parts[2]);
15367
+ names.add(path2.posix.basename(f).split(".")[0]);
15368
+ }
15369
+ decisionNamesAt.set(base, names);
15370
+ return names;
15371
+ }
15336
15372
  function invariant2(it, spec) {
15337
15373
  legacyExceptions(it);
15338
15374
  const N = localeNames();
@@ -399,6 +399,7 @@ var en_default = {
399
399
  violator: "violator does not pass{tag}: {name}",
400
400
  oneRecord: "one record per element{tag}",
401
401
  markOneElement: "a mark belongs in one element's file{tag}",
402
+ markNames: "a mark names a topic or its section{tag}",
402
403
  elementFiles: "element files from fileOf exist{tag}",
403
404
  outside: "outside scope{tag}: {item}",
404
405
  exception: "exception{tag}: {item} (#{issue})",
@@ -885,6 +886,7 @@ var ru_default = {
885
886
  violator: "нарушитель не проходит{tag}: {name}",
886
887
  oneRecord: "у элемента одна запись — отметка или exceptions/ и outside{tag}",
887
888
  markOneElement: "отметка — в файле одного элемента{tag}",
889
+ markNames: "отметка называет тему или её раздел{tag}",
888
890
  elementFiles: "файлы элементов из fileOf есть на диске{tag}",
889
891
  outside: "вне охвата{tag}: {item}",
890
892
  exception: "исключение{tag}: {item} (#{issue})",
@@ -23,7 +23,7 @@ import {
23
23
  envNamesInCode2,
24
24
  dotenvNames2,
25
25
  envVars2
26
- } from "./harness-4hzx0w5b.js";
26
+ } from "./harness-q8q5bvat.js";
27
27
  export {
28
28
  ENV_SERVICE2 as ENV_SERVICE,
29
29
  codeFiles2 as codeFiles,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miroshnik/elves",
3
- "version": "2026.1026.0",
3
+ "version": "2026.1026.2",
4
4
  "type": "module",
5
5
  "description": "Elves — a development flow with AI agents: shared rules and skills for any agent; install — bunx @miroshnik/elves install -g; the installer and skill scripts — TypeScript under Bun, tests — bun test in tests/",
6
6
  "repository": {
@@ -72,5 +72,5 @@
72
72
  "eslint-plugin-boundaries": "^7.2.0",
73
73
  "knip": "^6.41.0"
74
74
  },
75
- "gitHead": "c5e6bd32225415d44c4e961766e72a639142fd1e"
75
+ "gitHead": "d4afa46ebc54c4c981f5f22822bebbd9c21e4d95"
76
76
  }
@@ -395,6 +395,7 @@
395
395
  "violator": "violator does not pass{tag}: {name}",
396
396
  "oneRecord": "one record per element{tag}",
397
397
  "markOneElement": "a mark belongs in one element's file{tag}",
398
+ "markNames": "a mark names a topic or its section{tag}",
398
399
  "elementFiles": "element files from fileOf exist{tag}",
399
400
  "outside": "outside scope{tag}: {item}",
400
401
  "exception": "exception{tag}: {item} (#{issue})",
@@ -395,6 +395,7 @@
395
395
  "violator": "нарушитель не проходит{tag}: {name}",
396
396
  "oneRecord": "у элемента одна запись — отметка или exceptions/ и outside{tag}",
397
397
  "markOneElement": "отметка — в файле одного элемента{tag}",
398
+ "markNames": "отметка называет тему или её раздел{tag}",
398
399
  "elementFiles": "файлы элементов из fileOf есть на диске{tag}",
399
400
  "outside": "вне охвата{tag}: {item}",
400
401
  "exception": "исключение{tag}: {item} (#{issue})",
@@ -295,6 +295,7 @@ interface SpecNames {
295
295
  violator: string;
296
296
  oneRecord: string;
297
297
  markOneElement: string;
298
+ markNames: string;
298
299
  elementFiles: string;
299
300
  outside: string;
300
301
  exception: string;
@@ -183,7 +183,7 @@ each one — `checks.md` next to this file:
183
183
 
184
184
  - a convention on every item of a registry from the code — `invariant`
185
185
  (`standards/<name>/`);
186
- - a lint rule — `examples`, `eslintLinter`, the folder's `eslint.ts` fragment
186
+ - a lint rule — `examples`, `eslintLinter`, an `eslint.ts` or `<section>.eslint.ts` fragment
187
187
  collected by `collectEslint`;
188
188
  - out of scope and exceptions — `fileOf`, marks `spec-outside(<decision>)` /
189
189
  `spec-exception(<decision>) #N`, `exceptionsIn()`, `lintExceptions`;
@@ -44,8 +44,8 @@ no decision claims is dead and is deleted.
44
44
  - a topic of one section has the same anatomy — `<topic>.md` and a section
45
45
  with a name of its own; a test file named after its folder
46
46
  (`<name>/<name>.test.ts`) is a decision not regrouped yet;
47
- - the mechanism — in the same folder: an `eslint.ts` fragment, an ast-grep
48
- `rule.yml`. The project's tool configs (`eslint.config.*`, `sgconfig.yml`)
47
+ - the mechanism — in the same folder: an ESLint fragment (the topic's
48
+ `eslint.ts`, a section's `<section>.eslint.ts`), an ast-grep `rule.yml`. The project's tool configs (`eslint.config.*`, `sgconfig.yml`)
49
49
  only collect them from the tree (the harness's `collectEslint`) — a rule
50
50
  has one home;
51
51
  - `exceptions/<section>/` — exceptions for elements without a file of their
@@ -7,8 +7,8 @@ Which check for what:
7
7
  | What to check | With what | Where in `tests/` |
8
8
  |---|---|---|
9
9
  | A convention on every item of a registry from the code ("every mutation writes an audit record") | `invariant` | `standards/<name>/` |
10
- | A lint rule: «not allowed», «allowed», «outside scope» | `examples`, `eslintLinter` | `standards/<name>/` + an `eslint.ts` fragment |
11
- | An ESLint rule in a standard's folder | `collectEslint` in `eslint.config.*` | the tree's `eslint.ts` fragments |
10
+ | A lint rule: «not allowed», «allowed», «outside scope» | `examples`, `eslintLinter` | `standards/<topic>/` + an ESLint fragment (`eslint.ts`, `<section>.eslint.ts`) |
11
+ | An ESLint rule in a standard's folder | `collectEslint` in `eslint.config.*` | the tree's `eslint.ts` and `<section>.eslint.ts` fragments |
12
12
  | Out of scope and an item's exception with its own file | `fileOf` in `invariant`; a mark `spec-outside(<decision>)` / `spec-exception(<decision>) #N` in the file (`marksIn`), in JSON — the value of the `"//"` key | the item's file (a route, a script, a migration, a workspace package's `package.json`) |
13
13
  | Exceptions with a ratchet | `exceptionsIn()` → `exceptions` in `invariant`; a disable `-- #N reason` + `lintExceptions` | the decision folder's `exceptions/<item>.json`; migration — `spec-exceptions` |
14
14
  | Architecture model: module boundaries, directories, packages | `boundariesConfig`, `architecture` | `architecture/model.ts`, `architecture/<name>/` |
@@ -142,7 +142,8 @@ Biome will be added when a project needs them (the core doesn't depend on the
142
142
  linter, `Linter` is an interface with one required method, `lint`).
143
143
 
144
144
  **A rule lives in its standard's folder.** A flat config fragment is
145
- `eslint.ts` next to the test (`export default [{ files: ["src/domain/**"],
145
+ the topic's `eslint.ts` or a section's `<section>.eslint.ts` — each standard
146
+ of a topic keeps its own — next to the test (`export default [{ files: ["src/domain/**"],
146
147
  rules: {…} }]`, paths from the project root); the project's `eslint.config.*`
147
148
  only collects the tree's fragments — `lint` and the editor see the rule as
148
149
  usual:
@@ -168,7 +169,9 @@ export default restrict({ id: "no-alert", selectors: ["CallExpression[callee.nam
168
169
 
169
170
  A fragment is `eslint.ts`, `eslint.mts` (ESM even in a project without
170
171
  `"type": "module"`, without the `MODULE_TYPELESS_PACKAGE_JSON` warning),
171
- `eslint.mjs` or `eslint.js`; `tests/lib` holds helpers, not fragments.
172
+ `eslint.mjs` or `eslint.js`, a section's — the same under its prefix
173
+ (`<section>.eslint.mts`); a knip entry for the fragments —
174
+ `tests/**/*eslint.{ts,mts}`; `tests/lib` holds helpers, not fragments.
172
175
 
173
176
  **Exceptions are explicit, have a task, and go away when no longer needed.**
174
177
 
@@ -220,10 +223,13 @@ A fragment is `eslint.ts`, `eslint.mts` (ESM even in a project without
220
223
  The element is deleted — the mark goes too; two PRs don't edit a shared file.
221
224
  A record in `outside` or `exceptions/` outlived the element: one PR deleted a
222
225
  route, another added it as outside scope — after both merges `main` is red
223
- (measurement #211, three of five breakages). `audit` is the decision folder's
224
- name: a file may be an element of registries in different folders, and the
225
- mark names its own; `audit/cancel` is the folder invariant with that `rule`,
226
- without it — all of the folder's invariants. A mark starts the comment text
226
+ (measurement #211, three of five breakages). `audit` is a topic or its section:
227
+ the topic folder's name takes the file out of all its sections, a section's
228
+ name (`<topic>/audit.test.ts`) — out of that section's invariants only; a
229
+ name no topic or section of `tests/` has — red «a mark names a topic or its
230
+ section» with the mark's location. A file may be an element of registries in
231
+ different folders, and the mark names its own; `audit/cancel` is the
232
+ invariant with that `rule`, without it — all of them. A mark starts the comment text
227
233
  (`//`, `/*`, a block's `*`, `#`, `--`, `<!--`): in JS/TS — only in comments,
228
234
  not in strings; the format in the middle of prose isn't a mark (`marksIn`).
229
235
  JSON has no comments — there the mark is the value of the top-level key `"//"`
@@ -460,7 +466,10 @@ claimed behavior").
460
466
  entry points. The report is `.spec-claims.xml` (JUnit) in the standard's
461
467
  folder `tests/standards/entry-points` (`--standard`): pass it to `spec-doc`
462
468
  together with the runners' reports — the reconciliation gets into the spec
463
- with the standard's `entry-points.md` description.
469
+ with the standard's `entry-points.md` description. A section of a topic — the
470
+ registry `tests/standards/<topic>/<section>.entries.ts`: the report goes to
471
+ `<topic>/<section>.test.ts` by that path (explicit `--standard`, `--section`
472
+ win), the description — `<section>.md`.
464
473
 
465
474
  **Scenarios — `sequence` and `trace`.** How the parts of the system interact in
466
475
  a scenario is a sequence diagram from a capability test's trace, not a drawing.
@@ -143,7 +143,7 @@ a flat decision as a topic of one section. One task, one PR, in this order:
143
143
  `<topic>/<name>.md` (the other `<name>.*` files alongside), another test
144
144
  file of the folder `<name>/<file>.test.ts` → `<topic>/<name>.<file>.test.ts`
145
145
  (by its own name it would be a section of its own), a helper file under the
146
- section's prefix (`<name>.preload.ts`), `<name>/exceptions/*.json` → `<topic>/exceptions/<name>/`, a package
146
+ section's prefix (`<name>.preload.ts`, the ESLint fragment `<name>/eslint.ts` → `<topic>/<name>.eslint.ts`), `<name>/exceptions/*.json` → `<topic>/exceptions/<name>/`, a package
147
147
  subfolder `<name>/<package>/` → `<topic>/<package>/`. The moves go in a
148
148
  commit of their own, without content edits, so history follows the files
149
149
  and `pr labels` reads them as mechanical. Each topic gets `<topic>.md`: its
@@ -154,7 +154,14 @@ a flat decision as a topic of one section. One task, one PR, in this order:
154
154
  and added.
155
155
  3. **Cross-references.** Every path `tests/<root>/<name>/…` — in code, marks,
156
156
  tool configs, CI fragments, docs and the README — is rewritten to the new
157
- one; a search for each former path finds nothing outside the history.
157
+ one; a search for each former path finds nothing outside the history. A
158
+ mark `spec-outside(<name>)` / `spec-exception(<name>) #N` in an element's
159
+ file stays as is: `<name>` is now the section's name, and the mark takes
160
+ the file out of that section's invariants only; `spec-outside(<topic>)` —
161
+ out of all the topic's sections. A mark whose name no topic or section has
162
+ is red «a mark names a topic or its section». The `spec-claims` registry
163
+ moves as `tests/standards/<topic>/<section>.entries.ts`: the report goes
164
+ under that section by the path itself, no `--standard`/`--section`.
158
165
  4. **Labels.** After the merge — `github project check`, then `project fix
159
166
  --confirm`: a label whose name is a section of exactly one topic of the
160
167
  same root is merged into the topic's label on every task, closed ones too,
@@ -1,7 +1,8 @@
1
1
  /**
2
- * eslint-config — assembling ESLint rules from the spec tree: a standard's rule lives as an `eslint.ts` fragment
3
- * (default — a flat config object or array) in its folder, next to the examples and the reason, and the project's
4
- * `eslint.config.*` only collects the fragments — `lint` and the editor see the rule as usual:
2
+ * eslint-config — assembling ESLint rules from the spec tree: a standard's rule lives as a fragment (default — a flat
3
+ * config object or array) in its topic folder, next to the examples and the reason — the topic's `eslint.ts` or a
4
+ * section's `<section>.eslint.ts`, so each standard of a topic keeps its own —, and the project's `eslint.config.*`
5
+ * only collects the fragments — `lint` and the editor see the rule as usual:
5
6
  *
6
7
  * import { collectEslint } from "@miroshnik/elves/spec/eslint-config";
7
8
  * export default [...base, ...(await collectEslint(import.meta.dirname))];
@@ -20,12 +21,13 @@ import { readdirSync } from "node:fs";
20
21
  import path from "node:path";
21
22
  import { pathToFileURL } from "node:url";
22
23
 
23
- const FRAGMENT = new Set(["eslint.ts", "eslint.mts", "eslint.mjs", "eslint.js"]);
24
+ /** A topic's fragment `eslint.ts` or a section's `<section>.eslint.ts` (`.mts`, `.mjs`, `.js` too). */
25
+ const FRAGMENT = /^(?:[^.]+\.)?eslint\.(?:ts|mts|mjs|js)$/;
24
26
  const SKIP = new Set(["node_modules", ".git"]);
25
27
  // test helpers, not the spec: `spec-doc` skips them too
26
28
  const SKIP_REL = new Set(["tests/lib"]);
27
29
 
28
- /** Paths of `eslint.ts` fragments in the directories (by default `tests/`), in order. */
30
+ /** Paths of the topics' `eslint.ts` and the sections' `<section>.eslint.ts` fragments in the directories (by default `tests/`), in order. */
29
31
  export function eslintFragments(root: string, dirs: string[] = ["tests"]): string[] {
30
32
  const out: string[] = [];
31
33
  const walk = (dir: string): void => {
@@ -39,7 +41,7 @@ export function eslintFragments(root: string, dirs: string[] = ["tests"]): strin
39
41
  const full = path.join(dir, e.name);
40
42
  if (e.isDirectory()) {
41
43
  if (!SKIP.has(e.name) && !SKIP_REL.has(path.relative(root, full).split(path.sep).join("/"))) walk(full);
42
- } else if (FRAGMENT.has(e.name)) out.push(full);
44
+ } else if (FRAGMENT.test(e.name)) out.push(full);
43
45
  }
44
46
  };
45
47
  for (const d of dirs) walk(path.join(root, d));
@@ -67,7 +69,7 @@ const SPEC_PLUGIN: { meta: { name: string }; rules: Record<string, object> } = {
67
69
 
68
70
  /**
69
71
  * A standard's ban by AST selectors (as in `no-restricted-syntax`) — as its own rule `spec/<id>` in the shared `spec`
70
- * plugin: bans of different standards on the same files all apply. A standard's `eslint.ts` fragment:
72
+ * plugin: bans of different standards on the same files all apply. A standard's fragment (`eslint.ts`, `<section>.eslint.ts`):
71
73
  *
72
74
  * export default restrict({ id: "no-alert", selectors: ["CallExpression[callee.name='alert']"], message: "…", files: ["src/**"] });
73
75
  */
@@ -107,9 +107,11 @@ interface Outside {
107
107
 
108
108
  /**
109
109
  * A mark in the element's file — a comment, like `eslint-disable` for lint: `spec-outside(<decision>[/<rule>]): reason`
110
- * or `spec-exception(<decision>[/<rule>]) #N: reason`. The decision is the decision folder's name (`audit` —
111
- * `tests/standards/audit`): a file can be an element of registries of different folders, the mark names its own;
112
- * `rule` is the folder's invariant, without it — all of them.
110
+ * or `spec-exception(<decision>[/<rule>]) #N: reason`. The decision is a topic or one of its sections (#745): the topic
111
+ * folder's name (`pages` — `tests/standards/pages`) covers all its sections, a section's name (`routes` —
112
+ * `pages/routes.test.ts`) only the invariants of that section — the former decision's mark keeps working after the
113
+ * regrouping. A file can be an element of registries of different folders, the mark names its own; `rule` is the
114
+ * invariant's, without it — all of them.
113
115
  */
114
116
  export interface Mark {
115
117
  kind: "outside" | "exception";
@@ -447,7 +449,9 @@ function elementMarks<T>(it: It, spec: Invariant<T>, key: (item: T) => string, t
447
449
  const out = { outside: [] as Outside[], exceptions: [] as Exception[], skipped: [] as Mark[] };
448
450
  const folder = spec.fileOf && callerFolder();
449
451
  if (!folder) return out;
450
- const decision = path.posix.basename(folder.rel);
452
+ // a topic's name — all its sections, a section's (the test file's name, as in exceptionsIn) — only its own
453
+ const topic = path.posix.basename(folder.rel);
454
+ const section = path.posix.basename(folder.file).split(".")[0]!;
451
455
  const byFile = new Map<string, string[]>();
452
456
  for (const item of spec.items) {
453
457
  const f = spec.fileOf!(item);
@@ -455,6 +459,7 @@ function elementMarks<T>(it: It, spec: Invariant<T>, key: (item: T) => string, t
455
459
  }
456
460
  const shared: string[] = [];
457
461
  const missing: string[] = [];
462
+ const unknown: string[] = [];
458
463
  for (const [file, keys] of byFile) {
459
464
  let text: string;
460
465
  try {
@@ -467,7 +472,17 @@ function elementMarks<T>(it: It, spec: Invariant<T>, key: (item: T) => string, t
467
472
  if (!text.includes("spec-")) continue;
468
473
  // an absolute path (built from a root) is shown from the repository root, like other hints
469
474
  const shown = path.isAbsolute(file) ? shownPath(file) : file;
470
- const mine = marksIn(shown, text).filter((m) => m.decision === decision);
475
+ const all = marksIn(shown, text);
476
+ // a name no topic or section has — the mark is taken by nobody and the element silently turns red or stays out
477
+ const known = all.some((m) => m.decision !== topic && m.decision !== section) ? decisionNames(folder.base) : null;
478
+ for (const m of all) {
479
+ if (!known || known.has(m.decision) || m.decision === topic || m.decision === section) continue;
480
+ const at = `${m.file}:${m.line}`;
481
+ if (seen.has(`mark\0${at}`)) continue;
482
+ seen.add(`mark\0${at}`);
483
+ unknown.push(`${at}: spec-${m.kind}(${m.decision}) — no topic or section of tests/ is named «${m.decision}»: a topic's name takes the file out of all its sections, a section's — out of that section's invariants`);
484
+ }
485
+ const mine = all.filter((m) => m.decision === topic || m.decision === section);
471
486
  out.skipped.push(...mine.filter((m) => m.rule && m.rule !== spec.rule));
472
487
  const marks = mine.filter((m) => !m.rule || m.rule === spec.rule);
473
488
  if (!marks.length) continue;
@@ -490,9 +505,34 @@ function elementMarks<T>(it: It, spec: Invariant<T>, key: (item: T) => string, t
490
505
  throw new Error(missing.join("\n"));
491
506
  });
492
507
  }
508
+ if (unknown.length) {
509
+ it(locale.fill(localeNames().spec.harness.markNames, { tag }), () => {
510
+ throw new Error(unknown.join("\n"));
511
+ });
512
+ }
493
513
  return out;
494
514
  }
495
515
 
516
+ const decisionNamesAt = new Map<string, Set<string>>();
517
+
518
+ /**
519
+ * Names a mark may give in a project (`base` — the directory with `tests/`): topic folders `tests/<kind>/<name>` and
520
+ * sections — the first part of a test file's name inside them.
521
+ */
522
+ function decisionNames(base: string): Set<string> {
523
+ let names = decisionNamesAt.get(base);
524
+ if (names) return names;
525
+ names = new Set<string>();
526
+ for (const f of filesUnder("tests", base)) {
527
+ if (!/\.(?:test|spec|e2e)\.[cm]?[jt]sx?$/.test(f)) continue;
528
+ const parts = f.split("/");
529
+ if (parts.length > 3) names.add(parts[2]!);
530
+ names.add(path.posix.basename(f).split(".")[0]!);
531
+ }
532
+ decisionNamesAt.set(base, names);
533
+ return names;
534
+ }
535
+
496
536
  /**
497
537
  * Tests of a convention: "registry is not empty" (a typo in a path doesn't pass silently), "violator does not pass"
498
538
  * (the check can fail) and a test per element. No counters in the names: the registry grows — the spec diff shows only
@@ -15,7 +15,8 @@
15
15
  * section — `exceptions/<section>/`), a file per exception (`{ item, issue, reason }`): an entry point without a test,
16
16
  * with a task, until a test appears; once it appears — "remove the exception". The report is JUnit in the standard's
17
17
  * folder (`spec-doc` puts it into the spec with the description from the standard's `<folder>.md`; with `--section` — a
18
- * section of the topic `--standard` (#685), the description from `<section>.md`). Codes: 0 — everything is claimed,
18
+ * section of the topic `--standard` (#685), the description from `<section>.md`; without both flags a registry
19
+ * `tests/standards/<topic>/<section>.entries.ts` names that topic and section, #751). Codes: 0 — everything is claimed,
19
20
  * 1 — there are unclaimed ones or exception errors, 2 — a call error.
20
21
  */
21
22
 
@@ -38,6 +39,7 @@ is claimed, 1 — there are unclaimed ones or exception errors, 2 — a call err
38
39
 
39
40
  Examples:
40
41
  spec-claims --entries tests/standards/entry-points/entries.ts
42
+ spec-claims --entries tests/standards/routes/entry-points.entries.ts # report: tests/standards/routes/entry-points.test.ts
41
43
  spec-claims --entries entries.json --journal .spec-journal --report .spec-claims.xml`;
42
44
  const CAPABILITIES = "tests/capabilities/";
43
45
 
@@ -92,6 +94,23 @@ ${body}
92
94
  `;
93
95
  }
94
96
 
97
+ const DEFAULT_STANDARD = "tests/standards/entry-points";
98
+ /** A registry `tests/standards/<topic>/<section>.entries.<ext>` — its topic and section (#751). */
99
+ const SECTION_ENTRIES = /(?:^|\/)(tests\/standards\/[^/]+)\/([^/]+)\.entries\.(?:[cm]?[jt]s|json)$/;
100
+
101
+ /**
102
+ * Where the report goes: explicit `--standard`/`--section` win; without them a registry
103
+ * `tests/standards/<topic>/<section>.entries.ts` names its topic and section — after regrouping decisions into topics
104
+ * the project moves the registry, and a default path hidden in the script would leave the report under the old decision
105
+ * (#751); otherwise — `tests/standards/entry-points`.
106
+ */
107
+ function reportPlace(entries: string, standard?: string, section?: string): { standard: string; section: string } {
108
+ const m = SECTION_ENTRIES.exec(entries.split(path.sep).join("/"));
109
+ const topic = (standard ?? m?.[1] ?? DEFAULT_STANDARD).replace(/\/+$/, "");
110
+ const name = section ?? (standard === undefined && m ? m[2]! : path.posix.basename(topic));
111
+ return { standard: topic, section: name };
112
+ }
113
+
95
114
  export async function main(argv: string[]): Promise<number> {
96
115
  let opts;
97
116
  try {
@@ -101,7 +120,7 @@ export async function main(argv: string[]): Promise<number> {
101
120
  entries: { type: "string" },
102
121
  journal: { type: "string", default: ".spec-journal" },
103
122
  exceptions: { type: "string" },
104
- standard: { type: "string", default: "tests/standards/entry-points" },
123
+ standard: { type: "string" },
105
124
  section: { type: "string" },
106
125
  report: { type: "string", default: ".spec-claims.xml" },
107
126
  help: { type: "boolean", short: "h", default: false },
@@ -184,9 +203,9 @@ export async function main(argv: string[]): Promise<number> {
184
203
  cases.push({ name: locale.fill(N.spec.harness.exception, { tag: "", item: e.item, issue: e.issue }), failure });
185
204
  }
186
205
 
187
- const standard = v.standard.replace(/\/+$/, "");
206
+ const { standard, section } = reportPlace(v.entries, v.standard, v.section);
188
207
  // a section of a topic (#685) — its file `<section>.test.ts`: spec-doc shows it under the section with `<section>.md`
189
- writeFileSync(v.report, junit(`${standard}/${v.section ?? path.posix.basename(standard)}.test.ts`, N.spec.claims.describe, cases));
208
+ writeFileSync(v.report, junit(`${standard}/${section}.test.ts`, N.spec.claims.describe, cases));
190
209
  const unclaimed = entries.filter((id) => !excepted.has(id) && !claimed(id));
191
210
  const failed = cases.filter((c) => c.failure);
192
211
  console.error(
@@ -203,9 +203,20 @@ export function regroupMap(baseFiles: Iterable<string>, headFiles: Iterable<stri
203
203
  const k = path.posix.join(path.posix.dirname(folder), L.sectionOf(f, flatHead)!);
204
204
  places.set(k, (places.get(k) ?? new Set()).add(`${folder}#${L.sectionOf(f, flatHead)}`));
205
205
  }
206
+ const flatBase = L.unregroupedFolders(baseFiles);
207
+ // the topic kept the former decision's folder name (#743): its own section — the one topic section not named after
208
+ // another former decision of the root
209
+ const own = new Map<string, Set<string>>();
210
+ for (const f of head) {
211
+ const folder = L.decisionFolder(f);
212
+ if (folder === null || !flatBase.has(folder) || flatHead.has(folder) || !L.isTestFile(f)) continue;
213
+ const section = L.sectionOf(f, flatHead)!;
214
+ if (flatBase.has(path.posix.join(path.posix.dirname(folder), section))) continue;
215
+ own.set(folder, (own.get(folder) ?? new Set()).add(`${folder}#${section}`));
216
+ }
206
217
  const out = new Map<string, string>();
207
- for (const folder of L.unregroupedFolders(baseFiles)) {
208
- const to = places.get(folder);
218
+ for (const folder of flatBase) {
219
+ const to = own.get(folder) ?? places.get(folder);
209
220
  if (!flatHead.has(folder) && to?.size === 1) out.set(folder, [...to][0]!);
210
221
  }
211
222
  return out;
@@ -336,6 +347,7 @@ export function diff(
336
347
  baseTests: Test[],
337
348
  headTests: Test[],
338
349
  pool: Test[] = [],
350
+ files: { base: Iterable<string>; head: Iterable<string> } = { base: [], head: [] },
339
351
  ): { removed: Test[]; changed: [Test, Test][]; added: Test[]; moved: Moved[] } {
340
352
  const b = new Map<string, Test>();
341
353
  const h = new Map<string, Test>();
@@ -365,8 +377,10 @@ export function diff(
365
377
  // 0b) regrouping into topics (#685): a test of a decision not regrouped at the base, with the same describe and name
366
378
  // in a section of the same root named after that decision — the section keeps the former name — moved with its file,
367
379
  // not removed and added. An exact match again: a test renamed in the move is honestly "changed" or "added".
368
- const flatBase = L.unregroupedFolders(baseTests.map((t) => t.path));
369
- const flatHead = L.unregroupedFolders(headTests.map((t) => t.path));
380
+ // Not regrouped — by the revision's files, as in regroupMap (#746): a lint standard's folder-named file has only
381
+ // examples(it, …), no static tests
382
+ const flatBase = L.unregroupedFolders([...baseTests.map((t) => t.path), ...files.base]);
383
+ const flatHead = L.unregroupedFolders([...headTests.map((t) => t.path), ...files.head]);
370
384
  const regroupKey = (folder: string, t: Test) => JSON.stringify([path.posix.dirname(folder), path.posix.basename(folder), t.describes, t.name]);
371
385
  const former = new Map<string, Test[]>();
372
386
  for (const r of removed) {
@@ -383,6 +397,13 @@ export function diff(
383
397
  moved.push({ test: a, from: L.folderOf(r), regrouped: true });
384
398
  regrouped.add(a).add(r);
385
399
  }
400
+ // the topic kept the former decision's folder name (#743): its tests are in the same folder, so neither removed nor
401
+ // added, but now in a section of the topic — regrouped into it, not outside the lists
402
+ for (const [k, a] of h) {
403
+ const r = b.get(k);
404
+ const folder = L.decisionFolder(a.path);
405
+ if (r && folder && flatBase.has(folder) && !flatHead.has(folder)) moved.push({ test: a, from: L.folderOf(r), regrouped: true });
406
+ }
386
407
  added = added.filter((a) => !regrouped.has(a));
387
408
  removed.splice(0, removed.length, ...removed.filter((r) => !regrouped.has(r)));
388
409
  // 1) the same file and the same body, unique in the file among the removed and among the new — the test is renamed,
@@ -428,28 +449,41 @@ export function unmovedFiles(outFiles: string[], moved: Moved[], count: Map<stri
428
449
  }
429
450
 
430
451
  const outMark = (t: Test) => (L.classify(t.path)[0] === "out" ? ` ⚠️ ${N.spec.diff.outsideMark}` : "");
431
- // decision folders of not-yet-regrouped decisions (a test file named after the folder) among the base and HEAD tests:
432
- // render sets it — a test of such a folder is in its one section, the folder's own
433
- let UNREGROUPED = new Set<string>();
452
+ /** The revision a test is from: a base test — removed, the old side of a change; the rest — HEAD. */
453
+ type Rev = "base" | "head";
454
+ // decision folders of not-yet-regrouped decisions (a test file named after the folder), each revision its own (#743):
455
+ // a folder flat only at the base is a topic with sections for HEAD tests. `setFlat` sets it — a test of such a folder
456
+ // is in its one section, the folder's own
457
+ const FLAT: Record<Rev, Set<string>> = { base: new Set(), head: new Set() };
458
+
459
+ /** The not-yet-regrouped folders of each revision by its test files: base and HEAD places are counted separately. */
460
+ export function setFlat(files: { base: Iterable<string>; head: Iterable<string> }): void {
461
+ FLAT.base = L.unregroupedFolders(files.base);
462
+ FLAT.head = L.unregroupedFolders(files.head);
463
+ }
434
464
 
435
465
  /**
436
466
  * A test's place in the lists — its folder and, in a topic with sections (#685), the section after `#`, as in the
437
- * spec-doc anchor: `tests/standards/routes#permissions`; the lists go by place.
467
+ * spec-doc anchor: `tests/standards/routes#permissions`; by the flat folders of the test's revision. The lists go by
468
+ * place.
438
469
  */
439
- export function placeOf(t: Test): string {
470
+ export function placeOf(t: Test, rev: Rev = "head"): string {
471
+ const flat = FLAT[rev];
440
472
  const folder = L.decisionFolder(t.path);
441
- const section = L.sectionOf(t.path, UNREGROUPED);
442
- return folder === null || section === null || UNREGROUPED.has(folder) ? L.folderOf(t) : `${L.folderOf(t)}#${section}`;
473
+ const section = L.sectionOf(t.path, flat);
474
+ return folder === null || section === null || flat.has(folder) ? L.folderOf(t) : `${L.folderOf(t)}#${section}`;
443
475
  }
444
476
 
445
477
  /** The lists by place — topic and section; within a place — the order of the difference. */
446
- const byPlace = <T>(list: T[], test: (x: T) => Test): T[] =>
478
+ const byPlace = <T>(list: T[], test: (x: T) => Test, rev: Rev = "head"): T[] =>
447
479
  list
448
- .map((x, i) => ({ x, i, p: placeOf(test(x)) }))
480
+ .map((x, i) => ({ x, i, p: placeOf(test(x), rev) }))
449
481
  .sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : a.i - b.i))
450
482
  .map((e) => e.x);
451
483
 
452
- const entry = (t: Test) => `- \`${placeOf(t)}\` · ${L.titleOf(t)}${outMark(t)}`;
484
+ const entryAt = (t: Test, rev: Rev) => `- \`${placeOf(t, rev)}\` · ${L.titleOf(t)}${outMark(t)}`;
485
+ const entry = (t: Test) => entryAt(t, "head");
486
+ const removedEntry = (t: Test) => entryAt(t, "base");
453
487
 
454
488
  function changedEntry([o, n]: [Test, Test]): string {
455
489
  const md = (parts: string[]) => parts.map(L.mdText).join(" › ");
@@ -634,6 +668,25 @@ function exceptionLine(folder: string, e: ExceptionEntry): string {
634
668
  }
635
669
  const CODE = /\.[cm]?[jt]sx?$/;
636
670
 
671
+ /**
672
+ * `git diff --name-status -M -z`: every path the diff names — both sides of a rename, so the base reads the file at its
673
+ * former path — and the renames, former path → new.
674
+ */
675
+ export function changedFiles(out: string): { paths: string[]; renames: Map<string, string> } {
676
+ const f = out.split("\0");
677
+ const paths: string[] = [];
678
+ const renames = new Map<string, string>();
679
+ for (let i = 0; i < f.length && f[i]; ) {
680
+ const st = f[i++]!;
681
+ if (st[0] === "R" || st[0] === "C") {
682
+ const [from, to] = [f[i++]!, f[i++]!];
683
+ paths.push(from, to);
684
+ if (st[0] === "R") renames.set(from, to);
685
+ } else paths.push(f[i++]!);
686
+ }
687
+ return { paths, renames };
688
+ }
689
+
637
690
  /** Files at a revision (null — the working tree) by paths from the git root: path without the prefix → text. */
638
691
  function readFiles(rev: string | null, top: string, prefix: string, paths: string[]): Map<string, string> {
639
692
  if (rev !== null) return filesAtRev(rev, top, prefix, paths);
@@ -660,6 +713,7 @@ async function decisionsAt(
660
713
  checks: Check[],
661
714
  place: (file: string) => string,
662
715
  regrouped: ReadonlyMap<string, string> = new Map(),
716
+ movedCode: ReadonlyMap<string, string> = new Map(),
663
717
  ): Promise<Decisions> {
664
718
  const listed = rev !== null ? gitText(["ls-tree", "-r", "--name-only", "-z", rev, "--", prefix + L.TESTS], top).split("\0") : [];
665
719
  const excPaths =
@@ -687,7 +741,8 @@ async function decisionsAt(
687
741
  const rel = p.slice(prefix.length);
688
742
  const text = files.get(rel);
689
743
  if (!text || !text.includes("eslint-disable")) continue;
690
- for (const d of disablesIn(rel, text)) exceptions.push(`\`${rel}\` · ${d.rules.join(", ") || N.spec.lint.allRules} — ${d.description || N.spec.lint.noReason}`);
744
+ const named = movedCode.get(rel) ?? rel;
745
+ for (const d of disablesIn(rel, text)) exceptions.push(`\`${named}\` · ${d.rules.join(", ") || N.spec.lint.allRules} — ${d.description || N.spec.lint.noReason}`);
691
746
  }
692
747
  return { model: modelFacts(model), exceptions: exceptions.sort(), checks: checkFacts(checks, place) };
693
748
  }
@@ -767,14 +822,14 @@ export const SUMMARY_LIMIT = 100;
767
822
  function summaryLines(removed: Test[], changed: [Test, Test][], added: Test[], limit: number, harness?: Set<Test>): string[] {
768
823
  type Row = { removed: number; changed: number; added: number; harness: number };
769
824
  const rows = new Map<string, Row>();
770
- const row = (t: Test) => {
771
- const f = placeOf(t);
825
+ const row = (t: Test, rev: Rev) => {
826
+ const f = placeOf(t, rev);
772
827
  if (!rows.has(f)) rows.set(f, { removed: 0, changed: 0, added: 0, harness: 0 });
773
828
  return rows.get(f)!;
774
829
  };
775
830
  const count = (ts: Test[], k: "removed" | "changed" | "added") => {
776
831
  for (const t of ts) {
777
- const r = row(t);
832
+ const r = row(t, k === "removed" ? "base" : "head");
778
833
  r[k]++;
779
834
  if (harness?.has(t)) r.harness++;
780
835
  }
@@ -828,12 +883,24 @@ export function render(
828
883
  added: Test[],
829
884
  outFiles: string[],
830
885
  moved: Moved[] = [],
831
- opts: { limit?: number; harness?: Set<Test>; extra?: Set<Test>; note?: string; notStatements?: number; files?: Iterable<string> } = {},
886
+ opts: {
887
+ limit?: number;
888
+ harness?: Set<Test>;
889
+ extra?: Set<Test>;
890
+ note?: string;
891
+ notStatements?: number;
892
+ files?: { base: Iterable<string>; head: Iterable<string> };
893
+ } = {},
832
894
  ): string {
833
- // `files` — all test files of the base and HEAD: a folder-named file the PR didn't touch still makes the folder one
834
- // section
835
- UNREGROUPED = L.unregroupedFolders(opts.files ?? [...removed, ...changed.flat(), ...added].map((t) => t.path));
836
- removed = byPlace(removed, (t) => t);
895
+ // `files` — the test files of the base and of HEAD: a folder-named file the PR didn't touch still makes the folder
896
+ // one section
897
+ setFlat(
898
+ opts.files ?? {
899
+ base: [...removed, ...changed.map(([o]) => o)].map((t) => t.path),
900
+ head: [...changed.map(([, n]) => n), ...added, ...moved.map((m) => m.test)].map((t) => t.path),
901
+ },
902
+ );
903
+ removed = byPlace(removed, (t) => t, "base");
837
904
  changed = byPlace(changed, ([, n]) => n);
838
905
  added = byPlace(added, (t) => t);
839
906
  const lines = [N.prSpecSection, "", locale.fill(N.spec.diff.base, { base: baseLabel }), ""];
@@ -847,7 +914,7 @@ export function render(
847
914
  if (summarized(removed, changed, added, limit)) {
848
915
  // a removed test is a removed requirement: named one by one in the PR body while there aren't more of them than
849
916
  // the threshold
850
- if (removed.length && removed.length <= limit) lines.push(`**${N.spec.diff.removed} (${removed.length}):**`, "", ...removed.map(entry), "");
917
+ if (removed.length && removed.length <= limit) lines.push(`**${N.spec.diff.removed} (${removed.length}):**`, "", ...removed.map(removedEntry), "");
851
918
  lines.push(...summaryLines(removed, changed, added, limit, opts.harness));
852
919
  lines.push(...movedLines(moved));
853
920
  if (outFiles.length) lines.push(outsideFilesLine(outFiles), "");
@@ -856,7 +923,7 @@ export function render(
856
923
  }
857
924
  const mark = (t: Test) => (opts.extra?.has(t) ? ` — ${N.spec.diff.extraMark}` : "");
858
925
  const sections: [string, string[]][] = [
859
- [N.spec.diff.removed, removed.map(entry)],
926
+ [N.spec.diff.removed, removed.map(removedEntry)],
860
927
  [N.spec.diff.changed, changed.map((p) => changedEntry(p) + mark(p[1]))],
861
928
  [N.spec.diff.added, added.map((t) => entry(t) + mark(t))],
862
929
  ];
@@ -1144,7 +1211,7 @@ export async function main(argv: string[]): Promise<number> {
1144
1211
  let checks: HarnessDescribe[];
1145
1212
  let sourced: Check[]; // harness calls in the head's sources: their describes aren't "report only"
1146
1213
  let notStatements: number;
1147
- let testFiles: string[]; // test files of the base and HEAD: which folders aren't regrouped yet
1214
+ let testFiles: { base: string[]; head: string[] }; // test files of the base and HEAD: which folders aren't regrouped yet
1148
1215
  try {
1149
1216
  const { top, root, prefix } = findRoot(v.root);
1150
1217
  const base = v["no-merge-base"] ? v.base : gitText(["merge-base", v.base, v.worktree ? "HEAD" : v.head], top).trim();
@@ -1190,8 +1257,9 @@ export async function main(argv: string[]): Promise<number> {
1190
1257
  }
1191
1258
  const changedOut = outOfTreeFiles(base, v.head, top, prefix, v.worktree);
1192
1259
  const { pool, count, gone } = outOfTreePool(base, v.head, top, root, prefix, changedOut, v.worktree);
1193
- ({ removed, changed, added, moved } = diff(baseTests, headTests, pool));
1194
- testFiles = [...baseTests, ...headTests].map((t) => t.path);
1260
+ testFiles = { base: [...baseTests.map((t) => t.path), ...baseFiles.keys()], head: [...headTests.map((t) => t.path), ...headFiles.keys()] };
1261
+ ({ removed, changed, added, moved } = diff(baseTests, headTests, pool, testFiles));
1262
+ setFlat(testFiles); // scenarios are matched by place before render
1195
1263
  // checks by place, as the tests: a decision regrouped into a topic keeps its checks
1196
1264
  const regrouped = regroupMap(baseFiles.keys(), headFiles.keys());
1197
1265
  // the files of a revision with tests or harness calls: a check's file has no static tests
@@ -1201,15 +1269,21 @@ export async function main(argv: string[]): Promise<number> {
1201
1269
  notStatements = newNotStatements([...added, ...changed.map(([, n]) => n)], baseTests, LANG);
1202
1270
  outFiles = unmovedFiles(changedOut, moved, count, gone);
1203
1271
  // lint disables — only in changed code files: a removed or added exception is always in the PR diff
1204
- const changedAll = (
1272
+ const status = changedFiles(
1205
1273
  v.worktree
1206
- ? gitText(["diff", "--name-only", "-z", base, "--", "."], top) + gitText(["ls-files", "--others", "--exclude-standard", "-z", "--", "."], top)
1207
- : gitText(["diff", "--name-only", "-z", base, v.head, "--", "."], top)
1208
- )
1209
- .split("\0")
1210
- .filter((f) => f.startsWith(prefix) && CODE.test(f) && !FLOW_COPY.test(f.slice(prefix.length)));
1274
+ ? gitText(["diff", "--name-status", "-M", "-z", base, "--", "."], top)
1275
+ : gitText(["diff", "--name-status", "-M", "-z", base, v.head, "--", "."], top),
1276
+ );
1277
+ const untracked = v.worktree ? gitText(["ls-files", "--others", "--exclude-standard", "-z", "--", "."], top).split("\0") : [];
1278
+ const changedAll = [...status.paths, ...untracked].filter((f) => f.startsWith(prefix) && CODE.test(f) && !FLOW_COPY.test(f.slice(prefix.length)));
1279
+ // a file regrouped into a topic keeps its lint disables (#746): the base names them by the file's HEAD path
1280
+ const movedCode = new Map<string, string>();
1281
+ for (const [from, to] of status.renames) {
1282
+ const folder = L.decisionFolder(from.slice(prefix.length));
1283
+ if (folder && regrouped.has(folder) && from.startsWith(prefix) && to.startsWith(prefix)) movedCode.set(from.slice(prefix.length), to.slice(prefix.length));
1284
+ }
1211
1285
  decisionData = {
1212
- base: await decisionsAt(base, top, prefix, changedAll, baseScan.checks, basePlace, regrouped),
1286
+ base: await decisionsAt(base, top, prefix, changedAll, baseScan.checks, basePlace, regrouped, movedCode),
1213
1287
  head: await decisionsAt(v.worktree ? null : v.head, top, prefix, changedAll, headScan.checks, headPlace),
1214
1288
  };
1215
1289
  decisions = decisionLines(decisionData.base, decisionData.head, limit);