From 5dbcc90a86ba1f14e586744e76681f2e835e7f3a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 13 Oct 2026 10:47:27 +0900 Subject: [PATCH 1/6] fix(ast-grep-config): set preferUnplugged so please-style check works under Yarn PnP Yarn Plug'n'Play keeps packages in zip archives, which ast-grep cannot read. Marking the package preferUnplugged makes Yarn install it as a real directory. Refs #45 --- packages/ast-grep-config/package.json | 1 + packages/cli/test/check/ast-grep.test.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/ast-grep-config/package.json b/packages/ast-grep-config/package.json index 0e1c40a..6c63d10 100644 --- a/packages/ast-grep-config/package.json +++ b/packages/ast-grep-config/package.json @@ -31,6 +31,7 @@ "engines": { "node": ">=22.0.0" }, + "preferUnplugged": true, "scripts": { "test": "bun test && ast-grep test" }, diff --git a/packages/cli/test/check/ast-grep.test.ts b/packages/cli/test/check/ast-grep.test.ts index af36cd9..16d6aee 100644 --- a/packages/cli/test/check/ast-grep.test.ts +++ b/packages/cli/test/check/ast-grep.test.ts @@ -1,4 +1,4 @@ -import { chmodSync } from 'node:fs' +import { chmodSync, readFileSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' @@ -89,3 +89,10 @@ describe('checkCodeOrganization file set', () => { expect(result.findings).toEqual([]) }) }) + +describe('Yarn Plug\'n\'Play', () => { + test('the rules package asks Yarn to install it unpacked, so ast-grep can read extract/ from disk', () => { + const pkg = JSON.parse(readFileSync(join(import.meta.dir, '../../../ast-grep-config/package.json'), 'utf-8')) as { preferUnplugged?: boolean } + expect(pkg.preferUnplugged).toBe(true) + }) +}) From d75576d40fffd700ed57d44f7e2f067f3ebbe359 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 13 Oct 2026 10:47:46 +0900 Subject: [PATCH 2/6] fix(editorconfig): publish @pleaseai/editorconfig and warn under Yarn PnP Register the package with release-please so it is published, and make init skip the .editorconfig copy under Yarn Plug'n'Play with a yarn node command that copies the file through Yarn's resolver. The README also documents that please-style check works under Plug'n'Play. Refs #45 --- .github/workflows/release-please.yml | 14 ++++++++ .release-please-manifest.json | 3 +- packages/cli/README.ko.md | 12 +++++++ packages/cli/README.md | 12 +++++++ packages/cli/src/appliers.ts | 50 ++++++++++++++++------------ packages/cli/src/i18n.ts | 3 ++ packages/cli/test/appliers.test.ts | 36 +++++++++++++++++++- packages/editorconfig/LICENSE | 21 ++++++++++++ packages/editorconfig/package.json | 17 +++++++++- release-please-config.json | 3 ++ 10 files changed, 146 insertions(+), 25 deletions(-) create mode 100644 packages/editorconfig/LICENSE diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 095fae3..a00dc36 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -27,6 +27,7 @@ jobs: prettier-config-released: ${{ steps.release.outputs['packages/perttier-config--release_created'] }} code-style-released: ${{ steps.release.outputs['packages/cli--release_created'] }} ast-grep-config-released: ${{ steps.release.outputs['packages/ast-grep-config--release_created'] }} + editorconfig-released: ${{ steps.release.outputs['packages/editorconfig--release_created'] }} steps: - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token @@ -110,6 +111,19 @@ jobs: npm publish pleaseai-prettier-config-*.tgz --provenance --access public fi + # No build step: the package ships its .editorconfig as-is. + - name: Publish @pleaseai/editorconfig + if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.release-please.outputs.editorconfig-released == 'true') }} + working-directory: packages/editorconfig + run: | + VERSION=$(node -p "require('./package.json').version") + if npm view "@pleaseai/editorconfig@$VERSION" version 2>/dev/null; then + echo "⚠ @pleaseai/editorconfig@$VERSION already published, skipping" + else + bun pm pack + npm publish pleaseai-editorconfig-*.tgz --provenance --access public + fi + # No build step: the package ships YAML rules and READMEs as-is. - name: Publish @pleaseai/ast-grep-config id: publish-ast-grep-config diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ea78a66..09bb914 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,6 @@ "packages/eslint-config": "0.0.4", "packages/perttier-config": "0.0.1", "packages/cli": "0.0.3", - "packages/ast-grep-config": "0.0.1" + "packages/ast-grep-config": "0.0.1", + "packages/editorconfig": "0.0.1" } diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index ac456fa..b3bf777 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -72,6 +72,18 @@ Yarn Plug'n'Play 프로젝트(프로젝트나 상위 워크스페이스 루트 `node_modules/@pleaseai/ast-grep-config/rules`가 설치되어 있으면 상위 디렉터리가 Plug'n'Play여도 `sgconfig.yml`을 작성합니다. +**editorconfig** 도구도 마찬가지입니다. Plug'n'Play에서는 `init`이 +`node_modules`에서 `.editorconfig`를 복사할 수 없으므로 파일을 건너뛰고, Yarn의 +모듈 해석으로 복사하는 명령을 출력합니다. + +```bash +yarn node -e "require('fs').copyFileSync(require.resolve('@pleaseai/editorconfig/.editorconfig'), '.editorconfig')" +``` + +`please-style check`는 Plug'n'Play에서도 동작합니다. +`@pleaseai/ast-grep-config`가 `preferUnplugged`를 설정해 두었으므로, Yarn이 이 +패키지를 ast-grep이 읽을 수 있는 실제 디렉터리로 설치합니다. + ## `please-style check` `check`는 diff --git a/packages/cli/README.md b/packages/cli/README.md index a439419..e3327fa 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -72,6 +72,18 @@ packages in zip archives without `node_modules`. To use the rules, set `node_modules/@pleaseai/ast-grep-config/rules` still gets `sgconfig.yml`, even under an enclosing Plug'n'Play directory. +The same goes for the **editorconfig** tool: under Plug'n'Play `init` cannot +copy `.editorconfig` from `node_modules`, so it skips the file and prints the +command that copies it with Yarn's resolver: + +```bash +yarn node -e "require('fs').copyFileSync(require.resolve('@pleaseai/editorconfig/.editorconfig'), '.editorconfig')" +``` + +`please-style check` itself works under Plug'n'Play: `@pleaseai/ast-grep-config` +sets `preferUnplugged`, so Yarn installs it as a real directory that ast-grep +can read. + ## `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 33f196e..171962e 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -79,16 +79,41 @@ async function applyPrettierConfig(ctx: ToolContext): Promise { return result } +/** + * Yarn Plug'n'Play installs packages as zip archives without `node_modules`, so a package file read from + * `node_modules` does not exist. A workspace package's `.pnp.cjs` lives at the workspace root, so the enclosing + * directories are searched too; an `installed` path that exists means this project is not under Plug'n'Play, + * whatever an ancestor holds. + */ +function usesPlugNPlay(cwd: string, installed: string): boolean { + if (existsSync(joinPath(cwd, installed))) { + 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 + } + } +} + // --- editorconfig ----------------------------------------------------------- +const EDITORCONFIG_SOURCE = 'node_modules/@pleaseai/editorconfig/.editorconfig' + async function applyEditorConfig(ctx: ToolContext): Promise { const result: ToolApplyResult = { created: [], updated: [], skipped: [] } const target = joinPath(ctx.cwd, '.editorconfig') - const source = joinPath(ctx.cwd, 'node_modules', '@pleaseai', 'editorconfig', '.editorconfig') + const source = joinPath(ctx.cwd, EDITORCONFIG_SOURCE) const label = '.editorconfig' if (!existsSync(source)) { - // Package not installed yet (e.g. install step failed). Skip silently. + // Not installed (e.g. the install step failed), or installed by Plug'n'Play without a node_modules copy. + if (usesPlugNPlay(ctx.cwd, EDITORCONFIG_SOURCE)) { + log.warn(t('editorconfigPlugNPlay')) + } result.skipped.push(label) return result } @@ -228,30 +253,11 @@ 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. - * 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 { - 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 { const result: ToolApplyResult = { created: [], updated: [], skipped: [] } const target = joinPath(ctx.cwd, 'sgconfig.yml') const label = 'sgconfig.yml' - if (usesPlugNPlay(ctx.cwd)) { + if (usesPlugNPlay(ctx.cwd, AST_GREP_RULES)) { log.warn(t('astGrepPlugNPlay')) result.skipped.push(label) return result diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 4a322bf..7e75069 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -18,6 +18,7 @@ interface Messages { updated: (file: string) => string agentsBlockUpdated: string astGrepPlugNPlay: string + editorconfigPlugNPlay: string doctorHeading: string doctorOk: string doctorMissing: string @@ -47,6 +48,7 @@ const messages: Record = { 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.', + editorconfigPlugNPlay: 'Yarn Plug\'n\'Play has no node_modules, so .editorconfig was not copied from node_modules/@pleaseai/editorconfig. Copy it with: yarn node -e "require(\'fs\').copyFileSync(require.resolve(\'@pleaseai/editorconfig/.editorconfig\'), \'.editorconfig\')"', doctorHeading: 'Project status', doctorOk: 'OK', doctorMissing: 'missing', @@ -102,6 +104,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e 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을 다시 실행하세요.', + editorconfigPlugNPlay: 'Yarn Plug\'n\'Play는 node_modules를 만들지 않아 node_modules/@pleaseai/editorconfig에서 .editorconfig를 복사하지 못했습니다. 다음 명령으로 복사하세요: yarn node -e "require(\'fs\').copyFileSync(require.resolve(\'@pleaseai/editorconfig/.editorconfig\'), \'.editorconfig\')"', doctorHeading: '프로젝트 상태', doctorOk: '정상', doctorMissing: '없음', diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index 77a6191..6f861c3 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -1,7 +1,8 @@ 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' +import { log } from '@clack/prompts' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import { applyAgentsMd, applyAstGrep, findTool } from '../src/appliers.js' let dir: string @@ -93,6 +94,39 @@ describe('ast-grep tool', () => { }) }) +// --------------------------------------------------------------------------- +// editorconfig +// --------------------------------------------------------------------------- + +describe('editorconfig tool', () => { + test('the package it installs is publishable and ships .editorconfig', () => { + const pkg = JSON.parse(readFileSync(join(import.meta.dir, '../../editorconfig/package.json'), 'utf-8')) as { private?: boolean, files?: string[] } + expect(pkg.private).toBeUndefined() + expect(pkg.files).toContain('.editorconfig') + }) + + test('copies .editorconfig from node_modules', async () => { + mkdirSync(join(dir, 'node_modules', '@pleaseai', 'editorconfig'), { recursive: true }) + writeFileSync(join(dir, 'node_modules', '@pleaseai', 'editorconfig', '.editorconfig'), 'root = true\n') + const result = await findTool('editorconfig')!.apply({ cwd: dir, autoAccept: true }) + expect(result.created).toEqual(['.editorconfig']) + expect(readFileSync(join(dir, '.editorconfig'), 'utf-8')).toBe('root = true\n') + }) + + test('skips with a warning under Yarn Plug\'n\'Play, where node_modules does not exist', async () => { + writeFileSync(join(dir, '.pnp.cjs'), '') + const warn = spyOn(log, 'warn').mockImplementation(() => {}) + try { + const result = await findTool('editorconfig')!.apply({ cwd: dir, autoAccept: true }) + expect(result).toEqual({ created: [], updated: [], skipped: ['.editorconfig'] }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('yarn node -e')) + } + finally { + warn.mockRestore() + } + }) +}) + // --------------------------------------------------------------------------- // AGENTS.md // --------------------------------------------------------------------------- diff --git a/packages/editorconfig/LICENSE b/packages/editorconfig/LICENSE new file mode 100644 index 0000000..4da9093 --- /dev/null +++ b/packages/editorconfig/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Passion Factory Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/editorconfig/package.json b/packages/editorconfig/package.json index 0826597..2d09e91 100644 --- a/packages/editorconfig/package.json +++ b/packages/editorconfig/package.json @@ -1,5 +1,20 @@ { "name": "@pleaseai/editorconfig", "version": "0.0.1", - "private": true + "description": "Shared .editorconfig for PleaseAI projects, copied into a project by @pleaseai/code-style", + "author": "PleaseAI", + "license": "MIT", + "homepage": "https://github.com/pleaseai/code-style/tree/main/packages/editorconfig#readme", + "repository": { + "type": "git", + "url": "https://github.com/pleaseai/code-style.git", + "directory": "packages/editorconfig" + }, + "keywords": [ + "editorconfig", + "pleaseai" + ], + "files": [ + ".editorconfig" + ] } diff --git a/release-please-config.json b/release-please-config.json index f989af9..1a355d3 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -15,6 +15,9 @@ }, "packages/ast-grep-config": { "package-name": "@pleaseai/ast-grep-config" + }, + "packages/editorconfig": { + "package-name": "@pleaseai/editorconfig" } } } From 7ee94f5644ba685c7e9555ebfd624a30fc2329a2 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 13 Oct 2026 10:48:00 +0900 Subject: [PATCH 3/6] feat(cli): recognize Kotlin Multiplatform source sets in the test-path check Treat src/Test/kotlin sets as test roots and src/Main/kotlin sets as source roots for modules with src/commonMain/kotlin. Refs #45 --- packages/cli/README.ko.md | 6 +++ packages/cli/README.md | 9 ++++ packages/cli/src/check/layouts.ts | 45 ++++++++++++++----- .../cli/test/check/check-test-paths.test.ts | 16 +++++++ 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index b3bf777..81111f3 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -139,6 +139,12 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 하나라도 있 - Dart: `lib/a/b.dart` ↔ `test/a/b_test.dart`. - Kotlin/Java: `src/main/kotlin/…/Foo.kt` ↔ `src/test/kotlin/…/FooTest.kt` (`java`도 같음). + - Kotlin Multiplatform: `src/Test/kotlin/…/FooTest.kt` ↔ + `src/Main/kotlin/…/Foo.kt`(`commonTest` ↔ `commonMain`이며, + `iosTest`는 중간 소스 셋인 `appleMain`의 코드도 대상으로 삼을 수 있습니다). + `src/commonMain/kotlin/`이 있는 모듈이 대상이고, `androidInstrumentedTest`와 + `integrationTest`는 검사하지 않습니다. 헬퍼 검사는 아직 멀티플랫폼 모듈을 + 다루지 않습니다. - 테스트 루트 바로 아래의 환경 구간은 닫힌 목록 `unit`, `nuxt`, `browser`, `e2e`에 있을 때만 인정합니다. 첫 구간이 목록에 있으면 환경 구간으로도, 미러 경로의 일부로도 해석해 봅니다. `e2e/` 아래 테스트는 경로를 유도하지 diff --git a/packages/cli/README.md b/packages/cli/README.md index e3327fa..6c6faee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -143,6 +143,9 @@ What it checks: - Dart: `lib/a/b.dart` ↔ `test/a/b_test.dart`. - Kotlin/Java: `src/main/kotlin/…/Foo.kt` ↔ `src/test/kotlin/…/FooTest.kt` (same for `java`). + - Kotlin Multiplatform: `src/Test/kotlin/…/FooTest.kt` ↔ + `src/Main/kotlin/…/Foo.kt` (`commonTest` ↔ `commonMain`, and an + `iosTest` may target an intermediate `appleMain`). - An environment segment directly under the test root is allowed if it is on the closed list `unit`, `nuxt`, `browser`, `e2e`. A first segment on the list is tried both as an environment and as part of the mirrored path. @@ -259,6 +262,12 @@ snake_case (Dart), for example `parseURL` → `parse-url.ts`. - Kotlin and Java modules are directories that contain `src/main|test|testFixtures//`; tests are `*Test.kt`/`*Test.java`. A test may target a source in either main source set: `src/main/kotlin/…` (`.kt`, `.java`) or `src/main/java/…` (`.java`, `.kt`). +- A Kotlin module with `src/commonMain/kotlin/` is Kotlin Multiplatform: every + `src/Test/kotlin/` set is a test root and every `src/Main/kotlin/` + set a source root. `androidInstrumentedTest` and `integrationTest` do not + mirror sources and are not checked; a `*Test.kt` inside a `*Main` set is + reported as outside the test root. The helper check does not cover + multiplatform modules yet (they have no `src/testFixtures/`). - Test files that belong to no package or module (no enclosing `package.json`, `pubspec.yaml` or `src///` directory) are not checked. This is reported as a notice, aggregated per language with the count and up to three diff --git a/packages/cli/src/check/layouts.ts b/packages/cli/src/check/layouts.ts index 5b1b483..a197f7e 100644 --- a/packages/cli/src/check/layouts.ts +++ b/packages/cli/src/check/layouts.ts @@ -177,39 +177,62 @@ export const DART_LAYOUT: TestLayout = { // --- Kotlin / Java (Gradle / Maven) ----------------------------------------- +/** Kotlin Multiplatform test sets that do not mirror sources, like Android's `src/androidTest` or an `integrationTest` set. */ +const KMP_UNMIRRORED_TESTS = new Set(['androidInstrumented', 'androidAndroid', 'integration']) + function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayout { // Gradle/Maven modules mix both languages: a test may target a source in the sibling-language main set. const sibling = language === 'kotlin' ? 'java' : 'kotlin' const siblingExt = ext === '.kt' ? '.java' : '.kt' const test = new RegExp(`.Test\\${ext}$`) const sourceSet = new RegExp(`^(?:(.*)/)?src/(?:main|test|testFixtures)/${language}/`) + // Kotlin Multiplatform source sets: `src/commonMain/kotlin`, `src/jvmTest/kotlin`, … + const kmpSet = new RegExp(`^(?:(.*)/)?src/(\\w+)(Main|Test)/${language}/`) return { language, units(files, base, rootDir) { const dirs = new Set() + // Module dir → KMP source set names (`commonMain`, `jvmTest`, …). + const kmpSets = new Map>() for (const f of files) { const m = sourceSet.exec(f) if (m != null) { dirs.add(m[1] ?? '') } + const k = kmpSet.exec(f) + if (k != null && !(k[3] === 'Test' && KMP_UNMIRRORED_TESTS.has(k[2] ?? ''))) { + const dir = k[1] ?? '' + kmpSets.set(dir, (kmpSets.get(dir) ?? new Set()).add(`${k[2]}${k[3]}`)) + } } - return [...dirs].map(dir => ({ - dir, - testRoots: [`src/test/${language}`], - sourceRoots: [`src/main/${language}`], - alternateSourceRoots: [`src/main/${sibling}`], - keepRootName: false, - envSegments: withConfig(rootDir, dir, base).envSegments, - })) + // A module with `src/commonMain/` is multiplatform: every `*Test` set is a test root, and a + // test may target any `*Main` set (an `iosTest` may test code in an intermediate `appleMain`). + for (const [dir, sets] of kmpSets) { + if (sets.has('commonMain')) { + dirs.add(dir) + } + } + return [...dirs].map((dir) => { + const sets = kmpSets.get(dir) + const kmp = sets?.has('commonMain') === true ? [...sets].sort() : null + return { + dir, + testRoots: kmp == null ? [`src/test/${language}`] : kmp.filter(s => s.endsWith('Test')).map(s => `src/${s}/${language}`), + sourceRoots: kmp == null ? [`src/main/${language}`] : kmp.filter(s => s.endsWith('Main')).map(s => `src/${s}/${language}`), + alternateSourceRoots: kmp == null ? [`src/main/${sibling}`] : [], + keepRootName: false, + envSegments: withConfig(rootDir, dir, base).envSegments, + } + }) }, isTestFile: path => test.test(path), sourceStem: fileName => fileName.slice(0, -`Test${ext}`.length), sourceExtensions: [ext, siblingExt], allowIndex: false, helperDirs: () => ['src/testFixtures'], - // Only a test class inside production sources is misplaced; other source - // sets (androidTest, integrationTest, …) are their own test roots. - reportOutsideRoot: rel => rel.startsWith('src/main/'), + // Only a test class inside production sources (`src/main`, or a multiplatform `src/*Main`) is misplaced; + // other source sets (androidTest, integrationTest, …) are their own test roots. + reportOutsideRoot: rel => /^src\/(?:main|\w+Main)\//.test(rel), } } diff --git a/packages/cli/test/check/check-test-paths.test.ts b/packages/cli/test/check/check-test-paths.test.ts index f6db3aa..38bdf5d 100644 --- a/packages/cli/test/check/check-test-paths.test.ts +++ b/packages/cli/test/check/check-test-paths.test.ts @@ -216,6 +216,22 @@ describe('test-path-derivable-from-source (Dart, Kotlin, Java)', () => { expect(files(findings)).toEqual(['src/test/kotlin/com/x/MissingTest.kt']) }) + test('kotlin multiplatform: *Test sets mirror any *Main set; instrumented tests are their own root', () => { + const findings = findingsFor({ + 'shared/src/commonMain/kotlin/com/acme/Invoice.kt': '', + 'shared/src/appleMain/kotlin/com/acme/Keychain.kt': '', + 'shared/src/commonTest/kotlin/com/acme/InvoiceTest.kt': '', + 'shared/src/iosTest/kotlin/com/acme/KeychainTest.kt': '', + 'shared/src/jvmTest/kotlin/com/acme/PaymentTest.kt': '', + 'shared/src/androidInstrumentedTest/kotlin/com/acme/ScreenTest.kt': '', + 'shared/src/commonMain/kotlin/com/acme/RefundTest.kt': '', + }, SLUG) + expect(findings.map(f => [f.kind, f.file])).toEqual([ + ['test-outside-root', 'shared/src/commonMain/kotlin/com/acme/RefundTest.kt'], + ['orphan-test', 'shared/src/jvmTest/kotlin/com/acme/PaymentTest.kt'], + ]) + }) + test('java: src/main/java mirrors to src/test/java with Test.java', () => { const findings = findingsFor({ 'src/main/java/com/acme/Invoice.java': '', From 771e539d69aa4ff962efaf3b632ed2cf87f3cf0a Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 13 Oct 2026 12:11:40 +0900 Subject: [PATCH 4/6] fix(cli): sort Kotlin Multiplatform source sets with localeCompare Address SonarCloud S2871/S7780 and a Codacy unnecessary-condition finding in the Kotlin Multiplatform layout. Refs #45 --- packages/cli/src/check/layouts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/check/layouts.ts b/packages/cli/src/check/layouts.ts index a197f7e..1c13574 100644 --- a/packages/cli/src/check/layouts.ts +++ b/packages/cli/src/check/layouts.ts @@ -187,7 +187,7 @@ function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayou const test = new RegExp(`.Test\\${ext}$`) const sourceSet = new RegExp(`^(?:(.*)/)?src/(?:main|test|testFixtures)/${language}/`) // Kotlin Multiplatform source sets: `src/commonMain/kotlin`, `src/jvmTest/kotlin`, … - const kmpSet = new RegExp(`^(?:(.*)/)?src/(\\w+)(Main|Test)/${language}/`) + const kmpSet = new RegExp(String.raw`^(?:(.*)/)?src/(\w+)(Main|Test)/${language}/`) return { language, units(files, base, rootDir) { @@ -200,7 +200,7 @@ function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayou dirs.add(m[1] ?? '') } const k = kmpSet.exec(f) - if (k != null && !(k[3] === 'Test' && KMP_UNMIRRORED_TESTS.has(k[2] ?? ''))) { + if (k != null && !(k[3] === 'Test' && KMP_UNMIRRORED_TESTS.has(k[2]))) { const dir = k[1] ?? '' kmpSets.set(dir, (kmpSets.get(dir) ?? new Set()).add(`${k[2]}${k[3]}`)) } @@ -214,7 +214,7 @@ function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayou } return [...dirs].map((dir) => { const sets = kmpSets.get(dir) - const kmp = sets?.has('commonMain') === true ? [...sets].sort() : null + const kmp = sets?.has('commonMain') === true ? [...sets].sort((a, b) => a.localeCompare(b)) : null return { dir, testRoots: kmp == null ? [`src/test/${language}`] : kmp.filter(s => s.endsWith('Test')).map(s => `src/${s}/${language}`), From 53158ed0144f707efb22ba747e4d3f940ff6eb17 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 13 Oct 2026 16:35:58 +0900 Subject: [PATCH 5/6] fix(cli): tighten Kotlin Multiplatform detection and the PnP editorconfig hint - detect a multiplatform module without commonMain sources, and let its tests target Java sources of a *Main set - skip multiplatform modules in the helper check and report a test outside the test root only in src/main for non-multiplatform modules - print the Plug'n'Play editorconfig hint only when .editorconfig is missing, and copy with COPYFILE_EXCL so the command never overwrites it --- packages/cli/README.ko.md | 10 ++++--- packages/cli/README.md | 14 +++++---- packages/cli/src/appliers.ts | 2 +- packages/cli/src/check/check-helpers.ts | 6 +++- packages/cli/src/check/check-test-paths.ts | 2 +- packages/cli/src/check/layouts.ts | 29 ++++++++++++------- packages/cli/src/i18n.ts | 4 +-- packages/cli/test/appliers.test.ts | 13 +++++++++ packages/cli/test/check/check-helpers.test.ts | 10 +++++++ .../cli/test/check/check-test-paths.test.ts | 17 +++++++++++ 10 files changed, 81 insertions(+), 26 deletions(-) diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index 81111f3..d33230d 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -77,7 +77,7 @@ Plug'n'Play여도 `sgconfig.yml`을 작성합니다. 모듈 해석으로 복사하는 명령을 출력합니다. ```bash -yarn node -e "require('fs').copyFileSync(require.resolve('@pleaseai/editorconfig/.editorconfig'), '.editorconfig')" +yarn node -e "const fs = require('fs'); fs.copyFileSync(require.resolve('@pleaseai/editorconfig/.editorconfig'), '.editorconfig', fs.constants.COPYFILE_EXCL)" ``` `please-style check`는 Plug'n'Play에서도 동작합니다. @@ -142,9 +142,11 @@ bunx @pleaseai/code-style check --strict # 발견 사항이 하나라도 있 - Kotlin Multiplatform: `src/Test/kotlin/…/FooTest.kt` ↔ `src/Main/kotlin/…/Foo.kt`(`commonTest` ↔ `commonMain`이며, `iosTest`는 중간 소스 셋인 `appleMain`의 코드도 대상으로 삼을 수 있습니다). - `src/commonMain/kotlin/`이 있는 모듈이 대상이고, `androidInstrumentedTest`와 - `integrationTest`는 검사하지 않습니다. 헬퍼 검사는 아직 멀티플랫폼 모듈을 - 다루지 않습니다. + `src/commonMain/kotlin/`이 있거나, `src/Main/kotlin/` 셋은 있지만 + `src/main|test|testFixtures/kotlin/`이 없는 모듈이 대상입니다. 같은 셋의 + `java/` 소스도 대상으로 삼습니다. `androidInstrumentedTest`, + `androidAndroidTest`, `integrationTest`는 검사하지 않습니다. 헬퍼 검사는 + 멀티플랫폼 모듈을 건너뜁니다(`src/testFixtures/`가 없기 때문입니다). - 테스트 루트 바로 아래의 환경 구간은 닫힌 목록 `unit`, `nuxt`, `browser`, `e2e`에 있을 때만 인정합니다. 첫 구간이 목록에 있으면 환경 구간으로도, 미러 경로의 일부로도 해석해 봅니다. `e2e/` 아래 테스트는 경로를 유도하지 diff --git a/packages/cli/README.md b/packages/cli/README.md index 6c6faee..6370dff 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -77,7 +77,7 @@ copy `.editorconfig` from `node_modules`, so it skips the file and prints the command that copies it with Yarn's resolver: ```bash -yarn node -e "require('fs').copyFileSync(require.resolve('@pleaseai/editorconfig/.editorconfig'), '.editorconfig')" +yarn node -e "const fs = require('fs'); fs.copyFileSync(require.resolve('@pleaseai/editorconfig/.editorconfig'), '.editorconfig', fs.constants.COPYFILE_EXCL)" ``` `please-style check` itself works under Plug'n'Play: `@pleaseai/ast-grep-config` @@ -262,12 +262,14 @@ snake_case (Dart), for example `parseURL` → `parse-url.ts`. - Kotlin and Java modules are directories that contain `src/main|test|testFixtures//`; tests are `*Test.kt`/`*Test.java`. A test may target a source in either main source set: `src/main/kotlin/…` (`.kt`, `.java`) or `src/main/java/…` (`.java`, `.kt`). -- A Kotlin module with `src/commonMain/kotlin/` is Kotlin Multiplatform: every - `src/Test/kotlin/` set is a test root and every `src/Main/kotlin/` - set a source root. `androidInstrumentedTest` and `integrationTest` do not +- A Kotlin module with `src/commonMain/kotlin/`, or with `src/Main/kotlin/` + sets and no `src/main|test|testFixtures/kotlin/`, is Kotlin Multiplatform: + every `src/Test/kotlin/` set is a test root and every + `src/Main/kotlin/` set (or its `java/` sibling) a source root. + `androidInstrumentedTest`, `androidAndroidTest` and `integrationTest` do not mirror sources and are not checked; a `*Test.kt` inside a `*Main` set is - reported as outside the test root. The helper check does not cover - multiplatform modules yet (they have no `src/testFixtures/`). + reported as outside the test root. The helper check skips multiplatform + modules (they have no `src/testFixtures/`). - Test files that belong to no package or module (no enclosing `package.json`, `pubspec.yaml` or `src///` directory) are not checked. This is reported as a notice, aggregated per language with the count and up to three diff --git a/packages/cli/src/appliers.ts b/packages/cli/src/appliers.ts index 171962e..ae1a5c1 100644 --- a/packages/cli/src/appliers.ts +++ b/packages/cli/src/appliers.ts @@ -111,7 +111,7 @@ async function applyEditorConfig(ctx: ToolContext): Promise { if (!existsSync(source)) { // Not installed (e.g. the install step failed), or installed by Plug'n'Play without a node_modules copy. - if (usesPlugNPlay(ctx.cwd, EDITORCONFIG_SOURCE)) { + if (!existsSync(target) && usesPlugNPlay(ctx.cwd, EDITORCONFIG_SOURCE)) { log.warn(t('editorconfigPlugNPlay')) } result.skipped.push(label) diff --git a/packages/cli/src/check/check-helpers.ts b/packages/cli/src/check/check-helpers.ts index c6c89bd..8aaaa58 100644 --- a/packages/cli/src/check/check-helpers.ts +++ b/packages/cli/src/check/check-helpers.ts @@ -89,12 +89,16 @@ export function inspectHelpers(lang: HelperLanguage): HelperResult { if (inDesignatedLocation(candidate.file, lang) || inFixtureProject(candidate.file, lang.units)) { continue } + const unit = unitOf(candidate.file, lang.units) + // A unit without a designated helper location (a multiplatform module) has nowhere to move a helper to. + if (unit != null && lang.layout.helperDirs(unit).length === 0) { + continue + } const importers = new Set([...lang.importers(candidate)].filter(f => !inFixtureProject(f, lang.units))) importers.delete(candidate.file) if (importers.size < 2) { continue } - const unit = unitOf(candidate.file, lang.units) if (unit == null) { unitless.push(candidate.file) continue diff --git a/packages/cli/src/check/check-test-paths.ts b/packages/cli/src/check/check-test-paths.ts index ceb69e8..fbaf777 100644 --- a/packages/cli/src/check/check-test-paths.ts +++ b/packages/cli/src/check/check-test-paths.ts @@ -108,7 +108,7 @@ function inspectUnitTestFile(file: string, unit: Unit, layout: TestLayout, fileS } const testRoot = unit.testRoots.find(root => isUnder(rel, root) && rel !== root) if (testRoot == null) { - return layout.reportOutsideRoot(rel) ? outsideRootFinding(file, unit, layout) : null + return layout.reportOutsideRoot(rel, unit) ? outsideRootFinding(file, unit, layout) : null } if (layout.helperDirs(unit).some(dir => isUnder(rel, dir))) { return null diff --git a/packages/cli/src/check/layouts.ts b/packages/cli/src/check/layouts.ts index 1c13574..cd076eb 100644 --- a/packages/cli/src/check/layouts.ts +++ b/packages/cli/src/check/layouts.ts @@ -24,6 +24,8 @@ export interface Unit { envSegments: string[] /** Fixture projects (a `package.json` under a test root) relative to `dir`; their test files are test data. */ fixtureDirs?: string[] + /** A Kotlin Multiplatform module (`src/Main`, `src/Test` source sets). */ + multiplatform?: boolean } /** @@ -43,8 +45,8 @@ export interface TestLayout { allowIndex: boolean /** Designated shared-helper directory, relative to the unit (per test root). */ helperDirs: (unit: Unit) => string[] - /** Report a test file found in `rel` (relative to its unit) outside the test roots? */ - reportOutsideRoot: (rel: string) => boolean + /** Report a test file found in `rel` (relative to `unit`) outside the test roots? */ + reportOutsideRoot: (rel: string, unit: Unit) => boolean } export function isUnder(path: string, dir: string): boolean { @@ -205,23 +207,27 @@ function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayou kmpSets.set(dir, (kmpSets.get(dir) ?? new Set()).add(`${k[2]}${k[3]}`)) } } - // A module with `src/commonMain/` is multiplatform: every `*Test` set is a test root, and a - // test may target any `*Main` set (an `iosTest` may test code in an intermediate `appleMain`). + // A module with `src/commonMain/`, or with `src/Main/` sets and no classic source set + // (an empty commonMain is not listed), is multiplatform: every `*Test` set is a test root, and a test + // may target any `*Main` set (an `iosTest` may test code in an intermediate `appleMain`). + const multiplatform = new Set() for (const [dir, sets] of kmpSets) { - if (sets.has('commonMain')) { + if (sets.has('commonMain') || (!dirs.has(dir) && [...sets].some(s => s.endsWith('Main')))) { + multiplatform.add(dir) dirs.add(dir) } } return [...dirs].map((dir) => { - const sets = kmpSets.get(dir) - const kmp = sets?.has('commonMain') === true ? [...sets].sort((a, b) => a.localeCompare(b)) : null + const kmp = multiplatform.has(dir) ? [...kmpSets.get(dir) ?? []].sort((a, b) => a.localeCompare(b)) : null + const mains = kmp?.filter(s => s.endsWith('Main')) ?? [] return { dir, testRoots: kmp == null ? [`src/test/${language}`] : kmp.filter(s => s.endsWith('Test')).map(s => `src/${s}/${language}`), - sourceRoots: kmp == null ? [`src/main/${language}`] : kmp.filter(s => s.endsWith('Main')).map(s => `src/${s}/${language}`), - alternateSourceRoots: kmp == null ? [`src/main/${sibling}`] : [], + sourceRoots: kmp == null ? [`src/main/${language}`] : mains.map(s => `src/${s}/${language}`), + alternateSourceRoots: kmp == null ? [`src/main/${sibling}`] : mains.map(s => `src/${s}/${sibling}`), keepRootName: false, envSegments: withConfig(rootDir, dir, base).envSegments, + multiplatform: kmp != null, } }) }, @@ -229,10 +235,11 @@ function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayou sourceStem: fileName => fileName.slice(0, -`Test${ext}`.length), sourceExtensions: [ext, siblingExt], allowIndex: false, - helperDirs: () => ['src/testFixtures'], + // Multiplatform modules have no `src/testFixtures`, so they have no designated helper location yet. + helperDirs: unit => unit.multiplatform === true ? [] : ['src/testFixtures'], // Only a test class inside production sources (`src/main`, or a multiplatform `src/*Main`) is misplaced; // other source sets (androidTest, integrationTest, …) are their own test roots. - reportOutsideRoot: rel => /^src\/(?:main|\w+Main)\//.test(rel), + reportOutsideRoot: (rel, unit) => unit.multiplatform === true ? /^src\/\w+Main\//.test(rel) : rel.startsWith('src/main/'), } } diff --git a/packages/cli/src/i18n.ts b/packages/cli/src/i18n.ts index 7e75069..1d1e108 100644 --- a/packages/cli/src/i18n.ts +++ b/packages/cli/src/i18n.ts @@ -48,7 +48,7 @@ const messages: Record = { 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.', - editorconfigPlugNPlay: 'Yarn Plug\'n\'Play has no node_modules, so .editorconfig was not copied from node_modules/@pleaseai/editorconfig. Copy it with: yarn node -e "require(\'fs\').copyFileSync(require.resolve(\'@pleaseai/editorconfig/.editorconfig\'), \'.editorconfig\')"', + editorconfigPlugNPlay: 'Yarn Plug\'n\'Play has no node_modules, so .editorconfig was not copied from node_modules/@pleaseai/editorconfig. Copy it with: yarn node -e "const fs = require(\'fs\'); fs.copyFileSync(require.resolve(\'@pleaseai/editorconfig/.editorconfig\'), \'.editorconfig\', fs.constants.COPYFILE_EXCL)"', doctorHeading: 'Project status', doctorOk: 'OK', doctorMissing: 'missing', @@ -104,7 +104,7 @@ Exit codes: 0 ok or warnings only, 1 findings with --strict, 2 usage or config e 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을 다시 실행하세요.', - editorconfigPlugNPlay: 'Yarn Plug\'n\'Play는 node_modules를 만들지 않아 node_modules/@pleaseai/editorconfig에서 .editorconfig를 복사하지 못했습니다. 다음 명령으로 복사하세요: yarn node -e "require(\'fs\').copyFileSync(require.resolve(\'@pleaseai/editorconfig/.editorconfig\'), \'.editorconfig\')"', + editorconfigPlugNPlay: 'Yarn Plug\'n\'Play는 node_modules를 만들지 않아 node_modules/@pleaseai/editorconfig에서 .editorconfig를 복사하지 못했습니다. 다음 명령으로 복사하세요: yarn node -e "const fs = require(\'fs\'); fs.copyFileSync(require.resolve(\'@pleaseai/editorconfig/.editorconfig\'), \'.editorconfig\', fs.constants.COPYFILE_EXCL)"', doctorHeading: '프로젝트 상태', doctorOk: '정상', doctorMissing: '없음', diff --git a/packages/cli/test/appliers.test.ts b/packages/cli/test/appliers.test.ts index 6f861c3..e2f4b39 100644 --- a/packages/cli/test/appliers.test.ts +++ b/packages/cli/test/appliers.test.ts @@ -125,6 +125,19 @@ describe('editorconfig tool', () => { warn.mockRestore() } }) + + test('does not warn under Plug\'n\'Play when .editorconfig already exists', async () => { + writeFileSync(join(dir, '.pnp.cjs'), '') + writeFileSync(join(dir, '.editorconfig'), 'root = true\n') + const warn = spyOn(log, 'warn').mockImplementation(() => {}) + try { + await findTool('editorconfig')!.apply({ cwd: dir, autoAccept: true }) + expect(warn).not.toHaveBeenCalled() + } + finally { + warn.mockRestore() + } + }) }) // --------------------------------------------------------------------------- diff --git a/packages/cli/test/check/check-helpers.test.ts b/packages/cli/test/check/check-helpers.test.ts index cdf6571..a876b21 100644 --- a/packages/cli/test/check/check-helpers.test.ts +++ b/packages/cli/test/check/check-helpers.test.ts @@ -180,6 +180,16 @@ describe('test-helpers-in-dedicated-location (Dart, Kotlin, Java)', () => { expect(findings).toEqual([]) }) + test('kotlin multiplatform: a shared helper is not reported (no designated location yet)', () => { + const findings = findingsFor({ + 'src/commonMain/kotlin/com/acme/Clock.kt': 'package com.acme', + 'src/commonTest/kotlin/com/acme/ClockTest.kt': 'package com.acme\nclass FakeClock\nclass ClockTest', + 'src/commonTest/kotlin/com/acme/InvoiceTest.kt': 'package com.acme\nclass InvoiceTest { val c = FakeClock() }', + 'src/jvmTest/kotlin/com/acme/PaymentTest.kt': 'package com.acme\nclass PaymentTest { val c = FakeClock() }', + }, SLUG) + expect(findings).toEqual([]) + }) + test.each(['ts', 'tsx'])('a default-exported helper is credited through default and namespace imports (.%s)', (ext) => { const findings = findingsFor({ ...PKG, diff --git a/packages/cli/test/check/check-test-paths.test.ts b/packages/cli/test/check/check-test-paths.test.ts index 38bdf5d..0b4e328 100644 --- a/packages/cli/test/check/check-test-paths.test.ts +++ b/packages/cli/test/check/check-test-paths.test.ts @@ -232,6 +232,23 @@ describe('test-path-derivable-from-source (Dart, Kotlin, Java)', () => { ]) }) + test('kotlin multiplatform: detected without commonMain sources; a test may target a Java source of a *Main set', () => { + const findings = findingsFor({ + 'shared/src/jvmMain/java/com/acme/Invoice.java': '', + 'shared/src/jvmMain/kotlin/com/acme/Payment.kt': '', + 'shared/src/jvmTest/kotlin/com/acme/InvoiceTest.kt': '', + 'shared/src/jvmTest/kotlin/com/acme/RefundTest.kt': '', + }, SLUG) + expect(files(findings)).toEqual(['shared/src/jvmTest/kotlin/com/acme/RefundTest.kt']) + }) + + test('kotlin: a test in a custom *Main set of a non-multiplatform module is not reported', () => { + expect(findingsFor({ + 'src/main/kotlin/com/acme/Invoice.kt': '', + 'src/benchMain/kotlin/com/acme/InvoiceTest.kt': '', + }, SLUG)).toEqual([]) + }) + test('java: src/main/java mirrors to src/test/java with Test.java', () => { const findings = findingsFor({ 'src/main/java/com/acme/Invoice.java': '', From 6e97282f9c0e7b13e5662b9ba3e2170673b19b03 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Tue, 13 Oct 2026 16:42:59 +0900 Subject: [PATCH 6/6] fix(cli): report tests in src/main of a multiplatform module --- packages/cli/src/check/layouts.ts | 2 +- packages/cli/test/check/check-test-paths.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/check/layouts.ts b/packages/cli/src/check/layouts.ts index cd076eb..907b904 100644 --- a/packages/cli/src/check/layouts.ts +++ b/packages/cli/src/check/layouts.ts @@ -239,7 +239,7 @@ function jvmLayout(language: 'kotlin' | 'java', ext: '.kt' | '.java'): TestLayou helperDirs: unit => unit.multiplatform === true ? [] : ['src/testFixtures'], // Only a test class inside production sources (`src/main`, or a multiplatform `src/*Main`) is misplaced; // other source sets (androidTest, integrationTest, …) are their own test roots. - reportOutsideRoot: (rel, unit) => unit.multiplatform === true ? /^src\/\w+Main\//.test(rel) : rel.startsWith('src/main/'), + reportOutsideRoot: (rel, unit) => rel.startsWith('src/main/') || (unit.multiplatform === true && /^src\/\w+Main\//.test(rel)), } } diff --git a/packages/cli/test/check/check-test-paths.test.ts b/packages/cli/test/check/check-test-paths.test.ts index 0b4e328..159ae21 100644 --- a/packages/cli/test/check/check-test-paths.test.ts +++ b/packages/cli/test/check/check-test-paths.test.ts @@ -225,10 +225,12 @@ describe('test-path-derivable-from-source (Dart, Kotlin, Java)', () => { 'shared/src/jvmTest/kotlin/com/acme/PaymentTest.kt': '', 'shared/src/androidInstrumentedTest/kotlin/com/acme/ScreenTest.kt': '', 'shared/src/commonMain/kotlin/com/acme/RefundTest.kt': '', + 'shared/src/main/kotlin/com/acme/LegacyTest.kt': '', }, SLUG) expect(findings.map(f => [f.kind, f.file])).toEqual([ ['test-outside-root', 'shared/src/commonMain/kotlin/com/acme/RefundTest.kt'], ['orphan-test', 'shared/src/jvmTest/kotlin/com/acme/PaymentTest.kt'], + ['test-outside-root', 'shared/src/main/kotlin/com/acme/LegacyTest.kt'], ]) })