From cbdf08e394d8ff3b8852ea349c853f711c05505d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 12 Oct 2026 18:00:20 +0900 Subject: [PATCH 1/6] fix(cli): tighten filename check heuristics for Dart generators and framework-reserved names Exempt Dart files only when the last stem segment names a known generator (g, freezed, mocks, gr, pb, ...), so a handwritten dotted name is judged. Treat Route/GET/loader/action etc. as framework-reserved only under routes/ or pages/ directories. Refs #37 --- packages/cli/README.md | 9 ++++--- packages/cli/src/check/check-filenames.ts | 20 ++++++++++----- .../cli/test/check/check-filenames.test.ts | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 3a4c35a..f58c3f3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -176,7 +176,7 @@ their exports cannot be enumerated): | TypeScript, TSX | `export default …`, `export { x as default }`, `export { default }` | `default export` | | TypeScript, TSX | `export const { a } = obj`, `export const [a] = arr` | `destructured export` | | TypeScript, TSX | `export { x as "string name" }` | `string-literal export name` | -| TypeScript, TSX | a file whose only export is a framework-reserved name: `Route` (TanStack Router), `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/`OPTIONS`/`ALL` (route handlers), `loader`, `action` | `framework-reserved export` | +| TypeScript, TSX | a file under a `routes/` or `pages/` directory whose only export is a framework-reserved name: `Route` (TanStack Router), `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/`OPTIONS`/`ALL` (route handlers), `loader`, `action`. Elsewhere these names are judged like any other export | `framework-reserved export` | | Dart | `export 'other.dart';` | `re-export` | | Dart | `part 'other.dart';` (generated `*.g.dart`/`*.freezed.dart` parts excluded; any other suffix such as `*.custom.dart` counts) | `library with parts` | @@ -202,8 +202,11 @@ Exempt **by convention** (no finding, no notice): `__mocks__/` directory, and anything under the package's test root (`test/`, `tests/`). - Dart: `part of` files, files under `integration_test/`, `test_driver/`, `bin/`, `tool/`, `example/`, `web/` or the test root, `*_test.dart`, - `errors.dart`, generated files whose name has an extra dot (`*.g.dart`, - `*.freezed.dart`), and files whose first line is a generated-code header + `errors.dart`, generator output named by its suffix (`*.g.dart`, + `*.freezed.dart`, `*.mocks.dart`, `*.gr.dart`, `*.config.dart`, + `*.gen.dart`, `*.chopper.dart`, `*.mapper.dart`, `*.gql.dart`, + `*.graphql.dart`, `*.pb.dart`, `*.pbenum.dart`, `*.pbjson.dart`, + `*.pbserver.dart`, `*.pbgrpc.dart`), and files whose first line is a generated-code header (`// GENERATED CODE` or `// File generated by`, as in `firebase_options.dart`). Names are compared by a separator-free key: the name is split into words at diff --git a/packages/cli/src/check/check-filenames.ts b/packages/cli/src/check/check-filenames.ts index 5d4d3fc..9daa54a 100644 --- a/packages/cli/src/check/check-filenames.ts +++ b/packages/cli/src/check/check-filenames.ts @@ -39,8 +39,8 @@ export interface FilenameLanguage { nameRules: string[] /** Rule ids whose presence means the file's exports cannot be enumerated; such files are skipped. */ opaqueRules?: string[] - /** Export names a framework dictates (a file whose only export is one is withheld, not judged). */ - reservedNames?: ReadonlySet + /** Whether a framework dictates the export `name` in `file` (a file whose only export is one is withheld, not judged). */ + isReserved?: (name: string, file: string) => boolean normalize: (name: string) => string isPublic: (name: string) => boolean /** File name without extension, or `null` when the file is exempt. */ @@ -63,6 +63,14 @@ function underTestRoot(file: string, unit: Unit | undefined): boolean { /** Names a router or runtime framework requires a file to export (TanStack Router `Route`, HTTP verb handlers, Remix `loader`/`action`). */ const FRAMEWORK_RESERVED = new Set(['Route', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL', 'loader', 'action']) +/** Directories a file-based router maps to URLs: `routes/` (TanStack Router, Remix, SolidStart) and `pages/` (Next.js API routes, Astro endpoints). */ +function inRouteDir(file: string): boolean { + return file.split('/').slice(0, -1).some(seg => seg === 'routes' || seg === 'pages') +} + +/** Last stem segment of a Dart code generator's output (`*.g.dart`, `*.freezed.dart`, Mockito `*.mocks.dart`, auto_route `*.gr.dart`, protobuf `*.pb*.dart`, …). */ +const DART_GENERATOR_SUFFIX = /\.(?:g|freezed|mocks|gr|config|gen|chopper|mapper|gql|graphql|pb|pbenum|pbjson|pbserver|pbgrpc)$/ + const GENERATED_HEADER = /^\/\/\s*(?:GENERATED CODE|File generated by)/ /** Dart files whose first line is a generated-code header; their name is dictated by the generator. */ @@ -97,7 +105,7 @@ export function typescriptFilenames(units: Unit[]): FilenameLanguage { language: 'typescript', nameRules: ['export-name'], opaqueRules: ['export-destructure', 'export-opaque'], - reservedNames: FRAMEWORK_RESERVED, + isReserved: (name, file) => FRAMEWORK_RESERVED.has(name) && inRouteDir(file), normalize: toKebabCase, isPublic: () => true, units, @@ -137,8 +145,8 @@ export function dartFilenames(units: Unit[], partFiles: Set): FilenameLa return null } const stem = name.slice(0, -'.dart'.length) - // `_test.dart`, the error file, and generated `*.g.dart`/`*.freezed.dart`. - if (stem.endsWith('_test') || stem === 'errors' || stem.includes('.')) { + // `_test.dart`, the error file, and generator output named by the generator. + if (stem.endsWith('_test') || stem === 'errors' || DART_GENERATOR_SUFFIX.test(stem)) { return null } return stem @@ -184,7 +192,7 @@ export function inspectFilenames(matches: ExtractMatch[], lang: FilenameLanguage opaque.set(m.file, (opaque.get(m.file) ?? new Set()).add('re-export')) continue } - if (lang.reservedNames?.has(m.text) === true) { + if (lang.isReserved?.(m.text, m.file) === true) { opaque.set(m.file, (opaque.get(m.file) ?? new Set()).add('framework-reserved export')) } const names = byFile.get(m.file) ?? new Map() diff --git a/packages/cli/test/check/check-filenames.test.ts b/packages/cli/test/check/check-filenames.test.ts index c94c33d..c450b5b 100644 --- a/packages/cli/test/check/check-filenames.test.ts +++ b/packages/cli/test/check/check-filenames.test.ts @@ -100,6 +100,18 @@ describe('code-filename-matches-primary-symbol (TypeScript)', () => { expect(result.notices).toEqual(['TypeScript: filename check skipped 3 file(s) whose exports cannot be enumerated (framework-reserved export): src/pages/api/users.ts, src/routes/other.ts, src/routes/posts.tsx']) }) + test('a framework-reserved name outside routes/ and pages/ is judged like any other export', () => { + const findings = findingsFor({ + ...PKG, + 'src/server/handlers.ts': 'export const loader = () => 1', + 'src/http/client.ts': 'export function GET() {}', + }, SLUG) + expect(findings.map(f => f.message)).toEqual([ + expect.stringContaining('Rename the file to `get.ts`'), + expect.stringContaining('Rename the file to `loader.ts`'), + ]) + }) + test('a decorated `export default class` is labelled a default export', () => { const result = checkFixture({ ...PKG, 'src/loader.ts': '@Injectable()\nexport default class Foo {}' }) expect(result.notices.join('\n')).toContain('(default export)') @@ -260,6 +272,19 @@ describe('code-filename-matches-primary-symbol (Dart)', () => { }, SLUG)).toEqual([]) }) + test('generator output is exempt by suffix, but a handwritten dotted name is judged', () => { + const findings = findingsFor({ + ...PUBSPEC, + 'lib/user_repository.mocks.dart': '// Mocks generated by Mockito 5.4.4 from annotations\nclass MockUserRepository {}', + 'lib/app_router.gr.dart': 'class HomeRoute {}', + 'lib/user.pb.dart': '// Generated code. Do not modify.\nclass User {}', + 'lib/user.pbenum.dart': 'class Role {}', + 'lib/user.model.dart': 'class UserModel {}', + 'lib/widgets.custom.dart': 'class Button {}', + }, SLUG) + expect(findings.map(f => f.file)).toEqual(['lib/widgets.custom.dart']) + }) + test('a library that re-exports others has an unknown symbol set, so it is not checked', () => { expect(findingsFor({ ...PUBSPEC, From 69e8d1a0819933ce45c7a43e43bc9b5aabfd0650 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 12 Oct 2026 18:00:35 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(ast-grep-config):=20recognize=20#[cfg?= =?UTF-8?q?=5Fattr(=E2=80=A6,=20test)]=20as=20a=20Rust=20test=20attribute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #37 --- packages/ast-grep-config/extract/rust.yml | 21 ++++++++++++++++++++- packages/cli/README.ko.md | 3 ++- packages/cli/README.md | 3 ++- packages/cli/test/check/check-rust.test.ts | 11 +++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/ast-grep-config/extract/rust.yml b/packages/ast-grep-config/extract/rust.yml index f9de8f1..3149144 100644 --- a/packages/ast-grep-config/extract/rust.yml +++ b/packages/ast-grep-config/extract/rust.yml @@ -187,10 +187,29 @@ rule: kind: token_tree stopBy: end --- +# A test function attribute: `#[test]`, `#[tokio::test(…)]`, or one applied +# conditionally, `#[cfg_attr(, test)]`. In the conditional form the +# `test` must be a top-level item after a comma (optionally path-qualified), so +# a `test` inside the predicate (`cfg_attr(all(unix, test), ignore)`) or as the +# predicate itself (`cfg_attr(test, derive(Debug))`) does not count. id: test-attr language: rust severity: hint message: test function attribute rule: kind: attribute_item - regex: ^#\[\s*(\w+::)*test\s*(\(|\]) + any: + - regex: ^#\[\s*(\w+::)*test\s*(\(|\]) + - has: + kind: attribute + all: + - has: + kind: identifier + regex: ^cfg_attr$ + - has: + kind: token_tree + has: + kind: identifier + regex: ^test$ + follows: + regex: ^(,|::)$ diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index af68a3f..90a878f 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -128,7 +128,8 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 하나라도 있 `cargo metadata --no-deps --format-version 1`을 읽고 `mod` 선언을 따라가, Cargo가 컴파일하지 않는 `tests/**/*.rs` 파일(`tests/common/`과 `tests//main.rs` 대상의 하위 모듈은 제외)과 어떤 `mod` 선언도 닿지 - 않는 `#[test]` 파일을 보고합니다. `cargo`가 없으면 알림을 남기고 Rust를 + 않는 `#[test]` 파일(`#[cfg_attr(…, test)]`로 붙인 테스트 속성 포함)을 + 보고합니다. `cargo`가 없으면 알림을 남기고 Rust를 건너뜁니다. - **`test-helpers-in-dedicated-location`**: 지정 위치 밖에서 선언된 헬퍼 후보(`mock*`, `createMock*`, `fake*`, `stub*`)는 둘 이상의 테스트 파일이 diff --git a/packages/cli/README.md b/packages/cli/README.md index f58c3f3..c24d766 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -132,7 +132,8 @@ What it checks: `cargo metadata --no-deps --format-version 1` and follows `mod` declarations, then reports `tests/**/*.rs` files Cargo never compiles (except `tests/common/` and submodules of a `tests//main.rs` - target), and `#[test]` files no `mod` declaration reaches. Without + target), and `#[test]` files no `mod` declaration reaches (a test + attribute applied with `#[cfg_attr(…, test)]` counts too). Without `cargo`, Rust is skipped with a notice. - Files come from `git ls-files` (tracked plus untracked-not-ignored) or, outside a git work tree, a directory walk (which also skips build and diff --git a/packages/cli/test/check/check-rust.test.ts b/packages/cli/test/check/check-rust.test.ts index a455795..f6fa59a 100644 --- a/packages/cli/test/check/check-rust.test.ts +++ b/packages/cli/test/check/check-rust.test.ts @@ -217,6 +217,17 @@ describe('rust unit-test split files', () => { expect(kinds(result)).toEqual(['unreachable-unit-test src/parser_tests.rs']) }) + test('a conditionally applied test attribute marks a test file; `test` in the predicate does not', () => { + const result = checkFixture({ + ...CARGO, + 'src/lib.rs': '', + 'src/miri_tests.rs': '#[cfg_attr(not(miri), test)]\nfn parses() {}', + 'src/async_tests.rs': '#[cfg_attr(feature = "rt", tokio::test(flavor = "multi_thread"))]\nasync fn runs() {}', + 'src/model.rs': '#[cfg_attr(test, derive(Debug))]\npub struct Model;\n#[cfg_attr(all(unix, test), ignore)]\nfn helper() {}', + }, stub([['lib', 'src/lib.rs']])) + expect(kinds(result)).toEqual(['unreachable-unit-test src/async_tests.rs', 'unreachable-unit-test src/miri_tests.rs']) + }) + test('a #[test] file declared inside an inline module is not reported', () => { const result = checkFixture({ ...CARGO, From 06deb0c97c8da10eface3557f5514dfcc80d0795 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 12 Oct 2026 18:01:04 +0900 Subject: [PATCH 3/6] fix(ast-grep-config): ignore Kotlin Multiplatform test source sets in error-file rule The kotlin-error-outside-errors-file rule now ignores Kotlin Multiplatform test source sets. Refs #37 --- .../rules/kotlin/kotlin-error-outside-errors-file.md | 6 ++++-- .../rules/kotlin/kotlin-error-outside-errors-file.yml | 4 +++- packages/ast-grep-config/test/e2e/sgconfig.test.ts | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.md b/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.md index 4e88b2d..9e592b7 100644 --- a/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.md +++ b/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.md @@ -67,5 +67,7 @@ class InvoiceService { /* … */ } ## Exempt files -`Errors.kt` and test source sets (`src/test/`, `src/testFixtures/`, -`src/androidTest/`) are ignored. +`Errors.kt` and test source sets are ignored: `src/test/`, `src/testFixtures/`, +and every source set whose name ends in `Test` (Android `src/androidTest/` and +`src/androidUnitTest/`, Kotlin Multiplatform `src/commonTest/`, `src/jvmTest/`, +`src/iosTest/`, `src/jsTest/`, …). diff --git a/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.yml b/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.yml index 35df160..17cbb41 100644 --- a/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.yml +++ b/packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.yml @@ -38,4 +38,6 @@ ignores: - '**/Errors.kt' - '**/src/test/**' - '**/src/testFixtures/**' - - '**/src/androidTest/**' + # Android and Kotlin Multiplatform test source sets: androidTest, + # androidUnitTest, commonTest, jvmTest, iosTest, jsTest, … + - '**/src/*Test/**' diff --git a/packages/ast-grep-config/test/e2e/sgconfig.test.ts b/packages/ast-grep-config/test/e2e/sgconfig.test.ts index 38da0bf..9ce9273 100644 --- a/packages/ast-grep-config/test/e2e/sgconfig.test.ts +++ b/packages/ast-grep-config/test/e2e/sgconfig.test.ts @@ -70,6 +70,10 @@ const PROJECT = { 'lib/auth/token_store.dart': 'class InvalidToken implements Exception {}', 'src/main/kotlin/com/acme/Errors.kt': 'class A : RuntimeException()', 'src/main/kotlin/com/acme/Billing.kt': 'class B : RuntimeException()', + 'shared/src/commonMain/kotlin/com/acme/Sync.kt': 'class C : RuntimeException()', + 'shared/src/commonTest/kotlin/com/acme/Fakes.kt': 'class D : RuntimeException()', + 'shared/src/iosTest/kotlin/com/acme/Fakes.kt': 'class E : RuntimeException()', + 'app/src/androidUnitTest/kotlin/com/acme/Fakes.kt': 'class F : RuntimeException()', 'src/main/java/com/acme/error/AException.java': 'public class AException extends RuntimeException {}', 'src/androidTest/java/com/acme/CException.java': 'public class CException extends RuntimeException {}', 'src/main/java/com/acme/BException.java': 'public class BException extends RuntimeException {}', @@ -84,6 +88,7 @@ const EXPECTED = [ 'lib/auth/token_store.dart:1 dart-error-outside-errors-file', 'packages/core/plugins/loader.ts:1 ts-no-default-export', 'pages/about.ts:1 ts-no-default-export', + 'shared/src/commonMain/kotlin/com/acme/Sync.kt:1 kotlin-error-outside-errors-file', 'src/card.tsx:1 tsx-no-default-export', 'src/main/java/com/acme/BException.java:1 java-error-outside-error-package', 'src/main/kotlin/com/acme/Billing.kt:1 kotlin-error-outside-errors-file', From bfa952a91b31ac2e71a2ca5d77793b9aaf235a21 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 12 Oct 2026 18:01:09 +0900 Subject: [PATCH 4/6] fix(cli): skip sgconfig.yml under Yarn Plug'n'Play with a warning Refs #37 --- packages/cli/README.ko.md | 6 ++++++ packages/cli/README.md | 6 ++++++ packages/cli/src/appliers.ts | 12 +++++++++++- packages/cli/src/i18n.ts | 3 +++ packages/cli/test/appliers.test.ts | 9 ++++++++- 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index 90a878f..a189a3b 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -63,6 +63,12 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 있으면 exit 1 ( 참고하세요. `please-style check`의 발견 사항은 slug를 보여 주며, 각 검사는 [`please-style check`](#please-style-check)에 설명되어 있습니다. +Yarn Plug'n'Play 프로젝트(`.pnp.cjs` 파일이 있는 경우)에서는 `init`이 +`sgconfig.yml`을 작성하지 않고 경고만 출력합니다. ast-grep은 디렉터리에서 +규칙을 읽는데, Plug'n'Play는 패키지를 `node_modules` 없이 zip 아카이브로 +보관하기 때문입니다. 규칙을 쓰려면 `.yarnrc.yml`에 +`nodeLinker: node-modules`를 설정하세요. + ## `please-style check` `check`는 diff --git a/packages/cli/README.md b/packages/cli/README.md index c24d766..faf37b3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -63,6 +63,12 @@ with the fix. See the structural rules. `please-style check` findings name their slug; each check is described under [`please-style check`](#please-style-check). +Under Yarn Plug'n'Play (a `.pnp.cjs` file in the project), `init` does not +write `sgconfig.yml` and prints a warning instead: ast-grep reads rules from a +directory, and Plug'n'Play keeps packages in zip archives without +`node_modules`. Set `nodeLinker: node-modules` in `.yarnrc.yml` to use the +rules. + ## `please-style check` `check` is the layer-3 path checker of diff --git a/packages/cli/src/appliers.ts b/packages/cli/src/appliers.ts index b88b91f..5019b9a 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -1,7 +1,7 @@ import type { Tool, ToolApplyResult, ToolContext } from './tools.js' import { copyFileSync, existsSync, writeFileSync } from 'node:fs' import { resolve as joinPath } from 'node:path' -import { confirm, isCancel } from '@clack/prompts' +import { confirm, isCancel, log } from '@clack/prompts' import { isPackageInstalled, readFileOrNull, @@ -228,10 +228,20 @@ function listsAstGrepRules(yaml: string): boolean { return entries.some(e => normalizeRuleDir(e) === AST_GREP_RULES) } +/** Yarn Plug'n'Play installs packages as zip archives without `node_modules`, so the rules path above does not exist. */ +function usesPlugNPlay(cwd: string): boolean { + return existsSync(joinPath(cwd, '.pnp.cjs')) || existsSync(joinPath(cwd, '.pnp.js')) +} + export async function applyAstGrep(ctx: ToolContext): Promise { const result: ToolApplyResult = { created: [], updated: [], skipped: [] } const target = joinPath(ctx.cwd, 'sgconfig.yml') const label = 'sgconfig.yml' + if (usesPlugNPlay(ctx.cwd)) { + log.warn(t('astGrepPlugNPlay')) + result.skipped.push(label) + return result + } const existing = readFileOrNull(target) if (existing == null) { diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 7ec39e6..4a322bf 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -17,6 +17,7 @@ interface Messages { created: (file: string) => string updated: (file: string) => string agentsBlockUpdated: string + astGrepPlugNPlay: string doctorHeading: string doctorOk: string doctorMissing: string @@ -45,6 +46,7 @@ const messages: Record = { created: file => `Created ${file}`, updated: file => `Updated ${file}`, agentsBlockUpdated: 'Refreshed AGENTS.md pleaseai-code-style block.', + astGrepPlugNPlay: 'Yarn Plug\'n\'Play has no node_modules, so ast-grep cannot read the rules from node_modules/@pleaseai/ast-grep-config. Set `nodeLinker: node-modules` in .yarnrc.yml, run yarn install, and run init again.', doctorHeading: 'Project status', doctorOk: 'OK', doctorMissing: 'missing', @@ -99,6 +101,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e created: file => `${file} 생성됨`, updated: file => `${file} 업데이트됨`, agentsBlockUpdated: 'AGENTS.md의 pleaseai-code-style 블록을 갱신했습니다.', + astGrepPlugNPlay: 'Yarn Plug\'n\'Play는 node_modules를 만들지 않아 ast-grep이 node_modules/@pleaseai/ast-grep-config의 규칙을 읽을 수 없습니다. .yarnrc.yml에 `nodeLinker: node-modules`를 설정하고 yarn install 후 init을 다시 실행하세요.', doctorHeading: '프로젝트 상태', doctorOk: '정상', doctorMissing: '없음', diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index 5207b9f..c26d2be 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' @@ -61,6 +61,13 @@ describe('ast-grep tool', () => { expect(readFileSync(join(dir, 'sgconfig.yml'), 'utf-8')).toContain('ruleDirs:\n - node_modules/@pleaseai/ast-grep-config/rules\n') }) + test('does not write sgconfig.yml under Yarn Plug\'n\'Play, where node_modules does not exist', async () => { + writeFileSync(join(dir, '.pnp.cjs'), '') + const result = await applyAstGrep({ cwd: dir, autoAccept: true }) + expect(result).toEqual({ created: [], updated: [], skipped: ['sgconfig.yml'] }) + expect(existsSync(join(dir, 'sgconfig.yml'))).toBe(false) + }) + test('overwrites a different sgconfig.yml when overwrites are accepted', async () => { writeFileSync(join(dir, 'sgconfig.yml'), 'ruleDirs:\n - rules\n') const result = await applyAstGrep({ cwd: dir, autoAccept: true }) From 2b364b6d2eb3bd82831fe4d5f10b0ba3e8a4890d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 12 Oct 2026 18:12:06 +0900 Subject: [PATCH 5/6] chore: apply AI code review suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect Yarn Plug'n'Play from a workspace package (.pnp.cjs at an enclosing root), unless the project has its own installed rules directory - Do not treat cfg_attr(…, test::helper) as a Rust test attribute - Document .pnp.js and the yarn install + init rerun step Refs #37 --- packages/ast-grep-config/extract/rust.yml | 7 ++++++- packages/cli/README.ko.md | 11 ++++++----- packages/cli/README.md | 11 ++++++----- packages/cli/src/appliers.ts | 20 +++++++++++++++++--- packages/cli/test/appliers.test.ts | 19 ++++++++++++++++++- packages/cli/test/check/check-rust.test.ts | 2 +- 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/ast-grep-config/extract/rust.yml b/packages/ast-grep-config/extract/rust.yml index 3149144..10dade5 100644 --- a/packages/ast-grep-config/extract/rust.yml +++ b/packages/ast-grep-config/extract/rust.yml @@ -189,7 +189,8 @@ rule: --- # A test function attribute: `#[test]`, `#[tokio::test(…)]`, or one applied # conditionally, `#[cfg_attr(, test)]`. In the conditional form the -# `test` must be a top-level item after a comma (optionally path-qualified), so +# `test` must be a top-level item after a comma, optionally path-qualified and +# never followed by `::` (`test::helper` is another attribute), so # a `test` inside the predicate (`cfg_attr(all(unix, test), ignore)`) or as the # predicate itself (`cfg_attr(test, derive(Debug))`) does not count. id: test-attr @@ -213,3 +214,7 @@ rule: regex: ^test$ follows: regex: ^(,|::)$ + # `test::helper` names a different attribute. + not: + precedes: + regex: ^::$ diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index a189a3b..fd5c882 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -63,11 +63,12 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 있으면 exit 1 ( 참고하세요. `please-style check`의 발견 사항은 slug를 보여 주며, 각 검사는 [`please-style check`](#please-style-check)에 설명되어 있습니다. -Yarn Plug'n'Play 프로젝트(`.pnp.cjs` 파일이 있는 경우)에서는 `init`이 -`sgconfig.yml`을 작성하지 않고 경고만 출력합니다. ast-grep은 디렉터리에서 -규칙을 읽는데, Plug'n'Play는 패키지를 `node_modules` 없이 zip 아카이브로 -보관하기 때문입니다. 규칙을 쓰려면 `.yarnrc.yml`에 -`nodeLinker: node-modules`를 설정하세요. +Yarn Plug'n'Play 프로젝트(프로젝트나 상위 워크스페이스 루트에 `.pnp.cjs` 또는 +`.pnp.js` 파일이 있는 경우)에서는 `init`이 `sgconfig.yml`을 작성하지 않고 +경고만 출력합니다. ast-grep은 디렉터리에서 규칙을 읽는데, Plug'n'Play는 +패키지를 `node_modules` 없이 zip 아카이브로 보관하기 때문입니다. 규칙을 +쓰려면 `.yarnrc.yml`에 `nodeLinker: node-modules`를 설정하고 `yarn install`을 +실행한 뒤 `init`을 다시 실행하세요. ## `please-style check` diff --git a/packages/cli/README.md b/packages/cli/README.md index faf37b3..9a781ef 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -63,11 +63,12 @@ with the fix. See the structural rules. `please-style check` findings name their slug; each check is described under [`please-style check`](#please-style-check). -Under Yarn Plug'n'Play (a `.pnp.cjs` file in the project), `init` does not -write `sgconfig.yml` and prints a warning instead: ast-grep reads rules from a -directory, and Plug'n'Play keeps packages in zip archives without -`node_modules`. Set `nodeLinker: node-modules` in `.yarnrc.yml` to use the -rules. +Under Yarn Plug'n'Play (a `.pnp.cjs` or `.pnp.js` file in the project or an +enclosing workspace root), `init` does not write `sgconfig.yml` and prints a +warning instead: ast-grep reads rules from a directory, and Plug'n'Play keeps +packages in zip archives without `node_modules`. To use the rules, set +`nodeLinker: node-modules` in `.yarnrc.yml`, run `yarn install`, and run +`init` again. ## `please-style check` diff --git a/packages/cli/src/appliers.ts b/packages/cli/src/appliers.ts index 5019b9a..33f196e 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -1,6 +1,6 @@ import type { Tool, ToolApplyResult, ToolContext } from './tools.js' import { copyFileSync, existsSync, writeFileSync } from 'node:fs' -import { resolve as joinPath } from 'node:path' +import { dirname, resolve as joinPath } from 'node:path' import { confirm, isCancel, log } from '@clack/prompts' import { isPackageInstalled, @@ -228,9 +228,23 @@ function listsAstGrepRules(yaml: string): boolean { return entries.some(e => normalizeRuleDir(e) === AST_GREP_RULES) } -/** Yarn Plug'n'Play installs packages as zip archives without `node_modules`, so the rules path above does not exist. */ +/** + * Yarn Plug'n'Play installs packages as zip archives without `node_modules`, so the rules path above does not exist. + * A workspace package's `.pnp.cjs` lives at the workspace root, so the enclosing directories are searched too; + * an installed rules directory means this project is not under Plug'n'Play, whatever an ancestor holds. + */ function usesPlugNPlay(cwd: string): boolean { - return existsSync(joinPath(cwd, '.pnp.cjs')) || existsSync(joinPath(cwd, '.pnp.js')) + if (existsSync(joinPath(cwd, AST_GREP_RULES))) { + return false + } + for (let dir = joinPath(cwd); ; dir = dirname(dir)) { + if (existsSync(joinPath(dir, '.pnp.cjs')) || existsSync(joinPath(dir, '.pnp.js'))) { + return true + } + if (dirname(dir) === dir) { + return false + } + } } export async function applyAstGrep(ctx: ToolContext): Promise { diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index c26d2be..77a6191 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' @@ -68,6 +68,23 @@ describe('ast-grep tool', () => { expect(existsSync(join(dir, 'sgconfig.yml'))).toBe(false) }) + test('detects Plug\'n\'Play from a workspace package whose .pnp.cjs is at the workspace root', async () => { + writeFileSync(join(dir, '.pnp.cjs'), '') + const pkg = join(dir, 'packages', 'app') + mkdirSync(pkg, { recursive: true }) + const result = await applyAstGrep({ cwd: pkg, autoAccept: true }) + expect(result.skipped).toEqual(['sgconfig.yml']) + expect(existsSync(join(pkg, 'sgconfig.yml'))).toBe(false) + }) + + test('writes sgconfig.yml for a node_modules project nested under a Plug\'n\'Play directory', async () => { + writeFileSync(join(dir, '.pnp.cjs'), '') + const project = join(dir, 'examples', 'app') + mkdirSync(join(project, 'node_modules', '@pleaseai', 'ast-grep-config', 'rules'), { recursive: true }) + const result = await applyAstGrep({ cwd: project, autoAccept: true }) + expect(result.created).toEqual(['sgconfig.yml']) + }) + test('overwrites a different sgconfig.yml when overwrites are accepted', async () => { writeFileSync(join(dir, 'sgconfig.yml'), 'ruleDirs:\n - rules\n') const result = await applyAstGrep({ cwd: dir, autoAccept: true }) diff --git a/packages/cli/test/check/check-rust.test.ts b/packages/cli/test/check/check-rust.test.ts index f6fa59a..bd7334c 100644 --- a/packages/cli/test/check/check-rust.test.ts +++ b/packages/cli/test/check/check-rust.test.ts @@ -223,7 +223,7 @@ describe('rust unit-test split files', () => { 'src/lib.rs': '', 'src/miri_tests.rs': '#[cfg_attr(not(miri), test)]\nfn parses() {}', 'src/async_tests.rs': '#[cfg_attr(feature = "rt", tokio::test(flavor = "multi_thread"))]\nasync fn runs() {}', - 'src/model.rs': '#[cfg_attr(test, derive(Debug))]\npub struct Model;\n#[cfg_attr(all(unix, test), ignore)]\nfn helper() {}', + 'src/model.rs': '#[cfg_attr(test, derive(Debug))]\npub struct Model;\n#[cfg_attr(all(unix, test), ignore)]\nfn helper() {}\n#[cfg_attr(feature = "x", test::helper)]\nfn other() {}', }, stub([['lib', 'src/lib.rs']])) expect(kinds(result)).toEqual(['unreachable-unit-test src/async_tests.rs', 'unreachable-unit-test src/miri_tests.rs']) }) From 495764c1329f528f7ec2770c317b04466ec2be9d Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Mon, 12 Oct 2026 18:15:24 +0900 Subject: [PATCH 6/6] chore: apply AI code review suggestions Document that an installed rules directory overrides an enclosing Plug'n'Play root. Refs #37 --- packages/cli/README.ko.md | 4 +++- packages/cli/README.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index fd5c882..ac456fa 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -68,7 +68,9 @@ Yarn Plug'n'Play 프로젝트(프로젝트나 상위 워크스페이스 루트 경고만 출력합니다. ast-grep은 디렉터리에서 규칙을 읽는데, Plug'n'Play는 패키지를 `node_modules` 없이 zip 아카이브로 보관하기 때문입니다. 규칙을 쓰려면 `.yarnrc.yml`에 `nodeLinker: node-modules`를 설정하고 `yarn install`을 -실행한 뒤 `init`을 다시 실행하세요. +실행한 뒤 `init`을 다시 실행하세요. 프로젝트 자체에 +`node_modules/@pleaseai/ast-grep-config/rules`가 설치되어 있으면 상위 디렉터리가 +Plug'n'Play여도 `sgconfig.yml`을 작성합니다. ## `please-style check` diff --git a/packages/cli/README.md b/packages/cli/README.md index 9a781ef..a439419 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -68,7 +68,9 @@ enclosing workspace root), `init` does not write `sgconfig.yml` and prints a warning instead: ast-grep reads rules from a directory, and Plug'n'Play keeps packages in zip archives without `node_modules`. To use the rules, set `nodeLinker: node-modules` in `.yarnrc.yml`, run `yarn install`, and run -`init` again. +`init` again. A project with its own installed +`node_modules/@pleaseai/ast-grep-config/rules` still gets `sgconfig.yml`, even +under an enclosing Plug'n'Play directory. ## `please-style check`