@noctcore/lint-meta-rules 0.5.0 → 0.6.1
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 +94 -60
- package/dist/{chunk-Z7TXSZR4.js → chunk-OYFQKSJN.js} +9 -1
- package/dist/i18n.js +1 -1
- package/dist/index.js +1 -1
- package/dist/prisma.js +3 -3
- package/dist/session.cjs +297 -0
- package/dist/session.d.cts +226 -0
- package/dist/session.d.ts +226 -0
- package/dist/session.js +244 -0
- package/dist/trpc.cjs +122 -0
- package/dist/trpc.d.cts +59 -0
- package/dist/trpc.d.ts +59 -0
- package/dist/trpc.js +73 -0
- package/docs/rules/agents-doc-presence.md +13 -4
- package/docs/rules/canonical-helpers-single-home.md +13 -3
- package/docs/rules/dockerfile-base-image-digest-pin.md +17 -4
- package/docs/rules/eslint-config-no-warn.md +22 -7
- package/docs/rules/file-size-ratchet.md +19 -9
- package/docs/rules/github-actions-least-privilege-permissions.md +20 -11
- package/docs/rules/github-actions-no-template-injection.md +21 -12
- package/docs/rules/github-actions-runner-pinned.md +19 -6
- package/docs/rules/github-actions-sha-pinned.md +19 -5
- package/docs/rules/idempotency-key-parity.md +91 -0
- package/docs/rules/layer-rank.md +17 -6
- package/docs/rules/no-cloned-component-folders.md +13 -4
- package/docs/rules/no-warn-severity.md +14 -3
- package/docs/rules/package-shape.md +13 -6
- package/docs/rules/prisma-method-surface.md +19 -4
- package/docs/rules/security-scanner-version-parity.md +16 -7
- package/docs/rules/service-image-digest-pin.md +21 -7
- package/docs/rules/session-epoch-captured.md +89 -0
- package/docs/rules/session-kind-stamped.md +89 -0
- package/docs/rules/session-landing-declared.md +95 -0
- package/docs/rules/session-mint-callers.md +85 -0
- package/docs/rules/tenant-model-registry-parity.md +18 -4
- package/docs/rules/test-runner-segregation.md +15 -5
- package/docs/rules/test-sibling-enforcement.md +12 -5
- package/docs/rules/test-workspace-enrollment.md +14 -6
- package/docs/rules/translation-dead-keys.md +33 -20
- package/docs/rules/ui-primitive-shape.md +13 -4
- package/docs/rules/workspace-graph-parity.md +15 -5
- package/package.json +18 -8
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
> CI and the local pre-push hook pin the same secret-scanner version, and the hook checks it at run
|
|
4
4
|
> time.
|
|
5
5
|
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createSecurityScannerVersionParityRule` from `@noctcore/lint-meta-rules` · Category `ci` · Fails CI by default: yes
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
6
10
|
## Why
|
|
7
11
|
|
|
8
12
|
Different scanner versions ship different rulesets, so a push can pass the local hook and fail CI (or
|
|
@@ -20,8 +24,6 @@ With the defaults (gitleaks):
|
|
|
20
24
|
- a hook `GITLEAKS_IMAGE="...gitleaks:vX.Y.Z"` tag that differs from CI,
|
|
21
25
|
- a hook that never runs `gitleaks version`.
|
|
22
26
|
|
|
23
|
-
Dormant when neither the workflows nor the hook mention the scanner.
|
|
24
|
-
|
|
25
27
|
```yaml
|
|
26
28
|
# .github/workflows/security.yml
|
|
27
29
|
env:
|
|
@@ -39,7 +41,15 @@ GITLEAKS_VERSION="8.29.0"
|
|
|
39
41
|
gitleaks git .
|
|
40
42
|
```
|
|
41
43
|
|
|
42
|
-
##
|
|
44
|
+
## What it does not flag
|
|
45
|
+
|
|
46
|
+
- A repo where neither the workflows nor the hook mention the scanner: the rule is dormant.
|
|
47
|
+
- Whether the hook's version comparison is correct: it checks only that the hook's text runs
|
|
48
|
+
`<scanner> version`.
|
|
49
|
+
- Versions not written as `x.y.z`, and any hook other than `hookFile`. A repo with several hooks needs
|
|
50
|
+
one rule instance per hook.
|
|
51
|
+
|
|
52
|
+
## Options
|
|
43
53
|
|
|
44
54
|
```ts
|
|
45
55
|
createSecurityScannerVersionParityRule(options?: SecurityScannerVersionParityOptions): IMetaRule
|
|
@@ -54,8 +64,7 @@ createSecurityScannerVersionParityRule(options?: SecurityScannerVersionParityOpt
|
|
|
54
64
|
| `workflowGlobs` | `string[]` | `['.github/workflows/*.yml', '.github/workflows/*.yaml']` | Workflow files to scan. |
|
|
55
65
|
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
56
66
|
|
|
57
|
-
##
|
|
67
|
+
## When not to use it
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
instance per hook.
|
|
69
|
+
If you run the secret scanner in only one place (CI or a local hook, not both), there is no second pin
|
|
70
|
+
to keep in step.
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Workflow service/container images and docker-compose images are pinned by `@sha256:` digest.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createServiceImageDigestPinRule` from `@noctcore/lint-meta-rules` · Category `ci` · Fails CI by default: yes
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
A tag (`postgres:17-alpine`) or `latest` names whatever the registry serves today, so a green build
|
|
@@ -11,9 +15,9 @@ can change under an unchanged commit, and a local run cannot promise the bytes C
|
|
|
11
15
|
## What it flags
|
|
12
16
|
|
|
13
17
|
- In workflow files: `image:` under a job's `services:`, and a job's `container:` in scalar or mapping
|
|
14
|
-
form.
|
|
15
|
-
- In compose files: every service's `image:`,
|
|
16
|
-
|
|
18
|
+
form.
|
|
19
|
+
- In compose files: every service's `image:`, including one behind an env default
|
|
20
|
+
(`${REDIS_IMAGE:-redis:7}`), which is reported as written since the text does not show a digest.
|
|
17
21
|
|
|
18
22
|
Each violation carries the 1-indexed line.
|
|
19
23
|
|
|
@@ -32,7 +36,17 @@ services:
|
|
|
32
36
|
image: app-api:local
|
|
33
37
|
```
|
|
34
38
|
|
|
35
|
-
##
|
|
39
|
+
## What it does not flag
|
|
40
|
+
|
|
41
|
+
- An image pinned as `tag@sha256:<digest>`.
|
|
42
|
+
- An action step's `with: image:` input: it is not an image pull.
|
|
43
|
+
- A compose service that also has `build:`: there `image:` tags an image built locally, which has no
|
|
44
|
+
upstream digest.
|
|
45
|
+
- An exact ref listed in `allowUnpinned`, and compose files under a `skipDirs` segment.
|
|
46
|
+
|
|
47
|
+
The check is line-based text, not a YAML parse.
|
|
48
|
+
|
|
49
|
+
## Options
|
|
36
50
|
|
|
37
51
|
```ts
|
|
38
52
|
createServiceImageDigestPinRule(options?: ServiceImageDigestPinOptions): IMetaRule
|
|
@@ -46,7 +60,7 @@ createServiceImageDigestPinRule(options?: ServiceImageDigestPinOptions): IMetaRu
|
|
|
46
60
|
| `allowUnpinned` | `string[]` | `[]` | Exact image refs allowed unpinned, for a ref the registry cannot serve a digest for. Only that exact ref passes; a bumped tag is checked again. |
|
|
47
61
|
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
48
62
|
|
|
49
|
-
##
|
|
63
|
+
## When not to use it
|
|
50
64
|
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
If your workflows and compose files only run throwaway local services where you accept whatever a tag
|
|
66
|
+
serves today, skip it.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# `session-epoch-captured`
|
|
2
|
+
|
|
3
|
+
> Every call into the sign-in seam passes the session epoch it captured before reading the
|
|
4
|
+
> credential.
|
|
5
|
+
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createSessionEpochCapturedRule` from `@noctcore/lint-meta-rules/session` · Category `source-text` · Fails CI by default: yes
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
10
|
+
Import it from the `session` entry point:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createSessionEpochCapturedRule } from '@noctcore/lint-meta-rules/session';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
A common way to make "sign out everywhere" stick is a per-user epoch: a revocation bumps it, and a mint
|
|
19
|
+
refuses to write a session under a stale value. Where the epoch is read decides what the fence covers.
|
|
20
|
+
Read inside the mint, it covers the session-store write and nothing else, so the whole credential check
|
|
21
|
+
(a password hash verify, an OAuth token exchange) is a window in which a sign-in that already read the
|
|
22
|
+
revoked state still mints a surviving session.
|
|
23
|
+
|
|
24
|
+
So every entry point reads the epoch BEFORE it reads the credential and hands the value to the seam
|
|
25
|
+
that ends the sign-in. Forgetting to is invisible: the sign-in works and every test passes, because the
|
|
26
|
+
race only loses under a concurrent revocation. This rule fails any call into the seam whose arguments
|
|
27
|
+
never mention the epoch.
|
|
28
|
+
|
|
29
|
+
## What it flags
|
|
30
|
+
|
|
31
|
+
Each `.<call>(...)` call in a file matched by `sourceGlobs` whose argument text (found by balancing
|
|
32
|
+
parentheses) does not mention `field` as a whole word. The parenthesis scanner reads each call on its
|
|
33
|
+
own, so a call written on one line is checked too, and an unstamped call cannot borrow the epoch of
|
|
34
|
+
the next one.
|
|
35
|
+
|
|
36
|
+
## What it does not flag
|
|
37
|
+
|
|
38
|
+
- `epochless: true`, or any identifier that merely contains the field: whole words only.
|
|
39
|
+
- Files in `exempt` (the seam's own home, which defines the method and its default).
|
|
40
|
+
- Files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
|
|
41
|
+
|
|
42
|
+
With no `call` the rule is inert.
|
|
43
|
+
|
|
44
|
+
## Options
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
createSessionEpochCapturedRule(options?: SessionEpochCapturedOptions): IMetaRule
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Option | Type | Default | Meaning |
|
|
51
|
+
| --- | --- | --- | --- |
|
|
52
|
+
| `id` | `string` | `'session-epoch-captured'` | Rule id. |
|
|
53
|
+
| `call` | `string` | none (inert) | The post-credential seam, matched as `.<call>(`. |
|
|
54
|
+
| `field` | `string` | `'epoch'` | The argument every call must mention. |
|
|
55
|
+
| `exempt` | `string[]` | `[]` | Repo-relative files skipped outright: the seam's own home. |
|
|
56
|
+
| `captureCall` | `string` | none | How the epoch is captured, quoted in the message. |
|
|
57
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Source to read. |
|
|
58
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
59
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
60
|
+
| `hint` | `string` | none | Appended to every message. |
|
|
61
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
62
|
+
|
|
63
|
+
### Worked example: an app with a post-credential sign-in seam
|
|
64
|
+
|
|
65
|
+
Every sign-in entry point (password, OAuth, second factor) ends by calling one seam,
|
|
66
|
+
`beginOrEstablish`, which a two-factor challenge service defines. Each entry point reads the epoch with
|
|
67
|
+
`sessionService.readEpoch(userId)` before it checks the credential, and passes it in:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
createSessionEpochCapturedRule({
|
|
71
|
+
call: 'beginOrEstablish',
|
|
72
|
+
exempt: ['apps/api/src/modules/auth/services/two-factor-challenge.service.ts'],
|
|
73
|
+
captureCall: 'sessionService.readEpoch(userId)',
|
|
74
|
+
sourceGlobs: [
|
|
75
|
+
'apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}',
|
|
76
|
+
'packages/*/{src,test,tests,security-spec}/**/*.{ts,tsx}',
|
|
77
|
+
'tools/**/*.{ts,tsx}',
|
|
78
|
+
],
|
|
79
|
+
excludeSuffixes: ['.spec.ts', '.test.ts'],
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two flows in such an app mint without a captured epoch and are outside the rule by construction, because they
|
|
84
|
+
never call the seam: signup (the account did not exist when the request began) and password change (it
|
|
85
|
+
revokes first and then re-issues, so the current counter is the right one).
|
|
86
|
+
|
|
87
|
+
## When not to use it
|
|
88
|
+
|
|
89
|
+
Without a revocation epoch (or an equivalent generation counter) there is no fence to extend.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# `session-kind-stamped`
|
|
2
|
+
|
|
3
|
+
> Every call that mints a session stamps the principal's kind onto it, or sits in an allowlisted,
|
|
4
|
+
> provably single-kind flow.
|
|
5
|
+
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createSessionKindStampedRule` from `@noctcore/lint-meta-rules/session` · Category `source-text` · Fails CI by default: yes
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
10
|
+
Import it from the `session` entry point:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createSessionKindStampedRule } from '@noctcore/lint-meta-rules/session';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
When one sign-in serves two kinds of account (staff and a customer portal, say) and the request
|
|
19
|
+
pipeline reads the kind from the SESSION rather than the database, the stamp is only as good as the
|
|
20
|
+
mint that wrote it. A session minted without the field reads as whatever the reader defaults to. For the
|
|
21
|
+
fence between the two kinds that is a silent promotion: a portal account whose session lost its stamp
|
|
22
|
+
is a staff caller inside its own tenant, and the tenant boundary waves it through as legitimate.
|
|
23
|
+
|
|
24
|
+
This is not hypothetical. In one production codebase, two re-issue paths (a password
|
|
25
|
+
change and a revoke-other-sessions action, both reachable by a portal account) shipped without the
|
|
26
|
+
stamp while every gate was green, because nothing mechanical looked.
|
|
27
|
+
|
|
28
|
+
## What it flags
|
|
29
|
+
|
|
30
|
+
For each `.<mintCall>(...)` call in a file matched by `sourceGlobs` (arguments found by balancing
|
|
31
|
+
parentheses, so a multi-line options object with nested calls is read whole):
|
|
32
|
+
|
|
33
|
+
1. If the arguments contain an object literal, that literal must mention `field` as a whole word. Each
|
|
34
|
+
call is checked on its own, so a file with two mints where only one stamps is still reported.
|
|
35
|
+
2. If the arguments carry no literal (options built elsewhere), the file must mention `field`
|
|
36
|
+
somewhere. Coarser, but no silent hole.
|
|
37
|
+
3. Otherwise the file must be in `allowUnstamped`.
|
|
38
|
+
|
|
39
|
+
## What it does not flag
|
|
40
|
+
|
|
41
|
+
- `.<mintCall>Something(`, and the method's definition.
|
|
42
|
+
- `kindless` or `kinds`: the field must appear as a whole word.
|
|
43
|
+
- Files in `allowUnstamped`, files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
|
|
44
|
+
|
|
45
|
+
With no `mintCall` the rule is inert.
|
|
46
|
+
|
|
47
|
+
Residual gap, stated rather than hidden: a file that stamps the field on one delegated mint and forgets
|
|
48
|
+
it on a second delegated mint passes clause 2.
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
createSessionKindStampedRule(options?: SessionKindStampedOptions): IMetaRule
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Meaning |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `id` | `string` | `'session-kind-stamped'` | Rule id. |
|
|
59
|
+
| `mintCall` | `string` | none (inert) | The method whose call mints a session, matched as `.<mintCall>(`. |
|
|
60
|
+
| `field` | `string` | `'kind'` | The session field every mint must stamp. |
|
|
61
|
+
| `allowUnstamped` | `string[]` | `[]` | Repo-relative files whose mints may stamp nothing because they provably never mint for an account that needs the field. Keep it short; write the proof next to each entry. |
|
|
62
|
+
| `stampExample` | `string` | none | An example of the stamp, quoted in the message. |
|
|
63
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
|
|
64
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
65
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
66
|
+
| `hint` | `string` | none | Appended to every message. |
|
|
67
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
68
|
+
|
|
69
|
+
### Worked example: an app with staff and portal accounts
|
|
70
|
+
|
|
71
|
+
One sign-in serves staff accounts and invite-only portal accounts, and every session mint goes
|
|
72
|
+
through `establishSession`. Self-signup only ever creates staff accounts, so its file is allowed to
|
|
73
|
+
mint without a stamp:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
createSessionKindStampedRule({
|
|
77
|
+
mintCall: 'establishSession',
|
|
78
|
+
// Self-signup creates a new tenant and its owner ADMIN; a PORTAL row is only ever
|
|
79
|
+
// invite-provisioned, so no input to this flow produces a portal session.
|
|
80
|
+
allowUnstamped: ['apps/api/src/modules/auth/services/register.service.ts'],
|
|
81
|
+
stampExample: "...(user.kind === 'PORTAL' ? { kind: 'PORTAL' as const } : {})",
|
|
82
|
+
sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
|
|
83
|
+
});
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## When not to use it
|
|
87
|
+
|
|
88
|
+
If the pipeline reads the account kind from the database on every request, a missing stamp costs
|
|
89
|
+
nothing and there is nothing to fence.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# `session-landing-declared`
|
|
2
|
+
|
|
3
|
+
> Every file that opens a door into a session declares where it leaves the caller, and a door whose
|
|
4
|
+
> landing demands a return shape has it.
|
|
5
|
+
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createSessionLandingDeclaredRule` from `@noctcore/lint-meta-rules/session` · Category `source-text` · Fails CI by default: yes
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
10
|
+
Import it from the `session` entry point:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createSessionLandingDeclaredRule } from '@noctcore/lint-meta-rules/session';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
With two shells behind one sign-in (staff and a customer portal, say), the client can only send an
|
|
19
|
+
account to the right one if the response that ends the sign-in says which kind it is. The first door
|
|
20
|
+
(the password sign-in) usually does. A second door that finishes a sign-in from a different service (a
|
|
21
|
+
second-factor challenge) can return the same union and still have its landing unwired, and nothing
|
|
22
|
+
fails: the account signs in, lands in the wrong shell, and bounces off its guard. In one production
|
|
23
|
+
codebase, every portal account with 2FA enabled did exactly that.
|
|
24
|
+
|
|
25
|
+
Two sibling rules fence the same seam, [`session-mint-callers`](./session-mint-callers.md) (who may
|
|
26
|
+
mint) and [`session-kind-stamped`](./session-kind-stamped.md) (the session carries the kind). Neither
|
|
27
|
+
can see this, because both are about the session and this is about the RESPONSE.
|
|
28
|
+
|
|
29
|
+
## What it flags
|
|
30
|
+
|
|
31
|
+
1. **Completeness.** A file matched by `sourceGlobs` that calls any of `doorCalls` and is not in
|
|
32
|
+
`doors`. A new door fails the build until someone classifies it, which is what keeps the list an
|
|
33
|
+
enumeration rather than a docblock nobody updates.
|
|
34
|
+
2. **The landing's demand.** A door whose landing maps to a string in `landings` and whose source never
|
|
35
|
+
contains that string (for example `Promise<ILoginResult>`, the return type that carries the kind to
|
|
36
|
+
the client). Coarse on purpose: it proves the kind REACHES the client, which is what a text rule can
|
|
37
|
+
see; where the client then navigates is the client's own tests' job.
|
|
38
|
+
3. **The declarations.** A door whose `landing` is not a key of `landings`, or whose `because` is empty.
|
|
39
|
+
4. **Staleness.** A door whose file no longer exists, or that `sourceGlobs` do not reach.
|
|
40
|
+
|
|
41
|
+
## What it does not flag
|
|
42
|
+
|
|
43
|
+
- A file that only defines a door method (the leading dot is required).
|
|
44
|
+
- A door whose landing maps to `null`: a re-issue that replaces the cookie of a caller already inside a
|
|
45
|
+
shell has no landing to get wrong.
|
|
46
|
+
- Files ending in an `excludeSuffixes` entry, and `skipDirs` segments.
|
|
47
|
+
|
|
48
|
+
With no `doorCalls` the rule is inert.
|
|
49
|
+
|
|
50
|
+
## Options
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
createSessionLandingDeclaredRule(options?: SessionLandingDeclaredOptions): IMetaRule
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Meaning |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `id` | `string` | `'session-landing-declared'` | Rule id. |
|
|
59
|
+
| `doorCalls` | `string[]` | `[]` (inert) | Methods whose call opens a door into a session (the mint, and the gate in front of it), each matched as `.<name>(`. |
|
|
60
|
+
| `doors` | `{ file, landing, because }[]` | `[]` | Every door: a repo-relative file, one of the `landings`, and why that landing is right. |
|
|
61
|
+
| `landings` | `Record<string, string \| null>` | `{}` | The landings a door may declare, each mapped to the text a door with it must contain, or `null`. |
|
|
62
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
|
|
63
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
64
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
65
|
+
| `hint` | `string` | none | Appended to every message. |
|
|
66
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
67
|
+
|
|
68
|
+
### Worked example: an app with two sign-in shells
|
|
69
|
+
|
|
70
|
+
Staff and portal accounts share one sign-in and land in different shells. Sessions open through
|
|
71
|
+
`establishSession` or the second-factor gate `beginOrEstablish`. The two doors either kind can reach
|
|
72
|
+
must return `Promise<ILoginResult>`; the rest are staff-only or re-issues:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const AUTH = 'apps/api/src/modules/auth';
|
|
76
|
+
|
|
77
|
+
createSessionLandingDeclaredRule({
|
|
78
|
+
doorCalls: ['establishSession', 'beginOrEstablish'],
|
|
79
|
+
landings: { 'login-result': 'Promise<ILoginResult>', reissue: null, 'staff-only': null },
|
|
80
|
+
doors: [
|
|
81
|
+
{ file: `${AUTH}/services/login.service.ts`, landing: 'login-result', because: 'The password sign-in: either half of the product arrives here.' },
|
|
82
|
+
{ file: `${AUTH}/services/two-factor-challenge.service.ts`, landing: 'login-result', because: 'The second door: verifyChallenge finishes a sign-in on its own.' },
|
|
83
|
+
{ file: `${AUTH}/oauth/oauth.controller.ts`, landing: 'staff-only', because: 'OAuthAccountService refuses a PORTAL account on every resolution branch.' },
|
|
84
|
+
{ file: `${AUTH}/services/register.service.ts`, landing: 'staff-only', because: 'Self-signup mints a tenant and its owner ADMIN.' },
|
|
85
|
+
{ file: `${AUTH}/services/password-change.service.ts`, landing: 'reissue', because: 'The caller already holds a session.' },
|
|
86
|
+
{ file: `${AUTH}/services/session-management.service.ts`, landing: 'reissue', because: 'revokeOthers re-issues the caller their own session.' },
|
|
87
|
+
],
|
|
88
|
+
sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## When not to use it
|
|
93
|
+
|
|
94
|
+
With a single shell, or with the landing decided server-side by a redirect that already reads the
|
|
95
|
+
account, there is no landing a door can get wrong.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# `session-mint-callers`
|
|
2
|
+
|
|
3
|
+
> The method that mints a session is callable only from an allowlist of files, so a new sign-in
|
|
4
|
+
> entry point cannot skip the gate in front of it.
|
|
5
|
+
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createSessionMintCallersRule` from `@noctcore/lint-meta-rules/session` · Category `source-text` · Fails CI by default: yes
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
10
|
+
Import it from the `session` entry point:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { createSessionMintCallersRule } from '@noctcore/lint-meta-rules/session';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
Most auth stacks have one method that turns an authenticated principal into a session: it writes the
|
|
19
|
+
session store, sets the cookie, rotates CSRF. In front of it sits a gate (a second-factor challenge, an
|
|
20
|
+
epoch capture) that every sign-in is meant to pass through. A new entry point, typically an OAuth
|
|
21
|
+
callback added months later, that calls the mint directly signs the user in with the gate skipped, and
|
|
22
|
+
nothing fails: the flow works, its tests pass, the gate's tests pass. The mint is the one door into a
|
|
23
|
+
session, so this rule fences the door by caller.
|
|
24
|
+
|
|
25
|
+
## What it flags
|
|
26
|
+
|
|
27
|
+
A file matched by `sourceGlobs` that calls `.<mintCall>(` (whitespace before the paren allowed) and is
|
|
28
|
+
not in `allowedCallers`.
|
|
29
|
+
|
|
30
|
+
## What it does not flag
|
|
31
|
+
|
|
32
|
+
- The method's definition (`async establishSession(`): the leading dot is required.
|
|
33
|
+
- `.<mintCall>Something(`: only whitespace may sit between the name and the paren.
|
|
34
|
+
- Files ending in an `excludeSuffixes` entry (tests drive the mint directly), and paths with a
|
|
35
|
+
`skipDirs` segment.
|
|
36
|
+
- Everything outside `sourceGlobs`, including a lint-meta rule module that quotes the call.
|
|
37
|
+
|
|
38
|
+
With no `mintCall` the rule is inert.
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
createSessionMintCallersRule(options?: SessionMintCallersOptions): IMetaRule
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Option | Type | Default | Meaning |
|
|
47
|
+
| --- | --- | --- | --- |
|
|
48
|
+
| `id` | `string` | `'session-mint-callers'` | Rule id. |
|
|
49
|
+
| `mintCall` | `string` | none (inert) | The method whose call mints a session, matched as `.<mintCall>(`. |
|
|
50
|
+
| `allowedCallers` | `string[]` | `[]` | Repo-relative files that may call it, matched exactly: the method's home, the gate, and flows with no gate to pass (signup, a re-issue to a caller who already holds a session). |
|
|
51
|
+
| `gateCall` | `string` | none | The method a sign-in entry point must call instead, named in the message. |
|
|
52
|
+
| `sourceGlobs` | `string[]` | `[]` (inert) | Application source to read. |
|
|
53
|
+
| `skipDirs` | `string[]` | `node_modules`, `.git`, `dist`, `.turbo`, `coverage` | Path segments skipped. |
|
|
54
|
+
| `excludeSuffixes` | `string[]` | `.spec.ts`, `.spec.tsx`, `.test.ts`, `.test.tsx` | File endings skipped. |
|
|
55
|
+
| `hint` | `string` | none | Appended to every message, e.g. a pointer to your auth docs. |
|
|
56
|
+
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
57
|
+
|
|
58
|
+
### Worked example: an app with a gated session mint
|
|
59
|
+
|
|
60
|
+
`establishSession` mints the session and `beginOrEstablish` is the second-factor gate every sign-in
|
|
61
|
+
must pass through. Only the mint's home, the gate, signup and the two re-issue flows may call the mint
|
|
62
|
+
directly:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const AUTH = 'apps/api/src/modules/auth/services';
|
|
66
|
+
|
|
67
|
+
createSessionMintCallersRule({
|
|
68
|
+
id: 'establish-session-callers',
|
|
69
|
+
mintCall: 'establishSession',
|
|
70
|
+
gateCall: 'beginOrEstablish',
|
|
71
|
+
allowedCallers: [
|
|
72
|
+
`${AUTH}/auth-shared.service.ts`,
|
|
73
|
+
`${AUTH}/two-factor-challenge.service.ts`,
|
|
74
|
+
`${AUTH}/register.service.ts`,
|
|
75
|
+
`${AUTH}/password-change.service.ts`,
|
|
76
|
+
`${AUTH}/session-management.service.ts`,
|
|
77
|
+
],
|
|
78
|
+
sourceGlobs: ['apps/*/{src,test,tests,security-spec}/**/*.{ts,tsx}'],
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## When not to use it
|
|
83
|
+
|
|
84
|
+
If sessions are minted by a framework you do not call (a hosted auth provider's middleware), there is no
|
|
85
|
+
method to fence.
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
> Every tenant-bearing Prisma model is scoped by the runtime tenant extension or exempt with a
|
|
4
4
|
> reason, and the tenant lint rules resolve with exactly that registry.
|
|
5
5
|
|
|
6
|
+
<!-- begin generated rule header -->
|
|
7
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createTenantModelRegistryParityRule` from `@noctcore/lint-meta-rules/prisma` · Category `config` · Fails CI by default: yes
|
|
8
|
+
<!-- end generated rule header -->
|
|
9
|
+
|
|
6
10
|
Import it from the `prisma` entry point:
|
|
7
11
|
|
|
8
12
|
```ts
|
|
@@ -45,7 +49,17 @@ cannot be resolved.
|
|
|
45
49
|
|
|
46
50
|
Async: it implements the harness's `runAsync` (`@noctcore/harness` 0.3.0 or newer).
|
|
47
51
|
|
|
48
|
-
##
|
|
52
|
+
## What it does not flag
|
|
53
|
+
|
|
54
|
+
- A schema model that carries only some of the `tenantFields` columns: a model must carry ALL of them
|
|
55
|
+
to be tenant-bearing.
|
|
56
|
+
- A tenant-bearing model listed in `unscopedByDesign`, once it names its hand-scope columns in
|
|
57
|
+
`handScopedModels` or records why it cannot in `handScopePending`.
|
|
58
|
+
- Hand-scope gaps when `requireHandScope` is `false`.
|
|
59
|
+
- The resolved ESLint config when `eslint` is `false`; the hand-scope option when `handScopedRule` is
|
|
60
|
+
`null` or the registry has no hand-scoped models.
|
|
61
|
+
|
|
62
|
+
## Options
|
|
49
63
|
|
|
50
64
|
```ts
|
|
51
65
|
createTenantModelRegistryParityRule(options?: TenantModelRegistryParityOptions): IMetaRule
|
|
@@ -77,10 +91,10 @@ createTenantModelRegistryParityRule(options?: TenantModelRegistryParityOptions):
|
|
|
77
91
|
|
|
78
92
|
Keys are Prisma delegate accessors (`invoice` for `model Invoice`).
|
|
79
93
|
|
|
80
|
-
|
|
94
|
+
### Worked example: a multi-tenant monorepo
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
extension and the exemption maps in a registry module its ESLint config also reads:
|
|
96
|
+
A project that scopes on `tenantId`, splits its schema per domain, keeps the runtime map in its API's
|
|
97
|
+
tenant extension and the exemption maps in a registry module its ESLint config also reads:
|
|
84
98
|
|
|
85
99
|
```ts
|
|
86
100
|
import { createTenantModelRegistryParityRule } from '@noctcore/lint-meta-rules/prisma';
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Bun-side and foreign-side test runners are never mixed within a package.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createTestRunnerSegregationRule` from `@noctcore/lint-meta-rules` · Category `testing` · Fails CI by default: yes
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
A package that mixes two test runners (e.g. `bun:test` and Vitest) has tests that run under one
|
|
@@ -15,10 +19,18 @@ and the **foreign set** (`vitestDirs`). For every `*.test.ts(x)` file:
|
|
|
15
19
|
|
|
16
20
|
- **bun-side** — must import the bun runner (`bunRunnerImport`) and must NOT import the foreign runner
|
|
17
21
|
(`foreignRunnerImport`);
|
|
18
|
-
- **foreign-side** — must NOT import the bun runner.
|
|
19
|
-
|
|
22
|
+
- **foreign-side** — must NOT import the bun runner.
|
|
23
|
+
|
|
24
|
+
## What it does not flag
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
- A foreign-side test with no direct foreign-runner import: the runner may arrive transitively via
|
|
27
|
+
shared test-utils.
|
|
28
|
+
- A bun-side test that imports `bunRunnerImport` and not the foreign runner.
|
|
29
|
+
- Anything in a `vitestDirs` directory for a missing bun import (those dirs leave the bun set).
|
|
30
|
+
- Files that are not `*.test.ts` / `*.test.tsx` (setup files, helpers), and `require()` or dynamic
|
|
31
|
+
imports: only a `from '<runner>'` import is read.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
22
34
|
|
|
23
35
|
```ts
|
|
24
36
|
createTestRunnerSegregationRule(options?: TestRunnerSegregationOptions): IMetaRule
|
|
@@ -33,8 +45,6 @@ createTestRunnerSegregationRule(options?: TestRunnerSegregationOptions): IMetaRu
|
|
|
33
45
|
| `foreignRunnerImport` | `string` | `'vitest'` | The foreign runner import specifier. |
|
|
34
46
|
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
35
47
|
|
|
36
|
-
De-projected from nightcore, which hardcoded `bun:test` vs `vitest` and the two dir sets.
|
|
37
|
-
|
|
38
48
|
## When not to use it
|
|
39
49
|
|
|
40
50
|
If your repo uses a single test runner everywhere, this rule has nothing to segregate.
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
> Every source file matched by `include` must have a colocated sibling test.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createTestSiblingEnforcementRule` from `@noctcore/lint-meta-rules` · Category `source-text` · Fails CI by default: yes
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
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 (
|
|
12
|
+
its behavior. Requiring a colocated test for a chosen class of files (`.utils.ts` helper files, say)
|
|
9
13
|
makes "did you test this?" a mechanical check rather than a review-time hope.
|
|
10
14
|
|
|
11
15
|
## What it flags
|
|
@@ -14,7 +18,13 @@ For each file matched by an `include` glob, the sibling test is the same path wi
|
|
|
14
18
|
extension replaced by each configured test extension. If none of those siblings exists, the source
|
|
15
19
|
file is flagged. Strict — there is no baseline; the pattern is opt-in via which files `include` selects.
|
|
16
20
|
|
|
17
|
-
##
|
|
21
|
+
## What it does not flag
|
|
22
|
+
|
|
23
|
+
- A matched file with a sibling for any one of `testExtensions` (`foo.utils.test.ts` or
|
|
24
|
+
`foo.utils.test.tsx` for `foo.utils.ts`).
|
|
25
|
+
- Files outside `include` (only `apps/web/src/**/*.utils.ts` by default).
|
|
26
|
+
|
|
27
|
+
## Options
|
|
18
28
|
|
|
19
29
|
```ts
|
|
20
30
|
createTestSiblingEnforcementRule(options?: TestSiblingEnforcementOptions): IMetaRule
|
|
@@ -26,9 +36,6 @@ createTestSiblingEnforcementRule(options?: TestSiblingEnforcementOptions): IMeta
|
|
|
26
36
|
| `testExtensions` | `string[]` | `['.test.ts', '.test.tsx']` | Accepted colocated-test extensions (replace the source extension). |
|
|
27
37
|
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
28
38
|
|
|
29
|
-
De-projected from nightcore, which hardcoded `apps/web/src/**/*.utils.ts` and the `.utils.test.ts(x)`
|
|
30
|
-
sibling shape.
|
|
31
|
-
|
|
32
39
|
## When not to use it
|
|
33
40
|
|
|
34
41
|
If you colocate tests loosely or centralize them in a `__tests__` tree, retune `include`/`testExtensions`
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> Every tested package must be enumerated in the aggregate test script.
|
|
4
4
|
|
|
5
|
+
<!-- begin generated rule header -->
|
|
6
|
+
Runs under `@noctcore/harness`, not ESLint · Factory `createTestWorkspaceEnrollmentRule` from `@noctcore/lint-meta-rules` · Category `testing` · Fails CI by default: yes
|
|
7
|
+
<!-- end generated rule header -->
|
|
8
|
+
|
|
5
9
|
## Why
|
|
6
10
|
|
|
7
11
|
When the CI test command is a hardcoded list of workspace paths, a package added without editing that
|
|
@@ -13,10 +17,17 @@ cannot silently escape CI.
|
|
|
13
17
|
|
|
14
18
|
Reads the root manifest's `scriptName` command. For each candidate directory (from `packageGlobs`
|
|
15
19
|
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.
|
|
17
|
-
|
|
20
|
+
the directory string does not appear in the script command, it is flagged.
|
|
21
|
+
|
|
22
|
+
## What it does not flag
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
- A candidate package with no file matching `testGlobSuffix`.
|
|
25
|
+
- Directories in `excludeDirs` (tested by a different runner or script).
|
|
26
|
+
- Anything when the root manifest is missing or is not valid JSON: the rule is a no-op.
|
|
27
|
+
- A directory whose path appears anywhere in the script text: the check is a substring match, not a
|
|
28
|
+
parse of the command.
|
|
29
|
+
|
|
30
|
+
## Options
|
|
20
31
|
|
|
21
32
|
```ts
|
|
22
33
|
createTestWorkspaceEnrollmentRule(options?: TestWorkspaceEnrollmentOptions): IMetaRule
|
|
@@ -32,9 +43,6 @@ createTestWorkspaceEnrollmentRule(options?: TestWorkspaceEnrollmentOptions): IMe
|
|
|
32
43
|
| `testGlobSuffix` | `string` | `'**/*.test.ts'` | Glob suffix (per dir) that detects the presence of tests. |
|
|
33
44
|
| `ciCritical` | `boolean` | `true` | Whether a violation fails CI. |
|
|
34
45
|
|
|
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
46
|
## When not to use it
|
|
39
47
|
|
|
40
48
|
If your test runner discovers packages automatically (no hardcoded path list), this rule is
|