@noctcore/lint-meta-rules 0.1.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.
- package/README.md +65 -0
- package/dist/index.cjs +748 -0
- package/dist/index.d.cts +395 -0
- package/dist/index.d.ts +395 -0
- package/dist/index.js +706 -0
- package/docs/rules/agents-doc-presence.md +40 -0
- package/docs/rules/canonical-helpers-single-home.md +35 -0
- package/docs/rules/file-size-ratchet.md +72 -0
- package/docs/rules/layer-rank.md +44 -0
- package/docs/rules/no-cloned-component-folders.md +40 -0
- package/docs/rules/no-warn-severity.md +34 -0
- package/docs/rules/package-shape.md +49 -0
- package/docs/rules/test-runner-segregation.md +40 -0
- package/docs/rules/test-sibling-enforcement.md +35 -0
- package/docs/rules/test-workspace-enrollment.md +41 -0
- package/docs/rules/ui-primitive-shape.md +41 -0
- package/docs/rules/workspace-graph-parity.md +41 -0
- package/package.json +56 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# `agents-doc-presence`
|
|
2
|
+
|
|
3
|
+
> An agent-contract doc must exist at the repo root, every surface, and every non-opted-out package.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
An agent editing a boundary should read its guardrails first. Requiring an `AGENTS.md` (or whatever
|
|
8
|
+
doc you name) at the root and at every meaningful package means the contract is never missing where it
|
|
9
|
+
matters. A new package ships the doc by default; only trivial leaves opt out, and the opt-out is
|
|
10
|
+
explicit and reviewable.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
Reports a missing doc at:
|
|
15
|
+
|
|
16
|
+
- the repo root (unless `requireAtRoot` is `false`),
|
|
17
|
+
- every directory derived from `surfaceGlobs` (all surfaces), and
|
|
18
|
+
- every directory derived from `packageGlobs`, except those in `optOut`.
|
|
19
|
+
|
|
20
|
+
## Factory
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
createAgentsDocPresenceRule(options?: AgentsDocPresenceOptions): IMetaRule
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Option | Type | Default | Meaning |
|
|
27
|
+
| --- | --- | --- | --- |
|
|
28
|
+
| `docFile` | `string` | `'AGENTS.md'` | The agent-contract filename required in each location. |
|
|
29
|
+
| `requireAtRoot` | `boolean` | `true` | Whether the repo root must carry the doc. |
|
|
30
|
+
| `surfaceGlobs` | `string[]` | `['apps/*/package.json']` | Surfaces (all require the doc). |
|
|
31
|
+
| `packageGlobs` | `string[]` | `['packages/*/package.json']` | Library packages (require the doc unless opted out). |
|
|
32
|
+
| `optOut` | `string[]` | `[]` | Package directories exempt from the requirement. |
|
|
33
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
34
|
+
|
|
35
|
+
De-projected from nightcore, which hardcoded `AGENTS.md`, the root/apps/packages layout and a fixed
|
|
36
|
+
leaf opt-out set.
|
|
37
|
+
|
|
38
|
+
## When not to use it
|
|
39
|
+
|
|
40
|
+
If your repo does not adopt an agent-contract doc convention, skip it.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# `canonical-helpers-single-home`
|
|
2
|
+
|
|
3
|
+
> A helper symbol must not be exported from two different helper homes.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
When the same helper name is exported from multiple files, callers import inconsistent copies and the
|
|
8
|
+
implementations drift. Keeping each helper in one canonical home makes "where does `slug` live?"
|
|
9
|
+
unambiguous and prevents silent divergence.
|
|
10
|
+
|
|
11
|
+
## What it flags
|
|
12
|
+
|
|
13
|
+
Scans the files matched by `include` (minus any path containing an `excludeContains` fragment),
|
|
14
|
+
extracts top-level exported identifiers (from `export function|const|let|var …` and `export { … }`
|
|
15
|
+
lists — keyed on the **local** name before any `as`), and flags any name that appears as an export in
|
|
16
|
+
more than one file. Strict, no baseline.
|
|
17
|
+
|
|
18
|
+
## Factory
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
createCanonicalHelpersSingleHomeRule(options?: CanonicalHelpersSingleHomeOptions): IMetaRule
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Option | Type | Default | Meaning |
|
|
25
|
+
| --- | --- | --- | --- |
|
|
26
|
+
| `include` | `string[]` | `['apps/web/src/**/*.utils.ts']` | Helper-home files to scan for duplicate exports. |
|
|
27
|
+
| `excludeContains` | `string[]` | `['/lib/']` | Drop any matched path containing one of these fragments. |
|
|
28
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
29
|
+
|
|
30
|
+
De-projected from nightcore, which hardcoded `apps/web/src/**/*.utils.ts` and a `/lib/` exclusion.
|
|
31
|
+
|
|
32
|
+
## When not to use it
|
|
33
|
+
|
|
34
|
+
If your project intentionally re-exports the same symbol from several modules (barrels, façades), scope
|
|
35
|
+
`include` to just the canonical-home files or skip the rule.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# `file-size-ratchet`
|
|
2
|
+
|
|
3
|
+
> Source files stay at or under a line cap, with a one-way, self-tightening baseline ratchet.
|
|
4
|
+
|
|
5
|
+
This single factory subsumes **both** of nightcore's file-size rules — `web-file-size-ratchet`
|
|
6
|
+
(apps/web/src, `.ts`+`.tsx`) and `engine-file-size-ratchet` (packages/engine/src, `.ts`) — which were
|
|
7
|
+
byte-identical logic differing only in scanned roots, extensions and exclusions. Create one instance
|
|
8
|
+
per capped area, each with its own `id` (the `id` names both the rule and its committed baseline file).
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
Nothing else caps whole-file size, and per-file escape hatches (e.g. ESLint counting only *exported*
|
|
13
|
+
hooks) let mega-files slip through. A ratchet freezes today's offenders and forbids growth: legacy
|
|
14
|
+
files may only shrink, and new files may never join. It does not force a refactor now — it stops the
|
|
15
|
+
debt from growing, and captures paydowns as they happen.
|
|
16
|
+
|
|
17
|
+
## What it flags
|
|
18
|
+
|
|
19
|
+
Measured in raw physical lines (`wc -l` semantics). Against a committed baseline at
|
|
20
|
+
`<baselineDir>/<id>.json`:
|
|
21
|
+
|
|
22
|
+
- a **new** over-cap file (not in the baseline), or a baselined one that **grew** past its frozen
|
|
23
|
+
count — a live violation;
|
|
24
|
+
- a baselined file still within its frozen count — **grandfathered** (a stderr notice, no violation);
|
|
25
|
+
- **self-tightening**: a baseline entry whose file is gone, is now at/under the cap, or shrank far
|
|
26
|
+
below its frozen value (`< frozen * tightenRatio`) is itself a violation demanding a baseline update.
|
|
27
|
+
|
|
28
|
+
The rule implements `baseline(ctx)` (the `IMetaRule` ratchet hook), which snapshots the current
|
|
29
|
+
offender map so the runner can regenerate the frozen file.
|
|
30
|
+
|
|
31
|
+
## Factory
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
createFileSizeRatchetRule(options?: FileSizeRatchetOptions): IMetaRule
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Option | Type | Default | Meaning |
|
|
38
|
+
| --- | --- | --- | --- |
|
|
39
|
+
| `id` | `string` | `'file-size-ratchet'` | Rule id **and** baseline file basename — give each instance a distinct id. |
|
|
40
|
+
| `roots` | `string[]` | `[]` | Source roots to scan (**empty is inert**), e.g. `['apps/web/src']`. |
|
|
41
|
+
| `extensions` | `string[]` | `['.ts', '.tsx']` | File extensions to include. |
|
|
42
|
+
| `cap` | `number` | `400` | The per-file line cap. |
|
|
43
|
+
| `excludeContains` | `string[]` | `['.test.', '.spec.', '.stories.']` | Exclude paths containing any of these. |
|
|
44
|
+
| `excludePrefixes` | `string[]` | `[]` | Exclude paths starting with any of these (e.g. codegen dirs). |
|
|
45
|
+
| `tightenRatio` | `number` | `0.85` | A baseline entry below `frozen * ratio` must be re-frozen. |
|
|
46
|
+
| `baselineDir` | `string` | `.nightcore/lint-meta/baselines` | Where committed baselines live (from `@noctcore/harness`). |
|
|
47
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
48
|
+
|
|
49
|
+
### Example: the two nightcore instances
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
createFileSizeRatchetRule({
|
|
53
|
+
id: 'web-file-size-ratchet',
|
|
54
|
+
roots: ['apps/web/src'],
|
|
55
|
+
extensions: ['.ts', '.tsx'],
|
|
56
|
+
excludeContains: ['.test.', '.stories.', '__screenshots__'],
|
|
57
|
+
excludePrefixes: ['apps/web/src/lib/generated/'],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
createFileSizeRatchetRule({
|
|
61
|
+
id: 'engine-file-size-ratchet',
|
|
62
|
+
roots: ['packages/engine/src'],
|
|
63
|
+
extensions: ['.ts'],
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
De-projected from nightcore, which hardcoded the roots, extensions, exclusions and the `400` cap.
|
|
68
|
+
|
|
69
|
+
## When not to use it
|
|
70
|
+
|
|
71
|
+
If you have no over-cap files and no desire to cap file size, skip it. If you want a hard cap with no
|
|
72
|
+
grandfathering, commit an empty baseline — every over-cap file then fails immediately.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# `layer-rank`
|
|
2
|
+
|
|
3
|
+
> A module may import only strictly-lower-ranked `<scope>` packages — equal (sideways) or higher
|
|
4
|
+
> (upward) is forbidden.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
Layered architectures encode a fixed dependency direction (e.g. `contracts → shared → storage/skills
|
|
9
|
+
→ engine → surfaces`). Enforcing it mechanically keeps the spine acyclic: a low tier can never reach
|
|
10
|
+
up into a higher one, and co-tier packages never entangle. New edges that would violate the direction
|
|
11
|
+
are caught before they calcify.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
Each source file is assigned an **importer rank**: surface files (matched by `surfacePrefix`) get
|
|
16
|
+
`surfaceRank`; otherwise the `packageDirPattern` capture is looked up in `ranks`. For each
|
|
17
|
+
`<scope>/<pkg>` import whose target is also ranked, the rule flags it when the target rank is `>=` the
|
|
18
|
+
importer rank (sideways when equal, upward when greater). Unranked importers and unranked targets are
|
|
19
|
+
skipped, so a package outside the documented spine never produces a false positive. Test files are
|
|
20
|
+
skipped.
|
|
21
|
+
|
|
22
|
+
## Factory
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
createLayerRankRule(options?: LayerRankOptions): IMetaRule
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Option | Type | Default | Meaning |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `scope` | `string` | `'@nightcore'` | npm scope of workspace packages. |
|
|
31
|
+
| `ranks` | `Record<string,number>` | `{}` | Layer package name → rank. **Empty makes the rule inert** (opt-in). |
|
|
32
|
+
| `surfacePrefix` | `string` | `'apps/'` | Path prefix marking a deployable surface. |
|
|
33
|
+
| `surfaceRank` | `number` | `undefined` | Rank for surface files; omit to leave surfaces unranked. |
|
|
34
|
+
| `packageDirPattern` | `string` | `'^packages/([^/]+)/'` | Regex source extracting the layer name from a file path. |
|
|
35
|
+
| `sourceGlobs` | `string[]` | packages + apps `src/**` `.ts`/`.tsx` | Source files to scan. |
|
|
36
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
37
|
+
|
|
38
|
+
De-projected from nightcore, which hardcoded a fixed rank table and the `@nightcore` scope. `ranks`
|
|
39
|
+
defaults to `{}` so the rule is inert until a consumer supplies its own layering.
|
|
40
|
+
|
|
41
|
+
## When not to use it
|
|
42
|
+
|
|
43
|
+
If your codebase is not organized into ranked layers, leave `ranks` empty (the rule then does
|
|
44
|
+
nothing).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# `no-cloned-component-folders`
|
|
2
|
+
|
|
3
|
+
> A component folder name may exist under only one feature.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Same-named component folders across features are how sibling drift starts: a component cloned into a
|
|
8
|
+
second feature diverges silently until the two are subtly incompatible. A genuinely shared surface
|
|
9
|
+
belongs in a shared destination (e.g. `components/ui`); a genuinely different one deserves a name that
|
|
10
|
+
says so.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
A `<feature>/<Name>/<barrelFile>` path marks a component folder. Grouping by `<Name>` across features
|
|
15
|
+
(excluding `excludedFeatures`), any name appearing under two or more features — and not in
|
|
16
|
+
`allowedClones` — is flagged. The allowlist freezes today's clone groups and only shrinks: an
|
|
17
|
+
`allowedClones` entry whose clone group no longer exists is itself flagged as stale.
|
|
18
|
+
|
|
19
|
+
## Factory
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
createNoClonedComponentFoldersRule(options?: NoClonedComponentFoldersOptions): IMetaRule
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Option | Type | Default | Meaning |
|
|
26
|
+
| --- | --- | --- | --- |
|
|
27
|
+
| `componentsRoot` | `string` | `'apps/web/src/components'` | Root under which `<feature>/<Component>/` folders live. |
|
|
28
|
+
| `barrelFile` | `string` | `'index.ts'` | Barrel file that marks a component folder. |
|
|
29
|
+
| `excludedFeatures` | `string[]` | `['ui', 'app']` | Feature folders whose contents are not feature components. |
|
|
30
|
+
| `allowedClones` | `string[]` | `[]` | Component names allowed to be cloned (a shrinking allowlist). |
|
|
31
|
+
| `sharedDest` | `string` | `'components/ui'` | Where a shared surface should be hoisted (used in the message). |
|
|
32
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
33
|
+
|
|
34
|
+
De-projected from nightcore, which hardcoded `apps/web/src/components`, the `ui`/`app` excluded
|
|
35
|
+
features, and a fixed `ALLOWED_CLONES` set.
|
|
36
|
+
|
|
37
|
+
## When not to use it
|
|
38
|
+
|
|
39
|
+
If your components are not organized as `<feature>/<Component>/` folders under one root, or you
|
|
40
|
+
intentionally maintain per-feature variants, retune or skip the rule.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# `no-warn-severity`
|
|
2
|
+
|
|
3
|
+
> ESLint severity is `error` or `off`, never `warn`.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
Agents iterate by reading CI failures. A `warn` severity is a silent miss — it neither fails the
|
|
8
|
+
build nor gets acted on — so a rule that matters must be an `error`, and a rule that does not should
|
|
9
|
+
be `off`. There is no useful middle.
|
|
10
|
+
|
|
11
|
+
## What it flags
|
|
12
|
+
|
|
13
|
+
Reads each configured flat-config file (skipping any that do not exist), strips line comments, and
|
|
14
|
+
reports any line containing a `'warn'` / `"warn"` severity literal. The violation carries the
|
|
15
|
+
1-indexed line number.
|
|
16
|
+
|
|
17
|
+
## Factory
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
createNoWarnSeverityRule(options?: NoWarnSeverityOptions): IMetaRule
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Option | Type | Default | Meaning |
|
|
24
|
+
| --- | --- | --- | --- |
|
|
25
|
+
| `configFiles` | `string[]` | `['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs']` | Flat-config files to scan; each is read only if present. |
|
|
26
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
27
|
+
|
|
28
|
+
De-projected from nightcore, which hardcoded `eslint.config.mjs`.
|
|
29
|
+
|
|
30
|
+
## When not to use it
|
|
31
|
+
|
|
32
|
+
If your project deliberately uses `warn` as an in-editor nudge that is not meant to gate CI, this rule
|
|
33
|
+
does not fit — though the recommended pattern is a separate lint layer for gating and `off` for
|
|
34
|
+
everything else.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# `package-shape`
|
|
2
|
+
|
|
3
|
+
> Every workspace is named `<scope>/<dir>`; library packages expose a barrel and point their build
|
|
4
|
+
> fields at the built output.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
A monorepo stays navigable when a package's identity is mechanical: its npm name matches its folder,
|
|
9
|
+
and a library's public surface is a single barrel whose published entrypoints resolve to built output
|
|
10
|
+
rather than raw source. This makes "where does `@scope/foo` live?" and "what does it export?"
|
|
11
|
+
answerable without reading the file.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
For each `package.json` matched by `libraryGlobs` (full checks) or `appGlobs` (name check only):
|
|
16
|
+
|
|
17
|
+
- **name** — must equal `<scope>/<dir-basename>` (or an `externalNames` override).
|
|
18
|
+
- **barrel** (library only) — the package must expose the configured `barrelPath`.
|
|
19
|
+
- **build fields** (library only) — each of `distFields` present as a string must contain
|
|
20
|
+
`distMarker`, and `exports` must reference it.
|
|
21
|
+
- **invalid JSON** — reported as a violation.
|
|
22
|
+
|
|
23
|
+
App/surface packages are deployable entrypoints (vite/tauri/bun), so only the name check applies to
|
|
24
|
+
them.
|
|
25
|
+
|
|
26
|
+
## Factory
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
createPackageShapeRule(options?: PackageShapeOptions): IMetaRule
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
| Option | Type | Default | Meaning |
|
|
33
|
+
| --- | --- | --- | --- |
|
|
34
|
+
| `scope` | `string` | `'@nightcore'` | npm scope every workspace is named under. |
|
|
35
|
+
| `libraryGlobs` | `string[]` | `['packages/*/package.json']` | Library packages (full checks). |
|
|
36
|
+
| `appGlobs` | `string[]` | `['apps/*/package.json']` | App/surface packages (name check only). |
|
|
37
|
+
| `externalNames` | `Record<string,string>` | `{}` | Directory → exact name overrides for intentionally off-scope packages. |
|
|
38
|
+
| `barrelPath` | `string` | `'src/index.ts'` | Barrel each library must expose, relative to its dir. |
|
|
39
|
+
| `distMarker` | `string` | `'dist/'` | Substring the build-output fields must contain. |
|
|
40
|
+
| `distFields` | `string[]` | `['main','module','types']` | `package.json` string fields that must point at built output. |
|
|
41
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
42
|
+
|
|
43
|
+
De-projected from nightcore, which hardcoded the `@nightcore` scope, the `packages/*` vs `apps/*`
|
|
44
|
+
split, the `src/index.ts` barrel and the `dist/` marker.
|
|
45
|
+
|
|
46
|
+
## When not to use it
|
|
47
|
+
|
|
48
|
+
If your packages are not scope-named after their folders, or libraries publish raw source (no build
|
|
49
|
+
step), tune or disable the relevant checks via options.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# `test-runner-segregation`
|
|
2
|
+
|
|
3
|
+
> Bun-side and foreign-side test runners are never mixed within a package.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A package that mixes two test runners (e.g. `bun:test` and Vitest) has tests that run under one
|
|
8
|
+
command but not another — a coverage gap hiding in plain sight. Segregating the runner by workspace,
|
|
9
|
+
and enforcing it per test file, keeps every test attributable to exactly one runner.
|
|
10
|
+
|
|
11
|
+
## What it flags
|
|
12
|
+
|
|
13
|
+
Directories split into a **bun set** (from `bunPackageGlobs` minus `vitestDirs`, plus `bunExtraDirs`)
|
|
14
|
+
and the **foreign set** (`vitestDirs`). For every `*.test.ts(x)` file:
|
|
15
|
+
|
|
16
|
+
- **bun-side** — must import the bun runner (`bunRunnerImport`) and must NOT import the foreign runner
|
|
17
|
+
(`foreignRunnerImport`);
|
|
18
|
+
- **foreign-side** — must NOT import the bun runner. (A direct foreign-runner import is not required —
|
|
19
|
+
it may arrive transitively via shared test-utils.)
|
|
20
|
+
|
|
21
|
+
## Factory
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
createTestRunnerSegregationRule(options?: TestRunnerSegregationOptions): IMetaRule
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Option | Type | Default | Meaning |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `bunPackageGlobs` | `string[]` | `['packages/*/package.json']` | Packages whose dirs default to the bun runner. |
|
|
30
|
+
| `bunExtraDirs` | `string[]` | `[]` | Extra bun-runner directories. |
|
|
31
|
+
| `vitestDirs` | `string[]` | `[]` | Directories that use the foreign (non-bun) runner. |
|
|
32
|
+
| `bunRunnerImport` | `string` | `'bun:test'` | The bun-side runner import specifier. |
|
|
33
|
+
| `foreignRunnerImport` | `string` | `'vitest'` | The foreign runner import specifier. |
|
|
34
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
35
|
+
|
|
36
|
+
De-projected from nightcore, which hardcoded `bun:test` vs `vitest` and the two dir sets.
|
|
37
|
+
|
|
38
|
+
## When not to use it
|
|
39
|
+
|
|
40
|
+
If your repo uses a single test runner everywhere, this rule has nothing to segregate.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# `test-sibling-enforcement`
|
|
2
|
+
|
|
3
|
+
> Every source file matched by `include` must have a colocated sibling test.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A pure helper with no test is a silent liability — the kind of code that drifts because nothing pins
|
|
8
|
+
its behavior. Requiring a colocated test for a chosen class of files (nightcore's `.utils.ts` sidecars)
|
|
9
|
+
makes "did you test this?" a mechanical check rather than a review-time hope.
|
|
10
|
+
|
|
11
|
+
## What it flags
|
|
12
|
+
|
|
13
|
+
For each file matched by an `include` glob, the sibling test is the same path with its `.ts`/`.tsx`
|
|
14
|
+
extension replaced by each configured test extension. If none of those siblings exists, the source
|
|
15
|
+
file is flagged. Strict — there is no baseline; the pattern is opt-in via which files `include` selects.
|
|
16
|
+
|
|
17
|
+
## Factory
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
createTestSiblingEnforcementRule(options?: TestSiblingEnforcementOptions): IMetaRule
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Option | Type | Default | Meaning |
|
|
24
|
+
| --- | --- | --- | --- |
|
|
25
|
+
| `include` | `string[]` | `['apps/web/src/**/*.utils.ts']` | Source files that must ship a colocated test. |
|
|
26
|
+
| `testExtensions` | `string[]` | `['.test.ts', '.test.tsx']` | Accepted colocated-test extensions (replace the source extension). |
|
|
27
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
28
|
+
|
|
29
|
+
De-projected from nightcore, which hardcoded `apps/web/src/**/*.utils.ts` and the `.utils.test.ts(x)`
|
|
30
|
+
sibling shape.
|
|
31
|
+
|
|
32
|
+
## When not to use it
|
|
33
|
+
|
|
34
|
+
If you colocate tests loosely or centralize them in a `__tests__` tree, retune `include`/`testExtensions`
|
|
35
|
+
or skip the rule.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# `test-workspace-enrollment`
|
|
2
|
+
|
|
3
|
+
> Every tested package must be enumerated in the aggregate test script.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
When the CI test command is a hardcoded list of workspace paths, a package added without editing that
|
|
8
|
+
list runs in **no gate** — its tests exist but never execute. This rule fails when any candidate
|
|
9
|
+
package that actually has test files is missing from the aggregate script, so a new tested workspace
|
|
10
|
+
cannot silently escape CI.
|
|
11
|
+
|
|
12
|
+
## What it flags
|
|
13
|
+
|
|
14
|
+
Reads the root manifest's `scriptName` command. For each candidate directory (from `packageGlobs`
|
|
15
|
+
plus `extraDirs`, minus `excludeDirs`) that contains at least one file matching `testGlobSuffix`, if
|
|
16
|
+
the directory string does not appear in the script command, it is flagged. A missing or invalid
|
|
17
|
+
manifest is a no-op.
|
|
18
|
+
|
|
19
|
+
## Factory
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
createTestWorkspaceEnrollmentRule(options?: TestWorkspaceEnrollmentOptions): IMetaRule
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Option | Type | Default | Meaning |
|
|
26
|
+
| --- | --- | --- | --- |
|
|
27
|
+
| `scriptName` | `string` | `'test:node'` | Root-manifest script that must enumerate every tested package. |
|
|
28
|
+
| `manifestPath` | `string` | `'package.json'` | The root manifest path. |
|
|
29
|
+
| `packageGlobs` | `string[]` | `['packages/*/package.json']` | Candidate test workspaces. |
|
|
30
|
+
| `extraDirs` | `string[]` | `[]` | Extra directories to check beyond the globbed packages. |
|
|
31
|
+
| `excludeDirs` | `string[]` | `[]` | Directories tested by a different runner/script. |
|
|
32
|
+
| `testGlobSuffix` | `string` | `'**/*.test.ts'` | Glob suffix (per dir) that detects the presence of tests. |
|
|
33
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
34
|
+
|
|
35
|
+
De-projected from nightcore, which hardcoded the `test:node` script, a `packages/*` + `apps/sidecar`
|
|
36
|
+
dir list, and a vitest exclusion set.
|
|
37
|
+
|
|
38
|
+
## When not to use it
|
|
39
|
+
|
|
40
|
+
If your test runner discovers packages automatically (no hardcoded path list), this rule is
|
|
41
|
+
unnecessary.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# `ui-primitive-shape`
|
|
2
|
+
|
|
3
|
+
> A folder primitive must ship its proof siblings; a flat primitive must carry none at the ui root.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
The primitives root is the one place exempt from folder-per-component: flat single files are fine for
|
|
8
|
+
presentational primitives. But once a primitive graduates to its own folder, it is a real component
|
|
9
|
+
and must carry the same proof-of-behavior siblings (a test, a story) as any feature component. This
|
|
10
|
+
closes the "the ui folder mixes two shapes with no rule" gap: flat = pure presentational; folder =
|
|
11
|
+
tested + storied.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
- **Folder primitives** — for each `<uiRoot>/<Name>/<barrelFile>`, each configured role must exist as
|
|
16
|
+
`<Name>.<role><extension>`; a missing one is flagged.
|
|
17
|
+
- **Flat primitives** — a flat `<uiRoot>/<Name><extension>` (capitalized) that already has a sibling
|
|
18
|
+
`<Name>.<role><extension>` at the ui root is flagged: those proof files belong inside a `<Name>/`
|
|
19
|
+
folder.
|
|
20
|
+
|
|
21
|
+
## Factory
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
createUiPrimitiveShapeRule(options?: UiPrimitiveShapeOptions): IMetaRule
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Option | Type | Default | Meaning |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `uiRoot` | `string` | `'apps/web/src/components/ui'` | The primitives root. |
|
|
30
|
+
| `barrelFile` | `string` | `'index.ts'` | Barrel file that marks a folder-primitive. |
|
|
31
|
+
| `roles` | `string[]` | `['test', 'stories']` | Proof-of-behavior sibling roles a folder-primitive must ship. |
|
|
32
|
+
| `extension` | `string` | `'.tsx'` | Extension of primitive and proof files. |
|
|
33
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
34
|
+
|
|
35
|
+
De-projected from nightcore, which hardcoded `apps/web/src/components/ui`, the `test`/`stories` roles,
|
|
36
|
+
and the `.tsx` extension.
|
|
37
|
+
|
|
38
|
+
## When not to use it
|
|
39
|
+
|
|
40
|
+
If you do not maintain a flat-vs-folder primitive convention or do not colocate stories/tests, skip
|
|
41
|
+
the rule.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# `workspace-graph-parity`
|
|
2
|
+
|
|
3
|
+
> Imported `<scope>/*` specifiers must be declared `workspace:*` deps, and tsconfig `references` must
|
|
4
|
+
> mirror those deps.
|
|
5
|
+
|
|
6
|
+
## Why
|
|
7
|
+
|
|
8
|
+
A cross-package edge is real in three places at once: the import in source, the `workspace:*` entry in
|
|
9
|
+
`package.json`, and the project reference in `tsconfig.json`. When they drift, a package builds
|
|
10
|
+
locally but breaks on a clean install or a project-graph build. This rule keeps the three in lockstep
|
|
11
|
+
so an edge can never be half-wired.
|
|
12
|
+
|
|
13
|
+
## What it flags
|
|
14
|
+
|
|
15
|
+
For each workspace `package.json`:
|
|
16
|
+
|
|
17
|
+
- **(a) imported ⊆ declared** — every `<scope>/<pkg>` imported under the source dir (test files
|
|
18
|
+
excluded, self-imports ignored) must appear as a `"<scope>/<pkg>": "workspace:*"` dependency.
|
|
19
|
+
- **(b) references mirror deps** — the tsconfig `references` must reference exactly the declared
|
|
20
|
+
workspace deps: a declared dep missing from references, or a referenced package that is not a
|
|
21
|
+
declared dep, is a violation.
|
|
22
|
+
|
|
23
|
+
## Factory
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
createWorkspaceGraphParityRule(options?: WorkspaceGraphParityOptions): IMetaRule
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Option | Type | Default | Meaning |
|
|
30
|
+
| --- | --- | --- | --- |
|
|
31
|
+
| `scope` | `string` | `'@nightcore'` | npm scope of workspace packages (all scope regexes are rebuilt from it). |
|
|
32
|
+
| `packageGlobs` | `string[]` | `['packages/*/package.json', 'apps/*/package.json']` | Packages to enforce parity for. |
|
|
33
|
+
| `srcDir` | `string` | `'src'` | Source directory (per package) scanned for imports. |
|
|
34
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
35
|
+
|
|
36
|
+
De-projected from nightcore, which hardcoded the `@nightcore` scope and a `src` source directory.
|
|
37
|
+
|
|
38
|
+
## When not to use it
|
|
39
|
+
|
|
40
|
+
If your project does not use the `workspace:*` protocol, or does not use TypeScript project
|
|
41
|
+
references, this rule's assumptions do not hold.
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noctcore/lint-meta-rules",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Portable, parameterized lint-meta rules — whole-repo / cross-file invariants ESLint cannot reach — for the @noctcore/harness lint-meta runner.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"docs",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"keywords": [
|
|
25
|
+
"lint-meta",
|
|
26
|
+
"noctcore",
|
|
27
|
+
"harness",
|
|
28
|
+
"structure-lock",
|
|
29
|
+
"monorepo",
|
|
30
|
+
"convention"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/noctcore/eslint-plugins.git",
|
|
39
|
+
"directory": "packages/lint-meta-rules"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/noctcore/eslint-plugins/tree/main/packages/lint-meta-rules",
|
|
42
|
+
"bugs": "https://github.com/noctcore/eslint-plugins/issues",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "bun test"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@noctcore/harness": "^0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^22.0.0",
|
|
53
|
+
"tsup": "^8.5.1",
|
|
54
|
+
"typescript": "^5.6.0"
|
|
55
|
+
}
|
|
56
|
+
}
|