@starci/eslint-canon-be 3.0.8 → 3.0.10
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 +13 -1
- package/LICENSE +21 -0
- package/README.md +12 -6
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +24 -36
- package/config-owner.mjs +20 -4
- package/connections.mjs +12 -12
- package/cookies.mjs +106 -0
- package/cqrs.mjs +22 -26
- package/data-access.mjs +9 -28
- package/default-deny.mjs +2 -1
- package/e2e-flow.mjs +4 -4
- package/event-bus.mjs +237 -0
- package/feature-thin.mjs +237 -0
- package/idempotency.mjs +57 -6
- package/index.mjs +22 -1
- package/infra-owner.mjs +1 -1
- package/injection.mjs +67 -22
- package/input-bounds.mjs +2 -1
- package/jobs.mjs +214 -0
- package/lib/ast.mjs +7 -0
- package/lib/config.mjs +1 -1
- package/lib/db-writes.mjs +1 -1
- package/lib/doors.mjs +81 -0
- package/lib/edition.mjs +45 -0
- package/lib/entity-writes.mjs +35 -0
- package/lib/path.mjs +0 -6
- package/lib/persistence.mjs +11 -18
- package/lib/ports.mjs +5 -2
- package/lib/specifier.mjs +27 -0
- package/lib/transactions.mjs +37 -0
- package/lib/transport-slots.mjs +8 -3
- package/lib/unit-spec.mjs +63 -2
- package/module-layering.mjs +3 -24
- package/module-shape.mjs +2 -2
- package/naming.mjs +2 -2
- package/package.json +5 -2
- package/project-graph.mjs +5 -2
- package/projections.mjs +117 -0
- package/query-safety.mjs +1 -1
- package/realtime.mjs +181 -0
- package/runtime/engine/config.mjs +486 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/engine/secrets.mjs +130 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +162 -31
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +912 -120
- package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
- package/runtime/knowledge/sonar-gate.yaml +52 -18
- package/runtime/modules/models/registry.yaml +553 -0
- package/runtime/modules/models/runtimes.yaml +695 -0
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
- package/runtime/scripts/api/git/config-get.mjs +10 -0
- package/runtime/scripts/api/git/lib.mjs +18 -26
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
- package/runtime/scripts/hfs/architecture/config.mjs +9 -6
- package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
- package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
- package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
- package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -2
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
- package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +17 -4
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
- package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
- package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
- package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
- package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
- package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
- package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
- package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
- package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
- package/runtime/scripts/hfs/edition-slots.mjs +66 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/hfs/path-findings.mjs +1 -1
- package/runtime/scripts/hfs/project-graph.mjs +3 -3
- package/runtime/scripts/hfs/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
- package/runtime/scripts/hfs/rules/read.mjs +1 -1
- package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +78 -108
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
- package/runtime/scripts/hfs/view.mjs +3 -2
- package/runtime/scripts/lib/dockerfile.mjs +124 -0
- package/runtime/scripts/lib/git.mjs +36 -0
- package/runtime/scripts/lib/i18n.mjs +86 -0
- package/runtime/scripts/lib/jsdoc.mjs +35 -0
- package/runtime/scripts/lib/json.mjs +13 -3
- package/runtime/scripts/lib/language.mjs +24 -5
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +57 -2
- package/runtime/scripts/lib/read-text.mjs +34 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +4 -3
- package/runtime/scripts/lib/sops-envelope.mjs +112 -0
- package/runtime/scripts/lib/stack-services.mjs +7 -31
- package/runtime/scripts/lib/ts-ast.mjs +35 -0
- package/runtime/scripts/lib/walk.mjs +4 -3
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +1 -1
- package/test-world.mjs +5 -4
- package/testing.mjs +27 -24
- package/transport.mjs +12 -9
- package/unit-spec.mjs +1 -1
- package/user-copy.mjs +5 -4
- package/webhooks.mjs +173 -0
- package/runtime/scripts/api/sops/decrypt.mjs +0 -16
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- New: `feature-thin` (R203 `BE_FEATURE_THIN`, law `feature-thin`): a feature-tier file of a role of `ruleParams.be.thinRoles` (handler, resolver, controller, consumer, processor, step, saga, saga-step, compensation, webhook, gateway, subscription, cli, mapper, the saga orchestrator service) maps its parameters and makes ONE delegating call into modules/, with no branch, loop, ternary, try, throw or computation. The slot manifest copy gains `coverage` on every tracked be slot and `ruleParams.be.logicRoles` and `thinRoles`.
|
|
6
|
+
- Changed: `unit-test-colocated` also allows `<name>.<role>.spec.ts` beside a logic role (policy, client, guard, mapper, ...) inside a slot whose coverage is required; the spec stays required only for the roles of `ruleParams.be.unitRoles`, and the texts say so.
|
|
7
|
+
- Changed: `no-repository-class` (R83) accepts `platform/saga` as a platform persistence capability (state of a saga run with its version fence).
|
|
8
|
+
- New: `service-isolation` (R168 `BE_SERVICE_ISOLATION`, new law `service-isolation`): a file of a service app imports no file of a sibling service app. The slot manifest copy gains `be.contract.events` and `events.ts`/`consumes.ts` in the api and worker app slots.
|
|
9
|
+
- New: `no-ambient-id` (R142 `BE_AMBIENT_ID`): `randomUUID` of Node's crypto, the `uuid` generators `v1`/`v4`/`v6`/`v7`, `nanoid` and `ulid` are used only inside `platform/ids`, specs included; code asks the `Ids` port and specs use `fakeIds()`.
|
|
10
|
+
- New: `response-cookie-attributes` (R141 `BE_COOKIE_ATTRIBUTES`): a cookie written through the Express or Fastify response states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict` in typed options, and `Set-Cookie` is never written as a header.
|
|
11
|
+
- New: `injected-param-name` (R85): a parameter that injects a port of the table `ruleParams.be.paramNames` of the slot manifest (`Clock`, `Logger`, `Cache`, `CommandBus`, `QueryBus`, `EntityManager`, `*Options`) carries the name that table gives it.
|
|
12
|
+
- Changed: `secret-compare-timing-safe` (R41) also recognises, by type, the `.digest()` of a crypto `Hash` or `Hmac`.
|
|
13
|
+
- Changed: the texts of `unit-test-colocated` and the law headers name the four test kinds and the pattern files; the bundled runtime copies follow.
|
|
14
|
+
|
|
3
15
|
## 3.0.7 - 2026-10-01
|
|
4
16
|
|
|
5
17
|
- Changed: the runtime layer check moves test-secrets onto api/sops/decrypt.mjs; bundled copies follow.
|
|
@@ -199,7 +211,7 @@ Round 2 back-end rules (catalog R42, R68 to R76). Every rule below has RuleTeste
|
|
|
199
211
|
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`.
|
|
200
212
|
|
|
201
213
|
- 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).
|
|
202
|
-
- Fixed: `import-owner-entry` (then under an older name) 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`.
|
|
214
|
+
- Fixed: `import-owner-entry` (then under an older name) now holds the HFS owner surface. An aliased import names the owner and stops (`@modules/domain/plan`, `@features/api/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`.
|
|
203
215
|
- 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`.
|
|
204
216
|
- `starciBeConfig` refuses a recommendation that carries an `off`.
|
|
205
217
|
- 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.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) StarCi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm i -D @starci/eslint-canon-be
|
|
|
12
12
|
|
|
13
13
|
## Use it
|
|
14
14
|
|
|
15
|
-
`eslint.config.mjs` of a back end is a managed file (`
|
|
15
|
+
`eslint.config.mjs` of a back end is a managed file (`starci app sync` renders it; `starci app check` refuses any other content):
|
|
16
16
|
|
|
17
17
|
```js
|
|
18
18
|
import { loadHfs, starciBeConfig } from "@starci/eslint-canon-be"
|
|
@@ -40,6 +40,8 @@ A sample, not the list:
|
|
|
40
40
|
| **Errors** | A `catch` rethrows, calls a method on a `Logger` receiver (typed, from `platform/logging`) or returns an outcome carrying its cause; a `DomainError` subclass lives only in `errors/<capability>.error.ts` of its owner, and that file is one code enum, one `Record<Code, ErrorKind>` table and one empty class; a `throw` throws a `DomainError`, never a bare `Error`, a framework exception or a platform error out of a domain (`catch-must-account`, `error-home`, `throw-domain-error`, `error-family-shape`) |
|
|
41
41
|
| **Schema authority** | Migrations only: every DataSource or TypeORM options object states the literal `synchronize: false` (found by the contextual type), no `migrationsRun`, no DDL string outside a migration, no entity glob; no `@Entity` type reachable from a transport signature, a handler `execute`, a contract or a message, and no GraphQL or validator import in an entity file (`no-runtime-schema`, `no-entity-in-contract`) |
|
|
42
42
|
| **Config and secrets** | `process.env` (any form), `@nestjs/config`, `dotenv` only in the file of `platform/config` that declares `EnvSource` (and the test world, slot `be.tests.world`, for `process.env`); a function exported by a `<capability>.config.ts` is called only from `apps/<app>/src/main.ts` and `<app>.options.ts`, its own config file or spec, the test world and a provider contract spec (`config-parsed-in-main`); no default of any kind for a value typed `Secret` or `Url`; secrets compare with `timingSafeEqual` (`no-direct-env-read`, `config-parsed-in-main`, `no-secret-default`, `secret-compare-timing-safe`) |
|
|
43
|
+
| **Ambient ids** (R142) | `randomUUID` of Node's crypto (named, namespace or global), the `v1`, `v4`, `v6` and `v7` generators of `uuid`, `nanoid` and `ulid` are used only inside `platform/ids`, specs included: code asks the injected `Ids` port and a spec provides `fakeIds()`; the generators are found by the module they are imported from, never by a local name (`no-ambient-id`) |
|
|
44
|
+
| **Cookies** (R141) | A cookie a door writes through the response of Express or Fastify (`cookie`, `setCookie`) states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict` in options typed with those literals, and `Set-Cookie` is never written as a header; the call is recognised by where its signature is declared (`response-cookie-attributes`). A secret-derived value is compared with `timingSafeEqual`: a `Secret` of `platform/config` and the `.digest()` of a `Hash` or `Hmac` of Node's crypto are recognised by type (`secret-compare-timing-safe`) |
|
|
43
45
|
| **Default deny** | Typed bodies and arguments, no `GraphQLJSON` tunnel, no `switch (operation)`, `@Public` carries a `PublicReason` member of `domain/identity`, and no `@UseGuards` (the global `APP_GUARD` chain decides) (`no-untyped-body`, `public-needs-reason`, `no-auth-use-guards`) |
|
|
44
46
|
| **Module shape** | No `@Global()`; `isGlobal: true` only in an app's `app.module.ts`; no import of another owner's module; typed `ConfigurableModuleBuilder`; `static register` is the only factory; a capability's representative module extends the `ConfigurableModuleClass` of its own module definition and every sub-module or feature module is plain (`capability-module-shape`); one module per file; no `new` of a provider; no module-level `let` (`no-global-decorator`, `is-global-only-in-app`, `no-cross-owner-module-import`, `static-module-register`) |
|
|
45
47
|
| **Suppression** | No `eslint-disable`, `eslint-env`, `/* global */`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `istanbul`, `c8` or `v8 ignore`, `NOSONAR`, `sonar-disable`, `prettier-ignore` or `vn-ok` comment, anywhere (`no-inline-suppression`) |
|
|
@@ -52,22 +54,26 @@ A sample, not the list:
|
|
|
52
54
|
| **Spec quality** | A spec does not read repository source with `fs` (`spec-no-source-read`); the borrowed `consistent-type-assertions` (`never`) and `no-non-null-assertion` leave no `as` and no `x!` in a spec, and `no-ambient-clock` and `no-direct-env-read` cover specs, so no `Date.now()`, argless `new Date()` or `process.env`. A unit spec is `<name>.service.spec.ts`: it builds its subject with `Test.createTestingModule({ providers })` from `@nestjs/testing`, `.compile()` and `moduleRef.get(...)` (`spec-builds-with-testing-module`), never `new` on the service (`spec-no-new-subject`), no `imports` key and no `override*` (`spec-module-definition-only-providers`), no `jest.mock`/`doMock`/`unstable_mockModule`/`requireMock` of an own module or a library (`spec-no-module-mock`), no cast-laundering helper in a spec or fixture: a return-only generic, an `unknown` handed back as another type, `JSON.parse(JSON.stringify(x))`, `Object.assign(new X(), y)` returned as another type (`no-return-only-generic`); a provider `{ provide: TOKEN, useValue: X }` takes X from the matching `@starci/jest-preset` double, the token table being `ruleParams.be.specDoubles` of the slot manifest (`spec-infra-double-from-kit`); a unit spec asserts exact values, never `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)` (`expect` being the jest global or the `@jest/globals` import): ids come from `fakeIds()` and dates from `FakeClock` (`spec-exact-values`); the EntityManager double is the kit's `mockEntityManager()`/`fakeTransaction()`, never ad hoc (`spec-typed-entity-manager`); test data goes through builders, see the **Test data builders** row |
|
|
53
55
|
| **Test data builders** (R98 to R101) | Builders live only in `src/tests/fixtures/builders/<area>.builder.ts`: a module of the test tree that exports a function creating persisted rows through an EntityManager, DataSource or QueryRunner is a builder wherever it sits (`persisting-builder-in-builders-slot`, R98); a spec never runs a raw INSERT/UPDATE/DELETE or a direct write (`save`, `insert`, `upsert`, `update`, `delete`, `remove`) on a typeorm receiver and never builds the same `@Entity` class as an object literal twice (`spec-no-raw-insert`, `spec-no-repeated-row-literal`, R99); a builder never asserts and has deterministic defaults (`builder-arranges-only`, R101); nothing under `src/tests` disables or drops a constraint (`tests-keep-constraints`, R100) |
|
|
54
56
|
| **CQRS** | A message extends `Command<R>` / `Query<R>` from `@nestjs/cqrs`, carries one `readonly params: ExecuteParams<X>` (no entity reachable from `X`), a handler extends `ICQRSHandler` and overrides `process`; a handler is thin: it injects only `*Service` classes and the Logger, and `process` is one `return this.<service>.<method>(...)` with no branch, loop, EntityManager or second call (`handler-is-thin`); no `*.use-case.ts`, no forwarder service, no `EventBus`, `@nestjs/event-emitter`, `EventEmitter` or RxJS `Subject` constructed or extended, and no class that stores callbacks other code registers (`handler-overrides-process`, `message-carries-params-only`, `message-typed-result`, `execute-params-shape`, `no-use-case`, `no-event-bus`) |
|
|
55
|
-
| **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/plan`), never through a path into it, and a same-owner import is relative and never through the own index (`import-owner-entry`); an owner has one `index.ts` (a nested one is refused) that holds only named `export { }` lines, with no `export *`, and a bounded number of names (`no-folder-reexport`); self-aliases and relative escapes are refused (`no-self-module-alias`, `no-relative-capability-escape`). Owners come from the HFS slots, aliases from the program's `compilerOptions.paths`. |
|
|
56
|
-
| **Data access** | The database is reached through the shared `EntityManager` only, received as a constructor parameter with the named injector of a declared connection - a lookalike name, property injection, an injected `DataSource` or `QueryRunner` outside `platform/database` and the
|
|
57
|
-
| **Connections** | One database is one connection and one `Inject<Conn>EntityManager()` injector: `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in `platform/database/<conn>.decorators.ts`, the
|
|
57
|
+
| **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/api/plan`), never through a path into it, and a same-owner import is relative and never through the own index (`import-owner-entry`); an owner has one `index.ts` (a nested one is refused) that holds only named `export { }` lines, with no `export *`, and a bounded number of names (`no-folder-reexport`); self-aliases and relative escapes are refused (`no-self-module-alias`, `no-relative-capability-escape`). Owners come from the HFS slots, aliases from the program's `compilerOptions.paths`. |
|
|
58
|
+
| **Data access** | The database is reached through the shared `EntityManager` only, received as a constructor parameter with the named injector of a declared connection - a lookalike name, property injection, an injected `DataSource` or `QueryRunner` outside `platform/database` and the cli (`apps/cli`, `src/features/cli/`), `getRepository` and repository types are refused, specs included (`must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner` whatever it is called, and no exported function takes an `EntityManager` first (a statement module); `src/tests` is exempt (`no-repository-class`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner`, whatever it is called, and no exported function takes an `EntityManager` first (a statement module); the test tree (`src/tests`) is exempt (`no-repository-class`); inside a transaction callback only the manager it received is used (`no-outer-manager-in-transaction`); no call through an integration, `HttpClient` or `MessagePublisher` typed receiver spans a transaction (`no-external-call-in-transaction`); the manager is injected only in application handlers, domain services and the platform database, inbox, outbox and lease capabilities (`em-injection-slots`) |
|
|
59
|
+
| **Connections** | One database is one connection and one `Inject<Conn>EntityManager()` injector: `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in `platform/database/<conn>.decorators.ts`, the cli and the test world; an exported EntityManager injector is exactly the declared connection's; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and the cli; the connection name is written once, in its `<conn>.connection.ts`; a `<conn>.config.ts` reads only keys of its `envPrefix` (`one-connection-per-database`) |
|
|
58
60
|
| **Operations** | A versioned operation is declared once in the app's typed operation table (`query<Input, Output, RefusalCode>()` / `mutation<...>()` registered by `defineOperations`, declared by the platform capability `operations`): its input and output are closed types (no `any`, `unknown`, `Record<string, unknown>`, unbound generic, function or `Date`) and its refusal codes a closed union of string literals; a route that takes or answers the table's types takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of one table (`operation-contract-decidable`, `operation-route-driven-by-table`) |
|
|
61
|
+
| **Webhooks and realtime** | A provider webhook door injects only the `WebhookSignatureService` and domain services, starts with the unconditional `verify` call, makes exactly one domain call and returns nothing (`webhook-shape`, `webhook-verify-first`); a realtime door is one gateway or subscription that injects only the `RealtimeHub` and subscribes to a topic built from its `@CurrentPrincipal()` (`realtime-shape`, `realtime-read-only`, `realtime-topic-scope`) |
|
|
59
62
|
| **Transport** | A door injects only `CommandBus` / `QueryBus` (and `RequestLocale` in REST) and dispatches exactly one message per handler, holding no branch, loop, EntityManager or call other than the bus, and pure `*.mapper.ts` functions and `unwrapOutcome` of `platform/primitives` (`transport-is-thin`); no response envelope, no `GraphQLJSON`, no `unknown` body, no `switch (input.operation)`; a REST door lives in `transport/http` and shows a public reason, a byte stream or a redirect; a capability never imports a feature (`transport-is-thin`, `no-response-envelope`, `no-graphql-json`, `door-lives-in-features`, `rest-door-needs-a-reason`, `no-capability-imports-features`) |
|
|
63
|
+
| **Feature thin** (R203 BE_FEATURE_THIN) | A feature-tier file of a thin role (`<name>.handler.ts`, `.resolver.ts`, `.controller.ts`, `.consumer.ts`, `.processor.ts`, `.step.ts`, `.saga.ts`, `.saga-step.ts`, `.compensation.ts`, `.webhook.ts`, `.gateway.ts`, `.subscription.ts`, `.cli.ts`, `.mapper.ts`, `.service.ts`) maps its parameters and makes exactly one delegating call into the modules - an injected member, a bus `execute` or a modules function in statement position - with no branch, loop, `try`, `throw` or computation; free calls are pure `*.mapper.ts` functions, the `Logger` port and a webhook's `verify`; orchestration (step order, compensation, claim, fencing, retry) belongs to the platform (`feature-thin`) |
|
|
60
64
|
| **Infrastructure owners** | Each raw library (HTTP, cache, queue, scheduler, logger, date, config, events) is imported or reached as a global only inside the capability the manifest names for it (`ruleParams.be.infraOwners`); `@nestjs/config`, `dotenv` and `@nestjs/event-emitter` nowhere; the `HttpClient` port of `platform/http` (found by the receiver's type) is called only in an `integrations` owner, `platform/http` and the test world (`infra-import-owner`, `http-client-only-in-integrations`) |
|
|
61
65
|
| **Observability** | Logs leave through the `Logger` port of `platform/logging`, never the framework's `Logger`; a log call's first argument is a member of an owner's `<owner>.log-events.ts` enum; a failure log carries the exception's identity, not only its wording (`no-framework-logger`, `no-interpolated-log-message`, `no-error-wording-as-log-identity`) |
|
|
62
|
-
| **Testing** |
|
|
66
|
+
| **Testing** | A unit spec is `<name>.<role>.spec.ts` beside its subject: REQUIRED beside every file of a spec-required role of `ruleParams.be.unitRoles` (every `<name>.service.ts` its `<name>.service.spec.ts`, every `<name>.cli.ts` of `src/features/cli/` its `<name>.cli.spec.ts`) and beside a webhook or realtime door that has no service of its own; OPTIONAL beside any other file of a `ruleParams.be.logicRoles` role inside a slot whose `coverage` is `required` (the file owes 100 per file, not a spec of its own); any other `*.spec.ts` outside `src/tests/{e2e,integration,contract,world}` (handler, resolver, controller, consumer, entity, module, composition) is a finding; there is no `.test.ts`, `int-spec` or `harness-spec`; a spec asserts a result or a state, not only a call; an e2e reads persisted state back through a typeorm `EntityManager` and reaches no model provider outside `src/tests/e2e/live/` (`unit-test-colocated`, `no-call-only-spec`, `e2e-asserts-persisted-state`, `no-model-call-in-e2e`, `no-api-shaped-e2e-filename`, `no-marker-model-stub`) |
|
|
67
|
+
| **Command line** | Every one-off action is a nest-commander command of the one cli app: `apps/cli/src/main.ts` boots with `CommandFactory.run` and never serves (`cli-bootstrap`); a `@Command` or `@SubCommand` is declared only in `src/features/cli/` as `<group>/<group>.cli.ts` or `<group>/subs/<name>.cli.ts`, one per file, each with its `<name>.cli.spec.ts` beside it (`cli-command-shape`, `unit-test-colocated`); `nest-commander` is imported only by the cli, and `process.argv`, `commander`, `yargs` and `minimist` nowhere (`cli-owner`) |
|
|
63
68
|
| **End-to-end flows** | One file, one flow, named steps; the flow enters through transport (a `CommandBus`, `QueryBus` or a handler, consumer or job is never called), boots through `src/tests/e2e/setup` (no `Test.createTestingModule`), **never sleeps** — it polls with `waitFor` until the state settles, with a deadline — and takes no branch inside a step (`e2e-uses-production-transport`, `no-wiring-in-flow-spec`, `no-sleep-in-flow`, `no-branch-in-flow-step`) |
|
|
64
69
|
| **CDC · event delivery** | Projections and events follow the declared delivery contract |
|
|
65
70
|
| **Service dependencies · source form** | Production source declares no `Mock*`, `Fake*` or `Stub*` class, function or constant: the marker is a whole PascalCase or camelCase word of the identifier, a double lives in a spec or `src/tests`, and an offline driver is an options-driven adapter named for what it does (`no-test-double-in-source`); the provider, repository-class and connection rules of this law are listed under Dependency injection and Data access |
|
|
66
71
|
| **Comments · naming · type safety** | Comments say why; no double cast through `unknown`; named exports only, the Jest `*.global-setup.ts` being the one default export; Vietnamese only in a message catalog or the `src/tests/fixtures/i18n/` fixtures slot (`be.tests.fixtures.i18n`), never in an identifier, string, comment or test title, specs included, in NFC or NFD spelling (`no-default-export`, `no-non-ascii-source`) |
|
|
67
72
|
| **User copy** | A literal message of an error class (judged by the constructed type extending `Error`), a text property handed to an outbound port (a receiver typed from `integrations` or `platform/messaging`) or a response copy in a transport slot comes from the messages catalog through the typed `MessageCatalog` port, specs included (`user-copy-through-catalog`) |
|
|
68
73
|
| **Temporal** | `Date.now`, a bare `new Date()`, `performance.now`, `process.hrtime` and `Temporal.Now` are referenced (not only called) only inside `platform/clock`, specs included (`no-ambient-clock`) |
|
|
69
|
-
| **Dependency injection** | Raw `Inject(`, `InjectEntityManager(`, `InjectDataSource(`, `InjectQueue(`, `InjectRepository(` and any third-party `Inject*` are called only in a `<owner>.decorators.ts` at an owner root, and injectors decorate constructor parameters only; an injector is zero-argument, typed `TypedParameterDecorator<T>`, built with `injector<T>(token)`, documented, over a `unique symbol` token; `T` equals the parameter type; a parameter typed by a platform, integrations or package declaration carries an injector (only domain services and the same owner are class-injected); no `ModuleRef`, no `forwardRef`, no string token; a constructor parameter of a Nest provider is typed by a class or carries an `Inject<Thing>()`, never a bare primitive, `Map`, union or interface (`injector-only`, `injector-shape`, `injector-type-match`, `infra-needs-injector`, `provider-param-token`, `no-module-ref`, `no-forward-ref`, `no-string-token`) |
|
|
74
|
+
| **Dependency injection** | Raw `Inject(`, `InjectEntityManager(`, `InjectDataSource(`, `InjectQueue(`, `InjectRepository(` and any third-party `Inject*` are called only in a `<owner>.decorators.ts` at an owner root, and injectors decorate constructor parameters only; an injector is zero-argument, typed `TypedParameterDecorator<T>`, built with `injector<T>(token)`, documented, over a `unique symbol` token; `T` equals the parameter type; a parameter typed by a platform, integrations or package declaration carries an injector (only domain services and the same owner are class-injected); no `ModuleRef`, no `forwardRef`, no string token; a constructor parameter of a Nest provider is typed by a class or carries an `Inject<Thing>()`, never a bare primitive, `Map`, union or interface; a parameter that injects a port of the table `ruleParams.be.paramNames` of the slot manifest (`Clock`, `Logger`, `Cache`, `CommandBus`, `QueryBus`, `EntityManager`, an `*Options`) carries the name that table gives it (`injector-only`, `injector-shape`, `injector-type-match`, `infra-needs-injector`, `provider-param-token`, `injected-param-name`, `no-module-ref`, `no-forward-ref`, `no-string-token`) |
|
|
70
75
|
| **Idempotency** | The first awaited expression of a public service method whose first parameter has an `eventId` is `claim(source, eventId)` on a receiver typed as the `Inbox` port, followed by an early return on `false`; doors are thin and claim nothing (`inbox-dedupe-required`) |
|
|
76
|
+
| **Service isolation** | A file of a service app (`apps/<service>/`) imports no file of a sibling service app, through a relative path or an alias of the program's own `paths`: the app that owns each file comes from the slot view, never from a name (`service-isolation`, R168 `BE_SERVICE_ISOLATION`). Services share only the `src` libraries and meet through the wire. |
|
|
71
77
|
|
|
72
78
|
Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
|
|
73
79
|
line leads straight to the document that explains why.
|
package/ambient-id.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `platform/ids` (catalog R142 `BE_AMBIENT_ID`).
|
|
3
|
+
*
|
|
4
|
+
* - `no-ambient-id` refuses every source of an identifier that does not come from the injected `Ids` port:
|
|
5
|
+
* `randomUUID` of `node:crypto` (a named import, a namespace or default import, or the global `crypto`), the
|
|
6
|
+
* generators of the `uuid` package that read the clock or a random source (`v1`, `v4`, `v6`, `v7`), and the `nanoid` and
|
|
7
|
+
* `ulid` packages. Business code asks `ids.next()`; a spec provides `fakeIds()`, so an id is exactly what the spec said
|
|
8
|
+
* and a failing run reproduces.
|
|
9
|
+
*
|
|
10
|
+
* The owner that may mint ids is asked of the slot view (`platform` tier, capability `ids`), as the clock is, and the test
|
|
11
|
+
* world (slot `be.tests.world`) is exempt like it is from the clock: it is the composition root of tests and names real
|
|
12
|
+
* resources. Specs are NOT exempt. The generators are found by the module they are imported from, never by a local name,
|
|
13
|
+
* so `import { randomUUID as newId } from "node:crypto"` is the same reference.
|
|
14
|
+
*/
|
|
15
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
16
|
+
import { isOwnedBy } from "./lib/ports.mjs"
|
|
17
|
+
|
|
18
|
+
/** Modules of Node's crypto: `randomUUID` of them mints an id. */
|
|
19
|
+
const CRYPTO_MODULES = new Set(["node:crypto", "crypto"])
|
|
20
|
+
|
|
21
|
+
/** Packages whose named exports mint an id, and which of them (`null`: every export). */
|
|
22
|
+
const ID_PACKAGES = Object.freeze({ uuid: ["v1", "v4", "v6", "v7"], nanoid: null, "nanoid/non-secure": null, ulid: null })
|
|
23
|
+
|
|
24
|
+
/** The variable a name resolves to from `node`'s scope, or null when it is a global. */
|
|
25
|
+
const variableOf = (context, node, name) => {
|
|
26
|
+
let scope = (context.sourceCode || context.getSourceCode()).getScope(node)
|
|
27
|
+
while (scope) {
|
|
28
|
+
const found = scope.set.get(name)
|
|
29
|
+
if (found) return found
|
|
30
|
+
scope = scope.upper
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `node` is the global `crypto` (or `globalThis.crypto`) or an import of Node's crypto module as a whole. */
|
|
36
|
+
const isCryptoObject = (context, node) => {
|
|
37
|
+
if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "globalThis" && node.property.type === "Identifier" && node.property.name === "crypto") return true
|
|
38
|
+
if (node.type !== "Identifier") return false
|
|
39
|
+
const variable = variableOf(context, node, node.name)
|
|
40
|
+
if (!variable || variable.defs.length === 0) return node.name === "crypto"
|
|
41
|
+
return variable.defs.every((def) => def.type === "ImportBinding" && CRYPTO_MODULES.has(def.parent.source.value) && def.node.type !== "ImportSpecifier")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** No id is minted outside `platform/ids`. */
|
|
45
|
+
export const noAmbientId = {
|
|
46
|
+
meta: {
|
|
47
|
+
type: "problem",
|
|
48
|
+
docs: { description: "`randomUUID`, the `uuid` generators, `nanoid` and `ulid` are used only inside `platform/ids`; code asks the injected `Ids` port." },
|
|
49
|
+
schema: [],
|
|
50
|
+
messages: {
|
|
51
|
+
id: "`{{call}}` mints an id from the ambient random source. Inject the `Ids` port (`@InjectIds() private readonly ids: Ids`, `ids.next()`); a spec then provides `fakeIds()` and every id is the one it said, which a direct call never allows.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const hfs = hfsOf(context)
|
|
56
|
+
const filename = context.filename || context.getFilename()
|
|
57
|
+
if (isOwnedBy(hfs, filename, "platform", "ids") || inTestWorld(hfs, filename)) return {}
|
|
58
|
+
return {
|
|
59
|
+
ImportDeclaration(node) {
|
|
60
|
+
const source = node.source.value
|
|
61
|
+
if (CRYPTO_MODULES.has(source)) {
|
|
62
|
+
for (const specifier of node.specifiers) {
|
|
63
|
+
if (specifier.type === "ImportSpecifier" && (specifier.imported.name ?? specifier.imported.value) === "randomUUID") context.report({ node: specifier, messageId: "id", data: { call: `randomUUID from ${source}` } })
|
|
64
|
+
}
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (!Object.hasOwn(ID_PACKAGES, source)) return
|
|
68
|
+
const banned = ID_PACKAGES[source]
|
|
69
|
+
for (const specifier of node.specifiers) {
|
|
70
|
+
const imported = specifier.type === "ImportSpecifier" ? (specifier.imported.name ?? specifier.imported.value) : specifier.type === "ImportDefaultSpecifier" ? "default" : "*"
|
|
71
|
+
if (banned === null || banned.includes(imported) || imported === "*") context.report({ node: specifier, messageId: "id", data: { call: `${imported === "default" ? source : imported} from ${source}` } })
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
MemberExpression(node) {
|
|
75
|
+
const member = !node.computed && node.property.type === "Identifier" ? node.property.name : node.computed && node.property.type === "Literal" ? node.property.value : null
|
|
76
|
+
if (member === "randomUUID" && isCryptoObject(context, node.object)) context.report({ node, messageId: "id", data: { call: "crypto.randomUUID" } })
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The rule this law contributes to the plugin. */
|
|
83
|
+
export const rules = {
|
|
84
|
+
"no-ambient-id": noAmbientId,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt with an Ids codemod. */
|
|
88
|
+
export const recommended = {
|
|
89
|
+
"starci-be/no-ambient-id": "error",
|
|
90
|
+
}
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of the one-off actions of a back end (catalog R148 `BE_CLI_BOOTSTRAP`, R149 `BE_CLI_COMMAND_SHAPE`, R150
|
|
3
|
+
* `BE_CLI_OWNER`). Every one-off action (migrate, seed, sync, backup, any operator command)
|
|
4
|
+
* lives in ONE app, `be/apps/cli` (slot `be.app.cli`), built on nest-commander, and its commands live in the cli feature root
|
|
5
|
+
* `src/features/cli/` (slot `be.cli`):
|
|
6
|
+
*
|
|
7
|
+
* - `cli-bootstrap`: the cli app boots with `CommandFactory.run(...)` of nest-commander in its `main.ts`; it never creates an HTTP
|
|
8
|
+
* application or a microservice (`NestFactory.create*` of `@nestjs/core`, `.listen()` on what it built, an HTTP platform adapter
|
|
9
|
+
* or `@nestjs/microservices`);
|
|
10
|
+
* - `cli-command-shape`: a nest-commander command class (`@Command` or `@SubCommand`, by the import that binds the decorator) is
|
|
11
|
+
* declared only in the cli feature root: a group `@Command` in `<group>/<group>.cli.ts`, a `@SubCommand` in
|
|
12
|
+
* `<group>/subs/<name>.cli.ts`, one class per file, and a sub-command extends `CommandRunner`. Its unit spec beside it
|
|
13
|
+
* (`<name>.cli.spec.ts`) is required by `starci app check` (scripts/hfs/rules/cli.mjs);
|
|
14
|
+
* - `cli-owner`: `nest-commander` is imported only by the cli app and the cli feature root; no back-end source parses the process
|
|
15
|
+
* arguments itself (`process.argv`) or imports another argument parser (commander, yargs, minimist): an action door anywhere
|
|
16
|
+
* else is refused.
|
|
17
|
+
*
|
|
18
|
+
* Where a file lives is asked of the slot view; what a name IS comes from the import that binds it. No path pattern is spelled here.
|
|
19
|
+
*/
|
|
20
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
21
|
+
import { decoratorCallee, importOf, isImportedFrom, moduleReferences } from "./lib/import-source.mjs"
|
|
22
|
+
|
|
23
|
+
/** The slot of the cli app and of the cli feature root. */
|
|
24
|
+
const CLI_APP_SLOT = "be.app.cli"
|
|
25
|
+
const CLI_FEATURE_SLOT = "be.cli"
|
|
26
|
+
const COMMANDER = "nest-commander"
|
|
27
|
+
/** The argument parsers no back-end source imports: nest-commander (in the cli app and feature only) parses the arguments. */
|
|
28
|
+
const FOREIGN_PARSERS = new Set(["commander", "yargs", "yargs/yargs", "yargs/helpers", "minimist"])
|
|
29
|
+
/** The entry file of an app (the slot's first requirement): where the cli app boots. */
|
|
30
|
+
const ENTRY = "main.ts"
|
|
31
|
+
|
|
32
|
+
const relativeIn = (hfs, file) => hfs.allows(file)?.relative ?? null
|
|
33
|
+
const isCliApp = (hfs, file) => hfs.slotOf(file) === CLI_APP_SLOT
|
|
34
|
+
const isCliFeature = (hfs, file) => hfs.slotOf(file) === CLI_FEATURE_SLOT
|
|
35
|
+
|
|
36
|
+
/** The cli app boots with CommandFactory.run and never serves HTTP or a microservice. */
|
|
37
|
+
export const cliBootstrap = {
|
|
38
|
+
meta: {
|
|
39
|
+
type: "problem",
|
|
40
|
+
docs: { description: "The cli app boots with `CommandFactory.run` of nest-commander and never creates an HTTP application or a microservice." },
|
|
41
|
+
schema: [],
|
|
42
|
+
messages: {
|
|
43
|
+
missing: "The cli app's `main.ts` never calls `CommandFactory.run(AppModule, ...)` of nest-commander. The cli app is the command line of the back end: boot it with CommandFactory.run, which parses the arguments and runs the command.",
|
|
44
|
+
server: "`{{name}}` makes the cli app a server. The cli app runs one command and exits: boot it with `CommandFactory.run` of nest-commander; a server is an api app.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
create(context) {
|
|
48
|
+
const hfs = hfsOf(context)
|
|
49
|
+
const filename = context.filename
|
|
50
|
+
if (!isCliApp(hfs, filename)) return {}
|
|
51
|
+
const entry = relativeIn(hfs, filename) === ENTRY
|
|
52
|
+
let boots = false
|
|
53
|
+
return {
|
|
54
|
+
...moduleReferences(({ source, value }) => {
|
|
55
|
+
if (value === "@nestjs/microservices" || value.startsWith("@nestjs/platform-")) context.report({ node: source, messageId: "server", data: { name: value } })
|
|
56
|
+
}),
|
|
57
|
+
CallExpression(node) {
|
|
58
|
+
const callee = node.callee
|
|
59
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
|
|
60
|
+
const method = callee.property.name
|
|
61
|
+
if (method === "run" && isImportedFrom(context, callee.object, COMMANDER, "CommandFactory")) boots = true
|
|
62
|
+
if (isImportedFrom(context, callee.object, "@nestjs/core", "NestFactory")) context.report({ node, messageId: "server", data: { name: `NestFactory.${method}` } })
|
|
63
|
+
else if (method === "listen" || method === "connectMicroservice" || method === "startAllMicroservices") context.report({ node, messageId: "server", data: { name: `.${method}()` } })
|
|
64
|
+
},
|
|
65
|
+
"Program:exit"(program) {
|
|
66
|
+
if (entry && !boots) context.report({ node: program, messageId: "missing" })
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The nest-commander decorator a class carries (Command or SubCommand), by the import that binds it. */
|
|
73
|
+
const commanderDecorator = (context, klass) => {
|
|
74
|
+
for (const decorator of klass.decorators ?? []) {
|
|
75
|
+
const callee = decoratorCallee(decorator)
|
|
76
|
+
if (callee?.type !== "Identifier") continue
|
|
77
|
+
const bound = importOf(context, callee)
|
|
78
|
+
if (bound?.source === COMMANDER && (bound.imported === "Command" || bound.imported === "SubCommand")) return { decorator, kind: bound.imported }
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where a command of each kind is declared in the cli feature root: `<group>/<group>.cli.ts` or `<group>/subs/<name>.cli.ts`. */
|
|
84
|
+
const placeOf = (relative) => {
|
|
85
|
+
const parts = String(relative ?? "").split("/")
|
|
86
|
+
if (parts.length === 2 && parts[1] === `${parts[0]}.cli.ts`) return "Command"
|
|
87
|
+
if (parts.length === 3 && parts[1] === "subs" && parts[2].endsWith(".cli.ts") && !parts[2].endsWith(".spec.ts")) return "SubCommand"
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A nest-commander command is declared only where the cli feature root keeps it, one per file, a sub-command a CommandRunner. */
|
|
92
|
+
export const cliCommandShape = {
|
|
93
|
+
meta: {
|
|
94
|
+
type: "problem",
|
|
95
|
+
docs: { description: "A nest-commander command is a group `@Command` in `src/features/cli/<group>/<group>.cli.ts` or a `@SubCommand` extending `CommandRunner` in `src/features/cli/<group>/subs/<name>.cli.ts`, one per file." },
|
|
96
|
+
schema: [],
|
|
97
|
+
messages: {
|
|
98
|
+
outside: "`@{{kind}}` declares a command outside the cli feature root (this file is in slot `{{slot}}`). Every one-off action is a command of `src/features/cli/`: a group in `<group>/<group>.cli.ts`, a sub-command in `<group>/subs/<name>.cli.ts`, compiled only into apps/cli.",
|
|
99
|
+
misplaced: "`@{{kind}}` sits in `{{relative}}`. A group `@Command` lives in `<group>/<group>.cli.ts` and a `@SubCommand` in `<group>/subs/<name>.cli.ts` of the cli feature root.",
|
|
100
|
+
second: "This file declares a second command. One command per file: move it to its own `<name>.cli.ts`.",
|
|
101
|
+
runner: "`@SubCommand` class `{{name}}` does not extend `CommandRunner` of nest-commander. A sub-command is a CommandRunner whose `run(...)` performs the action.",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
create(context) {
|
|
105
|
+
const hfs = hfsOf(context)
|
|
106
|
+
const filename = context.filename
|
|
107
|
+
const slot = hfs.slotOf(filename)
|
|
108
|
+
const place = isCliFeature(hfs, filename) ? placeOf(relativeIn(hfs, filename)) : null
|
|
109
|
+
let commands = 0
|
|
110
|
+
const check = (klass) => {
|
|
111
|
+
const found = commanderDecorator(context, klass)
|
|
112
|
+
if (!found) return
|
|
113
|
+
commands += 1
|
|
114
|
+
if (!isCliFeature(hfs, filename)) return context.report({ node: found.decorator, messageId: "outside", data: { kind: found.kind, slot: slot ?? "none" } })
|
|
115
|
+
if (place !== found.kind) return context.report({ node: found.decorator, messageId: "misplaced", data: { kind: found.kind, relative: relativeIn(hfs, filename) ?? filename } })
|
|
116
|
+
if (commands > 1) return context.report({ node: found.decorator, messageId: "second" })
|
|
117
|
+
if (found.kind === "SubCommand" && !(klass.superClass && isImportedFrom(context, klass.superClass, COMMANDER, "CommandRunner"))) context.report({ node: klass.id ?? klass, messageId: "runner", data: { name: klass.id?.name ?? "anonymous" } })
|
|
118
|
+
}
|
|
119
|
+
return { ClassDeclaration: check, ClassExpression: check }
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether a member expression is `process.argv` of the Node global (or `globalThis.process.argv`). */
|
|
124
|
+
const isProcessArgv = (context, node) => {
|
|
125
|
+
if (node.computed || node.property.type !== "Identifier" || node.property.name !== "argv") return false
|
|
126
|
+
const object = node.object
|
|
127
|
+
const isProcess = (expression) => {
|
|
128
|
+
if (expression.type !== "Identifier" || expression.name !== "process") return false
|
|
129
|
+
for (let scope = context.sourceCode.getScope(expression); scope; scope = scope.upper) {
|
|
130
|
+
const variable = scope.set.get("process")
|
|
131
|
+
if (variable) return variable.defs.length === 0
|
|
132
|
+
}
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
if (isProcess(object)) return true
|
|
136
|
+
return object.type === "MemberExpression" && !object.computed && object.object.type === "Identifier" && object.object.name === "globalThis" && object.property.type === "Identifier" && object.property.name === "process"
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** nest-commander belongs to the cli app and the cli feature root; nothing else parses arguments or opens an action door. */
|
|
140
|
+
export const cliOwner = {
|
|
141
|
+
meta: {
|
|
142
|
+
type: "problem",
|
|
143
|
+
docs: { description: "nest-commander is imported only by the cli app and the cli feature root; no back-end source reads `process.argv` or imports another argument parser." },
|
|
144
|
+
schema: [],
|
|
145
|
+
messages: {
|
|
146
|
+
commander: "`nest-commander` is imported outside the cli app and the cli feature root (this file is in slot `{{slot}}`). A one-off action is a command of `src/features/cli/` run by apps/cli; no other app or source opens an action door.",
|
|
147
|
+
parser: "`{{name}}` parses command-line arguments. The back end has one command line, apps/cli on nest-commander: write the action as a command of `src/features/cli/<group>/subs/<name>.cli.ts`.",
|
|
148
|
+
argv: "`process.argv` is read here. No back-end process parses its own arguments: a one-off action is a command of `src/features/cli/` that apps/cli runs through nest-commander.",
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
create(context) {
|
|
152
|
+
const hfs = hfsOf(context)
|
|
153
|
+
const filename = context.filename
|
|
154
|
+
const slot = hfs.slotOf(filename)
|
|
155
|
+
const owner = isCliApp(hfs, filename) || isCliFeature(hfs, filename)
|
|
156
|
+
return {
|
|
157
|
+
...moduleReferences(({ source, value }) => {
|
|
158
|
+
if (value === COMMANDER || value.startsWith(`${COMMANDER}/`)) {
|
|
159
|
+
if (!owner) context.report({ node: source, messageId: "commander", data: { slot: slot ?? "none" } })
|
|
160
|
+
} else if (FOREIGN_PARSERS.has(value)) context.report({ node: source, messageId: "parser", data: { name: value } })
|
|
161
|
+
}),
|
|
162
|
+
MemberExpression(node) {
|
|
163
|
+
if (isProcessArgv(context, node)) context.report({ node, messageId: "argv" })
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The rules this law contributes to the plugin. */
|
|
170
|
+
export const rules = {
|
|
171
|
+
"cli-bootstrap": cliBootstrap,
|
|
172
|
+
"cli-command-shape": cliCommandShape,
|
|
173
|
+
"cli-owner": cliOwner,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every rule is an error from the start: HFS keeps no baseline. */
|
|
177
|
+
export const recommended = {
|
|
178
|
+
"starci-be/cli-bootstrap": "error",
|
|
179
|
+
"starci-be/cli-command-shape": "error",
|
|
180
|
+
"starci-be/cli-owner": "error",
|
|
181
|
+
}
|
package/comments.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* constructor and an index signature are not surface, overload signatures of one name share one doc, and a property
|
|
11
11
|
* set to a literal or a named constant is a data constant (COMMENT-1's reason). Product source only: the test tiers
|
|
12
12
|
* (`e2e`, `fixtures` of the slot manifest) document their spec-read shapes at the type.
|
|
13
|
-
* - `require-enum-member-jsdoc`
|
|
13
|
+
* - `require-enum-member-jsdoc` checks an adjacent non-empty description, never whether it states a
|
|
14
14
|
* consequence. That half is read by a person, and the rule says so rather than pretending.
|
|
15
15
|
* - `no-non-ascii-source` takes no exemption marker: HFS removed `vn-ok`, so text a program depends on
|
|
16
16
|
* lives in a message catalog (slot be.domain.messages, of any module tier, or be.feature.messages), the only place Vietnamese may appear. Specs
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
27
27
|
import { normalizePath } from "./lib/path.mjs"
|
|
28
28
|
import { hasSecondLanguage } from "./runtime/scripts/lib/language.mjs"
|
|
29
|
+
import { jsdocBefore, jsdocDescription } from "./runtime/scripts/lib/jsdoc.mjs"
|
|
29
30
|
|
|
30
31
|
/** The slots of the per-owner message catalogs: the only source files that may hold Vietnamese. */
|
|
31
32
|
const CATALOG_SLOTS = new Set(["be.domain.messages", "be.feature.messages"])
|
|
@@ -65,7 +66,7 @@ const ORNAMENT = /[✅❌✔✖✗✘⭐⬆⬇➡⬅]/
|
|
|
65
66
|
const FIXTURE_SLOTS = new Set(["be.tests.fixtures.i18n"])
|
|
66
67
|
|
|
67
68
|
/** The Vietnamese language's own name, which is a label rather than prose. */
|
|
68
|
-
const ENDONYM = /
|
|
69
|
+
const ENDONYM = /Ti\u1ebfng Vi\u1ec7t/
|
|
69
70
|
|
|
70
71
|
/** Declarations whose surface other files depend on. */
|
|
71
72
|
const DOCUMENTED_KINDS = new Set([
|
|
@@ -85,11 +86,21 @@ const offenceIn = (line) => {
|
|
|
85
86
|
return null
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Whether an exported declaration has a surface a caller documents: a data constant is already described by its name,
|
|
91
|
+
* and a kind outside DOCUMENTED_KINDS has no surface at all.
|
|
92
|
+
*/
|
|
93
|
+
const hasDocumentedSurface = (declaration) => {
|
|
94
|
+
if (declaration.type === "VariableDeclaration") {
|
|
95
|
+
// a later declarator can expose a function even when the first binding is plain data
|
|
96
|
+
return declaration.declarations.some(({ init }) => Boolean(init)
|
|
97
|
+
&& (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression"))
|
|
98
|
+
}
|
|
99
|
+
return DOCUMENTED_KINDS.has(declaration.type)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether the adjacent JSDoc has a declaration description rather than only tags or a file header. */
|
|
103
|
+
const hasJsdocBefore = (sourceCode, node) => jsdocBefore(sourceCode, node) !== null
|
|
93
104
|
|
|
94
105
|
/** The declared name, for the message. */
|
|
95
106
|
const nameOf = (declaration) => {
|
|
@@ -108,7 +119,7 @@ export const requireExportJsdoc = {
|
|
|
108
119
|
schema: [],
|
|
109
120
|
messages: {
|
|
110
121
|
jsdoc:
|
|
111
|
-
"`{{name}}` is exported
|
|
122
|
+
"`{{name}}` is exported without an adjacent JSDoc description. This is surface other files depend on, and a name plus a signature says what it TAKES - never what it is for, or when to reach for it rather than the thing beside it.",
|
|
112
123
|
},
|
|
113
124
|
},
|
|
114
125
|
create(context) {
|
|
@@ -117,16 +128,7 @@ export const requireExportJsdoc = {
|
|
|
117
128
|
const declaration = node.declaration
|
|
118
129
|
// a re-export has nothing here to attach a doc to
|
|
119
130
|
if (!declaration) return
|
|
120
|
-
if (declaration
|
|
121
|
-
// only a const bound to a function has a surface; a data constant is already described
|
|
122
|
-
const first = declaration.declarations[0]
|
|
123
|
-
const init = first && first.init
|
|
124
|
-
const isFunction = init
|
|
125
|
-
&& (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
|
|
126
|
-
if (!isFunction) return
|
|
127
|
-
} else if (!DOCUMENTED_KINDS.has(declaration.type)) {
|
|
128
|
-
return
|
|
129
|
-
}
|
|
131
|
+
if (!hasDocumentedSurface(declaration)) return
|
|
130
132
|
if (hasJsdocBefore(sourceCode, node)) return
|
|
131
133
|
context.report({
|
|
132
134
|
node: declaration.id || declaration,
|
|
@@ -208,7 +210,7 @@ export const requirePublicMemberJsdoc = {
|
|
|
208
210
|
schema: [],
|
|
209
211
|
messages: {
|
|
210
212
|
jsdoc:
|
|
211
|
-
"`{{owner}}.{{name}}` is public surface
|
|
213
|
+
"`{{owner}}.{{name}}` is public surface without an adjacent JSDoc description. A caller reaches the member, not the type's doc: say what it is for, what calling or reading it causes, or what it holds.",
|
|
212
214
|
},
|
|
213
215
|
},
|
|
214
216
|
create(context) {
|
|
@@ -247,7 +249,7 @@ export const requireEnumMemberJsdoc = {
|
|
|
247
249
|
schema: [],
|
|
248
250
|
messages: {
|
|
249
251
|
jsdoc:
|
|
250
|
-
"Enum member `{{name}}` has no
|
|
252
|
+
"Enum member `{{name}}` has no adjacent JSDoc description. State what CHOOSING it causes, not what it is called - a member is picked at a call site far from the switch that gives it meaning. (A rule can only see that a doc exists; whether it states a consequence is read by a person.)",
|
|
251
253
|
},
|
|
252
254
|
},
|
|
253
255
|
create(context) {
|
|
@@ -328,7 +330,7 @@ const RESTATEMENT_FILLER = new Set(["a", "an", "the", "is", "of", "for", "to", "
|
|
|
328
330
|
|
|
329
331
|
/** The content words a doc comment contributes once filler is stripped out. */
|
|
330
332
|
const docContentWords = (comment) =>
|
|
331
|
-
(comment.
|
|
333
|
+
(jsdocDescription(comment).match(/[A-Za-z]+/g) || [])
|
|
332
334
|
.map((word) => word.toLowerCase())
|
|
333
335
|
.filter((word) => !RESTATEMENT_FILLER.has(word))
|
|
334
336
|
|
|
@@ -344,11 +346,6 @@ const isPureRestatement = (comment, declaredName) => {
|
|
|
344
346
|
return true
|
|
345
347
|
}
|
|
346
348
|
|
|
347
|
-
/** The doc block immediately before a node, or null. */
|
|
348
|
-
const jsdocBefore = (sourceCode, node) =>
|
|
349
|
-
sourceCode.getCommentsBefore(node).find((comment) => comment.type === "Block" && comment.value.startsWith("*"))
|
|
350
|
-
|| null
|
|
351
|
-
|
|
352
349
|
/** A doc block that only re-spells the declared name is COMMENT-3's violation wearing COMMENT-1's shape. */
|
|
353
350
|
export const noRestatedNameJsdoc = {
|
|
354
351
|
meta: {
|
|
@@ -369,16 +366,7 @@ export const noRestatedNameJsdoc = {
|
|
|
369
366
|
const declaration = node.declaration
|
|
370
367
|
// a re-export has nothing here to attach a doc to
|
|
371
368
|
if (!declaration) return
|
|
372
|
-
if (declaration
|
|
373
|
-
// only a const bound to a function has a surface; a data constant is already described
|
|
374
|
-
const first = declaration.declarations[0]
|
|
375
|
-
const init = first && first.init
|
|
376
|
-
const isFunction = init
|
|
377
|
-
&& (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
|
|
378
|
-
if (!isFunction) return
|
|
379
|
-
} else if (!DOCUMENTED_KINDS.has(declaration.type)) {
|
|
380
|
-
return
|
|
381
|
-
}
|
|
369
|
+
if (!hasDocumentedSurface(declaration)) return
|
|
382
370
|
const doc = jsdocBefore(sourceCode, node)
|
|
383
371
|
if (!doc) return
|
|
384
372
|
const name = nameOf(declaration)
|
package/config-owner.mjs
CHANGED
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
import ts from "typescript"
|
|
27
27
|
import { keyName, staticText, walk, wordsOf } from "./lib/ast.mjs"
|
|
28
28
|
import { isOwnedBy, isOwnedType, originsOf } from "./lib/declared.mjs"
|
|
29
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
29
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
30
30
|
import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
|
|
31
|
-
import { originsOfType, typed } from "./lib/types.mjs"
|
|
31
|
+
import { moduleOf, originsOfType, typed } from "./lib/types.mjs"
|
|
32
32
|
|
|
33
33
|
/** The test composition root (slot `be.tests.world`): it composes app options like `main.ts` and hands run state to jest workers through the environment. */
|
|
34
|
-
const isTestWorld =
|
|
34
|
+
const isTestWorld = inTestWorld
|
|
35
35
|
|
|
36
36
|
const PROCESS_MODULES = new Set(["process", "node:process"])
|
|
37
37
|
|
|
@@ -304,6 +304,22 @@ const isSecretValue = (context, node) => {
|
|
|
304
304
|
return isOwnedType(context, node, "Secret", "platform", "config") || (holder !== null && isOwnedType(context, holder, "Secret", "platform", "config"))
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/** The packages that declare the Node crypto types: a digest read out of a `Hash` or `Hmac` is secret-derived material. */
|
|
308
|
+
const CRYPTO_PACKAGES = new Set(["crypto", "node:crypto"])
|
|
309
|
+
|
|
310
|
+
/** A `.digest(...)` of a `Hash` or `Hmac` of Node's crypto, found by where the called signature is declared, never by the receiver's name. */
|
|
311
|
+
const isCryptoDigest = (context, node) => {
|
|
312
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return false
|
|
313
|
+
const { checker, toTs } = typed(context)
|
|
314
|
+
const tsCall = toTs(node)
|
|
315
|
+
const declaration = tsCall ? checker.getResolvedSignature(tsCall)?.declaration : undefined
|
|
316
|
+
if (!declaration || !declaration.name || declaration.name.getText() !== "digest") return false
|
|
317
|
+
const owner = declaration.parent
|
|
318
|
+
const ownerName = (ts.isInterfaceDeclaration(owner) || ts.isClassDeclaration(owner)) && owner.name ? owner.name.text : ""
|
|
319
|
+
if (ownerName !== "Hash" && ownerName !== "Hmac") return false
|
|
320
|
+
return CRYPTO_PACKAGES.has(moduleOf(declaration, String(declaration.getSourceFile().fileName).split("\\").join("/")))
|
|
321
|
+
}
|
|
322
|
+
|
|
307
323
|
/** A secret is compared with `timingSafeEqual`, never with an equality operator. */
|
|
308
324
|
export const secretCompareTimingSafe = {
|
|
309
325
|
meta: {
|
|
@@ -322,7 +338,7 @@ export const secretCompareTimingSafe = {
|
|
|
322
338
|
if (!["===", "!==", "==", "!="].includes(node.operator)) return
|
|
323
339
|
if (isPresenceOperand(node.left) || isPresenceOperand(node.right)) return
|
|
324
340
|
for (const side of [node.left, node.right]) {
|
|
325
|
-
if (isSecretValue(context, side)) {
|
|
341
|
+
if (isSecretValue(context, side) || isCryptoDigest(context, side)) {
|
|
326
342
|
context.report({ node, messageId: "compare", data: { name: context.sourceCode.getText(side), operator: node.operator } })
|
|
327
343
|
return
|
|
328
344
|
}
|