@jterrazz/test 12.0.2 → 12.2.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 +18 -2
- package/dist/checker.js +73 -7
- package/dist/index.d.ts +179 -16
- package/dist/index.js +60 -1141
- package/dist/literate.js +1734 -0
- package/dist/match.js +193 -1
- package/dist/oxlint.cjs +129 -15
- package/dist/oxlint.js +129 -15
- package/dist/queue.js +3 -295
- package/dist/vitest.d.ts +41 -0
- package/dist/vitest.js +68 -0
- package/package.json +5 -1
package/dist/match.js
CHANGED
|
@@ -1,3 +1,195 @@
|
|
|
1
|
+
//#region src/core/literate/literate-file.ts
|
|
2
|
+
/** The closed set of header keys. Anything else is an error naming the line. */
|
|
3
|
+
const HEADER_KEYS = [
|
|
4
|
+
"test",
|
|
5
|
+
"given",
|
|
6
|
+
"then",
|
|
7
|
+
"fixture",
|
|
8
|
+
"env",
|
|
9
|
+
"serve"
|
|
10
|
+
];
|
|
11
|
+
/** The separator opening a block's stderr section. */
|
|
12
|
+
const STDERR_MARKER = "--- stderr";
|
|
13
|
+
const HEADER_LINE = /^(?<key>[a-z]+):(?<value>.*)$/;
|
|
14
|
+
const EXIT_LINE = /^exit:\s*(?<code>-?\d+)\s*$/;
|
|
15
|
+
const KEY_SET = new Set(HEADER_KEYS);
|
|
16
|
+
/** A refusal from the grammar, carrying where it happened and which rule it is. */
|
|
17
|
+
var LiterateSyntaxError = class extends Error {
|
|
18
|
+
defect;
|
|
19
|
+
line;
|
|
20
|
+
constructor(fileName, line, message, defect) {
|
|
21
|
+
super(`${fileName}:${line}: ${message}`);
|
|
22
|
+
this.defect = defect;
|
|
23
|
+
this.line = line;
|
|
24
|
+
this.name = "LiterateSyntaxError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function fail(fileName, line, message, defect = "shape") {
|
|
28
|
+
throw new LiterateSyntaxError(fileName, line, message, defect);
|
|
29
|
+
}
|
|
30
|
+
/** Is this line the start of a block? `$ cmd`, or a bare `$` (no arguments). */
|
|
31
|
+
function isBlockStart(line) {
|
|
32
|
+
return line === "$" || line.startsWith("$ ");
|
|
33
|
+
}
|
|
34
|
+
function parseEnvTokens(value, fileName, line) {
|
|
35
|
+
const tokens = [];
|
|
36
|
+
for (const token of value.split(/\s+/).filter((part) => part.length > 0)) {
|
|
37
|
+
const separator = token.indexOf("=");
|
|
38
|
+
if (separator === -1) {
|
|
39
|
+
tokens.push({
|
|
40
|
+
kind: "set",
|
|
41
|
+
name: token
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const key = token.slice(0, separator);
|
|
46
|
+
if (key.length === 0) fail(fileName, line, `env: "${token}" has no variable name before the "="`);
|
|
47
|
+
tokens.push({
|
|
48
|
+
key,
|
|
49
|
+
kind: "pair",
|
|
50
|
+
value: token.slice(separator + 1)
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (tokens.length === 0) fail(fileName, line, "env: needs at least one KEY=value pair or a registered env-set name");
|
|
54
|
+
return tokens;
|
|
55
|
+
}
|
|
56
|
+
function parseServeEntry(value, fileName, line) {
|
|
57
|
+
const parts = value.split(/\s+/).filter((part) => part.length > 0);
|
|
58
|
+
const name = parts.shift();
|
|
59
|
+
if (name === void 0) fail(fileName, line, "serve: needs the name of a server registered in specification.cli()");
|
|
60
|
+
if (name.includes("=")) fail(fileName, line, `serve: "${name}" is a KEY=value pair — the server NAME comes first`);
|
|
61
|
+
const env = {};
|
|
62
|
+
for (const part of parts) {
|
|
63
|
+
const separator = part.indexOf("=");
|
|
64
|
+
if (separator <= 0) fail(fileName, line, `serve: "${part}" is not a KEY=value pair`);
|
|
65
|
+
env[part.slice(0, separator)] = part.slice(separator + 1);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
env,
|
|
69
|
+
line,
|
|
70
|
+
name
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse the header: every line up to the first blank one. `#` lines are
|
|
75
|
+
* comments; `test:`, `given:` and `then:` are mandatory and single; `fixture:`,
|
|
76
|
+
* `env:` and `serve:` repeat and keep their declaration order.
|
|
77
|
+
*/
|
|
78
|
+
function parseHeader(lines, fileName) {
|
|
79
|
+
const fixtures = [];
|
|
80
|
+
const env = [];
|
|
81
|
+
const serve = [];
|
|
82
|
+
const singles = /* @__PURE__ */ new Map();
|
|
83
|
+
let index = 0;
|
|
84
|
+
for (; index < lines.length; index++) {
|
|
85
|
+
const raw = lines[index];
|
|
86
|
+
if (raw.trim() === "") break;
|
|
87
|
+
const number = index + 1;
|
|
88
|
+
if (raw.trimStart().startsWith("#")) continue;
|
|
89
|
+
if (isBlockStart(raw)) fail(fileName, number, "a \"$\" block starts before the header is closed — separate the header from the blocks with a blank line");
|
|
90
|
+
const found = HEADER_LINE.exec(raw);
|
|
91
|
+
if (!found?.groups) fail(fileName, number, `"${raw}" is not a header line — expected "<key>: <value>" (keys: ${HEADER_KEYS.join(", ")})`);
|
|
92
|
+
const key = found.groups.key;
|
|
93
|
+
const value = found.groups.value.trim();
|
|
94
|
+
if (!KEY_SET.has(key)) fail(fileName, number, `unknown header key "${key}:" — known keys: ${HEADER_KEYS.join(", ")}`);
|
|
95
|
+
if (key === "fixture" || key === "env" || key === "serve") {
|
|
96
|
+
if (value.length === 0) fail(fileName, number, `${key}: is empty`);
|
|
97
|
+
if (key === "fixture") fixtures.push(value);
|
|
98
|
+
else if (key === "env") env.push(...parseEnvTokens(value, fileName, number));
|
|
99
|
+
else serve.push(parseServeEntry(value, fileName, number));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (singles.has(key)) fail(fileName, number, `${key}: is declared twice — it takes exactly one line`);
|
|
103
|
+
if (value.length === 0) fail(fileName, number, `${key}: is empty`);
|
|
104
|
+
singles.set(key, value);
|
|
105
|
+
}
|
|
106
|
+
for (const key of [
|
|
107
|
+
"test",
|
|
108
|
+
"given",
|
|
109
|
+
"then"
|
|
110
|
+
]) if (!singles.has(key)) fail(fileName, 1, `missing "${key}:" in the header — test, given and then are mandatory`, "narrative");
|
|
111
|
+
const header = {
|
|
112
|
+
env,
|
|
113
|
+
fixtures,
|
|
114
|
+
given: singles.get("given"),
|
|
115
|
+
serve,
|
|
116
|
+
test: singles.get("test"),
|
|
117
|
+
then: singles.get("then")
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
end: index,
|
|
121
|
+
header
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Split a block's stream section at `--- stderr`, dropping the ONE blank line
|
|
126
|
+
* that separates the block from the next `$` (that line belongs to the
|
|
127
|
+
* separator, not to the stream).
|
|
128
|
+
*/
|
|
129
|
+
function parseStreams(section, trailingSeparator) {
|
|
130
|
+
const lines = [...section];
|
|
131
|
+
if (trailingSeparator && lines.at(-1) === "") lines.pop();
|
|
132
|
+
const marker = lines.indexOf(STDERR_MARKER);
|
|
133
|
+
if (marker === -1) return {
|
|
134
|
+
stderr: null,
|
|
135
|
+
stdout: lines.join("\n")
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
stderr: lines.slice(marker + 1).join("\n"),
|
|
139
|
+
stdout: lines.slice(0, marker).join("\n")
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Parse the blocks: everything after the header's blank line. */
|
|
143
|
+
function parseBlocks(lines, start, fileName) {
|
|
144
|
+
const starts = [];
|
|
145
|
+
for (let index = start; index < lines.length; index++) if (isBlockStart(lines[index])) starts.push(index);
|
|
146
|
+
if (starts.length === 0) fail(fileName, start + 1, "no \"$ <command>\" block — a literate spec runs at least one command");
|
|
147
|
+
for (let index = start; index < starts[0]; index++) if (lines[index].trim() !== "") fail(fileName, index + 1, `"${lines[index]}" sits outside a block — the first line after the header must be "$ <command>"`);
|
|
148
|
+
return starts.map((blockStart, position) => {
|
|
149
|
+
const next = starts[position + 1] ?? lines.length;
|
|
150
|
+
const argv = lines[blockStart].slice(1).trim();
|
|
151
|
+
const exitLine = lines[blockStart + 1];
|
|
152
|
+
const exit = exitLine === void 0 ? null : EXIT_LINE.exec(exitLine);
|
|
153
|
+
if (!exit?.groups) fail(fileName, blockStart + 2, `the line after "$ ${argv}" must be "exit: <integer>", got ${exitLine === void 0 ? "(end of file)" : `"${exitLine}"`}`);
|
|
154
|
+
const streams = parseStreams(lines.slice(blockStart + 2, next), position + 1 < starts.length);
|
|
155
|
+
return {
|
|
156
|
+
argv,
|
|
157
|
+
exitCode: Number(exit.groups.code),
|
|
158
|
+
line: blockStart + 1,
|
|
159
|
+
stderr: streams.stderr,
|
|
160
|
+
stdout: streams.stdout
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Parse a `<case>.cli` file. `fileName` is used verbatim in error messages, so
|
|
166
|
+
* pass the path the reader would open (`specs/cli/no-estate.cli`).
|
|
167
|
+
*/
|
|
168
|
+
function parseLiterateFile(content, fileName) {
|
|
169
|
+
const lines = content.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
|
|
170
|
+
const { end, header } = parseHeader(lines, fileName);
|
|
171
|
+
return {
|
|
172
|
+
blocks: parseBlocks(lines, end + 1, fileName),
|
|
173
|
+
header,
|
|
174
|
+
headerText: `${lines.slice(0, end).join("\n")}\n`
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Render one block back to its source form (the update-mode inverse of the parser). */
|
|
178
|
+
function serializeLiterateBlock(block) {
|
|
179
|
+
const lines = [`$ ${block.argv}`.trimEnd(), `exit: ${block.exitCode}`];
|
|
180
|
+
if (block.stdout.length > 0) lines.push(block.stdout);
|
|
181
|
+
if (block.stderr !== null && block.stderr.length > 0) lines.push(STDERR_MARKER, block.stderr);
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Rebuild a `.cli` file from its VERBATIM header text and a fresh set of
|
|
186
|
+
* blocks. Update mode rewrites only what follows each `$` — the header comes
|
|
187
|
+
* back byte-identical, comments and all.
|
|
188
|
+
*/
|
|
189
|
+
function serializeLiterateFile(headerText, blocks) {
|
|
190
|
+
return `${headerText}\n${blocks.map((block) => serializeLiterateBlock(block)).join("\n\n")}\n`;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
1
193
|
//#region src/core/matching/match.ts
|
|
2
194
|
/** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
|
|
3
195
|
const TOKEN_KINDS = [
|
|
@@ -161,4 +353,4 @@ var CaptureScope = class {
|
|
|
161
353
|
}
|
|
162
354
|
};
|
|
163
355
|
//#endregion
|
|
164
|
-
export { match as i, Matcher as n, TOKEN_KINDS as r, CaptureScope as t };
|
|
356
|
+
export { LiterateSyntaxError as a, match as i, Matcher as n, parseLiterateFile as o, TOKEN_KINDS as r, serializeLiterateFile as s, CaptureScope as t };
|
package/dist/oxlint.cjs
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
|
|
|
4
4
|
});
|
|
5
5
|
let node_path = require("node:path");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
|
+
let node_url = require("node:url");
|
|
7
8
|
//#region src/lint/fs-cache.ts
|
|
8
9
|
/**
|
|
9
10
|
* Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
|
|
@@ -305,7 +306,7 @@ const RULE_DOCS = {
|
|
|
305
306
|
},
|
|
306
307
|
"b4-given-then": {
|
|
307
308
|
channel: "statique",
|
|
308
|
-
convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur.",
|
|
309
|
+
convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur. Dans un spec littéraire `<cas>.cli`, les deux marqueurs sont les lignes `given:` et `then:` de l’en-tête (passe checker `b4-cli-header`).",
|
|
309
310
|
family: "B",
|
|
310
311
|
id: "B4",
|
|
311
312
|
rationale: "La narration Given/Then rend l’intention du test lisible sans lire les assertions."
|
|
@@ -340,7 +341,7 @@ const RULE_DOCS = {
|
|
|
340
341
|
},
|
|
341
342
|
"c1-domain-structure": {
|
|
342
343
|
channel: "statique",
|
|
343
|
-
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`.",
|
|
344
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`.",
|
|
344
345
|
family: "C",
|
|
345
346
|
id: "C1",
|
|
346
347
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
@@ -452,7 +453,7 @@ const RULE_DOCS = {
|
|
|
452
453
|
},
|
|
453
454
|
"f1-no-subpath-import": {
|
|
454
455
|
channel: "statique",
|
|
455
|
-
convention: "Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/<subpath>` est une erreur, sauf
|
|
456
|
+
convention: "Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/<subpath>` est une erreur, sauf les subpaths que la map `exports` du paquet publie — `@jterrazz/test/oxlint` (plugin de lint) et `@jterrazz/test/vitest` (surface de configuration du runner) — exemptés partout. La liste est LUE du manifeste, jamais recopiée dans la règle.",
|
|
456
457
|
family: "F",
|
|
457
458
|
id: "F1",
|
|
458
459
|
rationale: "Un point d’entrée unique garde l’API publique explicite et les subpaths internes invisibles."
|
|
@@ -566,7 +567,7 @@ const RULE_DOCS = {
|
|
|
566
567
|
const CHECKER_PASSES = [
|
|
567
568
|
{
|
|
568
569
|
channel: "checker",
|
|
569
|
-
convention: "Tout `{{token}}` dans une fixture `expected/` appartient au vocabulaire figé ; un token inconnu est une erreur.",
|
|
570
|
+
convention: "Tout `{{token}}` dans une fixture `expected/` — ou dans les blocs d’un spec littéraire `<cas>.cli` — appartient au vocabulaire figé ; un token inconnu est une erreur.",
|
|
570
571
|
family: "D",
|
|
571
572
|
id: "D4",
|
|
572
573
|
name: "d4-unknown-token",
|
|
@@ -588,6 +589,22 @@ const CHECKER_PASSES = [
|
|
|
588
589
|
name: "d4b-http-first-line",
|
|
589
590
|
rationale: "La ligne d’ouverture distingue une requête d’une réponse — la contraindre attrape les fichiers mal placés."
|
|
590
591
|
},
|
|
592
|
+
{
|
|
593
|
+
channel: "checker",
|
|
594
|
+
convention: "Un spec littéraire `<cas>.cli` respecte sa grammaire : clés d’en-tête fermées (`test`, `given`, `then`, `fixture`, `env`, `serve`), première ligne de corps en `$ <commande>`, `exit: <entier>` juste après.",
|
|
595
|
+
family: "D",
|
|
596
|
+
id: "D4b",
|
|
597
|
+
name: "d4b-cli-shape",
|
|
598
|
+
rationale: "La grammaire est lue par le parseur du runner lui-même : le fichier que le lint accepte est exactement celui que le runner exécute."
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
channel: "checker",
|
|
602
|
+
convention: "Un spec littéraire `<cas>.cli` porte les trois lignes narratives `test:`, `given:` et `then:` ; il en manque une est une erreur.",
|
|
603
|
+
family: "B",
|
|
604
|
+
id: "B4",
|
|
605
|
+
name: "b4-cli-header",
|
|
606
|
+
rationale: "Le format littéraire est un test : la narration Given/Then y vit dans l’en-tête, pas dans des commentaires."
|
|
607
|
+
},
|
|
591
608
|
{
|
|
592
609
|
channel: "checker",
|
|
593
610
|
convention: "Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées.",
|
|
@@ -598,7 +615,7 @@ const CHECKER_PASSES = [
|
|
|
598
615
|
},
|
|
599
616
|
{
|
|
600
617
|
channel: "checker",
|
|
601
|
-
convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
|
|
618
|
+
convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` ni `*.cli` est orphelin (warning si argument non littéral). Un `<cas>.cli` référence les fixtures nommées par ses lignes `fixture:`.",
|
|
602
619
|
family: "C",
|
|
603
620
|
id: "C9",
|
|
604
621
|
name: "c9-dead-fixtures",
|
|
@@ -1471,6 +1488,16 @@ const DEFAULT_DEPTH = "facet-domain";
|
|
|
1471
1488
|
* - a `*.specification.ts` sits at the facet root — exactly
|
|
1472
1489
|
* `specs/<facet>/<file>.specification.ts`, never inside a domain.
|
|
1473
1490
|
*
|
|
1491
|
+
* `depth: 'facet'`
|
|
1492
|
+
*
|
|
1493
|
+
* The same facet root, one degree looser: the folder follows the assets. A
|
|
1494
|
+
* `*.test.ts` sits EITHER at the facet root (`specs/<facet>/<aspect>.test.ts`)
|
|
1495
|
+
* or one domain folder down (`specs/<facet>/<domain>/<aspect>.test.ts`) — a
|
|
1496
|
+
* test earns a domain folder when it carries assets of its own, and sits
|
|
1497
|
+
* beside its siblings when it does not. Anything deeper is rejected, and so is
|
|
1498
|
+
* a test loose at the specs root. A `*.specification.ts` sits at the facet
|
|
1499
|
+
* root, exactly as in `facet-domain`.
|
|
1500
|
+
*
|
|
1474
1501
|
* `depth: 'mirror'`
|
|
1475
1502
|
*
|
|
1476
1503
|
* The tree mirrors something outside itself (a command tree, a source tree), so
|
|
@@ -1495,6 +1522,24 @@ const c1DomainStructure = {
|
|
|
1495
1522
|
if (anchor === void 0) return;
|
|
1496
1523
|
const base = anchor.relative.at(-1) ?? "";
|
|
1497
1524
|
const nesting = anchor.relative.length - 1;
|
|
1525
|
+
if (depth === "facet") {
|
|
1526
|
+
if (base.endsWith(TEST_SUFFIX)) {
|
|
1527
|
+
if (nesting < 1) context.report({
|
|
1528
|
+
messageId: "testOutsideFacet",
|
|
1529
|
+
node
|
|
1530
|
+
});
|
|
1531
|
+
else if (nesting > 2) context.report({
|
|
1532
|
+
messageId: "testTooDeepForFacet",
|
|
1533
|
+
node
|
|
1534
|
+
});
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
if (base.endsWith(SPECIFICATION_SUFFIX) && nesting !== 1) context.report({
|
|
1538
|
+
messageId: "specNotAtFacetRoot",
|
|
1539
|
+
node
|
|
1540
|
+
});
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1498
1543
|
if (depth === "mirror") {
|
|
1499
1544
|
if (!base.endsWith(TEST_SUFFIX)) return;
|
|
1500
1545
|
if (nesting < 1) {
|
|
@@ -1539,12 +1584,15 @@ const c1DomainStructure = {
|
|
|
1539
1584
|
testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
|
|
1540
1585
|
testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
|
|
1541
1586
|
testNotMirroringDirectory: "A `*.test.ts` must be named after the directory holding it — `{{directory}}/{{directory}}.test.ts` — with `depth: \"mirror\"` (C1 — see docs/10-linting.md).",
|
|
1542
|
-
|
|
1587
|
+
testOutsideFacet: "A `*.test.ts` must live in a facet folder — `specs/<facet>/<aspect>.test.ts`, or one domain deeper: with `depth: \"facet\"` the specs root holds facets, not tests (C1 — see docs/10-linting.md).",
|
|
1588
|
+
testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/10-linting.md).",
|
|
1589
|
+
testTooDeepForFacet: "A `*.test.ts` must sit at the facet root or one domain folder down — `specs/<facet>/<aspect>.test.ts` or `specs/<facet>/<domain>/<aspect>.test.ts`: with `depth: \"facet\"` nothing nests deeper (C1 — see docs/10-linting.md)."
|
|
1543
1590
|
},
|
|
1544
1591
|
schema: [{
|
|
1545
1592
|
additionalProperties: false,
|
|
1546
1593
|
properties: { depth: {
|
|
1547
1594
|
enum: [
|
|
1595
|
+
"facet",
|
|
1548
1596
|
"facet-domain",
|
|
1549
1597
|
"mirror",
|
|
1550
1598
|
"off"
|
|
@@ -2594,22 +2642,88 @@ const d9wSingleUseRef = {
|
|
|
2594
2642
|
}
|
|
2595
2643
|
};
|
|
2596
2644
|
//#endregion
|
|
2645
|
+
//#region src/lint/package-exports.ts
|
|
2646
|
+
/**
|
|
2647
|
+
* The subpaths this package actually publishes, read from its OWN
|
|
2648
|
+
* `package.json` `exports` map.
|
|
2649
|
+
*
|
|
2650
|
+
* Rule F1 forbids importing `@jterrazz/test/<subpath>` — but a subpath the
|
|
2651
|
+
* package DECLARES is, by definition, part of its public contract
|
|
2652
|
+
* (`/oxlint` for the lint plugin, `/vitest` for the runner-config surface).
|
|
2653
|
+
* Deriving the exemption from the manifest rather than listing it in the rule
|
|
2654
|
+
* is what keeps the two from drifting: a subpath is exempt the moment it is
|
|
2655
|
+
* published, and stops being exempt the moment it is withdrawn — there is
|
|
2656
|
+
* nothing to remember to update.
|
|
2657
|
+
*
|
|
2658
|
+
* The manifest is found by walking up from THIS module, so the answer does not
|
|
2659
|
+
* depend on where the linted file sits: `<pkg>/dist/oxlint.js` in an install,
|
|
2660
|
+
* `<repo>/src/lint/` when the framework lints itself. `import.meta.url` is
|
|
2661
|
+
* safe in both bundle formats — the CJS build shims it to `__filename`.
|
|
2662
|
+
*/
|
|
2663
|
+
const PACKAGE$2 = "@jterrazz/test";
|
|
2664
|
+
/** Manifests are read once per lint process. */
|
|
2665
|
+
let cached = null;
|
|
2666
|
+
/**
|
|
2667
|
+
* Walk up from `startDir` for the `package.json` that declares {@link PACKAGE}.
|
|
2668
|
+
* The FIRST manifest found decides: if it names another package, the walk has
|
|
2669
|
+
* already left this package's tree and there is nothing to find above it.
|
|
2670
|
+
*/
|
|
2671
|
+
function findOwnManifest(startDir) {
|
|
2672
|
+
let dir = startDir;
|
|
2673
|
+
for (;;) {
|
|
2674
|
+
const text = readFileCached((0, node_path.join)(dir, "package.json"));
|
|
2675
|
+
if (text !== null) try {
|
|
2676
|
+
const manifest = JSON.parse(text);
|
|
2677
|
+
return manifest.name === PACKAGE$2 ? manifest : null;
|
|
2678
|
+
} catch {
|
|
2679
|
+
return null;
|
|
2680
|
+
}
|
|
2681
|
+
const parent = (0, node_path.dirname)(dir);
|
|
2682
|
+
if (parent === dir) return null;
|
|
2683
|
+
dir = parent;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Every subpath specifier the package publishes — `@jterrazz/test/oxlint`,
|
|
2688
|
+
* `@jterrazz/test/vitest`, … The root export (`.`) is not a subpath and is
|
|
2689
|
+
* absent from the list.
|
|
2690
|
+
*
|
|
2691
|
+
* An unreadable manifest yields an EMPTY list: with no contract to read, no
|
|
2692
|
+
* subpath can be vouched for, and F1's plain rule applies.
|
|
2693
|
+
*
|
|
2694
|
+
* @internal Exported for unit tests.
|
|
2695
|
+
*/
|
|
2696
|
+
function declaredSubpaths(startDir) {
|
|
2697
|
+
if (startDir === void 0 && cached !== null) return cached;
|
|
2698
|
+
const exports = findOwnManifest(startDir ?? (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)))?.exports;
|
|
2699
|
+
const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
|
|
2700
|
+
if (startDir === void 0) cached = subpaths;
|
|
2701
|
+
return subpaths;
|
|
2702
|
+
}
|
|
2703
|
+
//#endregion
|
|
2597
2704
|
//#region src/lint/rules/f1-no-subpath-import.ts
|
|
2598
2705
|
const PACKAGE$1 = "@jterrazz/test";
|
|
2599
|
-
const TOOL_SUBPATH$2 = "@jterrazz/test/oxlint";
|
|
2600
2706
|
/**
|
|
2601
|
-
* CONVENTIONS F1 — everything is imported from `@jterrazz/test`;
|
|
2602
|
-
* not exist.
|
|
2603
|
-
*
|
|
2604
|
-
*
|
|
2707
|
+
* CONVENTIONS F1 — everything a SPEC needs is imported from `@jterrazz/test`;
|
|
2708
|
+
* internal subpaths do not exist.
|
|
2709
|
+
*
|
|
2710
|
+
* The exception is not a list kept here: it is the package's own `exports` map
|
|
2711
|
+
* (`/oxlint`, the tool-facing plugin entry; `/vitest`, the runner-config
|
|
2712
|
+
* surface `vitest.config.ts` imports). A published subpath IS the public
|
|
2713
|
+
* contract, so reading the manifest is what keeps the rule from outlawing an
|
|
2714
|
+
* import the package itself prescribes. Each is exempt from any file — an
|
|
2715
|
+
* oxlint config, a shared preset, a vitest config.
|
|
2605
2716
|
*/
|
|
2606
2717
|
const f1NoSubpathImport = {
|
|
2607
2718
|
create(context) {
|
|
2719
|
+
const allowed = new Set(declaredSubpaths());
|
|
2608
2720
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
2609
|
-
if (!source.startsWith(`${PACKAGE$1}/`)) return;
|
|
2610
|
-
if (source === TOOL_SUBPATH$2) return;
|
|
2721
|
+
if (!source.startsWith(`${PACKAGE$1}/`) || allowed.has(source)) return;
|
|
2611
2722
|
context.report({
|
|
2612
|
-
data: {
|
|
2723
|
+
data: {
|
|
2724
|
+
published: allowed.size === 0 ? "none" : [...allowed].sort().join(", "),
|
|
2725
|
+
source
|
|
2726
|
+
},
|
|
2613
2727
|
messageId: "subpath",
|
|
2614
2728
|
node
|
|
2615
2729
|
});
|
|
@@ -2617,7 +2731,7 @@ const f1NoSubpathImport = {
|
|
|
2617
2731
|
},
|
|
2618
2732
|
meta: {
|
|
2619
2733
|
docs: RULE_DOCS["f1-no-subpath-import"],
|
|
2620
|
-
messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — subpaths do not exist (F1 — see docs/10-linting.md
|
|
2734
|
+
messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/10-linting.md). Published subpaths, exempt everywhere: {{published}}." },
|
|
2621
2735
|
type: "problem"
|
|
2622
2736
|
}
|
|
2623
2737
|
};
|