Skip to content

Commit 42a5f65

Browse files
authored
fix(cli): tighten please-style check heuristics deferred from #36 (#42)
* 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 * fix(ast-grep-config): recognize #[cfg_attr(…, test)] as a Rust test attribute Refs #37 * 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 * fix(cli): skip sgconfig.yml under Yarn Plug'n'Play with a warning Refs #37 * chore: apply AI code review suggestions - 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 * chore: apply AI code review suggestions Document that an installed rules directory overrides an enclosing Plug'n'Play root. Refs #37
1 parent 1858fcd commit 42a5f65

12 files changed

Lines changed: 169 additions & 18 deletions

File tree

‎packages/ast-grep-config/extract/rust.yml‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,34 @@ rule:
187187
kind: token_tree
188188
stopBy: end
189189
---
190+
# A test function attribute: `#[test]`, `#[tokio::test(…)]`, or one applied
191+
# conditionally, `#[cfg_attr(<predicate>, test)]`. In the conditional form the
192+
# `test` must be a top-level item after a comma, optionally path-qualified and
193+
# never followed by `::` (`test::helper` is another attribute), so
194+
# a `test` inside the predicate (`cfg_attr(all(unix, test), ignore)`) or as the
195+
# predicate itself (`cfg_attr(test, derive(Debug))`) does not count.
190196
id: test-attr
191197
language: rust
192198
severity: hint
193199
message: test function attribute
194200
rule:
195201
kind: attribute_item
196-
regex: ^#\[\s*(\w+::)*test\s*(\(|\])
202+
any:
203+
- regex: ^#\[\s*(\w+::)*test\s*(\(|\])
204+
- has:
205+
kind: attribute
206+
all:
207+
- has:
208+
kind: identifier
209+
regex: ^cfg_attr$
210+
- has:
211+
kind: token_tree
212+
has:
213+
kind: identifier
214+
regex: ^test$
215+
follows:
216+
regex: ^(,|::)$
217+
# `test::helper` names a different attribute.
218+
not:
219+
precedes:
220+
regex: ^::$

‎packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,7 @@ class InvoiceService { /* … */ }
6767

6868
## Exempt files
6969

70-
`Errors.kt` and test source sets (`src/test/`, `src/testFixtures/`,
71-
`src/androidTest/`) are ignored.
70+
`Errors.kt` and test source sets are ignored: `src/test/`, `src/testFixtures/`,
71+
and every source set whose name ends in `Test` (Android `src/androidTest/` and
72+
`src/androidUnitTest/`, Kotlin Multiplatform `src/commonTest/`, `src/jvmTest/`,
73+
`src/iosTest/`, `src/jsTest/`, …).

‎packages/ast-grep-config/rules/kotlin/kotlin-error-outside-errors-file.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,6 @@ ignores:
3838
- '**/Errors.kt'
3939
- '**/src/test/**'
4040
- '**/src/testFixtures/**'
41-
- '**/src/androidTest/**'
41+
# Android and Kotlin Multiplatform test source sets: androidTest,
42+
# androidUnitTest, commonTest, jvmTest, iosTest, jsTest, …
43+
- '**/src/*Test/**'

‎packages/ast-grep-config/test/e2e/sgconfig.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ const PROJECT = {
7070
'lib/auth/token_store.dart': 'class InvalidToken implements Exception {}',
7171
'src/main/kotlin/com/acme/Errors.kt': 'class A : RuntimeException()',
7272
'src/main/kotlin/com/acme/Billing.kt': 'class B : RuntimeException()',
73+
'shared/src/commonMain/kotlin/com/acme/Sync.kt': 'class C : RuntimeException()',
74+
'shared/src/commonTest/kotlin/com/acme/Fakes.kt': 'class D : RuntimeException()',
75+
'shared/src/iosTest/kotlin/com/acme/Fakes.kt': 'class E : RuntimeException()',
76+
'app/src/androidUnitTest/kotlin/com/acme/Fakes.kt': 'class F : RuntimeException()',
7377
'src/main/java/com/acme/error/AException.java': 'public class AException extends RuntimeException {}',
7478
'src/androidTest/java/com/acme/CException.java': 'public class CException extends RuntimeException {}',
7579
'src/main/java/com/acme/BException.java': 'public class BException extends RuntimeException {}',
@@ -84,6 +88,7 @@ const EXPECTED = [
8488
'lib/auth/token_store.dart:1 dart-error-outside-errors-file',
8589
'packages/core/plugins/loader.ts:1 ts-no-default-export',
8690
'pages/about.ts:1 ts-no-default-export',
91+
'shared/src/commonMain/kotlin/com/acme/Sync.kt:1 kotlin-error-outside-errors-file',
8792
'src/card.tsx:1 tsx-no-default-export',
8893
'src/main/java/com/acme/BException.java:1 java-error-outside-error-package',
8994
'src/main/kotlin/com/acme/Billing.kt:1 kotlin-error-outside-errors-file',

‎packages/cli/README.ko.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 있으면 exit 1 (
6363
참고하세요. `please-style check`의 발견 사항은 slug를 보여 주며, 각 검사는
6464
[`please-style check`](#please-style-check)에 설명되어 있습니다.
6565

66+
Yarn Plug'n'Play 프로젝트(프로젝트나 상위 워크스페이스 루트에 `.pnp.cjs` 또는
67+
`.pnp.js` 파일이 있는 경우)에서는 `init`이 `sgconfig.yml`을 작성하지 않고
68+
경고만 출력합니다. ast-grep은 디렉터리에서 규칙을 읽는데, Plug'n'Play는
69+
패키지를 `node_modules` 없이 zip 아카이브로 보관하기 때문입니다. 규칙을
70+
쓰려면 `.yarnrc.yml`에 `nodeLinker: node-modules`를 설정하고 `yarn install`을
71+
실행한 뒤 `init`을 다시 실행하세요. 프로젝트 자체에
72+
`node_modules/@pleaseai/ast-grep-config/rules`가 설치되어 있으면 상위 디렉터리가
73+
Plug'n'Play여도 `sgconfig.yml`을 작성합니다.
74+
6675
## `please-style check`
6776

6877
`check`는
@@ -128,7 +137,8 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 하나라도 있
128137
`cargo metadata --no-deps --format-version 1`을 읽고 `mod` 선언을 따라가,
129138
Cargo가 컴파일하지 않는 `tests/**/*.rs` 파일(`tests/common/`과
130139
`tests/<name>/main.rs` 대상의 하위 모듈은 제외)과 어떤 `mod` 선언도 닿지
131-
않는 `#[test]` 파일을 보고합니다. `cargo`가 없으면 알림을 남기고 Rust를
140+
않는 `#[test]` 파일(`#[cfg_attr(…, test)]`로 붙인 테스트 속성 포함)을
141+
보고합니다. `cargo`가 없으면 알림을 남기고 Rust를
132142
건너뜁니다.
133143
- **`test-helpers-in-dedicated-location`**: 지정 위치 밖에서 선언된 헬퍼
134144
후보(`mock*`, `createMock*`, `fake*`, `stub*`)는 둘 이상의 테스트 파일이

‎packages/cli/README.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ with the fix. See the
6363
structural rules. `please-style check` findings name their slug; each check is
6464
described under [`please-style check`](#please-style-check).
6565

66+
Under Yarn Plug'n'Play (a `.pnp.cjs` or `.pnp.js` file in the project or an
67+
enclosing workspace root), `init` does not write `sgconfig.yml` and prints a
68+
warning instead: ast-grep reads rules from a directory, and Plug'n'Play keeps
69+
packages in zip archives without `node_modules`. To use the rules, set
70+
`nodeLinker: node-modules` in `.yarnrc.yml`, run `yarn install`, and run
71+
`init` again. A project with its own installed
72+
`node_modules/@pleaseai/ast-grep-config/rules` still gets `sgconfig.yml`, even
73+
under an enclosing Plug'n'Play directory.
74+
6675
## `please-style check`
6776

6877
`check` is the layer-3 path checker of
@@ -132,7 +141,8 @@ What it checks:
132141
`cargo metadata --no-deps --format-version 1` and follows `mod`
133142
declarations, then reports `tests/**/*.rs` files Cargo never compiles
134143
(except `tests/common/` and submodules of a `tests/<name>/main.rs`
135-
target), and `#[test]` files no `mod` declaration reaches. Without
144+
target), and `#[test]` files no `mod` declaration reaches (a test
145+
attribute applied with `#[cfg_attr(…, test)]` counts too). Without
136146
`cargo`, Rust is skipped with a notice.
137147
- Files come from `git ls-files` (tracked plus untracked-not-ignored) or,
138148
outside a git work tree, a directory walk (which also skips build and
@@ -176,7 +186,7 @@ their exports cannot be enumerated):
176186
| TypeScript, TSX | `export default …`, `export { x as default }`, `export { default }` | `default export` |
177187
| TypeScript, TSX | `export const { a } = obj`, `export const [a] = arr` | `destructured export` |
178188
| TypeScript, TSX | `export { x as "string name" }` | `string-literal export name` |
179-
| 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` |
189+
| 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` |
180190
| Dart | `export 'other.dart';` | `re-export` |
181191
| Dart | `part 'other.dart';` (generated `*.g.dart`/`*.freezed.dart` parts excluded; any other suffix such as `*.custom.dart` counts) | `library with parts` |
182192

@@ -202,8 +212,11 @@ Exempt **by convention** (no finding, no notice):
202212
`__mocks__/` directory, and anything under the package's test root (`test/`, `tests/`).
203213
- Dart: `part of` files, files under `integration_test/`, `test_driver/`,
204214
`bin/`, `tool/`, `example/`, `web/` or the test root, `*_test.dart`,
205-
`errors.dart`, generated files whose name has an extra dot (`*.g.dart`,
206-
`*.freezed.dart`), and files whose first line is a generated-code header
215+
`errors.dart`, generator output named by its suffix (`*.g.dart`,
216+
`*.freezed.dart`, `*.mocks.dart`, `*.gr.dart`, `*.config.dart`,
217+
`*.gen.dart`, `*.chopper.dart`, `*.mapper.dart`, `*.gql.dart`,
218+
`*.graphql.dart`, `*.pb.dart`, `*.pbenum.dart`, `*.pbjson.dart`,
219+
`*.pbserver.dart`, `*.pbgrpc.dart`), and files whose first line is a generated-code header
207220
(`// GENERATED CODE` or `// File generated by`, as in `firebase_options.dart`).
208221

209222
Names are compared by a separator-free key: the name is split into words at

‎packages/cli/src/appliers.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Tool, ToolApplyResult, ToolContext } from './tools.js'
22
import { copyFileSync, existsSync, writeFileSync } from 'node:fs'
3-
import { resolve as joinPath } from 'node:path'
4-
import { confirm, isCancel } from '@clack/prompts'
3+
import { dirname, resolve as joinPath } from 'node:path'
4+
import { confirm, isCancel, log } from '@clack/prompts'
55
import {
66
isPackageInstalled,
77
readFileOrNull,
@@ -228,10 +228,34 @@ function listsAstGrepRules(yaml: string): boolean {
228228
return entries.some(e => normalizeRuleDir(e) === AST_GREP_RULES)
229229
}
230230

231+
/**
232+
* Yarn Plug'n'Play installs packages as zip archives without `node_modules`, so the rules path above does not exist.
233+
* A workspace package's `.pnp.cjs` lives at the workspace root, so the enclosing directories are searched too;
234+
* an installed rules directory means this project is not under Plug'n'Play, whatever an ancestor holds.
235+
*/
236+
function usesPlugNPlay(cwd: string): boolean {
237+
if (existsSync(joinPath(cwd, AST_GREP_RULES))) {
238+
return false
239+
}
240+
for (let dir = joinPath(cwd); ; dir = dirname(dir)) {
241+
if (existsSync(joinPath(dir, '.pnp.cjs')) || existsSync(joinPath(dir, '.pnp.js'))) {
242+
return true
243+
}
244+
if (dirname(dir) === dir) {
245+
return false
246+
}
247+
}
248+
}
249+
231250
export async function applyAstGrep(ctx: ToolContext): Promise<ToolApplyResult> {
232251
const result: ToolApplyResult = { created: [], updated: [], skipped: [] }
233252
const target = joinPath(ctx.cwd, 'sgconfig.yml')
234253
const label = 'sgconfig.yml'
254+
if (usesPlugNPlay(ctx.cwd)) {
255+
log.warn(t('astGrepPlugNPlay'))
256+
result.skipped.push(label)
257+
return result
258+
}
235259
const existing = readFileOrNull(target)
236260

237261
if (existing == null) {

‎packages/cli/src/check/check-filenames.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ export interface FilenameLanguage {
3939
nameRules: string[]
4040
/** Rule ids whose presence means the file's exports cannot be enumerated; such files are skipped. */
4141
opaqueRules?: string[]
42-
/** Export names a framework dictates (a file whose only export is one is withheld, not judged). */
43-
reservedNames?: ReadonlySet<string>
42+
/** Whether a framework dictates the export `name` in `file` (a file whose only export is one is withheld, not judged). */
43+
isReserved?: (name: string, file: string) => boolean
4444
normalize: (name: string) => string
4545
isPublic: (name: string) => boolean
4646
/** File name without extension, or `null` when the file is exempt. */
@@ -63,6 +63,14 @@ function underTestRoot(file: string, unit: Unit | undefined): boolean {
6363
/** Names a router or runtime framework requires a file to export (TanStack Router `Route`, HTTP verb handlers, Remix `loader`/`action`). */
6464
const FRAMEWORK_RESERVED = new Set(['Route', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL', 'loader', 'action'])
6565

66+
/** Directories a file-based router maps to URLs: `routes/` (TanStack Router, Remix, SolidStart) and `pages/` (Next.js API routes, Astro endpoints). */
67+
function inRouteDir(file: string): boolean {
68+
return file.split('/').slice(0, -1).some(seg => seg === 'routes' || seg === 'pages')
69+
}
70+
71+
/** Last stem segment of a Dart code generator's output (`*.g.dart`, `*.freezed.dart`, Mockito `*.mocks.dart`, auto_route `*.gr.dart`, protobuf `*.pb*.dart`, …). */
72+
const DART_GENERATOR_SUFFIX = /\.(?:g|freezed|mocks|gr|config|gen|chopper|mapper|gql|graphql|pb|pbenum|pbjson|pbserver|pbgrpc)$/
73+
6674
const GENERATED_HEADER = /^\/\/\s*(?:GENERATED CODE|File generated by)/
6775

6876
/** 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 {
97105
language: 'typescript',
98106
nameRules: ['export-name'],
99107
opaqueRules: ['export-destructure', 'export-opaque'],
100-
reservedNames: FRAMEWORK_RESERVED,
108+
isReserved: (name, file) => FRAMEWORK_RESERVED.has(name) && inRouteDir(file),
101109
normalize: toKebabCase,
102110
isPublic: () => true,
103111
units,
@@ -137,8 +145,8 @@ export function dartFilenames(units: Unit[], partFiles: Set<string>): FilenameLa
137145
return null
138146
}
139147
const stem = name.slice(0, -'.dart'.length)
140-
// `_test.dart`, the error file, and generated `*.g.dart`/`*.freezed.dart`.
141-
if (stem.endsWith('_test') || stem === 'errors' || stem.includes('.')) {
148+
// `_test.dart`, the error file, and generator output named by the generator.
149+
if (stem.endsWith('_test') || stem === 'errors' || DART_GENERATOR_SUFFIX.test(stem)) {
142150
return null
143151
}
144152
return stem
@@ -184,7 +192,7 @@ export function inspectFilenames(matches: ExtractMatch[], lang: FilenameLanguage
184192
opaque.set(m.file, (opaque.get(m.file) ?? new Set()).add('re-export'))
185193
continue
186194
}
187-
if (lang.reservedNames?.has(m.text) === true) {
195+
if (lang.isReserved?.(m.text, m.file) === true) {
188196
opaque.set(m.file, (opaque.get(m.file) ?? new Set()).add('framework-reserved export'))
189197
}
190198
const names = byFile.get(m.file) ?? new Map<string, number>()

‎packages/cli/src/i18n.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ interface Messages {
1717
created: (file: string) => string
1818
updated: (file: string) => string
1919
agentsBlockUpdated: string
20+
astGrepPlugNPlay: string
2021
doctorHeading: string
2122
doctorOk: string
2223
doctorMissing: string
@@ -45,6 +46,7 @@ const messages: Record<Locale, Messages> = {
4546
created: file => `Created ${file}`,
4647
updated: file => `Updated ${file}`,
4748
agentsBlockUpdated: 'Refreshed AGENTS.md pleaseai-code-style block.',
49+
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.',
4850
doctorHeading: 'Project status',
4951
doctorOk: 'OK',
5052
doctorMissing: 'missing',
@@ -99,6 +101,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e
99101
created: file => `${file} 생성됨`,
100102
updated: file => `${file} 업데이트됨`,
101103
agentsBlockUpdated: 'AGENTS.md의 pleaseai-code-style 블록을 갱신했습니다.',
104+
astGrepPlugNPlay: 'Yarn Plug\'n\'Play는 node_modules를 만들지 않아 ast-grep이 node_modules/@pleaseai/ast-grep-config의 규칙을 읽을 수 없습니다. .yarnrc.yml에 `nodeLinker: node-modules`를 설정하고 yarn install 후 init을 다시 실행하세요.',
102105
doctorHeading: '프로젝트 상태',
103106
doctorOk: '정상',
104107
doctorMissing: '없음',

‎packages/cli/test/appliers.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
1+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
22
import { tmpdir } from 'node:os'
33
import { join } from 'node:path'
44
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
@@ -61,6 +61,30 @@ describe('ast-grep tool', () => {
6161
expect(readFileSync(join(dir, 'sgconfig.yml'), 'utf-8')).toContain('ruleDirs:\n - node_modules/@pleaseai/ast-grep-config/rules\n')
6262
})
6363

64+
test('does not write sgconfig.yml under Yarn Plug\'n\'Play, where node_modules does not exist', async () => {
65+
writeFileSync(join(dir, '.pnp.cjs'), '')
66+
const result = await applyAstGrep({ cwd: dir, autoAccept: true })
67+
expect(result).toEqual({ created: [], updated: [], skipped: ['sgconfig.yml'] })
68+
expect(existsSync(join(dir, 'sgconfig.yml'))).toBe(false)
69+
})
70+
71+
test('detects Plug\'n\'Play from a workspace package whose .pnp.cjs is at the workspace root', async () => {
72+
writeFileSync(join(dir, '.pnp.cjs'), '')
73+
const pkg = join(dir, 'packages', 'app')
74+
mkdirSync(pkg, { recursive: true })
75+
const result = await applyAstGrep({ cwd: pkg, autoAccept: true })
76+
expect(result.skipped).toEqual(['sgconfig.yml'])
77+
expect(existsSync(join(pkg, 'sgconfig.yml'))).toBe(false)
78+
})
79+
80+
test('writes sgconfig.yml for a node_modules project nested under a Plug\'n\'Play directory', async () => {
81+
writeFileSync(join(dir, '.pnp.cjs'), '')
82+
const project = join(dir, 'examples', 'app')
83+
mkdirSync(join(project, 'node_modules', '@pleaseai', 'ast-grep-config', 'rules'), { recursive: true })
84+
const result = await applyAstGrep({ cwd: project, autoAccept: true })
85+
expect(result.created).toEqual(['sgconfig.yml'])
86+
})
87+
6488
test('overwrites a different sgconfig.yml when overwrites are accepted', async () => {
6589
writeFileSync(join(dir, 'sgconfig.yml'), 'ruleDirs:\n - rules\n')
6690
const result = await applyAstGrep({ cwd: dir, autoAccept: true })

0 commit comments

Comments
 (0)