@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
package/README.md
CHANGED
|
@@ -1,32 +1,56 @@
|
|
|
1
1
|
# @noctcore/lint-meta-rules
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Whole-repo checks that ESLint's one-file-at-a-time model cannot make: every workspace is named by
|
|
4
|
+
convention, every imported workspace package is a declared dependency, every GitHub Action is pinned
|
|
5
|
+
to a commit SHA, no ESLint rule resolves to `warn`, no source file grows past a line cap, and more.
|
|
6
|
+
This is not an ESLint plugin; the rules run under the
|
|
7
|
+
[`@noctcore/harness`](https://www.npmjs.com/package/@noctcore/harness) `lint-meta` runner.
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
contract published by `@noctcore/harness`: a pure function of an `IMetaCtx` returning `IViolation[]`.
|
|
9
|
+
**Docs:** [noctcore.github.io/eslint-plugins/packages/lint-meta-rules](https://noctcore.github.io/eslint-plugins/packages/lint-meta-rules/)
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Not a good fit if you are not running the harness.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
registry. The `@noctcore/harness` `lint-meta` subcommand runs a *bounded eval* that executes only one
|
|
14
|
-
local `.nightcore/lint-meta/registry.js` and never resolves arbitrary imports — a deliberate security
|
|
15
|
-
boundary, since that file runs inside a foreign CI. So the intended integration point is nightcore's
|
|
16
|
-
harness **export pipeline**: it reads a rule's source here, inlines/transforms it, and emits flat
|
|
17
|
-
JavaScript directly into a consumer's `.nightcore/lint-meta/`. No consumer registry imports this
|
|
18
|
-
package at runtime.
|
|
13
|
+
## What it checks
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
- **config**: workspace package names and build fields, the workspace dependency graph against
|
|
16
|
+
imports and tsconfig references, ESLint severities.
|
|
17
|
+
- **source-text**: layering between packages, a file-size ratchet, agent-doc presence, colocated
|
|
18
|
+
tests, one home per helper, no cloned component folders, UI primitive shape.
|
|
19
|
+
- **testing**: every tested package is enrolled in the aggregate test script, and test runners are
|
|
20
|
+
not mixed within a package.
|
|
21
|
+
- **ci**: GitHub Actions pinned by SHA and to named runners, no template injection, least-privilege
|
|
22
|
+
permissions, container images pinned by digest, and the secret scanner pinned to one version.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
Every check is listed in [Rules](#rules), with a page per rule.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
bun add -D @noctcore/harness @noctcore/lint-meta-rules # or npm i -D / pnpm add -D
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The `/i18n`, `/resolved-config` and `/prisma` entry points also need the optional peer `eslint`, and
|
|
33
|
+
`/i18n` needs `@typescript-eslint/parser` (see [Rules](#rules)).
|
|
34
|
+
|
|
35
|
+
## Run with the harness
|
|
36
|
+
|
|
37
|
+
Each rule implements the [`IMetaRule`](https://www.npmjs.com/package/@noctcore/harness) contract
|
|
38
|
+
from `@noctcore/harness`: a pure function of an `IMetaCtx` returning `IViolation[]`. The harness
|
|
39
|
+
`lint-meta` subcommand runs them.
|
|
40
|
+
|
|
41
|
+
You do not `require()` this package from your repo. The harness executes only one local file,
|
|
42
|
+
`.nightcore/lint-meta/registry.js`, and never resolves arbitrary imports, because that file runs
|
|
43
|
+
inside CI. Its **export pipeline** reads a rule's source from this package, inlines it and writes
|
|
44
|
+
plain JavaScript into your repo's `.nightcore/lint-meta/`. The package is on npm for versioning and
|
|
45
|
+
discoverability.
|
|
46
|
+
|
|
47
|
+
### Every rule is a factory
|
|
48
|
+
|
|
49
|
+
`IMetaRule.run(ctx)` takes no config, so each rule is exported as a **factory**,
|
|
50
|
+
`createXRule(options): IMetaRule`. Anything project-specific (a workspace scope, a source root, a
|
|
51
|
+
rank table) is a typed option with a default. Some defaults, such as the `@nightcore` scope and the
|
|
52
|
+
400-line cap, are only starting points: set your own. A programmatic caller (or the export pipeline)
|
|
53
|
+
constructs each rule with your options:
|
|
30
54
|
|
|
31
55
|
```ts
|
|
32
56
|
import { createPackageShapeRule, createFileSizeRatchetRule } from '@noctcore/lint-meta-rules';
|
|
@@ -42,33 +66,43 @@ over the whole catalog.
|
|
|
42
66
|
|
|
43
67
|
## Rules
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
| Factory |
|
|
52
|
-
| --- | --- | --- | --- |
|
|
53
|
-
| [`
|
|
54
|
-
| [`
|
|
55
|
-
| [`
|
|
56
|
-
| [`
|
|
57
|
-
| [`
|
|
58
|
-
| [`
|
|
59
|
-
| [`
|
|
60
|
-
| [`
|
|
61
|
-
| [`
|
|
62
|
-
| [`
|
|
63
|
-
| [`
|
|
64
|
-
| [`
|
|
65
|
-
| [`
|
|
66
|
-
| [`
|
|
67
|
-
| [`
|
|
68
|
-
| [`
|
|
69
|
-
| [`
|
|
70
|
-
| [`
|
|
71
|
-
| [`
|
|
69
|
+
The main entry exports 19 factories. `createFileSizeRatchetRule` covers any number of capped areas:
|
|
70
|
+
create one instance per area, each with its own `id`.
|
|
71
|
+
|
|
72
|
+
<!-- begin generated rules -->
|
|
73
|
+
<!-- Generated by `bun run docs:readmes` from each rule's meta. Do not edit by hand. -->
|
|
74
|
+
|
|
75
|
+
| Rule | Description | Factory | Import from | Category | Fails CI by default |
|
|
76
|
+
| --- | --- | --- | --- | --- | --- |
|
|
77
|
+
| [`agents-doc-presence`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/agents-doc-presence/) | AGENTS.md must exist at the repo root, every surface, and every non-opted-out package. | `createAgentsDocPresenceRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
78
|
+
| [`canonical-helpers-single-home`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/canonical-helpers-single-home/) | Pure helpers must live in one canonical home (flag the same exported symbol appearing in multiple homes). | `createCanonicalHelpersSingleHomeRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
79
|
+
| [`dockerfile-base-image-digest-pin`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/dockerfile-base-image-digest-pin/) | Dockerfile FROM base images must be pinned by `@sha256:` digest (scratch and earlier build stages exempt). | `createDockerfileBaseImageDigestPinRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
80
|
+
| [`file-size-ratchet`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/file-size-ratchet/) | Source files stay at or under 400 raw lines. Today's offenders are grandfathered by .nightcore/lint-meta/baselines/file-size-ratchet.json; a new/grown offender fails, and a stale/shrunk baseline entry demands tightening. | `createFileSizeRatchetRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
81
|
+
| [`github-actions-least-privilege-permissions`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/github-actions-least-privilege-permissions/) | GitHub Actions workflows declare a read-only top-level `permissions:` (no `write-all`/`read-all`, no `<scope>: write`); writes go on the job that needs them. | `createGithubActionsLeastPrivilegePermissionsRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
82
|
+
| [`github-actions-no-template-injection`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/github-actions-no-template-injection/) | GitHub Actions `run:` and github-script bodies never expand attacker-controllable `${{ }}` context (issue/PR titles, comments, branch names); pass it through `env:` instead. | `createGithubActionsNoTemplateInjectionRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
83
|
+
| [`github-actions-runner-pinned`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/github-actions-runner-pinned/) | GitHub Actions jobs must run on a pinned runner image (for example ubuntu-24.04), never a *-latest label. | `createGithubActionsRunnerPinnedRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
84
|
+
| [`github-actions-sha-pinned`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/github-actions-sha-pinned/) | GitHub Actions `uses:` refs must be pinned to a 40-character commit SHA with a `# vN` comment (local ./ actions exempt). | `createGithubActionsShaPinnedRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
85
|
+
| [`layer-rank`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/layer-rank/) | Fixed dependency direction by rank: a module imports only strictly-lower-ranked <scope> packages (equal/upward forbidden). | `createLayerRankRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
86
|
+
| [`no-cloned-component-folders`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/no-cloned-component-folders/) | A component folder name may exist under only ONE feature. Shared surfaces are hoisted; divergent ones get a divergent name. Today’s clones are frozen in a shrinking allowlist. | `createNoClonedComponentFoldersRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
87
|
+
| [`no-warn-severity`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/no-warn-severity/) | ESLint severity is 'error' or 'off', never 'warn'. A rule that matters is an error; a failure is fixed, not silenced. | `createNoWarnSeverityRule` | `@noctcore/lint-meta-rules` | `config` | yes |
|
|
88
|
+
| [`package-shape`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/package-shape/) | Every workspace is named <scope>/<dir>; library packages expose a barrel and point main/module/types/exports at the built output. | `createPackageShapeRule` | `@noctcore/lint-meta-rules` | `config` | yes |
|
|
89
|
+
| [`security-scanner-version-parity`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/security-scanner-version-parity/) | The gitleaks version pinned in the workflows must equal the one in scripts/ci/pre-push.sh, and the hook must compare a native gitleaks against it at run time. | `createSecurityScannerVersionParityRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
90
|
+
| [`service-image-digest-pin`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/service-image-digest-pin/) | Workflow service and container images, and docker-compose images, must be pinned by `@sha256:` digest (a service that builds locally is exempt). | `createServiceImageDigestPinRule` | `@noctcore/lint-meta-rules` | `ci` | yes |
|
|
91
|
+
| [`test-runner-segregation`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/test-runner-segregation/) | Bun-side packages use 'bun:test'; foreign-side packages use 'vitest'. Never mix runners. | `createTestRunnerSegregationRule` | `@noctcore/lint-meta-rules` | `testing` | yes |
|
|
92
|
+
| [`test-sibling-enforcement`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/test-sibling-enforcement/) | Every source file matched by `include` must have a colocated sibling test. Pure helpers must ship a test. | `createTestSiblingEnforcementRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
93
|
+
| [`test-workspace-enrollment`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/test-workspace-enrollment/) | Every candidate package with test files must be enumerated in the root 'test:node' script. | `createTestWorkspaceEnrollmentRule` | `@noctcore/lint-meta-rules` | `testing` | yes |
|
|
94
|
+
| [`ui-primitive-shape`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/ui-primitive-shape/) | A folder primitive under the ui root must ship its proof siblings (test, stories); a flat primitive must not carry sibling proof files at the root. | `createUiPrimitiveShapeRule` | `@noctcore/lint-meta-rules` | `source-text` | yes |
|
|
95
|
+
| [`workspace-graph-parity`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/workspace-graph-parity/) | Imported <scope>/* specifiers must be declared workspace:* deps, and tsconfig references must mirror those deps. | `createWorkspaceGraphParityRule` | `@noctcore/lint-meta-rules` | `config` | yes |
|
|
96
|
+
| [`translation-dead-keys`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/translation-dead-keys/) | Every translation catalog key must be reachable from the source: named by a translation call, or spelled by some string in the code. | `createTranslationDeadKeysRule` | `@noctcore/lint-meta-rules/i18n` | `source-text` | yes |
|
|
97
|
+
| [`prisma-method-surface`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/prisma-method-surface/) | The Prisma reads and writes the rules police partition the generated client's <Model>Delegate method surface exactly, so a Prisma upgrade cannot add an unguarded method. | `createPrismaMethodSurfaceRule` | `@noctcore/lint-meta-rules/prisma` | `config` | yes |
|
|
98
|
+
| [`tenant-model-registry-parity`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/tenant-model-registry-parity/) | Every tenant-bearing Prisma model is scoped by the runtime extension or exempt with a reason, and the tenant lint rules resolve with exactly that registry. | `createTenantModelRegistryParityRule` | `@noctcore/lint-meta-rules/prisma` | `config` | yes |
|
|
99
|
+
| [`eslint-config-no-warn`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/eslint-config-no-warn/) | Every rule in the RESOLVED ESLint config is "error" or "off", never "warn", including severities a spread preset injects. | `createEslintConfigNoWarnRule` | `@noctcore/lint-meta-rules/resolved-config` | `config` | yes |
|
|
100
|
+
| [`session-epoch-captured`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/session-epoch-captured/) | Every call into the sign-in seam must pass the session epoch captured before the credential was read, or a revocation landing during the credential check loses the race. | `createSessionEpochCapturedRule` | `@noctcore/lint-meta-rules/session` | `source-text` | yes |
|
|
101
|
+
| [`session-kind-stamped`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/session-kind-stamped/) | Every call that mints a session must stamp the principal kind onto it, or sit in an allowlisted, provably single-kind flow; a session read without the kind falls back to a default and can silently promote one kind of account into another. | `createSessionKindStampedRule` | `@noctcore/lint-meta-rules/session` | `source-text` | yes |
|
|
102
|
+
| [`session-landing-declared`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/session-landing-declared/) | Every file that opens a door into a session must declare where it leaves the caller, and a door whose landing demands a return shape (the one that carries the principal kind to the client) must have it. | `createSessionLandingDeclaredRule` | `@noctcore/lint-meta-rules/session` | `source-text` | yes |
|
|
103
|
+
| [`session-mint-callers`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/session-mint-callers/) | The method that mints a session may only be called from allowlisted files; a new sign-in entry point must route through the gate in front of it so the gate cannot be bypassed. | `createSessionMintCallersRule` | `@noctcore/lint-meta-rules/session` | `source-text` | yes |
|
|
104
|
+
| [`idempotency-key-parity`](https://noctcore.github.io/eslint-plugins/rules/lint-meta-rules/idempotency-key-parity/) | Procedures carrying the idempotency middleware must have a client caller that sends an idempotency key, or no client caller at all. | `createIdempotencyKeyParityRule` | `@noctcore/lint-meta-rules/trpc` | `source-text` | yes |
|
|
105
|
+
<!-- end generated rules -->
|
|
72
106
|
|
|
73
107
|
Every factory is callable with no arguments (all options default), so `createAllRules()` and
|
|
74
108
|
per-factory defaults work out of the box; supply options to retarget a rule at your own repo.
|
|
@@ -81,20 +115,12 @@ The main entry never loads ESLint; this one needs the optional peers `eslint` an
|
|
|
81
115
|
`@typescript-eslint/parser`. Its factories are not part of `RULE_FACTORIES` / `createAllRules()`:
|
|
82
116
|
they are inert until you point them at your catalogs.
|
|
83
117
|
|
|
84
|
-
| Factory | Category | What it enforces |
|
|
85
|
-
| --- | --- | --- |
|
|
86
|
-
| [`createTranslationDeadKeysRule`](./docs/rules/translation-dead-keys.md) | source-text | Every catalog key is reachable: named by a translation call, or spelled by some string in the source. |
|
|
87
|
-
|
|
88
118
|
### `@noctcore/lint-meta-rules/resolved-config`
|
|
89
119
|
|
|
90
120
|
Checks over the RESOLVED ESLint config. They load ESLint and resolve configs through
|
|
91
121
|
`calculateConfigForFile`, which is async, so they implement the harness's `runAsync`
|
|
92
122
|
(`@noctcore/harness` 0.3.0 or newer) and need the optional peer `eslint`.
|
|
93
123
|
|
|
94
|
-
| Factory | Category | What it enforces |
|
|
95
|
-
| --- | --- | --- |
|
|
96
|
-
| [`createEslintConfigNoWarnRule`](./docs/rules/eslint-config-no-warn.md) | config | No rule RESOLVES to `warn`, including a severity a spread preset injects, which the text scan of `no-warn-severity` cannot see. |
|
|
97
|
-
|
|
98
124
|
### `@noctcore/lint-meta-rules/prisma`
|
|
99
125
|
|
|
100
126
|
Whole-repo Prisma guardrails that keep `@noctcore/eslint-plugin-prisma`'s inputs honest. They read
|
|
@@ -102,7 +128,15 @@ the plugin's method sets, schema parser and registry reconciliation, so the lint
|
|
|
102
128
|
checks cannot disagree. The registry parity check resolves an ESLint config (async, `runAsync`) and
|
|
103
129
|
needs the optional peer `eslint`. Neither is in `RULE_FACTORIES`: both need the project's paths.
|
|
104
130
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
131
|
+
### `@noctcore/lint-meta-rules/session`
|
|
132
|
+
|
|
133
|
+
Fences around the one seam that turns an authenticated principal into a session. Each rule is inert
|
|
134
|
+
until you name that seam: the method that mints, the gate in front of it, the files allowed to call it,
|
|
135
|
+
the landings a sign-in can end in. None is in `RULE_FACTORIES`. They load nothing beyond the harness
|
|
136
|
+
contract.
|
|
137
|
+
|
|
138
|
+
### `@noctcore/lint-meta-rules/trpc`
|
|
139
|
+
|
|
140
|
+
Cross-tree checks between tRPC routers and the clients that call them. The decorator shape defaults to
|
|
141
|
+
`nestjs-trpc`'s; the rule is inert until you name the middleware and the two trees. Not in
|
|
142
|
+
`RULE_FACTORIES`.
|
|
@@ -47,6 +47,13 @@ function unquote(value) {
|
|
|
47
47
|
function anywhereGlobs(baseNames) {
|
|
48
48
|
return baseNames.flatMap((name) => [`**/${name}`, `**/.*/**/${name}`]);
|
|
49
49
|
}
|
|
50
|
+
function readSourceText(read, rel) {
|
|
51
|
+
try {
|
|
52
|
+
return read(rel);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
50
57
|
|
|
51
58
|
export {
|
|
52
59
|
dirOf,
|
|
@@ -59,5 +66,6 @@ export {
|
|
|
59
66
|
globFiles,
|
|
60
67
|
stripYamlComment,
|
|
61
68
|
unquote,
|
|
62
|
-
anywhereGlobs
|
|
69
|
+
anywhereGlobs,
|
|
70
|
+
readSourceText
|
|
63
71
|
};
|
package/dist/i18n.js
CHANGED
package/dist/index.js
CHANGED
package/dist/prisma.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
globFiles
|
|
3
|
-
} from "./chunk-Z7TXSZR4.js";
|
|
4
1
|
import {
|
|
5
2
|
firstOptionOf,
|
|
6
3
|
resolveRules,
|
|
7
4
|
severityOf
|
|
8
5
|
} from "./chunk-VFCX3QKZ.js";
|
|
6
|
+
import {
|
|
7
|
+
globFiles
|
|
8
|
+
} from "./chunk-OYFQKSJN.js";
|
|
9
9
|
|
|
10
10
|
// src/prisma/prisma-method-surface.ts
|
|
11
11
|
import { PRISMA_READ_METHODS, PRISMA_WRITE_METHODS } from "@noctcore/eslint-plugin-prisma";
|
package/dist/session.cjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/session.ts
|
|
21
|
+
var session_exports = {};
|
|
22
|
+
__export(session_exports, {
|
|
23
|
+
callArguments: () => callArguments,
|
|
24
|
+
createSessionEpochCapturedRule: () => createSessionEpochCapturedRule,
|
|
25
|
+
createSessionKindStampedRule: () => createSessionKindStampedRule,
|
|
26
|
+
createSessionLandingDeclaredRule: () => createSessionLandingDeclaredRule,
|
|
27
|
+
createSessionMintCallersRule: () => createSessionMintCallersRule
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(session_exports);
|
|
30
|
+
|
|
31
|
+
// src/rules/shared.ts
|
|
32
|
+
function escapeRegExp(value) {
|
|
33
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
34
|
+
}
|
|
35
|
+
var DEFAULT_SKIP_DIRS = [
|
|
36
|
+
"node_modules",
|
|
37
|
+
".git",
|
|
38
|
+
"dist",
|
|
39
|
+
".turbo",
|
|
40
|
+
"coverage"
|
|
41
|
+
];
|
|
42
|
+
function globFiles(glob, globs, skipDirs = []) {
|
|
43
|
+
const skip = new Set(skipDirs);
|
|
44
|
+
const found = /* @__PURE__ */ new Set();
|
|
45
|
+
for (const pattern of globs) {
|
|
46
|
+
for (const rel of glob(pattern)) {
|
|
47
|
+
if (!rel.split("/").some((segment) => skip.has(segment))) found.add(rel);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [...found].sort();
|
|
51
|
+
}
|
|
52
|
+
function readSourceText(read, rel) {
|
|
53
|
+
try {
|
|
54
|
+
return read(rel);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/session/scan.ts
|
|
61
|
+
var DEFAULT_EXCLUDE_SUFFIXES = [
|
|
62
|
+
".spec.ts",
|
|
63
|
+
".spec.tsx",
|
|
64
|
+
".test.ts",
|
|
65
|
+
".test.tsx"
|
|
66
|
+
];
|
|
67
|
+
function scopedSources(ctx, options) {
|
|
68
|
+
const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
|
|
69
|
+
const excludeSuffixes = options.excludeSuffixes ?? DEFAULT_EXCLUDE_SUFFIXES;
|
|
70
|
+
const matched = globFiles(ctx.glob, options.sourceGlobs ?? [], skipDirs).map(toPosix);
|
|
71
|
+
const sources = [];
|
|
72
|
+
for (const file of matched) {
|
|
73
|
+
if (excludeSuffixes.some((suffix) => file.endsWith(suffix))) continue;
|
|
74
|
+
const text = readSourceText(ctx.read, file);
|
|
75
|
+
if (text !== null) sources.push({ file, text });
|
|
76
|
+
}
|
|
77
|
+
return { matched: new Set(matched), sources };
|
|
78
|
+
}
|
|
79
|
+
function toPosix(file) {
|
|
80
|
+
return file.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
81
|
+
}
|
|
82
|
+
function pathSet(files) {
|
|
83
|
+
return new Set((files ?? []).map(toPosix));
|
|
84
|
+
}
|
|
85
|
+
function callsMethod(source, method) {
|
|
86
|
+
return new RegExp(`\\.${escapeRegExp(method)}\\s*\\(`, "u").test(source);
|
|
87
|
+
}
|
|
88
|
+
function callArguments(source, method) {
|
|
89
|
+
const needle = `.${method}`;
|
|
90
|
+
const calls = [];
|
|
91
|
+
let from = 0;
|
|
92
|
+
for (; ; ) {
|
|
93
|
+
const hit = source.indexOf(needle, from);
|
|
94
|
+
if (hit === -1) break;
|
|
95
|
+
from = hit + needle.length;
|
|
96
|
+
const open = source.indexOf("(", from);
|
|
97
|
+
if (open === -1) break;
|
|
98
|
+
if (source.slice(from, open).trim() !== "") continue;
|
|
99
|
+
let depth = 0;
|
|
100
|
+
let end = open;
|
|
101
|
+
for (; end < source.length; end += 1) {
|
|
102
|
+
const char = source[end];
|
|
103
|
+
if (char === "(") depth += 1;
|
|
104
|
+
else if (char === ")") {
|
|
105
|
+
depth -= 1;
|
|
106
|
+
if (depth === 0) break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
calls.push(source.slice(open + 1, end));
|
|
110
|
+
from = end;
|
|
111
|
+
}
|
|
112
|
+
return calls;
|
|
113
|
+
}
|
|
114
|
+
function mentionsIdentifier(text, identifier) {
|
|
115
|
+
return new RegExp(`\\b${escapeRegExp(identifier)}\\b`, "u").test(text);
|
|
116
|
+
}
|
|
117
|
+
function withHint(message, hint) {
|
|
118
|
+
return hint === void 0 || hint === "" ? message : `${message} ${hint}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/session/session-epoch-captured.ts
|
|
122
|
+
var DEFAULT_ID = "session-epoch-captured";
|
|
123
|
+
var DEFAULT_FIELD = "epoch";
|
|
124
|
+
function createSessionEpochCapturedRule(options = {}) {
|
|
125
|
+
const id = options.id ?? DEFAULT_ID;
|
|
126
|
+
const call = options.call ?? "";
|
|
127
|
+
const field = options.field ?? DEFAULT_FIELD;
|
|
128
|
+
const exempt = pathSet(options.exempt);
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
category: "source-text",
|
|
132
|
+
ciCritical: options.ciCritical ?? true,
|
|
133
|
+
description: "Every call into the sign-in seam must pass the session epoch captured before the credential was read, or a revocation landing during the credential check loses the race.",
|
|
134
|
+
run(ctx) {
|
|
135
|
+
if (call === "") return [];
|
|
136
|
+
const violations = [];
|
|
137
|
+
for (const { file, text } of scopedSources(ctx, options).sources) {
|
|
138
|
+
if (exempt.has(file)) continue;
|
|
139
|
+
for (const args of callArguments(text, call)) {
|
|
140
|
+
if (mentionsIdentifier(args, field)) continue;
|
|
141
|
+
violations.push({
|
|
142
|
+
file,
|
|
143
|
+
rule: id,
|
|
144
|
+
message: withHint(
|
|
145
|
+
`${call}() must be passed \`${field}\`, captured${options.captureCall === void 0 ? "" : ` with ${options.captureCall}`} BEFORE this flow reads the credential it authenticates on. Without it the epoch fence starts at the session write, so a revocation landing during the credential check loses the race.`,
|
|
146
|
+
options.hint
|
|
147
|
+
)
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return violations;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/session/session-kind-stamped.ts
|
|
157
|
+
var DEFAULT_ID2 = "session-kind-stamped";
|
|
158
|
+
var DEFAULT_FIELD2 = "kind";
|
|
159
|
+
function createSessionKindStampedRule(options = {}) {
|
|
160
|
+
const id = options.id ?? DEFAULT_ID2;
|
|
161
|
+
const mintCall = options.mintCall ?? "";
|
|
162
|
+
const field = options.field ?? DEFAULT_FIELD2;
|
|
163
|
+
const allowUnstamped = pathSet(options.allowUnstamped);
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
category: "source-text",
|
|
167
|
+
ciCritical: options.ciCritical ?? true,
|
|
168
|
+
description: "Every call that mints a session must stamp the principal kind onto it, or sit in an allowlisted, provably single-kind flow; a session read without the kind falls back to a default and can silently promote one kind of account into another.",
|
|
169
|
+
run(ctx) {
|
|
170
|
+
if (mintCall === "") return [];
|
|
171
|
+
const violations = [];
|
|
172
|
+
for (const { file, text } of scopedSources(ctx, options).sources) {
|
|
173
|
+
if (allowUnstamped.has(file)) continue;
|
|
174
|
+
const calls = callArguments(text, mintCall);
|
|
175
|
+
if (calls.length === 0) continue;
|
|
176
|
+
const fileStamps = mentionsIdentifier(text, field);
|
|
177
|
+
for (const args of calls) {
|
|
178
|
+
const stamped = args.includes("{") ? mentionsIdentifier(args, field) : fileStamps;
|
|
179
|
+
if (stamped) continue;
|
|
180
|
+
violations.push({
|
|
181
|
+
file,
|
|
182
|
+
rule: id,
|
|
183
|
+
message: withHint(
|
|
184
|
+
`${mintCall}() must stamp \`${field}\` onto the session it mints${options.stampExample === void 0 ? "" : `: add \`${options.stampExample}\` to the options`}. A session read without \`${field}\` falls back to the reader's default, so an unstamped mint can silently promote one kind of account into another. If this flow provably can never mint for an account that needs the field, add the file to \`allowUnstamped\` with the proof.`,
|
|
185
|
+
options.hint
|
|
186
|
+
)
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return violations;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/session/session-landing-declared.ts
|
|
196
|
+
var DEFAULT_ID3 = "session-landing-declared";
|
|
197
|
+
function createSessionLandingDeclaredRule(options = {}) {
|
|
198
|
+
const id = options.id ?? DEFAULT_ID3;
|
|
199
|
+
const doorCalls = (options.doorCalls ?? []).filter((name) => name !== "");
|
|
200
|
+
const doors = (options.doors ?? []).map((door) => ({ ...door, file: toPosix(door.file) }));
|
|
201
|
+
const landings = options.landings ?? {};
|
|
202
|
+
const landingNames = Object.keys(landings);
|
|
203
|
+
const callList = doorCalls.join(" or ");
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
category: "source-text",
|
|
207
|
+
ciCritical: options.ciCritical ?? true,
|
|
208
|
+
description: "Every file that opens a door into a session must declare where it leaves the caller, and a door whose landing demands a return shape (the one that carries the principal kind to the client) must have it.",
|
|
209
|
+
run(ctx) {
|
|
210
|
+
if (doorCalls.length === 0) return [];
|
|
211
|
+
const violations = [];
|
|
212
|
+
const report = (file, message) => {
|
|
213
|
+
violations.push({ file, rule: id, message: withHint(message, options.hint) });
|
|
214
|
+
};
|
|
215
|
+
for (const door of doors) {
|
|
216
|
+
if (!Object.hasOwn(landings, door.landing)) {
|
|
217
|
+
report(
|
|
218
|
+
door.file,
|
|
219
|
+
`\`doors\` declares this file with landing \`${door.landing}\`, which is not one of the configured landings (${landingNames.join(", ") || "none"}).`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (door.because.trim() === "") {
|
|
223
|
+
report(door.file, "`doors` declares this file with an empty `because`. Write down why its landing is the right one.");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const { matched, sources } = scopedSources(ctx, options);
|
|
227
|
+
for (const { file, text } of sources) {
|
|
228
|
+
if (!doorCalls.some((name) => callsMethod(text, name))) continue;
|
|
229
|
+
const declared = doors.find((door) => door.file === file);
|
|
230
|
+
if (declared === void 0) {
|
|
231
|
+
report(
|
|
232
|
+
file,
|
|
233
|
+
`This file opens a door into a session (${callList}) and does not declare where it leaves the caller. Add it to \`doors\` with one of the landings (${landingNames.join(", ") || "none"}) and a \`because\`: can this door end a sign-in for an account the client must route by kind? Then declare the landing that returns the kind. Does the caller already hold a session? Or can such an account provably never reach it? Say so in \`because\`.`
|
|
234
|
+
);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const required = landings[declared.landing];
|
|
238
|
+
if (typeof required === "string" && required !== "" && !text.includes(required)) {
|
|
239
|
+
report(
|
|
240
|
+
file,
|
|
241
|
+
`This door is declared \`${declared.landing}\` but its source never contains \`${required}\`, so the sign-in it completes hands the client nothing to route the account by. Return it, or reclassify the door in \`doors\` with the proof that it cannot end such a sign-in.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const door of doors) {
|
|
246
|
+
if (!matched.has(door.file)) {
|
|
247
|
+
report(
|
|
248
|
+
door.file,
|
|
249
|
+
"`doors` names a file that no longer exists, or that `sourceGlobs` do not reach. Remove the entry, or point it at the file the door moved to."
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return violations;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/session/session-mint-callers.ts
|
|
259
|
+
var DEFAULT_ID4 = "session-mint-callers";
|
|
260
|
+
function createSessionMintCallersRule(options = {}) {
|
|
261
|
+
const id = options.id ?? DEFAULT_ID4;
|
|
262
|
+
const mintCall = options.mintCall ?? "";
|
|
263
|
+
const allowed = pathSet(options.allowedCallers);
|
|
264
|
+
const gate = options.gateCall;
|
|
265
|
+
return {
|
|
266
|
+
id,
|
|
267
|
+
category: "source-text",
|
|
268
|
+
ciCritical: options.ciCritical ?? true,
|
|
269
|
+
description: "The method that mints a session may only be called from allowlisted files; a new sign-in entry point must route through the gate in front of it so the gate cannot be bypassed.",
|
|
270
|
+
run(ctx) {
|
|
271
|
+
if (mintCall === "") return [];
|
|
272
|
+
const violations = [];
|
|
273
|
+
const allowedList = [...allowed].join(", ") || "none";
|
|
274
|
+
for (const { file, text } of scopedSources(ctx, options).sources) {
|
|
275
|
+
if (allowed.has(file)) continue;
|
|
276
|
+
if (!callsMethod(text, mintCall)) continue;
|
|
277
|
+
violations.push({
|
|
278
|
+
file,
|
|
279
|
+
rule: id,
|
|
280
|
+
message: withHint(
|
|
281
|
+
`${mintCall}() may only be called from the allowlisted files (${allowedList}).${gate === void 0 ? "" : ` A sign-in or OAuth entry point must call ${gate}() instead, so the checks it runs before minting are not bypassed.`} If this is a genuine mint that passes no gate (a signup, or a re-issue to a caller who already holds a session), add the file to \`allowedCallers\` with a justification.`,
|
|
282
|
+
options.hint
|
|
283
|
+
)
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return violations;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
291
|
+
0 && (module.exports = {
|
|
292
|
+
callArguments,
|
|
293
|
+
createSessionEpochCapturedRule,
|
|
294
|
+
createSessionKindStampedRule,
|
|
295
|
+
createSessionLandingDeclaredRule,
|
|
296
|
+
createSessionMintCallersRule
|
|
297
|
+
});
|