@miroshnik/elves 2026.1026.1 → 2026.1026.3

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-dgtt7kht.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";
@@ -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();
@@ -15595,9 +15631,10 @@ function testFileAt(stack, root) {
15595
15631
  for (const m of stack.matchAll(STACK_FILE)) {
15596
15632
  const abs = m[1];
15597
15633
  const rel = path2.relative(root, abs).split(path2.sep).join("/");
15598
- if (!rel.startsWith(".."))
15599
- return { rel, abs };
15600
15634
  const i = abs.indexOf("/tests/");
15635
+ const inTests = i >= 0 && !path2.relative(abs.slice(0, i + "/tests".length), root).startsWith("..");
15636
+ if (!rel.startsWith("..") && !inTests)
15637
+ return { rel, abs };
15601
15638
  if (i >= 0)
15602
15639
  return { rel: abs.slice(i + 1), abs };
15603
15640
  }
@@ -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-dgtt7kht.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.1",
3
+ "version": "2026.1026.3",
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": "6c5389f526d10b2b11b78bd6a8bdb6d31a6821a9"
75
+ "gitHead": "dc0511ec6dc2569d601baa3490d19fd386bffd26"
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;
@@ -223,10 +223,13 @@ A fragment is `eslint.ts`, `eslint.mts` (ESM even in a project without
223
223
  The element is deleted — the mark goes too; two PRs don't edit a shared file.
224
224
  A record in `outside` or `exceptions/` outlived the element: one PR deleted a
225
225
  route, another added it as outside scope — after both merges `main` is red
226
- (measurement #211, three of five breakages). `audit` is the decision folder's
227
- name: a file may be an element of registries in different folders, and the
228
- mark names its own; `audit/cancel` is the folder invariant with that `rule`,
229
- 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
230
233
  (`//`, `/*`, a block's `*`, `#`, `--`, `<!--`): in JS/TS — only in comments,
231
234
  not in strings; the format in the middle of prose isn't a mark (`marksIn`).
232
235
  JSON has no comments — there the mark is the value of the top-level key `"//"`
@@ -463,7 +466,10 @@ claimed behavior").
463
466
  entry points. The report is `.spec-claims.xml` (JUnit) in the standard's
464
467
  folder `tests/standards/entry-points` (`--standard`): pass it to `spec-doc`
465
468
  together with the runners' reports — the reconciliation gets into the spec
466
- 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`.
467
473
 
468
474
  **Scenarios — `sequence` and `trace`.** How the parts of the system interact in
469
475
  a scenario is a sequence diagram from a capability test's trace, not a drawing.
@@ -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,
@@ -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
@@ -852,13 +892,18 @@ export function testFileIn(stack: string, root: string): string | null {
852
892
  return testFileAt(stack, root)?.rel ?? null;
853
893
  }
854
894
 
855
- /** The same plus the file's absolute path: `rel` — from the project root, `abs` ends with `rel`. */
895
+ /**
896
+ * The same plus the file's absolute path: `rel` — from the project root, `abs` ends with `rel`. A runner outside the
897
+ * project root (a monorepo package's directory) or inside its `tests/` (a package of tests, #754) — `rel` from the
898
+ * directory with that `tests/`, not from the runner's cwd.
899
+ */
856
900
  function testFileAt(stack: string, root: string): { rel: string; abs: string } | null {
857
901
  for (const m of stack.matchAll(STACK_FILE)) {
858
902
  const abs = m[1]!;
859
903
  const rel = path.relative(root, abs).split(path.sep).join("/");
860
- if (!rel.startsWith("..")) return { rel, abs };
861
904
  const i = abs.indexOf("/tests/");
905
+ const inTests = i >= 0 && !path.relative(abs.slice(0, i + "/tests".length), root).startsWith("..");
906
+ if (!rel.startsWith("..") && !inTests) return { rel, abs };
862
907
  if (i >= 0) return { rel: abs.slice(i + 1), abs };
863
908
  }
864
909
  return null;
@@ -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);