@rigour-labs/core 6.3.1 → 6.4.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 (130) hide show
  1. package/README.md +1 -1
  2. package/dist/deep/changed-functions.d.ts +15 -0
  3. package/dist/deep/changed-functions.js +34 -0
  4. package/dist/deep/code-pass.d.ts +10 -0
  5. package/dist/deep/code-pass.js +37 -2
  6. package/dist/deep/code-pass.test.d.ts +1 -0
  7. package/dist/deep/code-pass.test.js +69 -0
  8. package/dist/deep/code-review-prompt.d.ts +1 -1
  9. package/dist/deep/code-review-prompt.js +3 -2
  10. package/dist/deep/diff-tests/calls.d.ts +8 -0
  11. package/dist/deep/diff-tests/calls.js +85 -0
  12. package/dist/deep/diff-tests/calls.test.d.ts +1 -0
  13. package/dist/deep/diff-tests/calls.test.js +33 -0
  14. package/dist/deep/diff-tests/run.d.ts +21 -0
  15. package/dist/deep/diff-tests/run.js +178 -0
  16. package/dist/deep/diff-tests/run.test.d.ts +1 -0
  17. package/dist/deep/diff-tests/run.test.js +58 -0
  18. package/dist/deep/reference-pack.d.ts +19 -0
  19. package/dist/deep/reference-pack.js +101 -0
  20. package/dist/deep/reference-pack.test.d.ts +1 -0
  21. package/dist/deep/reference-pack.test.js +46 -0
  22. package/dist/deep/review-context-export.d.ts +11 -0
  23. package/dist/deep/review-context-export.js +31 -0
  24. package/dist/deep/review-context-export.test.d.ts +1 -0
  25. package/dist/deep/review-context-export.test.js +32 -0
  26. package/dist/deep/self-check.d.ts +21 -0
  27. package/dist/deep/self-check.js +49 -0
  28. package/dist/firewall/trusted-control.d.ts +1 -2
  29. package/dist/gates/deep-analysis.d.ts +2 -0
  30. package/dist/gates/deep-analysis.js +12 -0
  31. package/dist/gates/deep-runner.js +2 -1
  32. package/dist/gates/hallucinated-imports/js-resolver.d.ts +7 -0
  33. package/dist/gates/hallucinated-imports/js-resolver.js +17 -1
  34. package/dist/gates/hallucinated-imports-sveltekit.test.js +24 -0
  35. package/dist/gates/semantic-bugs.js +4 -3
  36. package/dist/gates/semantic-bugs.test.js +11 -5
  37. package/dist/hooks/checker.d.ts +1 -2
  38. package/dist/hooks/index.d.ts +4 -0
  39. package/dist/hooks/index.js +2 -0
  40. package/dist/hooks/stop-review.d.ts +25 -0
  41. package/dist/hooks/stop-review.js +31 -0
  42. package/dist/index.d.ts +26 -2
  43. package/dist/index.js +10 -6
  44. package/dist/inference/index.d.ts +1 -1
  45. package/dist/inference/index.js +3 -5
  46. package/dist/inference/local-model.test.d.ts +1 -0
  47. package/dist/inference/local-model.test.js +23 -0
  48. package/dist/inference/sidecar-provider.d.ts +10 -1
  49. package/dist/inference/sidecar-provider.js +28 -6
  50. package/dist/inference/types.d.ts +8 -1
  51. package/dist/inference/types.js +14 -0
  52. package/dist/inference/types.test.d.ts +1 -0
  53. package/dist/inference/types.test.js +19 -0
  54. package/dist/review/agent-fixes.d.ts +18 -0
  55. package/dist/review/agent-fixes.js +118 -0
  56. package/dist/review/agent-fixes.test.d.ts +1 -0
  57. package/dist/review/agent-fixes.test.js +32 -0
  58. package/dist/review/changed-lines.d.ts +19 -0
  59. package/dist/review/changed-lines.js +22 -0
  60. package/dist/review/diff-test-findings.d.ts +3 -0
  61. package/dist/review/diff-test-findings.js +41 -0
  62. package/dist/review/effectiveness.d.ts +34 -0
  63. package/dist/review/effectiveness.js +114 -0
  64. package/dist/review/effectiveness.test.d.ts +1 -0
  65. package/dist/review/effectiveness.test.js +42 -0
  66. package/dist/review/git-diff.d.ts +9 -0
  67. package/dist/review/git-diff.js +42 -0
  68. package/dist/review/review.d.ts +38 -0
  69. package/dist/review/review.js +52 -0
  70. package/dist/review/review.test.d.ts +1 -0
  71. package/dist/review/review.test.js +87 -0
  72. package/dist/semantic/benchmark.js +2 -2
  73. package/dist/semantic/benchmark.test.js +5 -1
  74. package/dist/semantic/engine.d.ts +3 -0
  75. package/dist/semantic/engine.js +8 -2
  76. package/dist/semantic/failure-handling.d.ts +12 -0
  77. package/dist/semantic/failure-handling.js +41 -0
  78. package/dist/semantic/intent/parallel-reads.js +2 -30
  79. package/dist/semantic/learn/learn.d.ts +2 -0
  80. package/dist/semantic/learn/learn.js +18 -0
  81. package/dist/semantic/project-facts.d.ts +25 -0
  82. package/dist/semantic/project-facts.js +104 -0
  83. package/dist/semantic/project-facts.test.d.ts +1 -0
  84. package/dist/semantic/project-facts.test.js +45 -0
  85. package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.d.ts +2 -0
  86. package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.js +71 -0
  87. package/dist/semantic/rules/ecosystem/deps-internal-symbol.d.ts +2 -0
  88. package/dist/semantic/rules/ecosystem/deps-internal-symbol.js +36 -0
  89. package/dist/semantic/rules/ecosystem/dom-self-query-in-component.d.ts +2 -0
  90. package/dist/semantic/rules/ecosystem/dom-self-query-in-component.js +60 -0
  91. package/dist/semantic/rules/ecosystem/env-bare-browser-global.d.ts +2 -0
  92. package/dist/semantic/rules/ecosystem/env-bare-browser-global.js +57 -0
  93. package/dist/semantic/rules/ecosystem/exports-condition-parity.d.ts +2 -0
  94. package/dist/semantic/rules/ecosystem/exports-condition-parity.js +57 -0
  95. package/dist/semantic/rules/ecosystem/exports-forgotten-type.d.ts +2 -0
  96. package/dist/semantic/rules/ecosystem/exports-forgotten-type.js +128 -0
  97. package/dist/semantic/rules/ecosystem/imports-barrel-cycle.d.ts +2 -0
  98. package/dist/semantic/rules/ecosystem/imports-barrel-cycle.js +78 -0
  99. package/dist/semantic/rules/ecosystem/index.d.ts +15 -0
  100. package/dist/semantic/rules/ecosystem/index.js +22 -0
  101. package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.d.ts +2 -0
  102. package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.js +87 -0
  103. package/dist/semantic/rules/ecosystem/react-inline-html-object.d.ts +2 -0
  104. package/dist/semantic/rules/ecosystem/react-inline-html-object.js +30 -0
  105. package/dist/semantic/rules/ecosystem/shared.d.ts +18 -0
  106. package/dist/semantic/rules/ecosystem/shared.js +43 -0
  107. package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.d.ts +2 -0
  108. package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.js +36 -0
  109. package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.d.ts +2 -0
  110. package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.js +66 -0
  111. package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.d.ts +2 -0
  112. package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.js +31 -0
  113. package/dist/semantic/rules/ecosystem/vue-static-computed.d.ts +2 -0
  114. package/dist/semantic/rules/ecosystem/vue-static-computed.js +51 -0
  115. package/dist/semantic/sites/call-sites.d.ts +25 -0
  116. package/dist/semantic/sites/call-sites.js +69 -0
  117. package/dist/semantic/sites/call-sites.test.d.ts +1 -0
  118. package/dist/semantic/sites/call-sites.test.js +46 -0
  119. package/dist/semantic/sites/export.d.ts +2 -0
  120. package/dist/semantic/sites/export.js +22 -0
  121. package/dist/semantic/types.d.ts +4 -0
  122. package/dist/services/engineering-knowledge-graph.d.ts +1 -2
  123. package/dist/templates/universal-config.js +2 -1
  124. package/dist/types/index.d.ts +31 -8
  125. package/dist/types/index.js +4 -2
  126. package/dist/utils/diff-removed.test.d.ts +1 -0
  127. package/dist/utils/diff-removed.test.js +29 -0
  128. package/dist/utils/diff.d.ts +12 -0
  129. package/dist/utils/diff.js +26 -0
  130. package/package.json +6 -6
@@ -0,0 +1,104 @@
1
+ /**
2
+ * What a file's own package declares: its nearest package.json dependencies and
3
+ * nearest tsconfig compiler options. Ecosystem rules are gated on these, so a
4
+ * Solid rule only runs where Solid is a dependency, and in a monorepo each
5
+ * package is judged by its own manifest. Lookups never leave `cwd`, and are
6
+ * cached for one analysis run.
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import ts from 'typescript';
11
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
12
+ export class ProjectFacts {
13
+ cwd;
14
+ packages = new Map();
15
+ options = new Map();
16
+ constructor(cwd) {
17
+ this.cwd = cwd;
18
+ }
19
+ /** Declared dependency names and version ranges of the package that owns `file`. */
20
+ dependencies(file) {
21
+ return this.nearestPackage(file)?.dependencies ?? new Map();
22
+ }
23
+ /** Directory of the package.json that owns `file`. */
24
+ packageDir(file) {
25
+ return this.dirsUp(file).find(dir => this.readPackage(dir));
26
+ }
27
+ /** The `name` of the package that owns `file`. */
28
+ packageName(file) {
29
+ return this.nearestPackage(file)?.name;
30
+ }
31
+ declares(file, ...names) {
32
+ const deps = this.dependencies(file);
33
+ return names.some(name => deps.has(name));
34
+ }
35
+ /**
36
+ * Highest major version a declared dependency may run on, across every field
37
+ * and `||` alternative (`>=18.0.0 || >=19.0.0` -> 19). A package that supports
38
+ * a major must work on it. Undefined when no range names a version (`catalog:`, `workspace:*`).
39
+ */
40
+ majorVersion(file, name) {
41
+ const majors = (this.nearestPackage(file)?.ranges.get(name) ?? [])
42
+ .flatMap(range => range.split('||'))
43
+ .map(alternative => alternative.match(/(\d+)(?:\.[\dx*]+)*/)?.[1])
44
+ .filter((m) => m !== undefined)
45
+ .map(Number);
46
+ return majors.length ? Math.max(...majors) : undefined;
47
+ }
48
+ /** Compiler options of the nearest tsconfig.json above `file`, extends resolved. */
49
+ compilerOptions(file) {
50
+ const dir = path.dirname(path.resolve(this.cwd, file));
51
+ const cached = this.options.get(dir);
52
+ if (cached)
53
+ return cached;
54
+ const configPath = this.dirsUp(file).map(d => path.join(d, 'tsconfig.json')).find(p => fs.existsSync(p));
55
+ let options = {};
56
+ if (configPath) {
57
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
58
+ if (!read.error)
59
+ options = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath)).options;
60
+ }
61
+ this.options.set(dir, options);
62
+ return options;
63
+ }
64
+ dirsUp(file) {
65
+ const root = path.resolve(this.cwd);
66
+ const dirs = [];
67
+ for (let dir = path.dirname(path.resolve(root, file)); dir.startsWith(root); dir = path.dirname(dir)) {
68
+ dirs.push(dir);
69
+ if (dir === root)
70
+ break;
71
+ }
72
+ return dirs;
73
+ }
74
+ nearestPackage(file) {
75
+ for (const dir of this.dirsUp(file)) {
76
+ const manifest = this.readPackage(dir);
77
+ if (manifest)
78
+ return manifest;
79
+ }
80
+ return undefined;
81
+ }
82
+ readPackage(dir) {
83
+ if (this.packages.has(dir))
84
+ return this.packages.get(dir);
85
+ let manifest = null;
86
+ try {
87
+ const json = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
88
+ const dependencies = new Map();
89
+ const ranges = new Map();
90
+ for (const field of DEPENDENCY_FIELDS) {
91
+ for (const [name, range] of Object.entries(json[field] ?? {})) {
92
+ dependencies.set(name, String(range));
93
+ ranges.set(name, [...(ranges.get(name) ?? []), String(range)]);
94
+ }
95
+ }
96
+ manifest = { name: typeof json.name === 'string' ? json.name : undefined, dependencies, ranges };
97
+ }
98
+ catch {
99
+ manifest = null;
100
+ }
101
+ this.packages.set(dir, manifest);
102
+ return manifest;
103
+ }
104
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { ProjectFacts } from './project-facts.js';
6
+ let root;
7
+ afterEach(() => { if (root)
8
+ fs.rmSync(root, { recursive: true, force: true }); root = undefined; });
9
+ function write(files) {
10
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'project-facts-'));
11
+ for (const [name, text] of Object.entries(files)) {
12
+ fs.mkdirSync(path.dirname(path.join(root, name)), { recursive: true });
13
+ fs.writeFileSync(path.join(root, name), text);
14
+ }
15
+ return root;
16
+ }
17
+ describe('ProjectFacts', () => {
18
+ it('reads the nearest package.json, so each monorepo package is judged by its own', () => {
19
+ const dir = write({
20
+ 'package.json': JSON.stringify({ name: 'root', devDependencies: { vite: '^5.0.0' } }),
21
+ 'packages/ui/package.json': JSON.stringify({ name: '@x/ui', peerDependencies: { 'solid-js': '^1.8.0' } }),
22
+ 'packages/ui/src/a.tsx': '',
23
+ });
24
+ const facts = new ProjectFacts(dir);
25
+ expect(facts.declares('packages/ui/src/a.tsx', 'solid-js')).toBe(true);
26
+ expect(facts.declares('packages/ui/src/a.tsx', 'vite')).toBe(false);
27
+ expect(facts.majorVersion('packages/ui/src/a.tsx', 'solid-js')).toBe(1);
28
+ expect(facts.packageName('packages/ui/src/a.tsx')).toBe('@x/ui');
29
+ expect(facts.packageDir('packages/ui/src/a.tsx')).toBe(path.join(dir, 'packages/ui'));
30
+ });
31
+ it('never reads a manifest above cwd', () => {
32
+ const dir = write({ 'package.json': JSON.stringify({ dependencies: { react: '^19.0.0' } }), 'app/src/a.tsx': '' });
33
+ const facts = new ProjectFacts(path.join(dir, 'app'));
34
+ expect(facts.dependencies('src/a.tsx').size).toBe(0);
35
+ expect(facts.packageDir('src/a.tsx')).toBeUndefined();
36
+ });
37
+ it('resolves compiler options through tsconfig extends', () => {
38
+ const dir = write({
39
+ 'tsconfig.base.json': JSON.stringify({ compilerOptions: { stripInternal: true } }),
40
+ 'pkg/tsconfig.json': JSON.stringify({ extends: '../tsconfig.base.json' }),
41
+ 'pkg/src/a.ts': '',
42
+ });
43
+ expect(new ProjectFacts(dir).compilerOptions('pkg/src/a.ts').stripInternal).toBe(true);
44
+ });
45
+ });
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const depsCodegenUndeclaredImport: SemanticRule;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * deps/codegen-undeclared-import: generated code imports a package the
3
+ * generating package does not declare.
4
+ *
5
+ * Plugins and compilers emit source into their users' builds. An emitted
6
+ * `import … from 'pkg'` resolves in the user's project, so `pkg` must be a
7
+ * dependency or peer dependency of the plugin; otherwise builds fail wherever
8
+ * the user has not happened to install it. TanStack's Rsbuild plugin emitted
9
+ * `react-refresh/runtime` and broke apps without it.
10
+ *
11
+ * Only text that is emitted as code counts: a template given to a code builder
12
+ * (`template.statement\`…\`` or `template.statement(\`…\`)`), the `code` a
13
+ * bundler hook returns, or text given to a MagicString edit. Strings that merely mention imports (docs, lint rule
14
+ * data, demos) do not.
15
+ */
16
+ import { builtinModules } from 'module';
17
+ import ts from 'typescript';
18
+ import { forEachNode, propertyNameText } from '../../ast.js';
19
+ import { finding, packageOf } from './shared.js';
20
+ const EMITTED_IMPORT = /(?:^|[\s;{}])import\s+(?:[\w*{}\s,$]+\s+from\s+)?['"]([^'"]+)['"]/g;
21
+ const BUILTINS = new Set(builtinModules);
22
+ const CODE_TAG = /(?:^|\.)(?:template|statement|statements|expression|smart|program|ast|js|ts|jsx|tsx|code)$/;
23
+ const EMIT_METHODS = new Set(['prepend', 'append', 'appendLeft', 'appendRight', 'prependLeft', 'prependRight', 'overwrite']);
24
+ export const depsCodegenUndeclaredImport = {
25
+ id: 'deps/codegen-undeclared-import',
26
+ check(ctx) {
27
+ const { sourceFile, project } = ctx;
28
+ const declared = project.dependencies(sourceFile.fileName);
29
+ if (declared.size === 0)
30
+ return [];
31
+ const self = project.packageName(sourceFile.fileName);
32
+ const findings = [];
33
+ forEachNode(sourceFile, (node) => {
34
+ const text = templateText(node);
35
+ if (!text || !isEmittedCode(node))
36
+ return;
37
+ for (const match of text.matchAll(EMITTED_IMPORT)) {
38
+ const pkg = packageOf(match[1]);
39
+ if (!pkg || pkg.includes('${') || BUILTINS.has(pkg) || declared.has(pkg) || pkg === self)
40
+ continue;
41
+ findings.push(finding(ctx, 'deps/codegen-undeclared-import', node, 'high', `Generated code imports \`${match[1]}\`, but \`${pkg}\` is not a dependency or peer dependency of this package; builds without it fail.`, `Declare \`${pkg}\` as a (peer) dependency, or emit code that does not import it.`));
42
+ }
43
+ });
44
+ return findings;
45
+ },
46
+ };
47
+ /** Text of a string or template that is code: it must contain an import statement, not just a path. */
48
+ function templateText(node) {
49
+ if (ts.isNoSubstitutionTemplateLiteral(node) || ts.isStringLiteral(node))
50
+ return node.text.includes('import') ? node.text : undefined;
51
+ if (ts.isTemplateExpression(node)) {
52
+ const text = [node.head.text, ...node.templateSpans.map(s => s.literal.text)].join('${}');
53
+ return text.includes('import') ? text : undefined;
54
+ }
55
+ return undefined;
56
+ }
57
+ /** The string is emitted as module code, not merely text that mentions an import. */
58
+ function isEmittedCode(node) {
59
+ const parent = node.parent;
60
+ if (ts.isTaggedTemplateExpression(parent))
61
+ return CODE_TAG.test(parent.tag.getText());
62
+ if (ts.isPropertyAssignment(parent) && propertyNameText(parent.name) === 'code')
63
+ return true;
64
+ if (!ts.isCallExpression(parent) || !parent.arguments.includes(node))
65
+ return false;
66
+ const callee = parent.expression;
67
+ // template.statement(`import …`), a code builder called rather than tagged
68
+ if (CODE_TAG.test(callee.getText()))
69
+ return true;
70
+ return ts.isPropertyAccessExpression(callee) && EMIT_METHODS.has(callee.name.text);
71
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const depsInternalSymbol: SemanticRule;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * deps/internal-symbol: reading a library's internal `Symbol.for('<lib>.internal…')`
3
+ * with the type error silenced.
4
+ *
5
+ * Internal symbols change without notice between releases, and the
6
+ * `@ts-expect-error` hides exactly the signal that the value is not public API.
7
+ * TanStack Start read h3's internal response symbol and replaced it with the
8
+ * public `event.res`.
9
+ */
10
+ import ts from 'typescript';
11
+ import { forEachNode } from '../../ast.js';
12
+ import { finding, leadingCommentText } from './shared.js';
13
+ const INTERNAL_KEY = /(?:^|[.:/])(?:internal|private)(?:[.:/]|$)/i;
14
+ const SILENCED = /@ts-(?:expect-error|ignore)/;
15
+ export const depsInternalSymbol = {
16
+ id: 'deps/internal-symbol',
17
+ check(ctx) {
18
+ const findings = [];
19
+ forEachNode(ctx.sourceFile, (node) => {
20
+ if (!ts.isCallExpression(node) || !isSymbolFor(node))
21
+ return;
22
+ const key = node.arguments[0];
23
+ if (!key || !ts.isStringLiteralLike(key) || !INTERNAL_KEY.test(key.text))
24
+ return;
25
+ if (!SILENCED.test(leadingCommentText(ctx.sourceFile, node)))
26
+ return;
27
+ findings.push(finding(ctx, 'deps/internal-symbol', node, 'medium', `\`Symbol.for('${key.text}')\` reads a library internal with the type error silenced; it can break on any release.`, 'Use the library\'s public API for this value, or pin the version and cover it with a test.'));
28
+ });
29
+ return findings;
30
+ },
31
+ };
32
+ function isSymbolFor(call) {
33
+ const callee = call.expression;
34
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'for'
35
+ && ts.isIdentifier(callee.expression) && callee.expression.text === 'Symbol';
36
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const domSelfQueryInComponent: SemanticRule;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * dom/self-query-in-component: a component scans the whole document to find
3
+ * its own element.
4
+ *
5
+ * `document.querySelectorAll(...)` inside a component's mount or effect, looping
6
+ * to find the node whose attribute equals this instance's id, costs O(n) per
7
+ * instance and O(n²) per page. A ref gives the element directly. TanStack's
8
+ * Solid hydration did this for every boundary.
9
+ */
10
+ import ts from 'typescript';
11
+ import { forEachNode } from '../../ast.js';
12
+ import { finding, isJsxFile } from './shared.js';
13
+ const LIFECYCLE = new Set(['onMount', 'createEffect', 'createRenderEffect', 'useEffect', 'useLayoutEffect', 'onMounted', 'watchEffect']);
14
+ export const domSelfQueryInComponent = {
15
+ id: 'dom/self-query-in-component',
16
+ check(ctx) {
17
+ if (!isJsxFile(ctx.sourceFile) && !ctx.sourceFile.fileName.endsWith('.vue.ts'))
18
+ return [];
19
+ const findings = [];
20
+ forEachNode(ctx.sourceFile, (node) => {
21
+ if (!ts.isCallExpression(node) || !isDocumentQueryAll(node) || !insideLifecycle(node))
22
+ return;
23
+ if (!matchesOwnIdentity(node))
24
+ return;
25
+ findings.push(finding(ctx, 'dom/self-query-in-component', node, 'medium', 'Each instance scans the whole document to find its own element: O(n) per instance, O(n²) per page.', 'Attach a ref to the element and use it directly.'));
26
+ });
27
+ return findings;
28
+ },
29
+ };
30
+ function isDocumentQueryAll(call) {
31
+ const callee = call.expression;
32
+ return ts.isPropertyAccessExpression(callee) && callee.name.text === 'querySelectorAll'
33
+ && ts.isIdentifier(callee.expression) && callee.expression.text === 'document';
34
+ }
35
+ function insideLifecycle(node) {
36
+ for (let current = node.parent; current; current = current.parent) {
37
+ if (!ts.isCallExpression(current))
38
+ continue;
39
+ const callee = current.expression;
40
+ const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : '';
41
+ if (LIFECYCLE.has(name))
42
+ return true; // onMount(...) or Solid.onMount(...)
43
+ }
44
+ return false;
45
+ }
46
+ /** The query's results are filtered by comparing an attribute to something (the instance's own id). */
47
+ function matchesOwnIdentity(query) {
48
+ let scope = query;
49
+ while (scope.parent && !ts.isBlock(scope.parent))
50
+ scope = scope.parent;
51
+ const block = scope.parent ?? scope;
52
+ let matches = false;
53
+ forEachNode(block, (node) => {
54
+ if (matches || !ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken)
55
+ return;
56
+ matches = [node.left, node.right].some(side => ts.isCallExpression(side) && ts.isPropertyAccessExpression(side.expression)
57
+ && side.expression.name.text === 'getAttribute');
58
+ });
59
+ return matches;
60
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const envBareBrowserGlobal: SemanticRule;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * env/bare-browser-global: a browser global used bare (`addEventListener(...)`,
3
+ * `history.x`) in a file that knows it may run outside a browser.
4
+ *
5
+ * The file qualifies the global as `window.x` elsewhere, compares against
6
+ * `window`, or checks `isServer` / `typeof window`: it runs where the global is
7
+ * absent or is not the window (SSR, Node with a jsdom window, workers). A bare
8
+ * use there throws or reaches the wrong object. TanStack Router's core crashed
9
+ * router creation under Node + jsdom this way.
10
+ */
11
+ import ts from 'typescript';
12
+ import { forEachNode } from '../../ast.js';
13
+ import { finding } from './shared.js';
14
+ const BARE_CALLS = new Set(['addEventListener', 'removeEventListener', 'dispatchEvent']);
15
+ const BARE_OBJECTS = new Set(['history', 'location', 'localStorage', 'sessionStorage']);
16
+ /** The file guards for non-browser runtimes: `isServer`, `typeof window`/`document`, or compares against `window`. */
17
+ const ENV_AWARE = /\bisServer\b|typeof\s+(?:window|document)\b|[=!]==?\s*window\b|\bwindow\s*[=!]==?/;
18
+ export const envBareBrowserGlobal = {
19
+ id: 'env/bare-browser-global',
20
+ check(ctx) {
21
+ const { sourceFile, checker } = ctx;
22
+ const qualified = windowQualified(sourceFile);
23
+ const aware = qualified.size > 0 || ENV_AWARE.test(sourceFile.text);
24
+ if (!aware)
25
+ return [];
26
+ const findings = [];
27
+ forEachNode(sourceFile, (node) => {
28
+ const bare = bareGlobal(node);
29
+ if (!bare || isLocal(checker, bare))
30
+ return;
31
+ findings.push(finding(ctx, 'env/bare-browser-global', node, 'medium', `Bare \`${bare.text}\` in a file that also runs outside the browser; where the global is not the window (SSR, jsdom, workers) it throws or hits the wrong object.`, `Use \`window.${bare.text}\` as the rest of the file does.`));
32
+ });
33
+ return findings;
34
+ },
35
+ };
36
+ /** Names the file reads as `window.<name>`. */
37
+ function windowQualified(sourceFile) {
38
+ const names = new Set();
39
+ forEachNode(sourceFile, (node) => {
40
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'window'
41
+ && (BARE_CALLS.has(node.name.text) || BARE_OBJECTS.has(node.name.text)))
42
+ names.add(node.name.text);
43
+ });
44
+ return names;
45
+ }
46
+ function bareGlobal(node) {
47
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && BARE_CALLS.has(node.expression.text))
48
+ return node.expression;
49
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && BARE_OBJECTS.has(node.expression.text))
50
+ return node.expression;
51
+ return undefined;
52
+ }
53
+ /** Declared in the project (an import, a parameter, a local): not the browser global. */
54
+ function isLocal(checker, id) {
55
+ const declarations = checker.getSymbolAtLocation(id)?.declarations ?? [];
56
+ return declarations.some(d => !d.getSourceFile().isDeclarationFile);
57
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const exportsConditionParity: SemanticRule;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * exports/condition-parity: a conditional entry (`index.react-server.ts`,
3
+ * `index.rsc.ts`, `index.browser.ts`, …) exports fewer names than the package's
4
+ * default `index.ts` beside it.
5
+ *
6
+ * Bundlers pick the conditional entry for that environment, so every name it
7
+ * lacks is an import that works in one environment and fails in another.
8
+ * TanStack Start's `react-server` entry dropped `useServerFn` and broke RSC apps.
9
+ */
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import ts from 'typescript';
13
+ import { finding } from './shared.js';
14
+ const VARIANT = /^index\.([a-z][\w-]*)\.[cm]?[jt]sx?$/i;
15
+ const DECLARATION = /\.d\.[cm]?ts$/i;
16
+ export const exportsConditionParity = {
17
+ id: 'exports/condition-parity',
18
+ check(ctx) {
19
+ const { sourceFile } = ctx;
20
+ const base = path.basename(sourceFile.fileName);
21
+ const variant = base.match(VARIANT);
22
+ if (!variant || DECLARATION.test(base))
23
+ return [];
24
+ const defaultEntry = siblingIndex(path.dirname(sourceFile.fileName));
25
+ if (!defaultEntry)
26
+ return [];
27
+ const { checker, entry, variant: own } = entries(ctx, defaultEntry);
28
+ if (!entry || !own)
29
+ return [];
30
+ const ownNames = exportNames(checker, own);
31
+ const missing = [...exportNames(checker, entry)].filter(name => !ownNames.has(name));
32
+ if (missing.length === 0)
33
+ return [];
34
+ const anchor = sourceFile.statements.find(s => ts.isExportDeclaration(s)) ?? sourceFile.statements[0] ?? sourceFile;
35
+ const shown = missing.slice(0, 5).map(n => `\`${n}\``).join(', ');
36
+ return [finding(ctx, 'exports/condition-parity', anchor, 'medium', `The \`${variant[1]}\` entry lacks ${missing.length} export(s) of ${path.basename(defaultEntry)}: ${shown}${missing.length > 5 ? ', …' : ''}. Imports of them fail only in that environment.`, `Re-export them from ${base}, or point the \`${variant[1]}\` condition at the default entry.`)];
37
+ },
38
+ };
39
+ function siblingIndex(dir) {
40
+ return ['index.ts', 'index.tsx', 'index.js', 'index.mts'].map(f => path.join(dir, f)).find(f => fs.existsSync(f));
41
+ }
42
+ /**
43
+ * Both entries in one program: the analysis program when it loaded the default
44
+ * entry (a full check), otherwise a two-file program (a review of the variant alone).
45
+ */
46
+ function entries(ctx, defaultEntry) {
47
+ const entry = ctx.program.getSourceFile(defaultEntry);
48
+ if (entry)
49
+ return { checker: ctx.checker, entry, variant: ctx.sourceFile };
50
+ const options = { ...ctx.project.compilerOptions(ctx.sourceFile.fileName), noEmit: true, skipLibCheck: true, types: [] };
51
+ const pair = ts.createProgram([defaultEntry, ctx.sourceFile.fileName], options);
52
+ return { checker: pair.getTypeChecker(), entry: pair.getSourceFile(defaultEntry), variant: pair.getSourceFile(ctx.sourceFile.fileName) };
53
+ }
54
+ function exportNames(checker, sourceFile) {
55
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
56
+ return new Set(moduleSymbol ? checker.getExportsOfModule(moduleSymbol).map(s => s.name) : []);
57
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const exportsForgottenType: SemanticRule;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * exports/forgotten-type: a package entry exports a function whose signature
3
+ * names a type the entry does not export (api-extractor's "forgotten export").
4
+ *
5
+ * Consumers can call the function but cannot name its parameter or return
6
+ * types, so they cannot annotate, wrap or store the values. TanStack Start's
7
+ * RSC entry exported `renderServerComponent` without the Renderable* types it
8
+ * returns.
9
+ */
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import ts from 'typescript';
13
+ import { finding } from './shared.js';
14
+ const ENTRY = /^index\.[cm]?tsx?$/;
15
+ export const exportsForgottenType = {
16
+ id: 'exports/forgotten-type',
17
+ check(ctx) {
18
+ const { sourceFile, checker } = ctx;
19
+ if (!ENTRY.test(path.basename(sourceFile.fileName)) || !isPackageEntry(ctx))
20
+ return [];
21
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
22
+ if (!moduleSymbol)
23
+ return [];
24
+ const exported = checker.getExportsOfModule(moduleSymbol);
25
+ const exportedTargets = new Set(exported.map(s => resolve(checker, s)));
26
+ // One finding per export statement: the engine keeps one finding per line.
27
+ const missing = new Map();
28
+ for (const symbol of exported) {
29
+ for (const declaration of resolve(checker, symbol).declarations ?? []) {
30
+ const fn = functionOf(declaration);
31
+ if (!fn)
32
+ continue;
33
+ for (const type of signatureTypes(checker, fn)) {
34
+ if (exportedTargets.has(type) || subpathExports(ctx, type))
35
+ continue;
36
+ const anchor = exportStatementFor(checker, sourceFile, symbol.name) ?? sourceFile.statements[0] ?? sourceFile;
37
+ const entry = missing.get(anchor) ?? { names: [], types: new Set() };
38
+ if (!entry.names.includes(symbol.name))
39
+ entry.names.push(symbol.name);
40
+ entry.types.add(type.name);
41
+ missing.set(anchor, entry);
42
+ }
43
+ }
44
+ }
45
+ const findings = [];
46
+ for (const [anchor, { names, types }] of missing) {
47
+ const typeList = [...types].map(t => `\`${t}\``).join(', ');
48
+ findings.push(finding(ctx, 'exports/forgotten-type', anchor, 'medium', `${names.map(n => `\`${n}\``).join(', ')} ${names.length > 1 ? 'are' : 'is'} exported, but ${types.size > 1 ? 'their signatures use' : 'the signature uses'} ${typeList}, which this entry does not export; consumers cannot name ${types.size > 1 ? 'them' : 'it'}.`, `Export ${typeList} (a type-only export is enough) from ${path.basename(sourceFile.fileName)}.`));
49
+ }
50
+ return findings;
51
+ },
52
+ };
53
+ /** The entry of its package: `index.ts` beside package.json or in its `src/`. */
54
+ function isPackageEntry(ctx) {
55
+ // TypeScript names files with '/', path.resolve with the OS separator: compare resolved paths.
56
+ const dir = path.resolve(path.dirname(ctx.sourceFile.fileName));
57
+ const owner = ctx.project.packageDir(ctx.sourceFile.fileName);
58
+ return !!owner && (dir === owner || dir === path.join(owner, 'src'));
59
+ }
60
+ /**
61
+ * Exported by a subpath entry instead: an `index` file between the type's
62
+ * declaration and the package root that exports it (`pkg/sub` entries).
63
+ */
64
+ function subpathExports(ctx, type) {
65
+ const owner = ctx.project.packageDir(ctx.sourceFile.fileName);
66
+ const declared = type.declarations?.[0]?.getSourceFile().fileName;
67
+ const self = path.resolve(ctx.sourceFile.fileName);
68
+ if (!owner || !declared)
69
+ return false;
70
+ for (let dir = path.resolve(path.dirname(declared)); dir.startsWith(owner) && dir !== owner; dir = path.dirname(dir)) {
71
+ const index = ['index.ts', 'index.tsx', 'index.mts'].map(f => path.join(dir, f)).find(f => fs.existsSync(f));
72
+ if (!index || index === self)
73
+ continue;
74
+ const loaded = ctx.program.getSourceFile(index);
75
+ const moduleSymbol = loaded && ctx.checker.getSymbolAtLocation(loaded);
76
+ if (moduleSymbol ? ctx.checker.getExportsOfModule(moduleSymbol).some(s => s.name === type.name)
77
+ : new RegExp(`\\b${type.name}\\b|export \\*`).test(fs.readFileSync(index, 'utf8')))
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ function resolve(checker, symbol) {
83
+ return symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
84
+ }
85
+ /** `function f()` or `const f = () => …` / `const f = function () {…}`. */
86
+ function functionOf(declaration) {
87
+ if (ts.isFunctionDeclaration(declaration))
88
+ return declaration;
89
+ const init = ts.isVariableDeclaration(declaration) ? declaration.initializer : undefined;
90
+ return init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) ? init : undefined;
91
+ }
92
+ /** Project-declared interfaces, type aliases, classes and enums named in parameter and return annotations. */
93
+ function signatureTypes(checker, fn) {
94
+ const types = [];
95
+ const visit = (node) => {
96
+ if (ts.isTypeReferenceNode(node)) {
97
+ const symbol = checker.getSymbolAtLocation(node.typeName);
98
+ const target = symbol && resolve(checker, symbol);
99
+ if (target && isProjectType(target))
100
+ types.push(target);
101
+ }
102
+ ts.forEachChild(node, visit);
103
+ };
104
+ for (const param of fn.parameters)
105
+ if (param.type)
106
+ visit(param.type);
107
+ if (fn.type)
108
+ visit(fn.type);
109
+ return types;
110
+ }
111
+ function isProjectType(symbol) {
112
+ const kinds = ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias | ts.SymbolFlags.Class | ts.SymbolFlags.Enum;
113
+ return (symbol.flags & kinds) !== 0
114
+ && (symbol.declarations ?? []).some(d => !d.getSourceFile().isDeclarationFile && !d.getSourceFile().fileName.includes('/node_modules/'));
115
+ }
116
+ /** The statement that exports `name`: a named export, or the `export *` whose module provides it. */
117
+ function exportStatementFor(checker, sourceFile, name) {
118
+ const declarations = sourceFile.statements.filter(ts.isExportDeclaration);
119
+ const named = declarations.find(s => s.exportClause && ts.isNamedExports(s.exportClause)
120
+ && s.exportClause.elements.some(e => e.name.text === name));
121
+ if (named)
122
+ return named;
123
+ return declarations.find(s => !s.exportClause && s.moduleSpecifier && providesName(checker, s.moduleSpecifier, name));
124
+ }
125
+ function providesName(checker, specifier, name) {
126
+ const moduleSymbol = checker.getSymbolAtLocation(specifier);
127
+ return !!moduleSymbol && checker.getExportsOfModule(moduleSymbol).some(s => s.name === name);
128
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const importsBarrelCycle: SemanticRule;