@geonosis/oxlint-plugin-biological-architecture 0.1.2 → 0.2.1

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 CHANGED
@@ -1,9 +1,9 @@
1
1
  # @geonosis/oxlint-plugin-biological-architecture
2
2
 
3
- 54 architecture rules as an oxlint JS plugin: which tier a file belongs to, what it may import,
3
+ 55 architecture rules as an oxlint JS plugin: which tier a file belongs to, what it may import,
4
4
  where state is allowed to live, and the backend invariants that go with them. Nothing is hardcoded
5
- to a repo — every list of brands, features, tables or paths is an option whose default does nothing
6
- until it is configured.
5
+ to a repo — every list of brands, features, tables or paths is an option. A rule enabled without the
6
+ options it needs refuses the run rather than passing silently.
7
7
 
8
8
  ## Install
9
9
 
@@ -63,6 +63,7 @@ everything below the organelle is inert. A cell never composes another cell.
63
63
  | `backend-d1` | 2 | Cloudflare D1 facts — false on an engine with transactions |
64
64
  | `backend-medusa` | 1 | the storefront route surface |
65
65
  | `backend-sagaflow-cf` | 1 | durable runs on Cloudflare Workflows — false of an inline saga |
66
+ | `tooling` | 1 | `layer-walls` — a repo's OWN layers, declared as data and walled |
66
67
  | `comment-ceiling` | 1 | the comment posture, opt-in (+ oxlint's `capitalized-comments`) |
67
68
 
68
69
  A rule that is false on another stack gets its own preset rather than riding along in a generic
@@ -72,99 +73,76 @@ one: a preset carrying a repo fact is a repo fact wearing a preset's name.
72
73
 
73
74
  A repo that already has its own copy of these rules does not delete it because the version numbers
74
75
  match. It deletes it when the **findings** match: same tree, same rule ids, two configs, and a diff.
75
- `geonosis-lint-parity` is that diff.
76
+ [`@geonosis/lint-parity`](https://www.npmjs.com/package/@geonosis/lint-parity) is that diff — a
77
+ separate package, because a parity tool welded to one rule plugin can only ever check that plugin.
78
+ What this package ships towards it is the **evidence**: `corpus/`, the fixture tree its own gates
79
+ run against, one deliberately broken file per rule, plus a `manifest.json` generated from the
80
+ plugin's own rules export naming exactly the rules that corpus speaks for.
76
81
 
77
82
  ```bash
78
- geonosis-lint-parity --a <config-a.json> --b <config-b.json> [--oxlint <path>] [--out <dir>] -- <paths…>
83
+ pnpm add -D @geonosis/lint-parity
79
84
  ```
80
85
 
81
- **Write both configs inside the workspace being linted** — `<ws>/.oxlintrc.parity-a.json`, deleted
82
- afterwards — or name the plugin by absolute path in `jsPlugins`. oxlint resolves a `jsPlugins`
83
- specifier relative to the **config file's** directory, not the working directory, so a config
84
- written to a temp dir cannot find a plugin installed in the workspace: it fails with
85
- `Cannot find module '@…/plugin'`, and the tool correctly refuses with
86
- `config A: oxlint exited 1 without reporting a single finding`.
87
-
88
86
  ### Step 0 — reach, before parity
89
87
 
90
88
  Parity between two configs that both do nothing is perfect parity. In one source repo 34 of 51
91
89
  configured rules fired nowhere in any corpus it had: 34 rules a consumer would inherit as claims
92
- nothing could check. So this package ships the **reach corpus** the fixture tree its own gates run
93
- against, one deliberately broken file per rule — and answers the question directly:
90
+ nothing could check. Point the tool at the corpus this package installed:
94
91
 
95
92
  ```bash
96
- geonosis-lint-parity --corpus [<dir>] --a oxlintrc.fork.json --b oxlintrc.published.json [--out <dir>]
93
+ npx geonosis-lint-parity \
94
+ --corpus node_modules/@geonosis/oxlint-plugin-biological-architecture/corpus \
95
+ --a oxlintrc.fork.json --b oxlintrc.published.json --out proofs/reach
97
96
  ```
98
97
 
99
- Both configs run over the corpus (copied to a temp root first, so neither a `.gitignore` nor
100
- oxlint's `node_modules` skip can quietly turn "every rule fired nowhere" into the answer). It prints
101
- a fired-under-A / fired-under-B row per in-scope rule and **exits 1 when one fires under neither**.
102
- `--out` writes `CORPUS.md`.
103
-
104
- **A corpus covers the rules its own `manifest.json` names, and only those.** The one this package
105
- ships names `biological-architecture/*` — one deliberately broken file per rule, the list generated
106
- from the plugin's own rules export at build time. A configured `eqeqeq`, `unicorn/*` or
107
- `typescript/*` is listed under **outside this corpus** and gets no verdict: this tree is no evidence
108
- either way about somebody else's rule, and judging one would fail every real config for saying
109
- nothing. `--corpus <dir>` points the same run at another plugin's corpus; the scope moves with it.
98
+ It prints a fired-under-A / fired-under-B row per in-scope rule and **exits 1 when one fires under
99
+ neither**. A configured `eqeqeq`, `unicorn/*` or `typescript/*` is listed under **outside this
100
+ corpus** and gets no verdict: this tree is no evidence either way about somebody else's rule.
110
101
 
111
102
  Every rule this package exports has at least one corpus file that fires it, and a test fails if that
112
103
  stops being true — so a rule cannot be added without evidence it reaches anything.
113
104
 
114
105
  ### Then parity
115
106
 
116
- It runs oxlint twice with `--format=unix` over the same paths, strips the working-directory prefix,
117
- sorts, and prints the two counts, `only in A`, `only in B` and a per-rule tally. **It exits 1 when
118
- `only in A` is non-empty** — a finding the old config caught and the new one does not. New findings
119
- are what an upgrade is for; lost ones stop the adoption. `--out` writes `a.txt`, `b.txt`,
120
- `only-in-a.txt`, `only-in-b.txt`, `changed.txt` and `SUMMARY.md` for the proof file.
121
-
122
- **A finding is identified by where it is and which rule found it — `path:line:col` plus
123
- `plugin(rule)`.** The message is not part of it, and neither is the severity. A release that only
124
- rewords a rule's message would otherwise show as one finding lost and one gained, and stop an
125
- adoption over a sentence. Those pair up instead under **changed**, reported in full and never
126
- reaching the exit code; a severity change lands there too, because that is a fact about the config
127
- rather than a rule that stopped firing.
128
-
129
107
  **Fork vs published**, before deleting a vendored copy — two configs identical but for `jsPlugins`:
130
108
 
131
109
  ```bash
132
110
  # oxlintrc.fork.json → "jsPlugins": ["./packages/eslint-plugin-biological-architecture"]
133
111
  # oxlintrc.published.json → "jsPlugins": ["@geonosis/oxlint-plugin-biological-architecture"]
134
- geonosis-lint-parity \
112
+ npx geonosis-lint-parity \
135
113
  --a oxlintrc.fork.json --b oxlintrc.published.json \
136
114
  --out proofs/adoption-parity \
137
115
  -- apps packages
138
116
  ```
139
117
 
140
- **Version vs version**, as the kit's own release check the same config, pointing first at the
141
- installed version and then at a local build, by absolute path:
142
-
143
- ```bash
144
- geonosis-lint-parity \
145
- --a oxlintrc.0.1.0.json --b oxlintrc.0.1.1.json \
146
- --out proofs/parity-0.1.0-vs-0.1.1 \
147
- -- apps packages
148
- ```
118
+ **Write both configs inside the workspace being linted**oxlint resolves a `jsPlugins` specifier
119
+ relative to the **config file's** directory, not the working directory. It exits 1 when a finding
120
+ config A reported is missing under config B; a reworded message is reported as **changed** and never
121
+ reaches the exit code. The tool's README has the rest.
149
122
 
150
- `--oxlint` overrides the binary; by default it walks up from the working directory for
151
- `node_modules/.bin/oxlint`, so the comparison runs under the consumer's own linter rather than one
152
- `npx` might fetch. Every difference the run reports has to trace to a decision written down in
153
- `RULES.md`; one that does not is a defect, not a difference.
123
+ Every difference a run reports has to trace to a decision written down in `RULES.md`; one that does
124
+ not is a defect, not a difference.
154
125
 
155
126
  ### What the two configs must say for the findings to match
156
127
 
157
- A fork usually had its repo's vocabulary compiled in. Here it is configuration, and a config that
158
- omits it turns the rule off which reads as a lost finding, correctly. Carry these across:
128
+ A fork usually had its repo's vocabulary compiled in. Here it is configuration and **a rule
129
+ enabled without the options it needs refuses the run**, naming itself and the missing option, rather
130
+ than quietly finding nothing. That is deliberate: a rule enabled at `"error"` with nothing to
131
+ compare against is indistinguishable from a clean tree, and one consumer shipped exactly that for
132
+ months. Carry these across, or turn the rule off:
159
133
 
160
- | Rule | Option | Without it |
134
+ | Rule | Option | Enabled without it |
161
135
  | --- | --- | --- |
162
- | `no-brand-names` | `{ brands, allowedSubstrings }` | does nothing |
136
+ | `no-brand-names` | `{ brands, allowedSubstrings }` | refuses the run |
163
137
  | `organelle-dependency` | `{ sharedFeatures }` | only `shared` crosses a feature boundary |
164
- | `documents-share-one-table` | `{ perKindTables }` | does nothing |
165
- | `document-sagas-are-generic` | `{ perKindPrefixes }` | does nothing |
166
- | `step-opens-its-own-cell` | `{ engine, within, wrapper }` | does nothing |
167
- | `no-raw-html-atoms` | the positional element→atom map | does nothing |
138
+ | `documents-share-one-table` | `{ perKindTables }` | refuses the run |
139
+ | `document-sagas-are-generic` | `{ perKindPrefixes }` | refuses the run |
140
+ | `step-opens-its-own-cell` | `{ engine, within, wrapper }` | refuses the run |
141
+ | `no-raw-html-atoms` | the positional element→atom map | refuses the run |
142
+ | `layer-walls` | `{ layers: [{ name, paths, mayImport }], externals? }` | refuses the run |
143
+
144
+ An empty list counts as unconfigured. A rule set to `"off"`, or never named, is untouched — "does
145
+ nothing until configured" survives for a rule nobody enabled, which is oxlint's job, not ours.
168
146
 
169
147
  Two more facts a fork's config will trip on:
170
148
 
@@ -35,6 +35,39 @@
35
35
  }
36
36
  ],
37
37
  "biological-architecture/effect-hook-naming": "error",
38
+ "biological-architecture/layer-walls": [
39
+ "error",
40
+ {
41
+ "layers": [
42
+ {
43
+ "name": "core",
44
+ "paths": [
45
+ "(?:^|/)layers/core/"
46
+ ],
47
+ "mayImport": []
48
+ },
49
+ {
50
+ "name": "feature",
51
+ "paths": [
52
+ "(?:^|/)layers/feature/"
53
+ ],
54
+ "mayImport": [
55
+ "core"
56
+ ]
57
+ },
58
+ {
59
+ "name": "shell",
60
+ "paths": [
61
+ "(?:^|/)layers/shell/"
62
+ ],
63
+ "mayImport": [
64
+ "core",
65
+ "feature"
66
+ ]
67
+ }
68
+ ]
69
+ }
70
+ ],
38
71
  "biological-architecture/max-comment-density": "error",
39
72
  "biological-architecture/molecule-atoms-only": "error",
40
73
  "biological-architecture/molecule-must-compose": "error",
@@ -0,0 +1,3 @@
1
+ import { helper } from '../feature/sibling'
2
+
3
+ export const reachesUp = () => helper()
@@ -0,0 +1 @@
1
+ export const thing = () => 'core'
@@ -0,0 +1,3 @@
1
+ import { boot } from '../shell/boot'
2
+
3
+ export const crosses = () => boot()
@@ -0,0 +1,3 @@
1
+ import { thing } from '../core/thing'
2
+
3
+ export const downward = () => thing()
@@ -0,0 +1,3 @@
1
+ import { helper } from './sibling'
2
+
3
+ export const sameLayer = () => helper()
@@ -0,0 +1,3 @@
1
+ import type { Boot } from '../shell/boot'
2
+
3
+ export const describeBoot = (boot: Boot): string => boot.name
@@ -0,0 +1 @@
1
+ export const helper = () => 'feature'
@@ -0,0 +1,3 @@
1
+ export type Boot = { name: string }
2
+
3
+ export const boot = () => 'shell'
@@ -13,6 +13,7 @@
13
13
  "biological-architecture/document-sagas-are-generic",
14
14
  "biological-architecture/documents-share-one-table",
15
15
  "biological-architecture/effect-hook-naming",
16
+ "biological-architecture/layer-walls",
16
17
  "biological-architecture/max-comment-density",
17
18
  "biological-architecture/molecule-atoms-only",
18
19
  "biological-architecture/molecule-must-compose",
@@ -59,6 +59,7 @@ var TENANT_SCOPING = [
59
59
  "tables-declare-their-plane",
60
60
  "tenant-tables-carry-org-id"
61
61
  ];
62
+ var TOOLING = ["layer-walls"];
62
63
  var RULE_PREFIX = "biological-architecture";
63
64
  var asRules = (names) => Object.fromEntries(names.map((name) => [`${RULE_PREFIX}/${name}`, "error"]));
64
65
  var COMMENT_CEILING = {
@@ -75,7 +76,8 @@ var PRESETS = {
75
76
  "comment-ceiling": COMMENT_CEILING,
76
77
  frontend: { rules: asRules(FRONTEND) },
77
78
  recommended: { rules: asRules(FRONTEND) },
78
- "tenant-scoping": { rules: asRules(TENANT_SCOPING) }
79
+ "tenant-scoping": { rules: asRules(TENANT_SCOPING) },
80
+ tooling: { rules: asRules(TOOLING) }
79
81
  };
80
82
  var rulesOfPreset = (preset) => Object.keys(PRESETS[preset].rules).filter((id) => id.startsWith(`${RULE_PREFIX}/`)).map((id) => id.slice(RULE_PREFIX.length + 1));
81
83
 
@@ -0,0 +1,39 @@
1
+ interface AtomMapping {
2
+ atomFile: string;
3
+ atomName: string;
4
+ nativeElement: string;
5
+ }
6
+ interface AtomRestriction {
7
+ atom: string;
8
+ element: string;
9
+ importPath: string;
10
+ }
11
+ interface RestrictedSyntaxEntry {
12
+ message: string;
13
+ selector: string;
14
+ }
15
+ /**
16
+ * Build no-restricted-syntax entries that forbid raw native HTML usage
17
+ * for every native element wrapped by an atom. Apply these entries to
18
+ * molecules, compounds, cells, and tissues (NOT atoms themselves).
19
+ */
20
+ declare function buildAtomRestrictions(atomsDir: string, importPathPrefix?: string): RestrictedSyntaxEntry[];
21
+ /**
22
+ * Build atom restrictions in the format expected by the no-raw-html-atoms rule.
23
+ * This replaces no-restricted-syntax for linters that don't support it (e.g. oxlint).
24
+ */
25
+ declare function buildAtomRuleRestrictions(atomsDir: string, importPathPrefix?: string): AtomRestriction[];
26
+ /**
27
+ * Scan the atoms directory and build a mapping from native HTML element
28
+ * to the atom that wraps it. This is used to generate no-restricted-syntax
29
+ * entries that forbid raw native HTML usage in higher-level components.
30
+ *
31
+ * When multiple atoms wrap the same element (e.g. `input.tsx` and
32
+ * `inline-input.tsx` both wrap `<input>`), the atom whose filename
33
+ * exactly matches the element name is preferred as the canonical one.
34
+ * Variants are kept as alternatives in the error message.
35
+ */
36
+ declare function scanAtomMappings(atomsDir: string): AtomMapping[];
37
+ declare function detectFirstNativeJsxElement(source: string): null | string;
38
+
39
+ export { buildAtomRestrictions, buildAtomRuleRestrictions, detectFirstNativeJsxElement, scanAtomMappings };
@@ -0,0 +1,54 @@
1
+ import { Preset } from './presets.js';
2
+ export { PRESETS, PresetName, rulesOfPreset } from './presets.js';
3
+
4
+ type Rule = {
5
+ create: (context: never) => Record<string, unknown>;
6
+ fixShape: string;
7
+ meta: {
8
+ docs: {
9
+ description: string;
10
+ };
11
+ messages: Record<string, string>;
12
+ schema: unknown[];
13
+ type: string;
14
+ };
15
+ };
16
+ declare const rules: Record<string, Rule>;
17
+ type RuleName = string;
18
+
19
+ declare const plugin: {
20
+ configs: {
21
+ 'backend-d1': {
22
+ rules: Record<string, "error">;
23
+ };
24
+ 'backend-medusa': {
25
+ rules: Record<string, "error">;
26
+ };
27
+ 'backend-sagaflow-cf': {
28
+ rules: Record<string, "error">;
29
+ };
30
+ 'backend-workflows': {
31
+ rules: Record<string, "error">;
32
+ };
33
+ 'comment-ceiling': Preset;
34
+ frontend: {
35
+ rules: Record<string, "error">;
36
+ };
37
+ recommended: {
38
+ rules: Record<string, "error">;
39
+ };
40
+ 'tenant-scoping': {
41
+ rules: Record<string, "error">;
42
+ };
43
+ tooling: {
44
+ rules: Record<string, "error">;
45
+ };
46
+ };
47
+ meta: {
48
+ name: string;
49
+ version: string;
50
+ };
51
+ rules: Record<string, Rule>;
52
+ };
53
+
54
+ export { Preset, type Rule, type RuleName, plugin as default, rules };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
- PRESETS
3
- } from "./chunk-74VWOEPL.js";
2
+ PRESETS,
3
+ rulesOfPreset
4
+ } from "./chunk-HXBZSOIU.js";
4
5
 
5
6
  // src/index.ts
6
7
  import { createRequire } from "module";
@@ -142,7 +143,7 @@ import * as path from "path";
142
143
 
143
144
  // src/rules/lib/cells-barrel.ts
144
145
  var CELLS_BARREL_FILE = /(?:^|\/)cells\/(?:index\.ts|dynamic\.tsx?)$/;
145
- var isCellsBarrel = (path2) => CELLS_BARREL_FILE.test(path2);
146
+ var isCellsBarrel = (path3) => CELLS_BARREL_FILE.test(path3);
146
147
 
147
148
  // src/rules/cell-must-not-compose-cell.ts
148
149
  function cellStem(filePath) {
@@ -566,12 +567,27 @@ var literalNamedBy = (node, callee) => {
566
567
  return name?.type === "Literal" && typeof name.value === "string" ? name.value : null;
567
568
  };
568
569
 
570
+ // src/rules/lib/options.ts
571
+ var NOTHING = "nothing";
572
+ var isMissing = (value) => value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
573
+ var requireOption = (rule, value, option) => {
574
+ if (isMissing(value)) {
575
+ throw new Error(
576
+ `biological-architecture/${rule} is enabled but its \`${option}\` option is ${NOTHING}, so it can never fire. Configure \`${option}\`, or turn the rule off \u2014 an enabled rule that cannot fire reads exactly like a rule that found nothing.`
577
+ );
578
+ }
579
+ return value;
580
+ };
581
+
569
582
  // src/rules/document-sagas-are-generic.ts
570
583
  var escapeForRegex = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
571
584
  var documentSagasAreGeneric = {
572
585
  create(context) {
573
- const prefixes = context.options?.[0]?.perKindPrefixes ?? [];
574
- if (prefixes.length === 0) return {};
586
+ const prefixes = requireOption(
587
+ "document-sagas-are-generic",
588
+ context.options?.[0]?.perKindPrefixes,
589
+ "perKindPrefixes"
590
+ );
575
591
  if (!/\/packages\/workflows\/src\//.test(context.filename.replaceAll("\\", "/"))) return {};
576
592
  const perKindPrefix = new RegExp(`^(?:${prefixes.map(escapeForRegex).join("|")})\\.`);
577
593
  return {
@@ -584,7 +600,8 @@ var documentSagasAreGeneric = {
584
600
  },
585
601
  fixShape: `A saga named for one document kind (\`invoice.issue\`, \`quote.accept\`) is the hand-written fork the
586
602
  generic base entity replaced. Mint it from the generic factory \u2014 \`documentSagas(entity, effects)\` \u2014
587
- and pass the kind as data. Configure the kind vocabulary through the rule's \`perKindPrefixes\` option.`,
603
+ and pass the kind as data. Configure the kind vocabulary through the rule's \`perKindPrefixes\` option; enabled without it the
604
+ rule refuses the run.`,
588
605
  meta: {
589
606
  docs: {
590
607
  description: "Document sagas are minted, not written: `documentSagas(entity, effects)` gives every entity that extends Document its create/save/setStatus/remove/send/convert/duplicate/share from one body. A saga literally named for one entity is a fork."
@@ -610,8 +627,13 @@ var document_sagas_are_generic_default = documentSagasAreGeneric;
610
627
  var TABLE_CALLEES = ["sqliteTable", "pgTable"];
611
628
  var documentsShareOneTable = {
612
629
  create(context) {
613
- const perKindTables = new Set(context.options?.[0]?.perKindTables ?? []);
614
- if (perKindTables.size === 0) return {};
630
+ const perKindTables = new Set(
631
+ requireOption(
632
+ "documents-share-one-table",
633
+ context.options?.[0]?.perKindTables,
634
+ "perKindTables"
635
+ )
636
+ );
615
637
  if (!/\/packages\/db\/src\/schema\//.test(context.filename.replaceAll("\\", "/"))) return {};
616
638
  return {
617
639
  CallExpression(node) {
@@ -627,8 +649,9 @@ var documentsShareOneTable = {
627
649
  fixShape: `Every document kind is a row of the one \`documents\` table with a \`kind\` column, not a table of its
628
650
  own. A \`pgTable('invoices', \u2026)\` beside a \`pgTable('quotes', \u2026)\` is the second storage the base entity
629
651
  retired: write the row, not the table. Both constructors count \u2014 \`pgTable\` and \`sqliteTable\` \u2014 since
630
- the dialect is a seam fact, not a reason for a kind to own storage. The forbidden table names are
631
- the rule's \`perKindTables\` option.`,
652
+ the dialect is a seam fact, not a reason for a kind to own storage. The forbidden table names are the rule's
653
+ \`perKindTables\` option; enabled without it the rule refuses the run rather than passing every
654
+ schema silently.`,
632
655
  meta: {
633
656
  docs: {
634
657
  description: "Documents share ONE table: `documents` holds the head every kind derives and the body its kind declares. A table named for one kind of document is a second storage."
@@ -720,6 +743,109 @@ file and the export together \u2014 half the pair still fires.`,
720
743
  };
721
744
  var effect_hook_naming_default = effectHookNaming;
722
745
 
746
+ // src/rules/layer-walls.ts
747
+ import * as path2 from "path";
748
+ var REQUIRE = "require";
749
+ var isRelative = (source) => source.startsWith("./") || source.startsWith("../");
750
+ var isExternal = (source) => !isRelative(source) && !source.startsWith("/");
751
+ var layerWalls = {
752
+ create(context) {
753
+ const { externals = "allow", layers = [] } = context.options?.[0] ?? {};
754
+ requireOption("layer-walls", layers, "layers");
755
+ const compiled = layers.map((layer) => ({
756
+ mayImport: new Set(layer.mayImport ?? []),
757
+ name: layer.name,
758
+ paths: layer.paths.map((source) => new RegExp(source))
759
+ }));
760
+ const layerOf = (pathish) => compiled.find((layer) => layer.paths.some((pattern) => pattern.test(pathish)));
761
+ const filename = context.filename.replaceAll(/\\/g, "/");
762
+ const here = layerOf(filename);
763
+ if (here === void 0) return {};
764
+ const dir = path2.posix.dirname(filename);
765
+ const file = filename.split("/").pop() ?? filename;
766
+ const allowed = here.mayImport.size === 0 ? "nothing" : [...here.mayImport].toSorted().join(", ");
767
+ const check = (source, node) => {
768
+ const target = layerOf(source) ?? (isRelative(source) ? layerOf(path2.posix.normalize(path2.posix.join(dir, source))) : void 0);
769
+ if (target === void 0) {
770
+ if (externals === "deny" && isExternal(source)) {
771
+ context.report({ data: { file, from: here.name, source }, messageId: "external", node });
772
+ }
773
+ return;
774
+ }
775
+ if (target.name === here.name || here.mayImport.has(target.name)) return;
776
+ context.report({
777
+ data: { allowed, file, from: here.name, source, to: target.name },
778
+ messageId: "crossed",
779
+ node
780
+ });
781
+ };
782
+ const fromModule = (node) => {
783
+ if (!node.source) return;
784
+ if (node.importKind === "type" || node.exportKind === "type") return;
785
+ check(node.source.value, node);
786
+ };
787
+ return {
788
+ CallExpression(node) {
789
+ const source = literalNamedBy(node, REQUIRE);
790
+ if (source !== null) check(source, node);
791
+ },
792
+ ExportAllDeclaration: fromModule,
793
+ ExportNamedDeclaration: fromModule,
794
+ ImportDeclaration: fromModule,
795
+ ImportExpression(node) {
796
+ const source = node.source;
797
+ if (source?.type === "Literal" && typeof source.value === "string") {
798
+ check(source.value, node);
799
+ }
800
+ }
801
+ };
802
+ },
803
+ fixShape: `Layers are declared in the rule's \`layers\` option \u2014 \`{ name, paths, mayImport }\` \u2014 and a file may
804
+ import its own layer plus the layers it names, nothing else. When this fires, MOVE something: the
805
+ code the wrong side wanted goes down into the layer both sides may import, or the file itself
806
+ belongs in the layer it keeps reaching for. Widening \`mayImport\` to make the diagnostic stop is
807
+ deleting the wall, and the wall is the only reason the layers are worth naming.
808
+
809
+ A relative source is resolved against the importing file before it is judged, so \`../../shell/boot\`
810
+ is the same breach as naming the layer outright. Type-only imports and re-exports are exempt; a
811
+ package or a \`node:\` builtin is not a wall unless \`externals\` is \`"deny"\`. A file in no declared
812
+ layer is unconstrained, so a repo can wall one corner of its tree and leave the rest alone. Enabled
813
+ with no \`layers\` at all the rule refuses the run: walls nobody declared are not walls held.`,
814
+ meta: {
815
+ docs: {
816
+ description: "Import direction between a repo's own declared layers. Each layer names the files it owns (regex sources) and the layers it may import; a file may always import its own layer. Relative sources are resolved against the importing file first. Enabled with no layers configured it refuses the run."
817
+ },
818
+ messages: {
819
+ crossed: '"{{file}}" is in layer "{{from}}", which may not import layer "{{to}}" \u2014 and "{{source}}" is in it. "{{from}}" may import: {{allowed}}. Move the shared code down into a layer both may import, or move this file into the layer it keeps reaching for; widening the wall deletes the only thing the layers were for.',
820
+ external: '"{{file}}" is in layer "{{from}}", which declares its externals denied, and "{{source}}" is outside every declared layer. Reach it through a layer that owns it, or allow externals for this configuration.'
821
+ },
822
+ schema: [
823
+ {
824
+ additionalProperties: false,
825
+ properties: {
826
+ externals: { enum: ["allow", "deny"], type: "string" },
827
+ layers: {
828
+ items: {
829
+ additionalProperties: false,
830
+ properties: {
831
+ mayImport: { items: { type: "string" }, type: "array" },
832
+ name: { type: "string" },
833
+ paths: { items: { type: "string" }, type: "array" }
834
+ },
835
+ required: ["name", "paths"],
836
+ type: "object"
837
+ },
838
+ type: "array"
839
+ }
840
+ },
841
+ type: "object"
842
+ }
843
+ ],
844
+ type: "problem"
845
+ }
846
+ };
847
+ var layer_walls_default = layerWalls;
848
+
723
849
  // src/rules/max-comment-density.ts
724
850
  var exemptFiles2 = [
725
851
  /\/__tests__\//,
@@ -1117,7 +1243,7 @@ var escapeForRegex2 = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")
1117
1243
  var noBrandNames = {
1118
1244
  create(context) {
1119
1245
  const { allowedSubstrings = [], brands = [] } = context.options?.[0] ?? {};
1120
- if (brands.length === 0) return {};
1246
+ requireOption("no-brand-names", brands, "brands");
1121
1247
  const filename = context.filename.replaceAll(/\\/g, "/");
1122
1248
  if (EXEMPT_PATH.test(filename)) return {};
1123
1249
  const brandPattern = new RegExp(`\\b(?:${brands.map(escapeForRegex2).join("|")})\\b`, "i");
@@ -1158,12 +1284,13 @@ var noBrandNames = {
1158
1284
  };
1159
1285
  },
1160
1286
  fixShape: `The kit is brand-agnostic: no brand identity in its source \u2014 not in identifiers, strings, copy or
1161
- comments. Brand names live in the demo/example layer. Configure the rule's \`brands\` list (it does
1162
- nothing with an empty list) and put real demo-asset hosts in \`allowedSubstrings\` so a CDN URL never
1163
- trips it.`,
1287
+ comments. Brand names live in the demo/example layer. Configure the rule's \`brands\` list and put real
1288
+ demo-asset hosts in \`allowedSubstrings\` so a CDN URL never trips it. Enabled with no \`brands\`, the
1289
+ rule refuses the run \u2014 configure it or turn it off, because a brand rule that knows no brands reads
1290
+ exactly like a clean tree.`,
1164
1291
  meta: {
1165
1292
  docs: {
1166
- description: "No brand identity in a brand-agnostic kit. Brand names (in code, copy, or comments) belong in the examples/demo layer, not the kit source. Configure the brand list and any allowed demo-asset substrings through the rule options; with no brands the rule does nothing."
1293
+ description: "No brand identity in a brand-agnostic kit. Brand names (in code, copy, or comments) belong in the examples/demo layer, not the kit source. Configure the brand list and any allowed demo-asset substrings through the rule options; enabled with no brands it refuses the run."
1167
1294
  },
1168
1295
  messages: {
1169
1296
  brandLeak: 'Brand name "{{brand}}" leaked into the kit at line {{line}}. The kit is brand-agnostic \u2014 move brand identity to the examples/demo layer.'
@@ -1988,8 +2115,11 @@ var no_orm_outside_db_default = noOrmOutsideDb;
1988
2115
  // src/rules/no-raw-html-atoms.ts
1989
2116
  var noRawHtmlAtoms = {
1990
2117
  create(context) {
1991
- const restrictions = context.options[0];
1992
- if (!restrictions || restrictions.length === 0) return {};
2118
+ const restrictions = requireOption(
2119
+ "no-raw-html-atoms",
2120
+ context.options[0],
2121
+ "element\u2192atom map"
2122
+ );
1993
2123
  const elementMap = /* @__PURE__ */ new Map();
1994
2124
  for (const r of restrictions) {
1995
2125
  elementMap.set(r.element, r);
@@ -2014,8 +2144,9 @@ var noRawHtmlAtoms = {
2014
2144
  };
2015
2145
  },
2016
2146
  fixShape: `A native element that already has an atom wrapper (\`<button>\`, \`<input>\`, \`<td>\`, \`<header>\`, \u2026) is
2017
- written as the atom in levels 2-6. Import the atom named in the rule's options and use it. Configure
2018
- the element\u2192atom map per repo; with no map the rule does nothing.`,
2147
+ written as the atom in levels 2-6. Import the atom named in the rule's options and use it. Configure the element\u2192atom map per repo;
2148
+ enabled without one the rule refuses the run, because a rule with no map to check reads exactly like
2149
+ a tree with no raw elements in it.`,
2019
2150
  meta: {
2020
2151
  docs: {
2021
2152
  description: "Forbid raw native HTML elements when an atom wrapper exists. Use the atom the options name instead."
@@ -3001,12 +3132,14 @@ can be misspelled and a second default nobody agreed to.`,
3001
3132
  var ssot_no_process_env_default = ssotNoProcessEnv;
3002
3133
 
3003
3134
  // src/rules/step-opens-its-own-cell.ts
3135
+ var RULE = "step-opens-its-own-cell";
3004
3136
  var STEP = "step";
3005
3137
  var readable = (pattern) => pattern.replaceAll("\\", "").replace(/\$$/, "");
3006
3138
  var stepOpensItsOwnCell = {
3007
3139
  create(context) {
3008
- const { engine, within, wrapper } = context.options?.[0] ?? {};
3009
- if (engine === void 0 || within === void 0) return {};
3140
+ const { wrapper } = context.options?.[0] ?? {};
3141
+ const engine = requireOption(RULE, context.options?.[0]?.engine, "engine");
3142
+ const within = requireOption(RULE, context.options?.[0]?.within, "within");
3010
3143
  const filename = context.filename.replaceAll("\\", "/");
3011
3144
  if (!new RegExp(within).test(filename)) return {};
3012
3145
  if (wrapper !== void 0 && new RegExp(wrapper).test(filename)) return {};
@@ -3036,10 +3169,10 @@ var stepOpensItsOwnCell = {
3036
3169
  package. A durable run hibernates and retries between steps, so each step body must open the
3037
3170
  connection it uses rather than share one opened once per run \u2014 the wrapper does that, the engine's
3038
3171
  \`step\` does not. The wrapper file is the one place allowed to reach the engine. Configure
3039
- \`engine\`, \`within\` and \`wrapper\`; unconfigured, the rule does nothing.`,
3172
+ \`engine\`, \`within\` and \`wrapper\`; enabled without \`engine\` or \`within\` the rule refuses the run.`,
3040
3173
  meta: {
3041
3174
  docs: {
3042
- description: "A durable saga takes its `step` from the repo's own wrapper, which opens a connection inside each step body \u2014 a durable run hibernates and retries, so a handle shared across steps is dead by the time a later step uses it. The engine module, the guarded tree and the wrapper file are options; with none the rule does nothing."
3175
+ description: "A durable saga takes its `step` from the repo's own wrapper, which opens a connection inside each step body \u2014 a durable run hibernates and retries, so a handle shared across steps is dead by the time a later step uses it. The engine module, the guarded tree and the wrapper file are options; enabled without them it refuses the run."
3043
3176
  },
3044
3177
  messages: {
3045
3178
  engineStep: '"{{file}}" imports `step` from `{{engine}}`. Import it from {{wrapper}} instead: a durable run spans hibernation and retries, so the connection a step uses has to be opened inside that step rather than shared across steps. The wrapper opens it; the engine\'s `step` does not.'
@@ -3869,6 +4002,7 @@ var rules = {
3869
4002
  "document-sagas-are-generic": document_sagas_are_generic_default,
3870
4003
  "documents-share-one-table": documents_share_one_table_default,
3871
4004
  "effect-hook-naming": effect_hook_naming_default,
4005
+ "layer-walls": layer_walls_default,
3872
4006
  "max-comment-density": max_comment_density_default,
3873
4007
  "molecule-atoms-only": molecule_atoms_only_default,
3874
4008
  "molecule-must-compose": molecule_must_compose_default,
@@ -3925,5 +4059,6 @@ var index_default = plugin;
3925
4059
  export {
3926
4060
  PRESETS,
3927
4061
  index_default as default,
3928
- rules
4062
+ rules,
4063
+ rulesOfPreset
3929
4064
  };