@starci/eslint-canon-be 3.0.7 → 3.0.9
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/README.md +13 -7
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +18 -21
- 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 +3 -1
- 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/hfs.mjs +3 -3
- 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 -3
- package/project-graph.mjs +8 -5
- package/projections.mjs +117 -0
- package/query-safety.mjs +1 -1
- package/realtime.mjs +181 -0
- package/runtime/engine/config.mjs +494 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/engine/runtime-root.mjs +5 -0
- package/runtime/engine/yaml.mjs +3 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +915 -123
- package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
- package/runtime/knowledge/sonar-gate.yaml +53 -19
- package/runtime/modules/models/registry.yaml +591 -0
- package/runtime/modules/models/runtimes.yaml +701 -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 +17 -27
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
- package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
- package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
- 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/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
- package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
- package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
- package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
- package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
- package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
- package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
- package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
- package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
- package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
- package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
- package/runtime/scripts/{checks → 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/{checks → hfs}/architecture/source-names.mjs +27 -147
- package/runtime/scripts/{checks → 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/{checks → hfs}/architecture/surface.mjs +10 -4
- package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
- package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
- package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
- 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 +247 -0
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
- package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
- package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
- package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
- package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
- package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
- 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/json.mjs +13 -3
- package/runtime/scripts/lib/language.mjs +26 -7
- package/runtime/scripts/lib/line-count.mjs +9 -0
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +59 -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 +58 -0
- package/runtime/scripts/lib/stack-services.mjs +7 -31
- package/runtime/scripts/lib/ts-ast.mjs +35 -0
- package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +3 -2
- 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/recorded-lines.mjs +0 -27
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
- /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
4
|
+
import { words } from './dockerfile.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
|
|
@@ -14,11 +15,11 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
14
15
|
* service and is returned as `proxy`.
|
|
15
16
|
*/
|
|
16
17
|
export const STACKS_DIRECTORY = '.starcistacks';
|
|
17
|
-
|
|
18
|
+
const STACKS_FILE = 'application-stacks.yaml';
|
|
18
19
|
export const DEFAULT_ENVIRONMENT = 'dev';
|
|
19
20
|
|
|
20
21
|
/** Test-only value the `*_FILE` secret variables of a compose service become (the file variable is replaced by the plain variable of the same name). Never a real secret: the test stack listens on loopback only. */
|
|
21
|
-
|
|
22
|
+
const TEST_SECRET_VALUE = 'starci-test-secret';
|
|
22
23
|
|
|
23
24
|
const NOT_RUN_ROLES = new Set(['service', 'observability']);
|
|
24
25
|
const NOT_RUN_PROFILES = new Set(['app']);
|
|
@@ -44,8 +45,6 @@ const KNOWN_IMAGES = Object.freeze({
|
|
|
44
45
|
clickhouse: { kind: 'database', aliases: ['clickhouse', 'database', 'db'], probe: { tcp: true } },
|
|
45
46
|
kafka: { kind: 'queue', aliases: ['kafka', 'queue', 'broker'], probe: { tcp: true } },
|
|
46
47
|
redpanda: { kind: 'queue', aliases: ['redpanda', 'kafka', 'queue', 'broker'], probe: { tcp: true } },
|
|
47
|
-
rabbitmq: { kind: 'queue', aliases: ['rabbitmq', 'amqp', 'queue', 'broker'], probe: { tcp: true } },
|
|
48
|
-
nats: { kind: 'queue', aliases: ['nats', 'queue', 'broker'], probe: { tcp: true } },
|
|
49
48
|
elasticsearch: { kind: 'search', aliases: ['elasticsearch', 'elastic', 'search'], probe: { tcp: true } },
|
|
50
49
|
opensearch: { kind: 'search', aliases: ['opensearch', 'search'], probe: { tcp: true } },
|
|
51
50
|
});
|
|
@@ -58,14 +57,14 @@ const KNOWN_IMAGES = Object.freeze({
|
|
|
58
57
|
export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
|
|
59
58
|
|
|
60
59
|
/** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
|
|
61
|
-
|
|
60
|
+
const repositoryOf = image => {
|
|
62
61
|
const withoutDigest = image.split('@')[0];
|
|
63
62
|
const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
|
|
64
63
|
return lastSegment.split(':')[0];
|
|
65
64
|
};
|
|
66
65
|
|
|
67
66
|
/** The repository path of an image reference without its tag: `quay.io/keycloak/keycloak:26.0` -> `quay.io/keycloak/keycloak`. */
|
|
68
|
-
|
|
67
|
+
const imagePathOf = image => {
|
|
69
68
|
const withoutDigest = image.split('@')[0];
|
|
70
69
|
const lastSlash = withoutDigest.lastIndexOf('/');
|
|
71
70
|
const colon = withoutDigest.indexOf(':', lastSlash + 1);
|
|
@@ -73,30 +72,7 @@ export const imagePathOf = image => {
|
|
|
73
72
|
};
|
|
74
73
|
|
|
75
74
|
/** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/** The shell-like words of a compose `command` string, double and single quotes grouping a word. */
|
|
79
|
-
const words = text => {
|
|
80
|
-
const out = [];
|
|
81
|
-
let current = '';
|
|
82
|
-
let quote = null;
|
|
83
|
-
let started = false;
|
|
84
|
-
for (const char of text) {
|
|
85
|
-
if (quote) {
|
|
86
|
-
if (char === quote) quote = null;
|
|
87
|
-
else current += char;
|
|
88
|
-
} else if (char === '"' || char === "'") {
|
|
89
|
-
quote = char;
|
|
90
|
-
started = true;
|
|
91
|
-
} else if (/\s/u.test(char)) {
|
|
92
|
-
if (started || current) out.push(current);
|
|
93
|
-
current = '';
|
|
94
|
-
started = false;
|
|
95
|
-
} else current += char;
|
|
96
|
-
}
|
|
97
|
-
if (started || current) out.push(current);
|
|
98
|
-
return out;
|
|
99
|
-
};
|
|
75
|
+
const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
|
|
100
76
|
|
|
101
77
|
const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
|
|
102
78
|
|
|
@@ -129,7 +105,7 @@ const mountsOf = (value, directory) => {
|
|
|
129
105
|
continue;
|
|
130
106
|
}
|
|
131
107
|
const parts = String(entry).split(':');
|
|
132
|
-
// A Windows drive letter is not a separator:
|
|
108
|
+
// A Windows drive letter is not a separator: a `<drive>:<path>:/y` mount keeps its drive.
|
|
133
109
|
const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
|
|
134
110
|
const target = parts.shift();
|
|
135
111
|
if (!source || !target || !(source.startsWith('.') || path.isAbsolute(source))) continue;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ts-ast.mjs — the small reads the hfs checks share over TypeScript's own AST: a node's name or
|
|
2
|
+
// literal text, a node's 1-based position, a named property's initializer text. `ts` is the caller's
|
|
3
|
+
// already-resolved compiler (see package-at.mjs loadTypescript); nothing here resolves a package.
|
|
4
|
+
|
|
5
|
+
/** The text of an identifier or a string-literal-like `node`, or null (property names, literal specifiers). */
|
|
6
|
+
export const nameText = (ts, node) => (node && (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) ? node.text : null);
|
|
7
|
+
|
|
8
|
+
/** The text of a string-literal-like `node` (a string literal or a no-substitution template), or null. */
|
|
9
|
+
export const literalText = (ts, node) => (node && ts.isStringLiteralLike(node) ? node.text : null);
|
|
10
|
+
|
|
11
|
+
/** The 1-based {line, column} of `node` in `sourceFile`. */
|
|
12
|
+
export const sourceLocation = (sourceFile, node) => {
|
|
13
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
14
|
+
return { line: position.line + 1, column: position.character + 1 };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The `.text` of `key`'s property initializer in `text` (parsed as `file`): a property assignment
|
|
19
|
+
* inside an object literal, or — `declaration: true` — a class property declaration. `kind` is
|
|
20
|
+
* 'string' (a string-literal-like initializer) or 'identifier'. The last match wins; null when none.
|
|
21
|
+
*/
|
|
22
|
+
export function propertyText(ts, text, { file = 'source.ts', key, declaration = false, kind = 'string' } = {}) {
|
|
23
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
24
|
+
let value = null;
|
|
25
|
+
const visit = (node) => {
|
|
26
|
+
const isProp = declaration ? ts.isPropertyDeclaration(node) : ts.isPropertyAssignment(node);
|
|
27
|
+
if (isProp && node.name && ts.isIdentifier(node.name) && node.name.text === key && node.initializer) {
|
|
28
|
+
const ok = kind === 'identifier' ? ts.isIdentifier(node.initializer) : ts.isStringLiteralLike(node.initializer);
|
|
29
|
+
if (ok) value = node.initializer.text;
|
|
30
|
+
}
|
|
31
|
+
ts.forEachChild(node, visit);
|
|
32
|
+
};
|
|
33
|
+
visit(source);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
@@ -1,23 +1,13 @@
|
|
|
1
|
+
// walk.mjs - filesystem walking helpers: isInside (containment) and walkFiles (depth-first file listing).
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import {fileURLToPath} from 'node:url';
|
|
4
4
|
|
|
5
|
-
/** Whether `target` is `root` itself or inside it (filesystem paths, either separator). */
|
|
6
|
-
export function isInside(root, target) {
|
|
5
|
+
/** Whether `target` is `root` itself or inside it (filesystem paths, either separator). `includeSelf: false` excludes `target === root`. */
|
|
6
|
+
export function isInside(root, target, { includeSelf = true } = {}) {
|
|
7
7
|
const relative = path.relative(root, target);
|
|
8
|
-
|
|
8
|
+
if (relative === '') return includeSelf;
|
|
9
|
+
return !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
/** True when this module is the process entry point (`node file.mjs`, not an import). */
|
|
12
|
-
/** The real path of a file, or its resolved path when it cannot be read (a missing file is never the entry module). */
|
|
13
|
-
const realPathOf = (file) => { try { return fs.realpathSync.native(path.resolve(file)); } catch { return path.resolve(file); } };
|
|
14
|
-
/**
|
|
15
|
-
* True when the module at `metaUrl` is the process entry point. Both sides are compared as REAL paths: Node gives a module
|
|
16
|
-
* its symlink-resolved URL, while argv[1] keeps the path it was started with, so an entry reached through a node_modules
|
|
17
|
-
* junction or symlink (a lane worktree, an npx shim) would otherwise look like an import and silently do nothing.
|
|
18
|
-
*/
|
|
19
|
-
export const isMain = (metaUrl, argv = process.argv) => Boolean(argv[1]) && realPathOf(argv[1]) === realPathOf(fileURLToPath(metaUrl));
|
|
20
|
-
|
|
21
11
|
/**
|
|
22
12
|
* Every entry under `dir` that is not a directory, depth-first; `filter` sees the entry name and full
|
|
23
13
|
* path. `sorted` sorts each directory's entries by name (default: readdir order). `exclude`
|
package/schema-authority.mjs
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* `@nestjs/typeorm` options type) with no literal `synchronize: false`; a `synchronize` that is anything but
|
|
9
9
|
* the literal `false`; `new DataSource(<not an object literal>)` whose options cannot be read; a
|
|
10
10
|
* `dataSource.synchronize()` or `.dropDatabase()` call; the `migrationsRun` option in any form and a `runMigrations()` /
|
|
11
|
-
* `undoLastMigration()` call outside
|
|
11
|
+
* `undoLastMigration()` call outside the cli (apps/cli and src/features/cli) and the test world; `dropSchema` set to anything but `false`; a schema
|
|
12
12
|
* builder (`createSchemaBuilder()`, `.build()` of typeorm's `SchemaBuilder`); a `synchronize` key in the options of typeorm's
|
|
13
13
|
* `@Entity` (a per-entity switch is a second authority); a lifecycle hook (`onModuleInit`, `onApplicationBootstrap`) that
|
|
14
|
-
* writes rows through an `EntityManager` or `DataSource` (seeding
|
|
15
|
-
* outside the migrations of `persistence/`; and an entity or migration glob. Only
|
|
14
|
+
* writes rows through an `EntityManager` or `DataSource` (seeding is a cli command); a DDL statement in any string
|
|
15
|
+
* outside the migrations of `persistence/`; and an entity or migration glob. Only the cli (its migrate and seed commands) runs migrations and seeds.
|
|
16
16
|
* - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a boundary:
|
|
17
17
|
* any type whose declaration carries `@Entity` (from `typeorm`), reached through the type arguments and properties of
|
|
18
18
|
* a transport signature, the `execute` of a handler, or a `*.contracts.ts`, `*.command.ts` or `*.query.ts` file; and
|
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
* never of a path pattern.
|
|
23
23
|
*/
|
|
24
24
|
import ts from "typescript"
|
|
25
|
+
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
25
26
|
import { keyName, walk } from "./lib/ast.mjs"
|
|
26
27
|
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
27
28
|
import { baseName, isMigrationFile, packageOfFile } from "./lib/ports.mjs"
|
|
29
|
+
import { inCli } from "./lib/persistence.mjs"
|
|
28
30
|
import { isPackageType, typed } from "./lib/types.mjs"
|
|
29
31
|
import { isDeclarationFile } from "./lib/path.mjs"
|
|
30
32
|
|
|
@@ -37,7 +39,7 @@ const OPTION_PACKAGES = new Set(["typeorm", "@nestjs/typeorm"])
|
|
|
37
39
|
/** The DataSource operations that change the schema at runtime. */
|
|
38
40
|
const SCHEMA_OPERATIONS = new Set(["synchronize", "dropDatabase"])
|
|
39
41
|
|
|
40
|
-
/** The DataSource operations that run migrations: allowed only in
|
|
42
|
+
/** The DataSource operations that run migrations: allowed only in the cli (apps/cli, src/features/cli) and the test world. */
|
|
41
43
|
const MIGRATION_RUNNERS = new Set(["runMigrations", "undoLastMigration"])
|
|
42
44
|
|
|
43
45
|
/** The typeorm types whose methods write rows. */
|
|
@@ -99,8 +101,8 @@ const isOperationsObject = (context, node) => {
|
|
|
99
101
|
return members.length > 0 && members.every(isMethod)
|
|
100
102
|
}
|
|
101
103
|
|
|
102
|
-
/** Whether a file is
|
|
103
|
-
const isMigrationHost = (hfs, filename) => hfs
|
|
104
|
+
/** Whether a file is the cli (the cli app or the cli feature root, whose migrate and seed commands run them) or the test world: the only places that run migrations and seed. */
|
|
105
|
+
const isMigrationHost = (hfs, filename) => inCli(hfs, filename) || inTestWorld(hfs, filename)
|
|
104
106
|
|
|
105
107
|
/** The SQL text of a call argument: a literal, a template, or the quasis of a tagged template. */
|
|
106
108
|
const sqlTextOf = (argument) => (argument ? stringsUnder(argument).join(" ") : "")
|
|
@@ -133,7 +135,7 @@ const bootWriteOf = (context, method, klass, visited) => {
|
|
|
133
135
|
/** Whether an object literal has a property called `name`. */
|
|
134
136
|
const hasKey = (objectNode, name) => objectNode.properties.some((property) => property.type === "Property" && !property.computed && keyName(property.key) === name)
|
|
135
137
|
|
|
136
|
-
/** The schema is decided by migrations run by
|
|
138
|
+
/** The schema is decided by migrations run by the cli migrate command, never by the running process. */
|
|
137
139
|
export const noRuntimeSchema = {
|
|
138
140
|
meta: {
|
|
139
141
|
type: "problem",
|
|
@@ -143,13 +145,13 @@ export const noRuntimeSchema = {
|
|
|
143
145
|
synchronize: "`synchronize` is set to something other than the literal `false`. The schema changes by migration only; a boot-time ORM diff decides it otherwise.",
|
|
144
146
|
synchronizeMissing: "This DataSource options object does not state `synchronize: false`. Write the literal `false` in every options object, so what the connection may do to the schema is readable where it is built.",
|
|
145
147
|
optionsNotLiteral: "`new DataSource(...)` is given options that are not an object literal, so `synchronize: false` cannot be seen. Pass the options as an object literal that states `synchronize: false`.",
|
|
146
|
-
synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let
|
|
147
|
-
migrationsRun: "`migrationsRun` is banned. Only `
|
|
148
|
-
runMigrations: "`.{{name}}()` runs migrations outside
|
|
148
|
+
synchronizeCall: "`.{{name}}()` changes the schema at runtime. Write a migration and let the cli migrate command run it.",
|
|
149
|
+
migrationsRun: "`migrationsRun` is banned. Only the cli migrate command (`cli migrate run`) runs migrations, once per connection, before api and worker start.",
|
|
150
|
+
runMigrations: "`.{{name}}()` runs migrations outside the cli. Only the cli migrate command (and the test world, over the same connections) runs them, once per connection, before api and worker start.",
|
|
149
151
|
dropSchema: "`dropSchema` is set to something other than the literal `false`. A connection never drops the schema it opens; a migration changes it.",
|
|
150
152
|
schemaBuilder: "`{{name}}` builds or logs the schema from the entity metadata at runtime. The schema changes by migration only.",
|
|
151
153
|
entitySynchronize: "`synchronize` is set in the options of `@Entity`. A per-entity switch is a second schema authority next to the connection's literal `false`; delete the key.",
|
|
152
|
-
bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding
|
|
154
|
+
bootSeed: "`{{hook}}` writes rows (`{{call}}`), so every process that composes this provider seeds at boot. Seeding is a cli command, run once per connection.",
|
|
153
155
|
ddl: "A schema-changing statement (`CREATE|ALTER|DROP ...`) outside `persistence/migrations/`. Schema-changing SQL belongs in a migration.",
|
|
154
156
|
glob: "`{{key}}` is found by glob. List the entities and migrations explicitly from each capability's `index.ts` so what runs is what was reviewed.",
|
|
155
157
|
},
|
|
@@ -269,7 +271,7 @@ const PARAMETER_HOLDERS = new Set(["Identifier", "ObjectPattern", "ArrayPattern"
|
|
|
269
271
|
const boundaryOf = (hfs, filename) => {
|
|
270
272
|
const slot = hfs.slotOf(filename) ?? ""
|
|
271
273
|
const name = baseName(filename)
|
|
272
|
-
if (
|
|
274
|
+
if (isTransportSlot(slot)) return "file"
|
|
273
275
|
if (/\.(?:contracts|command|query)\.ts$/.test(name)) return "file"
|
|
274
276
|
if (name.endsWith(".handler.ts")) return "execute"
|
|
275
277
|
return null
|
package/service-deps.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* token: it is either a broken provider or a raw string/number injection.
|
|
12
12
|
* - `no-repository-class` (R83): a class that receives or returns an `EntityManager`, a typeorm `Repository`, a
|
|
13
13
|
* `DataSource` or a `QueryRunner` in any member lives only where the manager may be held (an application handler,
|
|
14
|
-
* a domain service, a platform persistence capability), in the
|
|
14
|
+
* a domain service, a platform persistence capability), in the cli app, in the test world or in a migration.
|
|
15
15
|
* A per-entity persistence class anywhere else is a repository under another name.
|
|
16
16
|
* - `no-test-double-in-source` (R89): a class, function or constant of production source is not named as a test
|
|
17
17
|
* double (`Mock`, `Fake`, `Stub` as a PascalCase or camelCase word). A double lives in a spec or `src/tests`;
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* slot view. A name is read only for the fixed vocabulary of the double markers, as whole words.
|
|
22
22
|
*/
|
|
23
23
|
import ts from "typescript"
|
|
24
|
-
import {
|
|
24
|
+
import { paramParts } from "./lib/ast.mjs"
|
|
25
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
25
26
|
import { isSpecFile, normalizePath } from "./lib/path.mjs"
|
|
26
|
-
import { implementsMigration,
|
|
27
|
+
import { implementsMigration, inCli, isMigrationFile, mayHoldEntityManager } from "./lib/persistence.mjs"
|
|
27
28
|
import { typed, typeOrigins } from "./lib/types.mjs"
|
|
28
29
|
|
|
29
30
|
// -- provider-param-token --------------------------------------------------------------------------
|
|
@@ -31,13 +32,6 @@ import { typed, typeOrigins } from "./lib/types.mjs"
|
|
|
31
32
|
/** The convention's injector vocabulary: `Inject` or `Inject<Thing>`. */
|
|
32
33
|
const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/
|
|
33
34
|
|
|
34
|
-
/** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
|
|
35
|
-
const paramParts = (param) => {
|
|
36
|
-
const inner = param.type === "TSParameterProperty" ? param.parameter : param
|
|
37
|
-
const target = inner.type === "AssignmentPattern" ? inner.left : inner
|
|
38
|
-
return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
/** True when a decorator expression is a call of an `Inject*` function, or of anything typed `TypedParameterDecorator<T>`. */
|
|
42
36
|
const isInjector = (context, decorator) => {
|
|
43
37
|
const expression = decorator.expression
|
|
@@ -140,11 +134,11 @@ const persistenceMember = (context, classNode) => {
|
|
|
140
134
|
export const noRepositoryClass = {
|
|
141
135
|
meta: {
|
|
142
136
|
type: "problem",
|
|
143
|
-
docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the
|
|
137
|
+
docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the cli app, the test world or a migration." },
|
|
144
138
|
schema: [],
|
|
145
139
|
messages: {
|
|
146
140
|
statements:
|
|
147
|
-
"`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, `apps/
|
|
141
|
+
"`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, the cli (`apps/cli`, `src/features/cli`) and `src/tests` take a manager in a free function.",
|
|
148
142
|
repository:
|
|
149
143
|
"`{{name}}` takes or holds a `{{type}}` in `{{member}}` but is not an application handler, a domain service or a platform persistence capability. A class wrapping the manager per entity is a repository whatever it is called: it hides which connection and transaction a call runs in and cannot be unit-tested with a token double. Move the queries into the handler or the domain service that owns the use, and call the injected EntityManager directly.",
|
|
150
144
|
},
|
|
@@ -153,7 +147,7 @@ export const noRepositoryClass = {
|
|
|
153
147
|
const hfs = hfsOf(context)
|
|
154
148
|
const filename = context.filename || context.getFilename()
|
|
155
149
|
const file = normalizePath(filename)
|
|
156
|
-
if (mayHoldEntityManager(hfs, file) ||
|
|
150
|
+
if (mayHoldEntityManager(hfs, file) || inCli(hfs, file) || inTestWorld(hfs, file) || isMigrationFile(hfs, file) || (hfs.slotOf(file) ?? "").startsWith("be.tests.")) return {}
|
|
157
151
|
const check = (node) => {
|
|
158
152
|
if (implementsMigration(context, node)) return
|
|
159
153
|
const found = persistenceMember(context, node)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that keeps the services of one product apart (catalog R168 `BE_SERVICE_ISOLATION`).
|
|
3
|
+
*
|
|
4
|
+
* A back-end service is a Nest app at `be/apps/<service>/` of the one repository; services share the `be/src` libraries and talk
|
|
5
|
+
* to each other only through the wire (a client of the sibling's API, or the events of its vendored contract under
|
|
6
|
+
* `be/contracts/<service>/`). One service importing a file of another service's `apps/<other>/` folder reaches into a sibling's
|
|
7
|
+
* composition and options, which makes the two one deployable. The check asks the HFS slot view which app owns a file (the owner of
|
|
8
|
+
* an `apps/<app>/src/` slot) for both the importing file and the file the specifier resolves to, through a relative path or the
|
|
9
|
+
* program's own `compilerOptions.paths`; it never matches a path by name.
|
|
10
|
+
*/
|
|
11
|
+
import { posix } from "node:path"
|
|
12
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
13
|
+
import { resolveAlias } from "./lib/specifier.mjs"
|
|
14
|
+
|
|
15
|
+
/** The app slots: the slot ids of a service app's `apps/<app>/src/` folder. */
|
|
16
|
+
const isAppSlot = (slotId) => typeof slotId === "string" && slotId.startsWith("be.app.")
|
|
17
|
+
|
|
18
|
+
const isRelative = (specifier) => specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../")
|
|
19
|
+
|
|
20
|
+
/** Calls `check(node, specifier)` for every import and re-export that names a module. */
|
|
21
|
+
const onSpecifiers = (check) => ({
|
|
22
|
+
ImportDeclaration(node) {
|
|
23
|
+
check(node.source, node.source.value)
|
|
24
|
+
},
|
|
25
|
+
ExportNamedDeclaration(node) {
|
|
26
|
+
if (node.source) check(node.source, node.source.value)
|
|
27
|
+
},
|
|
28
|
+
ExportAllDeclaration(node) {
|
|
29
|
+
if (node.source) check(node.source, node.source.value)
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
/** A file of one service app imports no file of a sibling service app. */
|
|
34
|
+
export const serviceIsolation = {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: { description: "A service app (`apps/<service>/`) imports no file of a sibling service app; services share only the `src` libraries and meet through the wire." },
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
sibling:
|
|
41
|
+
"`{{specifier}}` reaches into the service app `{{other}}` from `{{self}}`. Services share only the `src` libraries; call the sibling through its API client or consume its events through the vendored contract (`be/contracts/{{other}}/`).",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
create(context) {
|
|
45
|
+
const filename = context.filename || context.getFilename()
|
|
46
|
+
const hfs = hfsOf(context)
|
|
47
|
+
if (!isAppSlot(hfs.slotOf(filename))) return {}
|
|
48
|
+
const self = hfs.ownerOf(filename)
|
|
49
|
+
if (!self) return {}
|
|
50
|
+
return onSpecifiers((node, specifier) => {
|
|
51
|
+
if (typeof specifier !== "string") return
|
|
52
|
+
const target = isRelative(specifier)
|
|
53
|
+
? posix.normalize(`${posix.dirname(filename.replaceAll("\\", "/"))}/${specifier}`)
|
|
54
|
+
: resolveAlias(context, specifier)?.target
|
|
55
|
+
if (!target) return
|
|
56
|
+
const reached = [`${target}.ts`, `${target}/index.ts`].find((candidate) => isAppSlot(hfs.slotOf(candidate)))
|
|
57
|
+
if (!reached) return
|
|
58
|
+
const other = hfs.ownerOf(reached)
|
|
59
|
+
if (!other || other === self) return
|
|
60
|
+
context.report({ node, messageId: "sibling", data: { specifier, self: posix.basename(posix.dirname(self)), other: posix.basename(posix.dirname(other)) } })
|
|
61
|
+
})
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const rules = {
|
|
66
|
+
"service-isolation": serviceIsolation,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The level this law asks for: `error`, switched off nowhere. */
|
|
70
|
+
export const recommended = {
|
|
71
|
+
"starci-be/service-isolation": "error",
|
|
72
|
+
}
|
package/size-budget.mjs
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
* baseline is git's.
|
|
10
10
|
*
|
|
11
11
|
* There is no soft-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing the
|
|
12
|
-
* files over the soft budget (`fileLines.soft`) is a report item of the
|
|
12
|
+
* files over the soft budget (`fileLines.soft`) is a report item of the starci app check, never a block.
|
|
13
13
|
* There is no baseline file and no allowlist: the record is the repository's own history. A spec, a fixture and an e2e
|
|
14
14
|
* file are source files like any other. A migration is exempt (a file of the `be.persistence` slot named
|
|
15
15
|
* `<timestamp>-<name>.ts`: append-only, never edited), and so is a declaration file.
|
|
16
16
|
*/
|
|
17
17
|
import { basename } from "node:path"
|
|
18
18
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
19
|
-
import {
|
|
19
|
+
import { recordedLines } from "./runtime/scripts/api/git/recorded-lines.mjs"
|
|
20
|
+
import { hardGrowthLines, lineCount } from "./runtime/scripts/lib/line-count.mjs"
|
|
20
21
|
|
|
21
22
|
/** A migration is a file of the persistence slot named `<timestamp>-<name>.ts`. */
|
|
22
23
|
const isMigration = (hfs, filename) => hfs.slotOf(filename) === "be.persistence" && /^\d{13,14}-[^/]+\.[cm]?ts$/.test(basename(filename))
|
package/test-world.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Unit specs sit beside their subject. Everything else lives under `src/tests/`, one folder per kind:
|
|
5
5
|
* - `world/` the ONLY test infrastructure (slot `be.tests.world`): `global-setup.ts` starts (or attaches to) the stack
|
|
6
6
|
* the repository declares in `.starcistacks/<env>` (the test-world library) - every service of it runs
|
|
7
|
-
* REAL behind toxiproxy - and runs
|
|
7
|
+
* REAL behind toxiproxy - and runs the migrations once over the cli app's connections; `use-test-world.ts` exports
|
|
8
8
|
* `useTestWorld({ apps } | { modules })` -> `world.apps.<name>.api`, `world.db.<connection>`,
|
|
9
9
|
* `world.infra.<service>` (`latency(ms)`, `cut()`, `restore()` on the real service),
|
|
10
10
|
* `world.fake.<provider>` (network-edge fakes of external SaaS with failNext/replayWebhook/delay),
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Receivers and origins are judged by type and by the slot of their declaring file, never by name.
|
|
19
19
|
*/
|
|
20
20
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
21
|
+
import { inCli } from "./lib/persistence.mjs"
|
|
21
22
|
import { isPackageType, typeOrigins } from "./lib/types.mjs"
|
|
22
23
|
|
|
23
24
|
/** Every test slot under `src/tests/` except the world, where the infrastructure lives. */
|
|
@@ -36,7 +37,7 @@ const isEnvMember = (node) => node?.type === "MemberExpression" && isProcessEnv(
|
|
|
36
37
|
/**
|
|
37
38
|
* The world is the only infrastructure location. Refused in fixtures, integration, e2e and contract specs: importing a
|
|
38
39
|
* migration (a class declared in a `be.persistence` `migrations/` file, or a value whose type is an array of them) or
|
|
39
|
-
* anything the
|
|
40
|
+
* anything the cli app declares; importing typeorm's `DataSource` or a testcontainers package; calling
|
|
40
41
|
* `runMigrations`/`synchronize`/`dropDatabase`/`createSchema`... on a typeorm receiver; constructing a container; writing
|
|
41
42
|
* `process.env`. Migration behaviour is tested by the e2e world.
|
|
42
43
|
*/
|
|
@@ -46,7 +47,7 @@ export const testsInfraOnlyInWorld = {
|
|
|
46
47
|
docs: { description: "Only src/tests/world starts infrastructure, migrates, holds a DataSource or writes process.env." },
|
|
47
48
|
schema: [],
|
|
48
49
|
messages: {
|
|
49
|
-
migration: "This test imports a migration or the
|
|
50
|
+
migration: "This test imports a migration or the cli. The schema is prepared once by `src/tests/world/global-setup.ts`, which runs the migrations over the cli app's connections; migration behaviour is tested through the e2e world.",
|
|
50
51
|
call: "`{{name}}` builds or changes the schema outside `src/tests/world`. The world migrates once; a test uses `world.db.<connection>`.",
|
|
51
52
|
infra: "This test imports or starts infrastructure (testcontainers, typeorm's DataSource). It belongs to `src/tests/world`; a test uses `useTestWorld(...)` and `world.db.<connection>`.",
|
|
52
53
|
env: "This test writes `process.env`. The environment is set once by `src/tests/world`; a test takes the world as it is.",
|
|
@@ -57,7 +58,7 @@ export const testsInfraOnlyInWorld = {
|
|
|
57
58
|
const filename = context.filename || context.getFilename()
|
|
58
59
|
if (!OUTSIDE_WORLD.has(hfs.slotOf(filename))) return {}
|
|
59
60
|
const isMigrationDecl = (file) => hfs.slotOf(file) === "be.persistence" && /\/migrations\/[^/]+$/.test(hfs.relative(file))
|
|
60
|
-
const fromMigrate = (file) => hfs
|
|
61
|
+
const fromMigrate = (file) => inCli(hfs, file)
|
|
61
62
|
const migrationTyped = (node) => {
|
|
62
63
|
const services = context.sourceCode.parserServices
|
|
63
64
|
const checker = services.program.getTypeChecker()
|
package/testing.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold `
|
|
2
|
+
* The rules that hold `knowledge/patterns/be/test.yaml` (catalog R47 `BE_TEST_TOPOLOGY` and R48 `BE_SPEC_QUALITY`).
|
|
3
3
|
*
|
|
4
4
|
* MOST OF THAT LAW IS NOT MACHINE-CHECKABLE, and pretending otherwise would be worse than checking nothing. No rule
|
|
5
5
|
* can tell whether a file represents a business flow, whether the unhappy path it covers drags a critical flow
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* - a spec whose every assertion is about a call rather than a result (`no-call-only-spec`);
|
|
10
10
|
* - a unit spec of anything but a service, a service with no `<name>.service.spec.ts` beside it, a service spec with no
|
|
11
|
-
* service beside it, and a file of a kind the convention bans (`.test.ts`,
|
|
11
|
+
* service beside it, and a file of a kind the convention bans (`.test.ts`, `.int-spec.ts`, `.harness-spec.ts`)
|
|
12
12
|
* (`unit-test-colocated`);
|
|
13
13
|
* - an e2e that never reads persisted state back, and an e2e that reaches a model provider
|
|
14
14
|
* (`e2e-asserts-persisted-state`, `no-model-call-in-e2e`);
|
|
@@ -24,17 +24,17 @@
|
|
|
24
24
|
|
|
25
25
|
import { statSync } from "node:fs"
|
|
26
26
|
import { basename, dirname, join } from "node:path"
|
|
27
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
27
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
28
28
|
import { isPackageType, typeOrigins } from "./lib/types.mjs"
|
|
29
|
-
import {
|
|
29
|
+
import { doorOfSpec, isUnitSpecFile, unitRoleOfSpec, unitRoleOfSubject } from "./lib/unit-spec.mjs"
|
|
30
30
|
|
|
31
31
|
/** The file name of a linted path, in forward-slash form. */
|
|
32
32
|
const baseOf = (filename) => basename(String(filename || "").replace(/\\/g, "/"))
|
|
33
33
|
|
|
34
|
-
/** Test kind
|
|
34
|
+
/** Test kind `spec`: a unit spec, `<name>.service.spec.ts` beside its service. */
|
|
35
35
|
const isUnitSpec = (filename) => /\.spec\.ts$/.test(baseOf(filename))
|
|
36
36
|
|
|
37
|
-
/** Test kind
|
|
37
|
+
/** Test kind `e2e-spec`: `*.e2e-spec.ts` under `src/tests/e2e/` (`integration-spec` and `contract-spec` are the other two kinds of the tests slots). */
|
|
38
38
|
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(baseOf(filename))
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -121,23 +121,23 @@ const exists = (path) => {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/** Kinds of test file the convention bans: `.test.ts`, `*.int-spec.ts`, `*.harness-spec.ts` (any spec kind other than `spec` and `
|
|
124
|
+
/** Kinds of test file the convention bans: `.test.ts`, `*.int-spec.ts`, `*.harness-spec.ts` (any spec kind other than `spec`, `e2e-spec`, `integration-spec` and `contract-spec`). */
|
|
125
125
|
const BANNED_TEST_FILE = /(?:\.test|\.int-spec|\.harness-spec)\.[cm]?[jt]sx?$/
|
|
126
126
|
|
|
127
127
|
/**
|
|
128
128
|
* Unit specs are for services only: every `<name>.service.ts` has its `<name>.service.spec.ts` beside it, every unit spec is
|
|
129
|
-
* one of those, and there are no
|
|
129
|
+
* one of those, and there are no kinds of test file beyond `spec`, `e2e-spec`, `integration-spec` and `contract-spec`.
|
|
130
130
|
*/
|
|
131
131
|
export const unitTestColocated = {
|
|
132
132
|
meta: {
|
|
133
133
|
type: "problem",
|
|
134
|
-
docs: { description: "Only a `<name>.service.ts`
|
|
134
|
+
docs: { description: "Only the unit-tested roles (`ruleParams.be.unitRoles`: a `<name>.service.ts`, a cli command `<name>.cli.ts` of `src/features/cli`) own a REQUIRED unit spec, each beside it as `<name>.<role>.spec.ts`; any other logic role (`ruleParams.be.logicRoles`: policy, client, guard, mapper, ...) inside a slot whose coverage is required may have one; any other unit spec is a finding, and there are only the spec kinds `spec`, `e2e-spec`, `integration-spec` and `contract-spec`." },
|
|
135
135
|
schema: [],
|
|
136
136
|
messages: {
|
|
137
|
-
suffix: "`{{name}}` is a banned kind of test file. A test is a `<name>.service.spec.ts` unit beside its service or a `*.e2e-spec.ts`
|
|
138
|
-
notService: "`{{name}}` is a unit spec of something that
|
|
139
|
-
orphan: "`{{name}}` has
|
|
140
|
-
missing: "This
|
|
137
|
+
suffix: "`{{name}}` is a banned kind of test file. A test is a `<name>.service.spec.ts` unit beside its service, or a `*.e2e-spec.ts`, `*.integration-spec.ts` or `*.contract-spec.ts` under its `src/tests/` folder; there is no `.test.ts`, `.int-spec.ts` or `.harness-spec.ts`.",
|
|
138
|
+
notService: "`{{name}}` is a unit spec of something that carries no unit-tested logic here. A unit spec sits beside a `<name>.<role>.ts` of a spec-required role (a service, a cli command of `src/features/cli`) or of a logic role (`ruleParams.be.logicRoles`: policy, client, guard, mapper, ...) inside the logic of `src/modules/**`, where every file owes 100 per file; a feature file (handler, resolver, controller, consumer, processor, step, ...) is thin (BE_FEATURE_THIN) and is proven by the integration and e2e flows, and an entity, module or composition is declaration. Delete this spec and move any business rule it checks into a service, policy or mapper of `src/modules/**`.",
|
|
139
|
+
orphan: "`{{name}}` has nothing beside it to test. A unit spec is `<name>.<role>.spec.ts` next to the `<name>.<role>.ts` it tests (a service, or a cli command of `src/features/cli`; the roles are `ruleParams.be.unitRoles`); move it beside its subject, or rename it after the subject it tests.",
|
|
140
|
+
missing: "This unit-tested subject has no spec. Add `{{spec}}` beside it: it builds the subject with `Test.createTestingModule`, provides only its constructor dependencies as typed doubles and asserts results or state.",
|
|
141
141
|
},
|
|
142
142
|
},
|
|
143
143
|
create(context) {
|
|
@@ -149,17 +149,20 @@ export const unitTestColocated = {
|
|
|
149
149
|
return { Program(node) { context.report({ node, messageId: "suffix", data: { name } }) } }
|
|
150
150
|
}
|
|
151
151
|
if (isUnitSpecFile(hfs, filename)) {
|
|
152
|
-
|
|
153
|
-
if (exists(join(dirname(filename),
|
|
152
|
+
const door = doorOfSpec(hfs, filename)
|
|
153
|
+
if (door !== null) return exists(join(dirname(filename), door)) ? {} : { Program(node) { context.report({ node, messageId: "orphan", data: { name } }) } }
|
|
154
|
+
const role = unitRoleOfSpec(hfs, filename)
|
|
155
|
+
if (!role) return { Program(node) { context.report({ node, messageId: "notService", data: { name } }) } }
|
|
156
|
+
const subject = `${name.slice(0, -`.${role.spec}.ts`.length)}.${role.role}.ts`
|
|
157
|
+
if (exists(join(dirname(filename), subject))) return {}
|
|
154
158
|
return { Program(node) { context.report({ node, messageId: "orphan", data: { name } }) } }
|
|
155
159
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return { Program(node) { context.report({ node, messageId: "missing", data: { spec: `${name.slice(0, -".ts".length)}.spec.ts` } }) } }
|
|
160
|
+
// a unit-tested subject outside the test tree (ruleParams.be.unitRoles): its spec sits beside it
|
|
161
|
+
const role = unitRoleOfSubject(hfs, filename)
|
|
162
|
+
if (!role) return {}
|
|
163
|
+
const spec = `${name.slice(0, -`.${role.role}.ts`.length)}.${role.spec}.ts`
|
|
164
|
+
if (exists(join(dirname(filename), spec))) return {}
|
|
165
|
+
return { Program(node) { context.report({ node, messageId: "missing", data: { spec } }) } }
|
|
163
166
|
},
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -175,7 +178,7 @@ const isStateReader = (context, node) => STATE_TYPES.some((name) => isPackageTyp
|
|
|
175
178
|
/** The handle `useTestWorld` answers, by where its type is declared: the `@starci/test-world` package or the repository's world slot. */
|
|
176
179
|
const isWorldHandle = (context, node) => {
|
|
177
180
|
const hfs = hfsOf(context)
|
|
178
|
-
return typeOrigins(context, node).some((origin) => origin.name === "TestWorld" && (origin.module === "@starci/test-world" || hfs
|
|
181
|
+
return typeOrigins(context, node).some((origin) => origin.name === "TestWorld" && (origin.module === "@starci/test-world" || inTestWorld(hfs, origin.file)))
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
/** The name a non-computed member access reads, or null. */
|
|
@@ -302,7 +305,7 @@ export const noApiShapedE2eFilename = {
|
|
|
302
305
|
// -- TESTING-7 -------------------------------------------------------------------------------------
|
|
303
306
|
|
|
304
307
|
/** Test infrastructure: the fixtures and the test world, where the shared model stub lives (slots `be.tests.fixtures`, `be.tests.world`). */
|
|
305
|
-
const isTestInfrastructure = (hfs, filename) =>
|
|
308
|
+
const isTestInfrastructure = (hfs, filename) => hfs.slotOf(filename) === "be.tests.fixtures" || inTestWorld(hfs, filename)
|
|
306
309
|
|
|
307
310
|
/** Bare markers a stub returns when nobody gave it a real answer to stand in for. */
|
|
308
311
|
const MARKER_STRINGS = new Set(["stubbed", "stub", "ok", "test", "mock", "fake", "todo", "tbd", "n/a", "pending", ""])
|