@jterrazz/test 15.3.0 → 16.1.0
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/README.md +120 -427
- package/dist/appium.adapter.js +71 -15
- package/dist/artifacts.js +25 -1735
- package/dist/browser/index.js +304 -233
- package/dist/checker.d.ts +1 -1
- package/dist/checker.js +1103 -97
- package/dist/fixtures.js +110 -0
- package/dist/index.d.ts +956 -971
- package/dist/index.js +2159 -2281
- package/dist/literate.js +1747 -0
- package/dist/match.js +625 -84
- package/dist/oxlint.cjs +3585 -1480
- package/dist/oxlint.d.cts +60 -48
- package/dist/oxlint.d.ts +60 -48
- package/dist/oxlint.js +3585 -1480
- package/dist/playwright.adapter.js +51 -8
- package/dist/{intercept.js → server.js} +2 -2
- package/dist/stream.js +6 -6
- package/dist/{ambiguity.js → substring-warning.js} +106 -7
- package/dist/vitest.d.ts +67 -48
- package/dist/vitest.js +229 -93
- package/docs/01-architecture.md +95 -0
- package/docs/02-developing.md +351 -0
- package/docs/03-testing.md +495 -0
- package/docs/04-operating.md +101 -0
- package/docs/05-module-tests.md +152 -0
- package/docs/06-integration.md +180 -0
- package/docs/07-component.md +246 -0
- package/docs/08-website.md +404 -0
- package/docs/09-mobile.md +270 -0
- package/docs/10-api.md +264 -0
- package/docs/11-jobs.md +157 -0
- package/docs/12-cli.md +677 -0
- package/docs/13-elements.md +242 -0
- package/docs/14-assertions.md +314 -0
- package/docs/15-tokens.md +253 -0
- package/docs/16-contracts.md +438 -0
- package/docs/17-services.md +188 -0
- package/docs/18-conventions.md +305 -0
- package/docs/19-linting.md +510 -0
- package/docs/README.md +36 -0
- package/docs/decisions/001-v9-conventions-on-three-channels.md +50 -0
- package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +49 -0
- package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +84 -0
- package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +81 -0
- package/docs/decisions/005-the-doubles-ladder-is-closed.md +86 -0
- package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +245 -0
- package/docs/decisions/007-compose-mode-leaves-the-package.md +53 -0
- package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +75 -0
- package/docs/decisions/_template.md +22 -0
- package/docs/reference/classes/Matcher.md +120 -0
- package/docs/reference/classes/ProcessHandle.md +368 -0
- package/docs/reference/functions/defineContract.md +39 -0
- package/docs/reference/functions/defineContracts.md +36 -0
- package/docs/reference/functions/disabled.md +28 -0
- package/docs/reference/functions/enabled.md +20 -0
- package/docs/reference/functions/focused.md +26 -0
- package/docs/reference/functions/mockOf.md +79 -0
- package/docs/reference/functions/postgres.md +31 -0
- package/docs/reference/functions/process.md +33 -0
- package/docs/reference/functions/redis.md +28 -0
- package/docs/reference/functions/required.md +41 -0
- package/docs/reference/functions/selected.md +26 -0
- package/docs/reference/functions/sqlite.md +33 -0
- package/docs/reference/functions/testId.md +20 -0
- package/docs/reference/functions/text.md +40 -0
- package/docs/reference/functions/valued.md +28 -0
- package/docs/reference/functions/waitUntil.md +46 -0
- package/docs/reference/functions/within.md +29 -0
- package/docs/reference/index.md +196 -0
- package/docs/reference/interfaces/BaseResult.md +87 -0
- package/docs/reference/interfaces/CallResult.md +160 -0
- package/docs/reference/interfaces/CaptureScope.md +82 -0
- package/docs/reference/interfaces/CliResult.md +253 -0
- package/docs/reference/interfaces/ContainerAccessor.md +166 -0
- package/docs/reference/interfaces/DirectoryAccessor.md +33 -0
- package/docs/reference/interfaces/FetchResult.md +180 -0
- package/docs/reference/interfaces/FilesystemAccessor.md +46 -0
- package/docs/reference/interfaces/HttpResult.md +126 -0
- package/docs/reference/interfaces/JsonAccessor.md +46 -0
- package/docs/reference/interfaces/PageResult.md +356 -0
- package/docs/reference/interfaces/RenderResult.md +72 -0
- package/docs/reference/interfaces/RenderedText.md +110 -0
- package/docs/reference/interfaces/ResponseAccessor.md +47 -0
- package/docs/reference/interfaces/ScreenResult.md +128 -0
- package/docs/reference/interfaces/TableAccessor.md +26 -0
- package/docs/reference/interfaces/TextAccessor.md +96 -0
- package/docs/reference/type-aliases/ApiHandle.md +68 -0
- package/docs/reference/type-aliases/ApiSpecification.md +213 -0
- package/docs/reference/type-aliases/ApiSpecificationOptions.md +68 -0
- package/docs/reference/type-aliases/BrowserConsoleMessage.md +30 -0
- package/docs/reference/type-aliases/BrowserLinkElement.md +50 -0
- package/docs/reference/type-aliases/BrowserMetaElement.md +40 -0
- package/docs/reference/type-aliases/BrowserOpenOptions.md +87 -0
- package/docs/reference/type-aliases/BrowserPage.md +132 -0
- package/docs/reference/type-aliases/BrowserPort.md +51 -0
- package/docs/reference/type-aliases/CliEnv.md +11 -0
- package/docs/reference/type-aliases/CliHandle.md +80 -0
- package/docs/reference/type-aliases/CliInput.md +36 -0
- package/docs/reference/type-aliases/CliOutput.md +46 -0
- package/docs/reference/type-aliases/CliPort.md +58 -0
- package/docs/reference/type-aliases/CliSpecification.md +144 -0
- package/docs/reference/type-aliases/CliSpecificationOptions.md +134 -0
- package/docs/reference/type-aliases/ComponentChain.md +156 -0
- package/docs/reference/type-aliases/ComponentScenario.md +20 -0
- package/docs/reference/type-aliases/ComponentUi.md +47 -0
- package/docs/reference/type-aliases/ComponentVisitor.md +10 -0
- package/docs/reference/type-aliases/ContainerPort.md +113 -0
- package/docs/reference/type-aliases/Contract.md +68 -0
- package/docs/reference/type-aliases/ContractInput.md +13 -0
- package/docs/reference/type-aliases/ContractRequest.md +95 -0
- package/docs/reference/type-aliases/ContractResponder.md +23 -0
- package/docs/reference/type-aliases/ContractResponse.md +78 -0
- package/docs/reference/type-aliases/ContractResponseValue.md +13 -0
- package/docs/reference/type-aliases/Contracts.md +48 -0
- package/docs/reference/type-aliases/DatabaseKeys.md +16 -0
- package/docs/reference/type-aliases/DatabasePort.md +72 -0
- package/docs/reference/type-aliases/DeviceOpenOptions.md +46 -0
- package/docs/reference/type-aliases/DevicePort.md +51 -0
- package/docs/reference/type-aliases/DeviceScreen.md +32 -0
- package/docs/reference/type-aliases/DeviceTimeouts.md +39 -0
- package/docs/reference/type-aliases/DockerSpecConfig.md +43 -0
- package/docs/reference/type-aliases/DomMount.md +21 -0
- package/docs/reference/type-aliases/ElementKind.md +27 -0
- package/docs/reference/type-aliases/ElementMatch.md +74 -0
- package/docs/reference/type-aliases/ElementOptions.md +29 -0
- package/docs/reference/type-aliases/ElementRef.md +128 -0
- package/docs/reference/type-aliases/ExecOptions.md +37 -0
- package/docs/reference/type-aliases/FileAccessor.md +54 -0
- package/docs/reference/type-aliases/HonoApp.md +31 -0
- package/docs/reference/type-aliases/HttpContractFilter.md +49 -0
- package/docs/reference/type-aliases/HttpResponseInit.md +46 -0
- package/docs/reference/type-aliases/HttpStreamInit.md +58 -0
- package/docs/reference/type-aliases/IntegrationHandle.md +46 -0
- package/docs/reference/type-aliases/IntegrationSpecification.md +111 -0
- package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +42 -0
- package/docs/reference/type-aliases/Intercept.md +19 -0
- package/docs/reference/type-aliases/InterceptOptions.md +28 -0
- package/docs/reference/type-aliases/InterceptScope.md +11 -0
- package/docs/reference/type-aliases/IsolationStrategy.md +75 -0
- package/docs/reference/type-aliases/JobHandle.md +34 -0
- package/docs/reference/type-aliases/JobsHandle.md +57 -0
- package/docs/reference/type-aliases/JobsResult.md +16 -0
- package/docs/reference/type-aliases/JobsSpecification.md +94 -0
- package/docs/reference/type-aliases/JobsSpecificationOptions.md +56 -0
- package/docs/reference/type-aliases/LandmarkKind.md +20 -0
- package/docs/reference/type-aliases/LiterateRunFlags.md +27 -0
- package/docs/reference/type-aliases/LiterateServeRegistration.md +70 -0
- package/docs/reference/type-aliases/MatchFixtureOptions.md +27 -0
- package/docs/reference/type-aliases/MatchableRequest.md +61 -0
- package/docs/reference/type-aliases/MatcherKind.md +34 -0
- package/docs/reference/type-aliases/MobileBackendOptions.md +28 -0
- package/docs/reference/type-aliases/MobileElementKind.md +14 -0
- package/docs/reference/type-aliases/MobileElementMatch.md +60 -0
- package/docs/reference/type-aliases/MobileElementRef.md +20 -0
- package/docs/reference/type-aliases/MobileHandle.md +65 -0
- package/docs/reference/type-aliases/MobileScenario.md +20 -0
- package/docs/reference/type-aliases/MobileSpecification.md +51 -0
- package/docs/reference/type-aliases/MobileSpecificationOptions.md +126 -0
- package/docs/reference/type-aliases/MobileVisitor.md +81 -0
- package/docs/reference/type-aliases/MockOfOptions.md +26 -0
- package/docs/reference/type-aliases/MockPort.md +10 -0
- package/docs/reference/type-aliases/PinnedClock.md +10 -0
- package/docs/reference/type-aliases/PostgresOptions.md +32 -0
- package/docs/reference/type-aliases/ProcessOptions.md +112 -0
- package/docs/reference/type-aliases/RedisOptions.md +20 -0
- package/docs/reference/type-aliases/RenderSubject.md +12 -0
- package/docs/reference/type-aliases/ScreenNode.md +67 -0
- package/docs/reference/type-aliases/ServerPort.md +36 -0
- package/docs/reference/type-aliases/ServerResponse.md +46 -0
- package/docs/reference/type-aliases/ServerSpec.md +21 -0
- package/docs/reference/type-aliases/ServiceHandle.md +216 -0
- package/docs/reference/type-aliases/ServiceRecord.md +12 -0
- package/docs/reference/type-aliases/SpecDocument.md +84 -0
- package/docs/reference/type-aliases/SpecEnvToken.md +21 -0
- package/docs/reference/type-aliases/SpecFileAssertion.md +27 -0
- package/docs/reference/type-aliases/SpecFixture.md +30 -0
- package/docs/reference/type-aliases/SpecKind.md +8 -0
- package/docs/reference/type-aliases/SpecRun.md +108 -0
- package/docs/reference/type-aliases/SpecServeEntry.md +40 -0
- package/docs/reference/type-aliases/SpecStream.md +32 -0
- package/docs/reference/type-aliases/SpecificationConfig.md +290 -0
- package/docs/reference/type-aliases/SqliteOptions.md +36 -0
- package/docs/reference/type-aliases/SseEvent.md +58 -0
- package/docs/reference/type-aliases/StreamBody.md +61 -0
- package/docs/reference/type-aliases/TextFilter.md +22 -0
- package/docs/reference/type-aliases/VisitScenario.md +20 -0
- package/docs/reference/type-aliases/Visitor.md +215 -0
- package/docs/reference/type-aliases/WaitUntilOptions.md +46 -0
- package/docs/reference/type-aliases/WebsiteBackendOptions.md +37 -0
- package/docs/reference/type-aliases/WebsiteHandle.md +51 -0
- package/docs/reference/type-aliases/WebsiteSpecification.md +118 -0
- package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +40 -0
- package/docs/reference/variables/anthropic.md +19 -0
- package/docs/reference/variables/banner.md +10 -0
- package/docs/reference/variables/button.md +10 -0
- package/docs/reference/variables/clock.md +16 -0
- package/docs/reference/variables/complementary.md +10 -0
- package/docs/reference/variables/component.md +10 -0
- package/docs/reference/variables/content.md +10 -0
- package/docs/reference/variables/contentinfo.md +10 -0
- package/docs/reference/variables/dialog.md +10 -0
- package/docs/reference/variables/field.md +10 -0
- package/docs/reference/variables/form.md +10 -0
- package/docs/reference/variables/heading.md +10 -0
- package/docs/reference/variables/http.md +40 -0
- package/docs/reference/variables/intercept.md +11 -0
- package/docs/reference/variables/link.md +10 -0
- package/docs/reference/variables/listitem.md +10 -0
- package/docs/reference/variables/main.md +10 -0
- package/docs/reference/variables/match.md +8 -0
- package/docs/reference/variables/navigation.md +10 -0
- package/docs/reference/variables/openai.md +21 -0
- package/docs/reference/variables/option.md +17 -0
- package/docs/reference/variables/region.md +10 -0
- package/docs/reference/variables/row.md +10 -0
- package/docs/reference/variables/search.md +10 -0
- package/docs/reference/variables/specification.md +8 -0
- package/docs/reference/variables/status.md +10 -0
- package/docs/reference/variables/table.md +10 -0
- package/package.json +32 -11
- package/dist/spec-document.js +0 -651
package/dist/checker.js
CHANGED
|
@@ -1,9 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
3
|
-
import {
|
|
2
|
+
import { _ as isSeq, a as DOCUMENT_KEYS, c as SpecSyntaxError, d as readSpecFile, g as isScalar, h as isMap, l as assertedStreams, m as Scalar, o as RUN_KEYS, r as TOKEN_KINDS, s as SPEC_EXTENSION, u as parseSpecDocument, v as renderYamlSource } from "./match.js";
|
|
3
|
+
import { a as GROUND_FIXTURES, i as GROUND_EXPECTED, n as resolveFixtureSource, o as GROUND_REQUESTS, r as GROUND_DIRS, s as GROUND_SEEDS } from "./fixtures.js";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
+
//#region src/lint/fs-cache.ts
|
|
8
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
9
|
+
function kindOf(path) {
|
|
10
|
+
const cached = kinds.get(path);
|
|
11
|
+
if (cached !== void 0) return cached;
|
|
12
|
+
let result;
|
|
13
|
+
try {
|
|
14
|
+
result = statSync(path).isDirectory() ? "dir" : "file";
|
|
15
|
+
} catch {
|
|
16
|
+
result = "missing";
|
|
17
|
+
}
|
|
18
|
+
kinds.set(path, result);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function isFile(path) {
|
|
22
|
+
return kindOf(path) === "file";
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/lint/ast.ts
|
|
26
|
+
/**
|
|
27
|
+
* The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
|
|
28
|
+
* searched no higher than the nearest `package.json`. `undefined` when the file
|
|
29
|
+
* is not under one.
|
|
30
|
+
*
|
|
31
|
+
* One anchor for every specs-aware rule, because two searches are two
|
|
32
|
+
* answers: an innermost match and an outermost one read a nested tree as two
|
|
33
|
+
* different facets, and a bare "is `specs` a segment of this path" test
|
|
34
|
+
* answers about the machine — a checkout living under `~/specs/` would be one
|
|
35
|
+
* whole specs tree, with F2's protection of production code silently off. The
|
|
36
|
+
* package boundary makes the search stop where the project does.
|
|
37
|
+
*/
|
|
38
|
+
function specsAnchor(filename) {
|
|
39
|
+
const relative = [basename(filename)];
|
|
40
|
+
let directory = dirname(filename);
|
|
41
|
+
for (;;) {
|
|
42
|
+
if (basename(directory) === "specs") return {
|
|
43
|
+
directory,
|
|
44
|
+
relative
|
|
45
|
+
};
|
|
46
|
+
if (isFile(join(directory, "package.json"))) return;
|
|
47
|
+
const parent = dirname(directory);
|
|
48
|
+
if (parent === directory) return;
|
|
49
|
+
relative.unshift(basename(directory));
|
|
50
|
+
directory = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Is this file inside a `specs/` tree? — the predicate form of {@link specsAnchor}. */
|
|
54
|
+
function isUnderSpecs(filename) {
|
|
55
|
+
return specsAnchor(filename) !== void 0;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/lint/role.ts
|
|
59
|
+
/**
|
|
60
|
+
* The three suffixes that DECLARE tests, as a name-level predicate.
|
|
61
|
+
*
|
|
62
|
+
* `roleOf` is the answer for a rule handed a file; the passes that WALK a tree
|
|
63
|
+
* only ever hold a name, and this is the same vocabulary for them — one place
|
|
64
|
+
* to read, so a tree walk and a rule never disagree on what a test is.
|
|
65
|
+
*/
|
|
66
|
+
function isTestFileName(name) {
|
|
67
|
+
return name.endsWith(".test.ts") || name.endsWith(".test.tsx") || name.endsWith(".spec.ts");
|
|
68
|
+
}
|
|
69
|
+
/** A test that runs under node's project — a `.test.tsx` renders and is not one. */
|
|
70
|
+
function isNodeTestFileName(name) {
|
|
71
|
+
return name.endsWith(".test.ts") || name.endsWith(".spec.ts");
|
|
72
|
+
}
|
|
73
|
+
/** The nearest package root at or above a directory, when there is one. */
|
|
74
|
+
function packageRootOf(directory) {
|
|
75
|
+
let current = directory;
|
|
76
|
+
for (;;) {
|
|
77
|
+
if (isFile(join(current, "package.json"))) return current;
|
|
78
|
+
const parent = dirname(current);
|
|
79
|
+
if (parent === current) return;
|
|
80
|
+
current = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
7
84
|
//#region src/lint/checker-crossfile.ts
|
|
8
85
|
/**
|
|
9
86
|
* The cross-file checker passes — the analyses oxlint cannot express because
|
|
@@ -159,9 +236,10 @@ function blankComments(text) {
|
|
|
159
236
|
for (let i = 0; i < text.length; i += 1) {
|
|
160
237
|
const char = text[i];
|
|
161
238
|
const next = text[i + 1];
|
|
162
|
-
if (state === "line")
|
|
163
|
-
|
|
164
|
-
|
|
239
|
+
if (state === "line") {
|
|
240
|
+
if (char === "\n") state = "";
|
|
241
|
+
else out[i] = " ";
|
|
242
|
+
} else if (state === "block") {
|
|
165
243
|
if (char === "*" && next === "/") {
|
|
166
244
|
out[i] = " ";
|
|
167
245
|
out[i + 1] = " ";
|
|
@@ -201,12 +279,12 @@ function collectSpecLiterals(path) {
|
|
|
201
279
|
/**
|
|
202
280
|
* Is this a file a leaf's ground may be referenced from?
|
|
203
281
|
*
|
|
204
|
-
*
|
|
205
|
-
* tree — sits in the same `_expected
|
|
206
|
-
* apart.
|
|
282
|
+
* Every test suffix counts, and so does a document: a rendered unit's golden —
|
|
283
|
+
* its ARIA tree — sits in the same `_expected/` as a spec's, read by a file the
|
|
284
|
+
* suffix alone tells apart.
|
|
207
285
|
*/
|
|
208
286
|
function isReferrer(name) {
|
|
209
|
-
return
|
|
287
|
+
return isTestFileName(name) || name.endsWith(".spec.yaml");
|
|
210
288
|
}
|
|
211
289
|
function isDir(path) {
|
|
212
290
|
try {
|
|
@@ -421,7 +499,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
|
|
|
421
499
|
if (model.dockerAware && model.runnerExports.size > 0) dockerSpecs.set(file, model);
|
|
422
500
|
}
|
|
423
501
|
if (dockerSpecs.size === 0) return violations;
|
|
424
|
-
for (const testFile of listFiles(rootDir,
|
|
502
|
+
for (const testFile of listFiles(rootDir, isNodeTestFileName)) {
|
|
425
503
|
const text = readSource(testFile);
|
|
426
504
|
const runners = /* @__PURE__ */ new Set();
|
|
427
505
|
for (const binding of namedImports(text)) {
|
|
@@ -440,7 +518,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
|
|
|
440
518
|
violations.push({
|
|
441
519
|
file: rel,
|
|
442
520
|
line,
|
|
443
|
-
message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/
|
|
521
|
+
message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/19-linting.md)`,
|
|
444
522
|
rule: "b5",
|
|
445
523
|
severity: "error"
|
|
446
524
|
});
|
|
@@ -473,7 +551,7 @@ function checkDatabaseProperty(rootDir) {
|
|
|
473
551
|
if (model.sqlDatabaseCount !== null && model.sqlDatabaseCount >= 1 && model.runnerExports.size > 0) enforced.set(file, model);
|
|
474
552
|
}
|
|
475
553
|
if (enforced.size === 0) return violations;
|
|
476
|
-
for (const testFile of listFiles(rootDir,
|
|
554
|
+
for (const testFile of listFiles(rootDir, isNodeTestFileName)) {
|
|
477
555
|
const text = readSource(testFile);
|
|
478
556
|
const applicable = /* @__PURE__ */ new Set();
|
|
479
557
|
for (const binding of namedImports(text)) {
|
|
@@ -498,14 +576,14 @@ function checkDatabaseProperty(rootDir) {
|
|
|
498
576
|
if (count >= 2 && !hasDatabase) violations.push({
|
|
499
577
|
file: rel,
|
|
500
578
|
line,
|
|
501
|
-
message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/
|
|
579
|
+
message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/19-linting.md)`,
|
|
502
580
|
rule: "a7",
|
|
503
581
|
severity: "error"
|
|
504
582
|
});
|
|
505
583
|
else if (count === 1 && hasDatabase) violations.push({
|
|
506
584
|
file: rel,
|
|
507
585
|
line,
|
|
508
|
-
message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/
|
|
586
|
+
message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/19-linting.md)`,
|
|
509
587
|
rule: "a7",
|
|
510
588
|
severity: "error"
|
|
511
589
|
});
|
|
@@ -527,7 +605,7 @@ function entryReferenced(entry, entryIsDir, literals, patterns = []) {
|
|
|
527
605
|
/** A child named like a ground subdir but actually a fixture container. */
|
|
528
606
|
function isConventionalSubdir(path) {
|
|
529
607
|
if (!isDir(path)) return false;
|
|
530
|
-
if (listFiles(path,
|
|
608
|
+
if (listFiles(path, isTestFileName).length > 0) return false;
|
|
531
609
|
let children;
|
|
532
610
|
try {
|
|
533
611
|
children = readdirSync(path, { withFileTypes: true });
|
|
@@ -555,7 +633,7 @@ function checkDeadFixtures(rootDir) {
|
|
|
555
633
|
violations.push({
|
|
556
634
|
file: rel,
|
|
557
635
|
line: 1,
|
|
558
|
-
message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/
|
|
636
|
+
message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/19-linting.md)`,
|
|
559
637
|
rule: "c9",
|
|
560
638
|
severity: "error"
|
|
561
639
|
});
|
|
@@ -584,7 +662,7 @@ function checkDeadFixtures(rootDir) {
|
|
|
584
662
|
violations.push({
|
|
585
663
|
file: relEntry,
|
|
586
664
|
line: 1,
|
|
587
|
-
message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/
|
|
665
|
+
message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/19-linting.md)`,
|
|
588
666
|
rule: "c9",
|
|
589
667
|
severity
|
|
590
668
|
});
|
|
@@ -610,7 +688,7 @@ function checkPoolFixtures(rootDir) {
|
|
|
610
688
|
violations.push({
|
|
611
689
|
file: relEntry,
|
|
612
690
|
line: 1,
|
|
613
|
-
message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/
|
|
691
|
+
message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot) || basename(specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/19-linting.md)`,
|
|
614
692
|
rule: "c9",
|
|
615
693
|
severity: "error"
|
|
616
694
|
});
|
|
@@ -714,7 +792,7 @@ function checkPoolFixtureSharing(rootDir) {
|
|
|
714
792
|
return {
|
|
715
793
|
file: relEntry,
|
|
716
794
|
line: 1,
|
|
717
|
-
message: `${relEntry}: only ${relLeaf} reaches for this pool fixture — the pool is what SEVERAL leaves share; move it to ${relLeaf}/${GROUND_FIXTURES}/${entry} and reference it as '${entry}' (C14 — see docs/
|
|
795
|
+
message: `${relEntry}: only ${relLeaf} reaches for this pool fixture — the pool is what SEVERAL leaves share; move it to ${relLeaf}/${GROUND_FIXTURES}/${entry} and reference it as '${entry}' (C14 — see docs/19-linting.md, or run the checker with --fix)`,
|
|
718
796
|
rule: "c14",
|
|
719
797
|
severity: "error"
|
|
720
798
|
};
|
|
@@ -768,7 +846,7 @@ function checkLocalFixtureReach(rootDir) {
|
|
|
768
846
|
violations.push({
|
|
769
847
|
file: rel,
|
|
770
848
|
line,
|
|
771
|
-
message: `${rel}:${line}: fixture "${path}" reaches outside ${relative(rootDir, ground)} — a leaf's ground sits beside it; ground several leaves share goes to the $FIXTURES pool at ${relative(rootDir, pool)} (C15 — see docs/
|
|
849
|
+
message: `${rel}:${line}: fixture "${path}" reaches outside ${relative(rootDir, ground)} — a leaf's ground sits beside it; ground several leaves share goes to the $FIXTURES pool at ${relative(rootDir, pool)} (C15 — see docs/19-linting.md)`,
|
|
772
850
|
rule: "c15",
|
|
773
851
|
severity: "error"
|
|
774
852
|
});
|
|
@@ -777,6 +855,439 @@ function checkLocalFixtureReach(rootDir) {
|
|
|
777
855
|
}
|
|
778
856
|
return violations;
|
|
779
857
|
}
|
|
858
|
+
/** The directive, whatever precedes it on the line, and what follows the ids. */
|
|
859
|
+
const DIRECTIVE = /(?<lead>.*?)checker-disable(?:-next-line|-line)\s+(?<rest>.*)$/iu;
|
|
860
|
+
/**
|
|
861
|
+
* A directive is written in a COMMENT — the word inside a string is a test
|
|
862
|
+
* TALKING about suppressions, which is what this package's own suite does.
|
|
863
|
+
*/
|
|
864
|
+
const COMMENT_LEAD = /(?:^|\s)(?:\/\/|\/\*|\*|#)\s*$/u;
|
|
865
|
+
/** The files a directive can be written in — source, documents, goldens. */
|
|
866
|
+
const READABLE = /\.(?:[cm]?[jt]sx?|ya?ml|json|md|sql|http|txt)$/u;
|
|
867
|
+
/** Is this a file a human writes a directive in? */
|
|
868
|
+
function isReadableText(path) {
|
|
869
|
+
return READABLE.test(path);
|
|
870
|
+
}
|
|
871
|
+
/** A reason: the `--` separator with something after it. */
|
|
872
|
+
const REASON = /--\s*\S/u;
|
|
873
|
+
/**
|
|
874
|
+
* J9 — every checker suppression says WHY.
|
|
875
|
+
*
|
|
876
|
+
* A suppression with no reason is a rule turned off by someone who is no longer
|
|
877
|
+
* here: the next reader cannot tell whether the pass was wrong, the shape was
|
|
878
|
+
* exceptional, or it was simply in the way — so the line survives every review
|
|
879
|
+
* and the convention quietly stops holding. The toolchain's own
|
|
880
|
+
* `suppressions-reason` gate reads `oxlint-disable*` and nothing else, which is
|
|
881
|
+
* why this channel carries its own.
|
|
882
|
+
*/
|
|
883
|
+
function checkSuppressionReason(rootDir) {
|
|
884
|
+
const violations = [];
|
|
885
|
+
for (const file of listFiles(rootDir, isReadableText)) {
|
|
886
|
+
const text = readText$1(file);
|
|
887
|
+
if (!text.includes("checker-disable")) continue;
|
|
888
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
889
|
+
const match = DIRECTIVE.exec(line);
|
|
890
|
+
if (match === null || !COMMENT_LEAD.test(match.groups?.lead ?? "") || REASON.test(match.groups?.rest ?? "")) continue;
|
|
891
|
+
const rel = relative(rootDir, file);
|
|
892
|
+
violations.push({
|
|
893
|
+
file: rel,
|
|
894
|
+
line: index + 1,
|
|
895
|
+
message: `${rel}:${index + 1}: say why: \`checker-disable-next-line <id> -- <reason>\` (J9 — see docs/19-linting.md)`,
|
|
896
|
+
rule: "j9-checker-suppression-reason",
|
|
897
|
+
severity: "error"
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return violations;
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/lint/checker-facets.ts
|
|
905
|
+
/**
|
|
906
|
+
* The FACET passes — what a tree says about itself, read from the tree.
|
|
907
|
+
*
|
|
908
|
+
* A statique rule sees one file; these three need the shape around it: which
|
|
909
|
+
* folder a file sits under, what the specification at that folder's root
|
|
910
|
+
* constructs, which ground lives below it, and who reads that ground. The four
|
|
911
|
+
* hold the one law the fork rests on — **folder = constructor** — from both
|
|
912
|
+
* sides: a facet folder has the runner its name promises (C20), a spec under it
|
|
913
|
+
* reaches that runner (C18), and ground one spec alone reads belongs to that
|
|
914
|
+
* spec (C21w).
|
|
915
|
+
*
|
|
916
|
+
* A first-level folder that is NOT a facet is a repository suite — it covers a
|
|
917
|
+
* tree rather than a product, C1's declared depth judges its shape, and none of
|
|
918
|
+
* these passes reaches it.
|
|
919
|
+
*/
|
|
920
|
+
/** The six facet folders — a first-level name under `specs/` that IS a constructor. */
|
|
921
|
+
const FACETS$1 = [
|
|
922
|
+
"api",
|
|
923
|
+
"cli",
|
|
924
|
+
"integration",
|
|
925
|
+
"jobs",
|
|
926
|
+
"mobile",
|
|
927
|
+
"website"
|
|
928
|
+
];
|
|
929
|
+
/**
|
|
930
|
+
* Directories no facet walk enters.
|
|
931
|
+
*
|
|
932
|
+
* The `$FIXTURES` pool is verbatim material — the reusable apps AND the trees
|
|
933
|
+
* that fail on purpose — never live specs, so every pass prunes it exactly as
|
|
934
|
+
* the token checker does.
|
|
935
|
+
*/
|
|
936
|
+
const SKIPPED$2 = /* @__PURE__ */ new Set([
|
|
937
|
+
".git",
|
|
938
|
+
"dist",
|
|
939
|
+
GROUND_FIXTURES,
|
|
940
|
+
"node_modules"
|
|
941
|
+
]);
|
|
942
|
+
/**
|
|
943
|
+
* Does this facet-relative path cross ground? A LEADING underscore names it —
|
|
944
|
+
* the four ground directories and nothing else carry one (C13).
|
|
945
|
+
*/
|
|
946
|
+
function isUnderGround(relativePath) {
|
|
947
|
+
return relativePath.split(/[/\\]/u).some((segment) => segment.startsWith("_"));
|
|
948
|
+
}
|
|
949
|
+
/** `readdirSync` that answers `[]` for anything it cannot read. */
|
|
950
|
+
function entriesOf(dir) {
|
|
951
|
+
try {
|
|
952
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
953
|
+
} catch {
|
|
954
|
+
return [];
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
/** The specification files at a directory's own level. */
|
|
958
|
+
function specificationsIn(dir) {
|
|
959
|
+
return entriesOf(dir).filter((entry) => entry.isFile() && entry.name.includes(".specification.")).map((entry) => join(dir, entry.name));
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Every import SOURCE a file names — static, dynamic, and re-export.
|
|
963
|
+
*
|
|
964
|
+
* The source, not a substring of the file: `../website.specification` carries
|
|
965
|
+
* no extension, so a scan for `.specification.js` read a spec that reaches its
|
|
966
|
+
* runner as a module test parked in a facet tree, and told the author to move
|
|
967
|
+
* the file that specifies the product.
|
|
968
|
+
*/
|
|
969
|
+
const IMPORT_SOURCE = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*['"](?<source>[^'"]+)['"]/gu;
|
|
970
|
+
/** The framework's own entry — a spec may construct the runner itself. */
|
|
971
|
+
const CONSTRUCTS = /\bspecification\.[a-z]+\s*\(/u;
|
|
972
|
+
/**
|
|
973
|
+
* Does this file reach the runner of the facet it sits under?
|
|
974
|
+
*
|
|
975
|
+
* Two shapes answer yes: importing the facet's `*.specification` module
|
|
976
|
+
* (however the import spells the extension), and constructing the runner in
|
|
977
|
+
* the file itself — a refusal spec proves the constructor that must FAIL to
|
|
978
|
+
* start, and has no specification module to import.
|
|
979
|
+
*/
|
|
980
|
+
function reachesRunner(text) {
|
|
981
|
+
for (const match of text.matchAll(IMPORT_SOURCE)) {
|
|
982
|
+
const source = match.groups?.source ?? "";
|
|
983
|
+
if (source === "@jterrazz/test" || source.startsWith("@jterrazz/test/")) return true;
|
|
984
|
+
if (source.slice(source.lastIndexOf("/") + 1).includes(".specification")) return true;
|
|
985
|
+
}
|
|
986
|
+
return CONSTRUCTS.test(text);
|
|
987
|
+
}
|
|
988
|
+
/** Read a file as text, or `null`. */
|
|
989
|
+
function textOf$1(path) {
|
|
990
|
+
try {
|
|
991
|
+
return readFileSync(path, "utf8");
|
|
992
|
+
} catch {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Every file under `dir`, depth-first.
|
|
998
|
+
*
|
|
999
|
+
* @yields each file path.
|
|
1000
|
+
*/
|
|
1001
|
+
function* walkFiles(dir) {
|
|
1002
|
+
for (const entry of entriesOf(dir)) {
|
|
1003
|
+
const path = join(dir, entry.name);
|
|
1004
|
+
if (entry.isDirectory()) {
|
|
1005
|
+
if (!SKIPPED$2.has(entry.name)) yield* walkFiles(path);
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
yield path;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/** The facet folders a specs root actually holds. */
|
|
1012
|
+
function facetFoldersOf(specsRoot) {
|
|
1013
|
+
return entriesOf(specsRoot).filter((entry) => entry.isDirectory() && FACETS$1.includes(entry.name)).map((entry) => ({
|
|
1014
|
+
facet: entry.name,
|
|
1015
|
+
path: join(specsRoot, entry.name)
|
|
1016
|
+
}));
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* C20 — a folder named for a facet holds the specification that constructs it.
|
|
1020
|
+
*
|
|
1021
|
+
* Positive-only: `specs/api/` promises `specification.api()`, and a reader who
|
|
1022
|
+
* opens it expecting a runner and finding none has been told something false by
|
|
1023
|
+
* the tree itself. Any OTHER first-level name is a repository suite and is
|
|
1024
|
+
* judged by C1's declared depth alone — the folder never has to be a facet.
|
|
1025
|
+
*/
|
|
1026
|
+
function checkFacetFolder(specsRoot) {
|
|
1027
|
+
const violations = [];
|
|
1028
|
+
for (const { facet, path } of facetFoldersOf(specsRoot)) {
|
|
1029
|
+
if (specificationsIn(path).some((file) => (textOf$1(file) ?? "").includes(`specification.${facet}(`))) continue;
|
|
1030
|
+
const rel = relative(specsRoot, path);
|
|
1031
|
+
violations.push({
|
|
1032
|
+
file: rel,
|
|
1033
|
+
line: 1,
|
|
1034
|
+
message: `${rel}:1: \`specs/${facet}/\` is a facet folder with no \`${facet}.specification.ts\` constructing \`specification.${facet}()\` — create it, or rename the folder (C20 — see docs/19-linting.md)`,
|
|
1035
|
+
rule: "c20-facet-folder",
|
|
1036
|
+
severity: "error"
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
return violations;
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* C18 — a spec under a facet folder reaches the runner that folder promises.
|
|
1043
|
+
*
|
|
1044
|
+
* A test file that imports neither the facet's specification module nor the
|
|
1045
|
+
* framework is a MODULE test parked in a facet tree: it runs under the facet's
|
|
1046
|
+
* project, pays its budget and its services, and proves something that belongs
|
|
1047
|
+
* beside the module it covers.
|
|
1048
|
+
*/
|
|
1049
|
+
function checkModuleTestUnderFacet(specsRoot) {
|
|
1050
|
+
const violations = [];
|
|
1051
|
+
for (const { facet, path } of facetFoldersOf(specsRoot)) {
|
|
1052
|
+
if (specificationsIn(path).length === 0) continue;
|
|
1053
|
+
for (const file of walkFiles(path)) {
|
|
1054
|
+
if (!isTestFileName(file.slice(file.lastIndexOf("/") + 1))) continue;
|
|
1055
|
+
if (isUnderGround(relative(path, file))) continue;
|
|
1056
|
+
if (reachesRunner(textOf$1(file) ?? "")) continue;
|
|
1057
|
+
const rel = relative(specsRoot, file);
|
|
1058
|
+
violations.push({
|
|
1059
|
+
file: rel,
|
|
1060
|
+
line: 1,
|
|
1061
|
+
message: `${rel}:1: reaches no runner under the \`${facet}\` facet: import \`{ ${facet} }\` from \`${facet}.specification.js\`, or move a module test beside its module (C18 — see docs/19-linting.md)`,
|
|
1062
|
+
rule: "c18-module-test-under-facet",
|
|
1063
|
+
severity: "error"
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return violations;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Every directory under `dir`, depth-first.
|
|
1071
|
+
*
|
|
1072
|
+
* @yields each directory path.
|
|
1073
|
+
*/
|
|
1074
|
+
function* walkDirectories(dir) {
|
|
1075
|
+
for (const entry of entriesOf(dir)) {
|
|
1076
|
+
if (!entry.isDirectory() || SKIPPED$2.has(entry.name)) continue;
|
|
1077
|
+
const path = join(dir, entry.name);
|
|
1078
|
+
yield path;
|
|
1079
|
+
yield* walkDirectories(path);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/** The ground names a leaf can hold. */
|
|
1083
|
+
const GROUND = new Set(GROUND_DIRS);
|
|
1084
|
+
/**
|
|
1085
|
+
* A golden named by a TEMPLATE rather than by a literal — `toMatch(`${x}.json`)`.
|
|
1086
|
+
*
|
|
1087
|
+
* Such a test reads whatever the table hands it, so the names under the ground
|
|
1088
|
+
* appear nowhere in its source. It is a reader of every golden in its leaf,
|
|
1089
|
+
* and counting it as none made a shared `_expected/` read as one spec's own.
|
|
1090
|
+
*/
|
|
1091
|
+
const COMPUTED_GOLDEN = /\.toMatch\(\s*(?:`|[A-Za-z_$])/u;
|
|
1092
|
+
/**
|
|
1093
|
+
* C21w (warning) — ground a single spec reads belongs to that spec.
|
|
1094
|
+
*
|
|
1095
|
+
* A leaf holding several specs and one `_expected/` that only one of them
|
|
1096
|
+
* names reads as shared material: the next author assumes someone else depends
|
|
1097
|
+
* on it and nobody dares touch it. The fix is C1's — the spec that stands on
|
|
1098
|
+
* its own ground earns its own domain folder.
|
|
1099
|
+
*/
|
|
1100
|
+
function checkGroundOwnedByOne(specsRoot) {
|
|
1101
|
+
const violations = [];
|
|
1102
|
+
for (const directory of [specsRoot, ...walkDirectories(specsRoot)]) {
|
|
1103
|
+
const entries = entriesOf(directory);
|
|
1104
|
+
const tests = entries.filter((entry) => entry.isFile() && isNodeTestFileName(entry.name)).map((entry) => join(directory, entry.name));
|
|
1105
|
+
if (tests.length < 2) continue;
|
|
1106
|
+
const grounds = entries.filter((entry) => entry.isDirectory() && GROUND.has(entry.name)).map((entry) => entry.name);
|
|
1107
|
+
for (const ground of grounds) {
|
|
1108
|
+
const names = [...walkFiles(join(directory, ground))].map((file) => file.slice(file.lastIndexOf("/") + 1));
|
|
1109
|
+
if (names.length === 0) continue;
|
|
1110
|
+
const readers = tests.filter((test) => {
|
|
1111
|
+
const text = textOf$1(test) ?? "";
|
|
1112
|
+
return names.some((name) => text.includes(name)) || text.includes(`${ground}/`) || COMPUTED_GOLDEN.test(text);
|
|
1113
|
+
});
|
|
1114
|
+
if (readers.length !== 1) continue;
|
|
1115
|
+
const reader = readers[0] ?? "";
|
|
1116
|
+
const rel = relative(specsRoot, join(directory, ground));
|
|
1117
|
+
violations.push({
|
|
1118
|
+
file: rel,
|
|
1119
|
+
line: 1,
|
|
1120
|
+
message: `${rel}:1: \`${ground}/\` here is read by \`${relative(directory, reader)}\` alone — give it its own domain folder (C21 — see docs/19-linting.md)`,
|
|
1121
|
+
rule: "c21w-ground-owned-by-one",
|
|
1122
|
+
severity: "warn"
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return violations;
|
|
1127
|
+
}
|
|
1128
|
+
//#endregion
|
|
1129
|
+
//#region src/lint/checker-placement.ts
|
|
1130
|
+
/**
|
|
1131
|
+
* C12 placement — the suffix says the kind, so the tree has to agree with it.
|
|
1132
|
+
*
|
|
1133
|
+
* `.spec.ts` is the ASSEMBLED product's word: the app met through HTTP, a
|
|
1134
|
+
* binary run, a page served, a module put against real services. It lives under
|
|
1135
|
+
* `specs/<facet>/`. `.test.ts` is the UNIT's word: it sits beside the module it
|
|
1136
|
+
* covers. A file wearing one word in the other's place is the one shape that
|
|
1137
|
+
* makes the fork unreadable — a reader cannot tell what a test is FOR without
|
|
1138
|
+
* opening it — so both directions are errors with a mechanical fix, and the
|
|
1139
|
+
* checker's `--fix` performs the move.
|
|
1140
|
+
*
|
|
1141
|
+
* Two clauses, and each runs where it can see what it judges:
|
|
1142
|
+
*
|
|
1143
|
+
* - a `.spec.ts` with no `specs/` ancestor — the MEMBER pass, which walks a
|
|
1144
|
+
* package and is the only run that sees a file outside a specs tree. The
|
|
1145
|
+
* ancestor is read by the one anchor every other pass reads (`specsAnchor`),
|
|
1146
|
+
* so a member whose own root IS the specs tree — a workspace declaring
|
|
1147
|
+
* `packages: ['specs']` — holds specs, not strays;
|
|
1148
|
+
* - a `.test.ts` under `specs/<facet>/` where `<facet>` is one of the six
|
|
1149
|
+
* constructors — the TREE pass. A first-level folder that is NOT a facet
|
|
1150
|
+
* (the package's own `specs/lint/`, a repository's consistency suites) is a
|
|
1151
|
+
* repository suite: it covers a tree rather than the assembled product, so
|
|
1152
|
+
* it keeps `.test.ts` and C1's declared depth is what judges its shape.
|
|
1153
|
+
*
|
|
1154
|
+
* A tree may also be ONE facet's whole, with no `specs/<facet>/` level: the
|
|
1155
|
+
* runner sits at the root of `specs/` and the folders below it are domains (the
|
|
1156
|
+
* `depth: 'mirror'` shape, where the tree follows a command tree instead of a
|
|
1157
|
+
* facet/domain one). Such a tree carries a `*.specification.ts` at its root,
|
|
1158
|
+
* and that is what says so — every `.test.ts` below it specifies the assembled
|
|
1159
|
+
* product, whatever folder it sits in. Without this clause the whole shape was
|
|
1160
|
+
* invisible to C12 and its specs had to be renamed by hand.
|
|
1161
|
+
*/
|
|
1162
|
+
/** The six facet folders — a first-level name under `specs/` that IS a constructor. */
|
|
1163
|
+
const FACETS = /* @__PURE__ */ new Set([
|
|
1164
|
+
"api",
|
|
1165
|
+
"cli",
|
|
1166
|
+
"integration",
|
|
1167
|
+
"jobs",
|
|
1168
|
+
"mobile",
|
|
1169
|
+
"website"
|
|
1170
|
+
]);
|
|
1171
|
+
/** Directories no placement walk enters. */
|
|
1172
|
+
const SKIPPED$1 = /* @__PURE__ */ new Set([
|
|
1173
|
+
".git",
|
|
1174
|
+
"dist",
|
|
1175
|
+
"node_modules"
|
|
1176
|
+
]);
|
|
1177
|
+
/**
|
|
1178
|
+
* Every file under `dir`, depth-first.
|
|
1179
|
+
*
|
|
1180
|
+
* `stopAtPackages` bounds the walk at the next `package.json`: the member pass
|
|
1181
|
+
* judges ONE member, and a nested member is judged by its own run — without the
|
|
1182
|
+
* bound, a workspace root reported every finding of every package below it and
|
|
1183
|
+
* the same file was named twice.
|
|
1184
|
+
*
|
|
1185
|
+
* @yields every file path under `dir`, depth-first.
|
|
1186
|
+
*/
|
|
1187
|
+
function* walk(dir, stopAtPackages = false) {
|
|
1188
|
+
let entries;
|
|
1189
|
+
try {
|
|
1190
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1191
|
+
} catch {
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
for (const entry of entries) {
|
|
1195
|
+
const path = join(dir, entry.name);
|
|
1196
|
+
if (entry.isDirectory()) {
|
|
1197
|
+
if (SKIPPED$1.has(entry.name)) continue;
|
|
1198
|
+
if (stopAtPackages && existsSync(join(path, "package.json"))) continue;
|
|
1199
|
+
yield* walk(path, stopAtPackages);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
yield path;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
/** Read a file as text, or the empty string when it cannot be read. */
|
|
1206
|
+
function textOf(path) {
|
|
1207
|
+
try {
|
|
1208
|
+
return readFileSync(path, "utf8");
|
|
1209
|
+
} catch {
|
|
1210
|
+
return "";
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Clause one — a `.spec.ts` that sits outside every `specs/` tree.
|
|
1215
|
+
*
|
|
1216
|
+
* The facet cannot be guessed (only the author knows whether the file meets the
|
|
1217
|
+
* product through HTTP or through a binary), so this clause reports and the
|
|
1218
|
+
* mover leaves it alone: `to` is `undefined` and the fix line names the shape.
|
|
1219
|
+
*/
|
|
1220
|
+
function checkSpecOutsideSpecs(memberDir) {
|
|
1221
|
+
const violations = [];
|
|
1222
|
+
for (const path of walk(memberDir, true)) {
|
|
1223
|
+
if (!path.endsWith(".spec.ts")) continue;
|
|
1224
|
+
if (isUnderSpecs(path)) continue;
|
|
1225
|
+
const rel = relative(memberDir, path);
|
|
1226
|
+
violations.push({
|
|
1227
|
+
file: rel,
|
|
1228
|
+
line: 1,
|
|
1229
|
+
message: `${rel}:1: a \`.spec.ts\` lives under \`specs/<facet>/\` — move it there, or rename it \`.test.ts\` beside the module it covers (C12 — see docs/19-linting.md)`,
|
|
1230
|
+
rule: "c12-spec-file-name",
|
|
1231
|
+
severity: "error"
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
return violations;
|
|
1235
|
+
}
|
|
1236
|
+
/**
|
|
1237
|
+
* Clause two — a `.test.ts` under a facet folder of a `specs/` tree.
|
|
1238
|
+
*
|
|
1239
|
+
* The fix is mechanical and the mover performs it: the facet folder already
|
|
1240
|
+
* says what the file specifies, so the only thing missing is the word.
|
|
1241
|
+
*
|
|
1242
|
+
* A file that reaches no runner is left to C18, whose fix is the opposite move
|
|
1243
|
+
* — one file, one finding, and `--fix` never renames a module test into a spec.
|
|
1244
|
+
*/
|
|
1245
|
+
function checkTestUnderFacet(specsRoot) {
|
|
1246
|
+
return movesUnderFacet(specsRoot).map(({ from }) => {
|
|
1247
|
+
const rel = relative(specsRoot, from);
|
|
1248
|
+
const first = rel.split(/[/\\]/u)[0] ?? "";
|
|
1249
|
+
return {
|
|
1250
|
+
file: rel,
|
|
1251
|
+
line: 1,
|
|
1252
|
+
message: `${rel}:1: a \`.test.ts\` under \`${FACETS.has(first) ? `specs/${first}/` : "specs/"}\` specifies the assembled product — rename it \`.spec.ts\` (fixable: \`jterrazz-test-check <root> --fix\`) (C12 — see docs/19-linting.md)`,
|
|
1253
|
+
rule: "c12-spec-file-name",
|
|
1254
|
+
severity: "error"
|
|
1255
|
+
};
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Is this tree ONE facet's whole, rooted at `specs/`?
|
|
1260
|
+
*
|
|
1261
|
+
* A `*.specification.ts` at the ROOT of the tree is the statement: the runner
|
|
1262
|
+
* is the tree's, so every folder below it is a domain of that one facet and no
|
|
1263
|
+
* first level is a repository suite.
|
|
1264
|
+
*/
|
|
1265
|
+
function isRootedFacetTree(specsRoot) {
|
|
1266
|
+
try {
|
|
1267
|
+
return readdirSync(specsRoot, { withFileTypes: true }).some((entry) => entry.isFile() && entry.name.includes(".specification."));
|
|
1268
|
+
} catch {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/** The renames clause two asks for, as absolute from/to pairs. */
|
|
1273
|
+
function movesUnderFacet(specsRoot) {
|
|
1274
|
+
const moves = [];
|
|
1275
|
+
const rooted = isRootedFacetTree(specsRoot);
|
|
1276
|
+
for (const path of walk(specsRoot)) {
|
|
1277
|
+
if (!path.endsWith(".test.ts")) continue;
|
|
1278
|
+
const parts = relative(specsRoot, path).split(/[/\\]/u);
|
|
1279
|
+
const facet = parts[0];
|
|
1280
|
+
if (!rooted && (parts.length < 2 || facet === void 0 || !FACETS.has(facet))) continue;
|
|
1281
|
+
if (parts.some((part) => part.startsWith("_"))) continue;
|
|
1282
|
+
if (!reachesRunner(textOf(path))) continue;
|
|
1283
|
+
moves.push({
|
|
1284
|
+
fix: "rename to `.spec.ts`",
|
|
1285
|
+
from: path,
|
|
1286
|
+
to: `${path.slice(0, -8)}.spec.ts`
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
return moves;
|
|
1290
|
+
}
|
|
780
1291
|
//#endregion
|
|
781
1292
|
//#region src/lint/checker-member.ts
|
|
782
1293
|
/**
|
|
@@ -794,6 +1305,16 @@ function checkLocalFixtureReach(rootDir) {
|
|
|
794
1305
|
* it for one member, and a path-less `--format json` run at the project root
|
|
795
1306
|
* runs it for every member the root manifest declares.
|
|
796
1307
|
*/
|
|
1308
|
+
/** How deep a member may sit below the root — the toolchain's bound, to the segment. */
|
|
1309
|
+
const MEMBER_DEPTH = 6;
|
|
1310
|
+
/** Directories the member walk never enters. */
|
|
1311
|
+
const SKIPPED = /* @__PURE__ */ new Set([
|
|
1312
|
+
".git",
|
|
1313
|
+
".artifacts",
|
|
1314
|
+
"coverage",
|
|
1315
|
+
"dist",
|
|
1316
|
+
"node_modules"
|
|
1317
|
+
]);
|
|
797
1318
|
/** The two simulated DOMs — the same pair the statique E5b refuses. */
|
|
798
1319
|
const SIMULATED_DOM = /\benvironment\s*:\s*['"](?<dom>happy-dom|jsdom)['"]/u;
|
|
799
1320
|
/**
|
|
@@ -825,8 +1346,88 @@ const RETIRED = /* @__PURE__ */ new Set([
|
|
|
825
1346
|
"mockdate"
|
|
826
1347
|
]);
|
|
827
1348
|
const RETIRED_SCOPES = ["@testing-library/"];
|
|
1349
|
+
/**
|
|
1350
|
+
* The one retired seam a member may still declare, and what makes it allowed.
|
|
1351
|
+
*
|
|
1352
|
+
* React Native renders under jest until the react-native-web answer lands, and
|
|
1353
|
+
* where jest is the sanctioned runner `@testing-library/react-native` is the
|
|
1354
|
+
* only vocabulary there is: the rule would be asking for a facet that does not
|
|
1355
|
+
* reach that runtime yet. The allowance lapses the day jest stops running the
|
|
1356
|
+
* member's tests, and it has TWO sources, because jest is not always the
|
|
1357
|
+
* member's own: in a workspace where one app owns the only working RN
|
|
1358
|
+
* toolchain, the library declares the vocabulary and the app runs the tests
|
|
1359
|
+
* that use it.
|
|
1360
|
+
*/
|
|
1361
|
+
const JEST_ONLY_SEAM = "@testing-library/react-native";
|
|
1362
|
+
/** Does the member's OWN manifest say jest runs here? */
|
|
1363
|
+
function declaresJest(manifest) {
|
|
1364
|
+
const declares = (name) => name in (manifest.dependencies ?? {}) || name in (manifest.devDependencies ?? {});
|
|
1365
|
+
return declares("jest") || declares("jest-expo") || manifest.jest !== void 0 || /(?:^|[\s/])jest(?:\s|$)/u.test(manifest.scripts?.test ?? "");
|
|
1366
|
+
}
|
|
1367
|
+
/** The config names jest answers to, whatever a project writes it in. */
|
|
1368
|
+
const JEST_CONFIG = /^jest\.config\.[cm]?[jt]s$/u;
|
|
1369
|
+
/** The keys of a jest config that name WHERE its tests are. */
|
|
1370
|
+
const JEST_REACH = /\b(?:roots|testMatch|testPathDirs|testPathIgnorePatterns)\s*:\s*\[(?<paths>[^\]]*)\]/gu;
|
|
1371
|
+
/** One quoted entry of such a list. */
|
|
1372
|
+
const JEST_PATH = /['"`](?<path>[^'"`\n]*)['"`]/gu;
|
|
1373
|
+
/** Every `jest.config.*` under `rootDir`, by absolute path — read once per root. */
|
|
1374
|
+
const jestConfigs = /* @__PURE__ */ new Map();
|
|
1375
|
+
/** Find them, bounded by the same skips and depth the member walk uses. */
|
|
1376
|
+
function findJestConfigs(rootDir) {
|
|
1377
|
+
const cached = jestConfigs.get(rootDir);
|
|
1378
|
+
if (cached !== void 0) return cached;
|
|
1379
|
+
const found = [];
|
|
1380
|
+
const walk = (dir, depth) => {
|
|
1381
|
+
if (depth > MEMBER_DEPTH) return;
|
|
1382
|
+
let entries;
|
|
1383
|
+
try {
|
|
1384
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1385
|
+
} catch {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
for (const entry of entries) {
|
|
1389
|
+
const path = join(dir, entry.name);
|
|
1390
|
+
if (entry.isDirectory()) {
|
|
1391
|
+
if (!entry.name.startsWith(".") && !SKIPPED.has(entry.name)) walk(path, depth + 1);
|
|
1392
|
+
} else if (JEST_CONFIG.test(entry.name)) found.push(path);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
walk(rootDir, 0);
|
|
1396
|
+
jestConfigs.set(rootDir, found);
|
|
1397
|
+
return found;
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Does a jest config ELSEWHERE in the workspace collect this member's tests?
|
|
1401
|
+
*
|
|
1402
|
+
* jterrazz-design is the shape: the render tests are co-located in
|
|
1403
|
+
* `packages/native/src`, and `apps/playground` — which owns the only working
|
|
1404
|
+
* React Native toolchain — is what runs them, rooting into the library from its
|
|
1405
|
+
* own config. The library's manifest says `vitest --run`, so reading the
|
|
1406
|
+
* allowance from the member alone answered "no jest here" and F8 errored on
|
|
1407
|
+
* exactly the layout the exemption was written for.
|
|
1408
|
+
*
|
|
1409
|
+
* `<rootDir>` is jest's own token for the directory its config sits in, so a
|
|
1410
|
+
* path is resolved against that, and the member is reached when the result
|
|
1411
|
+
* lands inside it.
|
|
1412
|
+
*/
|
|
1413
|
+
function jestReachesMember(memberDir, rootDir) {
|
|
1414
|
+
return findJestConfigs(rootDir).some((config) => {
|
|
1415
|
+
const from = dirname(config);
|
|
1416
|
+
let text;
|
|
1417
|
+
try {
|
|
1418
|
+
text = readFileSync(config, "utf8");
|
|
1419
|
+
} catch {
|
|
1420
|
+
return false;
|
|
1421
|
+
}
|
|
1422
|
+
return [...text.matchAll(JEST_REACH)].some((list) => [...(list.groups?.paths ?? "").matchAll(JEST_PATH)].some((entry) => {
|
|
1423
|
+
const stated = (entry.groups?.path ?? "").replace("<rootDir>", from);
|
|
1424
|
+
const absolute = resolve(from, stated);
|
|
1425
|
+
return absolute === memberDir || absolute.startsWith(`${memberDir}/`);
|
|
1426
|
+
}));
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
828
1429
|
/** Where a member states what it collects, whatever extension it writes it in. */
|
|
829
|
-
const CONFIG_NAMES = [
|
|
1430
|
+
const CONFIG_NAMES$1 = [
|
|
830
1431
|
"vitest.config.ts",
|
|
831
1432
|
"vitest.config.mts",
|
|
832
1433
|
"vitest.config.cts",
|
|
@@ -834,14 +1435,6 @@ const CONFIG_NAMES = [
|
|
|
834
1435
|
"vitest.config.mjs",
|
|
835
1436
|
"vitest.config.cjs"
|
|
836
1437
|
];
|
|
837
|
-
/** Directories the member walk never enters. */
|
|
838
|
-
const SKIPPED = /* @__PURE__ */ new Set([
|
|
839
|
-
".git",
|
|
840
|
-
".artifacts",
|
|
841
|
-
"coverage",
|
|
842
|
-
"dist",
|
|
843
|
-
"node_modules"
|
|
844
|
-
]);
|
|
845
1438
|
/** A member's manifest, or null when it has none (it is then not a member). */
|
|
846
1439
|
function readManifest(dir) {
|
|
847
1440
|
const path = join(dir, "package.json");
|
|
@@ -854,7 +1447,7 @@ function readManifest(dir) {
|
|
|
854
1447
|
}
|
|
855
1448
|
/** The member's vitest config, by path, or null. */
|
|
856
1449
|
function configOf(dir) {
|
|
857
|
-
for (const name of CONFIG_NAMES) {
|
|
1450
|
+
for (const name of CONFIG_NAMES$1) {
|
|
858
1451
|
const path = join(dir, name);
|
|
859
1452
|
if (existsSync(path)) return path;
|
|
860
1453
|
}
|
|
@@ -887,7 +1480,7 @@ function holdsTests(dir) {
|
|
|
887
1480
|
if (!SKIPPED.has(entry.name) && !entry.name.startsWith(".")) stack.push(join(current, entry.name));
|
|
888
1481
|
continue;
|
|
889
1482
|
}
|
|
890
|
-
if (
|
|
1483
|
+
if (isTestFileName(entry.name)) return true;
|
|
891
1484
|
}
|
|
892
1485
|
}
|
|
893
1486
|
return false;
|
|
@@ -899,9 +1492,12 @@ function holdsTests(dir) {
|
|
|
899
1492
|
*/
|
|
900
1493
|
const DELEGATING_TEST = /--workspaces|(?:^|\s)-r(?:\s|$)|--recursive|turbo\s+run|nx\s+run-many|lerna\s+run/u;
|
|
901
1494
|
/** Every seam name this member declares that it should not, and why it should not. */
|
|
902
|
-
function declaredSeams(manifest) {
|
|
1495
|
+
function declaredSeams(manifest, jestRuns) {
|
|
903
1496
|
if (manifest.name === FRAMEWORK) return [];
|
|
904
|
-
const isRetired = (name) =>
|
|
1497
|
+
const isRetired = (name) => {
|
|
1498
|
+
if (name === JEST_ONLY_SEAM && jestRuns) return false;
|
|
1499
|
+
return RETIRED.has(name) || RETIRED_SCOPES.some((scope) => name.startsWith(scope));
|
|
1500
|
+
};
|
|
905
1501
|
return [.../* @__PURE__ */ new Set([...Object.keys(manifest.dependencies ?? {}).filter((name) => CARRIED.has(name) || isRetired(name)), ...Object.keys(manifest.devDependencies ?? {}).filter((name) => CARRIED.has(name) || CARRIED_IN_DEV.has(name) || isRetired(name))])].toSorted().map((name) => ({
|
|
906
1502
|
kind: isRetired(name) ? "retired" : "carried",
|
|
907
1503
|
name
|
|
@@ -928,7 +1524,7 @@ function configPresent(manifest, memberDir, label, subject) {
|
|
|
928
1524
|
return [{
|
|
929
1525
|
file: join(label, "package.json"),
|
|
930
1526
|
line: 1,
|
|
931
|
-
message: `${subject}: no vitest.config.ts — vitest's 5 s budget and an unexcluded \`_fixtures/\` are running your tests; write \`export default defineSpecConfig()\` (E3 — see docs/
|
|
1527
|
+
message: `${subject}: no vitest.config.ts — vitest's 5 s budget and an unexcluded \`_fixtures/\` are running your tests; write \`export default defineSpecConfig()\` (E3 — see docs/19-linting.md#e3-config-present)`,
|
|
932
1528
|
rule: "e3",
|
|
933
1529
|
severity: "error"
|
|
934
1530
|
}];
|
|
@@ -942,7 +1538,7 @@ function simulatedDom(config, rootDir) {
|
|
|
942
1538
|
return [{
|
|
943
1539
|
file: relConfig,
|
|
944
1540
|
line: text.slice(0, found.index).split("\n").length,
|
|
945
|
-
message: `${relConfig}: \`environment: '${found.groups.dom}'\` gives every file of this project a drawing of a browser — a rendered thing is a \`.test.tsx\` beside its component, collected by \`component()\` (E5b — see docs/
|
|
1541
|
+
message: `${relConfig}: \`environment: '${found.groups.dom}'\` gives every file of this project a drawing of a browser — a rendered thing is a \`.test.tsx\` beside its component, collected by \`component()\` (E5b — see docs/19-linting.md#e5b-no-simulated-dom-config-member)`,
|
|
946
1542
|
rule: "e5b",
|
|
947
1543
|
severity: "error"
|
|
948
1544
|
}];
|
|
@@ -953,16 +1549,30 @@ function simulatedDom(config, rootDir) {
|
|
|
953
1549
|
* The two are not the same sentence: removing a transitive changes nothing a
|
|
954
1550
|
* test can see, while dropping a retired seam means writing its facet.
|
|
955
1551
|
*/
|
|
956
|
-
function seamDependencies(
|
|
957
|
-
|
|
1552
|
+
function seamDependencies(member) {
|
|
1553
|
+
const { label, manifest, memberDir, rootDir, subject } = member;
|
|
1554
|
+
return declaredSeams(manifest, declaresJest(manifest) || jestReachesMember(memberDir, rootDir)).map((seam) => ({
|
|
958
1555
|
file: join(label, "package.json"),
|
|
959
1556
|
line: lineOfKey(memberDir, seam.name),
|
|
960
|
-
message: seam.kind === "carried" ? `${subject}: \`${seam.name}\` is a transitive of \`@jterrazz/test\` — remove the declaration (F8 — see docs/
|
|
1557
|
+
message: seam.kind === "carried" ? `${subject}: \`${seam.name}\` is a transitive of \`@jterrazz/test\` — remove the declaration (F8 — see docs/19-linting.md#f8-no-seam-dependency)` : `${subject}: \`${seam.name}\` is a seam \`@jterrazz/test\` replaced — the facet is \`component()\`, \`website()\`, \`clock\` or \`intercept()\`; remove the declaration (F8 — see docs/19-linting.md#f8-no-seam-dependency)`,
|
|
961
1558
|
rule: "f8",
|
|
962
1559
|
severity: "error"
|
|
963
1560
|
}));
|
|
964
1561
|
}
|
|
965
1562
|
/**
|
|
1563
|
+
* Re-anchor a member-relative finding on the root the run reports against, so
|
|
1564
|
+
* one member's path reads the same alone as inside a whole-workspace run.
|
|
1565
|
+
*/
|
|
1566
|
+
function anchoredToRoot(violations, label) {
|
|
1567
|
+
if (label === ".") return violations;
|
|
1568
|
+
const anchored = [];
|
|
1569
|
+
for (const violation of violations) anchored.push({
|
|
1570
|
+
...violation,
|
|
1571
|
+
file: join(label, violation.file)
|
|
1572
|
+
});
|
|
1573
|
+
return anchored;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
966
1576
|
* Run E3, E5b and F8 over ONE member. `memberDir` is absolute; `rootDir`
|
|
967
1577
|
* anchors the paths the findings report, so a member's finding reads the same
|
|
968
1578
|
* whether the pass ran over it alone or over the whole workspace.
|
|
@@ -976,7 +1586,14 @@ function checkMember(memberDir, rootDir) {
|
|
|
976
1586
|
return [
|
|
977
1587
|
...config === null ? configPresent(manifest, memberDir, label, subject) : [],
|
|
978
1588
|
...config === null ? [] : simulatedDom(config, rootDir),
|
|
979
|
-
...seamDependencies(
|
|
1589
|
+
...seamDependencies({
|
|
1590
|
+
label,
|
|
1591
|
+
manifest,
|
|
1592
|
+
memberDir,
|
|
1593
|
+
rootDir,
|
|
1594
|
+
subject
|
|
1595
|
+
}),
|
|
1596
|
+
...anchoredToRoot(checkSpecOutsideSpecs(memberDir), label)
|
|
980
1597
|
];
|
|
981
1598
|
}
|
|
982
1599
|
/** The glob-ish workspace patterns a root manifest declares. */
|
|
@@ -996,8 +1613,6 @@ function toPattern(glob) {
|
|
|
996
1613
|
const escaped = glob.replaceAll(/[.+^${}()|[\]\\]/gu, String.raw`\$&`).replaceAll("**", " ").replaceAll("*", "[^/]*").replaceAll(" ", ".*");
|
|
997
1614
|
return new RegExp(`^${escaped}$`, "u");
|
|
998
1615
|
}
|
|
999
|
-
/** How deep a member may sit below the root — the toolchain's bound, to the segment. */
|
|
1000
|
-
const MEMBER_DEPTH = 6;
|
|
1001
1616
|
/**
|
|
1002
1617
|
* Every workspace member declared by the root manifest, plus the root itself —
|
|
1003
1618
|
* a single-package repository IS its own member, and the pass has to reach it.
|
|
@@ -1055,11 +1670,14 @@ const SPECS_DEPTH = 6;
|
|
|
1055
1670
|
*
|
|
1056
1671
|
* A `specs/` tree is never descended into (the fixtures under it are not spec
|
|
1057
1672
|
* roots), and neither is another package's tree: a directory carrying its own
|
|
1058
|
-
* `package.json` belongs to whichever member declares it.
|
|
1673
|
+
* `package.json` belongs to whichever member declares it. A member DIRECTORY
|
|
1674
|
+
* named `specs` is a root of its own — the tree can be the member.
|
|
1059
1675
|
*/
|
|
1060
1676
|
function discoverSpecRoots(rootDir) {
|
|
1061
1677
|
const roots = /* @__PURE__ */ new Set();
|
|
1062
|
-
|
|
1678
|
+
const members = discoverMembers(rootDir);
|
|
1679
|
+
for (const member of members.length === 0 ? [rootDir] : members) {
|
|
1680
|
+
if (basename(member) === "specs") roots.add(member);
|
|
1063
1681
|
const walk = (dir, depth) => {
|
|
1064
1682
|
if (depth > SPECS_DEPTH) return;
|
|
1065
1683
|
let entries;
|
|
@@ -1301,27 +1919,99 @@ const VOLATILE = [
|
|
|
1301
1919
|
}
|
|
1302
1920
|
];
|
|
1303
1921
|
/**
|
|
1922
|
+
* The volatile literal on this line, if it carries one — the reader BOTH halves
|
|
1923
|
+
* of d5's reach use, so a document's expected stream and a file under
|
|
1924
|
+
* `_expected/` are judged by one list.
|
|
1925
|
+
*
|
|
1926
|
+
* A `{{token}}` is removed before the test: the token IS the answer the rule
|
|
1927
|
+
* asks for, and a path written after one is the token's tail, not a literal.
|
|
1928
|
+
*/
|
|
1929
|
+
/**
|
|
1930
|
+
* The whole word the match sits inside — the string the pinned-value carve-out
|
|
1931
|
+
* looks up.
|
|
1932
|
+
*
|
|
1933
|
+
* A pattern matches the SHAPE that makes a value volatile (`/tmp/`, a loopback
|
|
1934
|
+
* origin) and not the value itself, so asking whether the document pinned
|
|
1935
|
+
* `/tmp/` would exempt every temp path once any of them was named. The word the
|
|
1936
|
+
* match belongs to is what the document either states or does not.
|
|
1937
|
+
*/
|
|
1938
|
+
function wordAround(text, index) {
|
|
1939
|
+
let start = index;
|
|
1940
|
+
let end = index;
|
|
1941
|
+
while (start > 0 && !/[\s"'(),;=]/u.test(text[start - 1] ?? "")) start -= 1;
|
|
1942
|
+
while (end < text.length && !/[\s"'(),;=]/u.test(text[end] ?? "")) end += 1;
|
|
1943
|
+
return text.slice(start, end);
|
|
1944
|
+
}
|
|
1945
|
+
function volatileLiteralIn(line) {
|
|
1946
|
+
const bare = line.replaceAll(/\{\{[^}]*\}\}/gu, " ");
|
|
1947
|
+
for (const { pattern, token } of VOLATILE) {
|
|
1948
|
+
const found = pattern.exec(bare);
|
|
1949
|
+
if (found !== null) {
|
|
1950
|
+
const at = found.index + found[0].length - found[0].trimStart().length;
|
|
1951
|
+
return {
|
|
1952
|
+
found: found[0].trim(),
|
|
1953
|
+
token,
|
|
1954
|
+
word: wordAround(bare, at)
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1304
1960
|
* A stream that pins a loopback port, a temp directory or somebody's home is a
|
|
1305
1961
|
* golden that passed once, on one machine: the port was free that second, the
|
|
1306
1962
|
* temp path belonged to that run. It is the exact shape of a value the update
|
|
1307
1963
|
* writer would have tokenised, which is why finding one means a token was
|
|
1308
1964
|
* overwritten by hand.
|
|
1965
|
+
*
|
|
1966
|
+
* Unless the document FIXED it — the same carve-out d5w rests on, for the same
|
|
1967
|
+
* reason: a path the command was handed, or one a `fixture:` ships, is a value
|
|
1968
|
+
* the document states rather than one the run minted.
|
|
1309
1969
|
*/
|
|
1310
|
-
function checkVolatileLiterals(document) {
|
|
1970
|
+
function checkVolatileLiterals(document, dir) {
|
|
1311
1971
|
const findings = [];
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1972
|
+
let fixed = null;
|
|
1973
|
+
for (const stream of assertedStreams(document)) for (const [index, text] of stream.text.split("\n").entries()) {
|
|
1974
|
+
const volatileLiteral = volatileLiteralIn(text);
|
|
1975
|
+
if (volatileLiteral === void 0) continue;
|
|
1976
|
+
fixed ??= fixedData(document, dir);
|
|
1977
|
+
if (fixed.includes(volatileLiteral.word)) continue;
|
|
1978
|
+
findings.push(finding("d5-spec-volatile-literal", stream.line + index, `"${volatileLiteral.found}" is a value the next run will not reproduce — write ${volatileLiteral.token}`));
|
|
1317
1979
|
}
|
|
1318
1980
|
return findings;
|
|
1319
1981
|
}
|
|
1320
1982
|
const ISO8601 = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/u;
|
|
1321
1983
|
const UUID = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/u;
|
|
1322
|
-
/**
|
|
1984
|
+
/**
|
|
1985
|
+
* Everything the document itself FIXED — what it feeds in, what it names, and
|
|
1986
|
+
* what its fixtures hold.
|
|
1987
|
+
*
|
|
1988
|
+
* The first three sources are the document's own words: a literal that appears
|
|
1989
|
+
* in a command's arguments or in an `env:`/`serve:` value is one the DOCUMENT
|
|
1990
|
+
* put there, not one a run minted — a reader sees it being pinned two lines
|
|
1991
|
+
* above the stream that asserts it.
|
|
1992
|
+
*
|
|
1993
|
+
* The last is the ground beside it. A document's sibling `_fixtures/` is its
|
|
1994
|
+
* material whether or not a `fixture:` line names the exact file: the tree is
|
|
1995
|
+
* there to be read by the run, and a value it holds is one the repository
|
|
1996
|
+
* committed.
|
|
1997
|
+
*/
|
|
1323
1998
|
function fixedData(document, dir) {
|
|
1324
|
-
const parts = document.runs.
|
|
1999
|
+
const parts = document.runs.flatMap((run) => [run.stdin ?? "", run.command]);
|
|
2000
|
+
for (const token of document.env) parts.push(token.kind === "pair" ? `${token.key}=${token.value}` : token.name);
|
|
2001
|
+
for (const entry of document.serve) parts.push(entry.name, ...Object.values(entry.env));
|
|
2002
|
+
const visit = (path) => {
|
|
2003
|
+
let stat;
|
|
2004
|
+
try {
|
|
2005
|
+
stat = statSync(path);
|
|
2006
|
+
} catch {
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
if (!stat.isDirectory()) {
|
|
2010
|
+
parts.push(readText(path));
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
for (const entry of readdirSync(path)) visit(join(path, entry));
|
|
2014
|
+
};
|
|
1325
2015
|
for (const fixture of document.fixtures) {
|
|
1326
2016
|
let source;
|
|
1327
2017
|
try {
|
|
@@ -1329,21 +2019,9 @@ function fixedData(document, dir) {
|
|
|
1329
2019
|
} catch {
|
|
1330
2020
|
continue;
|
|
1331
2021
|
}
|
|
1332
|
-
const visit = (path) => {
|
|
1333
|
-
let stat;
|
|
1334
|
-
try {
|
|
1335
|
-
stat = statSync(path);
|
|
1336
|
-
} catch {
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
if (!stat.isDirectory()) {
|
|
1340
|
-
parts.push(readText(path));
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
for (const entry of readdirSync(path)) visit(join(path, entry));
|
|
1344
|
-
};
|
|
1345
2022
|
visit(source);
|
|
1346
2023
|
}
|
|
2024
|
+
visit(join(dir, GROUND_FIXTURES));
|
|
1347
2025
|
return parts.join("\n");
|
|
1348
2026
|
}
|
|
1349
2027
|
/**
|
|
@@ -1507,7 +2185,7 @@ function checkSpecConventions(text, rel, path) {
|
|
|
1507
2185
|
...checkBlockScalars(file, (scalar) => lineAt(scalar)),
|
|
1508
2186
|
...checkFileName(rel),
|
|
1509
2187
|
...checkDescription(file.document),
|
|
1510
|
-
...checkVolatileLiterals(file.document),
|
|
2188
|
+
...checkVolatileLiterals(file.document, dir),
|
|
1511
2189
|
...checkPinnedValues(file.document, dir),
|
|
1512
2190
|
...checkEmptyAssertions(file.document),
|
|
1513
2191
|
...checkSilentRefusals(file.document),
|
|
@@ -1515,7 +2193,7 @@ function checkSpecConventions(text, rel, path) {
|
|
|
1515
2193
|
].map(({ line, message, rule, severity }) => ({
|
|
1516
2194
|
file: rel,
|
|
1517
2195
|
line,
|
|
1518
|
-
message: `${rel}:${line}: ${message} (${rule} — see docs/
|
|
2196
|
+
message: `${rel}:${line}: ${message} (${rule} — see docs/19-linting.md)`,
|
|
1519
2197
|
rule: codeOf(rule),
|
|
1520
2198
|
severity
|
|
1521
2199
|
}));
|
|
@@ -1547,7 +2225,7 @@ function checkSpecDescriptionsUnique(rootDir) {
|
|
|
1547
2225
|
violations.push({
|
|
1548
2226
|
file: rel,
|
|
1549
2227
|
line: document.descriptionLine,
|
|
1550
|
-
message: `${rel}:${document.descriptionLine}: "${document.description}" is already the description of ${first} — two documents of one directory cannot share a title (j4-spec-description-unique — see docs/
|
|
2228
|
+
message: `${rel}:${document.descriptionLine}: "${document.description}" is already the description of ${first} — two documents of one directory cannot share a title (j4-spec-description-unique — see docs/19-linting.md)`,
|
|
1551
2229
|
rule: "j4",
|
|
1552
2230
|
severity: "error"
|
|
1553
2231
|
});
|
|
@@ -1623,8 +2301,30 @@ const BRACE_BLOCK = /\{\{(?<inner>[^{}]*)\}\}/gu;
|
|
|
1623
2301
|
/** The leading identifier of a brace block, for malformed-ref classification. */
|
|
1624
2302
|
const LEADING_WORD = /^(?<kind>[A-Za-z][A-Za-z0-9]*)/u;
|
|
1625
2303
|
const KNOWN = new Set(TOKEN_KINDS);
|
|
1626
|
-
/** Directories whose files
|
|
1627
|
-
const FIXTURE_DIRS = /* @__PURE__ */ new Set([
|
|
2304
|
+
/** Directories whose files the walk judges, each by its own law. */
|
|
2305
|
+
const FIXTURE_DIRS = /* @__PURE__ */ new Set([
|
|
2306
|
+
GROUND_EXPECTED,
|
|
2307
|
+
GROUND_REQUESTS,
|
|
2308
|
+
GROUND_SEEDS
|
|
2309
|
+
]);
|
|
2310
|
+
/**
|
|
2311
|
+
* The ways a seed asks the machine what time it is.
|
|
2312
|
+
*
|
|
2313
|
+
* Three spellings and nothing else, because each is decidable on sight: the
|
|
2314
|
+
* quoted `'now'` argument SQLite's date functions take (`datetime('now')`,
|
|
2315
|
+
* `strftime('%s', 'now')`, `julianday('now')`, `date('now', '-1 day')`), the
|
|
2316
|
+
* SQL keyword every engine spells the same, and Postgres/MySQL's `NOW()`.
|
|
2317
|
+
* The bare word `now` is not one of them — it is a column name as often as a
|
|
2318
|
+
* clock read, and a rule that guessed would be refused rather than obeyed.
|
|
2319
|
+
*/
|
|
2320
|
+
const CLOCK_READ = /'now'|\bCURRENT_(?:TIMESTAMP|DATE|TIME)\b|\bNOW\s*\(\s*\)/iu;
|
|
2321
|
+
/** A line comment, and a block comment — a `-- datetime('now')` is prose, not state. */
|
|
2322
|
+
const SQL_LINE_COMMENT = /--[^\n]*/gu;
|
|
2323
|
+
const SQL_BLOCK_COMMENT = /\/\*[\s\S]*?\*\//gu;
|
|
2324
|
+
/** The file with its comments blanked out, line for line so a finding still points. */
|
|
2325
|
+
function withoutComments$1(text) {
|
|
2326
|
+
return text.replaceAll(SQL_BLOCK_COMMENT, (block) => block.replaceAll(/[^\n]/gu, " ")).replaceAll(SQL_LINE_COMMENT, "");
|
|
2327
|
+
}
|
|
1628
2328
|
/**
|
|
1629
2329
|
* Directories the walk never enters. `_fixtures/` trees (the shared pool and
|
|
1630
2330
|
* the leaf-local ones) are verbatim `.fixture()` cwd material — file STATE, not
|
|
@@ -1636,6 +2336,56 @@ const SKIPPED_DIRS = /* @__PURE__ */ new Set([
|
|
|
1636
2336
|
GROUND_FIXTURES,
|
|
1637
2337
|
"node_modules"
|
|
1638
2338
|
]);
|
|
2339
|
+
/** The four ground names — what a document may never sit under (C16). */
|
|
2340
|
+
const GROUND_NAMES = new Set(GROUND_DIRS);
|
|
2341
|
+
/**
|
|
2342
|
+
* The passes a run over a `specs/` TREE puts on it.
|
|
2343
|
+
*
|
|
2344
|
+
* The three `d4*`/`d10w` ids are sub-scans of {@link checkConventionFiles}; the
|
|
2345
|
+
* rest are the cross-file and facet passes. The CLI prints this list when a run
|
|
2346
|
+
* is clean, so what the reader is told ran is the registry itself.
|
|
2347
|
+
*/
|
|
2348
|
+
const TREE_PASS_IDS = [
|
|
2349
|
+
"a7-database-property",
|
|
2350
|
+
"b5-await-using-inference",
|
|
2351
|
+
"c12-spec-file-name",
|
|
2352
|
+
"c14-pool-fixture-shared",
|
|
2353
|
+
"c15-local-fixture-reach",
|
|
2354
|
+
"c16-document-outside-ground",
|
|
2355
|
+
"c18-module-test-under-facet",
|
|
2356
|
+
"c20-facet-folder",
|
|
2357
|
+
"c21w-ground-owned-by-one",
|
|
2358
|
+
"c23-seed-no-clock-read",
|
|
2359
|
+
"c8-spec-registered-name",
|
|
2360
|
+
"c9-dead-fixtures",
|
|
2361
|
+
"d10w-tokens-in-requests",
|
|
2362
|
+
"d11w-spec-silent-refusal",
|
|
2363
|
+
"d4-malformed-ref",
|
|
2364
|
+
"d4-unknown-token",
|
|
2365
|
+
"d4b-http-first-line",
|
|
2366
|
+
"d4b-spec-block-scalar",
|
|
2367
|
+
"d4b-spec-key-order",
|
|
2368
|
+
"d4b-spec-shape",
|
|
2369
|
+
"d5-spec-volatile-literal",
|
|
2370
|
+
"d5w-spec-pinned-value",
|
|
2371
|
+
"j3w-spec-empty-assertion",
|
|
2372
|
+
"j4-spec-description-unique",
|
|
2373
|
+
"j9-checker-suppression-reason",
|
|
2374
|
+
"j5-spec-description"
|
|
2375
|
+
];
|
|
2376
|
+
/**
|
|
2377
|
+
* The passes a run over one workspace MEMBER puts on it — what no file under
|
|
2378
|
+
* `specs/` can answer: whether the member states how its tests run, whether its
|
|
2379
|
+
* config draws a browser, what it declares, and whether a `.spec.ts` of its own
|
|
2380
|
+
* ever reached a specs tree.
|
|
2381
|
+
*/
|
|
2382
|
+
const MEMBER_PASS_IDS = [
|
|
2383
|
+
"c12-spec-file-name",
|
|
2384
|
+
"e3-config-present",
|
|
2385
|
+
"e5b-no-simulated-dom-config-member",
|
|
2386
|
+
"f8-no-seam-dependency"
|
|
2387
|
+
];
|
|
2388
|
+
[.../* @__PURE__ */ new Set([...TREE_PASS_IDS, ...MEMBER_PASS_IDS])];
|
|
1639
2389
|
/**
|
|
1640
2390
|
* Scan one fixture text for tokens outside the grammar: unknown kinds
|
|
1641
2391
|
* (`{{userid}}`) and malformed captures of a known kind (`{{iso8601#}}`,
|
|
@@ -1710,7 +2460,7 @@ function checkSpecFile(text, rel) {
|
|
|
1710
2460
|
return [{
|
|
1711
2461
|
file: rel,
|
|
1712
2462
|
line: error.line,
|
|
1713
|
-
message: `${error.message} (D4b — see docs/
|
|
2463
|
+
message: `${error.message} (D4b — see docs/19-linting.md)`,
|
|
1714
2464
|
rule: "d4b",
|
|
1715
2465
|
severity: "error"
|
|
1716
2466
|
}];
|
|
@@ -1730,6 +2480,37 @@ function checkSpecFile(text, rel) {
|
|
|
1730
2480
|
return violations;
|
|
1731
2481
|
}
|
|
1732
2482
|
/**
|
|
2483
|
+
* C23 — a seed states absolute timestamps; it never reads the clock.
|
|
2484
|
+
*
|
|
2485
|
+
* A `_seeds/*.sql` that writes `datetime('now')` makes the row's date the date
|
|
2486
|
+
* the suite happened to run, so a case about "published yesterday" proves
|
|
2487
|
+
* something different every day and nothing at all across midnight. Worse under
|
|
2488
|
+
* SQLite, where `datetime('now')` writes a space-separated text while an ORM
|
|
2489
|
+
* writes ISO `T`: the two sort against each other, and the ordering breaks on
|
|
2490
|
+
* the days the values straddle a calendar boundary.
|
|
2491
|
+
*
|
|
2492
|
+
* The chain already owns the answer — `.clock()` pins what the code under test
|
|
2493
|
+
* reads — so the seed has nothing to ask the machine for.
|
|
2494
|
+
*/
|
|
2495
|
+
function checkSeedClockReads(path, rel, name) {
|
|
2496
|
+
if (!name.endsWith(".sql")) return [];
|
|
2497
|
+
const text = decodeText(path);
|
|
2498
|
+
if (text === null) return [];
|
|
2499
|
+
const violations = [];
|
|
2500
|
+
for (const [index, line] of withoutComments$1(text).split("\n").entries()) {
|
|
2501
|
+
const found = CLOCK_READ.exec(line);
|
|
2502
|
+
if (found === null) continue;
|
|
2503
|
+
violations.push({
|
|
2504
|
+
file: rel,
|
|
2505
|
+
line: index + 1,
|
|
2506
|
+
message: `${rel}:${index + 1}: \`${found[0]}\` reads the clock — a seed states the instant the case is about, and the chain pins the run's with \`.clock()\` (C23 — see docs/19-linting.md)`,
|
|
2507
|
+
rule: "c23-seed-no-clock-read",
|
|
2508
|
+
severity: "error"
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
return violations;
|
|
2512
|
+
}
|
|
2513
|
+
/**
|
|
1733
2514
|
* Walk `rootDir` and check every fixture file. Paths in the result are relative
|
|
1734
2515
|
* to `rootDir`. Errors fail the checker; warnings are advisory.
|
|
1735
2516
|
*/
|
|
@@ -1752,12 +2533,23 @@ function checkConventionFiles(rootDir) {
|
|
|
1752
2533
|
continue;
|
|
1753
2534
|
}
|
|
1754
2535
|
if (entry.name.endsWith(".spec.yaml")) {
|
|
2536
|
+
if (rel.split(/[/\\]/u).some((segment) => GROUND_NAMES.has(segment))) violations.push({
|
|
2537
|
+
file: rel,
|
|
2538
|
+
line: 1,
|
|
2539
|
+
message: `${rel}:1: a \`${SPEC_EXTENSION}\` document is a SPEC, not ground — move it beside the test that runs it (C16 — see docs/19-linting.md)`,
|
|
2540
|
+
rule: "c16-document-outside-ground",
|
|
2541
|
+
severity: "error"
|
|
2542
|
+
});
|
|
1755
2543
|
const text = decodeText(path);
|
|
1756
2544
|
if (text !== null) violations.push(...checkSpecFile(text, rel), ...checkSpecConventions(text, rel, path));
|
|
1757
2545
|
continue;
|
|
1758
2546
|
}
|
|
1759
2547
|
if (inside === null) continue;
|
|
1760
2548
|
const depth1 = dir.endsWith(`/${inside}`) || dir.endsWith(`\\${inside}`);
|
|
2549
|
+
if (inside === "_seeds") {
|
|
2550
|
+
violations.push(...checkSeedClockReads(path, rel, entry.name));
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
1761
2553
|
if (inside === "_requests") {
|
|
1762
2554
|
if (!entry.name.endsWith(".http")) continue;
|
|
1763
2555
|
const text = decodeText(path);
|
|
@@ -1765,14 +2557,14 @@ function checkConventionFiles(rootDir) {
|
|
|
1765
2557
|
if (depth1 && !REQUEST_LINE.test(firstLine(text))) violations.push({
|
|
1766
2558
|
file: rel,
|
|
1767
2559
|
line: 1,
|
|
1768
|
-
message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/
|
|
2560
|
+
message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/19-linting.md)`,
|
|
1769
2561
|
rule: "d4b",
|
|
1770
2562
|
severity: "error"
|
|
1771
2563
|
});
|
|
1772
2564
|
for (const { line, token } of findKnownTokens(text)) violations.push({
|
|
1773
2565
|
file: rel,
|
|
1774
2566
|
line,
|
|
1775
|
-
message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/
|
|
2567
|
+
message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/19-linting.md)`,
|
|
1776
2568
|
rule: "d10w",
|
|
1777
2569
|
severity: "warn",
|
|
1778
2570
|
token
|
|
@@ -1784,7 +2576,7 @@ function checkConventionFiles(rootDir) {
|
|
|
1784
2576
|
if (depth1 && entry.name.endsWith(".http") && !STATUS_LINE.test(firstLine(text))) violations.push({
|
|
1785
2577
|
file: rel,
|
|
1786
2578
|
line: 1,
|
|
1787
|
-
message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/
|
|
2579
|
+
message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/19-linting.md)`,
|
|
1788
2580
|
rule: "d4b",
|
|
1789
2581
|
severity: "error"
|
|
1790
2582
|
});
|
|
@@ -1796,6 +2588,17 @@ function checkConventionFiles(rootDir) {
|
|
|
1796
2588
|
severity: "error",
|
|
1797
2589
|
token
|
|
1798
2590
|
});
|
|
2591
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
2592
|
+
const volatileLiteral = volatileLiteralIn(line);
|
|
2593
|
+
if (volatileLiteral === void 0) continue;
|
|
2594
|
+
violations.push({
|
|
2595
|
+
file: rel,
|
|
2596
|
+
line: index + 1,
|
|
2597
|
+
message: `${rel}:${index + 1}: "${volatileLiteral.found}" is a value the next run will not reproduce — write ${volatileLiteral.token} (D5 — see docs/19-linting.md)`,
|
|
2598
|
+
rule: "d5-spec-volatile-literal",
|
|
2599
|
+
severity: "error"
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
1799
2602
|
}
|
|
1800
2603
|
};
|
|
1801
2604
|
visit(rootDir, null);
|
|
@@ -1809,6 +2612,11 @@ function checkConventionFiles(rootDir) {
|
|
|
1809
2612
|
*/
|
|
1810
2613
|
function runAllChecks(rootDir) {
|
|
1811
2614
|
return [
|
|
2615
|
+
...checkTestUnderFacet(rootDir),
|
|
2616
|
+
...checkFacetFolder(rootDir),
|
|
2617
|
+
...checkModuleTestUnderFacet(rootDir),
|
|
2618
|
+
...checkGroundOwnedByOne(rootDir),
|
|
2619
|
+
...checkSuppressionReason(rootDir),
|
|
1812
2620
|
...checkConventionFiles(rootDir),
|
|
1813
2621
|
...checkSpecDescriptionsUnique(rootDir),
|
|
1814
2622
|
...checkDeadFixtures(rootDir),
|
|
@@ -1827,33 +2635,55 @@ function formatViolations(violations) {
|
|
|
1827
2635
|
/**
|
|
1828
2636
|
* CLI entry for the conventions checker (bundled as `dist/checker.js`).
|
|
1829
2637
|
*
|
|
1830
|
-
* node dist/checker.js [
|
|
2638
|
+
* node dist/checker.js [dir] [--fix] # a specs tree, or a project root
|
|
1831
2639
|
* node dist/checker.js --format json # every root, every member
|
|
1832
2640
|
* node dist/checker.js --member <dir> # one workspace member
|
|
1833
2641
|
*
|
|
1834
|
-
* Three runs, one reporter.
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
2642
|
+
* Three runs, one reporter. A PATH is read for what it IS. Name a specs tree
|
|
2643
|
+
* — a directory with a `specs` segment in its path — and it walks that tree
|
|
2644
|
+
* with every tree pass: the token/HTTP grammar (D4 / D4b / D10), the
|
|
2645
|
+
* `<case>.spec.yaml` document conventions, the cross-file passes (C9 dead
|
|
1837
2646
|
* fixtures, C14/C15 fixture placement, B5 await-using inference, A7 database
|
|
1838
|
-
* property)
|
|
1839
|
-
*
|
|
1840
|
-
*
|
|
2647
|
+
* property) and the facet passes (C12, C18, C20, C21w), which read the first
|
|
2648
|
+
* level of the tree and are the reason the anchor has to be right. Name
|
|
2649
|
+
* anything else and it is a PROJECT ROOT: the run is the path-less one,
|
|
2650
|
+
* anchored there. With `--member <dir>` it runs the member pass (E3
|
|
2651
|
+
* config-present, E5b no simulated DOM in a config, F8 no seam dependency)
|
|
2652
|
+
* over one workspace member, whether or not that member has a `specs/` root.
|
|
1841
2653
|
*
|
|
1842
2654
|
* With NO path it runs both over the whole project: every `specs/` root it
|
|
1843
2655
|
* discovers from the root manifest's workspaces, and every member. That is the
|
|
1844
2656
|
* run `@jterrazz/typescript`'s ratchet rests on, so the contract is exactly
|
|
1845
2657
|
* "a path-less run reports what the per-root and per-member runs report".
|
|
1846
2658
|
*
|
|
2659
|
+
* A NAMED root that turns out to hold no `specs/` tree at all is refused, loud
|
|
2660
|
+
* and non-zero. Pointed at a project root, the facet passes used to key on its
|
|
2661
|
+
* first level, find no facet folder there and report nothing — `<project>` and
|
|
2662
|
+
* `<project>/specs` disagreed on the same tree, and the run that reported
|
|
2663
|
+
* nothing was the one a CI line was likelier to be written with.
|
|
2664
|
+
*
|
|
1847
2665
|
* `--format json` prints `[{ code, file, line, severity, message }]` on stdout
|
|
1848
2666
|
* and nothing else, where `code` is `jterrazz-check(<id>)` — the namespace the
|
|
1849
2667
|
* ratchet records under. Exit 1 on any ERROR-level violation either way;
|
|
1850
2668
|
* warnings are reported but never fail the run.
|
|
1851
2669
|
*
|
|
1852
2670
|
* `--fix` applies the rewritable passes — the two document ones (key order and
|
|
1853
|
-
* block scalars)
|
|
1854
|
-
*
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
2671
|
+
* block scalars), C14, which MOVES a single-reader pool fixture beside its leaf
|
|
2672
|
+
* and rewrites the literals that named it, and C12, which RENAMES a `.test.ts`
|
|
2673
|
+
* under a facet folder to `.spec.ts` — then checks what is left, so a run that
|
|
2674
|
+
* fixes everything exits 0. With no path it applies them to every `specs/` root
|
|
2675
|
+
* it discovers: `--fix` means the same thing whichever run the operator typed.
|
|
2676
|
+
*
|
|
2677
|
+
* After a C12 rename it NAMES the entries of the owning member's configs that
|
|
2678
|
+
* the rename BROKE — a glob whose prefix covers a file it moved, or a literal
|
|
2679
|
+
* naming one — and nothing else. An entry the rename left behind collects
|
|
2680
|
+
* nothing, the run stays green with fewer files, and nothing else in the
|
|
2681
|
+
* toolchain would have said a word.
|
|
2682
|
+
*
|
|
2683
|
+
* C12's rename goes through `git mv` where the tree is a git working tree, so
|
|
2684
|
+
* the history follows the file across a migration that touches hundreds of
|
|
2685
|
+
* them; outside one it falls back to a plain rename. C14's move stays a plain
|
|
2686
|
+
* rename and the author stages what the working tree now shows.
|
|
1857
2687
|
*/
|
|
1858
2688
|
const argv = process.argv.slice(2);
|
|
1859
2689
|
const fix = argv.includes("--fix");
|
|
@@ -1871,10 +2701,17 @@ function valueOf(flag) {
|
|
|
1871
2701
|
const index = argv.indexOf(flag);
|
|
1872
2702
|
return index === -1 ? void 0 : argv[index + 1];
|
|
1873
2703
|
}
|
|
1874
|
-
/**
|
|
1875
|
-
|
|
2704
|
+
/**
|
|
2705
|
+
* What a run names when it is clean — the pass REGISTRY, never a sentence
|
|
2706
|
+
* written beside it.
|
|
2707
|
+
*
|
|
2708
|
+
* The hand-written list stopped at the cross-file passes of 15.x while the
|
|
2709
|
+
* channel grew four tree passes and a suppression gate, so the summary
|
|
2710
|
+
* under-reported exactly what it exists to report.
|
|
2711
|
+
*/
|
|
2712
|
+
const TREE_PASSES = TREE_PASS_IDS.join(", ");
|
|
1876
2713
|
/** The passes a member run puts over a package. */
|
|
1877
|
-
const MEMBER_PASSES = "
|
|
2714
|
+
const MEMBER_PASSES = MEMBER_PASS_IDS.join(", ");
|
|
1878
2715
|
/**
|
|
1879
2716
|
* Print the findings and exit on the first error-level one. `passes` names the
|
|
1880
2717
|
* ones that ACTUALLY ran: a member run that claimed the tree passes would be
|
|
@@ -1884,7 +2721,7 @@ function report(violations, what, passes) {
|
|
|
1884
2721
|
const errors = violations.filter((violation) => violation.severity === "error");
|
|
1885
2722
|
if (json) {
|
|
1886
2723
|
console.log(JSON.stringify(violations.map((violation) => ({
|
|
1887
|
-
code: `jterrazz-check(${violation.rule})`,
|
|
2724
|
+
code: `jterrazz-check(${codeOf(violation.rule)})`,
|
|
1888
2725
|
file: violation.file,
|
|
1889
2726
|
line: violation.line,
|
|
1890
2727
|
message: violation.message,
|
|
@@ -1914,22 +2751,191 @@ if (member !== void 0) {
|
|
|
1914
2751
|
const dir = requireDirectory(member, "member directory");
|
|
1915
2752
|
report(checkMember(dir, root), `for member ${relative(root, dir) || "."}`, MEMBER_PASSES);
|
|
1916
2753
|
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
2754
|
+
/**
|
|
2755
|
+
* Rename one file, keeping git's record of it where there is one.
|
|
2756
|
+
*
|
|
2757
|
+
* `git mv` is tried first and its failure is not an error: a path outside a
|
|
2758
|
+
* working tree, or one git does not track yet, renames exactly as well with
|
|
2759
|
+
* `renameSync` — what must not happen is a migration that loses the history of
|
|
2760
|
+
* every spec it touches because the tool reached for the blunter call.
|
|
2761
|
+
*/
|
|
2762
|
+
function renameKeepingHistory(from, to) {
|
|
2763
|
+
if (spawnSync("git", [
|
|
2764
|
+
"mv",
|
|
2765
|
+
from,
|
|
2766
|
+
to
|
|
2767
|
+
], {
|
|
2768
|
+
cwd: dirname(from),
|
|
2769
|
+
stdio: "ignore"
|
|
2770
|
+
}).status !== 0) renameSync(from, to);
|
|
2771
|
+
}
|
|
2772
|
+
/** C12's mover: every `.test.ts` under a facet folder becomes a `.spec.ts`. */
|
|
2773
|
+
function fixSpecSuffixes(root) {
|
|
2774
|
+
const renamed = [];
|
|
2775
|
+
for (const { from, to } of movesUnderFacet(root)) {
|
|
2776
|
+
if (to === void 0 || existsSync(to)) continue;
|
|
2777
|
+
renameKeepingHistory(from, to);
|
|
2778
|
+
renamed.push({
|
|
2779
|
+
from,
|
|
2780
|
+
to
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
return renamed;
|
|
2784
|
+
}
|
|
2785
|
+
/** Where a member states what it collects, whatever extension it writes it in. */
|
|
2786
|
+
const CONFIG_NAMES = [
|
|
2787
|
+
"vitest.config.ts",
|
|
2788
|
+
"vitest.config.mts",
|
|
2789
|
+
"vitest.config.cts",
|
|
2790
|
+
"vitest.config.js",
|
|
2791
|
+
"vitest.config.mjs",
|
|
2792
|
+
"vitest.config.cjs"
|
|
2793
|
+
];
|
|
2794
|
+
/** What a config COLLECTS: the two keys whose entries name test files. */
|
|
2795
|
+
const COLLECTION = /\b(?:include|exclude)\s*:\s*\[(?<entries>[^\]]*)\]/gu;
|
|
2796
|
+
/** One quoted entry of such a list. */
|
|
2797
|
+
const ENTRY = /['"`](?<entry>[^'"`\n]*)['"`]/gu;
|
|
2798
|
+
/** The quotes a config's entries are written in. */
|
|
2799
|
+
const QUOTES = /* @__PURE__ */ new Set([
|
|
2800
|
+
"'",
|
|
2801
|
+
"\"",
|
|
2802
|
+
"`"
|
|
2803
|
+
]);
|
|
2804
|
+
/** The index of the newline ending the `//` comment that starts at `index`. */
|
|
2805
|
+
function endOfLineComment(text, index) {
|
|
2806
|
+
const newline = text.indexOf("\n", index);
|
|
2807
|
+
return newline === -1 ? text.length : newline;
|
|
2808
|
+
}
|
|
2809
|
+
/** The index just past the block comment that starts at `index`. */
|
|
2810
|
+
function endOfBlockComment(text, index) {
|
|
2811
|
+
const close = text.indexOf("*/", index + 2);
|
|
2812
|
+
return close === -1 ? text.length : close + 2;
|
|
2813
|
+
}
|
|
2814
|
+
/**
|
|
2815
|
+
* The config, with what is not code taken out.
|
|
2816
|
+
*
|
|
2817
|
+
* A notice that read a COMMENT as an include told signews-mobile to follow a
|
|
2818
|
+
* glob no project collected on. The scan is character by character because a
|
|
2819
|
+
* regex cannot tell the two apart: `specs/api/**` + `/*.test.ts` opens a block
|
|
2820
|
+
* comment to any pattern that does not know it is inside a string, and a `://`
|
|
2821
|
+
* opens a line comment to one that does not either.
|
|
2822
|
+
*/
|
|
2823
|
+
function withoutComments(text) {
|
|
2824
|
+
let out = "";
|
|
2825
|
+
let quote = null;
|
|
2826
|
+
let index = 0;
|
|
2827
|
+
while (index < text.length) {
|
|
2828
|
+
const char = text[index] ?? "";
|
|
2829
|
+
const pair = text.slice(index, index + 2);
|
|
2830
|
+
if (quote !== null) {
|
|
2831
|
+
const escaped = char === "\\";
|
|
2832
|
+
out += escaped ? pair : char;
|
|
2833
|
+
quote = char === quote ? null : quote;
|
|
2834
|
+
index += escaped ? 2 : 1;
|
|
2835
|
+
} else if (pair === "//") {
|
|
2836
|
+
out += "\n";
|
|
2837
|
+
index = endOfLineComment(text, index);
|
|
2838
|
+
} else if (pair === "/*") index = endOfBlockComment(text, index);
|
|
2839
|
+
else {
|
|
2840
|
+
quote = QUOTES.has(char) ? char : null;
|
|
2841
|
+
out += char;
|
|
2842
|
+
index += 1;
|
|
2843
|
+
}
|
|
1923
2844
|
}
|
|
2845
|
+
return out;
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Does this entry of a config sitting in `dir` reach `file`?
|
|
2849
|
+
*
|
|
2850
|
+
* A glob reaches what its literal PREFIX — everything before the first `*` —
|
|
2851
|
+
* contains; an entry with no `*` reaches the one file it names. Both are
|
|
2852
|
+
* resolved against the config's own directory, which is what vitest does.
|
|
2853
|
+
*/
|
|
2854
|
+
function reaches(dir, entry, file) {
|
|
2855
|
+
const star = entry.indexOf("*");
|
|
2856
|
+
if (star === -1) return resolve(dir, entry) === file;
|
|
2857
|
+
const prefix = resolve(dir, entry.slice(0, star));
|
|
2858
|
+
return file === prefix || file.startsWith(`${prefix}/`);
|
|
2859
|
+
}
|
|
2860
|
+
/** Every entry of every `include`/`exclude` list a config states. */
|
|
2861
|
+
function collectedEntries(text) {
|
|
2862
|
+
const entries = [];
|
|
2863
|
+
for (const list of text.matchAll(COLLECTION)) for (const match of (list.groups?.entries ?? "").matchAll(ENTRY)) entries.push(match.groups?.entry ?? "");
|
|
2864
|
+
return entries;
|
|
2865
|
+
}
|
|
2866
|
+
/** What one broken entry says, glob or literal. */
|
|
2867
|
+
function noticeFor(where, entry) {
|
|
2868
|
+
return entry.includes("*") ? `${where}: ${entry} still names \`.test.ts\` — the files it collected are \`.spec.ts\` now, and the project collects nothing until the glob follows (C12)` : `${where}: ${entry} names a file this run renamed — it is \`${entry.slice(0, -8)}.spec.ts\` now, and the entry collects nothing until it follows (C12)`;
|
|
2869
|
+
}
|
|
2870
|
+
/** The entries of ONE config the renames broke. */
|
|
2871
|
+
function staleEntriesIn(path, owner, renamed) {
|
|
2872
|
+
const directory = dirname(path);
|
|
2873
|
+
return collectedEntries(withoutComments(readFileSync(path, "utf8"))).filter((entry) => entry.endsWith(".test.ts")).filter((entry) => renamed.some(({ from }) => reaches(directory, entry, from))).map((entry) => noticeFor(relative(owner, path), entry));
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* The entries a rename leaves behind.
|
|
2877
|
+
*
|
|
2878
|
+
* A consumer that names its suffix in an `include` loses the whole suite the
|
|
2879
|
+
* moment the mover renames it: vitest collects fewer files, exits 0, and the
|
|
2880
|
+
* only trace is a number nobody compares. The mover owns that consequence —
|
|
2881
|
+
* it cannot rewrite a config it does not parse, so it NAMES what it broke, and
|
|
2882
|
+
* the author updates it in the same commit as the rename.
|
|
2883
|
+
*
|
|
2884
|
+
* What it names is exactly what it broke: an entry of an `include`/`exclude`
|
|
2885
|
+
* list, never a comment, and only where the entry reaches a file this run
|
|
2886
|
+
* actually moved. A glob over `src/` collecting module tests the mover never
|
|
2887
|
+
* touched is not a stale glob, and four repositories of the wave were told it
|
|
2888
|
+
* was — one of them would have emptied its unit project by following the
|
|
2889
|
+
* notice.
|
|
2890
|
+
*/
|
|
2891
|
+
function staleIncludes(root, renamed) {
|
|
2892
|
+
if (renamed.length === 0) return [];
|
|
2893
|
+
const owner = packageRootOf(root) ?? dirname(root);
|
|
2894
|
+
return [.../* @__PURE__ */ new Set([
|
|
2895
|
+
root,
|
|
2896
|
+
owner,
|
|
2897
|
+
dirname(root)
|
|
2898
|
+
])].flatMap((directory) => CONFIG_NAMES.map((name) => join(directory, name)).filter((path) => existsSync(path))).flatMap((path) => staleEntriesIn(path, owner, renamed));
|
|
2899
|
+
}
|
|
2900
|
+
/** Apply every rewritable pass over one tree, printing what it did. */
|
|
2901
|
+
function applyFixes(root) {
|
|
2902
|
+
const renamed = fixSpecSuffixes(root);
|
|
2903
|
+
for (const { from, to } of renamed) console.log(`conventions checker: renamed ${relative(root, from)} → ${relative(root, to)} (C12)`);
|
|
2904
|
+
for (const stale of staleIncludes(root, renamed)) console.log(`conventions checker: ${stale}`);
|
|
2905
|
+
const written = fixSpecFiles(root);
|
|
2906
|
+
if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
|
|
2907
|
+
for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* Is this directory a specs tree?
|
|
2911
|
+
*
|
|
2912
|
+
* The `specs` SEGMENT is the anchor every other pass reads (`specsAnchor`), so
|
|
2913
|
+
* the CLI reads it too: `specs`, `apps/web/specs` and `specs/api` are trees —
|
|
2914
|
+
* a subtree of one is still inside it — and everything else is a project root
|
|
2915
|
+
* holding zero or more of them.
|
|
2916
|
+
*/
|
|
2917
|
+
function isSpecsTree(dir) {
|
|
2918
|
+
return dir.split(/[/\\]/u).includes("specs");
|
|
2919
|
+
}
|
|
2920
|
+
if (positional !== void 0 && isSpecsTree(requireDirectory(positional, "directory"))) {
|
|
2921
|
+
const root = requireDirectory(positional, "directory");
|
|
2922
|
+
if (fix) applyFixes(root);
|
|
1924
2923
|
report(runAllChecks(root), `under ${root}`, TREE_PASSES);
|
|
1925
2924
|
}
|
|
1926
|
-
const root = resolve(".");
|
|
2925
|
+
const root = positional === void 0 ? resolve(".") : requireDirectory(positional, "directory");
|
|
2926
|
+
const specsRoots = discoverSpecRoots(root);
|
|
2927
|
+
if (positional !== void 0 && specsRoots.length === 0) {
|
|
2928
|
+
console.error(`conventions checker: no \`specs/\` tree under ${root} — name a specs tree, or a project root that holds one`);
|
|
2929
|
+
process.exit(1);
|
|
2930
|
+
}
|
|
1927
2931
|
const found = [];
|
|
1928
|
-
for (const specsRoot of
|
|
2932
|
+
for (const specsRoot of specsRoots) {
|
|
1929
2933
|
const prefix = relative(root, specsRoot);
|
|
2934
|
+
if (fix) applyFixes(specsRoot);
|
|
1930
2935
|
for (const violation of runAllChecks(specsRoot)) found.push({
|
|
1931
2936
|
...violation,
|
|
1932
|
-
file: `${prefix}/${violation.file}
|
|
2937
|
+
file: `${prefix}/${violation.file}`,
|
|
2938
|
+
message: `${prefix}/${violation.message}`
|
|
1933
2939
|
});
|
|
1934
2940
|
}
|
|
1935
2941
|
found.push(...checkMembers(root));
|