@rexezuge/tooling 0.0.0-stage → 1.0.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.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -2
  3. package/dist/eslint-boundaries.d.ts +75 -0
  4. package/dist/eslint-boundaries.d.ts.map +1 -0
  5. package/dist/eslint-boundaries.js +138 -0
  6. package/dist/eslint-boundaries.js.map +1 -0
  7. package/dist/eslint-rules.d.ts +41 -0
  8. package/dist/eslint-rules.d.ts.map +1 -0
  9. package/dist/eslint-rules.js +273 -0
  10. package/dist/eslint-rules.js.map +1 -0
  11. package/dist/eslint.d.ts +96 -0
  12. package/dist/eslint.d.ts.map +1 -0
  13. package/dist/eslint.js +189 -0
  14. package/dist/eslint.js.map +1 -0
  15. package/dist/functions/pages-proxy.d.ts +113 -0
  16. package/dist/functions/pages-proxy.d.ts.map +1 -0
  17. package/dist/functions/pages-proxy.js +131 -0
  18. package/dist/functions/pages-proxy.js.map +1 -0
  19. package/dist/index.d.ts +31 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +28 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/scripts/backup/d1-target.d.ts +35 -0
  24. package/dist/scripts/backup/d1-target.d.ts.map +1 -0
  25. package/dist/scripts/backup/d1-target.js +31 -0
  26. package/dist/scripts/backup/d1-target.js.map +1 -0
  27. package/dist/scripts/backup/destination-config.d.ts +61 -0
  28. package/dist/scripts/backup/destination-config.d.ts.map +1 -0
  29. package/dist/scripts/backup/destination-config.js +57 -0
  30. package/dist/scripts/backup/destination-config.js.map +1 -0
  31. package/dist/scripts/backup/encrypt-backup.d.ts +35 -0
  32. package/dist/scripts/backup/encrypt-backup.d.ts.map +1 -0
  33. package/dist/scripts/backup/encrypt-backup.js +98 -0
  34. package/dist/scripts/backup/encrypt-backup.js.map +1 -0
  35. package/dist/scripts/backup/naming.d.ts +44 -0
  36. package/dist/scripts/backup/naming.d.ts.map +1 -0
  37. package/dist/scripts/backup/naming.js +58 -0
  38. package/dist/scripts/backup/naming.js.map +1 -0
  39. package/dist/scripts/check-god-files.d.ts +164 -0
  40. package/dist/scripts/check-god-files.d.ts.map +1 -0
  41. package/dist/scripts/check-god-files.js +271 -0
  42. package/dist/scripts/check-god-files.js.map +1 -0
  43. package/dist/scripts/ensure-spa-shell-stub.d.ts +3 -0
  44. package/dist/scripts/ensure-spa-shell-stub.d.ts.map +1 -0
  45. package/dist/scripts/ensure-spa-shell-stub.js +30 -0
  46. package/dist/scripts/ensure-spa-shell-stub.js.map +1 -0
  47. package/dist/scripts/init-secrets.d.ts +63 -0
  48. package/dist/scripts/init-secrets.d.ts.map +1 -0
  49. package/dist/scripts/init-secrets.js +240 -0
  50. package/dist/scripts/init-secrets.js.map +1 -0
  51. package/dist/scripts/lib/cli-args.d.ts +78 -0
  52. package/dist/scripts/lib/cli-args.d.ts.map +1 -0
  53. package/dist/scripts/lib/cli-args.js +116 -0
  54. package/dist/scripts/lib/cli-args.js.map +1 -0
  55. package/dist/scripts/lib/github-actions.d.ts +26 -0
  56. package/dist/scripts/lib/github-actions.d.ts.map +1 -0
  57. package/dist/scripts/lib/github-actions.js +38 -0
  58. package/dist/scripts/lib/github-actions.js.map +1 -0
  59. package/dist/scripts/lib/wrangler-table.d.ts +46 -0
  60. package/dist/scripts/lib/wrangler-table.d.ts.map +1 -0
  61. package/dist/scripts/lib/wrangler-table.js +99 -0
  62. package/dist/scripts/lib/wrangler-table.js.map +1 -0
  63. package/dist/scripts/migrations-lock.d.ts +3 -0
  64. package/dist/scripts/migrations-lock.d.ts.map +1 -0
  65. package/dist/scripts/migrations-lock.js +46 -0
  66. package/dist/scripts/migrations-lock.js.map +1 -0
  67. package/dist/scripts/prepare-wrangler-config.d.ts +3 -0
  68. package/dist/scripts/prepare-wrangler-config.d.ts.map +1 -0
  69. package/dist/scripts/prepare-wrangler-config.js +50 -0
  70. package/dist/scripts/prepare-wrangler-config.js.map +1 -0
  71. package/dist/scripts/spa-shell.d.ts +41 -0
  72. package/dist/scripts/spa-shell.d.ts.map +1 -0
  73. package/dist/scripts/spa-shell.js +155 -0
  74. package/dist/scripts/spa-shell.js.map +1 -0
  75. package/dist/scripts/validate-locales.d.ts +26 -0
  76. package/dist/scripts/validate-locales.d.ts.map +1 -0
  77. package/dist/scripts/validate-locales.js +350 -0
  78. package/dist/scripts/validate-locales.js.map +1 -0
  79. package/dist/scripts/verify-migrations.d.ts +62 -0
  80. package/dist/scripts/verify-migrations.d.ts.map +1 -0
  81. package/dist/scripts/verify-migrations.js +302 -0
  82. package/dist/scripts/verify-migrations.js.map +1 -0
  83. package/dist/scripts/verify-spa-shell.d.ts +3 -0
  84. package/dist/scripts/verify-spa-shell.d.ts.map +1 -0
  85. package/dist/scripts/verify-spa-shell.js +53 -0
  86. package/dist/scripts/verify-spa-shell.js.map +1 -0
  87. package/dist/scripts/wrangler-config/cli.d.ts +22 -0
  88. package/dist/scripts/wrangler-config/cli.d.ts.map +1 -0
  89. package/dist/scripts/wrangler-config/cli.js +51 -0
  90. package/dist/scripts/wrangler-config/cli.js.map +1 -0
  91. package/dist/scripts/wrangler-config/patches.d.ts +51 -0
  92. package/dist/scripts/wrangler-config/patches.d.ts.map +1 -0
  93. package/dist/scripts/wrangler-config/patches.js +140 -0
  94. package/dist/scripts/wrangler-config/patches.js.map +1 -0
  95. package/dist/scripts/wrangler-config/resources.d.ts +70 -0
  96. package/dist/scripts/wrangler-config/resources.d.ts.map +1 -0
  97. package/dist/scripts/wrangler-config/resources.js +290 -0
  98. package/dist/scripts/wrangler-config/resources.js.map +1 -0
  99. package/dist/scripts/wrangler-config/types.d.ts +103 -0
  100. package/dist/scripts/wrangler-config/types.d.ts.map +1 -0
  101. package/dist/scripts/wrangler-config/types.js +49 -0
  102. package/dist/scripts/wrangler-config/types.js.map +1 -0
  103. package/dist/test/integration-migrations.d.ts +167 -0
  104. package/dist/test/integration-migrations.d.ts.map +1 -0
  105. package/dist/test/integration-migrations.js +171 -0
  106. package/dist/test/integration-migrations.js.map +1 -0
  107. package/dist/test/mocks/cloudflare-workers.d.ts +106 -0
  108. package/dist/test/mocks/cloudflare-workers.d.ts.map +1 -0
  109. package/dist/test/mocks/cloudflare-workers.js +90 -0
  110. package/dist/test/mocks/cloudflare-workers.js.map +1 -0
  111. package/dist/vite.d.ts +117 -0
  112. package/dist/vite.d.ts.map +1 -0
  113. package/dist/vite.js +125 -0
  114. package/dist/vite.js.map +1 -0
  115. package/dist/vitest-web.d.ts +73 -0
  116. package/dist/vitest-web.d.ts.map +1 -0
  117. package/dist/vitest-web.js +72 -0
  118. package/dist/vitest-web.js.map +1 -0
  119. package/dist/vitest.d.ts +92 -0
  120. package/dist/vitest.d.ts.map +1 -0
  121. package/dist/vitest.js +128 -0
  122. package/dist/vitest.js.map +1 -0
  123. package/package.json +58 -3
  124. package/src/eslint-boundaries.ts +169 -0
  125. package/src/eslint-rules.ts +276 -0
  126. package/src/eslint.test.ts +175 -0
  127. package/src/eslint.ts +257 -0
  128. package/src/functions/pages-proxy.test.ts +72 -0
  129. package/src/functions/pages-proxy.ts +187 -0
  130. package/src/github/actions/retry-step/action.yml +39 -0
  131. package/src/github/actions/setup-env/action.yml +20 -0
  132. package/src/github/dependabot.yml +30 -0
  133. package/src/github/workflows/backup-main.yml +46 -0
  134. package/src/github/workflows/continuous-deployment.yml +188 -0
  135. package/src/github/workflows/continuous-integration.yml +259 -0
  136. package/src/github/workflows/scheduled-version-update.yml +38 -0
  137. package/src/github/workflows/upstream-sync.yml +56 -0
  138. package/src/index.ts +42 -0
  139. package/src/scripts/backup/backup-rules.test.ts +105 -0
  140. package/src/scripts/backup/d1-target.ts +54 -0
  141. package/src/scripts/backup/destination-config.ts +92 -0
  142. package/src/scripts/backup/encrypt-backup.ts +107 -0
  143. package/src/scripts/backup/naming.ts +62 -0
  144. package/src/scripts/check-god-files.test.ts +131 -0
  145. package/src/scripts/check-god-files.ts +327 -0
  146. package/src/scripts/ensure-spa-shell-stub.ts +34 -0
  147. package/src/scripts/init-secrets.ts +265 -0
  148. package/src/scripts/lib/cli-args.ts +154 -0
  149. package/src/scripts/lib/github-actions.ts +41 -0
  150. package/src/scripts/lib/wrangler-table.ts +105 -0
  151. package/src/scripts/migrations-lock.ts +52 -0
  152. package/src/scripts/prepare-wrangler-config.ts +51 -0
  153. package/src/scripts/spa-shell.test.ts +91 -0
  154. package/src/scripts/spa-shell.ts +179 -0
  155. package/src/scripts/validate-locales.test.ts +89 -0
  156. package/src/scripts/validate-locales.ts +380 -0
  157. package/src/scripts/verify-migrations.test.ts +71 -0
  158. package/src/scripts/verify-migrations.ts +364 -0
  159. package/src/scripts/verify-spa-shell.ts +56 -0
  160. package/src/scripts/wrangler-config/cli.ts +51 -0
  161. package/src/scripts/wrangler-config/patches.ts +157 -0
  162. package/src/scripts/wrangler-config/resources.ts +330 -0
  163. package/src/scripts/wrangler-config/types.ts +113 -0
  164. package/src/test/integration-migrations.test.ts +169 -0
  165. package/src/test/integration-migrations.ts +267 -0
  166. package/src/test/mocks/cloudflare-workers.ts +115 -0
  167. package/src/vite.test.ts +83 -0
  168. package/src/vite.ts +202 -0
  169. package/src/vitest-web.ts +109 -0
  170. package/src/vitest.ts +185 -0
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defineRepoLintConfig } from './eslint';
3
+
4
+ /**
5
+ * Structural checks on the produced flat config.
6
+ *
7
+ * These do not run ESLint — a rule that has to be observed linting is a rule this
8
+ * suite cannot afford, and the repos' own suites lint their trees. What is asserted
9
+ * here is the part that is a *decision* rather than a lint result: which paths are
10
+ * ignored, which rules are errors rather than warnings, and that the boundary table
11
+ * and the dynamic-`import()` guard agree with each other. A regression in any of
12
+ * those is a config that silently stops gating something.
13
+ */
14
+
15
+ /** The only glob syntax the config uses: `**`, `*`, and `{a,b}` alternation. */
16
+ function globToRegExp(pattern: string): RegExp {
17
+ const escape = (char: string): string => char.replaceAll(/[$()+.?[\\\]^{|}]/g, String.raw`\$&`);
18
+ let out = '';
19
+ for (let index = 0; index < pattern.length; index += 1) {
20
+ const char = pattern[index] as string;
21
+ if (char === '*' && pattern[index + 1] === '*') {
22
+ out += '.*';
23
+ index += 1;
24
+ if (pattern[index + 1] === '/') index += 1;
25
+ } else if (char === '*') {
26
+ out += '[^/]*';
27
+ } else if (char === '{') {
28
+ const end = pattern.indexOf('}', index);
29
+ const alternatives = pattern
30
+ .slice(index + 1, end)
31
+ .split(',')
32
+ .map((alternative) => escape(alternative));
33
+ out += `(?:${alternatives.join('|')})`;
34
+ index = end;
35
+ } else {
36
+ out += escape(char);
37
+ }
38
+ }
39
+ return new RegExp(`^${out}$`);
40
+ }
41
+
42
+ /** Every config object in the array, flattened through any nested arrays. */
43
+ function entries(config: unknown): Array<Record<string, unknown>> {
44
+ const out: Array<Record<string, unknown>> = [];
45
+ for (const entry of config as unknown[]) {
46
+ if (Array.isArray(entry)) {
47
+ out.push(...entries(entry));
48
+ } else if (typeof entry === 'object' && entry !== null) {
49
+ out.push(entry as Record<string, unknown>);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * True when the entry applies to `path`.
57
+ *
58
+ * An entry with no `files` key is global — the prettier block and the plugin presets
59
+ * are written that way, so a matcher that required `files` would report them as
60
+ * applying to nothing and the config's most important decision would look absent.
61
+ */
62
+ function covers(entry: Record<string, unknown>, path: string): boolean {
63
+ const files = entry['files'];
64
+ if (!Array.isArray(files)) return true;
65
+ return files.some((pattern) => typeof pattern === 'string' && globToRegExp(pattern).test(path));
66
+ }
67
+
68
+ /** Every rule that applies to `path`, later entries winning. */
69
+ function rulesOf(config: unknown, path: string): Record<string, unknown> {
70
+ const rules: Record<string, unknown> = {};
71
+ for (const entry of entries(config)) {
72
+ if (covers(entry, path)) Object.assign(rules, entry['rules']);
73
+ }
74
+ return rules;
75
+ }
76
+
77
+ /** JSON of every rule block in the config. Rules are plain data; plugins are not. */
78
+ function rulesJson(config: unknown): string {
79
+ return entries(config)
80
+ .map((entry) => JSON.stringify(entry['rules'] ?? {}))
81
+ .join('\n');
82
+ }
83
+
84
+ const config = defineRepoLintConfig({ scope: '@fixture' });
85
+
86
+ describe('defineRepoLintConfig', () => {
87
+ it('ignores the generated and build trees but not the test suite', () => {
88
+ const ignores = (config[0] as { ignores?: string[] }).ignores ?? [];
89
+ expect(ignores).toContain('apps/api/src/generated/**');
90
+ expect(ignores).toContain('apps/web/dist/**');
91
+ // `test/**` is deliberately absent: Edge-Sonic's decision, and the one the
92
+ // other source repos had the opposite of.
93
+ expect(ignores.some((pattern) => pattern.startsWith('test/'))).toBe(false);
94
+ });
95
+
96
+ it('makes formatting drift an error, not a warning', () => {
97
+ // `pnpm run lint` is `eslint --fix --quiet`, and `--quiet` discards warnings: a
98
+ // `prettier/prettier` warning is a gate that has never run.
99
+ expect(rulesOf(config, 'src/anything.ts')['prettier/prettier']).toBe('error');
100
+ });
101
+
102
+ it('gives web files browser globals in addition to node globals', () => {
103
+ // The sources linted apps/web under globals.node only, so window and document
104
+ // read as undefined globals — and the vitest web config runs those suites under
105
+ // jsdom, so the lint environment disagreed with the execution environment.
106
+ const webEntries = entries(config).filter((entry) => covers(entry, 'apps/web/src/main.tsx'));
107
+ const browser = webEntries
108
+ .map((entry) => Object.keys((entry as { languageOptions?: { globals?: Record<string, unknown> } }).languageOptions?.globals ?? {}))
109
+ .flat();
110
+ expect(browser).toContain('window');
111
+ expect(browser).toContain('document');
112
+ // And the node globals survive: a shared file touching both still lints.
113
+ expect(browser).toContain('process');
114
+ });
115
+
116
+ it('keeps the three plugin families on', () => {
117
+ // sonarjs / unicorn / regexp are each a preset spread into the array; a preset
118
+ // that failed to import would leave the array without their rule blocks.
119
+ const all = rulesJson(config);
120
+ expect(all).toContain('unicorn/');
121
+ expect(all).toContain('sonarjs/');
122
+ expect(all).toContain('regexp/');
123
+ });
124
+
125
+ it('parameterizes the boundary rules by scope', () => {
126
+ const rules = rulesJson(config);
127
+ expect(rules).toContain('@fixture/backend-data');
128
+ expect(rules).not.toContain('@durable-dav/backend-data');
129
+ });
130
+
131
+ it('bans a backend package from the SPA, with a message that says why', () => {
132
+ const rules = rulesJson(config);
133
+ expect(rules).toContain('ships zero backend dependencies');
134
+ });
135
+
136
+ it('mirrors every boundary in a dynamic-import guard', () => {
137
+ const syntax = rulesOf(config, 'packages/backend-runtime/src/x.ts')['no-restricted-syntax'];
138
+ expect(Array.isArray(syntax)).toBe(true);
139
+ // `no-restricted-imports` reads ImportDeclaration only, so the same boundary has
140
+ // to be stated for ImportExpression too — that hole was load-bearing in
141
+ // Edge-Sonic, where the credential path reached a banned module through it.
142
+ expect(JSON.stringify(syntax)).toContain("ImportExpression[source.type='Literal']");
143
+ expect(JSON.stringify(syntax)).toContain('@fixture/backend-data');
144
+ });
145
+
146
+ it('relaxes the Vitest idioms in tests but not the correctness rules', () => {
147
+ const rules = rulesOf(config, 'test/schema.test.ts');
148
+ expect(rules['@typescript-eslint/unbound-method']).toBe('off');
149
+ expect(rules['unicorn/prefer-https']).toBe('off');
150
+ expect(rules['sonarjs/no-clear-text-protocols']).toBe('off');
151
+ // Unused variables stay on in tests, at exactly the severity the product
152
+ // source gets: a dead fake is a defect, not an artefact of test shape.
153
+ expect(rules['@typescript-eslint/no-unused-vars']).toStrictEqual(
154
+ rulesOf(config, 'src/anything.ts')['@typescript-eslint/no-unused-vars'],
155
+ );
156
+ });
157
+
158
+ it('accepts extra layers without losing the canonical ones', () => {
159
+ const extended = defineRepoLintConfig({
160
+ scope: '@fixture',
161
+ extraLayers: [
162
+ {
163
+ dir: 'apps/api/src/endpoints',
164
+ ban: ['@fixture/backend-data/dao'],
165
+ allowTypeImports: true,
166
+ message: 'route classes must not import DAOs directly',
167
+ },
168
+ ],
169
+ });
170
+ const rules = rulesJson(extended);
171
+ expect(rules).toContain('route classes must not import DAOs directly');
172
+ expect(rules).toContain('zero-dependency base layer');
173
+ expect(rules).toContain('ships zero backend dependencies');
174
+ });
175
+ });
package/src/eslint.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The converged ESLint flat config for a monorepo in the Rexezuge family.
3
+ *
4
+ * Provenance: the base is Durable-DAV's `eslint.config.mjs` (380+ lines, the most
5
+ * elaborate of the nine repos, with the layered `no-restricted-imports` boundary
6
+ * rules). Merged in from the other three stacks that share the same plugin set:
7
+ *
8
+ * - **Edge-Sonic** — `test/**` is *not* ignored (it is linted like everything else,
9
+ * with a narrow override block for Vitest idioms), and `unicorn/prefer-ternary`
10
+ * is off. Edge-Sonic is also the source for the dynamic-`import()` boundary
11
+ * guard and its `apps/web` layer block.
12
+ * - **Mail-Meow** — `prettier/prettier` is an **error**, not a warning.
13
+ * - **AWS-AccessBridge** — the `apps/api` DAO/service boundary uses
14
+ * `allowTypeImports` where the source repos do not. It is also the source for
15
+ * the anchored skip patterns and the symlink-safe walk that came out of its own
16
+ * backlog.
17
+ *
18
+ * Canonical decisions, and why each one:
19
+ *
20
+ * 1. **`prettier/prettier: 'error'`.** Three of the four source repos had it at
21
+ * `warn`, which under `eslint --quiet` is unenforceable — a gate nobody can see
22
+ * is a gate that has never run. Formatting drift is therefore a hard failure.
23
+ * 2. **The layered boundary rules are parameterized by `scope`.** Every source
24
+ * repo wrote the same seven blocks with a different npm scope hardcoded; the
25
+ * factory takes `scope` (`'@myapp'`) and generates the `@myapp/*` patterns.
26
+ * The table itself lives in {@link ./eslint-boundaries}.
27
+ * 3. **`test/**` is linted, not ignored.** Durable-DAV/AWS/Mail-Meow ignored it;
28
+ * Edge-Sonic does not, and records why: ~200 KB of test code had accumulated
29
+ * violations that nothing reported.
30
+ * 4. **`apps/web` is a layer in its own right**, banned from every `@scope/*`
31
+ * package. Edge-Sonic is the only source with that block; the others had none,
32
+ * which is how their layer table's "may import: the browser" was a convention
33
+ * rather than a gate. A backend package imported by the SPA compiles and then
34
+ * fails at bundle time, with the error pointing at the bundler.
35
+ * 5. **jsdom + node globals for web files.** The sources ran `globals.node` only;
36
+ * `apps/web` needs browser globals as well, and the vitest config for the SPA
37
+ * runs under jsdom, so the lint environment matches the test environment.
38
+ * 6. **`eslint-plugin-react-hooks` is NOT imported here.** The kit stays
39
+ * zero-dependency, and the plugin belongs to the app that has JSX. A consumer
40
+ * appends it — the README shows the four lines.
41
+ *
42
+ * The rule bodies live in {@link ./eslint-rules} and the boundary table in
43
+ * {@link ./eslint-boundaries}; this file is the assembly. A repo that needs a
44
+ * boundary the canonical table does not name passes `extraLayers` — see
45
+ * {@link RepoLintConfigOptions}.
46
+ */
47
+
48
+ import globals from 'globals';
49
+ import tseslint from 'typescript-eslint';
50
+ import unicorn from 'eslint-plugin-unicorn';
51
+ import { configs as sonarjsConfigs } from 'eslint-plugin-sonarjs';
52
+ import pluginRegexp from 'eslint-plugin-regexp';
53
+ import eslintConfigPrettier from 'eslint-config-prettier';
54
+ import prettier from 'eslint-plugin-prettier';
55
+ import type { FlatConfig } from 'typescript-eslint';
56
+ import { DEFAULT_PROJECT_DIRS, layerBans, restrictedDynamicImports, restrictedImports } from './eslint-boundaries';
57
+ import type { LayerBan } from './eslint-boundaries';
58
+ import { SONARJS_RULES, TEST_RULES, TYPESCRIPT_RULES, UNICORN_RULES } from './eslint-rules';
59
+
60
+ /**
61
+ * What `defineRepoLintConfig` accepts.
62
+ */
63
+ export interface RepoLintConfigOptions {
64
+ /**
65
+ * The consuming repo's npm scope, e.g. `'@myapp'`. Every `no-restricted-imports`
66
+ * pattern in the boundary rules is derived from it, so the same factory serves
67
+ * all nine repos.
68
+ */
69
+ readonly scope: string;
70
+ /**
71
+ * The workspace directories the layered boundary rules are scoped to, as repo-
72
+ * relative globs. Defaults to the canonical layout the nine repos share.
73
+ */
74
+ readonly projectDirs?: readonly string[];
75
+ /**
76
+ * Extra ignore globs, merged with the canonical set.
77
+ */
78
+ readonly extraIgnores?: readonly string[];
79
+ /**
80
+ * Where `projectService` resolves the tsconfig from. Defaults to the directory
81
+ * ESLint is invoked in, which is the repo root in every CI job.
82
+ */
83
+ readonly tsconfigRootDir?: string;
84
+ /**
85
+ * Extra boundary layers, appended to the canonical table.
86
+ *
87
+ * The escape valve for a repo whose layout has a boundary the canonical table
88
+ * does not name — AWS-AccessBridge's `apps/api/src/endpoints/**` rule ("route
89
+ * classes must not import DAOs directly") and its `aws4fetch` ban are the shape.
90
+ * Same {@link LayerBan} form, so `extraLayers` flows through the
91
+ * `no-restricted-imports` and the `no-restricted-syntax` guard alike.
92
+ */
93
+ readonly extraLayers?: readonly LayerBan[];
94
+ }
95
+
96
+ /**
97
+ * One argument to `tseslint.config()`.
98
+ */
99
+ type ConfigInput = Parameters<typeof tseslint.config>[number];
100
+
101
+ /**
102
+ * A plugin preset or config object as the source repos pass it.
103
+ */
104
+ type MaybeConfig = FlatConfig.Config | readonly FlatConfig.Config[];
105
+
106
+ /**
107
+ * Bridge a third-party preset into `tseslint.config()`.
108
+ *
109
+ * `sonarjs`' `recommended` preset is typed as a union that includes the legacy
110
+ * config shape, which `tseslint.config()` does not accept — but ESLint accepts it
111
+ * at runtime, which is the only thing that matters here. One checked assertion,
112
+ * rather than forking the plugin's own types.
113
+ */
114
+ function asConfig(config: MaybeConfig): ConfigInput {
115
+ return config as ConfigInput;
116
+ }
117
+
118
+ /**
119
+ * Bridge a rule block onto `tseslint.config()`'s `rules` field.
120
+ *
121
+ * The blocks in `eslint-rules.ts` are written as ESLint expects them —
122
+ * `'off' | 'warn' | 'error' | [level, ...options]` — but spelled as data so a
123
+ * rule's reason comment sits beside the rule itself. ESLint's own `RuleEntry`
124
+ * union is narrower than the data shape, and widening every entry to that union
125
+ * would buy nothing; one assertion here, with the reason, is the cheaper and
126
+ * more honest of the two.
127
+ */
128
+ function asRules(block: Readonly<Record<string, unknown>>): FlatConfig.Config['rules'] {
129
+ return block as FlatConfig.Config['rules'];
130
+ }
131
+
132
+ /**
133
+ * Build the repo's flat config.
134
+ *
135
+ * Consumed from the repo root as three lines:
136
+ *
137
+ * ```js
138
+ * // eslint.config.mjs
139
+ * import { defineRepoLintConfig } from '@rexezuge/tooling/eslint';
140
+ * export default defineRepoLintConfig({ scope: '@myapp' });
141
+ * ```
142
+ */
143
+ export function defineRepoLintConfig(options: RepoLintConfigOptions): FlatConfig.ConfigArray {
144
+ const { scope } = options;
145
+ const projectDirs = options.projectDirs ?? DEFAULT_PROJECT_DIRS;
146
+ const canonical = layerBans(scope).filter((layer) => projectDirs.some((dir) => layer.dir.startsWith(dir)));
147
+ const extra = options.extraLayers ?? [];
148
+ const bans = [...canonical, ...extra];
149
+
150
+ return tseslint.config(
151
+ {
152
+ ignores: [
153
+ 'eslint.config.mjs',
154
+ 'eslint.config.js',
155
+ 'scripts/**',
156
+ 'worker-configuration.d.ts',
157
+ 'local/**',
158
+ 'app/dist/**',
159
+ 'apps/web/dist/**',
160
+ 'apps/*/dist/**',
161
+ 'src/generated/**',
162
+ 'apps/api/src/generated/**',
163
+ 'coverage/**',
164
+ 'coverage-integration/**',
165
+ 'coverage-web/**',
166
+ 'node_modules/**',
167
+ ...(options.extraIgnores ?? []),
168
+ ],
169
+ },
170
+
171
+ // Base: globals for all JS/TS source files. Node is the floor because the
172
+ // scripts and the workers both run on it; the web block below adds browser
173
+ // globals rather than replacing them, so a shared file that touches both
174
+ // (there are several in `shared/`) still lints.
175
+ {
176
+ files: ['**/*.{js,mjs,cjs,ts,mts,cts,tsx,jsx}'],
177
+ languageOptions: {
178
+ globals: globals.node,
179
+ },
180
+ },
181
+
182
+ // Type-aware TS rules, scoped to TS files only.
183
+ {
184
+ files: ['**/*.{ts,mts,cts,tsx}'],
185
+ extends: tseslint.configs.recommendedTypeChecked,
186
+ languageOptions: {
187
+ parserOptions: {
188
+ projectService: true,
189
+ tsconfigRootDir: options.tsconfigRootDir ?? process.cwd(),
190
+ },
191
+ },
192
+ },
193
+
194
+ // --- typescript-eslint overrides (identical in all four source stacks) ---
195
+ {
196
+ files: ['**/*.{ts,mts,cts,tsx}'],
197
+ rules: asRules(TYPESCRIPT_RULES),
198
+ },
199
+
200
+ // --- Unicorn: modern JS/TS quality rules ---
201
+ asConfig(unicorn.configs['flat/recommended']),
202
+ {
203
+ rules: asRules(UNICORN_RULES),
204
+ },
205
+
206
+ // --- SonarJS: code smell and bug detection ---
207
+ asConfig(sonarjsConfigs.recommended),
208
+ {
209
+ rules: asRules(SONARJS_RULES),
210
+ },
211
+
212
+ // --- Regexp: static analysis for regular expressions ---
213
+ asConfig(pluginRegexp.configs['flat/recommended']),
214
+
215
+ // --- Prettier: formatting drift is a hard error ---
216
+ //
217
+ // Mail-Meow's decision, adopted repo-wide because the alternative is
218
+ // unenforceable: `pnpm run lint` is `eslint --fix --quiet` in three of the
219
+ // four source repos, and `--quiet` discards every warning, so a
220
+ // `prettier/prettier` *warning* is a silent no-op. 242 files were in that
221
+ // state in Edge-Sonic: the gate had never run.
222
+ asConfig(eslintConfigPrettier),
223
+ {
224
+ plugins: { prettier },
225
+ rules: {
226
+ 'prettier/prettier': 'error',
227
+ },
228
+ },
229
+
230
+ // --- Import direction guardrails (parameterized by scope) ---
231
+ ...restrictedImports(bans),
232
+ ...restrictedDynamicImports(bans),
233
+
234
+ // --- Web: browser globals, matching the jsdom test environment ---
235
+ //
236
+ // The sources linted `apps/web` under `globals.node` only, so `window`,
237
+ // `document` and `localStorage` read as undefined globals. The vitest web
238
+ // config runs those suites under jsdom; the lint environment should agree
239
+ // with the environment the code is executed in, or the rule is reporting
240
+ // something untrue about how the file behaves.
241
+ {
242
+ files: ['apps/web/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}'],
243
+ languageOptions: {
244
+ globals: { ...globals.node, ...globals.browser },
245
+ },
246
+ },
247
+
248
+ // --- Test suite ---
249
+ //
250
+ // `test/**` is deliberately NOT in the ignores list above. See TEST_RULES
251
+ // for the override block and the reasoning behind it.
252
+ {
253
+ files: ['test/**/*.{ts,tsx,mts,cts}', '**/*.test.{ts,tsx,mts,cts}', '**/*.spec.{ts,tsx,mts,cts}'],
254
+ rules: asRules(TEST_RULES),
255
+ },
256
+ );
257
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createPagesProxy } from './pages-proxy';
3
+ import type { PagesProxyEnv } from './pages-proxy';
4
+
5
+ function fakeEnv(): { env: PagesProxyEnv; seen: { url: string; method: string; headers: Headers; body: string | null }[] } {
6
+ const seen: { url: string; method: string; headers: Headers; body: string | null }[] = [];
7
+ const env: PagesProxyEnv = {
8
+ API_WORKER: {
9
+ async fetch(request: Request): Promise<Response> {
10
+ seen.push({
11
+ url: request.url,
12
+ method: request.method,
13
+ headers: new Headers(request.headers),
14
+ body: request.method === 'GET' || request.method === 'HEAD' ? null : await request.text(),
15
+ });
16
+ return new Response('ok', { status: 200 });
17
+ },
18
+ },
19
+ };
20
+ return { env, seen };
21
+ }
22
+
23
+ const onRequest = createPagesProxy();
24
+
25
+ describe('createPagesProxy', () => {
26
+ it('forwards the URL verbatim and rewrites the three X-Forwarded headers', async () => {
27
+ const { env, seen } = fakeEnv();
28
+ const request = new Request('https://pages.example.com/user/volumes?page=2', { method: 'GET' });
29
+ const response = await onRequest({ request, env } as never);
30
+ expect(response.status).toBe(200);
31
+ expect(seen[0]?.url).toBe('https://pages.example.com/user/volumes?page=2');
32
+ expect(seen[0]?.headers.get('X-Forwarded-Host')).toBe('pages.example.com');
33
+ expect(seen[0]?.headers.get('X-Forwarded-Proto')).toBe('https');
34
+ expect(seen[0]?.headers.get('X-Forwarded-Uri')).toBe('/user/volumes?page=2');
35
+ });
36
+
37
+ it('forwards a body for non-GET/HEAD', async () => {
38
+ const { env, seen } = fakeEnv();
39
+ await onRequest({
40
+ request: new Request('https://pages.example.com/user/volumes', { method: 'PUT', body: '{"a":1}' }),
41
+ env,
42
+ } as never);
43
+ expect(seen[0]?.body).toBe('{"a":1}');
44
+ });
45
+
46
+ it('replaces X-Forwarded-For from CF-Connecting-IP, never trusting a caller value', async () => {
47
+ const { env, seen } = fakeEnv();
48
+ // The client IP arrives as a request header (the edge sets it), and any
49
+ // caller-supplied X-Forwarded-For must be displaced by it, never kept.
50
+ const headers = new Headers({ 'X-Forwarded-For': '1.2.3.4', 'CF-Connecting-IP': '9.9.9.9' });
51
+ await onRequest({ request: new Request('https://pages.example.com/', { headers }), env } as never);
52
+ expect(seen[0]?.headers.get('X-Forwarded-For')).toBe('9.9.9.9');
53
+ });
54
+
55
+ it('strips hop-by-hop and client-forwarding headers', async () => {
56
+ const { env, seen } = fakeEnv();
57
+ const headers = new Headers({
58
+ Connection: 'keep-alive',
59
+ 'Proxy-Authorization': 'Basic x',
60
+ Forwarded: 'for=1.2.3.4',
61
+ 'X-Real-Ip': '1.2.3.4',
62
+ 'True-Client-IP': '1.2.3.4',
63
+ });
64
+ await onRequest({ request: new Request('https://pages.example.com/', { headers }), env } as never);
65
+ const forwarded = seen[0]?.headers;
66
+ expect(forwarded?.get('Connection')).toBeNull();
67
+ expect(forwarded?.get('Proxy-Authorization')).toBeNull();
68
+ expect(forwarded?.get('Forwarded')).toBeNull();
69
+ expect(forwarded?.get('X-Real-Ip')).toBeNull();
70
+ expect(forwarded?.get('True-Client-IP')).toBeNull();
71
+ });
72
+ });