@slip-stream-kit/eslint-plugin 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ export { packageStructure } from './package-structure';
@@ -0,0 +1,27 @@
1
+ import type { Rule } from 'eslint';
2
+ export declare const PACKAGE_TYPE_VALUES: readonly ["frontend", "backend", "lib", "e2e", "mobile"];
3
+ export type PackageType = (typeof PACKAGE_TYPE_VALUES)[number];
4
+ export interface LayerEntry {
5
+ /** First-level directories allowed under `src/`. */
6
+ layers: string[];
7
+ /**
8
+ * Allowed directories one level further down, keyed by where they sit: `features/*` means "inside
9
+ * every folder of `features/`" (each folder is a unit of any name), `services` means "directly
10
+ * inside `services/`". A layer without a key is not inspected below its first level.
11
+ */
12
+ segments?: Record<string, string[]>;
13
+ /** Skill or doc that owns this layout, appended to the message. `''` or absent → no "See …" sentence. */
14
+ skill?: string;
15
+ }
16
+ export interface Options extends Partial<Record<PackageType, LayerEntry>> {
17
+ /** Globs; the rule is skipped for matching files. */
18
+ ignore?: string[];
19
+ }
20
+ /**
21
+ * Built-in entries per package type. A present option key replaces the whole entry (layers,
22
+ * segments AND skill) — the same absent→default / set→replace semantics as every other option in
23
+ * the plugin. `mobile` and `lib` have no entry until their layouts settle, so they are silent.
24
+ */
25
+ export declare const DEFAULT_ENTRIES: Partial<Record<PackageType, LayerEntry>>;
26
+ export declare const resolveEntry: (type: PackageType, options: Options) => LayerEntry | undefined;
27
+ export declare const packageStructure: Rule.RuleModule;
@@ -0,0 +1,16 @@
1
+ export declare const CONFIG_FILENAME = "infra-kit.config.ts";
2
+ /**
3
+ * The package directory a linted file belongs to: the nearest ancestor holding `infra-kit.config.ts`
4
+ * or `package.json`. Null for virtual / relative filenames, which have no place on disk to walk from.
5
+ */
6
+ export declare const findPackageRoot: (filename: string) => string | null;
7
+ /**
8
+ * The directory names between `<packageRoot>/src/` and `filename`, outermost first — `[]` for a
9
+ * file directly in `src/`, null when the file is not under `src/` at all (so `dist/src/x/...`,
10
+ * built output a consumer ships, is null too).
11
+ */
12
+ export declare const dirsUnderSrc: (packageRoot: string, filename: string) => string[] | null;
13
+ /** Nearest ancestor of `packageRoot` (inclusive) that is a pnpm workspace root, or null outside one. */
14
+ export declare const findRepoRoot: (packageRoot: string) => string | null;
15
+ /** Drops the dirname → root memo; only tests that build and tear down fixture trees need this. */
16
+ export declare const resetPackageRootCache: () => void;
@@ -0,0 +1,24 @@
1
+ import type { PackageType } from '@slip-stream-kit/config/package-type';
2
+ /**
3
+ * The literal `type` declared in an `infra-kit.config.ts` source text, or undefined when it is
4
+ * absent, computed, or not one of the known types.
5
+ *
6
+ * The config is read as text rather than imported: `require(esm)` is cached by the ESM loader, so a
7
+ * rewritten config would keep returning its old value, and importing it would execute consumer code
8
+ * (and its zod import) inside the ESLint process. Comments are stripped first so a commented-out
9
+ * `// type: 'backend'` above the live literal cannot win; the first live match does.
10
+ */
11
+ export declare const readDeclaredType: (text: string) => PackageType | undefined;
12
+ /**
13
+ * The package's type: the literal declared in its `infra-kit.config.ts` when there is one, else the
14
+ * CLI's inference from directory and dependency signals.
15
+ *
16
+ * One `statSync` on the config per call is the steady-state cost; the text is re-read only when the
17
+ * mtime moved, so a config edit is seen by the next lint of any file in the package — in a CLI run
18
+ * or a long-lived IDE server alike. `package.json` is read once per root and never revalidated: it
19
+ * only feeds the dependency fallback, and a dependency change is a `pnpm install` event, not an
20
+ * edit-lint loop.
21
+ */
22
+ export declare const resolvePackageType: (packageRoot: string) => PackageType;
23
+ /** Drops the root → type memo; only tests that build and tear down fixture trees need this. */
24
+ export declare const resetPackageTypeCache: () => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@slip-stream-kit/eslint-plugin",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "description": "Custom ESLint rules enforcing the white-label frontend architecture conventions",
6
6
  "author": "Arthur Saenko <arthur.saenz7@gmail.com> (https://github.com/ArthurSaenz)",
7
7
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "eslint": ">=9"
37
37
  },
38
38
  "devDependencies": {
39
+ "@slip-stream-kit/config": "0.11.0",
39
40
  "@types/node": "^26.5.1",
40
41
  "@typescript-eslint/parser": "^8.70.0",
41
42
  "@wl/eslint-config": "0.1.0",
package/readme.md CHANGED
@@ -526,6 +526,108 @@ story is satisfied when any candidate (`.tsx`, `.jsx`, `.ts`, `.js`) exists on d
526
526
  - **Unanchored globs.** `paths`/`ignore` patterns match anywhere in the path, so anchor them
527
527
  (e.g. start with `**/`) when you need precision.
528
528
 
529
+ ### `package-structure`
530
+
531
+ Allowlist the **layers under a package's `src/`**, and the **segments inside them**, per package
532
+ type. A file that sits in a directory the lists do not name is reported once, at the top of the
533
+ file — every file under the offending directory carries the same message, which is how a "red
534
+ folder" surfaces in the IDE explorer. The message names the skill that owns that layout, so the fix
535
+ has an owner:
536
+
537
+ ```
538
+ `core` is not an allowed `src/` layer for package type `frontend` (allowed: app, features, lib, components, pages, routes). See /infra-kit:fe-architect for the frontend layout.
539
+ `hooks` is not an allowed segment of `features/user` for package type `frontend` (allowed: containers, components, services, __stories__, __tests__). See /infra-kit:fe-architect for the frontend layout.
540
+ ```
541
+
542
+ Two levels are checked:
543
+
544
+ - **Layers** — the first directory under `src/` must be in `layers`.
545
+ - **Segments** — inside a layer that has a `segments` key, the next directory must be in that list.
546
+ `features/*` means "inside every folder of `features/`" (each folder is a unit of any name, e.g. a
547
+ feature or a service); `services` (no `/*`) would mean "directly inside `services/`". A layer
548
+ without a key is not inspected below its first level.
549
+
550
+ Never reported: files — in `src/` itself, in a layer, in a unit (`features/user/index.ts`) — and
551
+ anything deeper than a segment (`features/user/components/default/…`). A file not under
552
+ `<package>/src/` at all is silent, so a shipped `dist/src/**` is too. A `__tests__` or
553
+ `__stories__` folder is an ordinary name: allowed exactly where a list says so.
554
+
555
+ ```js
556
+ {
557
+ rules: {
558
+ '@wl/package-structure': 'error',
559
+ },
560
+ }
561
+ ```
562
+
563
+ #### How the package type is resolved
564
+
565
+ The package root is the nearest ancestor of the linted file holding an `infra-kit.config.ts` or a
566
+ `package.json`. Its type is the `type` **literal** declared in `infra-kit.config.ts` when there is one
567
+ (`type: 'backend'`); otherwise the same inference the `infra-kit` CLI uses — `apps/<app>/ui` →
568
+ `frontend`, `apps/<app>/api` → `backend`, `apps/<app>/tests` → `e2e`, a `mobile-app` directory →
569
+ `mobile`, then dependency signals (`@playwright/test` → `e2e`, `@capacitor/*` → `mobile`,
570
+ `serverless` → `backend`), falling back to `lib`.
571
+
572
+ The config file is read as **text** — it is never imported or executed — and re-read when its mtime
573
+ changes, so an edit is picked up by the next lint of any file in that package, in CI and in the IDE
574
+ language server alike. A computed `type` (`type: pickType()`) is not a literal and falls back to
575
+ inference.
576
+
577
+ #### Options
578
+
579
+ One entry per package type. An **absent** key uses the built-in default below; a **present** key
580
+ replaces the whole entry (`layers`, `segments` and `skill`) — there is no merging. A type with no
581
+ entry is silent, which is why `mobile` and `lib` report nothing by default: their layouts are not
582
+ settled.
583
+
584
+ | Type | Default `layers` | Default `segments` | Default `skill` |
585
+ | ---------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------- |
586
+ | `frontend` | `app`, `features`, `lib`, `components`, `pages`, `routes` | `features/*` → `containers`, `components`, `services`, `__stories__`, `__tests__` | `/infra-kit:fe-architect` |
587
+ | `backend` | `controllers`, `services`, `lib`, `config` | `services/*` → `__tests__` | `/infra-kit:be-architect` |
588
+ | `e2e` | `tests`, `pages`, `fixtures`, `mocks`, `data`, `config`, `lib`, `components` | — | `/infra-kit:e2e-architect` |
589
+ | `mobile` | — (silent) | — | — |
590
+ | `lib` | — (silent) | — | — |
591
+
592
+ | Option | Type | Description |
593
+ | ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
594
+ | `<type>.layers` | `string[]` | Allowed first-level `src/` directories for that type. |
595
+ | `<type>.segments` | `Record<string, string[]>` | Allowed directories one level down, keyed by `<layer>/*` (inside each folder of the layer) or `<layer>`. A key must name a layer from `layers`. |
596
+ | `<type>.skill` | `string` | Appended as "See `<skill>` for the `<type>` layout."; `''` or absent drops the sentence. |
597
+ | `ignore` | `string[]` | Skip files matching these globs. |
598
+
599
+ ```js
600
+ // Override one type wholesale; the other types keep their defaults.
601
+ export default config({
602
+ rules: {
603
+ '@wl/package-structure': [
604
+ 'error',
605
+ {
606
+ backend: {
607
+ layers: ['lambda_controllers', 'services', 'libs', 'local'],
608
+ segments: { 'services/*': ['__tests__', 'fixtures'] },
609
+ skill: '/infra-kit:be-architect',
610
+ },
611
+ },
612
+ ],
613
+ },
614
+ })
615
+ ```
616
+
617
+ Flat config **replaces** rule options across matching blocks (it does not merge), so a consumer
618
+ override restates every type it wants to change — and only those.
619
+
620
+ #### Caveats
621
+
622
+ - **Filesystem-coupled.** The rule stats the package's `infra-kit.config.ts` once per linted file
623
+ (microseconds) and reads it only when the mtime changed. `package.json` dependency signals are
624
+ read once per lint process; adding `@playwright/test` mid-session is a `pnpm install` event, not an
625
+ edit-lint loop, and is seen by the next process.
626
+ - **`--cache`.** Renaming a directory changes every file path under it, so ESLint's cache already
627
+ misses; editing `infra-kit.config.ts` does not touch the linted files, so a cached result can go
628
+ stale until those files change. Run without `--cache` in CI if you rely on this rule as a gate.
629
+ - **The backend skill is a forward reference.** `/infra-kit:be-architect` is named in the default
630
+ message ahead of the skill shipping; override `backend.skill` if the pointer should go elsewhere.
529
631
  ### `max-jsdoc-lines`
530
632
 
531
633
  Cap the height of a JSDoc block. The block's **prose** and its **`@example` bodies**
@@ -731,3 +833,42 @@ Module-level rationale is exempted the same way: tag the block `@fileoverview`
731
833
  (or `@module` / `@packageDocumentation`). There is no positional exemption, and
732
834
  the message names the tag inline so the escape hatch is visible at the point of
733
835
  failure.
836
+
837
+ ### `require-jsdoc-example`
838
+
839
+ Graduated JSDoc requirement driven by **cognitive complexity**: at or above `minComplexity` a named
840
+ top-level function must carry a leading JSDoc block, and at or above `exampleComplexity` that block
841
+ must also include an `@example` tag. Functions below the first threshold are left alone — the rule
842
+ asks for documentation where a reader needs it, not everywhere.
843
+
844
+ ```ts
845
+ // ❌ Incorrect — complexity 13, no JSDoc at all
846
+ export const resolveRoute = (ctx: Ctx) => { /* nested branches, loops, early returns … */ }
847
+
848
+ // ✅ Correct — a block, and because 13 ≥ 12, a worked `@example`
849
+ /**
850
+ * Pick the route for a request, honouring locale, redirects and the fallback page.
851
+ *
852
+ * @example
853
+ * resolveRoute({ path: '/he/deals', locale: 'he' }) // → { page: 'deals', locale: 'he' }
854
+ */
855
+ export const resolveRoute = (ctx: Ctx) => { … }
856
+ ```
857
+
858
+ Targets are named `function` declarations and `const name = () => …` / `function` expressions at the
859
+ top level, with or without an `export` wrapper; the JSDoc may sit on the declaration or on the
860
+ `export` statement. Complexity is the same cognitive-complexity metric `sonarjs` uses. The
861
+ recommended preset ships this rule at `warn` in its own `**/*.{ts,tsx}` block, because the functions
862
+ it targets overwhelmingly live in plain `.ts` modules.
863
+
864
+ #### Options (all optional)
865
+
866
+ | Option | Default | Description |
867
+ | ------------------- | ------- | ----------------------------------------------------------------------------------- |
868
+ | `minComplexity` | `8` | Cognitive complexity at or above which a function must carry a leading JSDoc block. |
869
+ | `exampleComplexity` | `12` | Cognitive complexity at or above which that block must also include an `@example`. |
870
+ | `paths` | `[]` | Restrict the rule to files matching these globs. |
871
+ | `ignore` | `[]` | Skip files matching these globs (takes precedence over `paths`). |
872
+
873
+ The `@example` bodies this rule mandates are charged to their own budget by
874
+ [`max-jsdoc-lines`](#max-jsdoc-lines), so satisfying one rule can never violate the other.