@rigour-labs/core 6.4.0 → 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 (107) hide show
  1. package/dist/deep/changed-functions.d.ts +15 -0
  2. package/dist/deep/changed-functions.js +34 -0
  3. package/dist/deep/code-pass.d.ts +10 -0
  4. package/dist/deep/code-pass.js +37 -2
  5. package/dist/deep/code-pass.test.d.ts +1 -0
  6. package/dist/deep/code-pass.test.js +69 -0
  7. package/dist/deep/code-review-prompt.d.ts +1 -1
  8. package/dist/deep/code-review-prompt.js +3 -2
  9. package/dist/deep/diff-tests/calls.d.ts +8 -0
  10. package/dist/deep/diff-tests/calls.js +85 -0
  11. package/dist/deep/diff-tests/calls.test.d.ts +1 -0
  12. package/dist/deep/diff-tests/calls.test.js +33 -0
  13. package/dist/deep/diff-tests/run.d.ts +21 -0
  14. package/dist/deep/diff-tests/run.js +178 -0
  15. package/dist/deep/diff-tests/run.test.d.ts +1 -0
  16. package/dist/deep/diff-tests/run.test.js +58 -0
  17. package/dist/deep/reference-pack.d.ts +19 -0
  18. package/dist/deep/reference-pack.js +101 -0
  19. package/dist/deep/reference-pack.test.d.ts +1 -0
  20. package/dist/deep/reference-pack.test.js +46 -0
  21. package/dist/deep/review-context-export.d.ts +11 -0
  22. package/dist/deep/review-context-export.js +31 -0
  23. package/dist/deep/review-context-export.test.d.ts +1 -0
  24. package/dist/deep/review-context-export.test.js +32 -0
  25. package/dist/deep/self-check.d.ts +21 -0
  26. package/dist/deep/self-check.js +49 -0
  27. package/dist/firewall/trusted-control.d.ts +1 -2
  28. package/dist/gates/deep-analysis.d.ts +2 -0
  29. package/dist/gates/deep-analysis.js +12 -0
  30. package/dist/gates/deep-runner.js +2 -1
  31. package/dist/gates/semantic-bugs.js +4 -3
  32. package/dist/gates/semantic-bugs.test.js +6 -0
  33. package/dist/hooks/checker.d.ts +1 -2
  34. package/dist/hooks/index.d.ts +2 -0
  35. package/dist/index.d.ts +20 -1
  36. package/dist/index.js +4 -5
  37. package/dist/inference/index.d.ts +1 -1
  38. package/dist/inference/index.js +3 -5
  39. package/dist/inference/local-model.test.d.ts +1 -0
  40. package/dist/inference/local-model.test.js +23 -0
  41. package/dist/inference/sidecar-provider.d.ts +10 -1
  42. package/dist/inference/sidecar-provider.js +28 -6
  43. package/dist/inference/types.d.ts +8 -1
  44. package/dist/inference/types.js +14 -0
  45. package/dist/inference/types.test.d.ts +1 -0
  46. package/dist/inference/types.test.js +19 -0
  47. package/dist/review/diff-test-findings.d.ts +3 -0
  48. package/dist/review/diff-test-findings.js +41 -0
  49. package/dist/review/review.d.ts +4 -2
  50. package/dist/review/review.js +5 -2
  51. package/dist/semantic/benchmark.js +2 -2
  52. package/dist/semantic/benchmark.test.js +5 -1
  53. package/dist/semantic/engine.d.ts +3 -0
  54. package/dist/semantic/engine.js +8 -2
  55. package/dist/semantic/failure-handling.d.ts +12 -0
  56. package/dist/semantic/failure-handling.js +41 -0
  57. package/dist/semantic/intent/parallel-reads.js +2 -30
  58. package/dist/semantic/project-facts.d.ts +25 -0
  59. package/dist/semantic/project-facts.js +104 -0
  60. package/dist/semantic/project-facts.test.d.ts +1 -0
  61. package/dist/semantic/project-facts.test.js +45 -0
  62. package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.d.ts +2 -0
  63. package/dist/semantic/rules/ecosystem/deps-codegen-undeclared-import.js +71 -0
  64. package/dist/semantic/rules/ecosystem/deps-internal-symbol.d.ts +2 -0
  65. package/dist/semantic/rules/ecosystem/deps-internal-symbol.js +36 -0
  66. package/dist/semantic/rules/ecosystem/dom-self-query-in-component.d.ts +2 -0
  67. package/dist/semantic/rules/ecosystem/dom-self-query-in-component.js +60 -0
  68. package/dist/semantic/rules/ecosystem/env-bare-browser-global.d.ts +2 -0
  69. package/dist/semantic/rules/ecosystem/env-bare-browser-global.js +57 -0
  70. package/dist/semantic/rules/ecosystem/exports-condition-parity.d.ts +2 -0
  71. package/dist/semantic/rules/ecosystem/exports-condition-parity.js +57 -0
  72. package/dist/semantic/rules/ecosystem/exports-forgotten-type.d.ts +2 -0
  73. package/dist/semantic/rules/ecosystem/exports-forgotten-type.js +128 -0
  74. package/dist/semantic/rules/ecosystem/imports-barrel-cycle.d.ts +2 -0
  75. package/dist/semantic/rules/ecosystem/imports-barrel-cycle.js +78 -0
  76. package/dist/semantic/rules/ecosystem/index.d.ts +15 -0
  77. package/dist/semantic/rules/ecosystem/index.js +22 -0
  78. package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.d.ts +2 -0
  79. package/dist/semantic/rules/ecosystem/paths-unnormalized-module-key.js +87 -0
  80. package/dist/semantic/rules/ecosystem/react-inline-html-object.d.ts +2 -0
  81. package/dist/semantic/rules/ecosystem/react-inline-html-object.js +30 -0
  82. package/dist/semantic/rules/ecosystem/shared.d.ts +18 -0
  83. package/dist/semantic/rules/ecosystem/shared.js +43 -0
  84. package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.d.ts +2 -0
  85. package/dist/semantic/rules/ecosystem/solid-jsx-and-conditional.js +36 -0
  86. package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.d.ts +2 -0
  87. package/dist/semantic/rules/ecosystem/ts-internal-in-public-signature.js +66 -0
  88. package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.d.ts +2 -0
  89. package/dist/semantic/rules/ecosystem/vite-rollup-only-hook-field.js +31 -0
  90. package/dist/semantic/rules/ecosystem/vue-static-computed.d.ts +2 -0
  91. package/dist/semantic/rules/ecosystem/vue-static-computed.js +51 -0
  92. package/dist/semantic/sites/call-sites.d.ts +25 -0
  93. package/dist/semantic/sites/call-sites.js +69 -0
  94. package/dist/semantic/sites/call-sites.test.d.ts +1 -0
  95. package/dist/semantic/sites/call-sites.test.js +46 -0
  96. package/dist/semantic/sites/export.d.ts +2 -0
  97. package/dist/semantic/sites/export.js +22 -0
  98. package/dist/semantic/types.d.ts +4 -0
  99. package/dist/services/engineering-knowledge-graph.d.ts +1 -2
  100. package/dist/templates/universal-config.js +1 -0
  101. package/dist/types/index.d.ts +31 -8
  102. package/dist/types/index.js +2 -1
  103. package/dist/utils/diff-removed.test.d.ts +1 -0
  104. package/dist/utils/diff-removed.test.js +29 -0
  105. package/dist/utils/diff.d.ts +12 -0
  106. package/dist/utils/diff.js +26 -0
  107. package/package.json +6 -6
@@ -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;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * imports/barrel-cycle: a runtime re-export (`export … from './x'`) whose target
3
+ * imports this file back, directly or through other project modules.
4
+ *
5
+ * A barrel in an import cycle evaluates before the modules it re-exports have
6
+ * run: exports are undefined at first use, and HMR updates loop through the
7
+ * cycle. TanStack Start's `Hydrate` re-export pulled the app's router into a
8
+ * cycle and caused circular HMR updates. Type-only edges are erased, so they
9
+ * are ignored.
10
+ */
11
+ import ts from 'typescript';
12
+ import { finding } from './shared.js';
13
+ const MAX_VISITED = 2000;
14
+ const edgesByChecker = new WeakMap();
15
+ export const importsBarrelCycle = {
16
+ id: 'imports/barrel-cycle',
17
+ check(ctx) {
18
+ const { sourceFile, checker } = ctx;
19
+ const findings = [];
20
+ for (const statement of sourceFile.statements) {
21
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly || !statement.moduleSpecifier)
22
+ continue;
23
+ const target = moduleFile(checker, statement.moduleSpecifier);
24
+ if (!target || !reaches(checker, target, sourceFile))
25
+ continue;
26
+ findings.push(finding(ctx, 'imports/barrel-cycle', statement, 'medium', `This re-export of \`${statement.moduleSpecifier.text}\` closes an import cycle back to this file; its exports can be undefined at first use and HMR updates loop.`, 'Re-export from a module outside the cycle (a dedicated subpath entry), or break the back-import.'));
27
+ }
28
+ return findings;
29
+ },
30
+ };
31
+ function reaches(checker, from, to) {
32
+ const seen = new Set([from]);
33
+ const queue = [from];
34
+ while (queue.length && seen.size < MAX_VISITED) {
35
+ for (const next of edges(checker, queue.shift())) {
36
+ if (next === to)
37
+ return true;
38
+ if (!seen.has(next)) {
39
+ seen.add(next);
40
+ queue.push(next);
41
+ }
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+ /** Runtime imports and re-exports of a project file, to other project files. */
47
+ function edges(checker, file) {
48
+ let cache = edgesByChecker.get(checker);
49
+ if (!cache)
50
+ edgesByChecker.set(checker, cache = new Map());
51
+ const cached = cache.get(file);
52
+ if (cached)
53
+ return cached;
54
+ const targets = [];
55
+ for (const statement of file.statements) {
56
+ const specifier = runtimeSpecifier(statement);
57
+ const target = specifier && moduleFile(checker, specifier);
58
+ if (target)
59
+ targets.push(target);
60
+ }
61
+ cache.set(file, targets);
62
+ return targets;
63
+ }
64
+ function runtimeSpecifier(statement) {
65
+ if (ts.isImportDeclaration(statement)) {
66
+ const clause = statement.importClause;
67
+ return clause?.isTypeOnly ? undefined : statement.moduleSpecifier;
68
+ }
69
+ if (ts.isExportDeclaration(statement) && !statement.isTypeOnly)
70
+ return statement.moduleSpecifier;
71
+ return undefined;
72
+ }
73
+ function moduleFile(checker, specifier) {
74
+ const declaration = checker.getSymbolAtLocation(specifier)?.valueDeclaration;
75
+ if (!declaration || !ts.isSourceFile(declaration) || declaration.isDeclarationFile)
76
+ return undefined;
77
+ return declaration.fileName.includes('/node_modules/') ? undefined : declaration;
78
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Ecosystem rules: framework, library and packaging knowledge, each gated on the
3
+ * file's own dependencies or shape.
4
+ *
5
+ * A rule runs by default only once it passes validation on repositories it was
6
+ * not designed from (driftbench rlaif/rules): it catches real fixes, is quiet on
7
+ * code as it stands, and its hits are right. Candidates run when named in
8
+ * `gates.semantic_bugs.rules` or `rigour scan-rules --rules`, until their hits
9
+ * are labelled.
10
+ */
11
+ import type { SemanticRule } from '../../types.js';
12
+ /** Validated: catch a documented upstream fix, silent on eight unrelated repositories. */
13
+ export declare const ECOSYSTEM_RULES: SemanticRule[];
14
+ /** Candidates: they fire on code as it stands, and those hits are not yet labelled. Opt-in. */
15
+ export declare const CANDIDATE_RULES: SemanticRule[];
@@ -0,0 +1,22 @@
1
+ import { depsCodegenUndeclaredImport } from './deps-codegen-undeclared-import.js';
2
+ import { depsInternalSymbol } from './deps-internal-symbol.js';
3
+ import { domSelfQueryInComponent } from './dom-self-query-in-component.js';
4
+ import { envBareBrowserGlobal } from './env-bare-browser-global.js';
5
+ import { exportsConditionParity } from './exports-condition-parity.js';
6
+ import { exportsForgottenType } from './exports-forgotten-type.js';
7
+ import { importsBarrelCycle } from './imports-barrel-cycle.js';
8
+ import { pathsUnnormalizedModuleKey } from './paths-unnormalized-module-key.js';
9
+ import { reactInlineHtmlObject } from './react-inline-html-object.js';
10
+ import { solidJsxAndConditional } from './solid-jsx-and-conditional.js';
11
+ import { tsInternalInPublicSignature } from './ts-internal-in-public-signature.js';
12
+ import { viteRollupOnlyHookField } from './vite-rollup-only-hook-field.js';
13
+ import { vueStaticComputed } from './vue-static-computed.js';
14
+ /** Validated: catch a documented upstream fix, silent on eight unrelated repositories. */
15
+ export const ECOSYSTEM_RULES = [
16
+ vueStaticComputed, reactInlineHtmlObject, tsInternalInPublicSignature, depsInternalSymbol,
17
+ depsCodegenUndeclaredImport, viteRollupOnlyHookField, domSelfQueryInComponent, exportsConditionParity,
18
+ ];
19
+ /** Candidates: they fire on code as it stands, and those hits are not yet labelled. Opt-in. */
20
+ export const CANDIDATE_RULES = [
21
+ solidJsxAndConditional, pathsUnnormalizedModuleKey, envBareBrowserGlobal, exportsForgottenType, importsBarrelCycle,
22
+ ];
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const pathsUnnormalizedModuleKey: SemanticRule;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * paths/unnormalized-module-key: a bundler module's file path is used as a map
3
+ * key or compared with another path without POSIX normalization.
4
+ *
5
+ * Rspack and webpack report modules by OS path (`nameForCondition()`,
6
+ * `.resource`, `.userRequest`), which on Windows uses backslashes, while route
7
+ * generators and Vite ids use forward slashes. The lookup silently misses on
8
+ * Windows only. TanStack Start lost every route's CSS on Windows this way.
9
+ */
10
+ import ts from 'typescript';
11
+ import { enclosingFunction, forEachNode, forEachOwnNode, symbolOf, unwrap } from '../../ast.js';
12
+ import { finding } from './shared.js';
13
+ const BUNDLERS = ['@rspack/core', 'webpack', '@rsbuild/core'];
14
+ const OS_PATH_PROPERTIES = new Set(['resource', 'userRequest', 'rawRequest']);
15
+ const OS_PATH_METHODS = new Set(['nameForCondition']);
16
+ const NORMALIZER = /normali[sz]e|posix|slash|toUnix|replace(?:All)?$/i;
17
+ export const pathsUnnormalizedModuleKey = {
18
+ id: 'paths/unnormalized-module-key',
19
+ check(ctx) {
20
+ const { sourceFile, checker, project } = ctx;
21
+ if (!project.declares(sourceFile.fileName, ...BUNDLERS))
22
+ return [];
23
+ const findings = [];
24
+ const tainted = new Set();
25
+ forEachNode(sourceFile, (node) => {
26
+ if (!ts.isVariableDeclaration(node) || !ts.isIdentifier(node.name) || !node.initializer)
27
+ return;
28
+ if (!readsOsPath(checker, node.initializer, tainted))
29
+ return;
30
+ const symbol = checker.getSymbolAtLocation(node.name);
31
+ const fn = enclosingFunction(node);
32
+ if (!symbol || !fn)
33
+ return;
34
+ tainted.add(symbol); // a later variable built from this one carries the OS path too
35
+ const use = keyUse(checker, fn, symbol);
36
+ if (use) {
37
+ findings.push(finding(ctx, 'paths/unnormalized-module-key', node, 'medium', `\`${node.name.text}\` holds a bundler module path in OS form and is used as a lookup key; on Windows it has backslashes and never matches.`, 'Normalize it to POSIX separators first (e.g. `.replace(/\\\\/g, \'/\')` or a normalizePath helper).'));
38
+ }
39
+ });
40
+ return findings;
41
+ },
42
+ };
43
+ /**
44
+ * The expression reads an OS path (from a bundler module, or from a variable that
45
+ * holds one) and is not normalized on the way.
46
+ */
47
+ function readsOsPath(checker, expr, tainted) {
48
+ let reads = false;
49
+ let normalized = false;
50
+ const walk = (node) => {
51
+ if (ts.isCallExpression(node)) {
52
+ const callee = node.expression;
53
+ const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : '';
54
+ if (NORMALIZER.test(name))
55
+ normalized = true;
56
+ if (OS_PATH_METHODS.has(name))
57
+ reads = true;
58
+ }
59
+ if (ts.isPropertyAccessExpression(node) && OS_PATH_PROPERTIES.has(node.name.text))
60
+ reads = true;
61
+ if (ts.isIdentifier(node) && tainted.size > 0) {
62
+ const symbol = symbolOf(checker, node);
63
+ if (symbol && tainted.has(symbol))
64
+ reads = true;
65
+ }
66
+ ts.forEachChild(node, walk);
67
+ };
68
+ walk(unwrap(expr));
69
+ return reads && !normalized;
70
+ }
71
+ /** A use of the variable as a Map key, an object key, or one side of an equality. */
72
+ function keyUse(checker, fn, symbol) {
73
+ let use;
74
+ forEachOwnNode(fn, (node) => {
75
+ if (use || !ts.isIdentifier(node) || symbolOf(checker, node) !== symbol)
76
+ return;
77
+ const parent = node.parent;
78
+ if (ts.isCallExpression(parent) && parent.arguments[0] === node && ts.isPropertyAccessExpression(parent.expression)
79
+ && ['get', 'set', 'has'].includes(parent.expression.name.text))
80
+ use = parent;
81
+ else if (ts.isElementAccessExpression(parent) && parent.argumentExpression === node)
82
+ use = parent;
83
+ else if (ts.isBinaryExpression(parent) && [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken].includes(parent.operatorToken.kind))
84
+ use = parent;
85
+ });
86
+ return use;
87
+ }
@@ -0,0 +1,2 @@
1
+ import type { SemanticRule } from '../../types.js';
2
+ export declare const reactInlineHtmlObject: SemanticRule;