From 722ce5b28a57e0e1c0f853719d75fed307ba088f Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:23:16 +0100 Subject: [PATCH] feat: expose location data on test results When calling `test(...)` this now returns `{hasMatch, range?}` so consumers can know _where_ in the source something matched. --- src/codemods/array-at.test.ts | 4 ++-- src/codemods/array-at.ts | 10 +++++++--- src/codemods/array-fill.test.ts | 10 +++++----- src/codemods/array-fill.ts | 19 +++++++++++-------- src/codemods/array-includes.test.ts | 8 ++++---- src/codemods/array-includes.ts | 10 +++++++--- src/codemods/array-to-reversed.test.ts | 8 ++++---- src/codemods/array-to-reversed.ts | 10 +++++++--- src/codemods/array-to-sorted.test.ts | 8 ++++---- src/codemods/array-to-sorted.ts | 11 +++++++---- src/codemods/array-to-spliced.test.ts | 10 +++++----- src/codemods/array-to-spliced.ts | 10 +++++----- src/codemods/exponentiation.test.ts | 4 ++-- src/codemods/exponentiation.ts | 10 +++++++--- src/codemods/nullish-coalescing.test.ts | 12 ++++++------ src/codemods/nullish-coalescing.ts | 10 +++++++--- src/codemods/object-hasown.test.ts | 6 +++--- src/codemods/object-hasown.ts | 10 +++++++--- src/codemods/postcss-sign-functions.test.ts | 8 ++++---- src/codemods/postcss-sign-functions.ts | 12 ++++++++---- src/codemods/spread-syntax.test.ts | 10 +++++----- src/codemods/spread-syntax.ts | 17 ++++++++++------- src/codemods/string-includes.test.ts | 8 ++++---- src/codemods/string-includes.ts | 10 +++++++--- src/codemods/url-canparse.test.ts | 6 +++--- src/codemods/url-canparse.ts | 12 ++++++++---- src/shared.ts | 17 ++++++++++++++++- src/typescript-utils.ts | 9 +++++++++ 28 files changed, 174 insertions(+), 105 deletions(-) diff --git a/src/codemods/array-at.test.ts b/src/codemods/array-at.test.ts index 3ad5741..649cf71 100644 --- a/src/codemods/array-at.test.ts +++ b/src/codemods/array-at.test.ts @@ -25,14 +25,14 @@ describe('array-at', () => { const source = ` const lastItem = myArray[myArray.length - 1]; `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when there is no array length - 1 access', () => { const source = ` const firstItem = myArray[0]; `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/array-at.ts b/src/codemods/array-at.ts index 2418574..9c6a003 100644 --- a/src/codemods/array-at.ts +++ b/src/codemods/array-at.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const arrayLengthLastIndexRule: NapiConfig = { rule: { @@ -8,11 +9,14 @@ const arrayLengthLastIndexRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(arrayLengthLastIndexRule); + const node = root.find(arrayLengthLastIndexRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/array-fill.test.ts b/src/codemods/array-fill.test.ts index 9f4847f..d8a25f7 100644 --- a/src/codemods/array-fill.test.ts +++ b/src/codemods/array-fill.test.ts @@ -123,28 +123,28 @@ for (let i = 0; i < 5; i++) { arr.push(1); }`; describe('test', () => { it('should detect Array.from with constant callback', () => { const source = `const arr = Array.from({length: 5}, () => 0);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect spread Array with map', () => { const source = `const arr = [...Array(5)].map(() => 0);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect for loop array filling', () => { const source = `const arr = new Array(5); for (let i = 0; i < arr.length; i++) { arr[i] = 0; }`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect regular array creation', () => { const source = `const arr = [1, 2, 3];`; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect when there are no fill patterns', () => { const source = `const arr = Array.from({length: 5}, (_, i) => i);`; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/array-fill.ts b/src/codemods/array-fill.ts index 845fa7d..e7606cf 100644 --- a/src/codemods/array-fill.ts +++ b/src/codemods/array-fill.ts @@ -5,7 +5,8 @@ import { type SgNode, type NapiConfig } from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const arrayFromRule: NapiConfig = { rule: { @@ -171,16 +172,18 @@ function transformEmptyArrayDeclarations(root: SgNode): Edit[] { } export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return ( - root.has(arrayFromRule) || - root.has(spreadMapRule) || - root.has(arrayDeclarationRule) || - root.has(emptyArrayDeclarationRule) - ); + const node = + root.find(arrayFromRule) ?? + root.find(spreadMapRule) ?? + root.find(arrayDeclarationRule) ?? + root.find(emptyArrayDeclarationRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/array-includes.test.ts b/src/codemods/array-includes.test.ts index dfb25f1..c2820c5 100644 --- a/src/codemods/array-includes.test.ts +++ b/src/codemods/array-includes.test.ts @@ -159,7 +159,7 @@ describe('array-includes', () => { console.log('found'); } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect bitwise NOT patterns', () => { @@ -168,7 +168,7 @@ describe('array-includes', () => { console.log('found'); } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when already using includes', () => { @@ -177,14 +177,14 @@ describe('array-includes', () => { console.log('found'); } `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect plain indexOf calls', () => { const source = ` const index = arr.indexOf(item); `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/array-includes.ts b/src/codemods/array-includes.ts index f2c293e..538538d 100644 --- a/src/codemods/array-includes.ts +++ b/src/codemods/array-includes.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const positiveChecksRule: NapiConfig = { rule: { @@ -32,11 +33,14 @@ const negativeChecksRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(positiveChecksRule) || root.has(negativeChecksRule); + const node = root.find(positiveChecksRule) ?? root.find(negativeChecksRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/array-to-reversed.test.ts b/src/codemods/array-to-reversed.test.ts index ffbd03b..a5e1a00 100644 --- a/src/codemods/array-to-reversed.test.ts +++ b/src/codemods/array-to-reversed.test.ts @@ -43,22 +43,22 @@ describe('array-to-reversed', () => { describe('test', () => { it('should detect slice().reverse() pattern', () => { const source = 'const reversed = arr.slice().reverse();'; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect [...array].reverse() pattern', () => { const source = 'const reversed = [...arr].reverse();'; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect direct reverse() calls', () => { const source = 'const reversed = arr.reverse();'; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect when there is no reverse pattern', () => { const source = 'const arr = [1, 2, 3];'; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/array-to-reversed.ts b/src/codemods/array-to-reversed.ts index 47d5c37..fc689fd 100644 --- a/src/codemods/array-to-reversed.ts +++ b/src/codemods/array-to-reversed.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const arrayToReversedRule: NapiConfig = { rule: { @@ -21,11 +22,14 @@ const arrayToReversedRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(arrayToReversedRule); + const node = root.find(arrayToReversedRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/array-to-sorted.test.ts b/src/codemods/array-to-sorted.test.ts index 8c88eb2..0c23338 100644 --- a/src/codemods/array-to-sorted.test.ts +++ b/src/codemods/array-to-sorted.test.ts @@ -49,22 +49,22 @@ describe('array-to-sorted', () => { describe('test', () => { it('should detect slice().sort() pattern', () => { const source = 'const sorted = arr.slice().sort();'; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect [...arr].sort() pattern', () => { const source = 'const sorted = [...arr].sort();'; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect direct sort() calls', () => { const source = 'const sorted = arr.sort();'; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect when there is no sort pattern', () => { const source = 'const arr = [1, 2, 3];'; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/array-to-sorted.ts b/src/codemods/array-to-sorted.ts index 1d18260..8c83cd1 100644 --- a/src/codemods/array-to-sorted.ts +++ b/src/codemods/array-to-sorted.ts @@ -1,6 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; -import {getNodesSourceText} from '../typescript-utils.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getNodesSourceText, getRangeForNode} from '../typescript-utils.js'; const arrayToSortedRule: NapiConfig = { rule: { @@ -22,11 +22,14 @@ const arrayToSortedRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(arrayToSortedRule); + const node = root.find(arrayToSortedRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/array-to-spliced.test.ts b/src/codemods/array-to-spliced.test.ts index 629992e..fd17f2a 100644 --- a/src/codemods/array-to-spliced.test.ts +++ b/src/codemods/array-to-spliced.test.ts @@ -57,27 +57,27 @@ describe('array-to-spliced', () => { describe('test', () => { it('should detect slice() followed by splice() pattern', () => { const source = `const copy = arr.slice();\ncopy.splice(0, 1);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect spread followed by splice() pattern', () => { const source = `const copy = [...arr];\ncopy.splice(0, 1);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect direct splice() calls', () => { const source = `arr.splice(0, 1);`; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect when variable names do not match', () => { const source = `const copy = arr.slice();\nother.splice(0, 1);`; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect when there is no splice pattern', () => { const source = `const arr = [1, 2, 3];`; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/array-to-spliced.ts b/src/codemods/array-to-spliced.ts index 1fc5f15..73057b4 100644 --- a/src/codemods/array-to-spliced.ts +++ b/src/codemods/array-to-spliced.ts @@ -1,6 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; -import {getNodesSourceText} from '../typescript-utils.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getNodesSourceText, getRangeForNode} from '../typescript-utils.js'; const arrayClonePatterns = [ {pattern: 'const $NAME = $ARRAY.concat();'}, @@ -42,7 +42,7 @@ const createSpliceStatementRule = (name: string): NapiConfig => ({ }); export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); @@ -53,11 +53,11 @@ export const codemod: CodeMod = { const arraySplice = node.getMatch('ARRSPLICE'); if (name && arraySplice && name.text() === arraySplice.text()) { - return true; + return {hasMatch: true, range: getRangeForNode(node)}; } } - return false; + return {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/exponentiation.test.ts b/src/codemods/exponentiation.test.ts index 98818b9..ca84907 100644 --- a/src/codemods/exponentiation.test.ts +++ b/src/codemods/exponentiation.test.ts @@ -44,14 +44,14 @@ describe('exponentiation', () => { const source = ` const result = Math.pow(2, 3); `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when there is no Math.pow', () => { const source = ` const result = x ** y; `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/exponentiation.ts b/src/codemods/exponentiation.ts index 755fb31..fbed0b5 100644 --- a/src/codemods/exponentiation.ts +++ b/src/codemods/exponentiation.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const mathPowRule: NapiConfig = { rule: { @@ -8,11 +9,14 @@ const mathPowRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(mathPowRule); + const node = root.find(mathPowRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { let source = options.source; diff --git a/src/codemods/nullish-coalescing.test.ts b/src/codemods/nullish-coalescing.test.ts index 6db0546..6354ad7 100644 --- a/src/codemods/nullish-coalescing.test.ts +++ b/src/codemods/nullish-coalescing.test.ts @@ -136,42 +136,42 @@ describe('nullish-coalescing', () => { const source = ` const result = value !== null && value !== undefined ? value : defaultValue; `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect ternary with === null || === undefined', () => { const source = ` const result = value === null || value === undefined ? defaultValue : value; `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect loose equality ternary', () => { const source = ` const result = value != null ? value : defaultValue; `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect if statement with nullish assignment', () => { const source = ` if (x === null || x === undefined) { x = 5 } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when already using nullish coalescing', () => { const source = ` const result = value ?? defaultValue; `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect other patterns', () => { const source = ` const result = value !== null ? value : defaultValue; `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/nullish-coalescing.ts b/src/codemods/nullish-coalescing.ts index 164bb92..e8cf0b5 100644 --- a/src/codemods/nullish-coalescing.ts +++ b/src/codemods/nullish-coalescing.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const ternaryPatterns: NapiConfig = { rule: { @@ -31,11 +32,14 @@ const ifNullishAssignment: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(ternaryPatterns) || root.has(ifNullishAssignment); + const node = root.find(ternaryPatterns) ?? root.find(ifNullishAssignment); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/object-hasown.test.ts b/src/codemods/object-hasown.test.ts index 4ef40e0..65a4102 100644 --- a/src/codemods/object-hasown.test.ts +++ b/src/codemods/object-hasown.test.ts @@ -43,21 +43,21 @@ describe('object-hasown', () => { const source = ` const has = obj.hasOwnProperty('key'); `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect Object.prototype.hasOwnProperty.call()', () => { const source = ` const has = Object.prototype.hasOwnProperty.call(obj, 'key'); `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when there is no hasOwnProperty usage', () => { const source = ` const has = Object.hasOwn(obj, 'key'); `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/object-hasown.ts b/src/codemods/object-hasown.ts index b733a02..ce7901a 100644 --- a/src/codemods/object-hasown.ts +++ b/src/codemods/object-hasown.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const hasOwnPropertyRule: NapiConfig = { rule: { @@ -11,11 +12,14 @@ const hasOwnPropertyRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(hasOwnPropertyRule); + const node = root.find(hasOwnPropertyRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/postcss-sign-functions.test.ts b/src/codemods/postcss-sign-functions.test.ts index 334972e..9a17dc3 100644 --- a/src/codemods/postcss-sign-functions.test.ts +++ b/src/codemods/postcss-sign-functions.test.ts @@ -58,14 +58,14 @@ describe('postcss-sign-functions', () => { import signFunctions from '@csstools/postcss-sign-functions'; postcss([signFunctions()]).process(source); `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect postcss-sign-functions require', () => { const source = ` const signFunctions = require('@csstools/postcss-sign-functions'); `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when postcss-sign-functions is not imported', () => { @@ -73,12 +73,12 @@ describe('postcss-sign-functions', () => { import otherPlugin from 'some-other-plugin'; postcss([otherPlugin()]).process(source); `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect in empty code', () => { const source = `const x = 1;`; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/postcss-sign-functions.ts b/src/codemods/postcss-sign-functions.ts index 80aa429..22daa82 100644 --- a/src/codemods/postcss-sign-functions.ts +++ b/src/codemods/postcss-sign-functions.ts @@ -1,18 +1,22 @@ import {parse, Lang, type Edit} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; import { removeDefaultImportedSymbol, - createDefaultImportedSymbolRule + createDefaultImportedSymbolRule, + getRangeForNode } from '../typescript-utils.js'; const IMPORT_PATH = '@csstools/postcss-sign-functions'; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(createDefaultImportedSymbolRule(IMPORT_PATH)); + const node = root.find(createDefaultImportedSymbolRule(IMPORT_PATH)); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/spread-syntax.test.ts b/src/codemods/spread-syntax.test.ts index d705381..59ebd92 100644 --- a/src/codemods/spread-syntax.test.ts +++ b/src/codemods/spread-syntax.test.ts @@ -79,22 +79,22 @@ describe('spread-syntax', () => { describe('test', () => { it('should detect array.concat()', () => { const source = `const result = arr.concat(other);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect Object.assign({}, ...)', () => { const source = `const result = Object.assign({}, obj);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect function.apply(null, args)', () => { const source = `const result = fn.apply(null, args);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect function.apply(undefined, args)', () => { const source = `const result = fn.apply(undefined, args);`; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when no patterns match', () => { @@ -102,7 +102,7 @@ describe('spread-syntax', () => { const arr = [1, 2, 3]; const obj = {a: 1}; `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/spread-syntax.ts b/src/codemods/spread-syntax.ts index 1d9bb63..df15ea0 100644 --- a/src/codemods/spread-syntax.ts +++ b/src/codemods/spread-syntax.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const arrayConcatRule: NapiConfig = { rule: { @@ -27,15 +28,17 @@ const functionApplyRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return ( - root.has(arrayConcatRule) || - root.has(objectAssignRule) || - root.has(functionApplyRule) - ); + const node = + root.find(arrayConcatRule) ?? + root.find(objectAssignRule) ?? + root.find(functionApplyRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/string-includes.test.ts b/src/codemods/string-includes.test.ts index 4c410b0..5bf5bee 100644 --- a/src/codemods/string-includes.test.ts +++ b/src/codemods/string-includes.test.ts @@ -185,7 +185,7 @@ describe('string-includes', () => { console.log('found'); } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect bitwise NOT patterns', () => { @@ -194,7 +194,7 @@ describe('string-includes', () => { console.log('found'); } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when already using includes', () => { @@ -203,14 +203,14 @@ describe('string-includes', () => { console.log('found'); } `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); it('should not detect plain indexOf calls', () => { const source = ` const index = str.indexOf('test'); `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/string-includes.ts b/src/codemods/string-includes.ts index 81fbabb..9e8eb75 100644 --- a/src/codemods/string-includes.ts +++ b/src/codemods/string-includes.ts @@ -1,5 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getRangeForNode} from '../typescript-utils.js'; const positiveChecksRule: NapiConfig = { rule: { @@ -32,11 +33,14 @@ const negativeChecksRule: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(positiveChecksRule) || root.has(negativeChecksRule); + const node = root.find(positiveChecksRule) ?? root.find(negativeChecksRule); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/codemods/url-canparse.test.ts b/src/codemods/url-canparse.test.ts index 2a05872..ab574f6 100644 --- a/src/codemods/url-canparse.test.ts +++ b/src/codemods/url-canparse.test.ts @@ -68,7 +68,7 @@ describe('url-canparse', () => { return false; } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should detect try-catch with body pattern', () => { @@ -80,14 +80,14 @@ describe('url-canparse', () => { console.log('invalid'); } `; - expect(codemod.test({source})).toBe(true); + expect(codemod.test({source}).hasMatch).toBe(true); }); it('should not detect when there is no URL validation pattern', () => { const source = ` const url = new URL('https://example.com'); `; - expect(codemod.test({source})).toBe(false); + expect(codemod.test({source}).hasMatch).toBe(false); }); }); }); diff --git a/src/codemods/url-canparse.ts b/src/codemods/url-canparse.ts index 01f7384..d7d79f5 100644 --- a/src/codemods/url-canparse.ts +++ b/src/codemods/url-canparse.ts @@ -1,6 +1,6 @@ import {parse, Lang, type Edit, type NapiConfig} from '@ast-grep/napi'; -import type {Options, CodeMod} from '../shared.js'; -import {getNodesSourceText} from '../typescript-utils.js'; +import type {Options, CodeMod, TestResult} from '../shared.js'; +import {getNodesSourceText, getRangeForNode} from '../typescript-utils.js'; // Pattern: try { new URL(u); return true; } catch { return false; } const simpleBooleanReturnPattern: NapiConfig = { @@ -27,11 +27,15 @@ const tryWithBodyPattern: NapiConfig = { }; export const codemod: CodeMod = { - test(options: Options): boolean { + test(options: Options): TestResult { const ast = parse(Lang.TypeScript, options.source); const root = ast.root(); - return root.has(simpleBooleanReturnPattern) || root.has(tryWithBodyPattern); + const node = + root.find(simpleBooleanReturnPattern) ?? root.find(tryWithBodyPattern); + return node + ? {hasMatch: true, range: getRangeForNode(node)} + : {hasMatch: false}; }, apply(options: Options): string { const ast = parse(Lang.TypeScript, options.source); diff --git a/src/shared.ts b/src/shared.ts index a804295..9c97f51 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -2,7 +2,22 @@ export interface Options { source: string; } +export interface Position { + line: number; + column: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface TestResult { + hasMatch: boolean; + range?: Range; +} + export interface CodeMod { apply(options: Options): string; - test(options: Options): boolean; + test(options: Options): TestResult; } diff --git a/src/typescript-utils.ts b/src/typescript-utils.ts index a361d8d..94344e8 100644 --- a/src/typescript-utils.ts +++ b/src/typescript-utils.ts @@ -1,4 +1,13 @@ import type {Edit, SgNode, Rule, NapiConfig} from '@ast-grep/napi'; +import type {Range} from './shared.js'; + +export function getRangeForNode(node: SgNode): Range { + const range = node.range(); + return { + start: {line: range.start.line, column: range.start.column}, + end: {line: range.end.line, column: range.end.column} + }; +} export function getNodesSourceText(source: string, nodes: SgNode[]): string { if (nodes.length === 0) {