@starci/eslint-canon-be 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/data-access.mjs +1 -1
  4. package/index.mjs +2 -0
  5. package/lib/unit-spec.mjs +3 -3
  6. package/package.json +1 -1
  7. package/project-graph.mjs +23 -0
  8. package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
  9. package/runtime/scripts/checks/architecture/backend.mjs +350 -0
  10. package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
  11. package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
  12. package/runtime/scripts/checks/architecture/clones.mjs +200 -0
  13. package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
  14. package/runtime/scripts/checks/architecture/config.mjs +310 -0
  15. package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
  16. package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
  17. package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
  18. package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
  19. package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
  20. package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
  21. package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
  22. package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
  23. package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
  24. package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
  25. package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
  26. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
  27. package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
  28. package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
  29. package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
  30. package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
  31. package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
  32. package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
  33. package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
  34. package/runtime/scripts/checks/architecture/index.mjs +321 -0
  35. package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
  36. package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
  37. package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
  38. package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
  39. package/runtime/scripts/checks/architecture/owners.mjs +95 -0
  40. package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
  41. package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
  42. package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
  43. package/runtime/scripts/checks/architecture/registration.mjs +319 -0
  44. package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
  45. package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
  46. package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
  47. package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
  48. package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
  49. package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
  50. package/runtime/scripts/checks/architecture/surface.mjs +91 -0
  51. package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
  52. package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
  53. package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
  54. package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
  55. package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
  56. package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
  57. package/runtime/scripts/checks/common.mjs +44 -0
  58. package/runtime/scripts/checks/typescript-programs.mjs +82 -0
  59. package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
  60. package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
  61. package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
  62. package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
  63. package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
  64. package/runtime/scripts/lib/json.mjs +22 -0
  65. package/runtime/scripts/lib/project-graph.mjs +0 -0
  66. package/runtime/scripts/lib/project-rule.mjs +39 -0
  67. package/runtime/scripts/lib/repo-identity.mjs +50 -0
  68. package/runtime/scripts/lib/secret-patterns.mjs +44 -0
  69. package/runtime/scripts/lib/stack-services.mjs +217 -0
  70. package/runtime/scripts/lib/test-secrets.mjs +120 -0
  71. package/test-world.mjs +2 -3
  72. package/testing.mjs +1 -1
  73. package/unit-spec.mjs +43 -6
@@ -0,0 +1,44 @@
1
+ // secret-patterns.mjs — the one list of secret shapes the runtime refuses to carry: the push secret scan
2
+ // (scripts/supervisor/push-mains.mjs scanDiff) refuses an outgoing range that adds one, and the typed-log
3
+ // redaction (scripts/kernel/typed-logs.mjs redactText) blanks the value before a log row is stored.
4
+ // FORBIDDEN_FILES name paths that are secrets by being (an env file, a key file); SECRET_PATTERNS match values.
5
+
6
+ export const FORBIDDEN_FILES = [
7
+ { name: 'env-file', test: (f) => /(^|\/)\.env(\.[^/]*)?$/i.test(f) && !/\.env\.(example|sample|template)$/i.test(f) },
8
+ { name: 'secrets-dir', test: (f) => /(^|\/)\.secrets\//i.test(f) },
9
+ { name: 'private-key-file', test: (f) => /\.(pem|key|p12|pfx)$/i.test(f) || /(^|\/)id_(rsa|ed25519|ecdsa)$/i.test(f) },
10
+ // .starcistacks/<stack>/secrets/ commits sops twins only (owner ruling push-scan-test-secrets-encrypted): a plaintext
11
+ // credential there - a test password included - is git-ignored by the product repo and never pushed.
12
+ { name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
13
+ { name: 'credentials-json', test: (f) => /(^|\/)(credentials|service-account|client_secret)[^/]*\.json$/i.test(f) },
14
+ ];
15
+ export const SECRET_PATTERNS = [
16
+ { name: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
17
+ { name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
18
+ { name: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/ },
19
+ { name: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
20
+ { name: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
21
+ { name: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
22
+ { name: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
23
+ { name: 'telegram-bot-token', re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/ },
24
+ { name: 'stripe-secret', re: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/ },
25
+ { name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
26
+ // A keyword-assigned value that names itself a stand-in (a test stub's `accessToken: "fixture-..."`) is no
27
+ // candidate; only this heuristic takes the exemption, never a provider-shaped token above.
28
+ // A spec file's keyword-assigned values are test inputs (nivo-fe AuthenticationPage/index.spec.tsx: the sign-in
29
+ // form's password and a mocked accessToken refused the push, 2026-09-24): the heuristic skips spec files too.
30
+ { name: 'assigned-secret', re: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"]([^'"\s$<{]{12,})['"]/i, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
31
+ skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
32
+ ];
33
+
34
+
35
+ /** The names of the patterns one line of `file` matches (never the value). A stand-in value and a spec file's keyword assignment are no hit. */
36
+ export function secretHits(file, text) {
37
+ const names = [];
38
+ for (const rule of SECRET_PATTERNS) {
39
+ if (rule.skipFile?.test(file ?? '')) continue;
40
+ const hit = rule.re.exec(text);
41
+ if (hit && !rule.placeholder?.test(hit[1] ?? '')) names.push(rule.name);
42
+ }
43
+ return names;
44
+ }
@@ -0,0 +1,217 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseYaml } from '../../engine/yaml.mjs';
4
+
5
+ /**
6
+ * The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
7
+ * files of one environment (`environments.<env>.composeFiles`, `include:` followed). The R47 `test-world-files` machine check
8
+ * reads the stack through it: it refuses a fake of a service the stack declares, and a `fakedBy` of a stateful one. Nothing
9
+ * here names a product: the stack declares its services, this reads them.
10
+ *
11
+ * Which services the test world runs for real: every declared service except the app's own services (component role
12
+ * `service`, or a compose service under the `app` profile) and observability sidecars (role `observability`). The
13
+ * toxiproxy service (the failure-injection proxy the world routes every real service through) is declared like any other
14
+ * service and is returned as `proxy`.
15
+ */
16
+ export const STACKS_DIRECTORY = '.starcistacks';
17
+ export const STACKS_FILE = 'application-stacks.yaml';
18
+ export const DEFAULT_ENVIRONMENT = 'dev';
19
+
20
+ /** 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
+ export const TEST_SECRET_VALUE = 'starci-test-secret';
22
+
23
+ const NOT_RUN_ROLES = new Set(['service', 'observability']);
24
+ const NOT_RUN_PROFILES = new Set(['app']);
25
+
26
+ /**
27
+ * What the reader knows about the well-known images of a stack, by the repository name of the image: how the test stack
28
+ * tells the service answers (`probe`), which name its fakes could hide behind (`aliases`), and its kind. An image it does not
29
+ * know is probed by TCP and is only matched by its own name.
30
+ */
31
+ const KNOWN_IMAGES = Object.freeze({
32
+ postgres: { kind: 'database', aliases: ['postgres', 'postgresql', 'pg', 'database', 'db'], probe: { exec: ['pg_isready', '-h', '127.0.0.1'] } },
33
+ redis: { kind: 'cache', aliases: ['redis', 'cache', 'valkey'], probe: { exec: ['redis-cli', 'ping'] } },
34
+ valkey: { kind: 'cache', aliases: ['redis', 'cache', 'valkey'], probe: { exec: ['valkey-cli', 'ping'] } },
35
+ keycloak: { kind: 'identity', aliases: ['keycloak', 'idp', 'oidc'], probe: { http: { path: '/realms/master' } } },
36
+ minio: { kind: 'storage', aliases: ['minio', 's3', 'storage'], probe: { http: { path: '/minio/health/live', containerPort: 9000 } } },
37
+ mailpit: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/livez', containerPort: 8025 } } },
38
+ mailhog: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/', containerPort: 8025 } } },
39
+ maildev: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/healthz', containerPort: 1080 } } },
40
+ toxiproxy: { kind: 'proxy', aliases: ['toxiproxy'], probe: { http: { path: '/version', containerPort: 8474 } } },
41
+ mysql: { kind: 'database', aliases: ['mysql', 'database', 'db'], probe: { tcp: true } },
42
+ mariadb: { kind: 'database', aliases: ['mariadb', 'mysql', 'database', 'db'], probe: { tcp: true } },
43
+ mongo: { kind: 'database', aliases: ['mongo', 'mongodb', 'database', 'db'], probe: { tcp: true } },
44
+ clickhouse: { kind: 'database', aliases: ['clickhouse', 'database', 'db'], probe: { tcp: true } },
45
+ kafka: { kind: 'queue', aliases: ['kafka', 'queue', 'broker'], probe: { tcp: true } },
46
+ 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
+ elasticsearch: { kind: 'search', aliases: ['elasticsearch', 'elastic', 'search'], probe: { tcp: true } },
50
+ opensearch: { kind: 'search', aliases: ['opensearch', 'search'], probe: { tcp: true } },
51
+ });
52
+
53
+ /**
54
+ * The kinds of well-known image that hold data an app reads back. A stack service of one of these kinds (or one the stack gives
55
+ * a persistent volume) is stateful: it always runs real in the test world and can never be faked, even with a declared
56
+ * exception (BE-CONVENTION 1.16: only stateless compute that needs special hardware or an external model may be faked).
57
+ */
58
+ export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
59
+
60
+ /** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
61
+ export const repositoryOf = image => {
62
+ const withoutDigest = image.split('@')[0];
63
+ const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
64
+ return lastSegment.split(':')[0];
65
+ };
66
+
67
+ /** The repository path of an image reference without its tag: `quay.io/keycloak/keycloak:26.0` -> `quay.io/keycloak/keycloak`. */
68
+ export const imagePathOf = image => {
69
+ const withoutDigest = image.split('@')[0];
70
+ const lastSlash = withoutDigest.lastIndexOf('/');
71
+ const colon = withoutDigest.indexOf(':', lastSlash + 1);
72
+ return colon === -1 ? withoutDigest : withoutDigest.slice(0, colon);
73
+ };
74
+
75
+ /** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
76
+ export const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
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
+ };
100
+
101
+ const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
102
+
103
+ /** A compose `environment` (map or `KEY=VALUE` list) as a plain object; a `*_FILE` variable becomes the plain variable with the test value. */
104
+ const environmentOf = value => {
105
+ const entries = Array.isArray(value)
106
+ ? value.map(item => { const text = String(item); const at = text.indexOf('='); return at === -1 ? [text, ''] : [text.slice(0, at), text.slice(at + 1)]; })
107
+ : Object.entries(value ?? {}).map(([key, item]) => [key, item === null ? '' : String(item)]);
108
+ const out = {};
109
+ for (const [key, item] of entries) {
110
+ if (key.endsWith('_FILE')) out[key.slice(0, -'_FILE'.length)] = TEST_SECRET_VALUE;
111
+ else out[key] = item;
112
+ }
113
+ return out;
114
+ };
115
+
116
+ /** The container ports a compose `ports` list publishes: `"5432:5432"`, `"127.0.0.1:8080:80/tcp"`, `5432`, `{target: 80}`. */
117
+ const containerPortsOf = value => asList(value).map(entry => {
118
+ if (entry !== null && typeof entry === 'object') return Number(entry.target);
119
+ const container = String(entry).split('/')[0].split(':').at(-1);
120
+ return Number(container);
121
+ }).filter(Number.isInteger);
122
+
123
+ /** The bind mounts of a compose `volumes` list whose source is a path (`./realm.json:/x:ro`); named volumes are ephemeral in the test stack and are skipped. */
124
+ const mountsOf = (value, directory) => {
125
+ const mounts = [];
126
+ for (const entry of asList(value)) {
127
+ if (entry !== null && typeof entry === 'object') {
128
+ if (entry.type === 'bind' && typeof entry.source === 'string') mounts.push({ host: path.resolve(directory, entry.source), container: String(entry.target), readOnly: entry.read_only === true });
129
+ continue;
130
+ }
131
+ const parts = String(entry).split(':');
132
+ // A Windows drive letter is not a separator: `C:\x:/y` keeps its drive.
133
+ const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
134
+ const target = parts.shift();
135
+ if (!source || !target || !(source.startsWith('.') || path.isAbsolute(source))) continue;
136
+ mounts.push({ host: path.resolve(directory, source), container: target, readOnly: parts.includes('ro') });
137
+ }
138
+ return mounts;
139
+ };
140
+
141
+ /** True when a compose `volumes` list gives the service somewhere to keep data: a named volume, or a bind mount that is not read-only. */
142
+ const persistsData = value => asList(value).some(entry => {
143
+ if (entry !== null && typeof entry === 'object') return entry.type === 'volume' || (entry.type === 'bind' && entry.read_only !== true);
144
+ const parts = String(entry).split(':');
145
+ const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
146
+ parts.shift();
147
+ if (!source) return false;
148
+ const isPath = source.startsWith('.') || path.isAbsolute(source);
149
+ return !isPath || !parts.includes('ro');
150
+ });
151
+
152
+ const readYaml = file => parseYaml(fs.readFileSync(file, 'utf8')) ?? {};
153
+
154
+ /** The compose services of `file` and of every file it includes: [{name, definition, directory}]. */
155
+ const composeServices = (file, seen = new Set()) => {
156
+ const resolved = path.resolve(file);
157
+ if (seen.has(resolved) || !fs.existsSync(resolved)) return [];
158
+ seen.add(resolved);
159
+ const document = readYaml(resolved);
160
+ const directory = path.dirname(resolved);
161
+ const included = asList(document.include).flatMap(entry => {
162
+ const target = typeof entry === 'string' ? entry : entry?.path;
163
+ return typeof target === 'string' ? composeServices(path.resolve(directory, target), seen) : [];
164
+ });
165
+ const own = Object.entries(document.services ?? {}).map(([name, definition]) => ({ name, definition: definition ?? {}, directory }));
166
+ return [...included, ...own];
167
+ };
168
+
169
+ /**
170
+ * The stack of `environment`, or null when the repository declares none. `{ environment, directory, services, proxy }`:
171
+ * `services` are the services the environment declares (`name`, `image`, `repository`, `tag`, `role`, `kind`, `aliases`,
172
+ * `probe`, `command`, `environment`, `ports`, `mounts`, `persistent`, `runs`), `proxy` the toxiproxy service or null.
173
+ */
174
+ export function readStack({ root, environment = DEFAULT_ENVIRONMENT }) {
175
+ const stacksFile = path.join(root, STACKS_DIRECTORY, STACKS_FILE);
176
+ if (!fs.existsSync(stacksFile)) return null;
177
+ const declaration = readYaml(stacksFile);
178
+ const env = declaration.environments?.[environment];
179
+ if (!env) return null;
180
+ const directory = path.join(root, STACKS_DIRECTORY, environment);
181
+ const components = declaration.components ?? {};
182
+ const declared = new Map();
183
+ for (const composeFile of asList(env.composeFiles)) {
184
+ for (const { name, definition, directory: composeDirectory } of composeServices(path.join(directory, composeFile))) declared.set(name, { definition, directory: composeDirectory });
185
+ }
186
+ const services = [];
187
+ for (const [name, { definition, directory: composeDirectory }] of declared) {
188
+ const image = typeof definition.image === 'string' ? definition.image : components[name]?.image;
189
+ if (typeof image !== 'string') continue;
190
+ const repository = repositoryOf(image);
191
+ const known = KNOWN_IMAGES[repository];
192
+ const role = components[name]?.role ?? 'stateful';
193
+ const profiles = asList(definition.profiles);
194
+ const command = typeof definition.command === 'string' ? words(definition.command) : asList(definition.command).map(String);
195
+ services.push({
196
+ name,
197
+ image,
198
+ repository,
199
+ tag: isTagged(image) ? image.slice(imagePathOf(image).length + 1) : null,
200
+ role,
201
+ kind: known?.kind ?? 'other',
202
+ aliases: known?.aliases ?? [repository],
203
+ probe: known?.probe ?? { tcp: true },
204
+ command,
205
+ environment: environmentOf(definition.environment),
206
+ ports: containerPortsOf(definition.ports),
207
+ mounts: mountsOf(definition.volumes, composeDirectory),
208
+ persistent: persistsData(definition.volumes),
209
+ runs: !NOT_RUN_ROLES.has(role) && !profiles.some(profile => NOT_RUN_PROFILES.has(profile)),
210
+ });
211
+ }
212
+ const proxy = services.find(service => service.kind === 'proxy') ?? null;
213
+ return { environment, directory, services, proxy };
214
+ }
215
+
216
+ /** The names a fake of `service` could hide behind: its own name, its image repository and the aliases of a well-known image. */
217
+ export const namesOfService = service => [...new Set([service.name, service.repository, ...service.aliases])];
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // test-secrets.mjs — test credentials follow the product repository's own `.starcistacks` convention (owner ruling
3
+ // push-scan-test-secrets-encrypted, 2026-09-28: they work exactly like the stack custody and a plaintext password is never pushed).
4
+ // There is no store of the runtime's own: a test credential lives at `<repo>/.starcistacks/<stack>/secrets/test/<name>`,
5
+ // its plaintext git-ignored by the repo's `.starcistacks/**` rules and only the sops-encrypted twin `<name>.enc`
6
+ // committed. It is written with the repository's existing command (`node scripts/stack-secret.mjs set
7
+ // <stack>/secrets/test/<name>`, npm run secret:set), which encrypts against the recipients `.sops.yaml` names and
8
+ // removes the plaintext; this module only READS: the local plaintext when present, else the `.enc` decrypted by sops
9
+ // with the shared age identity (SOPS_AGE_KEY_FILE, default ~/.starci/master.identity) into memory.
10
+ // A credential that need not be stable across runs (a disposable account registered per run) is generated per run
11
+ // and stored nowhere. A value is never logged.
12
+ //
13
+ // node scripts/lib/test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal] prints nothing unless --reveal
14
+ //
15
+ // In a script: import { testSecret } from '<runtime>/scripts/lib/test-secrets.mjs';
16
+ // const password = testSecret('login-capture-password', { repo });
17
+ // The push secret scan (scripts/supervisor/push-mains.mjs) passes a `.enc` only when isSopsEnvelope holds.
18
+ import fs from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { parseYaml } from '../../engine/yaml.mjs';
24
+
25
+ const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
26
+ const STACK = /^[a-z0-9][a-z0-9-]{0,31}$/;
27
+ /** One sops-encrypted leaf: `ENC[AES256_GCM,data:...,iv:...,tag:...,type:...]` (data may be empty). */
28
+ const SOPS_VALUE = /^ENC\[AES256_GCM,data:[A-Za-z0-9+/=]*,iv:[A-Za-z0-9+/=]+,tag:[A-Za-z0-9+/=]+,type:[a-z]+\]$/;
29
+
30
+ /** Where test credential `name` of `stack` lives in `repo`: {plain, enc, rel}. `rel` is the path the repo's
31
+ * `stack-secret.mjs set` takes. */
32
+ export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
33
+ if (!NAME.test(String(name ?? ''))) throw new Error(`test secret name must match ${NAME} (got ${JSON.stringify(name)})`);
34
+ if (!STACK.test(String(stack ?? ''))) throw new Error(`stack must match ${STACK} (got ${JSON.stringify(stack)})`);
35
+ if (!repo) throw new Error('test secret needs {repo}: the product repository whose .starcistacks holds it');
36
+ const rel = `${stack}/secrets/test/${name}`;
37
+ const plain = path.join(path.resolve(String(repo)), '.starcistacks', stack, 'secrets', 'test', name);
38
+ return { plain, enc: `${plain}.enc`, rel };
39
+ }
40
+
41
+ /** The command that stores a test credential, in the product repository's own tooling. */
42
+ export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
43
+
44
+ /** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
45
+ export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
46
+
47
+ /** The sops binary: PATH, then winget's Links and Packages (spawn without a shell ignores PATHEXT). */
48
+ export function resolveSops(env = process.env) {
49
+ const win = process.platform === 'win32';
50
+ const names = win ? ['sops.exe', 'sops'] : ['sops'];
51
+ const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
52
+ if (win && env.LOCALAPPDATA) {
53
+ const winget = path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
54
+ dirs.push(path.join(winget, 'Links'));
55
+ const packages = path.join(winget, 'Packages');
56
+ try { for (const e of fs.readdirSync(packages)) if (/sops/i.test(e)) dirs.push(path.join(packages, e)); } catch { /* none */ }
57
+ }
58
+ for (const dir of dirs) for (const n of names) { const f = path.join(dir, n); try { if (fs.statSync(f).isFile()) return f; } catch { /* next */ } }
59
+ return null;
60
+ }
61
+
62
+ /** Read test credential `name`: the local plaintext when present, else its committed `.enc` through sops. */
63
+ export function testSecret(name, { repo, stack = 'dev', env = process.env, sops = null } = {}) {
64
+ const { plain, enc, rel } = testSecretPaths(name, { repo, stack });
65
+ if (fs.existsSync(plain)) return fs.readFileSync(plain, 'utf8').replace(/\r?\n$/, '');
66
+ if (!fs.existsSync(enc)) throw new Error(`test secret ${name} is not in .starcistacks/${rel}.enc; store it with \`${setCommand(name, stack)}\` in ${repo}`);
67
+ const bin = sops ?? resolveSops(env);
68
+ if (!bin) throw new Error('sops is not installed (Windows: winget install Mozilla.SOPS)');
69
+ const format = sopsFormatFor(plain);
70
+ const r = spawnSync(bin, ['--decrypt', '--input-type', format, '--output-type', format, enc], {
71
+ cwd: path.resolve(String(repo)), encoding: 'utf8', windowsHide: true,
72
+ env: { ...env, SOPS_AGE_KEY_FILE: env.SOPS_AGE_KEY_FILE || path.join(os.homedir(), '.starci', 'master.identity') },
73
+ });
74
+ if (r.status !== 0) throw new Error(`test secret ${name}: sops could not decrypt .starcistacks/${rel}.enc (${String(r.stderr ?? r.error?.message ?? '').trim().split(/\r?\n/).pop()})`);
75
+ return String(r.stdout).replace(/\r?\n$/, '');
76
+ }
77
+
78
+ /** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
79
+ const allEncrypted = (node) => {
80
+ if (node === null) return true;
81
+ if (Array.isArray(node)) return node.every(allEncrypted);
82
+ if (typeof node === 'object') return Object.values(node).every(allEncrypted);
83
+ return typeof node === 'string' && SOPS_VALUE.test(node);
84
+ };
85
+ const sopsTree = (doc) => !!doc && typeof doc === 'object' && !Array.isArray(doc) && !!doc.sops && typeof doc.sops === 'object'
86
+ && typeof doc.sops.mac === 'string' && SOPS_VALUE.test(doc.sops.mac)
87
+ && Object.keys(doc).length > 1 && Object.entries(doc).every(([k, v]) => k === 'sops' || allEncrypted(v));
88
+
89
+ /**
90
+ * True only for a sops-encrypted file with no plaintext value: binary/json form ({"data": ENC[...], "sops": {...}}),
91
+ * yaml form (every value ENC[...] plus the sops block) or dotenv form (KEY=ENC[...] lines plus sops_* metadata).
92
+ * The sops metadata must carry its mac. A key kept plaintext through `unencrypted_suffix` does not pass.
93
+ */
94
+ export function isSopsEnvelope(text) {
95
+ const src = String(text ?? '').replace(/^/, '');
96
+ if (!src.trim()) return false;
97
+ try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
98
+ const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
99
+ if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
100
+ const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
101
+ const meta = pairs.filter(([k]) => k.startsWith('sops_'));
102
+ const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
103
+ return vals.length > 0 && vals.every(([, v]) => SOPS_VALUE.test(v)) && meta.some(([k, v]) => k === 'sops_mac' && SOPS_VALUE.test(v));
104
+ }
105
+ try { return sopsTree(parseYaml(src)); } catch { return false; }
106
+ }
107
+
108
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
109
+ const [cmd, name, ...rest] = process.argv.slice(2);
110
+ const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
111
+ try {
112
+ if (cmd === 'get') {
113
+ const value = testSecret(name, { repo: opt('--repo'), stack: opt('--stack') ?? 'dev' });
114
+ if (rest.includes('--reveal')) process.stdout.write(`${value}\n`);
115
+ } else {
116
+ console.error(`usage: test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal]\nstore one with the product repo's own command: ${setCommand()}`);
117
+ process.exitCode = 2;
118
+ }
119
+ } catch (error) { console.error(String(error?.message ?? error)); process.exitCode = 1; }
120
+ }
package/test-world.mjs CHANGED
@@ -38,7 +38,7 @@ const isEnvMember = (node) => node?.type === "MemberExpression" && isProcessEnv(
38
38
  * migration (a class declared in a `be.persistence` `migrations/` file, or a value whose type is an array of them) or
39
39
  * anything the migrate app declares; importing typeorm's `DataSource` or a testcontainers package; calling
40
40
  * `runMigrations`/`synchronize`/`dropDatabase`/`createSchema`... on a typeorm receiver; constructing a container; writing
41
- * `process.env`. Migration behaviour is tested only by `apps/migrate`'s own specs.
41
+ * `process.env`. Migration behaviour is tested by the e2e world.
42
42
  */
43
43
  export const testsInfraOnlyInWorld = {
44
44
  meta: {
@@ -46,7 +46,7 @@ export const testsInfraOnlyInWorld = {
46
46
  docs: { description: "Only src/tests/world starts infrastructure, migrates, holds a DataSource or writes process.env." },
47
47
  schema: [],
48
48
  messages: {
49
- migration: "This test imports a migration or the migrate app. The schema is prepared once by `src/tests/world/global-setup.ts` running the real `apps/migrate` bootstrap; migrations are tested in `apps/migrate`'s own specs.",
49
+ migration: "This test imports a migration or the migrate app. The schema is prepared once by `src/tests/world/global-setup.ts` running the real `apps/migrate` bootstrap; migration behaviour is tested through the e2e world.",
50
50
  call: "`{{name}}` builds or changes the schema outside `src/tests/world`. The world migrates once; a test uses `world.db.<connection>`.",
51
51
  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
52
  env: "This test writes `process.env`. The environment is set once by `src/tests/world`; a test takes the world as it is.",
@@ -201,4 +201,3 @@ export const recommended = {
201
201
  "starci-be/tests-no-override": "error",
202
202
  "starci-be/test-world-shape": "error",
203
203
  }
204
-
package/testing.mjs CHANGED
@@ -126,7 +126,7 @@ 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 other kinds of test file. A composition spec is not a unit kind.
129
+ * one of those, and there are no other kinds of test file.
130
130
  */
131
131
  export const unitTestColocated = {
132
132
  meta: {
package/unit-spec.mjs CHANGED
@@ -17,6 +17,9 @@
17
17
  * `JSON.parse(JSON.stringify(x))`, or `Object.assign(new X(), y)` returned as another type.
18
18
  * - `spec-infra-double-from-kit`: a provider `{ provide: TOKEN, useValue: X }` takes X from the kit double the token calls
19
19
  * for. The table (token pattern -> double) is `ruleParams.be.specDoubles` of the slot manifest, not this file.
20
+ * - `spec-exact-values`: no `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)` (`expect` being the jest global
21
+ * or the `@jest/globals` import): ids and clocks come from the kit's deterministic doubles (`fakeIds()`, `FakeClock`), so a
22
+ * unit spec asserts the exact value.
20
23
  *
21
24
  * Paths are asked of the slot manifest (`lib/unit-spec.mjs`), a name is judged by the import that binds it, never by its spelling.
22
25
  */
@@ -164,13 +167,13 @@ export const specModuleDefinitionOnlyProviders = {
164
167
  /** The `jest` members that replace or fetch a module for the whole file. */
165
168
  const MODULE_MOCKS = new Set(["mock", "doMock", "unstable_mockModule", "requireMock", "setMock"])
166
169
 
167
- /** Whether `jest` here is the jest object: the ambient global, or the import from `@jest/globals`. */
168
- const isJestObject = (context, node) => {
169
- if (node.type !== "Identifier" || node.name !== "jest") return false
170
+ /** Whether the identifier `name` is the ambient global (or, when `source` is given, that import), not a local binding: `jest` and `expect` are the jest globals or `@jest/globals` imports. */
171
+ const isAmbientOrImported = (context, node, name, source) => {
172
+ if (node.type !== "Identifier" || node.name !== name) return false
170
173
  const imported = importOf(context, node)
171
- if (imported) return imported.source === "@jest/globals" && imported.imported === "jest"
174
+ if (imported) return source !== null && imported.source === source && imported.imported === name
172
175
  for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
173
- const variable = scope.set.get("jest")
176
+ const variable = scope.set.get(name)
174
177
  if (variable) return variable.defs.length === 0 || scope.type === "global" || variable.defs.every((definition) => definition.node?.parent?.declare === true)
175
178
  }
176
179
  return true
@@ -192,7 +195,7 @@ export const specNoModuleMock = {
192
195
  CallExpression(node) {
193
196
  const callee = node.callee
194
197
  if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || !MODULE_MOCKS.has(callee.property.name)) return
195
- if (isJestObject(context, callee.object)) context.report({ node, messageId: "moduleMock", data: { method: callee.property.name } })
198
+ if (isAmbientOrImported(context, callee.object, "jest", "@jest/globals")) context.report({ node, messageId: "moduleMock", data: { method: callee.property.name } })
196
199
  },
197
200
  }
198
201
  },
@@ -431,6 +434,38 @@ export const specInfraDoubleFromKit = {
431
434
  },
432
435
  }
433
436
 
437
+ // -- spec-exact-values -----------------------------------------------------------------------------------------
438
+
439
+ /** The global constructors whose `expect.any(...)` accepts every value of a kind, so asserts nothing exact. */
440
+ const LOOSE_KINDS = new Set(["String", "Number", "Date"])
441
+
442
+ /** A unit spec asserts the exact id and the exact date, never "some string" or "some date". */
443
+ export const specExactValues = {
444
+ meta: {
445
+ type: "problem",
446
+ docs: { description: "A service spec never asserts `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)`: ids and clocks come from `fakeIds()` and `FakeClock`, so the exact value is asserted." },
447
+ schema: [],
448
+ messages: {
449
+ loose: "`expect.any({{kind}})` accepts every {{what}}, so the assertion proves nothing about the value the service generated. The service takes its ids and its clock from tokens and the spec provides the kit's deterministic doubles: `{ provide: ID_GENERATOR, useValue: fakeIds() }` and `{ provide: CLOCK, useValue: new FakeClock(...) }` from `@starci/jest-preset`. Assert the exact value ({{exact}}).",
450
+ },
451
+ },
452
+ create(context) {
453
+ if (!isServiceSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
454
+ return {
455
+ CallExpression(node) {
456
+ const callee = node.callee
457
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || callee.property.name !== "any") return
458
+ if (!isAmbientOrImported(context, callee.object, "expect", "@jest/globals")) return
459
+ const kind = node.arguments[0]
460
+ if (kind?.type !== "Identifier" || !LOOSE_KINDS.has(kind.name) || !isAmbientOrImported(context, kind, kind.name, null)) return
461
+ const what = { String: "string", Number: "number", Date: "date" }[kind.name]
462
+ const exact = kind.name === "Date" ? "the clock's date, for example `clock.now()` of the `FakeClock` the spec provides" : "the id the `fakeIds()` double hands out"
463
+ context.report({ node, messageId: "loose", data: { kind: kind.name, what, exact } })
464
+ },
465
+ }
466
+ },
467
+ }
468
+
434
469
  /** The rules this law contributes to the plugin. */
435
470
  export const rules = {
436
471
  "spec-builds-with-testing-module": specBuildsWithTestingModule,
@@ -439,6 +474,7 @@ export const rules = {
439
474
  "spec-no-module-mock": specNoModuleMock,
440
475
  "no-return-only-generic": noReturnOnlyGeneric,
441
476
  "spec-infra-double-from-kit": specInfraDoubleFromKit,
477
+ "spec-exact-values": specExactValues,
442
478
  }
443
479
 
444
480
  /** Every rule of this law ships at `error`. */
@@ -449,4 +485,5 @@ export const recommended = {
449
485
  "starci-be/spec-no-module-mock": "error",
450
486
  "starci-be/no-return-only-generic": "error",
451
487
  "starci-be/spec-infra-double-from-kit": "error",
488
+ "starci-be/spec-exact-values": "error",
452
489
  }