@vhite/vhite 0.0.0-stage → 0.6.0

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 (49) hide show
  1. package/README.md +151 -2
  2. package/bin/cli.js +59 -0
  3. package/bin/commands/baseline.js +27 -0
  4. package/bin/commands/hook.js +58 -0
  5. package/bin/commands/info.js +39 -0
  6. package/bin/commands/scan.js +206 -0
  7. package/bin/commands/shared.js +67 -0
  8. package/bin/commands/watch.js +44 -0
  9. package/bin/output/text.js +40 -0
  10. package/package.json +71 -4
  11. package/rules/a11y/a11y-click-div.js +39 -0
  12. package/rules/a11y/a11y-img-alt.js +36 -0
  13. package/rules/a11y/a11y-input-label.js +40 -0
  14. package/rules/core/console-log.js +44 -0
  15. package/rules/core/debugger-statement.js +36 -0
  16. package/rules/core/empty-block.js +41 -0
  17. package/rules/core/empty-catch.js +40 -0
  18. package/rules/core/hardcoded-secret.js +41 -0
  19. package/rules/core/leftover-todo.js +30 -0
  20. package/rules/core/unused-import.js +69 -0
  21. package/rules/index.js +27 -0
  22. package/rules/react/react-dangerous-html.js +30 -0
  23. package/rules/react/react-missing-key.js +56 -0
  24. package/rules/shared/jsx-helpers.js +30 -0
  25. package/src/core/args.js +134 -0
  26. package/src/core/config.js +92 -0
  27. package/src/core/engine.js +144 -0
  28. package/src/core/exit-codes.js +15 -0
  29. package/src/core/files.js +21 -0
  30. package/src/core/setup.js +114 -0
  31. package/src/core/version.js +6 -0
  32. package/src/fix/approve.js +71 -0
  33. package/src/fix/backup.js +73 -0
  34. package/src/fix/fixers/debugger-statement.js +12 -0
  35. package/src/fix/fixers/index.js +9 -0
  36. package/src/fix/fixers/leftover-todo.js +32 -0
  37. package/src/fix/fixers/unused-import.js +18 -0
  38. package/src/fix/gitignore.js +43 -0
  39. package/src/fix/safe-fix.js +178 -0
  40. package/src/output/explanations-frontend.js +22 -0
  41. package/src/output/explanations.js +37 -0
  42. package/src/output/json-output.js +47 -0
  43. package/src/output/reporter.js +52 -0
  44. package/src/output/run-report.js +194 -0
  45. package/src/output/sarif.js +60 -0
  46. package/src/scan/baseline.js +60 -0
  47. package/src/scan/git.js +28 -0
  48. package/src/scan/ignore.js +68 -0
  49. package/src/scan/suppress.js +53 -0
@@ -0,0 +1,40 @@
1
+ export const USAGE = `Usage:
2
+ vhite [path] Scan a folder (default command)
3
+ vhite scan [path] Scan and report
4
+ vhite fix [path] Scan, then choose which fixes to apply
5
+ vhite baseline [path] Save current problems so only new ones are reported
6
+ vhite rules List all rules
7
+ vhite explain <rule> Explain one rule
8
+ vhite version Show the version
9
+
10
+ Options:
11
+ --format text|json Output format (default: text)
12
+ --quiet, -q Print only errors; the exit code still applies
13
+ --fix Apply all safe fixes without asking, then rescan
14
+ --changed Scan only files changed since the last commit (Git)
15
+ --staged Scan only files staged for commit (Git)
16
+ --no-baseline Ignore vhite.baseline.json for this run
17
+ --rule <name> Run only this rule (repeatable)
18
+ --disable <name> Turn off a rule (repeatable)
19
+ --include <path> Scan only files under this path (repeatable)
20
+ --exclude <path> Skip files under this path (repeatable)
21
+ --help, -h Show this help
22
+
23
+ Exit codes:
24
+ 0 no problems
25
+ 1 problems found (errors or warnings; info notes do not count)
26
+ 2 configuration or runtime error
27
+ 3 fixes applied and no problems remain
28
+ 4 fixes were rolled back
29
+
30
+ Ignoring code:
31
+ Put // vhite-disable-next-line rule-name above a line, or
32
+ // vhite-disable-line rule-name at the end of a line,
33
+ to skip that finding. Leave out the rule name to skip every rule.`;
34
+
35
+ export const BANNER = `vhite is installed and running
36
+ loading..
37
+ ....
38
+ ......
39
+ ........
40
+ Done..`;
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@vhite/vhite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0",
4
+ "description": "An AI code cleaner for front-end projects that finds and safely fixes common AI-generated problems",
5
+ "license": "ISC",
6
+ "author": "Victor Edem",
7
+ "type": "module",
8
+ "main": "bin/cli.js",
9
+ "bin": {
10
+ "vhite": "bin/cli.js"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "rules",
16
+ "README.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "keywords": [
25
+ "ai",
26
+ "ai-code",
27
+ "ai-generated-code",
28
+ "ai-code-cleanup",
29
+ "ai-code-review",
30
+ "code-cleanup",
31
+ "code-quality",
32
+ "code-review",
33
+ "linter",
34
+ "lint",
35
+ "static-analysis",
36
+ "code-analysis",
37
+ "front-end",
38
+ "frontend",
39
+ "javascript",
40
+ "jsx",
41
+ "react",
42
+ "accessibility",
43
+ "a11y",
44
+ "wcag",
45
+ "security",
46
+ "hardcoded-secrets",
47
+ "console-log",
48
+ "debugger",
49
+ "unused-imports",
50
+ "dead-code",
51
+ "cli",
52
+ "command-line",
53
+ "ci",
54
+ "github-actions",
55
+ "pre-commit",
56
+ "vibe-coding"
57
+ ],
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "https://github.com/Wilson-Edem/vhite"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/Wilson-Edem/vhite/issues"
64
+ },
65
+ "homepage": "https://github.com/Wilson-Edem/vhite#readme",
66
+ "scripts": {
67
+ "test": "node --test test/*/*.test.js"
68
+ },
69
+ "dependencies": {
70
+ "espree": "^10.0.1",
71
+ "eslint-scope": "^8.4.0"
72
+ }
73
+ }
@@ -0,0 +1,39 @@
1
+ import { childNodes, attributeNamed, hasSpread } from "../shared/jsx-helpers.js";
2
+
3
+ const CLICKABLE_TAGS = new Set(["div", "span"]);
4
+
5
+ const a11yClickDiv = {
6
+ name: "a11y-click-div",
7
+ description: "Flags clickable div or span elements that have no role",
8
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
9
+ requiresAst: true,
10
+ check({ ast }) {
11
+ const issues = [];
12
+
13
+ function visit(node) {
14
+ if (!node || typeof node !== "object") return;
15
+
16
+ if (
17
+ node.type === "JSXOpeningElement" &&
18
+ node.name?.type === "JSXIdentifier" &&
19
+ CLICKABLE_TAGS.has(node.name.name) &&
20
+ attributeNamed(node, "onClick") &&
21
+ !attributeNamed(node, "role") &&
22
+ !hasSpread(node)
23
+ ) {
24
+ issues.push({
25
+ start: node.range?.[0],
26
+ end: node.range?.[1],
27
+ message: `Clickable <${node.name.name}> is not reachable by keyboard or announced to screen readers; use a button, or add role and keyboard handling`,
28
+ });
29
+ }
30
+
31
+ for (const child of childNodes(node)) visit(child);
32
+ }
33
+
34
+ visit(ast);
35
+ return issues;
36
+ },
37
+ };
38
+
39
+ export default a11yClickDiv;
@@ -0,0 +1,36 @@
1
+ import { childNodes, attributeNamed, hasSpread } from "../shared/jsx-helpers.js";
2
+
3
+ const a11yImgAlt = {
4
+ name: "a11y-img-alt",
5
+ description: "Flags images that have no alt attribute",
6
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
7
+ requiresAst: true,
8
+ check({ ast }) {
9
+ const issues = [];
10
+
11
+ function visit(node) {
12
+ if (!node || typeof node !== "object") return;
13
+
14
+ if (
15
+ node.type === "JSXOpeningElement" &&
16
+ node.name?.type === "JSXIdentifier" &&
17
+ node.name.name === "img" &&
18
+ !hasSpread(node) &&
19
+ !attributeNamed(node, "alt")
20
+ ) {
21
+ issues.push({
22
+ start: node.range?.[0],
23
+ end: node.range?.[1],
24
+ message: "Image has no alt attribute; add a description, or alt=\"\" if it is decorative",
25
+ });
26
+ }
27
+
28
+ for (const child of childNodes(node)) visit(child);
29
+ }
30
+
31
+ visit(ast);
32
+ return issues;
33
+ },
34
+ };
35
+
36
+ export default a11yImgAlt;
@@ -0,0 +1,40 @@
1
+ import { childNodes, attributeNamed, hasSpread, literalValue } from "../shared/jsx-helpers.js";
2
+
3
+ const NO_LABEL_TYPES = new Set(["hidden", "submit", "button", "reset", "image"]);
4
+ const LABEL_ATTRIBUTES = ["aria-label", "aria-labelledby", "id", "title"];
5
+
6
+ const a11yInputLabel = {
7
+ name: "a11y-input-label",
8
+ description: "Flags form inputs with no label or accessible name attribute",
9
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
10
+ requiresAst: true,
11
+ check({ ast }) {
12
+ const issues = [];
13
+
14
+ function visit(node) {
15
+ if (!node || typeof node !== "object") return;
16
+
17
+ if (
18
+ node.type === "JSXOpeningElement" &&
19
+ node.name?.type === "JSXIdentifier" &&
20
+ node.name.name === "input" &&
21
+ !hasSpread(node) &&
22
+ !NO_LABEL_TYPES.has(literalValue(attributeNamed(node, "type"))) &&
23
+ !LABEL_ATTRIBUTES.some((name) => attributeNamed(node, name))
24
+ ) {
25
+ issues.push({
26
+ start: node.range?.[0],
27
+ end: node.range?.[1],
28
+ message: "Form input has no label; connect it to a label element, or add aria-label",
29
+ });
30
+ }
31
+
32
+ for (const child of childNodes(node)) visit(child);
33
+ }
34
+
35
+ visit(ast);
36
+ return issues;
37
+ },
38
+ };
39
+
40
+ export default a11yInputLabel;
@@ -0,0 +1,44 @@
1
+ const consoleLog = {
2
+ name: "console-log",
3
+ description: "Flags console.log calls that may have been left during debugging",
4
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
5
+ requiresAst: true,
6
+ check({ ast }) {
7
+ const issues = [];
8
+
9
+ function visit(node) {
10
+ if (!node || typeof node !== "object") return;
11
+
12
+ if (
13
+ node.type === "CallExpression" &&
14
+ node.callee?.type === "MemberExpression" &&
15
+ !node.callee.computed &&
16
+ node.callee.object?.type === "Identifier" &&
17
+ node.callee.object.name === "console" &&
18
+ node.callee.property?.type === "Identifier" &&
19
+ node.callee.property.name === "log"
20
+ ) {
21
+ issues.push({
22
+ start: node.range?.[0],
23
+ end: node.range?.[1],
24
+ message: "Review or remove this console.log call",
25
+ });
26
+ }
27
+
28
+ for (const value of Object.values(node)) {
29
+ if (Array.isArray(value)) {
30
+ for (const child of value) {
31
+ if (child && typeof child.type === "string") visit(child);
32
+ }
33
+ } else if (value && typeof value.type === "string") {
34
+ visit(value);
35
+ }
36
+ }
37
+ }
38
+
39
+ visit(ast);
40
+ return issues;
41
+ },
42
+ };
43
+
44
+ export default consoleLog;
@@ -0,0 +1,36 @@
1
+ const debuggerStatement = {
2
+ name: "debugger-statement",
3
+ description: "Flags debugger statements left in source code",
4
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
5
+ requiresAst: true,
6
+ check({ ast }) {
7
+ const issues = [];
8
+
9
+ function visit(node) {
10
+ if (!node || typeof node !== "object") return;
11
+
12
+ if (node.type === "DebuggerStatement") {
13
+ issues.push({
14
+ start: node.range?.[0],
15
+ end: node.range?.[1],
16
+ message: "Remove the debugger statement before shipping",
17
+ });
18
+ }
19
+
20
+ for (const value of Object.values(node)) {
21
+ if (Array.isArray(value)) {
22
+ for (const child of value) {
23
+ if (child && typeof child.type === "string") visit(child);
24
+ }
25
+ } else if (value && typeof value.type === "string") {
26
+ visit(value);
27
+ }
28
+ }
29
+ }
30
+
31
+ visit(ast);
32
+ return issues;
33
+ },
34
+ };
35
+
36
+ export default debuggerStatement;
@@ -0,0 +1,41 @@
1
+ const emptyBlock = {
2
+ name: "empty-block",
3
+ description: "Flags empty control-flow blocks that may hide unfinished logic",
4
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
5
+ requiresAst: true,
6
+ check({ ast }) {
7
+ const issues = [];
8
+
9
+ function visit(node, parent = null, key = null) {
10
+ if (!node || typeof node !== "object") return;
11
+
12
+ if (
13
+ node.type === "BlockStatement" &&
14
+ node.body.length === 0 &&
15
+ parent?.type !== "CatchClause" &&
16
+ key !== "body"
17
+ ) {
18
+ issues.push({
19
+ start: node.range?.[0],
20
+ end: node.range?.[1],
21
+ message: "Review this empty block; it may indicate missing logic",
22
+ });
23
+ }
24
+
25
+ for (const [childKey, value] of Object.entries(node)) {
26
+ if (Array.isArray(value)) {
27
+ for (const child of value) {
28
+ if (child && typeof child.type === "string") visit(child, node, childKey);
29
+ }
30
+ } else if (value && typeof value.type === "string") {
31
+ visit(value, node, childKey);
32
+ }
33
+ }
34
+ }
35
+
36
+ visit(ast);
37
+ return issues;
38
+ },
39
+ };
40
+
41
+ export default emptyBlock;
@@ -0,0 +1,40 @@
1
+ const emptyCatch = {
2
+ name: "empty-catch",
3
+ description: "Flags catch clauses that silently ignore errors",
4
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
5
+ requiresAst: true,
6
+ check({ ast }) {
7
+ const issues = [];
8
+
9
+ function visit(node) {
10
+ if (!node || typeof node !== "object") return;
11
+
12
+ if (
13
+ node.type === "CatchClause" &&
14
+ node.body?.type === "BlockStatement" &&
15
+ node.body.body.length === 0
16
+ ) {
17
+ issues.push({
18
+ start: node.range?.[0],
19
+ end: node.range?.[1],
20
+ message: "Handle, log, or explicitly document this caught error",
21
+ });
22
+ }
23
+
24
+ for (const value of Object.values(node)) {
25
+ if (Array.isArray(value)) {
26
+ for (const child of value) {
27
+ if (child && typeof child.type === "string") visit(child);
28
+ }
29
+ } else if (value && typeof value.type === "string") {
30
+ visit(value);
31
+ }
32
+ }
33
+ }
34
+
35
+ visit(ast);
36
+ return issues;
37
+ },
38
+ };
39
+
40
+ export default emptyCatch;
@@ -0,0 +1,41 @@
1
+ const SECRET_PATTERNS = [
2
+ {
3
+ pattern: /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|password)\b\s*[:=]\s*["'`][^"'`\s]{8,}["'`]/i,
4
+ label: "credential-like value",
5
+ },
6
+ {
7
+ pattern: /\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/,
8
+ label: "token-like value",
9
+ },
10
+ ];
11
+
12
+ const hardcodedSecret = {
13
+ name: "hardcoded-secret",
14
+ description: "Heuristically flags possible hardcoded credentials or API tokens",
15
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".html", ".css"],
16
+ check({ source }) {
17
+ const issues = [];
18
+ const lines = source.split("\n");
19
+
20
+ let lineStart = 0;
21
+ lines.forEach((text) => {
22
+ for (const item of SECRET_PATTERNS) {
23
+ const match = item.pattern.exec(text);
24
+ if (match) {
25
+ const start = lineStart + match.index;
26
+ issues.push({
27
+ start,
28
+ end: start + match[0].length,
29
+ message: `Possible hardcoded secret (${item.label}); move it to a secure environment variable and rotate it if real`,
30
+ });
31
+ break;
32
+ }
33
+ }
34
+ lineStart += text.length + 1;
35
+ });
36
+
37
+ return issues;
38
+ },
39
+ };
40
+
41
+ export default hardcodedSecret;
@@ -0,0 +1,30 @@
1
+ const todoPattern = /\b(TODO|FIXME)\b/;
2
+
3
+ const leftoverTodo = {
4
+ name: "leftover-todo",
5
+ description: "Flags comments left in code, with TODO and FIXME notes called out separately",
6
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
7
+ requiresAst: true,
8
+ check({ ast, source }) {
9
+ const issues = [];
10
+ for (const comment of ast.comments ?? []) {
11
+ if (comment.range?.[0] === 0 && source?.startsWith("#!")) continue;
12
+ if (comment.value.includes("vhite-disable")) continue;
13
+
14
+ const isTodo = todoPattern.test(comment.value);
15
+ issues.push({
16
+ start: comment.range?.[0],
17
+ end: comment.range?.[1],
18
+ message: isTodo ? "Leftover TODO or FIXME note" : "Code comment left in the file",
19
+ comment: {
20
+ type: comment.type,
21
+ value: comment.value,
22
+ range: comment.range,
23
+ },
24
+ });
25
+ }
26
+ return issues;
27
+ },
28
+ };
29
+
30
+ export default leftoverTodo;
@@ -0,0 +1,69 @@
1
+ import { analyze } from "eslint-scope";
2
+
3
+ const unusedImport = {
4
+ name: "unused-import",
5
+ description: "Flags imported bindings that are never read",
6
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
7
+ requiresAst: true,
8
+ check({ ast }) {
9
+ const issues = [];
10
+ const jsxNames = new Set();
11
+ let hasJsx = false;
12
+
13
+ function collectJsx(node) {
14
+ if (!node || typeof node !== "object") return;
15
+ if (node.type === "JSXIdentifier") jsxNames.add(node.name);
16
+ if (node.type === "JSXOpeningElement" || node.type === "JSXFragment") hasJsx = true;
17
+
18
+ for (const [key, value] of Object.entries(node)) {
19
+ if (["loc", "range", "tokens", "comments"].includes(key)) continue;
20
+ if (Array.isArray(value)) {
21
+ for (const child of value) {
22
+ if (child && typeof child.type === "string") collectJsx(child);
23
+ }
24
+ } else if (value && typeof value.type === "string") {
25
+ collectJsx(value);
26
+ }
27
+ }
28
+ }
29
+
30
+ collectJsx(ast);
31
+
32
+ let scopeManager;
33
+ try {
34
+ scopeManager = analyze(ast, {
35
+ ecmaVersion: 2022,
36
+ sourceType: "module",
37
+ optimistic: true,
38
+ ignoreEval: true,
39
+ });
40
+ } catch {
41
+ return issues;
42
+ }
43
+
44
+ for (const scope of scopeManager.scopes) {
45
+ for (const variable of scope.variables) {
46
+ const isImport = variable.defs?.some((definition) => definition.type === "ImportBinding");
47
+ if (!isImport) continue;
48
+
49
+ const hasRead = variable.references.some((reference) => reference.isRead());
50
+ const usedInJsx = jsxNames.has(variable.name);
51
+ const implicitReact = variable.name === "React" && hasJsx;
52
+
53
+ if (!hasRead && !usedInJsx && !implicitReact) {
54
+ const definition = variable.defs.find((item) => item.type === "ImportBinding");
55
+ const target = definition?.name ?? variable.identifiers[0];
56
+ issues.push({
57
+ start: target?.range?.[0],
58
+ end: target?.range?.[1],
59
+ message: `Imported binding "${variable.name}" is never used`,
60
+ });
61
+ }
62
+ }
63
+ }
64
+
65
+ return issues;
66
+ },
67
+ };
68
+
69
+ export default unusedImport;
package/rules/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import leftoverTodo from "./core/leftover-todo.js";
2
+ import debuggerStatement from "./core/debugger-statement.js";
3
+ import consoleLog from "./core/console-log.js";
4
+ import emptyCatch from "./core/empty-catch.js";
5
+ import unusedImport from "./core/unused-import.js";
6
+ import emptyBlock from "./core/empty-block.js";
7
+ import hardcodedSecret from "./core/hardcoded-secret.js";
8
+ import reactMissingKey from "./react/react-missing-key.js";
9
+ import reactDangerousHtml from "./react/react-dangerous-html.js";
10
+ import a11yImgAlt from "./a11y/a11y-img-alt.js";
11
+ import a11yInputLabel from "./a11y/a11y-input-label.js";
12
+ import a11yClickDiv from "./a11y/a11y-click-div.js";
13
+
14
+ export const rules = [
15
+ leftoverTodo,
16
+ debuggerStatement,
17
+ consoleLog,
18
+ emptyCatch,
19
+ unusedImport,
20
+ emptyBlock,
21
+ hardcodedSecret,
22
+ reactMissingKey,
23
+ reactDangerousHtml,
24
+ a11yImgAlt,
25
+ a11yInputLabel,
26
+ a11yClickDiv,
27
+ ];
@@ -0,0 +1,30 @@
1
+ import { childNodes } from "../shared/jsx-helpers.js";
2
+
3
+ const reactDangerousHtml = {
4
+ name: "react-dangerous-html",
5
+ description: "Flags uses of dangerouslySetInnerHTML, which can allow script injection",
6
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
7
+ requiresAst: true,
8
+ check({ ast }) {
9
+ const issues = [];
10
+
11
+ function visit(node) {
12
+ if (!node || typeof node !== "object") return;
13
+
14
+ if (node.type === "JSXAttribute" && node.name?.name === "dangerouslySetInnerHTML") {
15
+ issues.push({
16
+ start: node.range?.[0],
17
+ end: node.range?.[1],
18
+ message: "dangerouslySetInnerHTML can run injected scripts; render normal JSX or sanitize the HTML first",
19
+ });
20
+ }
21
+
22
+ for (const child of childNodes(node)) visit(child);
23
+ }
24
+
25
+ visit(ast);
26
+ return issues;
27
+ },
28
+ };
29
+
30
+ export default reactDangerousHtml;
@@ -0,0 +1,56 @@
1
+ import { childNodes, attributeNamed, hasSpread } from "../shared/jsx-helpers.js";
2
+
3
+ function returnedJsx(fn) {
4
+ if (fn.body.type === "JSXElement") return fn.body;
5
+ if (fn.body.type !== "BlockStatement") return null;
6
+
7
+ for (const statement of fn.body.body) {
8
+ if (statement.type === "ReturnStatement" && statement.argument?.type === "JSXElement") {
9
+ return statement.argument;
10
+ }
11
+ }
12
+ return null;
13
+ }
14
+
15
+ const reactMissingKey = {
16
+ name: "react-missing-key",
17
+ description: "Flags list items created inside a .map() call without a key prop",
18
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
19
+ requiresAst: true,
20
+ check({ ast }) {
21
+ const issues = [];
22
+
23
+ function visit(node) {
24
+ if (!node || typeof node !== "object") return;
25
+
26
+ const callback = node.arguments?.[0];
27
+ if (
28
+ node.type === "CallExpression" &&
29
+ node.callee?.type === "MemberExpression" &&
30
+ node.callee.property?.name === "map" &&
31
+ callback &&
32
+ ["ArrowFunctionExpression", "FunctionExpression"].includes(callback.type)
33
+ ) {
34
+ const element = returnedJsx(callback);
35
+ if (
36
+ element &&
37
+ !hasSpread(element.openingElement) &&
38
+ !attributeNamed(element.openingElement, "key")
39
+ ) {
40
+ issues.push({
41
+ start: element.range?.[0],
42
+ end: element.range?.[1],
43
+ message: "List item needs a unique key prop so React can track it",
44
+ });
45
+ }
46
+ }
47
+
48
+ for (const child of childNodes(node)) visit(child);
49
+ }
50
+
51
+ visit(ast);
52
+ return issues;
53
+ },
54
+ };
55
+
56
+ export default reactMissingKey;