@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.
- package/dist/github/scripts/github.js +1 -1
- package/dist/spec/scripts/architecture.js +2 -2
- package/dist/spec/scripts/eslint-config.js +2 -2
- package/dist/spec/scripts/{harness-4hzx0w5b.js → harness-q8q5bvat.js} +39 -3
- package/dist/spec/scripts/{harness-bysg15km.js → harness-xqp7tdyr.js} +2 -0
- package/dist/spec/scripts/harness.js +1 -1
- package/package.json +2 -2
- package/skills/github/locales/en.json +1 -0
- package/skills/github/locales/ru.json +1 -0
- package/skills/github/scripts/locale.ts +1 -0
- package/skills/spec/SKILL.md +1 -1
- package/skills/spec/canon.md +2 -2
- package/skills/spec/checks.md +18 -9
- package/skills/spec/migration.md +9 -2
- package/skills/spec/scripts/eslint-config.ts +9 -7
- package/skills/spec/scripts/harness.ts +45 -5
- package/skills/spec/scripts/spec-claims.ts +23 -4
- package/skills/spec/scripts/spec-diff.ts +109 -35
|
@@ -2,10 +2,10 @@ import {
|
|
|
2
2
|
invariant2,
|
|
3
3
|
codeOnly2,
|
|
4
4
|
envNamesInCode2
|
|
5
|
-
} from "./harness-
|
|
5
|
+
} from "./harness-q8q5bvat.js";
|
|
6
6
|
import {
|
|
7
7
|
locale_default
|
|
8
|
-
} from "./harness-
|
|
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 =
|
|
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.
|
|
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-
|
|
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
|
|
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
|
|
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})",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miroshnik/elves",
|
|
3
|
-
"version": "2026.1026.
|
|
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": "
|
|
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})",
|
package/skills/spec/SKILL.md
CHANGED
|
@@ -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`,
|
|
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`;
|
package/skills/spec/canon.md
CHANGED
|
@@ -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
|
|
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
|
package/skills/spec/checks.md
CHANGED
|
@@ -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/<
|
|
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`
|
|
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
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
package/skills/spec/migration.md
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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.
|
|
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`
|
|
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
|
|
111
|
-
* `tests/standards/
|
|
112
|
-
* `
|
|
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
|
-
|
|
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
|
|
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
|
|
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"
|
|
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.
|
|
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}/${
|
|
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
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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,
|
|
442
|
-
return folder === null || section === null ||
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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` —
|
|
834
|
-
// section
|
|
835
|
-
|
|
836
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1194
|
-
|
|
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
|
|
1272
|
+
const status = changedFiles(
|
|
1205
1273
|
v.worktree
|
|
1206
|
-
? gitText(["diff", "--name-
|
|
1207
|
-
: gitText(["diff", "--name-
|
|
1208
|
-
)
|
|
1209
|
-
|
|
1210
|
-
|
|
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);
|