@craft-ts/dev-tools 0.7.0-beta.13 → 0.7.0-beta.15

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 (89) hide show
  1. package/README.md +64 -10
  2. package/package.json +8 -2
  3. package/src/bin/agent-selector.d.ts +22 -0
  4. package/src/bin/agent-selector.js +100 -0
  5. package/src/bin/agent-selector.js.map +1 -0
  6. package/src/bin/craft-graph.js +74 -8
  7. package/src/bin/craft-graph.js.map +1 -1
  8. package/src/bin/craft.d.ts +23 -0
  9. package/src/bin/craft.js +245 -28
  10. package/src/bin/craft.js.map +1 -1
  11. package/src/eslint-rules/anchor-has-href.cjs +37 -3
  12. package/src/eslint-rules/craft-computed-name-match.cjs +26 -5
  13. package/src/eslint-rules/craft-name-match-utils.cjs +18 -9
  14. package/src/eslint-rules/hyperscript-walk.cjs +3 -3
  15. package/src/eslint-rules/index.cjs +46 -1
  16. package/src/eslint-rules/no-auth-token-in-local-storage.cjs +17 -0
  17. package/src/eslint-rules/no-craft-computed-side-effects.cjs +12 -5
  18. package/src/eslint-rules/no-craft-service-component-same-file.cjs +138 -0
  19. package/src/eslint-rules/no-effect-import-in-frontend.cjs +52 -0
  20. package/src/eslint-rules/no-effect-in-params.cjs +31 -1
  21. package/src/eslint-rules/no-explicit-effect-type.cjs +137 -0
  22. package/src/eslint-rules/no-free-has.cjs +47 -0
  23. package/src/eslint-rules/no-heading-level-skip.cjs +6 -6
  24. package/src/eslint-rules/no-raw-class.cjs +76 -0
  25. package/src/eslint-rules/no-raw-css-value.cjs +163 -0
  26. package/src/eslint-rules/no-raw-user-url.cjs +114 -0
  27. package/src/eslint-rules/no-trust-forwarded-headers.cjs +43 -0
  28. package/src/eslint-rules/no-unsafe-html.cjs +93 -0
  29. package/src/eslint-rules/no-unsafe-transfer-state.cjs +54 -0
  30. package/src/eslint-rules/prefer-craft-template-blocks.cjs +8 -8
  31. package/src/eslint-rules/prefer-direct-yieldable-callback.cjs +57 -14
  32. package/src/eslint-rules/prefer-inline-effect-insertion.cjs +263 -0
  33. package/src/eslint-rules/prefer-inline-route-providers.cjs +138 -0
  34. package/src/eslint-rules/recommended-config.cjs +6 -0
  35. package/src/eslint-rules/require-reactive-template-bindings.cjs +8 -8
  36. package/src/eslint-rules/require-route-security-policy.cjs +87 -0
  37. package/src/eslint-rules/require-server-function-timeout.cjs +39 -0
  38. package/src/eslint-rules/security-config.cjs +14 -0
  39. package/src/eslint-rules/security-rule-utils.cjs +21 -0
  40. package/src/eslint-rules/style-file-boundary.cjs +75 -0
  41. package/src/eslint-rules/sync-effect-body.cjs +343 -0
  42. package/src/index.d.ts +4 -0
  43. package/src/index.js +4 -0
  44. package/src/index.js.map +1 -1
  45. package/src/scripts/architecture-graph.js +8 -2
  46. package/src/scripts/architecture-graph.js.map +1 -1
  47. package/src/scripts/create/create-project.d.ts +71 -1
  48. package/src/scripts/create/create-project.js +1937 -176
  49. package/src/scripts/create/create-project.js.map +1 -1
  50. package/src/scripts/data-flow-graph.d.ts +1 -1
  51. package/src/scripts/effect-dependency-graph.d.ts +1 -1
  52. package/src/scripts/security-check.d.ts +30 -0
  53. package/src/scripts/security-check.js +223 -0
  54. package/src/scripts/security-check.js.map +1 -0
  55. package/src/scripts/style-architecture.d.ts +56 -0
  56. package/src/scripts/style-architecture.js +142 -0
  57. package/src/scripts/style-architecture.js.map +1 -0
  58. package/src/scripts/style-graph.d.ts +99 -0
  59. package/src/scripts/style-graph.js +179 -0
  60. package/src/scripts/style-graph.js.map +1 -0
  61. package/src/scripts/style-report.d.ts +55 -0
  62. package/src/scripts/style-report.js +67 -0
  63. package/src/scripts/style-report.js.map +1 -0
  64. package/src/scripts/template/migrate-template.js +1 -1
  65. package/src/scripts/template/migrate-template.js.map +1 -1
  66. package/src/scripts/angular-brand-codemod.ts +0 -3985
  67. package/src/scripts/architecture/migrate-architecture.ts +0 -653
  68. package/src/scripts/architecture-graph.ts +0 -3136
  69. package/src/scripts/components/migrate-components.ts +0 -227
  70. package/src/scripts/components/migration-diagnostic.ts +0 -8
  71. package/src/scripts/create/create-project.ts +0 -849
  72. package/src/scripts/data-flow-graph.ts +0 -240
  73. package/src/scripts/dependency-graph.ts +0 -5908
  74. package/src/scripts/effect-dependency-graph.ts +0 -630
  75. package/src/scripts/migrate.ts +0 -162
  76. package/src/scripts/migration-workspace.ts +0 -93
  77. package/src/scripts/primitives/migrate-named-primitives.ts +0 -627
  78. package/src/scripts/primitives/migrate-primitive-generators.ts +0 -410
  79. package/src/scripts/primitives/migrate-primitives.ts +0 -655
  80. package/src/scripts/primitives/migrate-yieldable-reactive-reads.ts +0 -499
  81. package/src/scripts/primitives/migration-diagnostic.ts +0 -15
  82. package/src/scripts/routes/migrate-routes.ts +0 -895
  83. package/src/scripts/routes/migration-diagnostic.ts +0 -25
  84. package/src/scripts/routes/route-command.ts +0 -1807
  85. package/src/scripts/routes/verify-routes.ts +0 -1511
  86. package/src/scripts/services/config.ts +0 -60
  87. package/src/scripts/services/migrate-services.ts +0 -1348
  88. package/src/scripts/services/migration-diagnostic.ts +0 -28
  89. package/src/scripts/template/migrate-template.ts +0 -418
@@ -7,6 +7,11 @@ const craftSignalSourceNameMatch = require('./craft-signal-source-name-match.cjs
7
7
  const noCraftComputedSideEffects = require('./no-craft-computed-side-effects.cjs');
8
8
  const provideHostNameMatchComponent = require('./provide-host-name-match-component.cjs');
9
9
  const preferCraftHttpTransport = require('./prefer-craft-http-transport.cjs');
10
+ const noCraftServiceComponentSameFile = require('./no-craft-service-component-same-file.cjs');
11
+ const noRawCssValue = require('./no-raw-css-value.cjs');
12
+ const noRawClass = require('./no-raw-class.cjs');
13
+ const noFreeHas = require('./no-free-has.cjs');
14
+ const styleFileBoundary = require('./style-file-boundary.cjs');
10
15
  const preferBrowserBoundaries = require('./prefer-browser-boundaries.cjs');
11
16
  const requireComponentMonitoring = require('./require-component-monitoring.cjs');
12
17
  const requirePrimitiveGeneratorUnwrap = require('./require-primitive-generator-unwrap.cjs');
@@ -67,15 +72,28 @@ const targetBlankNoopener = require('./target-blank-noopener.cjs');
67
72
  const requireFocusVisible = require('./require-focus-visible.cjs');
68
73
  const requireReducedMotion = require('./require-reduced-motion.cjs');
69
74
  const requireEffectAdapters = require('./require-effect-adapters.cjs');
75
+ const syncEffectBody = require('./sync-effect-body.cjs');
70
76
  const serverFunctionClientMatch = require('./server-function-client-match.cjs');
71
77
  const noInvalidInsertionPipe = require('./no-invalid-insertion-pipe.cjs');
72
78
  const noRedundantPrimitiveInsertion = require('./no-redundant-primitive-insertion.cjs');
73
79
  const noEffectOutsideLoaders = require('./no-effect-outside-loaders.cjs');
80
+ const noExplicitEffectType = require('./no-explicit-effect-type.cjs');
81
+ const preferInlineEffectInsertion = require('./prefer-inline-effect-insertion.cjs');
82
+ const preferInlineRouteProviders = require('./prefer-inline-route-providers.cjs');
74
83
  const preferQueryMethodOverStateTrigger = require('./prefer-query-method-over-state-trigger.cjs');
75
84
  const noInjectionToken = require('./no-injection-token.cjs');
76
85
  const noManualRouteProviderList = require('./no-manual-route-provider-list.cjs');
77
86
  const noWidenedRouteProviderContext = require('./no-widened-route-provider-context.cjs');
87
+ const requireRouteSecurityPolicy = require('./require-route-security-policy.cjs');
88
+ const requireServerFunctionTimeout = require('./require-server-function-timeout.cjs');
89
+ const noAuthTokenInLocalStorage = require('./no-auth-token-in-local-storage.cjs');
90
+ const noRawUserUrl = require('./no-raw-user-url.cjs');
91
+ const noUnsafeTransferState = require('./no-unsafe-transfer-state.cjs');
92
+ const noUnsafeHtml = require('./no-unsafe-html.cjs');
93
+ const noTrustForwardedHeaders = require('./no-trust-forwarded-headers.cjs');
94
+ const noEffectImportInFrontend = require('./no-effect-import-in-frontend.cjs');
78
95
  const recommendedRules = require('./recommended-config.cjs');
96
+ const securityRules = require('./security-config.cjs');
79
97
 
80
98
  const plugin = {
81
99
  rules: {
@@ -88,6 +106,11 @@ const plugin = {
88
106
  'no-craft-computed-side-effects': noCraftComputedSideEffects,
89
107
  'provide-host-name-match-component': provideHostNameMatchComponent,
90
108
  'prefer-craft-http-transport': preferCraftHttpTransport,
109
+ 'no-craft-service-component-same-file': noCraftServiceComponentSameFile,
110
+ 'no-raw-css-value': noRawCssValue,
111
+ 'no-raw-class': noRawClass,
112
+ 'no-free-has': noFreeHas,
113
+ 'style-file-boundary': styleFileBoundary,
91
114
  'prefer-browser-boundaries': preferBrowserBoundaries,
92
115
  'require-component-monitoring': requireComponentMonitoring,
93
116
  'require-primitive-generator-unwrap': requirePrimitiveGeneratorUnwrap,
@@ -151,16 +174,28 @@ const plugin = {
151
174
  'require-focus-visible': requireFocusVisible,
152
175
  'require-reduced-motion': requireReducedMotion,
153
176
  'require-effect-adapters': requireEffectAdapters,
177
+ 'sync-effect-body': syncEffectBody,
154
178
  'server-function-client-match': serverFunctionClientMatch,
155
179
  'no-invalid-insertion-pipe': noInvalidInsertionPipe,
156
180
  'no-redundant-primitive-insertion': noRedundantPrimitiveInsertion,
157
181
  'no-effect-outside-loaders': noEffectOutsideLoaders,
182
+ 'no-explicit-effect-type': noExplicitEffectType,
183
+ 'prefer-inline-effect-insertion': preferInlineEffectInsertion,
184
+ 'prefer-inline-route-providers': preferInlineRouteProviders,
158
185
  // Backwards-compatible alias; use no-effect-outside-loaders in new configs.
159
186
  'no-effect-in-params': noEffectOutsideLoaders,
160
187
  'prefer-query-method-over-state-trigger': preferQueryMethodOverStateTrigger,
161
188
  'no-injection-token': noInjectionToken,
162
189
  'no-manual-route-provider-list': noManualRouteProviderList,
163
190
  'no-widened-route-provider-context': noWidenedRouteProviderContext,
191
+ 'require-route-security-policy': requireRouteSecurityPolicy,
192
+ 'require-server-function-timeout': requireServerFunctionTimeout,
193
+ 'no-auth-token-in-local-storage': noAuthTokenInLocalStorage,
194
+ 'no-raw-user-url': noRawUserUrl,
195
+ 'no-unsafe-transfer-state': noUnsafeTransferState,
196
+ 'no-unsafe-html': noUnsafeHtml,
197
+ 'no-trust-forwarded-headers': noTrustForwardedHeaders,
198
+ 'no-effect-import-in-frontend': noEffectImportInFrontend,
164
199
  },
165
200
  };
166
201
 
@@ -190,20 +225,30 @@ const a11yRuleSeverity = {
190
225
  plugin.configs = {
191
226
  recommended: {
192
227
  plugins: { 'craft-ts': plugin },
193
- rules: recommendedRules,
228
+ rules: { ...recommendedRules, ...securityRules },
194
229
  },
195
230
  effect: {
196
231
  plugins: { 'craft-ts': plugin },
197
232
  rules: {
198
233
  ...recommendedRules,
234
+ ...securityRules,
199
235
  'craft-ts/require-effect-adapters': 'error',
236
+ 'craft-ts/sync-effect-body': 'error',
200
237
  'craft-ts/no-effect-outside-loaders': 'error',
238
+ 'craft-ts/no-explicit-effect-type': 'error',
239
+ 'craft-ts/prefer-inline-effect-insertion': 'error',
201
240
  },
202
241
  },
203
242
  a11y: {
204
243
  plugins: { 'craft-ts': plugin },
205
244
  rules: a11yRuleSeverity,
206
245
  },
246
+ // Conservé pour activer le préréglage seul, sur un projet qui ne prend pas
247
+ // `recommended` (une lib partagée, un dossier d'outillage).
248
+ security: {
249
+ plugins: { 'craft-ts': plugin },
250
+ rules: securityRules,
251
+ },
207
252
  };
208
253
 
209
254
  module.exports = plugin;
@@ -0,0 +1,17 @@
1
+ const { report } = require('./security-rule-utils.cjs');
2
+
3
+ module.exports = {
4
+ meta: { type: 'problem', schema: [], docs: { description: 'Do not persist authentication material in browser storage.' } },
5
+ create(context) {
6
+ return { CallExpression(node) {
7
+ const callee = node.callee;
8
+ if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier' || callee.property.name !== 'setItem') return;
9
+ const owner = callee.object && callee.object.type === 'Identifier' ? callee.object.name : '';
10
+ if (!/^(?:localStorage|sessionStorage)$/.test(owner)) return;
11
+ const key = node.arguments[0];
12
+ if (key && key.type === 'Literal' && /token|auth|jwt|session|refresh|password/i.test(String(key.value))) {
13
+ report(context, node, 'Authentication tokens must not be stored in localStorage or sessionStorage.');
14
+ }
15
+ } };
16
+ },
17
+ };
@@ -22,17 +22,24 @@ const WRITE_METHODS = new Set([
22
22
  'update',
23
23
  ]);
24
24
 
25
+ // `settled(...)` reads an already-resolved async source; `syncEffect(...)` runs
26
+ // an Effect whose `R` carries `SyncOp`, so the type already proved it cannot
27
+ // suspend. Both are reads, not asynchronous work.
28
+ // Module scope on purpose: the visitors below run before any `const` declared
29
+ // inside `create()` past its `return` would be initialised.
30
+ const ALLOWED_COMPUTED_CALLS = new Set(['settled', 'syncEffect']);
31
+
25
32
  module.exports = {
26
33
  meta: {
27
34
  type: 'problem',
28
35
  docs: {
29
36
  description:
30
- 'Disallow writes and asynchronous work inside craftComputed callbacks; only reactive reads and settled(...) are allowed.',
37
+ 'Disallow writes and asynchronous work inside craftComputed callbacks; only reactive reads, settled(...) and syncEffect(...) are allowed.',
31
38
  },
32
39
  schema: [],
33
40
  messages: {
34
41
  forbidden:
35
- 'Craft computed callbacks may only read reactive Craft values or use `settled(...)`; `{{call}}` is not allowed because it can write or perform asynchronous work.',
42
+ 'Craft computed callbacks may only read reactive Craft values or use `settled(...)` / `syncEffect(...)`; `{{call}}` is not allowed because it can write or perform asynchronous work.',
36
43
  missingTypeInfo:
37
44
  'This rule requires TypeScript type information to identify reactive reads and asynchronous Craft calls.',
38
45
  },
@@ -124,10 +131,10 @@ module.exports = {
124
131
  }
125
132
 
126
133
  function isAllowedSettledCall(node) {
134
+ if (node.callee.type !== 'Identifier') return false;
127
135
  return (
128
- node.callee.type === 'Identifier' &&
129
- (node.callee.name === 'settled' ||
130
- importedNames.get(node.callee.name) === 'settled')
136
+ ALLOWED_COMPUTED_CALLS.has(node.callee.name) ||
137
+ ALLOWED_COMPUTED_CALLS.has(importedNames.get(node.callee.name))
131
138
  );
132
139
  }
133
140
 
@@ -0,0 +1,138 @@
1
+ const CORE_MODULE = '@craft-ts/core';
2
+ const COMPONENT_MODULE = '@craft-ts/component';
3
+
4
+ module.exports = {
5
+ meta: {
6
+ type: 'problem',
7
+ docs: {
8
+ description:
9
+ 'Disallow declaring craftService and craftComponent in the same file so route-level providers do not break lazy loading.',
10
+ },
11
+ schema: [],
12
+ messages: {
13
+ separateFiles:
14
+ 'Do not declare craftService and craftComponent in the same file. If the service is provided at route level while the component is lazy-loaded, co-location breaks lazy loading; keep them in separate files.',
15
+ },
16
+ },
17
+ create(context) {
18
+ const sourceCode = context.sourceCode ?? context.getSourceCode();
19
+ const calls = [];
20
+
21
+ return {
22
+ CallExpression(node) {
23
+ const kind = getCraftHostKind(node.callee, sourceCode);
24
+ if (kind) {
25
+ calls.push({ kind, node });
26
+ }
27
+ },
28
+ 'Program:exit'() {
29
+ const hasService = calls.some(({ kind }) => kind === 'service');
30
+ const hasComponent = calls.some(({ kind }) => kind === 'component');
31
+
32
+ if (!hasService || !hasComponent) {
33
+ return;
34
+ }
35
+
36
+ for (const { node } of calls) {
37
+ context.report({
38
+ node,
39
+ messageId: 'separateFiles',
40
+ });
41
+ }
42
+ },
43
+ };
44
+ },
45
+ };
46
+
47
+ function getCraftHostKind(callee, sourceCode) {
48
+ if (callee.type === 'Identifier') {
49
+ if (
50
+ resolvesToImportedName(callee, sourceCode, 'craftService', CORE_MODULE)
51
+ ) {
52
+ return 'service';
53
+ }
54
+
55
+ if (
56
+ resolvesToImportedName(
57
+ callee,
58
+ sourceCode,
59
+ 'craftComponent',
60
+ COMPONENT_MODULE,
61
+ )
62
+ ) {
63
+ return 'component';
64
+ }
65
+
66
+ return undefined;
67
+ }
68
+
69
+ if (
70
+ callee.type !== 'MemberExpression' ||
71
+ callee.computed ||
72
+ callee.object.type !== 'Identifier' ||
73
+ callee.property.type !== 'Identifier'
74
+ ) {
75
+ return undefined;
76
+ }
77
+
78
+ if (
79
+ callee.property.name === 'craftService' &&
80
+ resolvesToNamespaceImport(callee.object, sourceCode, CORE_MODULE)
81
+ ) {
82
+ return 'service';
83
+ }
84
+
85
+ if (
86
+ callee.property.name === 'craftComponent' &&
87
+ resolvesToNamespaceImport(callee.object, sourceCode, COMPONENT_MODULE)
88
+ ) {
89
+ return 'component';
90
+ }
91
+
92
+ return undefined;
93
+ }
94
+
95
+ function resolvesToImportedName(identifier, sourceCode, importedName, module) {
96
+ const variable = findVariable(identifier, sourceCode);
97
+ return variable?.defs.some(
98
+ (definition) =>
99
+ definition.type === 'ImportBinding' &&
100
+ definition.node?.type === 'ImportSpecifier' &&
101
+ getImportedName(definition.node) === importedName &&
102
+ definition.parent?.source?.value === module,
103
+ );
104
+ }
105
+
106
+ function resolvesToNamespaceImport(identifier, sourceCode, module) {
107
+ const variable = findVariable(identifier, sourceCode);
108
+ return variable?.defs.some(
109
+ (definition) =>
110
+ definition.type === 'ImportBinding' &&
111
+ definition.node?.type === 'ImportNamespaceSpecifier' &&
112
+ definition.parent?.source?.value === module,
113
+ );
114
+ }
115
+
116
+ function findVariable(identifier, sourceCode) {
117
+ let scope = sourceCode.getScope(identifier);
118
+
119
+ while (scope) {
120
+ const variable = scope.variables.find(
121
+ (candidate) => candidate.name === identifier.name,
122
+ );
123
+ if (variable) {
124
+ return variable;
125
+ }
126
+ scope = scope.upper;
127
+ }
128
+
129
+ return undefined;
130
+ }
131
+
132
+ function getImportedName(specifier) {
133
+ if (specifier.imported.type === 'Identifier') {
134
+ return specifier.imported.name;
135
+ }
136
+
137
+ return specifier.imported.value;
138
+ }
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const EFFECT_IMPORT_PREFIXES = [
4
+ 'effect',
5
+ '@effect/',
6
+ '@craft-ts/effect',
7
+ '@craft-ts/i18n-effect',
8
+ ];
9
+
10
+ function isEffectImport(source) {
11
+ return EFFECT_IMPORT_PREFIXES.some(
12
+ (prefix) => prefix.endsWith('/')
13
+ ? source.startsWith(prefix)
14
+ : source === prefix || source.startsWith(prefix + '/'),
15
+ );
16
+ }
17
+
18
+ module.exports = {
19
+ meta: {
20
+ type: 'problem',
21
+ docs: {
22
+ description:
23
+ 'Prevent EffectTS imports from crossing into a plain frontend boundary.',
24
+ },
25
+ schema: [],
26
+ messages: {
27
+ forbidden:
28
+ 'EffectTS imports are not allowed in the plain frontend. Keep EffectTS in server files or choose the Effect frontend runtime.',
29
+ },
30
+ },
31
+
32
+ create(context) {
33
+ const reportIfEffectImport = (node) => {
34
+ const source = node.source?.value;
35
+ if (typeof source === 'string' && isEffectImport(source)) {
36
+ context.report({ node: node.source, messageId: 'forbidden' });
37
+ }
38
+ };
39
+
40
+ return {
41
+ ImportDeclaration: reportIfEffectImport,
42
+ ExportAllDeclaration: reportIfEffectImport,
43
+ ExportNamedDeclaration: reportIfEffectImport,
44
+ ImportExpression(node) {
45
+ const source = node.source;
46
+ if (source?.type === 'Literal' && typeof source.value === 'string' && isEffectImport(source.value)) {
47
+ context.report({ node: source, messageId: 'forbidden' });
48
+ }
49
+ },
50
+ };
51
+ },
52
+ };
@@ -22,7 +22,7 @@ module.exports = {
22
22
  schema: [],
23
23
  messages: {
24
24
  effect:
25
- 'Effect values and Effect service reads are only allowed in an Effect loader. Keep params, methods, craftComputed(...) and craftEffect(...) synchronous.',
25
+ 'Effect values and Effect service reads are only allowed in an Effect loader. Keep params, methods, craftComputed(...) and craftEffect(...) synchronous, or run a declared-synchronous Effect (SyncOp) through syncEffect(...).',
26
26
  },
27
27
  },
28
28
 
@@ -35,6 +35,11 @@ module.exports = {
35
35
  const reportedNodes = new WeakSet();
36
36
  const primitiveBindings = new Map();
37
37
  const effectBindings = new Set();
38
+ // `syncEffect(...)` is the one sanctioned way through: its argument is an
39
+ // Effect whose `R` carries `SyncOp`, so the type already proved someone
40
+ // declared it synchronous. Reporting it would ban the very API that makes
41
+ // a business calculation reusable from a computation.
42
+ const syncEffectBindings = new Set();
38
43
 
39
44
  return {
40
45
  ImportDeclaration(node) {
@@ -61,6 +66,10 @@ module.exports = {
61
66
  if (source === EFFECT_RUNTIME_PACKAGE && imported === 'Effect') {
62
67
  effectBindings.add(specifier.local.name);
63
68
  }
69
+
70
+ if (source === EFFECT_PACKAGE && imported === 'syncEffect') {
71
+ syncEffectBindings.add(specifier.local.name);
72
+ }
64
73
  }
65
74
 
66
75
  for (const specifier of node.specifiers) {
@@ -213,8 +222,29 @@ module.exports = {
213
222
  return tsNode ? checker.getTypeAtLocation(tsNode) : undefined;
214
223
  }
215
224
 
225
+ function isInsideSyncEffectCall(node) {
226
+ // Starts at the node itself: `syncEffect(...)` is exempt, and so is
227
+ // everything it wraps.
228
+ for (let current = node; current; current = current.parent) {
229
+ if (
230
+ current.type === 'CallExpression' &&
231
+ current.callee.type === 'Identifier' &&
232
+ syncEffectBindings.has(current.callee.name)
233
+ ) {
234
+ return true;
235
+ }
236
+ if (
237
+ ['FunctionDeclaration', 'Program'].includes(current.type)
238
+ ) {
239
+ return false;
240
+ }
241
+ }
242
+ return false;
243
+ }
244
+
216
245
  function report(node) {
217
246
  if (!node || reportedNodes.has(node)) return;
247
+ if (isInsideSyncEffectCall(node)) return;
218
248
  reportedNodes.add(node);
219
249
  context.report({ node, messageId: 'effect' });
220
250
  }
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const EFFECT_PACKAGE = 'effect';
4
+
5
+ module.exports = {
6
+ meta: {
7
+ type: 'suggestion',
8
+ docs: {
9
+ description:
10
+ 'Prefer the type inferred by Effect.gen over an explicit Effect return annotation.',
11
+ },
12
+ fixable: 'code',
13
+ schema: [],
14
+ messages: {
15
+ explicit:
16
+ 'Effect.gen infers the complete Effect type. Remove this explicit type annotation.',
17
+ },
18
+ },
19
+
20
+ create(context) {
21
+ const effectBindings = new Set();
22
+
23
+ return {
24
+ ImportDeclaration(node) {
25
+ if (node.source.value !== EFFECT_PACKAGE) return;
26
+
27
+ for (const specifier of node.specifiers) {
28
+ if (specifier.type === 'ImportNamespaceSpecifier') {
29
+ effectBindings.add(specifier.local.name);
30
+ } else if (
31
+ specifier.type === 'ImportSpecifier' &&
32
+ getIdentifierName(specifier.imported) === 'Effect'
33
+ ) {
34
+ effectBindings.add(specifier.local.name);
35
+ }
36
+ }
37
+ },
38
+
39
+ VariableDeclarator(node) {
40
+ if (!node.id.typeAnnotation || !isEffectGenExpression(node.init)) {
41
+ return;
42
+ }
43
+
44
+ report(node.id.typeAnnotation);
45
+ },
46
+
47
+ FunctionDeclaration(node) {
48
+ if (node.returnType && hasDirectEffectGenReturn(node.body)) {
49
+ report(node.returnType);
50
+ }
51
+ },
52
+
53
+ ArrowFunctionExpression(node) {
54
+ if (node.returnType && hasEffectGenBody(node.body)) {
55
+ report(node.returnType);
56
+ }
57
+ },
58
+
59
+ FunctionExpression(node) {
60
+ if (node.returnType && hasEffectGenBody(node.body)) {
61
+ report(node.returnType);
62
+ }
63
+ },
64
+ };
65
+
66
+ function report(annotation) {
67
+ context.report({
68
+ node: annotation,
69
+ messageId: 'explicit',
70
+ fix(fixer) {
71
+ return fixer.remove(annotation);
72
+ },
73
+ });
74
+ }
75
+
76
+ function hasEffectGenBody(body) {
77
+ if (isEffectGenExpression(body)) return true;
78
+ return body?.type === 'BlockStatement' && hasDirectEffectGenReturn(body);
79
+ }
80
+
81
+ function hasDirectEffectGenReturn(body) {
82
+ return Boolean(
83
+ body?.body?.some(
84
+ (statement) =>
85
+ statement.type === 'ReturnStatement' &&
86
+ isEffectGenExpression(statement.argument),
87
+ ),
88
+ );
89
+ }
90
+
91
+ function isEffectGenExpression(node) {
92
+ let current = unwrap(node);
93
+ while (current?.type === 'CallExpression') {
94
+ if (isEffectGenMember(current.callee)) return true;
95
+
96
+ if (
97
+ current.callee.type === 'MemberExpression' &&
98
+ !current.callee.computed
99
+ ) {
100
+ current = unwrap(current.callee.object);
101
+ continue;
102
+ }
103
+
104
+ return false;
105
+ }
106
+ return false;
107
+ }
108
+
109
+ function isEffectGenMember(node) {
110
+ return (
111
+ node?.type === 'MemberExpression' &&
112
+ !node.computed &&
113
+ node.property.type === 'Identifier' &&
114
+ node.property.name === 'gen' &&
115
+ node.object.type === 'Identifier' &&
116
+ effectBindings.has(node.object.name)
117
+ );
118
+ }
119
+ },
120
+ };
121
+
122
+ function getIdentifierName(node) {
123
+ return node.type === 'Identifier' ? node.name : undefined;
124
+ }
125
+
126
+ function unwrap(node) {
127
+ let current = node;
128
+ while (
129
+ current &&
130
+ ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression'].includes(
131
+ current.type,
132
+ )
133
+ ) {
134
+ current = current.expression;
135
+ }
136
+ return current;
137
+ }
@@ -0,0 +1,47 @@
1
+ const HAS = /:has\(/;
2
+
3
+ /**
4
+ * `:has()` in hand-written CSS reaches across a component boundary.
5
+ *
6
+ * A component whose appearance depends on markup it does not own has visual
7
+ * states nobody can enumerate — the matrix would have to know what every caller
8
+ * puts inside it. `descendant.userInvalid` and its siblings are the closed set
9
+ * that can be enumerated, and each one carries the driver that reaches it.
10
+ *
11
+ * The combinators that stay inside a component — `+`, `~`, `:nth-child` — are
12
+ * deliberately not restricted: they cannot see past the subtree the component
13
+ * renders itself.
14
+ *
15
+ * This is the net under raw CSS, not the mechanism: the generated property
16
+ * table has no way to spell a selector at all.
17
+ */
18
+ module.exports = {
19
+ meta: {
20
+ type: 'problem',
21
+ docs: {
22
+ description:
23
+ 'Disallow free-form :has() in styles; use the descendant.* axis instead.',
24
+ },
25
+ schema: [],
26
+ messages: {
27
+ freeHas:
28
+ ':has() here reaches across the component boundary, so what this component looks like depends on markup it does not own — a state the visual matrix cannot enumerate. Use the descendant axis instead: when(descendant.userInvalid, [...]), which is a closed set and carries its own test driver.',
29
+ },
30
+ },
31
+ create(context) {
32
+ const check = (node, text) => {
33
+ if (typeof text === 'string' && HAS.test(text)) {
34
+ context.report({ node, messageId: 'freeHas' });
35
+ }
36
+ };
37
+
38
+ return {
39
+ Literal(node) {
40
+ if (typeof node.value === 'string') check(node, node.value);
41
+ },
42
+ TemplateElement(node) {
43
+ check(node, node.value.cooked ?? node.value.raw);
44
+ },
45
+ };
46
+ },
47
+ };
@@ -3,12 +3,12 @@
3
3
  const { parseHyperscriptCall } = require('./hyperscript-walk.cjs');
4
4
 
5
5
  const TRANSPARENT = new Set([
6
- 'ifBlock',
7
- 'each',
8
- 'matchBlock',
9
- 'catchBlock',
10
- 'pendingBlock',
11
- 'defer',
6
+ 'ifNode',
7
+ 'forNode',
8
+ 'matchNode',
9
+ 'catchNode',
10
+ 'pendingNode',
11
+ 'deferNode',
12
12
  'craftTemplate',
13
13
  ]);
14
14