@jterrazz/test 15.1.0 → 15.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/dist/oxlint.js CHANGED
@@ -169,9 +169,6 @@ function walk(root, visit) {
169
169
  } else if (isNode(value)) walk(value, visit);
170
170
  }
171
171
  }
172
- function isNode(value) {
173
- return typeof value === "object" && value !== null && typeof value.type === "string";
174
- }
175
172
  /** The source start offset of a node or comment (oxlint `start`, else `range[0]`). */
176
173
  function nodeStart(node) {
177
174
  if (node === void 0) return -1;
@@ -218,6 +215,27 @@ function importSourceVisitor(onSource) {
218
215
  ImportExpression: fromSourceField
219
216
  };
220
217
  }
218
+ /** Is this value an AST node — an object carrying a `type` string? */
219
+ function isNode(value) {
220
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
221
+ }
222
+ /**
223
+ * A node's child by key, or `undefined` when the key holds something else.
224
+ *
225
+ * Every rule reaches for `node.parent`, `node.id`, `node.value`; the node shape
226
+ * types them as `unknown`, so each reach was an assertion of its own. One
227
+ * guarded read is the same answer, checked once.
228
+ */
229
+ function child(node, key) {
230
+ const value = node?.[key];
231
+ return isNode(value) ? value : void 0;
232
+ }
233
+ /** The name of an identifier node, or `undefined` for anything else. */
234
+ function identifierName(node) {
235
+ if (node?.type !== "Identifier") return;
236
+ const { name } = node;
237
+ return typeof name === "string" ? name : void 0;
238
+ }
221
239
  //#endregion
222
240
  //#region src/lint/manifest.ts
223
241
  /**
@@ -346,7 +364,7 @@ const RULE_DOCS = {
346
364
  },
347
365
  "c1-domain-structure": {
348
366
  channel: "statique",
349
- 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`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation.",
367
+ 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`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation. Un `.test.tsx` est hors de portée : une unité rendue se spécifie à côté de son composant, jamais dans un arbre facette/domaine — c’est I2 qui tient son voisinage.",
350
368
  family: "C",
351
369
  id: "C1",
352
370
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
@@ -463,6 +481,36 @@ const RULE_DOCS = {
463
481
  id: "D15",
464
482
  rationale: "Un statut isolé ne fige que le code de réponse et jette tout le reste du payload ; le golden complet capture la forme entière et sa grammaire de tokens (complète d12w, qui exige un amas de sondes de corps et manque le cas de la sonde de statut solitaire)."
465
483
  },
484
+ "e5-no-simulated-dom": {
485
+ channel: "statique",
486
+ convention: "Un fichier de test ne déclare pas de DOM simulé : la pragma `@vitest-environment happy-dom|jsdom` est une erreur. Portée : les rôles qui EXÉCUTENT un test (`module`, `component`, `specification`) — une config de projet est le domaine de E5b.",
487
+ facet: "component",
488
+ family: "E",
489
+ fix: "Déplacer le test à côté de son composant en `.test.tsx` et le faire collecter par le projet `component()`.",
490
+ id: "E5",
491
+ rationale: "Un DOM simulé se comporte PRESQUE comme un navigateur, et ce qui est livré est jugé par un vrai : la facette component rend dans le Chromium que la facette website pilote déjà.",
492
+ reach: "tests"
493
+ },
494
+ "e5b-no-simulated-dom-config": {
495
+ channel: "statique",
496
+ convention: "Une config vitest ne déclare pas de DOM simulé : `environment: 'happy-dom'|'jsdom'` est une erreur ; `'node'` et `'edge-runtime'` nomment un vrai runtime et restent hors de portée.",
497
+ facet: "component",
498
+ family: "E",
499
+ fix: "Supprimer `environment` et collecter le rendu avec `component()` ; les tests de module restent sous node.",
500
+ id: "E5b",
501
+ rationale: "La pragma (E5) n’engage qu’un fichier ; la config engage tout ce que le projet collecte — c’est ainsi qu’un dépôt hérite d’un DOM simulé que personne n’a choisi test par test.",
502
+ reach: "config"
503
+ },
504
+ "e6-component-project-helper": {
505
+ channel: "statique",
506
+ convention: "Un projet navigateur vient de `component()` : dans une config vitest, une clé `browser` sous `test` qui n'est pas dans un appel à `component(...)` est une erreur. Une clé `browser` ailleurs (un `define`, un enregistrement d'env) n'est pas un bloc de projet et reste hors de portée.",
507
+ facet: "component",
508
+ family: "E",
509
+ fix: "Remplacer le bloc par `component({ vite })` et ne garder que ce qui appartient au projet.",
510
+ id: "E6",
511
+ rationale: "Le provider épinglé à la version exacte du runner, le service worker servi depuis l’installation du framework, la transformation JSX du Vite courant, le pré-bundling d’un cache froid et les dossiers d’artefacts sont autant de runs qui passent ici et échouent sur la machine suivante.",
512
+ reach: "config"
513
+ },
466
514
  "f1-no-subpath-import": {
467
515
  channel: "statique",
468
516
  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.",
@@ -479,7 +527,7 @@ const RULE_DOCS = {
479
527
  },
480
528
  "f3-specs-public-entry": {
481
529
  channel: "statique",
482
- convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{specification,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` autre que `@jterrazz/test/oxlint`. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté) ; seul `specs/integrations/` peut importer en profondeur `src/integrations/**`.",
530
+ convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{specification,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` que la map `exports` du paquet ne publie pas — les subpaths publiés sont exemptés, LUS du manifeste comme en F1, qui en tient la liste. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté) ; seul `specs/integrations/` peut importer en profondeur `src/integrations/**`.",
483
531
  family: "F",
484
532
  id: "F3",
485
533
  rationale: "Tester par la surface publique garde les specs découplées des chemins internes du framework, sans gêner le consommateur qui importe sa propre app."
@@ -498,6 +546,26 @@ const RULE_DOCS = {
498
546
  id: "F5",
499
547
  rationale: "Cantonner les fixtures aux tests empêche la donnée de test de fuir dans le code de prod."
500
548
  },
549
+ "f6-no-foreign-test-runtime": {
550
+ channel: "statique",
551
+ convention: "Un fichier de test n’importe pas de second runtime de test : `@testing-library/*`, `happy-dom`, `jsdom`, `vitest/browser`, `vitest-browser-*` et `@vitest/browser*` sont des erreurs. Portée : les rôles qui EXÉCUTENT un test (`module`, `component`, `specification`) — la config d’un projet fixture et un module de providers sous `specs/` nomment légitimement l’adaptateur.",
552
+ facet: "component",
553
+ family: "F",
554
+ fix: "Passer par la facette qui remplace le seam : `component.render()`, le vocabulaire d’éléments et le visiteur.",
555
+ id: "F6",
556
+ rationale: "Un test qui importe l’adaptateur parle le dialecte de l’adaptateur : un dépôt finit avec autant de vocabulaires que de seams. Les groupes livrés sont ceux que la facette component REMPLACE ; le reste de la famille suit la vague de règles.",
557
+ reach: "tests"
558
+ },
559
+ "g4-no-dom-in-module-test": {
560
+ channel: "statique",
561
+ convention: "Un test de module ne touche aucun global du DOM : `document`, `window`, `navigator`, `HTMLElement` référencés en position de VALEUR dans un fichier de rôle `module` HORS de `specs/` sont une erreur. Un nom en position de type (`x as HTMLElement`) et un garde `typeof window` n’atteignent aucun document et restent hors de portée ; tant que le rôle `spec` n’existe pas, `module` couvre aussi les `.test.ts` de `specs/`, que la règle laisse donc passer.",
562
+ facet: "component",
563
+ family: "G",
564
+ fix: "Renommer le test en `.test.tsx` à côté du composant qu’il rend, et le faire collecter par `component()`.",
565
+ id: "G4",
566
+ rationale: "Un test de module tourne sous node, où `document` n’existe pas : un test qui en réclame un ne teste pas un module, il rend quelque chose — et il a désormais un endroit où aller.",
567
+ reach: "module"
568
+ },
501
569
  "i1-layer-boundaries": {
502
570
  channel: "statique",
503
571
  convention: "Le projet DÉCLARE ses couches sous `src/` via l’option `layers` (paquets autorisés, imports internes, un dossier = une dépendance, seams) ; tout import hors des arêtes déclarées est une erreur. Sans carte de couches, la règle est inerte.",
@@ -507,9 +575,11 @@ const RULE_DOCS = {
507
575
  },
508
576
  "i2-sibling-test-naming": {
509
577
  channel: "statique",
510
- convention: "Le test de `<fichier>.ts` est `<fichier>.test.ts` à côté de lui ; un `.test.ts` mal nommé, ou un dossier `__tests__/`, est une erreur.",
578
+ convention: "Le test de `<fichier>.ts` est `<fichier>.test.ts` à côté de lui ; un `.test.ts` mal nommé, ou un dossier `__tests__/`, est une erreur. Une unité RENDUE obéit à la même loi où qu’elle vive : un `.test.tsx` est voisin du `.tsx` qu’il rend, ou du `.ts` du hook ou de la fonction DOM dont il porte l’hôte.",
511
579
  family: "I",
580
+ fix: "Poser le test à côté de l’unité qu’il couvre, sous le même basename.",
512
581
  id: "I2",
582
+ reach: "tests",
513
583
  rationale: "Des tests voisins (parité avec le `foo_test.go` de Go) gardent test et code ensemble et découvrables."
514
584
  },
515
585
  "i4-no-vi-mock-in-src": {
@@ -840,14 +910,19 @@ function sortKey(entry) {
840
910
  ];
841
911
  }
842
912
  [
843
- ...Object.entries(RULE_DOCS).map(([name, doc]) => ({
844
- channel: doc.channel,
845
- convention: doc.convention,
846
- family: doc.family,
847
- id: doc.id,
848
- name,
849
- rationale: doc.rationale
850
- })),
913
+ ...Object.entries(RULE_DOCS).map(([name, doc]) => {
914
+ const entry = {
915
+ channel: doc.channel,
916
+ convention: doc.convention,
917
+ family: doc.family,
918
+ id: doc.id,
919
+ name,
920
+ rationale: doc.rationale
921
+ };
922
+ if ("fix" in doc) entry.fix = doc.fix;
923
+ if ("reach" in doc) entry.reach = doc.reach;
924
+ return entry;
925
+ }),
851
926
  ...CHECKER_PASSES,
852
927
  ...RUNTIME_RULES,
853
928
  ...PROCESS_RULES
@@ -859,6 +934,55 @@ function sortKey(entry) {
859
934
  return av.localeCompare(bv);
860
935
  });
861
936
  //#endregion
937
+ //#region src/lint/role.ts
938
+ /** The four ground names — what the specs of a row stand ON, never a spec. */
939
+ const GROUND = /* @__PURE__ */ new Set([
940
+ "_expected",
941
+ "_fixtures",
942
+ "_requests",
943
+ "_seeds"
944
+ ]);
945
+ /** A vitest config, whatever extension the project writes it in. */
946
+ const CONFIG = /^vitest\.config\.[cm]?[jt]s$/u;
947
+ /**
948
+ * The kind of file this path names.
949
+ *
950
+ * The suffix decides, and the order matters: a `*.specification.ts` is a
951
+ * specification wherever it sits, ground swallows anything under it that is not
952
+ * a test of its own module, and `.tsx` versus `.ts` is what tells a RENDERED
953
+ * unit from a plain one — the same distinction the two project helpers collect
954
+ * on, so a file is judged by the rules of the project that actually runs it.
955
+ */
956
+ function roleOf(filename) {
957
+ const parts = segments(filename);
958
+ const base = parts.at(-1) ?? "";
959
+ const inSpecs = isUnderSpecs(filename);
960
+ if (base.endsWith(".specification.ts") || base.endsWith(".specification.tsx")) return {
961
+ inSpecs,
962
+ role: "specification"
963
+ };
964
+ if (CONFIG.test(base)) return {
965
+ inSpecs,
966
+ role: "config"
967
+ };
968
+ if (base.endsWith(".test.tsx")) return {
969
+ inSpecs,
970
+ role: "component"
971
+ };
972
+ if (base.endsWith(".test.ts")) return {
973
+ inSpecs,
974
+ role: "module"
975
+ };
976
+ if (parts.slice(0, -1).some((segment) => GROUND.has(segment))) return {
977
+ inSpecs,
978
+ role: "ground"
979
+ };
980
+ return {
981
+ inSpecs,
982
+ role: "other"
983
+ };
984
+ }
985
+ //#endregion
862
986
  //#region src/lint/rules/a1-specification-file.ts
863
987
  const SPECIFICATION_SUFFIX$2 = ".specification.ts";
864
988
  /**
@@ -869,10 +993,15 @@ const SPECIFICATION_SUFFIX$2 = ".specification.ts";
869
993
  * Framework-internal unit tests of the constructors themselves (this repo's
870
994
  * `src/**\/*.test.ts`) disable the rule via a config override — for consumers
871
995
  * the rule is universal.
996
+ *
997
+ * The A family does not reach a `component`-role file. A rendered unit has no
998
+ * runner to create: no server to start, no database to isolate, no binary to
999
+ * find — which is why the component facet has no constructor and no
1000
+ * specification file for A1 to point at.
872
1001
  */
873
1002
  const a1SpecificationFile = {
874
1003
  create(context) {
875
- if (context.filename.endsWith(SPECIFICATION_SUFFIX$2)) return {};
1004
+ if (context.filename.endsWith(SPECIFICATION_SUFFIX$2) || roleOf(context.physicalFilename).role === "component") return {};
876
1005
  return { CallExpression(node) {
877
1006
  const member = specificationMember(node);
878
1007
  if (member !== void 0) context.report({
@@ -2839,6 +2968,122 @@ const d15wStatusOnlyProbe = {
2839
2968
  }
2840
2969
  };
2841
2970
  //#endregion
2971
+ //#region src/lint/rules/e5-no-simulated-dom.ts
2972
+ /** The pragma vitest reads to swap a test file's environment. */
2973
+ const PRAGMA = /@vitest-environment\s+(?<environment>[\w-]+)/u;
2974
+ /** The two simulated DOMs. */
2975
+ const SIMULATED$1 = /* @__PURE__ */ new Set(["happy-dom", "jsdom"]);
2976
+ /** The roles that RUN a test file — the only ones a pragma steers. */
2977
+ const REACHED$1 = /* @__PURE__ */ new Set([
2978
+ "component",
2979
+ "module",
2980
+ "specification"
2981
+ ]);
2982
+ /**
2983
+ * CONVENTIONS E5 — no simulated DOM, stated per file.
2984
+ *
2985
+ * `@vitest-environment happy-dom` buys a document that behaves almost like a
2986
+ * browser's: almost is the whole problem, because what ships is judged by a
2987
+ * real one. The component facet renders in the Chromium the website facet
2988
+ * already drives, so the pragma has nothing left to buy.
2989
+ */
2990
+ const e5NoSimulatedDom = {
2991
+ create(context) {
2992
+ if (!REACHED$1.has(roleOf(context.physicalFilename).role)) return {};
2993
+ return { Program(node) {
2994
+ for (const comment of context.sourceCode.getAllComments()) {
2995
+ const environment = PRAGMA.exec(comment.value)?.groups?.environment;
2996
+ if (environment !== void 0 && SIMULATED$1.has(environment)) context.report({
2997
+ data: { environment },
2998
+ messageId: "pragma",
2999
+ node
3000
+ });
3001
+ }
3002
+ } };
3003
+ },
3004
+ meta: {
3005
+ docs: RULE_DOCS["e5-no-simulated-dom"],
3006
+ messages: { pragma: "`@vitest-environment {{environment}}` simulates a browser this framework can open for real — a rendered thing is a `.test.tsx` beside its component, collected by the `component()` project (E5 — docs/13-linting.md#e5-no-simulated-dom)." },
3007
+ type: "problem"
3008
+ }
3009
+ };
3010
+ //#endregion
3011
+ //#region src/lint/rules/e5b-no-simulated-dom-config.ts
3012
+ /** The two simulated DOMs. */
3013
+ const SIMULATED = /* @__PURE__ */ new Set(["happy-dom", "jsdom"]);
3014
+ /**
3015
+ * CONVENTIONS E5b — the same ban, where a project states it for a whole tree.
3016
+ *
3017
+ * The pragma (E5) is one file's; `environment: 'happy-dom'` in a config is
3018
+ * every file the project collects, which is how a repository ends up with a
3019
+ * simulated DOM nobody ever chose per test. `'node'` and `'edge-runtime'` are
3020
+ * out of reach: they name a real runtime, not a drawing of one.
3021
+ */
3022
+ const e5bNoSimulatedDomConfig = {
3023
+ create(context) {
3024
+ if (roleOf(context.physicalFilename).role !== "config") return {};
3025
+ return { Program(node) {
3026
+ walk(node, (candidate) => {
3027
+ if (candidate.type !== "Property" || propertyKeyName(candidate) !== "environment") return;
3028
+ const environment = stringValue(child(candidate, "value"));
3029
+ if (environment !== void 0 && SIMULATED.has(environment)) context.report({
3030
+ data: { environment },
3031
+ messageId: "configured",
3032
+ node: candidate
3033
+ });
3034
+ });
3035
+ } };
3036
+ },
3037
+ meta: {
3038
+ docs: RULE_DOCS["e5b-no-simulated-dom-config"],
3039
+ messages: { configured: "`environment: '{{environment}}'` 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 — docs/13-linting.md#e5b-no-simulated-dom-config)." },
3040
+ type: "problem"
3041
+ }
3042
+ };
3043
+ //#endregion
3044
+ //#region src/lint/rules/e6-component-project-helper.ts
3045
+ /** Is this property the RUNNER's browser block, rather than a key that shares its name? */
3046
+ function isRunnerBrowserBlock(node) {
3047
+ for (let current = child(node, "parent"); current !== void 0; current = child(current, "parent")) if (current.type === "Property" && propertyKeyName(current) === "test") return true;
3048
+ return false;
3049
+ }
3050
+ /** Is this node inside a call to `component(...)`? */
3051
+ function insideComponentCall(node) {
3052
+ for (let current = child(node, "parent"); current !== void 0; current = child(current, "parent")) if (current.type === "CallExpression" && identifierName(child(current, "callee")) === "component") return true;
3053
+ return false;
3054
+ }
3055
+ /**
3056
+ * CONVENTIONS E6 — a browser project comes from `component()`.
3057
+ *
3058
+ * A hand-rolled `browser: { … }` block is a project that has to rediscover
3059
+ * everything the seam already knows: the provider pinned to the runner's exact
3060
+ * version, the service worker served from the framework's own install, the JSX
3061
+ * transform the current Vite actually uses, the dependencies a cold cache must
3062
+ * pre-bundle, and the artefact directories Browser Mode otherwise leaks at the
3063
+ * repository root. Every one of those is a run that fails on the second
3064
+ * machine, so the helper owns them and a config states only what is its own.
3065
+ */
3066
+ const e6ComponentProjectHelper = {
3067
+ create(context) {
3068
+ if (roleOf(context.physicalFilename).role !== "config") return {};
3069
+ return { Program(node) {
3070
+ walk(node, (candidate) => {
3071
+ if (candidate.type !== "Property" || propertyKeyName(candidate) !== "browser") return;
3072
+ if (!isRunnerBrowserBlock(candidate)) return;
3073
+ if (!insideComponentCall(candidate)) context.report({
3074
+ messageId: "handRolled",
3075
+ node: candidate
3076
+ });
3077
+ });
3078
+ } };
3079
+ },
3080
+ meta: {
3081
+ docs: RULE_DOCS["e6-component-project-helper"],
3082
+ messages: { handRolled: "Use `component({ vite })` — the provider, the worker and the pipeline are its, and a hand-rolled `browser:` block is the run that passes here and fails on the next machine (E6 — docs/13-linting.md#e6-component-project-helper)." },
3083
+ type: "problem"
3084
+ }
3085
+ };
3086
+ //#endregion
2842
3087
  //#region src/lint/package-exports.ts
2843
3088
  /**
2844
3089
  * The subpaths this package actually publishes, read from its OWN
@@ -2937,7 +3182,7 @@ const f1NoSubpathImport = {
2937
3182
  const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/u;
2938
3183
  const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/u;
2939
3184
  const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/u;
2940
- const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
3185
+ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
2941
3186
  /**
2942
3187
  * CONVENTIONS F2 — production code never imports a test artefact. In a
2943
3188
  * non-test file (not `*.test.ts` / `*.fixtures.ts` / `*.specification.ts`,
@@ -2956,7 +3201,7 @@ const f2NoTestImportsInProd = {
2956
3201
  const file = context.filename;
2957
3202
  if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || isUnderSpecs(file)) return {};
2958
3203
  return { ...importSourceVisitor(({ node, source }) => {
2959
- if (source === TOOL_SUBPATH$1) return;
3204
+ if (source === TOOL_SUBPATH) return;
2960
3205
  if (source === "vitest" || source.startsWith("vitest/")) context.report({
2961
3206
  data: { source },
2962
3207
  messageId: "testImport",
@@ -2983,7 +3228,6 @@ const f2NoTestImportsInProd = {
2983
3228
  //#endregion
2984
3229
  //#region src/lint/rules/f3-specs-public-entry.ts
2985
3230
  const PACKAGE = "@jterrazz/test";
2986
- const TOOL_SUBPATH = "@jterrazz/test/oxlint";
2987
3231
  /**
2988
3232
  * The framework's own top-level `src/` layers. Deep-importing any of these from
2989
3233
  * a spec couples the spec to internals the public entry exists to hide. These
@@ -3001,12 +3245,19 @@ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
3001
3245
  /**
3002
3246
  * CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
3003
3247
  * forbidden: a relative path resolving inside the framework repo's
3004
- * `src/{specification,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3005
- * other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
3006
- * imports of its OWN app source are always allowed — that is the pattern.
3248
+ * `src/{specification,integrations,vitest,lint}/`, or a `@jterrazz/test/<subpath>`
3249
+ * the package does not publish. A consumer's imports of its OWN app source are
3250
+ * always allowed — that is the pattern.
3007
3251
  *
3008
- * Distinct from F1 (which bans `@jterrazz/test/<subpath>` from any file): F3 is
3009
- * the specs-specific guard that also reaches relative framework-internal paths.
3252
+ * The exempt subpaths are not listed here: they are read from the package's own
3253
+ * `exports` map, the same source F1 uses. A published subpath IS the public
3254
+ * surface — reaching `@jterrazz/test/vitest` from a specs-tree `vitest.config.ts`
3255
+ * is the prescribed gesture, not a leak — so what a spec must not reach is what
3256
+ * the manifest never published. The root entry (`.`) is not a subpath and never
3257
+ * concerned this branch: `@jterrazz/test` is the public entry F3 points at.
3258
+ *
3259
+ * Distinct from F1 (which judges the same specifiers from any file): F3 is the
3260
+ * specs-specific guard that also reaches relative framework-internal paths.
3010
3261
  * Documented exception: specs under `specs/integrations/` may deep-import the
3011
3262
  * integration module they cover (`src/integrations/**`).
3012
3263
  */
@@ -3014,12 +3265,17 @@ const f3SpecsPublicEntry = {
3014
3265
  create(context) {
3015
3266
  const anchor = specsAnchor(context.filename);
3016
3267
  if (anchor === void 0) return {};
3268
+ const published = new Set(declaredSubpaths());
3269
+ const exempt = published.size === 0 ? "none" : [...published].join(", ");
3017
3270
  const underIntegrations = anchor.relative[0] === "integrations";
3018
3271
  return { ...importSourceVisitor(({ node, source }) => {
3019
3272
  if (source.startsWith(`${PACKAGE}/`)) {
3020
- if (source === TOOL_SUBPATH) return;
3273
+ if (published.has(source)) return;
3021
3274
  context.report({
3022
- data: { source },
3275
+ data: {
3276
+ published: exempt,
3277
+ source
3278
+ },
3023
3279
  messageId: "deepImport",
3024
3280
  node
3025
3281
  });
@@ -3031,7 +3287,10 @@ const f3SpecsPublicEntry = {
3031
3287
  if (!FRAMEWORK_LAYERS.has(layer)) return;
3032
3288
  if (underIntegrations && layer === "integrations") return;
3033
3289
  context.report({
3034
- data: { source },
3290
+ data: {
3291
+ published: exempt,
3292
+ source
3293
+ },
3035
3294
  messageId: "deepImport",
3036
3295
  node
3037
3296
  });
@@ -3039,7 +3298,7 @@ const f3SpecsPublicEntry = {
3039
3298
  },
3040
3299
  meta: {
3041
3300
  docs: RULE_DOCS["f3-specs-public-entry"],
3042
- messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/13-linting.md; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
3301
+ messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/13-linting.md). Exempt: the published subpaths {{published}}, and src/integrations/** from specs/integrations/." },
3043
3302
  type: "problem"
3044
3303
  }
3045
3304
  };
@@ -3096,6 +3355,164 @@ const f5FixturesOnlyFromTests = {
3096
3355
  }
3097
3356
  };
3098
3357
  //#endregion
3358
+ //#region src/lint/rules/f6-no-foreign-test-runtime.ts
3359
+ const GROUPS = [
3360
+ {
3361
+ fix: "`component.render()` and the element vocabulary render in a real browser — the queries are the same nouns, and they run against what ships",
3362
+ matches: (source) => source === "@testing-library" || source.startsWith("@testing-library/")
3363
+ },
3364
+ {
3365
+ fix: "a drawing of a browser proves what a browser does not — a rendered thing is a `.test.tsx` beside its component, collected by `component()`",
3366
+ matches: (source) => source === "happy-dom" || source === "jsdom"
3367
+ },
3368
+ {
3369
+ fix: "Browser Mode is the seam, not the surface — `component.render()` owns the locators, the visitor and the ARIA tree",
3370
+ matches: (source) => source === "vitest/browser" || source.startsWith("vitest-browser-") || source.startsWith("@vitest/browser")
3371
+ }
3372
+ ];
3373
+ /**
3374
+ * The roles that RUN a test — never a fixture app's config or a provider module
3375
+ * that happens to sit under `specs/`. A fixture project's own `vitest.config.ts`
3376
+ * has to name the provider, and a `providers.tsx` has to import the adapter it
3377
+ * wraps: neither is a spec speaking a second dialect.
3378
+ */
3379
+ const REACHED = /* @__PURE__ */ new Set([
3380
+ "component",
3381
+ "module",
3382
+ "specification"
3383
+ ]);
3384
+ /**
3385
+ * CONVENTIONS F6 — a test file imports no second test runtime.
3386
+ *
3387
+ * Every seam the framework owns is one a spec must not reach around: a test
3388
+ * that imports the adapter speaks the adapter's dialect, and a repository ends
3389
+ * up with as many vocabularies as it has seams. The groups the rule carries are
3390
+ * the ones the component facet REPLACES.
3391
+ */
3392
+ const f6NoForeignTestRuntime = {
3393
+ create(context) {
3394
+ if (!REACHED.has(roleOf(context.physicalFilename).role)) return {};
3395
+ return { ...importSourceVisitor(({ node, source }) => {
3396
+ const group = GROUPS.find((candidate) => candidate.matches(source));
3397
+ if (group !== void 0) context.report({
3398
+ data: {
3399
+ fix: group.fix,
3400
+ source
3401
+ },
3402
+ messageId: "foreignRuntime",
3403
+ node
3404
+ });
3405
+ }) };
3406
+ },
3407
+ meta: {
3408
+ docs: RULE_DOCS["f6-no-foreign-test-runtime"],
3409
+ messages: { foreignRuntime: "`{{source}}` is a second test runtime in a test file: {{fix}} (F6 — docs/13-linting.md#f6-no-foreign-test-runtime)." },
3410
+ type: "problem"
3411
+ }
3412
+ };
3413
+ //#endregion
3414
+ //#region src/lint/rules/g4-no-dom-in-module-test.ts
3415
+ /** The globals that only exist where a document does. */
3416
+ const DOM_GLOBALS = /* @__PURE__ */ new Set([
3417
+ "document",
3418
+ "HTMLElement",
3419
+ "navigator",
3420
+ "window"
3421
+ ]);
3422
+ /** The node kinds that BIND a name, and the key each one binds it under. */
3423
+ const BINDINGS = {
3424
+ CatchClause: "param",
3425
+ ClassDeclaration: "id",
3426
+ FunctionDeclaration: "id",
3427
+ ImportDefaultSpecifier: "local",
3428
+ ImportNamespaceSpecifier: "local",
3429
+ ImportSpecifier: "local",
3430
+ VariableDeclarator: "id"
3431
+ };
3432
+ /**
3433
+ * Every name this file declares for itself.
3434
+ *
3435
+ * A file-wide set rather than a scope chain: the rule only needs to know that
3436
+ * `document` is the test's own binding somewhere, and a name shadowed in one
3437
+ * scope is not a global in another. It can only under-report, which is the
3438
+ * right direction for a guard.
3439
+ */
3440
+ function declaredNames(root) {
3441
+ const names = /* @__PURE__ */ new Set();
3442
+ const add = (node) => {
3443
+ const name = identifierName(node);
3444
+ if (name !== void 0) names.add(name);
3445
+ };
3446
+ walk(root, (node) => {
3447
+ const key = BINDINGS[node.type];
3448
+ if (key !== void 0) add(child(node, key));
3449
+ const { params } = node;
3450
+ if (Array.isArray(params)) {
3451
+ for (const parameter of params) if (isNode(parameter)) add(parameter);
3452
+ }
3453
+ });
3454
+ return names;
3455
+ }
3456
+ /** The node kinds a name sits in when it is a TYPE, not a value the test reaches. */
3457
+ const TYPE_POSITIONS = /* @__PURE__ */ new Set([
3458
+ "TSInterfaceHeritage",
3459
+ "TSQualifiedName",
3460
+ "TSTypeQuery",
3461
+ "TSTypeReference"
3462
+ ]);
3463
+ /** Is this identifier read as a value, rather than named as a property, a key or a type? */
3464
+ function isValueReference(node) {
3465
+ const parent = child(node, "parent");
3466
+ if (parent === void 0) return true;
3467
+ if (TYPE_POSITIONS.has(parent.type)) return false;
3468
+ if (parent.type === "UnaryExpression" && parent.operator === "typeof") return false;
3469
+ if (parent.type === "MemberExpression") return child(parent, "property") !== node || parent.computed === true;
3470
+ if (parent.type === "Property") return child(parent, "value") === node || parent.computed === true;
3471
+ return true;
3472
+ }
3473
+ /**
3474
+ * CONVENTIONS G4 — a module test touches no DOM.
3475
+ *
3476
+ * A module test runs under node, where `document` does not exist: a test that
3477
+ * reaches for one is not testing a module, it is rendering something, and the
3478
+ * place for that is a `.test.tsx` beside the component.
3479
+ *
3480
+ * Reach is the `module` role OUTSIDE `specs/`: a `.test.tsx` is the rendered
3481
+ * kind and lives in a page, where those globals are what it is there to use;
3482
+ * and under `specs/` an api or cli spec is a `.test.ts` too until the `spec`
3483
+ * role exists, so the guard stops at the tree it can judge.
3484
+ *
3485
+ * A name in a TYPE position (`x as HTMLElement`) and a `typeof window` guard
3486
+ * name no document: neither reaches one, and a module that must run in both
3487
+ * runtimes is tested by exactly that guard.
3488
+ */
3489
+ const g4NoDomInModuleTest = {
3490
+ create(context) {
3491
+ const identity = roleOf(context.physicalFilename);
3492
+ if (identity.role !== "module" || identity.inSpecs) return {};
3493
+ let declared = /* @__PURE__ */ new Set();
3494
+ return {
3495
+ Identifier(node) {
3496
+ const name = identifierName(node);
3497
+ if (name === void 0 || !DOM_GLOBALS.has(name) || declared.has(name) || !isValueReference(node)) return;
3498
+ context.report({
3499
+ data: { name },
3500
+ messageId: "domGlobal",
3501
+ node
3502
+ });
3503
+ },
3504
+ Program(node) {
3505
+ declared = declaredNames(node);
3506
+ }
3507
+ };
3508
+ },
3509
+ meta: {
3510
+ docs: RULE_DOCS["g4-no-dom-in-module-test"],
3511
+ messages: { domGlobal: "`{{name}}` does not exist where a module test runs — a rendered thing is a `.test.tsx` beside its component, collected by the `component()` project (G4 — docs/13-linting.md#g4-no-dom-in-module-test)." },
3512
+ type: "problem"
3513
+ }
3514
+ };
3515
+ //#endregion
3099
3516
  //#region src/lint/rules/i1-layer-boundaries.ts
3100
3517
  const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
3101
3518
  function matchesPackage(source, packages) {
@@ -3228,21 +3645,25 @@ const i1LayerBoundaries = {
3228
3645
  //#region src/lint/rules/i2-sibling-test-naming.ts
3229
3646
  const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
3230
3647
  /**
3231
- * CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
3232
- * `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
3233
- * files:
3648
+ * CONVENTIONS I2 — a unit's test is its NEIGHBOUR: the test of `<file>.ts` is
3649
+ * `<file>.test.ts` next to it (Go's `foo_test.go` parity). A rendered unit
3650
+ * answers to the same law wherever it lives: a `.test.tsx` sits beside the
3651
+ * `.tsx` it renders, or the `.ts` of the hook or DOM function it hosts. Flags,
3652
+ * on visited files:
3234
3653
  *
3235
3654
  * - a `src/**` test whose neighbour module `<file>.ts` does not exist;
3655
+ * - a `component`-role test (`*.test.tsx`) with no neighbour of any tree;
3236
3656
  * - any file living in a `__tests__/` directory under `src/`;
3237
3657
  * - any file under a root-level `tests/` directory (the only test roots are
3238
- * sibling module tests under `src/` and product specifications in `specs/`).
3658
+ * sibling tests under `src/` and product specifications in `specs/`).
3239
3659
  */
3240
3660
  const i2SiblingTestNaming = {
3241
3661
  create(context) {
3242
3662
  const file = context.physicalFilename;
3243
3663
  const parts = segments(file);
3244
3664
  return { Program(node) {
3245
- if (parts.includes("src")) {
3665
+ const rendered = roleOf(file).role === "component";
3666
+ if (parts.includes("src") || rendered) {
3246
3667
  if (parts.includes("__tests__")) context.report({
3247
3668
  messageId: "testsDir",
3248
3669
  node
@@ -3254,8 +3675,8 @@ const i2SiblingTestNaming = {
3254
3675
  ".js"
3255
3676
  ].map((extension) => file.replace(TEST_FILE$1, extension));
3256
3677
  if (!neighbours.some(isFile)) context.report({
3257
- data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
3258
- messageId: "orphanTest",
3678
+ data: { expected: neighbours[rendered ? 1 : 0]?.split(/[/\\]/u).at(-1) ?? "" },
3679
+ messageId: rendered ? "orphanComponent" : "orphanTest",
3259
3680
  node
3260
3681
  });
3261
3682
  }
@@ -3272,6 +3693,7 @@ const i2SiblingTestNaming = {
3272
3693
  meta: {
3273
3694
  docs: RULE_DOCS["i2-sibling-test-naming"],
3274
3695
  messages: {
3696
+ orphanComponent: "A component test must sit NEXT to what it renders — no neighbour \"{{expected}}\" found (I2 — see docs/13-linting.md). A hook or a DOM function is the `.ts` of the same basename, with its Host in the test file.",
3275
3697
  orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/13-linting.md). A test needing more than its module is a specification: move it to specs/.",
3276
3698
  rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/13-linting.md).",
3277
3699
  testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/13-linting.md)."
@@ -3514,11 +3936,16 @@ const plugin = {
3514
3936
  "d12w-response-body-probe": d12wResponseBodyProbe,
3515
3937
  "d13w-unfrozen-negative-fixture": d13wUnfrozenNegativeFixture,
3516
3938
  "d15w-status-only-probe": d15wStatusOnlyProbe,
3939
+ "e5-no-simulated-dom": e5NoSimulatedDom,
3940
+ "e5b-no-simulated-dom-config": e5bNoSimulatedDomConfig,
3941
+ "e6-component-project-helper": e6ComponentProjectHelper,
3517
3942
  "f1-no-subpath-import": f1NoSubpathImport,
3518
3943
  "f2-no-test-imports-in-prod": f2NoTestImportsInProd,
3519
3944
  "f3-specs-public-entry": f3SpecsPublicEntry,
3520
3945
  "f4-no-test-to-test-import": f4NoTestToTestImport,
3521
3946
  "f5-fixtures-only-from-tests": f5FixturesOnlyFromTests,
3947
+ "f6-no-foreign-test-runtime": f6NoForeignTestRuntime,
3948
+ "g4-no-dom-in-module-test": g4NoDomInModuleTest,
3522
3949
  "i1-layer-boundaries": i1LayerBoundaries,
3523
3950
  "i2-sibling-test-naming": i2SiblingTestNaming,
3524
3951
  "i4-no-vi-mock-in-src": i4NoViMockInSrc,