@starci/eslint-canon-be 1.2.2 → 1.7.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/CHANGELOG.md +43 -0
- package/README.md +82 -7
- package/async-discipline.mjs +83 -0
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -4
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/package.json +1 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.7.0 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
- New: `named-entity-manager-only` (R83 `BE_UNNAMED_DATA_ACCESS`, in `data-access`): a bare `@InjectEntityManager()` with no connection argument, any `.getRepository(...)` call (on a DataSource, an EntityManager or a transaction manager) and an injected `DataSource` (a constructor parameter typed `DataSource`, `@InjectDataSource()`, `@InjectConnection()`) are refused. A `DataSource` is allowed only under `platform/database/` or `platform/databases/` (also `platform/db`, `platform/datasource`, `platform/typeorm`) and `apps/migrate/`; spec files are skipped. The message says to inject the shared EntityManager with the named injector and call it directly.
|
|
6
|
+
- Changed: `no-injected-repository` now refuses `@InjectRepository(...)` wherever it is written (a property or a method parameter, not only a constructor parameter), and its message states the same one pattern.
|
|
7
|
+
|
|
8
|
+
## 1.6.1 - 2026-09-30
|
|
9
|
+
|
|
10
|
+
- Fixed: `inbox-dedupe-required` (R80 `BE_INBOX_DEDUPE_MISSING`) no longer relies only on `<event>.consumer.ts` under `transport/message/` to recognize a consumer. A method decorated `@EventPattern`, `@MessagePattern`, `@OnEvent` or `@Process`, or a class whose name ends `Consumer` or `OutboxConsumer`, is now a consumer wherever the file sits. The producer half of an outbox (typically `*-outbox.service.ts` / `*OutboxService`, which reads pending rows and publishes) carries none of those shapes and stays unflagged unless it also consumes.
|
|
11
|
+
|
|
12
|
+
## 1.6.0 - 2026-09-30
|
|
13
|
+
|
|
14
|
+
Round 3 back-end rules (catalog R78 to R82), each with RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`.
|
|
15
|
+
|
|
16
|
+
- New: `user-copy-through-catalog` (R78 `BE_USER_COPY_LITERAL`, new law `user-copy`): a literal exception message, a `subject`/`title`/`body`/`text`/`message` property of a `notify`/`send`/`publish` call, or a `message`/`description`/`title` property of a `transport/` response comes from the per-capability messages catalog through the typed `MessageCatalog` port; a short code-shaped literal (`UPPER_SNAKE`, a bare word) and a template literal are left alone. `no-ambient-clock` (R79 `BE_AMBIENT_CLOCK`, new law `temporal`): `Date.now()`, a bare `new Date()` and `performance.now()` are read only inside `platform/clock`; `new Date(value)` built from a value the caller already holds is exempt. `inbox-dedupe-required` (R80 `BE_INBOX_DEDUPE_MISSING`, new law `idempotency`): a `@Public()` webhook controller and an outbox/queue consumer (`<event>.consumer.ts`) show a reference to an inbox or dedupe port before they act. `no-hand-rolled-retry` (R81 `BE_HAND_ROLLED_RETRY`, in `resilience`): a `for`, `while` or `do...while` loop that both catches an error and calls something shaped like a delay (`sleep`, `delay`, `wait`, `backoff`, `setTimeout`) goes through `platform/retry`; a polling loop with no `catch` is exempt, same as `no-query-in-loop` exempts a polling `while`. `no-external-call-in-transaction` (R82 `BE_TRANSACTION_EXTERNAL_CALL`, in `data-access`): `fetch`, an HTTP client call or any method of a `*Client`/`*Sdk` receiver does not run inside `.transaction(async (tx) => ...)`.
|
|
17
|
+
- Slot manifest (`knowledge/hfs/slots.yaml`, no version bump, additions stamped `since: 1.0.0`): `be.domain.messages` (`src/modules/{domain,integrations}/<capability>/messages/`) and `be.feature.messages` (`src/features/<feature>/messages/`), each an optional per-capability or per-feature vi/en catalog; `be.platform`'s required instances gain `clock` (the injected `Clock` port) and `i18n` (the `MessageCatalog` port); `be.domain` and `be.feature` allow a `messages/` folder.
|
|
18
|
+
- Deferred (owner ruling, no rule): floating promises; core `no-await-in-loop` is not adopted.
|
|
19
|
+
|
|
20
|
+
## 1.5.0 - 2026-09-30
|
|
21
|
+
|
|
22
|
+
- New: `no-query-in-loop` (R77 `BE_QUERY_IN_LOOP`): a repository, entity-manager or query-builder read (`find`, `findOne`, `count`, `getOne`, a raw `SELECT`) inside a `for`, `for...of`, `for...in` or an array `map`, `forEach`, `flatMap`, `filter`, `reduce`, `some` or `every` callback is one round trip per element (N+1). A polling `while` loop and a write per element are not reported.
|
|
23
|
+
|
|
24
|
+
## 1.4.0 - 2026-09-30
|
|
25
|
+
|
|
26
|
+
Round 2 back-end rules (catalog R42, R68 to R76). Every rule below has RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`; each was measured against nivo-backend, starci-next and mia-mia-backend before it shipped and is on at `error`.
|
|
27
|
+
|
|
28
|
+
- New: `no-interpolated-sql` (R68: SQL text carries no runtime substitution; a constant column list, a `${n}` placeholder, `quoteIdent(...)`, `sqlSetClause(...)` and a ternary of fixed strings are the only substitutions), `query-needs-limit` (R69: `getMany`, `getRawMany`, `find`, `findBy` and a raw `SELECT` state a bound; a lookup by `id` and an aggregate are exempt), `http-needs-timeout` (R70: `fetch` carries a `signal`, axios and HttpService calls carry `timeout` or `signal`, `axios.create` a `timeout`), `no-secret-in-log` (R71: a logger call names no credential and no personal identifier), `no-never-cast` and `no-non-null-assertion` (R72), `async-needs-await` (R73: an `async` function that never awaits; a class that extends or implements, a decorated method and a generator are exempt), `migration-down-reversible` (R74: `down()` exists, is not empty and is not a bare throw), `explicit-handler-return-type` (R75), `json-parse-needs-guard` (R76), and `dto-needs-validator` (R42: every property of an input class carries a `class-validator` decorator).
|
|
29
|
+
- New laws: `query-safety`, `resilience`, `log-safety`, `async-discipline`, `input-bounds`; `type-safety` and `schema-authority` gain rules.
|
|
30
|
+
|
|
31
|
+
## 1.3.0 - 2026-09-29
|
|
32
|
+
|
|
33
|
+
HFS back-end rules (catalog R18, R20, R34, R36-R38, R40-R45, R48). Every rule below has RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`.
|
|
34
|
+
|
|
35
|
+
- New: `catch-must-account` (R40), `error-home` (R38), `no-runtime-schema` (R34), `sql-only-in-repository` (R36), `no-entity-in-contract` (R37), `no-untyped-body` and `public-needs-reason` (R41), `no-direct-env-read` (R43), `no-secret-default` (R44), `secret-compare-timing-safe` (R41), `global-module-allowlist`, `typed-module-definition`, `static-module-register`, `no-new-injectable`, `no-module-let`, `one-module-per-file` (R45), `no-inline-suppression` (R18), `file-size-growth` (R20 ratchet; the soft budget is a hfs check report item, not a lint warning), `spec-no-source-read` and `spec-typed-doubles` (R48).
|
|
36
|
+
- Fixed: `must-deep-module-import` now holds the HFS owner surface. An aliased import names the owner and stops (`@modules/domain/plan`, `@features/plan`, or the explicit `/index`); a path into a file of another owner is refused, and a tier-only or root alias still is. The retired `lib` tier no longer counts as a tier. `no-folder-reexport` accepts a named `index.ts` surface (`export { X } from "./x"`, `export type { T } from "./contracts"`), and refuses `export *`, a `*_STORE` token, a whole `types` folder and an entry wider than the manifest's `indexExports` (60). Both are on again in `starciBeConfig`.
|
|
37
|
+
- Removed: no rule is off. The retired-rule list (`RETIRED`) and its export are gone, and so are the rules it switched off: `exception-name-ends-in-exception`, `exception-code-matches-class-name`, `exception-metadata-type-named-for-class`, `exception-extends-abstract`, `exception-in-errors-folder`, `require-exception-object-arg`, `throw-abstract-exception` (the Academy `AbstractException` family, replaced by `error-home`), `no-handler-encoded-failure` (expected refusals are typed unions), `handler-has-twin-spec`, `no-self-global-module` (replaced by `global-module-allowlist`), `no-line-suppression` and `require-vn-ok-reason` (replaced by `no-inline-suppression`), and the `vn-ok` exemption of `no-non-ascii-source`.
|
|
38
|
+
- `starciBeConfig` refuses a recommendation that carries an `off`.
|
|
39
|
+
- Slot parameters (the `@Global()` allowlist, the file line budget, the `index.ts` width) are read through `lib/slots.mjs` from `knowledge/hfs/slots.yaml` (`ruleParams.be.globalModules`, `ruleParams.be.fileLines.{soft,hardGrowth}`, and the `indexExports` budget of a `be.*` slot), with the rule catalog's values as the fallback.
|
|
40
|
+
|
|
41
|
+
## 1.2.3 — 2026-09-29
|
|
42
|
+
|
|
43
|
+
- `starciBeConfig({ sources, plugin, recommended })`, `linterOptions` and `RETIRED` are exported. The factory returns the one flat-config block a repository needs: the canon owns the retired-rule list (checked against the code-pattern manifest) and the warn-to-error lift, so a repository no longer hand-copies `replacedByChecks`.
|
|
44
|
+
- `harness-calls-provider-directly` applies only to model harnesses, identified by an LLM provider SDK import, a house model helper or gateway symbol, or a `@harness-kind model` comment - never by the `src/tests/e2e/live/` folder. A live e2e for an identity provider (Keycloak) or any other third party is no longer reported as a model harness with no LLM SDK.
|
|
45
|
+
|
|
3
46
|
## 1.2.2 — 2026-09-29
|
|
4
47
|
|
|
5
48
|
- Align module aliases and explicit public `index.ts` exports with HFS capability boundaries. Keep self-aliases, tier-only aliases, folder re-exports, and export-star public entries invalid.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @starci/eslint-canon-be
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**78 ESLint rules, from 26 laws, that hold a NestJS-shaped back end to one way of being written.**
|
|
4
4
|
|
|
5
5
|
Not a style pack. These rules enforce *architecture*: which layer may import which, whether a
|
|
6
6
|
failure carries its own identity, where a query is allowed to be built, what an end-to-end test is
|
|
@@ -28,7 +28,24 @@ export default [
|
|
|
28
28
|
Which globs the law applies to is your repository's fact. What the law says is not — so there is no
|
|
29
29
|
option to switch a rule off or lower it to a warning.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Prefer the factory, which owns the levels for you:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import starciBe, { recommended, starciBeConfig } from "@starci/eslint-canon-be"
|
|
35
|
+
|
|
36
|
+
export default [
|
|
37
|
+
// …your own ignores, language options and other plugins…
|
|
38
|
+
starciBeConfig({ sources: ["apps/**/*.ts", "src/**/*.ts"], plugin: starciBe, recommended }),
|
|
39
|
+
]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It states `warn` as `error` for a zero-warning gate, applies
|
|
43
|
+
`noInlineConfig` and unused-disable reporting, and refuses a recommendation that carries a rule switched `off`. HFS
|
|
44
|
+
keeps no retired-rule list: a rule the standard no longer holds is deleted from the plugin, and every rule that ships is on.
|
|
45
|
+
The parameters a rule takes from the slot manifest (the `@Global()` allowlist, the file line budget, the width of an
|
|
46
|
+
`index.ts`) come through `lib/slots.mjs`, which reads `knowledge/hfs/slots.yaml` and falls back to the rule catalog's values.
|
|
47
|
+
|
|
48
|
+
Also exported: `rules`, `ruleOwners`, `lawOwners`, `starciBeConfig`, `linterOptions`.
|
|
32
49
|
|
|
33
50
|
## What it actually catches
|
|
34
51
|
|
|
@@ -36,16 +53,31 @@ A sample, not the list:
|
|
|
36
53
|
|
|
37
54
|
| Area | What the rules hold |
|
|
38
55
|
|---|---|
|
|
39
|
-
| **
|
|
56
|
+
| **Errors** | A `catch` rethrows, logs through the logger port or returns an outcome carrying its cause; an error lives in its capability's `errors/`, extends `DomainError`, and no bare `Error` or framework exception escapes (`catch-must-account`, `error-home`) |
|
|
57
|
+
| **Schema authority** | Migrations only: no `synchronize`, no `migrationsRun`, no runtime DDL, no entity glob; raw SQL only in a persistence repository; no ORM entity in a contract (`no-runtime-schema`, `sql-only-in-repository`, `no-entity-in-contract`) |
|
|
58
|
+
| **Config and secrets** | `process.env` only in `platform/config`; no literal default for a secret, password, token, key or URL; secrets compare with `timingSafeEqual` (`no-direct-env-read`, `no-secret-default`, `secret-compare-timing-safe`) |
|
|
59
|
+
| **Default deny** | Typed bodies and arguments, no `GraphQLJSON` tunnel, no `switch (operation)`, and `@Public` carries a reason (`no-untyped-body`, `public-needs-reason`) |
|
|
60
|
+
| **Module shape** | `@Global()` only on the manifest allowlist; typed `ConfigurableModuleBuilder`; static `register`; one module per file; no `new` of a provider; no module-level `let` |
|
|
61
|
+
| **Suppression** | No `eslint-disable`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck` or `vn-ok` comment, anywhere (`no-inline-suppression`) |
|
|
62
|
+
| **Size** | A file over its line budget is not new and does not grow past its size at the parent commit (`file-size-growth`) |
|
|
63
|
+
| **Query safety** | SQL text carries no runtime substitution; a read states `take`, `limit` or `LIMIT` (`no-interpolated-sql`, `query-needs-limit`, `no-query-in-loop`) |
|
|
64
|
+
| **Resilience** | An outbound HTTP call states a timeout or signal; `JSON.parse` of outside text sits in a `try`; a loop that catches and waits goes through `platform/retry` (`http-needs-timeout`, `json-parse-needs-guard`, `no-hand-rolled-retry`) |
|
|
65
|
+
| **Log safety** | A logger call names no credential and no personal identifier (`no-secret-in-log`) |
|
|
66
|
+
| **Async and types** | An `async` function awaits; no `as never`, no `x!`; handlers and public methods declare a return type (`async-needs-await`, `no-never-cast`, `no-non-null-assertion`, `explicit-handler-return-type`) |
|
|
67
|
+
| **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has a `class-validator` decorator (`migration-down-reversible`, `dto-needs-validator`) |
|
|
68
|
+
| **Spec quality** | A spec does not read repository source with `fs`, and doubles are `mock<T>()`, not `as never` (`spec-no-source-read`, `spec-typed-doubles`) |
|
|
40
69
|
| **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
|
|
41
|
-
| **Module layering** |
|
|
42
|
-
| **Data access** | Queries stay where the layer says they may be built |
|
|
70
|
+
| **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/plan`), never through a path into it; an `index.ts` lists a bounded set of named exports, with no `export *`, storage token or types folder; self-aliases and tier-only aliases are refused. |
|
|
71
|
+
| **Data access** | Queries stay where the layer says they may be built; no transaction spans an external call (`no-external-call-in-transaction`); the database is reached through the named shared `EntityManager` only (`named-entity-manager-only`) |
|
|
43
72
|
| **Transport** | The wire shape is declared, not inferred from whatever a handler happened to return |
|
|
44
|
-
| **Observability** | A failure is logged
|
|
45
|
-
| **Testing** | Unit specs sit beside their subjects, including structural specs in HFS `src/tests` categories; model-quality harness specs
|
|
73
|
+
| **Observability** | A failure is logged through the logger port with a typed identity, so a log line can be traced to the law that names it |
|
|
74
|
+
| **Testing** | Unit specs sit beside their subjects, including structural specs in HFS `src/tests` categories; a model-quality harness (one that imports an LLM SDK, reaches a house model helper or declares `@harness-kind model`) calls its provider directly; other live e2e specs, such as an identity provider, are not judged as model harnesses. |
|
|
46
75
|
| **End-to-end flows** | One file, one flow, named steps, and **never sleep** — poll until the state settles, with a deadline |
|
|
47
76
|
| **CDC · event delivery** | Projections and events follow the declared delivery contract |
|
|
48
77
|
| **Comments · naming · type safety** | Comments say why; no double cast through `unknown` |
|
|
78
|
+
| **User copy** | A literal exception message, notification text or response copy comes from the per-capability messages catalog through the typed `MessageCatalog` port (`user-copy-through-catalog`) |
|
|
79
|
+
| **Temporal** | `Date.now()`, a bare `new Date()` and `performance.now()` are read only inside `platform/clock` (`no-ambient-clock`) |
|
|
80
|
+
| **Idempotency** | A `@Public()` webhook handler and an outbox/queue consumer claim the event through the shared inbox before they act (`inbox-dedupe-required`) |
|
|
49
81
|
|
|
50
82
|
Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
|
|
51
83
|
line leads straight to the document that explains why.
|
|
@@ -68,3 +100,46 @@ pointed at.
|
|
|
68
100
|
## Requirements
|
|
69
101
|
|
|
70
102
|
ESLint 9+ (flat config), Node 20.9+.
|
|
103
|
+
|
|
104
|
+
## HFS rules and their why codes
|
|
105
|
+
|
|
106
|
+
Each rule below reports through the code-pattern gate with a catalogued Vietnamese reason (`modules/kernel/failure-codes.yaml`,
|
|
107
|
+
mapped by `scripts/checks/lint-why.mjs`). The catalog id is the rule number in `knowledge/hfs/rules.yaml`.
|
|
108
|
+
|
|
109
|
+
| Rule | Catalog | Why code |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `catch-must-account` | R40 | `BE_LOGGER_REQUIRED` |
|
|
112
|
+
| `error-home` | R38 | `BE_ERROR_HOME` |
|
|
113
|
+
| `no-runtime-schema` | R34 | `BE_SCHEMA_AUTHORITY` |
|
|
114
|
+
| `sql-only-in-repository` | R36 | `BE_SQL_OUTSIDE_REPOSITORY` |
|
|
115
|
+
| `no-entity-in-contract` | R37 | `BE_ENTITY_IN_CONTRACT` |
|
|
116
|
+
| `no-untyped-body`, `public-needs-reason`, `secret-compare-timing-safe` | R41 | `BE_DEFAULT_DENY` |
|
|
117
|
+
| `no-direct-env-read` | R43 | `BE_CONFIG_OWNER` |
|
|
118
|
+
| `no-secret-default` | R44 | `BE_SECRET_DEFAULT` |
|
|
119
|
+
| `global-module-allowlist`, `typed-module-definition`, `static-module-register`, `no-new-injectable`, `no-module-let`, `one-module-per-file` | R45 | `BE_MODULE_SHAPE` |
|
|
120
|
+
| `no-inline-suppression` | R18 | `HFS_INLINE_SUPPRESSION` |
|
|
121
|
+
| `file-size-growth` | R20 | `HFS_SIZE_GROWTH` |
|
|
122
|
+
| `spec-no-source-read`, `spec-typed-doubles` | R48 | `BE_SPEC_QUALITY` |
|
|
123
|
+
| `must-deep-module-import`, `no-folder-reexport` | R30 | `BE_PUBLIC_SURFACE` |
|
|
124
|
+
| `dto-needs-validator` | R42 | `BE_INPUT_BOUNDED` |
|
|
125
|
+
| `no-interpolated-sql` | R68 | `BE_SQL_INTERPOLATED` |
|
|
126
|
+
| `query-needs-limit` | R69 | `BE_QUERY_UNBOUNDED` |
|
|
127
|
+
| `no-query-in-loop` | R77 | `BE_QUERY_IN_LOOP` |
|
|
128
|
+
| `http-needs-timeout` | R70 | `BE_HTTP_TIMEOUT` |
|
|
129
|
+
| `no-secret-in-log` | R71 | `BE_LOG_SECRET` |
|
|
130
|
+
| `no-never-cast`, `no-non-null-assertion` | R72 | `BE_TYPE_ESCAPE` |
|
|
131
|
+
| `async-needs-await` | R73 | `BE_ASYNC_NO_AWAIT` |
|
|
132
|
+
| `migration-down-reversible` | R74 | `BE_MIGRATION_REVERSIBLE` |
|
|
133
|
+
| `explicit-handler-return-type` | R75 | `BE_RETURN_TYPE` |
|
|
134
|
+
| `json-parse-needs-guard` | R76 | `BE_JSON_PARSE_UNGUARDED` |
|
|
135
|
+
| `user-copy-through-catalog` | R78 | `BE_USER_COPY_LITERAL` |
|
|
136
|
+
| `no-ambient-clock` | R79 | `BE_AMBIENT_CLOCK` |
|
|
137
|
+
| `inbox-dedupe-required` | R80 | `BE_INBOX_DEDUPE_MISSING` |
|
|
138
|
+
| `no-hand-rolled-retry` | R81 | `BE_HAND_ROLLED_RETRY` |
|
|
139
|
+
| `no-external-call-in-transaction` | R82 | `BE_TRANSACTION_EXTERNAL_CALL` |
|
|
140
|
+
| `named-entity-manager-only`, `no-injected-repository` | R83 | `BE_UNNAMED_DATA_ACCESS` |
|
|
141
|
+
|
|
142
|
+
There is no soft size-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing files over the soft budget (`ruleParams.be.fileLines.soft`) is a report item of the hfs check or the architecture machine, as migration backlog, and never blocks.
|
|
143
|
+
|
|
144
|
+
The tier direction matrix and the feature-imports-feature ban (R26, R28) are owned by the architecture machine
|
|
145
|
+
(`@starci/hfs`), not by a lint rule: they need the whole import graph.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that keeps `async` honest.
|
|
3
|
+
*
|
|
4
|
+
* `async-needs-await` (R73 `BE_ASYNC_NO_AWAIT`) refuses an `async` function that never awaits. Such a function
|
|
5
|
+
* promises asynchronous work and does none: it turns a thrown error into a rejected promise for no reason,
|
|
6
|
+
* hides that the body is synchronous, and reads as if a suspension point existed. Delete the `async` and return the
|
|
7
|
+
* value (or the promise the body already holds); where a signature must return a `Promise`, return `Promise.resolve(x)`.
|
|
8
|
+
*
|
|
9
|
+
* Three shapes are exempt because the `async` is not the author's choice:
|
|
10
|
+
* - a method of a class that `implements` or `extends` something (the base contract fixes the return type);
|
|
11
|
+
* - a method that carries a decorator (the framework contract fixes it: resolvers, event handlers, lifecycle hooks);
|
|
12
|
+
* - an `async` generator, whose `yield` is its suspension.
|
|
13
|
+
*/
|
|
14
|
+
import { walk } from "./lib/ast.mjs"
|
|
15
|
+
import { isDeclarationFile, isTestLane } from "./lib/path.mjs"
|
|
16
|
+
|
|
17
|
+
/** Whether the function body awaits, ignoring nested functions (each is judged alone). */
|
|
18
|
+
const awaits = (fn) => {
|
|
19
|
+
let found = false
|
|
20
|
+
walk(fn.body, (node) => {
|
|
21
|
+
if (found) return
|
|
22
|
+
if (node.type === "AwaitExpression") found = true
|
|
23
|
+
else if (node.type === "ForOfStatement" && node.await) found = true
|
|
24
|
+
}, { intoFunctions: false })
|
|
25
|
+
return found
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether the function is a method whose `async` a base class, interface or decorator fixes. */
|
|
29
|
+
const asyncIsContractual = (fn) => {
|
|
30
|
+
const method = fn.parent?.type === "MethodDefinition" ? fn.parent : null
|
|
31
|
+
if (!method) return false
|
|
32
|
+
if (method.decorators?.length > 0) return true
|
|
33
|
+
if (method.override) return true
|
|
34
|
+
const klass = method.parent?.parent
|
|
35
|
+
if (klass && (klass.superClass || (klass.implements && klass.implements.length > 0))) return true
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** An `async` function awaits something. */
|
|
40
|
+
export const asyncNeedsAwait = {
|
|
41
|
+
meta: {
|
|
42
|
+
type: "problem",
|
|
43
|
+
docs: { description: "An `async` function contains an `await`, or it is not `async`." },
|
|
44
|
+
schema: [],
|
|
45
|
+
messages: {
|
|
46
|
+
noAwait:
|
|
47
|
+
"This `async` function never awaits. It promises asynchronous work and does none, so a thrown error becomes a rejected promise for no reason and a reader looks for a suspension that is not there. Remove `async` and return the value or the promise the body already holds; where the signature must return a `Promise`, return `Promise.resolve(value)`.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
create(context) {
|
|
51
|
+
const filename = context.filename || context.getFilename()
|
|
52
|
+
if (isTestLane(filename) || isDeclarationFile(filename)) return {}
|
|
53
|
+
const check = (node) => {
|
|
54
|
+
if (!node.async || node.generator || !node.body) return
|
|
55
|
+
if (node.body.type !== "BlockStatement" && node.type === "ArrowFunctionExpression") {
|
|
56
|
+
// an expression body has no statements to await inside; `async () => x` is judged by the expression itself
|
|
57
|
+
let has = false
|
|
58
|
+
walk(node.body, (child) => {
|
|
59
|
+
if (child.type === "AwaitExpression") has = true
|
|
60
|
+
}, { intoFunctions: false })
|
|
61
|
+
if (!has) context.report({ node, messageId: "noAwait" })
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (asyncIsContractual(node)) return
|
|
65
|
+
if (!awaits(node)) context.report({ node, messageId: "noAwait" })
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
FunctionDeclaration: check,
|
|
69
|
+
FunctionExpression: check,
|
|
70
|
+
ArrowFunctionExpression: check,
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The rules this law contributes to the plugin. */
|
|
76
|
+
export const rules = {
|
|
77
|
+
"async-needs-await": asyncNeedsAwait,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt. */
|
|
81
|
+
export const recommended = {
|
|
82
|
+
"starci-be/async-needs-await": "error",
|
|
83
|
+
}
|
package/comments.mjs
CHANGED
|
@@ -8,11 +8,8 @@
|
|
|
8
8
|
* restate the name - which COMMENT-3 forbids. Only declarations with a surface get the check.
|
|
9
9
|
* - `require-enum-member-jsdoc` can check that a doc EXISTS and never that it states a
|
|
10
10
|
* consequence. That half is read by a person, and the rule says so rather than pretending.
|
|
11
|
-
* - `no-non-ascii-source`
|
|
12
|
-
* data
|
|
13
|
-
* - `require-vn-ok-reason` (law 6) holds the half `no-non-ascii-source` cannot: that marker is a
|
|
14
|
-
* bare escape hatch unless it says why the line stays, so a bare `vn-ok` is treated the same as
|
|
15
|
-
* no marker at all.
|
|
11
|
+
* - `no-non-ascii-source` takes no exemption marker: HFS removed `vn-ok`, so text a program depends on
|
|
12
|
+
* lives in a locale or data file, and a fixture lane is exempt for strings only (see the rule).
|
|
16
13
|
* - `no-restated-name-jsdoc` (law 7) holds the decidable slice of law 3 - a doc block whose only
|
|
17
14
|
* content is the declared name re-spelled in words teaches nothing beyond the import line, so it
|
|
18
15
|
* is COMMENT-3's violation wearing COMMENT-1's shape. It fires only on an exact match between the
|
|
@@ -23,9 +20,6 @@
|
|
|
23
20
|
|
|
24
21
|
import { normalizePath } from "./lib/path.mjs"
|
|
25
22
|
|
|
26
|
-
/** The marker that keeps a non-ASCII literal the program depends on. */
|
|
27
|
-
const KEEP_MARKER = /\bvn-ok\b/
|
|
28
|
-
|
|
29
23
|
/**
|
|
30
24
|
* The character classes this refuses, and why it is NOT simply "ASCII only".
|
|
31
25
|
*
|
|
@@ -161,15 +155,15 @@ export const requireEnumMemberJsdoc = {
|
|
|
161
155
|
|
|
162
156
|
// -- COMMENT-4 -------------------------------------------------------------------------------------
|
|
163
157
|
|
|
164
|
-
/** Source is English
|
|
158
|
+
/** Source prose is English; there is no exemption marker. */
|
|
165
159
|
export const noNonAsciiSource = {
|
|
166
160
|
meta: {
|
|
167
161
|
type: "problem",
|
|
168
|
-
docs: { description: "Source stays English
|
|
162
|
+
docs: { description: "Source stays English; no exemption marker." },
|
|
169
163
|
schema: [],
|
|
170
164
|
messages: {
|
|
171
165
|
nonAscii:
|
|
172
|
-
"This line carries {{offence}}. The bar is a reader who does not share the author's first language: a codebase with two languages in it has somebody for whom half the reasoning is unavailable, and it is the half explaining the surprising parts - and an emoji or an ornament carries tone rather than information, which reads differently to everybody.
|
|
166
|
+
"This line carries {{offence}}. The bar is a reader who does not share the author's first language: a codebase with two languages in it has somebody for whom half the reasoning is unavailable, and it is the half explaining the surprising parts - and an emoji or an ornament carries tone rather than information, which reads differently to everybody. There is no exemption marker: write the text in English, or keep product copy in a locale or data file.",
|
|
173
167
|
},
|
|
174
168
|
},
|
|
175
169
|
create(context) {
|
|
@@ -187,8 +181,8 @@ export const noNonAsciiSource = {
|
|
|
187
181
|
* chuyen khoan" - is feeding it data, and translating it would test a system nobody uses.
|
|
188
182
|
*
|
|
189
183
|
* Measured before it was written: of 92 findings in one back end, 89 were fixture strings and 3
|
|
190
|
-
* were comments.
|
|
191
|
-
* conversation fixture, which is noise
|
|
184
|
+
* were comments. Marking all 92 would have put an exemption on every line of every
|
|
185
|
+
* conversation fixture, which is noise; the whole lane is exempt for strings instead.
|
|
192
186
|
*
|
|
193
187
|
* The exemption is for STRINGS ONLY. A Vietnamese comment in a spec is the same problem it is
|
|
194
188
|
* anywhere else - the next reader still cannot follow the reasoning - so it is still refused.
|
|
@@ -207,7 +201,6 @@ export const noNonAsciiSource = {
|
|
|
207
201
|
const lines = sourceCode.getLines()
|
|
208
202
|
for (let index = 0; index < lines.length; index += 1) {
|
|
209
203
|
const line = lines[index]
|
|
210
|
-
if (KEEP_MARKER.test(line)) continue
|
|
211
204
|
// in a fixture lane only prose is policed; the data the fixture feeds is the point of it
|
|
212
205
|
if (commentLines && !commentLines.has(index + 1)) continue
|
|
213
206
|
const offence = offenceIn(line)
|
|
@@ -229,45 +222,6 @@ export const noNonAsciiSource = {
|
|
|
229
222
|
},
|
|
230
223
|
}
|
|
231
224
|
|
|
232
|
-
// -- law 6 -------------------------------------------------------------------------------------
|
|
233
|
-
|
|
234
|
-
/** The line-level test for law 6: `vn-ok` present, with a colon and at least one reason character after it. */
|
|
235
|
-
const REASON_AFTER_MARKER = /\bvn-ok\s*:\s*\S/
|
|
236
|
-
|
|
237
|
-
/** Every `vn-ok` marker carries a reason; a bare marker is not an exemption. */
|
|
238
|
-
export const requireVnOkReason = {
|
|
239
|
-
meta: {
|
|
240
|
-
type: "problem",
|
|
241
|
-
docs: {
|
|
242
|
-
description:
|
|
243
|
-
"COMMENT-5, law 6: a `vn-ok` marker carries a reason - a bare marker is not an exemption.",
|
|
244
|
-
},
|
|
245
|
-
schema: [],
|
|
246
|
-
messages: {
|
|
247
|
-
bareMarker:
|
|
248
|
-
"`vn-ok` on this line has no reason after it. The marker exists so the next sweep can read WHY the line stays instead of \"fixing\" it into a bug - a bare marker is only a way of switching that check off. Write `vn-ok: <reason>` and say what the marker is protecting.",
|
|
249
|
-
},
|
|
250
|
-
},
|
|
251
|
-
create(context) {
|
|
252
|
-
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
253
|
-
return {
|
|
254
|
-
"Program:exit"(node) {
|
|
255
|
-
const lines = sourceCode.getLines()
|
|
256
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
257
|
-
const line = lines[index]
|
|
258
|
-
if (!KEEP_MARKER.test(line)) continue
|
|
259
|
-
if (REASON_AFTER_MARKER.test(line)) continue
|
|
260
|
-
context.report({
|
|
261
|
-
node,
|
|
262
|
-
loc: { line: index + 1, column: 0 },
|
|
263
|
-
messageId: "bareMarker",
|
|
264
|
-
})
|
|
265
|
-
}
|
|
266
|
-
},
|
|
267
|
-
}
|
|
268
|
-
},
|
|
269
|
-
}
|
|
270
|
-
|
|
271
225
|
// -- law 7 -------------------------------------------------------------------------------------
|
|
272
226
|
|
|
273
227
|
/** Splits a declared name into its lowercase words, so `readUser` compares against "read user". */
|
|
@@ -367,7 +321,6 @@ export const rules = {
|
|
|
367
321
|
"require-export-jsdoc": requireExportJsdoc,
|
|
368
322
|
"require-enum-member-jsdoc": requireEnumMemberJsdoc,
|
|
369
323
|
"no-non-ascii-source": noNonAsciiSource,
|
|
370
|
-
"require-vn-ok-reason": requireVnOkReason,
|
|
371
324
|
"no-restated-name-jsdoc": noRestatedNameJsdoc,
|
|
372
325
|
}
|
|
373
326
|
|
|
@@ -383,6 +336,5 @@ export const recommended = {
|
|
|
383
336
|
"starci-be/require-export-jsdoc": "error",
|
|
384
337
|
"starci-be/require-enum-member-jsdoc": "error",
|
|
385
338
|
"starci-be/no-non-ascii-source": "error",
|
|
386
|
-
"starci-be/require-vn-ok-reason": "error",
|
|
387
339
|
"starci-be/no-restated-name-jsdoc": "error",
|
|
388
340
|
}
|
package/config-owner.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold HFS configuration and secrets (catalog R43 `BE_CONFIG_OWNER`, R44 `BE_SECRET_DEFAULT`,
|
|
3
|
+
* and the secret-comparison half of R41 `BE_DEFAULT_DENY`).
|
|
4
|
+
*
|
|
5
|
+
* - `no-direct-env-read` keeps `process.env` inside `platform/config`. Everything else receives typed options
|
|
6
|
+
* from `<capability>.config.ts`; a `readEnvironment()` call inside a `useFactory` is the same read in
|
|
7
|
+
* disguise and is refused too.
|
|
8
|
+
* - `no-secret-default` refuses a literal default for a secret, password, token, key or URL: an `env.X ?? "..."`
|
|
9
|
+
* fallback, a property or variable of that name holding a non-empty string, a `.default("...")` on a
|
|
10
|
+
* schema of that name, and a path built from `process.cwd()` and `src` or `.starcistacks`. A missing value
|
|
11
|
+
* must stop the boot, naming the key.
|
|
12
|
+
* - `secret-compare-timing-safe` refuses `===` and `!==` on a secret-named identifier. A secret compares with
|
|
13
|
+
* `timingSafeEqual`, because the length of the matching prefix is a measurable side channel.
|
|
14
|
+
*
|
|
15
|
+
* Test lanes are outside the default and env rules: a spec arranges its own environment and fake credentials.
|
|
16
|
+
* They are not outside the comparison rule.
|
|
17
|
+
*/
|
|
18
|
+
import { keyName, staticText, walk, wordsOf } from "./lib/ast.mjs"
|
|
19
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
20
|
+
|
|
21
|
+
const PLATFORM_CONFIG = /\/src\/modules\/platform\/config\//
|
|
22
|
+
|
|
23
|
+
/** `process.env` or `process["env"]`. */
|
|
24
|
+
const isProcessEnv = (node) =>
|
|
25
|
+
node.type === "MemberExpression" &&
|
|
26
|
+
node.object.type === "Identifier" &&
|
|
27
|
+
node.object.name === "process" &&
|
|
28
|
+
(node.computed ? node.property.type === "Literal" && node.property.value === "env" : keyName(node.property) === "env")
|
|
29
|
+
|
|
30
|
+
/** Only the platform config capability reads the process environment. */
|
|
31
|
+
export const noDirectEnvRead = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: "problem",
|
|
34
|
+
docs: { description: "`process.env` is read only inside `platform/config`." },
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: {
|
|
37
|
+
env: "`process.env` is read outside `platform/config`. Add the key to the capability's `<capability>.config.ts` (zod) and receive the value through its `<capability>.options.ts`.",
|
|
38
|
+
factory: "`readEnvironment()` inside a `useFactory` reads the environment per module. `main.ts` reads it once and passes options through `AppModule.register`.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
43
|
+
if (isDeclarationFile(filename) || isTestLane(filename) || PLATFORM_CONFIG.test(filename)) return {}
|
|
44
|
+
return {
|
|
45
|
+
MemberExpression(node) {
|
|
46
|
+
if (isProcessEnv(node)) context.report({ node, messageId: "env" })
|
|
47
|
+
},
|
|
48
|
+
CallExpression(node) {
|
|
49
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== "readEnvironment") return
|
|
50
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
51
|
+
if (parent.type === "Property" && keyName(parent.key) === "useFactory") {
|
|
52
|
+
context.report({ node, messageId: "factory" })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SECRET_WORDS = new Set(["password", "passwd", "passphrase", "secret", "token", "credential", "credentials", "apikey", "signature", "hmac"])
|
|
62
|
+
const KEY_PREFIXES = new Set(["api", "secret", "private", "access", "signing", "encryption", "master", "auth", "jwt", "session"])
|
|
63
|
+
const URL_WORDS = new Set(["url", "uri", "dsn", "endpoint"])
|
|
64
|
+
const NOT_A_VALUE_WORDS = new Set(["type", "kind", "length", "count", "ttl", "name", "prefix", "header", "scheme", "field", "path", "regex", "pattern"])
|
|
65
|
+
|
|
66
|
+
/** Whether a config name is a secret, or an infrastructure URL: `POSTGRES_PASSWORD`, `webhookSecret`, `redisUrl`. */
|
|
67
|
+
const configKind = (name) => {
|
|
68
|
+
const words = wordsOf(name)
|
|
69
|
+
const last = words.at(-1)
|
|
70
|
+
if (!last || NOT_A_VALUE_WORDS.has(last)) return null
|
|
71
|
+
if (SECRET_WORDS.has(last)) return "secret"
|
|
72
|
+
if (last === "key" && words.length > 1 && KEY_PREFIXES.has(words.at(-2))) return "secret"
|
|
73
|
+
if (URL_WORDS.has(last)) return "url"
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The name an env read spells: `process.env.X`, `env.X`, `env["X"]`, `config.get("X")`. */
|
|
78
|
+
const envReadName = (node) => {
|
|
79
|
+
if (node.type === "MemberExpression") {
|
|
80
|
+
const object = node.object
|
|
81
|
+
const isEnvObject = isProcessEnv(object) || (object.type === "Identifier" && /^env$/i.test(object.name))
|
|
82
|
+
if (!isEnvObject) return null
|
|
83
|
+
return node.computed ? staticText(node.property) : keyName(node.property)
|
|
84
|
+
}
|
|
85
|
+
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && keyName(node.callee.property) === "get") {
|
|
86
|
+
return staticText(node.arguments[0])
|
|
87
|
+
}
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const isNonEmptyText = (node) => {
|
|
92
|
+
const text = staticText(node)
|
|
93
|
+
return text !== null && text.trim() !== ""
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** An UPPER_SNAKE literal is the NAME of an environment key (`const API_KEY = "API_KEY"`), not a secret value. */
|
|
97
|
+
const isKeyName = (node, name) => {
|
|
98
|
+
const text = staticText(node) ?? ""
|
|
99
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(text)) return true
|
|
100
|
+
// `const MEDIA_SIGNING_SECRET_KEY = "mediaSigningSecret"`: a constant that names a lookup key holds the key's name
|
|
101
|
+
return /^[A-Z][A-Z0-9_]*_KEY$/.test(name ?? "") && /^[a-z][A-Za-z0-9]*$/.test(text)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const hasScheme = (node) => /^[a-z][a-z0-9+.-]*:\/\//i.test(staticText(node) ?? "")
|
|
105
|
+
|
|
106
|
+
const targetName = (node) => {
|
|
107
|
+
if (node.type === "Property" || node.type === "PropertyDefinition") return keyName(node.key)
|
|
108
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") return node.id.name
|
|
109
|
+
if (node.type === "AssignmentPattern" && node.left.type === "Identifier") return node.left.name
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** No secret, password, token, key or URL config has a literal default; a missing value stops the boot. */
|
|
114
|
+
export const noSecretDefault = {
|
|
115
|
+
meta: {
|
|
116
|
+
type: "problem",
|
|
117
|
+
docs: { description: "A secret or URL config value never has a literal default." },
|
|
118
|
+
schema: [],
|
|
119
|
+
messages: {
|
|
120
|
+
fallback: "`{{name}}` falls back to a literal. A missing {{kind}} must stop the boot with an error naming `{{name}}`, not start the app with a value from source.",
|
|
121
|
+
literal: "`{{name}}` holds a literal {{kind}}. A {{kind}} is supplied by configuration, never written in source.",
|
|
122
|
+
schemaDefault: "`.default(...)` gives `{{name}}` a value in source. Remove the default so a missing {{kind}} fails validation at boot.",
|
|
123
|
+
cwdPath: "A path built from `process.cwd()` and `{{segment}}` depends on where the process was started. Resolve it from a configured root.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
create(context) {
|
|
127
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
128
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
129
|
+
const report = (node, messageId, name, kind) => context.report({ node, messageId, data: { name, kind: kind === "url" ? "URL" : "secret" } })
|
|
130
|
+
const checkLiteralTarget = (node, value) => {
|
|
131
|
+
const name = targetName(node)
|
|
132
|
+
const kind = name ? configKind(name) : null
|
|
133
|
+
if (kind && value && isNonEmptyText(value) && (kind === "url" ? hasScheme(value) : !isKeyName(value, name))) report(node, "literal", name, kind)
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
LogicalExpression(node) {
|
|
137
|
+
if (node.operator !== "??" && node.operator !== "||") return
|
|
138
|
+
const name = envReadName(node.left)
|
|
139
|
+
const kind = name ? configKind(name) : null
|
|
140
|
+
if (kind && isNonEmptyText(node.right) && (kind === "url" ? hasScheme(node.right) : !isKeyName(node.right, name))) report(node, "fallback", name, kind)
|
|
141
|
+
},
|
|
142
|
+
Property(node) {
|
|
143
|
+
checkLiteralTarget(node, node.value)
|
|
144
|
+
},
|
|
145
|
+
PropertyDefinition(node) {
|
|
146
|
+
checkLiteralTarget(node, node.value)
|
|
147
|
+
},
|
|
148
|
+
VariableDeclarator(node) {
|
|
149
|
+
checkLiteralTarget(node, node.init)
|
|
150
|
+
},
|
|
151
|
+
AssignmentPattern(node) {
|
|
152
|
+
checkLiteralTarget(node, node.right)
|
|
153
|
+
},
|
|
154
|
+
CallExpression(node) {
|
|
155
|
+
const callee = node.callee
|
|
156
|
+
if (callee.type === "MemberExpression" && keyName(callee.property) === "default" && isNonEmptyText(node.arguments[0])) {
|
|
157
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
158
|
+
if (parent.type === "Property") {
|
|
159
|
+
const name = keyName(parent.key)
|
|
160
|
+
const kind = name ? configKind(name) : null
|
|
161
|
+
if (kind && (kind !== "url" || hasScheme(node.arguments[0]))) report(node, "schemaDefault", name, kind)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
if (callee.type === "MemberExpression" && ["join", "resolve"].includes(keyName(callee.property) ?? "")) {
|
|
168
|
+
let cwd = false
|
|
169
|
+
walk(node, (child) => {
|
|
170
|
+
if (child.type === "CallExpression" && child.callee.type === "MemberExpression" && keyName(child.callee.property) === "cwd") cwd = true
|
|
171
|
+
})
|
|
172
|
+
if (!cwd) return
|
|
173
|
+
for (const argument of node.arguments) {
|
|
174
|
+
const text = staticText(argument)
|
|
175
|
+
const segment = text === null ? null : text.split(/[\\/]/).find((part) => part === "src" || part === ".starcistacks")
|
|
176
|
+
if (segment) context.report({ node, messageId: "cwdPath", data: { segment } })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SECRET_COMPARE_WORDS = new Set(["secret", "password", "passwd", "passphrase", "token", "signature", "hmac", "credential", "credentials", "apikey"])
|
|
185
|
+
|
|
186
|
+
/** The identifier or property a comparison operand ends in, else null. */
|
|
187
|
+
const operandName = (node) => {
|
|
188
|
+
if (node.type === "Identifier") return node.name
|
|
189
|
+
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") return node.property.name
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Words before the last one that make a token a counter or a cursor, not a credential. */
|
|
194
|
+
const NOT_A_CREDENTIAL_QUALIFIERS = new Set(["fencing", "idempotency", "cursor", "page", "next", "continuation", "pagination", "sync", "csrf"])
|
|
195
|
+
|
|
196
|
+
const isSecretName = (name) => {
|
|
197
|
+
// an UPPER_SNAKE name is a constant naming a key, not the value it names
|
|
198
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(name)) return false
|
|
199
|
+
const words = wordsOf(name)
|
|
200
|
+
const last = words.at(-1)
|
|
201
|
+
if (!last || NOT_A_VALUE_WORDS.has(last) || words.some((word) => NOT_A_CREDENTIAL_QUALIFIERS.has(word))) return false
|
|
202
|
+
return SECRET_COMPARE_WORDS.has(last) || (last === "key" && words.length > 1 && KEY_PREFIXES.has(words.at(-2)))
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** An emptiness or presence check reveals nothing a timing attack could use. */
|
|
206
|
+
const isPresenceOperand = (node) =>
|
|
207
|
+
(node.type === "Literal" && (node.value === null || node.value === "" || typeof node.value === "boolean")) ||
|
|
208
|
+
(node.type === "Identifier" && node.name === "undefined") ||
|
|
209
|
+
(node.type === "UnaryExpression" && node.operator === "typeof")
|
|
210
|
+
|
|
211
|
+
/** A secret is compared with `timingSafeEqual`, never with an equality operator. */
|
|
212
|
+
export const secretCompareTimingSafe = {
|
|
213
|
+
meta: {
|
|
214
|
+
type: "problem",
|
|
215
|
+
docs: { description: "A secret-named value is compared with `timingSafeEqual`, not `===` or `!==`." },
|
|
216
|
+
schema: [],
|
|
217
|
+
messages: {
|
|
218
|
+
compare: "`{{name}}` is a secret compared with `{{operator}}`. Compare secrets with `crypto.timingSafeEqual` on equal-length buffers so the comparison time does not leak the match.",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
create(context) {
|
|
222
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
223
|
+
if (isDeclarationFile(filename)) return {}
|
|
224
|
+
return {
|
|
225
|
+
BinaryExpression(node) {
|
|
226
|
+
if (!["===", "!==", "==", "!="].includes(node.operator)) return
|
|
227
|
+
if (isPresenceOperand(node.left) || isPresenceOperand(node.right)) return
|
|
228
|
+
for (const side of [node.left, node.right]) {
|
|
229
|
+
const name = operandName(side)
|
|
230
|
+
if (name && isSecretName(name)) {
|
|
231
|
+
context.report({ node, messageId: "compare", data: { name, operator: node.operator } })
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The rules this law contributes to the plugin. */
|
|
241
|
+
export const rules = {
|
|
242
|
+
"no-direct-env-read": noDirectEnvRead,
|
|
243
|
+
"no-secret-default": noSecretDefault,
|
|
244
|
+
"secret-compare-timing-safe": secretCompareTimingSafe,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** All three start at error: a secret in source or a timing leak is never a warning. */
|
|
248
|
+
export const recommended = {
|
|
249
|
+
"starci-be/no-direct-env-read": "error",
|
|
250
|
+
"starci-be/no-secret-default": "error",
|
|
251
|
+
"starci-be/secret-compare-timing-safe": "error",
|
|
252
|
+
}
|