From d072dc699b4ea27d5e24f830cc7761c005cf83a1 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Sat, 8 Aug 2026 12:24:07 +0900 Subject: [PATCH 1/4] feat(core): govern lowercase units in architecture/reserved-directory-names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isUnitDir's letter test recognises only capitalised (.svelte-entry) units, so a .ts- or .svelte.ts-entry unit's children were never governed by any unitScopes declaration — measured at 129 of 299 units (43%) on a real tree (issue #386). Add anyCaseUnitScopes, a string-map counterpart to unitScopes gated on isAnyCaseUnitDir (isUnitDir without the letter test) instead of isUnitDir, following the naming rationale architecture/reserved-name-placement records for its own capitalisedUnitPlacements/anyCaseUnitPlacements split: the bare word "unit" is ambiguous once both predicates exist. An identical glob declared in both unit maps partitions rather than collides: unitScopes's gate is a strict subset of anyCaseUnitScopes's, so unitScopes governs at capitalised units and anyCaseUnitScopes governs alone at the lowercase ones unitScopes never reaches — letting one glob express a capitalised-superset / lowercase-subset convention. scopes still beats both unit maps on a tie, unchanged. The tri-state unused-key diagnostics, examined counts (this rule records none, matching unitScopes), and the tie-break are covered by 14 new tests. Co-Authored-By: Claude Fable 5 --- ...reserved-directory-names-any-case-units.md | 16 ++ .../architecture/reserved-directory-names.md | 80 +++++-- .../architecture/reserved-directory-names.md | 77 +++++-- ...6-07-29-reserved-directory-names-design.md | 4 + ...26-08-06-reserved-name-placement-design.md | 5 + .../architecture/reserved-directory-names.ts | 209 ++++++++++++------ .../test/reserved-directory-names.test.ts | 164 ++++++++++++++ 7 files changed, 441 insertions(+), 114 deletions(-) create mode 100644 .changeset/reserved-directory-names-any-case-units.md diff --git a/.changeset/reserved-directory-names-any-case-units.md b/.changeset/reserved-directory-names-any-case-units.md new file mode 100644 index 000000000..72ad2af1c --- /dev/null +++ b/.changeset/reserved-directory-names-any-case-units.md @@ -0,0 +1,16 @@ +--- +'@svelte-vitals/core': minor +--- + +Add `anyCaseUnitScopes` to `architecture/reserved-directory-names`: a counterpart to `unitScopes` that +governs units whose name does not begin A–Z. + +`unitScopes` identifies a unit with `isUnitDir`, which requires the directory name to begin A–Z as well +as holding a same-stemmed child file — so a lowercase, `.ts`- or `.svelte.ts`-entry unit's children (measured +at 129 of 299 units, 43%, on a real tree) were never governed by any declaration. `anyCaseUnitScopes` takes +the same option shape against `isAnyCaseUnitDir`, the same test without the letter requirement. Declaring +the identical glob in both maps is not a collision: `unitScopes` governs at capitalised units, +`anyCaseUnitScopes` governs alone at the lowercase ones `unitScopes` never reaches. + +Default behavior is unchanged — `anyCaseUnitScopes` defaults to `{}`, so a project that does not declare it +sees no new findings. diff --git a/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md b/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md index dbe0e22ed..aae69ed27 100644 --- a/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md +++ b/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md @@ -31,11 +31,12 @@ description: ディレクトリの直下に置ける名前は、その位置に ## 設定 -| オプション | 型 | デフォルト | -| ------------ | --------------------------------------------- | ---------- | -| `scopes` | ディレクトリ glob → 直下に置ける名前 のマップ | `{}` | -| `unitScopes` | 起点 glob → 直下に置ける名前 のマップ | `{}` | -| `exclude` | ディレクトリ glob のリスト | `[]` | +| オプション | 型 | デフォルト | +| ------------------- | ------------------------------------------------------------------------- | ---------- | +| `scopes` | ディレクトリ glob → 直下に置ける名前 のマップ | `{}` | +| `unitScopes` | 起点 glob → 直下に置ける名前 のマップ(名前が A–Z で始まるユニット用) | `{}` | +| `anyCaseUnitScopes` | 起点 glob → 直下に置ける名前 のマップ(大文字小文字を問わないユニット用) | `{}` | +| `exclude` | ディレクトリ glob のリスト | `[]` | ```js // svelte-vitals.config.js @@ -44,14 +45,15 @@ export default { 'architecture/reserved-directory-names': { options: { scopes: { 'src/lib': 'api|components|features|effect|db' }, - unitScopes: { 'src/**': 'parts|functions|stores|types|tests|styleGuide' } + unitScopes: { 'src/**': 'parts|functions|stores|types|tests|styleGuide' }, + anyCaseUnitScopes: { 'src/**': 'functions|stores|types|tests' } } } } }; ``` -### 2 つのオプションはキーが何を指すかが違う +### 3 つのオプションはキーが何を指すかが違う **`scopes` のキーは親を直接指します。** `'src/lib'` は `src/lib` にマッチし、その直下のサブ ディレクトリが取り得る名前は、あなたが列挙したものになります。 @@ -62,6 +64,15 @@ export default { `Card/Card.svelte.ts`)。glob が届かない対象 —— ユニットは任意の深さで入れ子になるため —— に対する 閉じた集合には、これを使ってください。 +**`anyCaseUnitScopes` のキーも起点を指しますが、大文字小文字を問わないユニットを対象にします** —— +文字の条件を除いた同じ判定で、`.ts` や `.svelte.ts` を entry とするユニット +(`formatDate/formatDate.ts`、`useThing/useThing.svelte.ts`)も数えます。`unitScopes` の文字条件が +認識するのは大文字始まり・`.svelte` entry のユニットだけなので、このオプションがなければ小文字 +ユニットの子はここでのどの宣言にも検査されません —— 実測では 299 ユニット中 129(43%)が該当しました。 +どちらのユニットオプションも裸の「unit」という語では命名していません —— 同じ分割を自身のオプションに +採用している `architecture/reserved-name-placement` が、両方の判定が存在するとその語だけでは曖昧に +なる理由を記録しています。 + `scopes` のキーは、子が**すべて**列挙した名前から成る場合にのみ書く価値があります。ルート ディレクトリは予約名とルートセグメントを並べて持ちますが、ルートセグメントは無制限です —— ページごとに 1 つ —— ので、そこに宣言を置くべきではありません。それでも書けば、すべてのセグメントが報告されます。 @@ -77,17 +88,35 @@ export default { ### どの宣言が優先されるか -両方のマップが 1 つのディレクトリにマッチした場合は、より特異なキーが優先されます。パスの +複数のマップが 1 つのディレクトリにマッチした場合は、より特異なキーが優先されます。パスの セグメント数が多いほうが先、同数なら `**` セグメントが少ないほう、それも同じならキーが長いほう、 -最後に辞書順です。これによって、どちらのマップも他方を狭めることができます。 +最後に辞書順です。これによって、どのマップも他方を狭めることができます。 + +**同一の** glob だけは、この手順では分けられない組み合わせで、そこでは固定の優先順位が決めます —— +**`scopes` はどちらのユニットマップにも優先し、`unitScopes` は `anyCaseUnitScopes` に優先します。** + +`scopes` がユニットマップに優先するのは、`scopes` がキーのマッチするすべてのディレクトリに適用される +のに対し、ユニットマップはそれぞれが求める大文字小文字のユニットにしか適用されないからです。同じ +glob を `scopes` とユニットマップの両方に宣言すると報告されます。両方が宣言されている範囲では +`scopes` が優先されるため、そこではユニットマップ側のエントリは何もチェックしません。(それ以外の +場所ではなお適用され得ます —— たとえばグローバルに宣言した `unitScopes` のキーが、`overrides` +エントリの中だけで追加された `scopes` のキーに覆われている場合、そのオーバーライドの適用範囲の外 +では引き続き適用されます。) + +`unitScopes` が `anyCaseUnitScopes` に優先するのは、`unitScopes` の文字条件のほうが 2 つのゲートの +うち狭いからです —— 大文字始まりのユニットは常に大文字小文字を問わないユニットでもありますが、逆は +成り立ちません —— そのため両方のマップに同一の glob を書くと、衝突ではなく**分割**になります。 +`unitScopes` は大文字始まりのユニットを対象にし、`anyCaseUnitScopes` は `unitScopes` が届かない +小文字のユニットだけを単独で対象にします。両方のエントリが実際に仕事をしているため、これは無効な +宣言として報告されません。 -**同一の** glob だけは、この手順では分けられない唯一の組み合わせで、そこでは `scopes` が優先されます -—— `scopes` はキーがマッチするすべてのディレクトリに適用されるのに対し、`unitScopes` はそのうち -ユニットであるものにしか適用されないからです。同じ glob を両方のマップに宣言すると報告されます。 -両方が宣言されている範囲では `scopes` が優先されるため、そこでは `unitScopes` 側のエントリは何も -チェックしません。(それ以外の場所ではなお適用され得ます —— たとえばグローバルに宣言した -`unitScopes` のキーが、`overrides` エントリの中だけで追加された `scopes` のキーに覆われている場合、 -そのオーバーライドの適用範囲の外では引き続き適用されます。) +```js +options: { + // 大文字始まりのユニットには parts と styleGuide も許可し、小文字のユニットには許可しない + unitScopes: { 'src/**': 'parts|styleGuide|functions|stores|types|tests' }, + anyCaseUnitScopes: { 'src/**': 'functions|stores|types|tests' } +} +``` **末尾**の `/**` は「このディレクトリ配下すべて」を意味し、ディレクトリ自身は対象にしません。 @@ -128,22 +157,29 @@ options: { このルールが言うのは「ここでは、これらの名前だけ」であり、「この名前は、ここだけ」とは言えません。 間違った場所にある `parts/` は、その場所自体が宣言されていない限り見えないままです。 -**ユニットの直下にユニットを入れ子にするプロジェクトは `unitScopes` を宣言すべきではありません** -—— 入れ子になったユニットは集合に含まれない子となり、報告されてしまいます。 +**ユニットの直下にユニットを入れ子にするプロジェクトは `unitScopes` も `anyCaseUnitScopes` も +宣言すべきではありません** —— 入れ子になったユニットは集合に含まれない子となり、報告されてしまいます。 宣言が書いてある内容を実際には検査していない場合は報告されるので、書き間違いでルールが黙って何も -しない状態にはなりません。次の 5 つがこの finding に該当し、それぞれメッセージに名前が出ます。 +しない状態にはなりません。次のケースがこの finding に該当し、それぞれメッセージに名前が出ます。 - glob が 1 つのディレクトリにもマッチしなかった - マッチしたディレクトリがすべて除外されていた -- ディレクトリにはマッチしたがユニットには一度もマッチしなかった +- `unitScopes` のキーがディレクトリにはマッチしたがユニットには一度もマッチしなかった +- `anyCaseUnitScopes` のキーがディレクトリにはマッチしたが、大文字小文字を問わずユニットに一度も + マッチしなかった —— 大文字始まりのユニットは常に大文字小文字を問わないユニットでもあるため、 + こちらのほうが強い主張です - 値が名前を 1 つも列挙していなかった -- 同じ glob が両方のマップに宣言されており、**双方**の値が 1 つ以上の名前を挙げていた。どちらかの値が何も挙げていない場合はマッチ前に捨てられ、もう一方が単独で適用されるため、代わりに「値が名前を 1 つも列挙していない」として報告されます +- 同じ glob が `scopes` とユニットマップの両方に宣言されており、**双方**の値が 1 つ以上の名前を + 挙げていた。どちらかの値が何も挙げていない場合はマッチ前に捨てられ、もう一方が単独で適用される + ため、代わりに「値が名前を 1 つも列挙していない」として報告されます。**2 つのユニットマップ**の + 両方に同じ glob を宣言した場合はこのケースに当たりません —— 上の「どの宣言が優先されるか」を + 参照してください。 意図的にまったく報告されないものが 2 つあります。 - `overrides` エントリの**中だけ**で宣言したキー。何にマッチしたかがそのオーバーライドの適用範囲に 依存するためです。ただし 1 つ例外があり、同一 glob の衝突検査はグローバル宣言に絞られていないため、 - `overrides` だけから組み上がった `scopes`/`unitScopes` の衝突は報告されます。 + `overrides` だけから組み上がった `scopes` とユニットマップの衝突は報告されます。 - 現在どのディレクトリも使っていない宣言済みの名前。この集合が表すのは現れて**よい**ものであって、 現れなければならないものではないからです。 diff --git a/docs/src/content/docs/rules/architecture/reserved-directory-names.md b/docs/src/content/docs/rules/architecture/reserved-directory-names.md index cd8c126a0..f302f8561 100644 --- a/docs/src/content/docs/rules/architecture/reserved-directory-names.md +++ b/docs/src/content/docs/rules/architecture/reserved-directory-names.md @@ -30,11 +30,12 @@ declaration — deciding to widen the set is a legitimate outcome, as long as it ## Configuration -| Option | Type | Default | -| ------------ | ------------------------------------------- | ------- | -| `scopes` | map of directory glob → allowed child names | `{}` | -| `unitScopes` | map of root glob → allowed child names | `{}` | -| `exclude` | list of directory globs | `[]` | +| Option | Type | Default | +| ------------------- | ----------------------------------------------------------------------- | ------- | +| `scopes` | map of directory glob → allowed child names | `{}` | +| `unitScopes` | map of root glob → allowed child names, for units whose name begins A–Z | `{}` | +| `anyCaseUnitScopes` | map of root glob → allowed child names, for units of either case | `{}` | +| `exclude` | list of directory globs | `[]` | ```js // svelte-vitals.config.js @@ -43,14 +44,15 @@ export default { 'architecture/reserved-directory-names': { options: { scopes: { 'src/lib': 'api|components|features|effect|db' }, - unitScopes: { 'src/**': 'parts|functions|stores|types|tests|styleGuide' } + unitScopes: { 'src/**': 'parts|functions|stores|types|tests|styleGuide' }, + anyCaseUnitScopes: { 'src/**': 'functions|stores|types|tests' } } } } }; ``` -### The two options differ in what their key names +### The three options differ in what their key names **A `scopes` key names the parent directly.** `'src/lib'` matches `src/lib`, and the names its immediate subdirectories may take are the ones you list. @@ -61,6 +63,14 @@ and which holds a file named after it (`Card/Card.svelte`, `Card/Card.ts`, `Card it for a closed set that hangs off something a glob cannot reach, because units nest to arbitrary depth. +**An `anyCaseUnitScopes` key names a root the same way, but governs units of either case** — the same +test without the letter requirement, so a `.ts`- or `.svelte.ts`-entry unit (`formatDate/formatDate.ts`, +`useThing/useThing.svelte.ts`) counts too. `unitScopes`'s letter test recognises only capitalised, +`.svelte`-entry units, so without this option a lowercase unit's children were never checked by any +declaration here — measured at 129 of 299 units (43%) on a real tree. Neither unit option is named with +the bare word "unit": `architecture/reserved-name-placement`, which takes the same split for its own +options, records why that word alone is ambiguous once both predicates exist. + A `scopes` key is only worth writing where the children are **entirely** drawn from the names you list. A route directory holds its reserved names beside its route segments, and route segments are unbounded — one per page — so no declaration belongs there. Writing one anyway reports every segment. @@ -76,16 +86,32 @@ closed set; there is no single table. ### Which declaration wins -When both maps match one directory, the most specific key governs: more path segments first, then -fewer `**` segments, then the longer key, then alphabetically first. That is what lets either map -narrow the other. +When more than one map matches one directory, the most specific key governs: more path segments +first, then fewer `**` segments, then the longer key, then alphabetically first. That is what lets any +map narrow another. + +Two **identical** globs are the only pair those steps cannot separate, and there a fixed priority +decides: **`scopes` beats both unit maps, and `unitScopes` beats `anyCaseUnitScopes`.** + +`scopes` wins over either unit map because it applies to every directory its key matches, while a unit +map applies only to the units of its required case. Declaring the same glob in `scopes` and a unit map +is reported: `scopes` wins wherever both are declared, so the unit map's entry does nothing there. (It +can still govern elsewhere — e.g. a `unitScopes` key declared globally and shadowed by a `scopes` key +added only inside an `overrides` entry still governs outside that override's scope.) + +`unitScopes` wins over `anyCaseUnitScopes` because `unitScopes`'s letter test is the narrower of the +two gates — every capitalised unit is also an any-case unit, never the reverse — so the identical glob +in both maps **partitions** rather than collides: `unitScopes` governs at capitalised units, +`anyCaseUnitScopes` governs alone at the lowercase ones `unitScopes` never reaches. This is not +reported as a dead declaration, because both entries do real work: -Two **identical** globs are the only pair those steps cannot separate, and there `scopes` wins — -because it applies to every directory its key matches, while `unitScopes` applies only to the ones -that are units. Declaring the same glob in both maps is reported: `scopes` wins wherever both are -declared, so the `unitScopes` entry does nothing there. (It can still govern elsewhere — e.g. a -`unitScopes` key declared globally and shadowed by a `scopes` key added only inside an `overrides` -entry still governs outside that override's scope.) +```js +options: { + // capitalised units get parts and styleGuide too; lowercase units do not + unitScopes: { 'src/**': 'parts|styleGuide|functions|stores|types|tests' }, + anyCaseUnitScopes: { 'src/**': 'functions|stores|types|tests' } +} +``` A **trailing** `/**` means "everything under this directory" and never governs the directory itself. @@ -126,22 +152,27 @@ Both answers are consistent with each rule's own definition. The rule says "here, only these names". It cannot say "this name, only here": a `parts/` in the wrong place is invisible unless that place is itself declared. -**A project that nests units directly inside units should not declare `unitScopes`** — the nested unit -is a child not in the set, and would be reported. +**A project that nests units directly inside units should not declare `unitScopes` or +`anyCaseUnitScopes`** — the nested unit is a child not in the set, and would be reported. A declaration that is not checking what it says is reported, so a typo cannot leave the rule silently -doing nothing. Five cases land in that finding, each named in the message: +doing nothing. These cases land in that finding, each named in the message: - the glob matched no directory; - every directory it matched is excluded; -- it matched directories but never a unit; +- a `unitScopes` key matched directories but never a unit; +- an `anyCaseUnitScopes` key matched directories but never a unit of either case — the stronger claim, + since every capitalised unit is also an any-case unit; - the value lists no name at all; -- the same glob is declared in both maps, with **both** values naming at least one directory. If either value names nothing it is dropped before matching, so the other governs alone and the empty-value reason reports instead. +- the same glob is declared in `scopes` and a unit map, with **both** values naming at least one + directory. If either value names nothing it is dropped before matching, so the other governs alone + and the empty-value reason reports instead. The same glob declared in **both unit maps** is not this + case — see "Which declaration wins" above. Two things are never reported: - A declaration written **only** inside an `overrides` entry, since whether it matched anything depends on which paths the override applies to. One exception: the identical-glob collision check - is not narrowed to globally declared keys, so a `scopes`/`unitScopes` collision assembled entirely - from `overrides` entries is still reported. + is not narrowed to globally declared keys, so a collision between `scopes` and a unit map assembled + entirely from `overrides` entries is still reported. - A declared name no directory currently uses — the set says what **may** appear, not what must. diff --git a/docs/superpowers/specs/2026-07-29-reserved-directory-names-design.md b/docs/superpowers/specs/2026-07-29-reserved-directory-names-design.md index 24a494b38..8c70b71f2 100644 --- a/docs/superpowers/specs/2026-07-29-reserved-directory-names-design.md +++ b/docs/superpowers/specs/2026-07-29-reserved-directory-names-design.md @@ -376,6 +376,10 @@ cascade the unit definition exists to prevent. a per-scope escape the option kinds cannot express today, and a candidate for the second rule-options iteration. M4 was listed here too until 2026-08-06; it is not blocked, per the note above. + Closed 2026-08-08 by issue #386: `anyCaseUnitScopes` takes the same split — `isAnyCaseUnitDir`, + the letter test dropped — as a second option map beside `unitScopes`, governing the 129 of 299 + units (43%) the letter test alone left unchecked. + - **A project that nests units directly inside units** should not declare `unitScopes`: the nested unit is a child not in the set, and would be reported. The rule page says so. - **A key that matched directories but won at none of them**, because a more specific key always beat diff --git a/docs/superpowers/specs/2026-08-06-reserved-name-placement-design.md b/docs/superpowers/specs/2026-08-06-reserved-name-placement-design.md index 3d8cf8fce..22e37776c 100644 --- a/docs/superpowers/specs/2026-08-06-reserved-name-placement-design.md +++ b/docs/superpowers/specs/2026-08-06-reserved-name-placement-design.md @@ -388,5 +388,10 @@ counterpart — `packages/cli/test/docs-links.test.ts` fails without both — th - **The `isUnitDir` mismatch in the sibling rules.** `reserved-directory-names` records that names under a lowercase unit "are never checked" as a coverage limit. The same split would close it there. Out of scope here, and now cross-referenced rather than left as two independent notes. + + Closed 2026-08-08 by issue #386: `reserved-directory-names` gained `anyCaseUnitScopes`, an + `isAnyCaseUnitDir`-gated counterpart to `unitScopes`, following this design's naming rationale — + neither unit option there is named with the bare word "unit" either. + - **The initial finding count.** Zero on the measured tree. The value is regression detection during the next reorganisation, not a backlog to clear. diff --git a/packages/core/src/rules/architecture/reserved-directory-names.ts b/packages/core/src/rules/architecture/reserved-directory-names.ts index 8aa175296..f8d8281f3 100644 --- a/packages/core/src/rules/architecture/reserved-directory-names.ts +++ b/packages/core/src/rules/architecture/reserved-directory-names.ts @@ -32,6 +32,7 @@ const recommendation = 'Use one of the names this location declares, or add the const OPTIONS: RuleOptionsSpec = { scopes: { kind: 'string-map', default: {} }, unitScopes: { kind: 'string-map', default: {} }, + anyCaseUnitScopes: { kind: 'string-map', default: {} }, exclude: { kind: 'string-list', default: [] } }; @@ -75,13 +76,32 @@ export function isAnyCaseUnitDir(dir: string, filesIn: Map): b return own !== undefined && own.some((f) => stem(f) === name); } +/** The three maps this rule compares, and the priority that breaks a byte-identical-glob tie. */ +type MapKind = 'scopes' | 'unitScopes' | 'anyCaseUnitScopes'; +// `scopes` has no eligibility gate at all, so wherever any unit map's glob matches, an identical +// `scopes` key matches too and must win — the same reasoning the two-map rule already recorded: +// preferring the gate-free map keeps a glob's outcome uniform instead of depending on a per-directory +// property. `unitScopes`'s gate (isUnitDir) is a strict subset of `anyCaseUnitScopes`'s (isAnyCaseUnitDir) +// — every capitalised unit is also an any-case unit — so on an identical glob `unitScopes` is the +// narrower, more specific claim and wins there too, letting one glob partition a governed name into a +// capitalised superset and an any-case subset (design 2026-08-06's worked example, ported to this rule's +// single-governing-set shape rather than that rule's union shape). +const PRIORITY: Record = { scopes: 0, unitScopes: 1, anyCaseUnitScopes: 2 }; + /** * architecture/reserved-directory-names — a directory's immediate subdirectories may only take names - * the project declared for that position (design 2026-07-29). L3: inert until a scope is declared. + * the project declared for that position (design 2026-07-29, extended 2026-08-08 for lowercase units — + * issue #386). * - * Two option maps, differing in what their key names. A `scopes` key names the parent directly. A + * Three option maps, differing in what their key names. A `scopes` key names the parent directly. A * `unitScopes` key names a root, and the rule governs the children of whichever directories beneath - * it are units — the shape a glob cannot reach, because units nest to arbitrary depth. + * it are units whose name begins A–Z — the shape a glob cannot reach, because units nest to arbitrary + * depth. An `anyCaseUnitScopes` key names a root the same way, but governs units of *either* case: + * `isUnitDir`'s letter test recognises only capitalised (`.svelte`-entry) units, so without this map a + * `.ts`- or `.svelte.ts`-entry unit's children were never checked by any declaration here — measured + * at 129 of 299 units (43%) on a real tree. Neither map is named with the bare word "unit": the + * sibling rule `architecture/reserved-name-placement` records why that word alone is ambiguous between + * the two predicates once both exist. * * There are no pass results. `computeScore` seeds every distinct `route` at 100 and averages, and the * subject here is a directory with no pre-existing score key, so a pass per directory would add @@ -128,41 +148,67 @@ export const architectureReservedDirectoryNames: Rule = { const globalOptions = resolveRuleOptions(ID, OPTIONS, ctx.config); const globalScopes = mapOption(globalOptions, 'scopes'); const globalUnits = mapOption(globalOptions, 'unitScopes'); - const globalKeys = new Set([...Object.keys(globalScopes), ...Object.keys(globalUnits)]); + const globalAnyUnits = mapOption(globalOptions, 'anyCaseUnitScopes'); + const globalKeys = new Set([ + ...Object.keys(globalScopes), + ...Object.keys(globalUnits), + ...Object.keys(globalAnyUnits) + ]); const usedKeys = new Set(); // Collected so the deferred classification can tell an unmatched key from a shadowed one, and a - // `unitScopes` key that never met a unit from either. Neither list is consulted unless some key - // ends the run with no work recorded. + // unit-map key that never met a directory of its required case from either. Neither list is + // consulted unless some key ends the run with no work recorded. const excludedDirs: string[] = []; const nonUnitDirs: string[] = []; - // A glob in both maps is a property of the options, not of the tree. Checked against the global - // resolution — which catches it even when no directory is examined — and against each - // per-directory resolution, which is where an `overrides` entry's contribution appears. - // Not restricted to `globalKeys`, unlike the inertness check below. A collision is a property of - // the resolved option keys and needs no intersection with the directory set, so it is reported - // even when both halves arrive from an `overrides` entry — which is the likeliest way it happens. - const collisions = new Set(); - const noteCollisions = (scopesMap: Record, unitMap: Record) => { + const nonAnyUnitDirs: string[] = []; + // A glob shared between `scopes` and a unit map is a property of the options, not of the tree, and + // is always a full collision: `scopes` has no eligibility gate, so an identical unit-map key never + // wins anywhere it matched. A glob shared between the two unit maps is NOT automatically a + // collision — `unitScopes`'s gate is a strict subset of `anyCaseUnitScopes`'s, so the any-case + // entry keeps real work at any-case units the letter test excludes, which is the partition this + // extension exists to enable. Checked against the global resolution — which catches it even when + // no directory is ever examined — and again against each per-directory resolution, which is where + // an `overrides` entry's contribution appears. + const collisions = new Map(); + const collisionMessage = (losers: string[]): string => { + const maps = ['scopes', ...losers]; + const list = maps.length === 2 ? `both ${maps[0]} and ${maps[1]}` : maps.join(', '); + return `declared in ${list}, so the scopes entry wins wherever ${losers.length > 1 ? 'they' : 'both'} apply`; + }; + const noteCollisions = ( + scopesMap: Record, + unitMap: Record, + anyUnitMap: Record + ) => { for (const key of Object.keys(scopesMap)) { - if (!Object.hasOwn(unitMap, key)) continue; // A value naming nothing is dropped before matching, so whichever side still names something - // governs alone and there is no contest to report — the claim below would be false. Both - // sides are checked, not just `scopes`: the two directions are the same failure wearing - // opposite labels. Skipping here also lets the empty-value reason report the real error, - // which it cannot do for a key that already carries a note. + // governs alone and there is no contest to report — the claim below would be false. The + // empty-value reason reports the real error instead, which it cannot do for a key that + // already carries a note. if (namesOf(scopesMap[key] as string).length === 0) continue; - if (namesOf(unitMap[key] as string).length === 0) continue; - collisions.add(key); + const losers: string[] = []; + if (Object.hasOwn(unitMap, key) && namesOf(unitMap[key] as string).length > 0) losers.push('unitScopes'); + if (Object.hasOwn(anyUnitMap, key) && namesOf(anyUnitMap[key] as string).length > 0) { + losers.push('anyCaseUnitScopes'); + } + if (losers.length > 0) collisions.set(key, collisionMessage(losers)); } }; - noteCollisions(globalScopes, globalUnits); + noteCollisions(globalScopes, globalUnits, globalAnyUnits); for (const dir of [...dirs].sort()) { const o = resolveRuleOptions(ID, OPTIONS, ctx.config, { route: dir, file: dir }, compiledOverrides); const scopes = mapOption(o, 'scopes'); const unitScopes = mapOption(o, 'unitScopes'); - if (Object.keys(scopes).length === 0 && Object.keys(unitScopes).length === 0) continue; // inert - noteCollisions(scopes, unitScopes); + const anyCaseUnitScopes = mapOption(o, 'anyCaseUnitScopes'); + if ( + Object.keys(scopes).length === 0 && + Object.keys(unitScopes).length === 0 && + Object.keys(anyCaseUnitScopes).length === 0 + ) { + continue; // inert + } + noteCollisions(scopes, unitScopes, anyCaseUnitScopes); // Exclusion first. On the violation path this is now belt-and-braces — the per-child check // below consults this same resolved list against the child's ancestors, and the parent is @@ -177,41 +223,61 @@ export const architectureReservedDirectoryNames: Rule = { // A key naming nothing at all is dropped before matching, so a typo cannot win on specificity // and then apply an empty set — under which every child would be reported against a - // requirement naming no name. `unitScopes` keys are eligible only where the directory is a - // unit, which is that map's whole identification criterion. + // requirement naming no name. Unit-map keys are eligible only where the directory is a unit of + // the map's required case, which is that map's whole identification criterion. const liveScopes = Object.keys(scopes).filter((k) => namesOf(scopes[k] as string).length > 0); const isUnit = isUnitDir(dir, filesIn); + const isAnyUnit = isAnyCaseUnitDir(dir, filesIn); const liveUnits = isUnit ? Object.keys(unitScopes).filter((k) => namesOf(unitScopes[k] as string).length > 0) : []; + const liveAnyUnits = isAnyUnit + ? Object.keys(anyCaseUnitScopes).filter((k) => namesOf(anyCaseUnitScopes[k] as string).length > 0) + : []; if (!isUnit) nonUnitDirs.push(dir); + if (!isAnyUnit) nonAnyUnitDirs.push(dir); const byPosition = matchKeys(dir, compile(liveScopes, true)); const byUnit = matchKeys(dir, compile(liveUnits, true)); - // Recorded for every surviving match, whether or not the key won the comparison: in both - // cases the key identified the directory and a check ran. + const byAnyUnit = matchKeys(dir, compile(liveAnyUnits, true)); + // Recorded for every surviving match, whether or not the key won the comparison: in every case + // the key identified the directory and a check ran. for (const k of byPosition.matched) if (globalKeys.has(k)) usedKeys.add(k); for (const k of byUnit.matched) if (globalKeys.has(k)) usedKeys.add(k); + for (const k of byAnyUnit.matched) if (globalKeys.has(k)) usedKeys.add(k); - // Both kinds of key match the same directory — the parent whose children are governed — so - // their specificity is comparable and it decides, rather than one kind outranking the other. - // `moreSpecificGlob` is false in both directions only for two identical globs, since its last - // step is lexicographic on the whole key; that is the one case it cannot settle, and it falls - // to `scopes` because `scopes` applies to every directory its key matches while `unitScopes` - // applies only to the ones that are units — so preferring it keeps a single glob's outcome - // uniform across its matches. - let governing: string[] | undefined; - if (byPosition.best !== undefined && byUnit.best !== undefined) { - governing = moreSpecificGlob(byUnit.best, byPosition.best) - ? namesOf(unitScopes[byUnit.best] as string) - : namesOf(scopes[byPosition.best] as string); - } else if (byPosition.best !== undefined) { - governing = namesOf(scopes[byPosition.best] as string); - } else if (byUnit.best !== undefined) { - governing = namesOf(unitScopes[byUnit.best] as string); + // All three kinds of key match the same directory — the parent whose children are governed — + // so their specificity is comparable and it decides, rather than one kind outranking another. + // `moreSpecificGlob` is false in both directions only for two identical globs, which is the one + // case it cannot settle; `PRIORITY` breaks it there. + const candidates: { kind: MapKind; best: string; names: string[] }[] = []; + if (byPosition.best !== undefined) { + candidates.push({ kind: 'scopes', best: byPosition.best, names: namesOf(scopes[byPosition.best] as string) }); + } + if (byUnit.best !== undefined) { + candidates.push({ + kind: 'unitScopes', + best: byUnit.best, + names: namesOf(unitScopes[byUnit.best] as string) + }); } - if (governing === undefined) continue; + if (byAnyUnit.best !== undefined) { + candidates.push({ + kind: 'anyCaseUnitScopes', + best: byAnyUnit.best, + names: namesOf(anyCaseUnitScopes[byAnyUnit.best] as string) + }); + } + let winner: (typeof candidates)[number] | undefined; + for (const c of candidates) { + if (winner === undefined || moreSpecificGlob(c.best, winner.best)) { + winner = c; + } else if (!moreSpecificGlob(winner.best, c.best) && PRIORITY[c.kind] < PRIORITY[winner.kind]) { + winner = c; + } + } + if (winner === undefined) continue; - const allowed = new Set(governing); + const allowed = new Set(winner.names); for (const child of kids.get(dir) ?? []) { if (allowed.has(baseName(child))) continue; // Both the parent's resolved exclusions and the child's own. An `overrides` entry scoped to @@ -244,7 +310,7 @@ export const architectureReservedDirectoryNames: Rule = { detection: { presence: 'none', value: 'absent' }, route: child, location: at, - message: `${child} is not one of the names declared here: ${governing.join(', ')}.`, + message: `${child} is not one of the names declared here: ${winner.names.join(', ')}.`, recommendation, docsUrl, fix: { @@ -260,38 +326,43 @@ export const architectureReservedDirectoryNames: Rule = { // suppressing one would silently suppress the rest. // // The two options-derived reasons are decided FIRST. A key they name has no recorded work by - // construction — a colliding `unitScopes` entry never governs, and a key naming nothing is - // dropped before matching — so feeding either to the traversal classification would label a - // configuration contradiction "matched no directory". + // construction — a colliding entry never governs, and a key naming nothing is dropped before + // matching — so feeding either to the traversal classification would label a configuration + // contradiction "matched no directory". const notes = new Map(); - for (const key of collisions) { - notes.set(key, 'declared in both scopes and unitScopes, so the scopes entry wins wherever both apply'); - } + for (const [key, message] of collisions) notes.set(key, message); for (const key of globalKeys) { if (notes.has(key)) continue; - // Both maps, not whichever holds the key first. A key present in both with one empty value is - // exactly the case the collision check declines, and reading only one side would leave it with - // no note at all: the other side is doing real work, so `usedKeys` absorbs the key and the - // unused classification below never sees it either. + // Every map, not whichever holds the key first. A key present in more than one with one empty + // value is exactly the case the collision check declines, and reading only one side would leave + // it with no note at all when another side is doing real work — `usedKeys` absorbs the key and + // the unused classification below never sees it either. const scopesEmpty = Object.hasOwn(globalScopes, key) && namesOf(globalScopes[key] as string).length === 0; const unitsEmpty = Object.hasOwn(globalUnits, key) && namesOf(globalUnits[key] as string).length === 0; - if (scopesEmpty || unitsEmpty) { + const anyUnitsEmpty = Object.hasOwn(globalAnyUnits, key) && namesOf(globalAnyUnits[key] as string).length === 0; + if (scopesEmpty || unitsEmpty || anyUnitsEmpty) { notes.set(key, 'names no directory name at all'); } } const unused = [...globalKeys].filter((k) => !notes.has(k) && !usedKeys.has(k)); - // A `unitScopes`-only key is recorded solely by matching a unit, so one that matched a non-unit - // and nothing else identified nothing. That is the same distinction `pascalCaseUnits` draws in - // `architecture/unit-entry-file`, where the casing gate is the identification criterion; here - // the gate is the unit test. Decided before the excluded/unmatched split, so an exclusion is - // never blamed for a key the unit test disqualified. - // This ordering is also what keeps the excluded label honest, and is why no separate - // "matched something surviving" set is needed here. `usedKeys` is narrower than "matched a - // surviving directory" — a `unitScopes` key is never recorded at a non-unit — so feeding such a - // key straight to `classifyUnusedKeys` could blame an exclusion whose removal changes nothing. - // Claiming the non-unit reason first removes the key from that pass entirely. - const unitOnly = unused.filter((k) => Object.hasOwn(globalUnits, k) && !Object.hasOwn(globalScopes, k)); + // A unit-map-only key is recorded solely by matching a directory of its required case, so one + // that matched only directories of the wrong case (or no unit at all) identified nothing. That is + // the same distinction `pascalCaseUnits` draws in `architecture/unit-entry-file`, where the casing + // gate is the identification criterion; here the gate is the unit test. Decided before the + // excluded/unmatched split, so an exclusion is never blamed for a key a unit test disqualified. + // + // `anyCaseUnitScopes` is checked first: its gate (isAnyCaseUnitDir) is the weaker of the two, so a + // key that fails it has also failed `unitScopes`'s gate, and the stronger, more informative "never + // a unit of either case" note should win over the narrower "never a unit" one when both maps share + // a glob (design 2026-08-06's partition, ported here) and neither is doing any work. + const anyUnitOnly = unused.filter((k) => Object.hasOwn(globalAnyUnits, k) && !Object.hasOwn(globalScopes, k)); + for (const key of keysMatchingAny(anyUnitOnly, nonAnyUnitDirs, compile)) { + notes.set(key, 'matched directories but never a unit of either case'); + } + const unitOnly = unused.filter( + (k) => Object.hasOwn(globalUnits, k) && !Object.hasOwn(globalScopes, k) && !notes.has(k) + ); for (const key of keysMatchingAny(unitOnly, nonUnitDirs, compile)) { notes.set(key, 'matched directories but never a unit'); } diff --git a/packages/core/test/reserved-directory-names.test.ts b/packages/core/test/reserved-directory-names.test.ts index d4550a7fe..b99432329 100644 --- a/packages/core/test/reserved-directory-names.test.ts +++ b/packages/core/test/reserved-directory-names.test.ts @@ -484,3 +484,167 @@ describe('architecture/reserved-directory-names — declarations that check noth expect(project(rs)[0]!.message).toContain("'src/nowhere/*'"); }); }); + +// Issue #386: isUnitDir's letter test recognises only capitalised units, so a lowercase unit's +// children were never governed by any declaration here. anyCaseUnitScopes closes that gap with +// isAnyCaseUnitDir — the same predicate without the letter test. +describe('architecture/reserved-directory-names — anyCaseUnitScopes', () => { + const ANY_UNITS = { anyCaseUnitScopes: { 'src/**': 'parts|tests' } }; + + it("reports a lowercase .ts-entry unit's undeclared child — the issue #386 repro", async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts', 'src/lib/formatDate/helpers/a.ts'], ANY_UNITS) + ); + expect(fails(rs)).toHaveLength(1); + expect(fails(rs)[0]!.route).toBe('src/lib/formatDate/helpers'); + expect(fails(rs)[0]!.message).toContain('parts, tests'); + }); + + it('reports no per-child finding under unitScopes alone — the gap issue #386 reports', async () => { + // unitScopes never finds a capitalised unit in this tree, so 'helpers/' goes unmeasured and + // silently unreported — the 43%-of-units gap the issue names. (The declaration also gets an + // honest 'never a unit' project-scoped note for finding no capitalised unit at all, which is a + // different, already-existing diagnostic — not the missing per-child finding pinned here.) + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts', 'src/lib/formatDate/helpers/a.ts'], { + unitScopes: { 'src/**': 'parts|tests' } + }) + ); + expect(fails(rs)).toEqual([]); + }); + + it("reports a lowercase .svelte.ts-entry unit's undeclared child", async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/useThing/useThing.svelte.ts', 'src/lib/useThing/helpers/a.ts'], ANY_UNITS) + ); + expect(fails(rs)).toHaveLength(1); + expect(fails(rs)[0]!.route).toBe('src/lib/useThing/helpers'); + }); + + it('reports no per-child finding under unitScopes alone for the .svelte.ts tree either', async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/useThing/useThing.svelte.ts', 'src/lib/useThing/helpers/a.ts'], { + unitScopes: { 'src/**': 'parts|tests' } + }) + ); + expect(fails(rs)).toEqual([]); + }); + + it('still governs a capitalised unit, exactly as unitScopes does', async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/Card/Card.svelte', 'src/lib/Card/helpers/a.ts'], ANY_UNITS) + ); + expect(fails(rs)).toHaveLength(1); + expect(fails(rs)[0]!.route).toBe('src/lib/Card/helpers'); + }); + + it('does not measure a same-case non-unit directory against the vocabulary', async () => { + // 'helpers' holds no helpers.ts of its own, so it is not an any-case unit and its child 'deep' — + // named outside {parts, tests} — must go unmeasured. 'formatDate' is a real any-case unit sharing + // the same declared glob, so the key is legitimately used elsewhere and the run stays silent + // rather than reporting a false positive on 'deep' or a dead-declaration note on the key. + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts', 'src/lib/helpers/deep/a.ts'], ANY_UNITS) + ); + expect(rs).toEqual([]); + }); +}); + +describe('architecture/reserved-directory-names — the unit-map partition (design 2026-08-06)', () => { + // The same glob in both unit maps is not a collision: unitScopes's gate (isUnitDir) is a strict + // subset of anyCaseUnitScopes's (isAnyCaseUnitDir), so at a capitalised unit both are eligible and + // the more specific — unitScopes — governs, while anyCaseUnitScopes governs alone at a lowercase + // unit, where unitScopes is never eligible. This lets one glob express the convention design + // 2026-08-06 measured: capitalised units get a superset of names, lowercase units a subset. + const PARTITION = { unitScopes: { 'src/**': 'parts|tests' }, anyCaseUnitScopes: { 'src/**': 'tests' } }; + + it('lets unitScopes win the identical-glob tie at a capitalised unit', async () => { + // 'parts' is in unitScopes's list but not anyCaseUnitScopes's. If anyCaseUnitScopes won the tie + // instead, this would report a false positive on parts/. + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/Card/Card.svelte', 'src/lib/Card/parts/Badge/Badge.svelte'], PARTITION) + ); + expect(rs).toEqual([]); + }); + + it('governs a lowercase unit through anyCaseUnitScopes alone, with its own narrower list', async () => { + // formatDate/ is never eligible for unitScopes (isUnitDir requires A–Z), so anyCaseUnitScopes + // governs alone here — and its list is 'tests' only, so parts/ is reported. + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts', 'src/lib/formatDate/parts/a.ts'], PARTITION) + ); + expect(fails(rs)).toHaveLength(1); + expect(fails(rs)[0]!.route).toBe('src/lib/formatDate/parts'); + expect(fails(rs)[0]!.message).toMatch(/declared here: tests\.$/); + }); + + it('reports neither declaration as dead, since both do real work', async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx( + [ + 'src/lib/Card/Card.svelte', + 'src/lib/Card/tests/a.ts', + 'src/lib/formatDate/formatDate.ts', + 'src/lib/formatDate/tests/a.ts' + ], + PARTITION + ) + ); + expect(project(rs)).toEqual([]); + }); +}); + +describe('architecture/reserved-directory-names — anyCaseUnitScopes declarations that check nothing', () => { + it('reports an anyCaseUnitScopes key that matched no directory', async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts'], { + anyCaseUnitScopes: { 'src/**': 'parts', 'src/nowhere/*': 'parts' } + }) + ); + expect(project(rs)).toHaveLength(1); + expect(project(rs)[0]!.message).toContain("'src/nowhere/*'"); + expect(project(rs)[0]!.message).toContain('matched no directory'); + }); + + it('reports an anyCaseUnitScopes key whose every match is excluded', async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/tests/formatDate/formatDate.ts'], { + anyCaseUnitScopes: { 'src/**/tests/*': 'parts' }, + exclude: ['**/tests'] + }) + ); + expect(project(rs)).toHaveLength(1); + expect(project(rs)[0]!.message).toContain('matched only excluded directories'); + }); + + it('reports an anyCaseUnitScopes key that matched directories but never a unit of either case', async () => { + // 'grouping' holds no same-stemmed file, so it is not a unit of any case — the key identified + // nothing, which is a stronger claim than unitScopes's 'never a unit' and gets its own wording. + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/grouping/a.ts'], { anyCaseUnitScopes: { 'src/lib/*': 'parts' } }) + ); + expect(project(rs)).toHaveLength(1); + expect(project(rs)[0]!.message).toContain('never a unit of either case'); + }); + + it('reports a value that names nothing at all for anyCaseUnitScopes', async () => { + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts'], { anyCaseUnitScopes: { 'src/**': '|' } }) + ); + expect(project(rs)).toHaveLength(1); + expect(project(rs)[0]!.message).toContain('names no directory name at all'); + }); + + it('reports the same glob declared in scopes and anyCaseUnitScopes', async () => { + // scopes has no eligibility gate, so it wins wherever this identical glob matches — the same + // shape as the scopes/unitScopes collision, extended to the new map. + const rs = await architectureReservedDirectoryNames.check( + ctx(['src/lib/formatDate/formatDate.ts'], { + scopes: { 'src/lib/*': 'parts' }, + anyCaseUnitScopes: { 'src/lib/*': 'parts' } + }) + ); + expect(project(rs)).toHaveLength(1); + expect(project(rs)[0]!.message).toContain('declared in both scopes and anyCaseUnitScopes'); + }); +}); From 9283a02c302e945a0ca0c0d14c4553563efd360b Mon Sep 17 00:00:00 2001 From: oekazuma Date: Sat, 8 Aug 2026 12:26:34 +0900 Subject: [PATCH 2/4] docs: mention anyCaseUnitScopes in the configuration guide (en/ja) The reserved-directory-names bullet still said "off until one of the two is set" and omitted anyCaseUnitScopes, added by the prior commit on this branch. Reworded to name the new map and avoid hardcoding a map count that rots on the next one (AGENTS.md's anti-hardcoding principle). Co-Authored-By: Claude Fable 5 --- docs/src/content/docs/guides/(setup)/configuration.mdx | 3 ++- docs/src/content/docs/ja/guides/(setup)/configuration.mdx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/guides/(setup)/configuration.mdx b/docs/src/content/docs/guides/(setup)/configuration.mdx index 50301eccf..064881ede 100644 --- a/docs/src/content/docs/guides/(setup)/configuration.mdx +++ b/docs/src/content/docs/guides/(setup)/configuration.mdx @@ -189,7 +189,8 @@ section with its exact option names and defaults. directory glob to casing set, and `exclude` globs. The rule is inert until you set `directories`. - [`architecture/reserved-directory-names`](/rules/architecture/reserved-directory-names) — `scopes` (directory glob → allowed child names), `unitScopes` (root glob → the names a unit's children may - take) and `exclude`. Off until one of the two is set. + take), `anyCaseUnitScopes` (the same, for units of either case) and `exclude`. Off until a scope map + is set. - [`architecture/route-component-import`](/rules/architecture/route-component-import) — an `exemptImporters` glob list of satellite files (stories, tests, specs) allowed to import a route entry by hand, added to the built-in list. diff --git a/docs/src/content/docs/ja/guides/(setup)/configuration.mdx b/docs/src/content/docs/ja/guides/(setup)/configuration.mdx index 2e36739c9..002c95bfe 100644 --- a/docs/src/content/docs/ja/guides/(setup)/configuration.mdx +++ b/docs/src/content/docs/ja/guides/(setup)/configuration.mdx @@ -159,7 +159,8 @@ export default { 何も出力しません。 - [`architecture/reserved-directory-names`](/ja/rules/architecture/reserved-directory-names) — `scopes`(ディレクトリ glob → 直下に置ける名前)、`unitScopes`(起点 glob → ユニット直下に置ける - 名前)、`exclude`。どちらかを設定するまで無効です。 + 名前)、`anyCaseUnitScopes`(同様、大文字小文字を問わないユニット用)、`exclude`。いずれかの + スコープマップを設定するまで無効です。 - [`architecture/route-component-import`](/ja/rules/architecture/route-component-import) — ルートエントリを手動でインポートしてよいサテライトファイル(stories、test、spec)を指定する `exemptImporters` の glob リスト。組み込みリストに追加されます。 From 7ad8ea04c585f7e9be208e789ec646ab4c03f11b Mon Sep 17 00:00:00 2001 From: oekazuma Date: Sat, 8 Aug 2026 13:16:41 +0900 Subject: [PATCH 3/4] docs(core): fix CodeRabbit review nits on the any-case unit-scopes docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-numerize the rule doc's "The three options" heading (en/ja) — it rots the next time a scope map is added, same reasoning as the configuration.mdx fix earlier on this branch. Correct the rule's own JSDoc: isUnitDir's letter test doesn't require a .svelte entry specifically (the .svelte/.ts split is the measured tree's correlation, not the predicate), and a lowercase unit wasn't strictly never checked by any declaration — a scopes key naming the parent directly could still reach one. Narrowed to "no generic unit-map declaration governed it". Comments and headings only, no behavior change. Co-Authored-By: Claude Fable 5 --- .../docs/ja/rules/architecture/reserved-directory-names.md | 2 +- .../docs/rules/architecture/reserved-directory-names.md | 2 +- .../src/rules/architecture/reserved-directory-names.ts | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md b/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md index aae69ed27..fd3e2365d 100644 --- a/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md +++ b/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md @@ -53,7 +53,7 @@ export default { }; ``` -### 3 つのオプションはキーが何を指すかが違う +### スコープマップはキーが何を指すかが違う **`scopes` のキーは親を直接指します。** `'src/lib'` は `src/lib` にマッチし、その直下のサブ ディレクトリが取り得る名前は、あなたが列挙したものになります。 diff --git a/docs/src/content/docs/rules/architecture/reserved-directory-names.md b/docs/src/content/docs/rules/architecture/reserved-directory-names.md index f302f8561..74573b88a 100644 --- a/docs/src/content/docs/rules/architecture/reserved-directory-names.md +++ b/docs/src/content/docs/rules/architecture/reserved-directory-names.md @@ -52,7 +52,7 @@ export default { }; ``` -### The three options differ in what their key names +### The scope maps differ in what their keys name **A `scopes` key names the parent directly.** `'src/lib'` matches `src/lib`, and the names its immediate subdirectories may take are the ones you list. diff --git a/packages/core/src/rules/architecture/reserved-directory-names.ts b/packages/core/src/rules/architecture/reserved-directory-names.ts index f8d8281f3..609e319b2 100644 --- a/packages/core/src/rules/architecture/reserved-directory-names.ts +++ b/packages/core/src/rules/architecture/reserved-directory-names.ts @@ -97,9 +97,10 @@ const PRIORITY: Record = { scopes: 0, unitScopes: 1, anyCaseUni * `unitScopes` key names a root, and the rule governs the children of whichever directories beneath * it are units whose name begins A–Z — the shape a glob cannot reach, because units nest to arbitrary * depth. An `anyCaseUnitScopes` key names a root the same way, but governs units of *either* case: - * `isUnitDir`'s letter test recognises only capitalised (`.svelte`-entry) units, so without this map a - * `.ts`- or `.svelte.ts`-entry unit's children were never checked by any declaration here — measured - * at 129 of 299 units (43%) on a real tree. Neither map is named with the bare word "unit": the + * `isUnitDir`'s letter test — A–Z plus a same-stemmed entry file, whatever its extension — excludes a + * lowercase unit, so without this map no generic unit-map declaration governed one's children (a + * `scopes` key naming the parent directly could still reach one) — measured at 129 of 299 units (43%) + * on a real tree. Neither map is named with the bare word "unit": the * sibling rule `architecture/reserved-name-placement` records why that word alone is ambiguous between * the two predicates once both exist. * From 62ad488400a06d08a4f632416d3c12dae1136bcb Mon Sep 17 00:00:00 2001 From: oekazuma Date: Sat, 8 Aug 2026 13:18:57 +0900 Subject: [PATCH 4/4] docs(core): finish de-numbering and fix the predicate claim in the rule prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-numerize the JSDoc's "Three option maps" opener too, matching the doc heading fixed earlier on this branch — same anti-count-rot reasoning. The rule doc prose (en/ja) repeated the same two JSDoc inaccuracies fixed previously: unitScopes's letter test doesn't require a .svelte entry specifically (extension-agnostic predicate), and a lowercase unit's children weren't strictly "never checked by any declaration" — a scopes key naming the parent directly could still reach one. Narrowed to "no generic unit-map declaration governed it" in both languages. Comments and prose only, no behavior change. Co-Authored-By: Claude Fable 5 --- .../rules/architecture/reserved-directory-names.md | 12 ++++++------ .../rules/architecture/reserved-directory-names.md | 10 +++++----- .../rules/architecture/reserved-directory-names.ts | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md b/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md index fd3e2365d..46199e247 100644 --- a/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md +++ b/docs/src/content/docs/ja/rules/architecture/reserved-directory-names.md @@ -66,12 +66,12 @@ export default { **`anyCaseUnitScopes` のキーも起点を指しますが、大文字小文字を問わないユニットを対象にします** —— 文字の条件を除いた同じ判定で、`.ts` や `.svelte.ts` を entry とするユニット -(`formatDate/formatDate.ts`、`useThing/useThing.svelte.ts`)も数えます。`unitScopes` の文字条件が -認識するのは大文字始まり・`.svelte` entry のユニットだけなので、このオプションがなければ小文字 -ユニットの子はここでのどの宣言にも検査されません —— 実測では 299 ユニット中 129(43%)が該当しました。 -どちらのユニットオプションも裸の「unit」という語では命名していません —— 同じ分割を自身のオプションに -採用している `architecture/reserved-name-placement` が、両方の判定が存在するとその語だけでは曖昧に -なる理由を記録しています。 +(`formatDate/formatDate.ts`、`useThing/useThing.svelte.ts`)も数えます。`unitScopes` の文字条件は +小文字のユニットを除外するため、このオプションがなければ汎用のユニットマップ宣言がその子を検査する +ことはありませんでした —— 親を直接指す `scopes` のキーであれば届くことがあります —— 実測では 299 +ユニット中 129(43%)が該当しました。どちらのユニットオプションも裸の「unit」という語では命名して +いません —— 同じ分割を自身のオプションに採用している `architecture/reserved-name-placement` が、 +両方の判定が存在するとその語だけでは曖昧になる理由を記録しています。 `scopes` のキーは、子が**すべて**列挙した名前から成る場合にのみ書く価値があります。ルート ディレクトリは予約名とルートセグメントを並べて持ちますが、ルートセグメントは無制限です —— ページごとに diff --git a/docs/src/content/docs/rules/architecture/reserved-directory-names.md b/docs/src/content/docs/rules/architecture/reserved-directory-names.md index 74573b88a..58cbdaf3d 100644 --- a/docs/src/content/docs/rules/architecture/reserved-directory-names.md +++ b/docs/src/content/docs/rules/architecture/reserved-directory-names.md @@ -65,11 +65,11 @@ depth. **An `anyCaseUnitScopes` key names a root the same way, but governs units of either case** — the same test without the letter requirement, so a `.ts`- or `.svelte.ts`-entry unit (`formatDate/formatDate.ts`, -`useThing/useThing.svelte.ts`) counts too. `unitScopes`'s letter test recognises only capitalised, -`.svelte`-entry units, so without this option a lowercase unit's children were never checked by any -declaration here — measured at 129 of 299 units (43%) on a real tree. Neither unit option is named with -the bare word "unit": `architecture/reserved-name-placement`, which takes the same split for its own -options, records why that word alone is ambiguous once both predicates exist. +`useThing/useThing.svelte.ts`) counts too. `unitScopes`'s letter test excludes a lowercase unit, so +without this option no generic unit-map declaration governed its children — a `scopes` key naming the +parent directly could still reach one — measured at 129 of 299 units (43%) on a real tree. Neither unit +option is named with the bare word "unit": `architecture/reserved-name-placement`, which takes the same +split for its own options, records why that word alone is ambiguous once both predicates exist. A `scopes` key is only worth writing where the children are **entirely** drawn from the names you list. A route directory holds its reserved names beside its route segments, and route segments are diff --git a/packages/core/src/rules/architecture/reserved-directory-names.ts b/packages/core/src/rules/architecture/reserved-directory-names.ts index 609e319b2..24ef70707 100644 --- a/packages/core/src/rules/architecture/reserved-directory-names.ts +++ b/packages/core/src/rules/architecture/reserved-directory-names.ts @@ -93,7 +93,7 @@ const PRIORITY: Record = { scopes: 0, unitScopes: 1, anyCaseUni * the project declared for that position (design 2026-07-29, extended 2026-08-08 for lowercase units — * issue #386). * - * Three option maps, differing in what their key names. A `scopes` key names the parent directly. A + * The option maps differ in what their keys name. A `scopes` key names the parent directly. A * `unitScopes` key names a root, and the rule governs the children of whichever directories beneath * it are units whose name begins A–Z — the shape a glob cannot reach, because units nest to arbitrary * depth. An `anyCaseUnitScopes` key names a root the same way, but governs units of *either* case: