@miroshnik/elves 0.0.0 → 2026.1018.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* architecture — the project's architecture model and its checks. There is one model, in `tests/architecture/model.ts`:
|
|
3
|
+
* modules (path, purpose), which modules and external packages each one depends on. From it:
|
|
4
|
+
*
|
|
5
|
+
* - `boundariesConfig` — ESLint boundary rules (the project's eslint-plugin-boundaries): importing a module one may not
|
|
6
|
+
* depend on is an error in `lint` and in the editor; the `eslint.ts` fragment in `tests/architecture/<name>/`;
|
|
7
|
+
* - `architecture` — tests: each code directory belongs to a module, an external package is imported only by a module
|
|
8
|
+
* it's allowed for, and each allowed package is actually used — the model doesn't diverge from the code either way.
|
|
9
|
+
*
|
|
10
|
+
* Imports — by static parsing of the sources (no build, no resolver): relative paths, the model's aliases and Node
|
|
11
|
+
* built-in modules aren't packages. Only `node:` APIs and erasable TypeScript: the project's test loads the module
|
|
12
|
+
* under the project's runner, Node included (tests/standards/harness-load).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
16
|
+
import type { Dirent } from "node:fs";
|
|
17
|
+
import { builtinModules } from "node:module";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import locale from "../../github/scripts/locale.ts";
|
|
21
|
+
import type { Names } from "../../github/scripts/locale.ts";
|
|
22
|
+
import { codeOnly, envNamesInCode, invariant } from "./harness.ts";
|
|
23
|
+
import type { EnvReader, Exception, Invariant, It } from "./harness.ts";
|
|
24
|
+
|
|
25
|
+
export interface Module {
|
|
26
|
+
/** The module's directory from the project root (or several). */
|
|
27
|
+
path: string | string[];
|
|
28
|
+
/** Purpose — one phrase: why the module exists and what isn't in it. */
|
|
29
|
+
purpose: string;
|
|
30
|
+
/** The module's public API (the entry file), if the module is closed behind it. */
|
|
31
|
+
api?: string;
|
|
32
|
+
/** Modules this one may depend on (its own files — always). */
|
|
33
|
+
dependsOn?: string[];
|
|
34
|
+
/** External packages this module may import. */
|
|
35
|
+
packages?: string[];
|
|
36
|
+
/**
|
|
37
|
+
* Library — a shared package built into several containers (a monorepo package in each app's bundle): its containers
|
|
38
|
+
* are the containers of the modules that depend on it, and those where it's named in `modules`.
|
|
39
|
+
*/
|
|
40
|
+
library?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** External system (C1): who it is, which modules talk to it and how — hosts, packages, environment keys, headers. */
|
|
44
|
+
export interface External {
|
|
45
|
+
purpose: string;
|
|
46
|
+
/**
|
|
47
|
+
* Adapter module, or several: only they talk to the external system. None — a perimeter (a CDN, a WAF in front of the
|
|
48
|
+
* system): no module talks to it, its hosts, packages, keys and headers aren't in the code.
|
|
49
|
+
*/
|
|
50
|
+
adapter?: string | string[];
|
|
51
|
+
hosts?: string[];
|
|
52
|
+
packages?: string[];
|
|
53
|
+
env?: string[];
|
|
54
|
+
/** HTTP headers the external system integrates through (`CF-IPCountry` from a CDN): only the adapter reads them. */
|
|
55
|
+
headers?: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Container (C2): a deployable unit — an app, a function, a cron, a worker, a database, a storage. */
|
|
59
|
+
export interface Container {
|
|
60
|
+
purpose: string;
|
|
61
|
+
/** Code modules deployed in this container. */
|
|
62
|
+
modules?: string[];
|
|
63
|
+
/** Deployable units from configs: `compose:<service>`, `supabase-function:<name>`, `vercel-cron:<path>`. */
|
|
64
|
+
deploy?: string[];
|
|
65
|
+
/** Containers this one is linked to (a request, a queue, a database): a relation, not an import. */
|
|
66
|
+
uses?: string[];
|
|
67
|
+
/** For a storage: the client packages used to talk to it (`pg`, `@supabase/supabase-js`). */
|
|
68
|
+
clients?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Model {
|
|
72
|
+
/** The system's name on the C1 and C2 diagrams of the docs (default — "System"). */
|
|
73
|
+
name?: string;
|
|
74
|
+
/** Code directories from the root (default `src`): each directory with code in them belongs to a module. */
|
|
75
|
+
roots?: string[];
|
|
76
|
+
/**
|
|
77
|
+
* Import aliases → a path from the root (`{ "@/": "src/" }`) or a list of paths — in a monorepo each app has its own
|
|
78
|
+
* `@/`: such an import is local, not a package.
|
|
79
|
+
*/
|
|
80
|
+
aliases?: Record<string, string | string[]>;
|
|
81
|
+
modules: Record<string, Module>;
|
|
82
|
+
/** External systems (C1): a payment provider, mail, a geocoder… */
|
|
83
|
+
externals?: Record<string, External>;
|
|
84
|
+
/** Containers (C2): deployable units and the relations between them. */
|
|
85
|
+
containers?: Record<string, Container>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Deployable units from the repository's deploy configs: `docker-compose.yml` / `compose.yml` services,
|
|
90
|
+
* `supabase/functions/<name>` functions, crons in `vercel.json`, Terraform compute resources in `*.tf`
|
|
91
|
+
* (`terraform:<type>.<name>`: an ECS service, a Lambda function, Cloud Run…). Parsing without dependencies: a compose
|
|
92
|
+
* service is a key indented by two spaces under the top-level `services:`, a Terraform resource is the line
|
|
93
|
+
* `resource "<type>" "<name>"`.
|
|
94
|
+
*/
|
|
95
|
+
export function deployUnits(root: string): string[] {
|
|
96
|
+
const out = new Set<string>();
|
|
97
|
+
for (const f of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
|
|
98
|
+
let text: string;
|
|
99
|
+
try {
|
|
100
|
+
text = readFileSync(path.join(root, f), "utf8");
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
let inServices = false;
|
|
105
|
+
for (const line of text.split(/\r?\n/)) {
|
|
106
|
+
if (/^services:\s*$/.test(line)) inServices = true;
|
|
107
|
+
else if (/^\S/.test(line)) inServices = false;
|
|
108
|
+
else if (inServices) {
|
|
109
|
+
const m = /^ {2}([A-Za-z0-9_.-]+):\s*$/.exec(line);
|
|
110
|
+
if (m) out.add(`compose:${m[1]}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
for (const e of readdirSync(path.join(root, "supabase", "functions"), { withFileTypes: true })) {
|
|
116
|
+
if (e.isDirectory() && !e.name.startsWith("_")) out.add(`supabase-function:${e.name}`);
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
/* no Supabase functions */
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const v = JSON.parse(readFileSync(path.join(root, "vercel.json"), "utf8")) as { crons?: { path: string }[] };
|
|
123
|
+
for (const c of v.crons ?? []) out.add(`vercel-cron:${c.path}`);
|
|
124
|
+
} catch {
|
|
125
|
+
/* no vercel.json or crons */
|
|
126
|
+
}
|
|
127
|
+
walkDirs(root, (rel, entries) => {
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
if (!e.isFile() || !e.name.endsWith(".tf")) continue;
|
|
130
|
+
for (const m of readFileSync(path.join(root, rel, e.name), "utf8").matchAll(TF_RESOURCE)) {
|
|
131
|
+
if (TERRAFORM_UNITS.has(m[1]!)) out.add(`terraform:${m[1]}.${m[2]}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return sorted([...out]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Terraform resources that deploy code: a service, a function, a job; a cluster, network, database isn't a code unit
|
|
139
|
+
const TERRAFORM_UNITS = new Set([
|
|
140
|
+
"aws_ecs_service",
|
|
141
|
+
"aws_lambda_function",
|
|
142
|
+
"aws_apprunner_service",
|
|
143
|
+
"google_cloud_run_service",
|
|
144
|
+
"google_cloud_run_v2_service",
|
|
145
|
+
"google_cloud_run_v2_job",
|
|
146
|
+
"google_cloudfunctions_function",
|
|
147
|
+
"google_cloudfunctions2_function",
|
|
148
|
+
"azurerm_container_app",
|
|
149
|
+
"azurerm_linux_function_app",
|
|
150
|
+
"kubernetes_deployment",
|
|
151
|
+
"kubernetes_deployment_v1",
|
|
152
|
+
"kubernetes_cron_job_v1",
|
|
153
|
+
]);
|
|
154
|
+
const TF_RESOURCE = /^[ \t]*resource\s+"([\w-]+)"\s+"([\w-]+)"/gm;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Monorepo workspace packages — directories with `package.json` by the `workspaces` patterns from `package.json` (a
|
|
158
|
+
* list or `{ packages }`) and `packages:` from `pnpm-workspace.yaml`; `!pattern` excludes. `*` — one directory level,
|
|
159
|
+
* `**` — any depth.
|
|
160
|
+
*/
|
|
161
|
+
export function workspacePackages(root: string): string[] {
|
|
162
|
+
const patterns: string[] = [];
|
|
163
|
+
try {
|
|
164
|
+
const ws = (JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { workspaces?: string[] | { packages?: string[] } }).workspaces;
|
|
165
|
+
patterns.push(...(Array.isArray(ws) ? ws : (ws?.packages ?? [])));
|
|
166
|
+
} catch {
|
|
167
|
+
/* no package.json or workspaces */
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
let inPackages = false;
|
|
171
|
+
for (const line of readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8").split(/\r?\n/)) {
|
|
172
|
+
if (/^packages:\s*$/.test(line)) inPackages = true;
|
|
173
|
+
else if (/^\S/.test(line)) inPackages = false;
|
|
174
|
+
else if (inPackages) {
|
|
175
|
+
const m = /^\s+-\s+(["']?)(.+?)\1\s*(?:#.*)?$/.exec(line);
|
|
176
|
+
if (m) patterns.push(m[2]!);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
/* no pnpm-workspace.yaml */
|
|
181
|
+
}
|
|
182
|
+
if (!patterns.length) return [];
|
|
183
|
+
const include = patterns.filter((p) => !p.startsWith("!")).map(glob);
|
|
184
|
+
const exclude = patterns.filter((p) => p.startsWith("!")).map((p) => glob(p.slice(1)));
|
|
185
|
+
const out: string[] = [];
|
|
186
|
+
walkDirs(root, (rel, entries) => {
|
|
187
|
+
if (!rel || !entries.some((e) => e.isFile() && e.name === "package.json")) return;
|
|
188
|
+
if (include.some((r) => r.test(rel)) && !exclude.some((r) => r.test(rel))) out.push(rel);
|
|
189
|
+
});
|
|
190
|
+
return sorted(out);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Workspace directory pattern → RegExp: `*` and `?` stay within one level, `**` — any depth, including zero. */
|
|
194
|
+
function glob(pattern: string): RegExp {
|
|
195
|
+
const segs = pattern.replace(/^\.\//, "").replace(/\/+$/, "").split("/");
|
|
196
|
+
const body = segs
|
|
197
|
+
.map((s) => (s === "**" ? "\0" : s.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")))
|
|
198
|
+
.join("/")
|
|
199
|
+
.replace(/\0\//g, "(?:.*/)?")
|
|
200
|
+
.replace(/\/\0/g, "(?:/.*)?")
|
|
201
|
+
.replace(/\0/g, ".*");
|
|
202
|
+
return new RegExp(`^${body}$`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Walks the repository's directories (without `node_modules`, builds and hidden ones): visit gets the path from the
|
|
207
|
+
* root and the directory's entries.
|
|
208
|
+
*/
|
|
209
|
+
function walkDirs(root: string, visit: (rel: string, entries: Dirent[]) => void, rel = ""): void {
|
|
210
|
+
let entries: Dirent[];
|
|
211
|
+
try {
|
|
212
|
+
entries = readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
visit(rel, entries);
|
|
217
|
+
for (const e of entries) {
|
|
218
|
+
if (e.isDirectory() && !SKIP.has(e.name) && !e.name.startsWith(".")) walkDirs(root, visit, rel ? `${rel}/${e.name}` : e.name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether the directory has a code file (`.d.ts` declarations aren't code). */
|
|
223
|
+
function hasCode(dir: string): boolean {
|
|
224
|
+
let entries: Dirent[];
|
|
225
|
+
try {
|
|
226
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
return entries.some((e) =>
|
|
231
|
+
e.isDirectory() ? !SKIP.has(e.name) && hasCode(path.join(dir, e.name)) : CODE.test(e.name) && !/\.d\.[cm]?ts$/.test(e.name),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const CODE = /\.[cm]?[jt]sx?$/;
|
|
236
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", ".next"]);
|
|
237
|
+
const BUILTIN = new Set(builtinModules);
|
|
238
|
+
|
|
239
|
+
const paths = (m: Module): string[] => (Array.isArray(m.path) ? m.path : [m.path]).map((p) => p.replace(/\/+$/, ""));
|
|
240
|
+
const names = (model: Model): string[] => Object.keys(model.modules).sort();
|
|
241
|
+
const sorted = <T>(xs: T[]): T[] => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* ESLint boundary rules from the model: an element per module, importing a module not in `dependsOn` is a
|
|
245
|
+
* `boundaries/dependencies` error. The rule skips external packages and built-in modules — `architecture` checks them.
|
|
246
|
+
* `boundaries` — the plugin from the project's dependencies; `settings` — e.g. the TypeScript resolver for aliases.
|
|
247
|
+
*/
|
|
248
|
+
export function boundariesConfig(model: Model, boundaries: object, opts: { files?: string[]; settings?: object } = {}): object[] {
|
|
249
|
+
const all = names(model);
|
|
250
|
+
const roots = model.roots ?? ["src"];
|
|
251
|
+
const policies = all.flatMap((name) => {
|
|
252
|
+
const allowed = new Set([name, ...(model.modules[name]!.dependsOn ?? [])]);
|
|
253
|
+
const denied = all.filter((n) => !allowed.has(n));
|
|
254
|
+
if (!denied.length) return [];
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
from: { element: { type: name } },
|
|
258
|
+
disallow: { to: { element: { types: { anyOf: denied } } } },
|
|
259
|
+
message: `module ${name} doesn't depend on {{to.element.type}} — module dependencies are in tests/architecture/model.ts`,
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
});
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
files: opts.files ?? roots.map((r) => `${r}/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}`),
|
|
266
|
+
plugins: { boundaries },
|
|
267
|
+
settings: {
|
|
268
|
+
// the longest path first: the plugin takes the first matching element, and src/lib is nested in src
|
|
269
|
+
"boundaries/elements": all
|
|
270
|
+
.flatMap((name) => paths(model.modules[name]!).map((p) => ({ type: name, pattern: p })))
|
|
271
|
+
.sort((a, b) => b.pattern.length - a.pattern.length),
|
|
272
|
+
"import/resolver": { node: { extensions: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"] } },
|
|
273
|
+
...opts.settings,
|
|
274
|
+
},
|
|
275
|
+
rules: { "boundaries/dependencies": [2, { default: "allow", policies }] },
|
|
276
|
+
},
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// import … from "x", export … from "x", import "x", import("x"), require("x"); `import type` isn't a code dependency
|
|
281
|
+
const IMPORT = /^\s*(import|export)\s+(type\s+)?[^;'"]*?\s*from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)/gm;
|
|
282
|
+
|
|
283
|
+
/** External packages a source imports: package name (`@scope/lib`, `lodash`), in order of appearance, no repeats. */
|
|
284
|
+
export function importsIn(text: string, aliases: Record<string, string | string[]> = {}): string[] {
|
|
285
|
+
const out: string[] = [];
|
|
286
|
+
for (const m of text.matchAll(IMPORT)) {
|
|
287
|
+
if (m[2]) continue; // import type / export type
|
|
288
|
+
const spec = m[3] ?? m[4] ?? m[5] ?? m[6];
|
|
289
|
+
if (!spec || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) continue;
|
|
290
|
+
if (Object.keys(aliases).some((a) => spec.startsWith(a))) continue;
|
|
291
|
+
const parts = spec.split("/");
|
|
292
|
+
const pkg = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
|
|
293
|
+
if (BUILTIN.has(pkg) || out.includes(pkg)) continue;
|
|
294
|
+
out.push(pkg);
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface Source {
|
|
300
|
+
file: string;
|
|
301
|
+
dir: string;
|
|
302
|
+
packages: string[];
|
|
303
|
+
hosts: string[];
|
|
304
|
+
env: string[];
|
|
305
|
+
/** Headers of the model's external systems that the source names as a string — in lower case. */
|
|
306
|
+
headers: string[];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// a URL literal in code: "https://api.example.com/…" — a host; local addresses and IPs aren't external systems
|
|
310
|
+
const URL_HOST = /["'`]https?:\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)(?::\d+)?(?=[\/"'`?#])/gi;
|
|
311
|
+
// local addresses — loopback and "this host" 0.0.0.0 never leave the machine: not external systems, tests may reach
|
|
312
|
+
// them over the network (networkGuard); IPv6 is in brackets, as URL.hostname returns it. Any other IP is an external
|
|
313
|
+
// system's address
|
|
314
|
+
const isLocal = (h: string) => h === "localhost" || h.endsWith(".localhost") || /^127(\.\d+){3}$/.test(h) || h === "0.0.0.0" || h === "[::1]";
|
|
315
|
+
// domains for examples, docs and tests (RFC 2606, RFC 6761) — in code they aren't an external system; but a request to
|
|
316
|
+
// them in tests is a forgotten mock or the real network, so networkGuard doesn't let them through (it uses isLocal)
|
|
317
|
+
const isReserved = (h: string) => /(^|\.)(example|invalid|test)$|(^|\.)example\.(com|net|org)$/.test(h);
|
|
318
|
+
// XML namespace URIs aren't service addresses: the xmlns attribute and the W3C namespaces (createElementNS)
|
|
319
|
+
const XMLNS = /xmlns(?::[\w-]+)?\s*=\s*$/;
|
|
320
|
+
const NAMESPACE_HOSTS = new Set(["www.w3.org"]);
|
|
321
|
+
// an href value is a link the visitor's browser opens (`<a href>`, `<Link href={…}>`, `{ href: … }`): the system
|
|
322
|
+
// doesn't talk to that host. `<link href>` is loaded by the browser itself, `location.href =` is navigation by code
|
|
323
|
+
// (payment, sign-in): those are hosts
|
|
324
|
+
const HREF = /(?<![.\w$])["']?href["']?\s*(?:=\s*\{?|:)\s*$/;
|
|
325
|
+
const OPEN_TAG = /<([A-Za-z][\w.:-]*)[^<>]*$/;
|
|
326
|
+
const isLink = (text: string, at: number): boolean =>
|
|
327
|
+
HREF.test(text.slice(Math.max(0, at - 40), at)) && OPEN_TAG.exec(text.slice(Math.max(0, at - 2000), at))?.[1] !== "link";
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Hosts of external systems in the source's URL literals; comments, namespaces, reserved domains and navigation links
|
|
331
|
+
* (`href`) aren't.
|
|
332
|
+
*/
|
|
333
|
+
export function hostsIn(source: string, file?: string): string[] {
|
|
334
|
+
return hostsInCode(codeOnly(source, file));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The same over code without comments: a source is parsed once for hosts, environment keys and headers. */
|
|
338
|
+
function hostsInCode(text: string): string[] {
|
|
339
|
+
const out = new Set<string>();
|
|
340
|
+
for (const m of text.matchAll(URL_HOST)) {
|
|
341
|
+
const h = m[1]!.toLowerCase();
|
|
342
|
+
if (isLocal(h) || isReserved(h) || NAMESPACE_HOSTS.has(h) || XMLNS.test(text.slice(Math.max(0, m.index - 40), m.index))) continue;
|
|
343
|
+
if (isLink(text, m.index)) continue;
|
|
344
|
+
out.add(h);
|
|
345
|
+
}
|
|
346
|
+
return [...out];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// a string literal that looks like an HTTP header name: `h.get("cf-ipcountry")`, `req.headers["CF-Connecting-IP"]`
|
|
350
|
+
const HEADER_LITERAL = /(["'`])([A-Za-z][A-Za-z0-9-]*)\1/g;
|
|
351
|
+
|
|
352
|
+
/** Headers from `declared` (in lower case) that the code names as a string, case-insensitively. */
|
|
353
|
+
function headersInCode(text: string, declared: Set<string>): string[] {
|
|
354
|
+
const out = new Set<string>();
|
|
355
|
+
for (const m of text.matchAll(HEADER_LITERAL)) {
|
|
356
|
+
const h = m[2]!.toLowerCase();
|
|
357
|
+
if (declared.has(h)) out.add(h);
|
|
358
|
+
}
|
|
359
|
+
return [...out];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Sources of the model's roots: packages, hosts, environment keys and headers — from code without comments, keys — also
|
|
364
|
+
* via `readers`.
|
|
365
|
+
*/
|
|
366
|
+
function sources(root: string, model: Model, readers?: readonly EnvReader[]): Source[] {
|
|
367
|
+
const out: Source[] = [];
|
|
368
|
+
const declared = new Set(Object.values(model.externals ?? {}).flatMap((e) => (e.headers ?? []).map((h) => h.toLowerCase())));
|
|
369
|
+
const walk = (rel: string): void => {
|
|
370
|
+
let entries;
|
|
371
|
+
try {
|
|
372
|
+
entries = readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
373
|
+
} catch {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
for (const e of entries) {
|
|
377
|
+
const child = `${rel}/${e.name}`;
|
|
378
|
+
if (e.isDirectory()) {
|
|
379
|
+
if (!SKIP.has(e.name)) walk(child);
|
|
380
|
+
} else if (CODE.test(e.name) && !/\.d\.[cm]?ts$/.test(e.name)) {
|
|
381
|
+
const text = readFileSync(path.join(root, child), "utf8");
|
|
382
|
+
const code = codeOnly(text, child);
|
|
383
|
+
out.push({
|
|
384
|
+
file: child,
|
|
385
|
+
dir: rel,
|
|
386
|
+
packages: importsIn(text, model.aliases),
|
|
387
|
+
hosts: hostsInCode(code),
|
|
388
|
+
env: envNamesInCode(code, { file: child, readers }),
|
|
389
|
+
headers: declared.size ? headersInCode(code, declared) : [],
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
for (const r of model.roots ?? ["src"]) walk(r.replace(/\/+$/, ""));
|
|
395
|
+
return out.sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Modules whose directory the path belongs to: the module with the longest matching path (`src/lib` is nested in
|
|
400
|
+
* `src` — directory `src/lib/x` belongs to module `src/lib`); several — the same path in several modules, a model
|
|
401
|
+
* conflict.
|
|
402
|
+
*/
|
|
403
|
+
function owners(model: Model, dir: string): string[] {
|
|
404
|
+
let best = -1;
|
|
405
|
+
let out: string[] = [];
|
|
406
|
+
for (const n of names(model)) {
|
|
407
|
+
for (const p of paths(model.modules[n]!)) {
|
|
408
|
+
if (dir !== p && !dir.startsWith(p + "/")) continue;
|
|
409
|
+
if (p.length > best) [best, out] = [p.length, [n]];
|
|
410
|
+
else if (p.length === best && !out.includes(n)) out.push(n);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Model tests: "directory <directory> is in module <module>" for each directory with code (the module with the longest
|
|
418
|
+
* matching path; outside the modules or one path in several modules — red),
|
|
419
|
+
* "workspace package <path> is in the code roots" for each monorepo package with code (outside `roots` — red),
|
|
420
|
+
* "<module> imports <package>" for each external package the module imports (not allowed — red),
|
|
421
|
+
* "<module> uses allowed package <package>" for each model package (not imported — red: remove it from the model).
|
|
422
|
+
* Each is "registry + invariant": not empty, a known violator, exceptions from the decision folder's `exceptions/`.
|
|
423
|
+
* `outside` — elements outside the model on purpose, with a reason (dev tools: the shared ESLint config, test helpers):
|
|
424
|
+
* a decision, not a debt with a task; a workspace package — also with the mark `spec-outside(<decision>)` in its
|
|
425
|
+
* `package.json`.
|
|
426
|
+
* `readers` — the project's own ways of reading the environment, the same as for `envVars` (`configGet`): for
|
|
427
|
+
* external system keys.
|
|
428
|
+
*/
|
|
429
|
+
export function architecture(
|
|
430
|
+
it: It,
|
|
431
|
+
opts: { root: string; model: Model; exceptions?: readonly Exception[]; outside?: readonly Outside[]; readers?: readonly EnvReader[] },
|
|
432
|
+
): void {
|
|
433
|
+
const { model } = opts;
|
|
434
|
+
// registries and test names are in the project language (the directory the runner runs from), at registration
|
|
435
|
+
const N = locale.names(locale.projectLanguage());
|
|
436
|
+
// registries are collected in full, then registered: an exception goes only to the registry that has its element
|
|
437
|
+
const specs: Invariant<unknown>[] = [];
|
|
438
|
+
const add: Add = (spec) => void specs.push(spec as Invariant<unknown>);
|
|
439
|
+
const src = sources(opts.root, model, opts.readers);
|
|
440
|
+
const dirs = sorted([...new Set(src.map((s) => s.dir))]);
|
|
441
|
+
const moduleOf = (dir: string) => owners(model, dir);
|
|
442
|
+
|
|
443
|
+
add({
|
|
444
|
+
registry: N.spec.architecture.dirsRegistry,
|
|
445
|
+
items: dirs,
|
|
446
|
+
name: (d) => locale.fill(N.spec.architecture.dirName, { dir: d, module: moduleOf(d).length === 1 ? moduleOf(d)[0]! : "?" }),
|
|
447
|
+
key: (d) => d,
|
|
448
|
+
check: (d) => {
|
|
449
|
+
const own = moduleOf(d);
|
|
450
|
+
if (!own.length) throw new Error(`${d}: code outside the model's modules — add a module to tests/architecture/model.ts or move the code`);
|
|
451
|
+
if (own.length > 1) throw new Error(`${d}: the directory is in several modules — ${own.join(", ")}`);
|
|
452
|
+
},
|
|
453
|
+
violator: { name: N.spec.architecture.dirViolator, item: "__outside_model__" },
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// the model's roots are set by hand: the check wouldn't see a new workspace package outside them at all
|
|
457
|
+
const roots = (model.roots ?? ["src"]).map((r) => r.replace(/^\.\//, "").replace(/\/+$/, ""));
|
|
458
|
+
const inRoots = (p: string) => roots.some((r) => r === p || r.startsWith(p + "/") || p.startsWith(r + "/"));
|
|
459
|
+
const packages = roots.some((r) => r === "" || r === ".") ? [] : workspacePackages(opts.root).filter((p) => hasCode(path.join(opts.root, p)));
|
|
460
|
+
if (packages.length) {
|
|
461
|
+
add({
|
|
462
|
+
registry: N.spec.architecture.workspaceRegistry,
|
|
463
|
+
items: packages,
|
|
464
|
+
name: (p) => locale.fill(N.spec.architecture.workspaceName, { path: p }),
|
|
465
|
+
key: (p) => p,
|
|
466
|
+
// "outside scope" and a package exception — a mark in its package.json: a deleted package takes its mark with it
|
|
467
|
+
// from the project root, not the runner's cwd: absolute, whatever `root` is given as
|
|
468
|
+
fileOf: (p) => path.resolve(opts.root, p, "package.json"),
|
|
469
|
+
check: (p) => {
|
|
470
|
+
if (!inRoots(p)) throw new Error(`${p}: a workspace package with code outside the model's roots — add it to roots in tests/architecture/model.ts`);
|
|
471
|
+
},
|
|
472
|
+
violator: { name: N.spec.architecture.workspaceViolator, item: "__outside_roots__" },
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// packages a module actually imports: module → package → files
|
|
477
|
+
const used = new Map<string, Map<string, string[]>>();
|
|
478
|
+
for (const s of src) {
|
|
479
|
+
const own = moduleOf(s.dir);
|
|
480
|
+
if (own.length !== 1) continue; // outside the modules — the directory test is already red
|
|
481
|
+
const byPkg = used.get(own[0]!) ?? new Map<string, string[]>();
|
|
482
|
+
for (const p of s.packages) byPkg.set(p, [...(byPkg.get(p) ?? []), s.file]);
|
|
483
|
+
used.set(own[0]!, byPkg);
|
|
484
|
+
}
|
|
485
|
+
const imports = [...used].flatMap(([mod, byPkg]) => [...byPkg].map(([pkg, files]) => ({ mod, pkg, files })));
|
|
486
|
+
const allowed = (mod: string) => model.modules[mod]?.packages ?? [];
|
|
487
|
+
add({
|
|
488
|
+
registry: N.spec.architecture.importsRegistry,
|
|
489
|
+
items: imports,
|
|
490
|
+
name: (i) => locale.fill(N.spec.architecture.importsName, { module: i.mod, package: i.pkg }),
|
|
491
|
+
key: (i) => `${i.mod}:${i.pkg}`,
|
|
492
|
+
check: (i) => {
|
|
493
|
+
if (!allowed(i.mod).includes(i.pkg)) throw new Error(`package ${i.pkg} is not allowed for module ${i.mod}: ${i.files.join(", ")}`);
|
|
494
|
+
},
|
|
495
|
+
violator: { name: N.spec.architecture.importsViolator, item: { mod: names(model)[0] ?? "", pkg: "__not_allowed__", files: ["__violator__"] } },
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const declared = names(model).flatMap((mod) => allowed(mod).map((pkg) => ({ mod, pkg })));
|
|
499
|
+
add({
|
|
500
|
+
registry: N.spec.architecture.allowedRegistry,
|
|
501
|
+
items: declared,
|
|
502
|
+
name: (d) => locale.fill(N.spec.architecture.allowedName, { module: d.mod, package: d.pkg }),
|
|
503
|
+
key: (d) => `${d.mod}:${d.pkg}`,
|
|
504
|
+
check: (d) => {
|
|
505
|
+
if (!used.get(d.mod)?.has(d.pkg)) throw new Error(`module ${d.mod} doesn't import ${d.pkg} — remove the package from the model or fix the code`);
|
|
506
|
+
},
|
|
507
|
+
violator: { name: N.spec.architecture.allowedViolator, item: { mod: "__no_module__", pkg: "__unused__" } },
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
if (model.externals) c1(add, N, model, src);
|
|
511
|
+
if (model.containers) c2(add, N, opts.root, model, src);
|
|
512
|
+
const exceptions = route(opts.exceptions ?? [], specs);
|
|
513
|
+
const outside = route(opts.outside ?? [], specs);
|
|
514
|
+
for (const [i, spec] of specs.entries()) invariant(it, { ...spec, exceptions: exceptions[i], outside: outside[i] });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
type Add = <T>(spec: Invariant<T>) => void;
|
|
518
|
+
|
|
519
|
+
/** "Outside scope" for a model element: key, reason and the registry name when the element is in several registries. */
|
|
520
|
+
export interface Outside {
|
|
521
|
+
item: string;
|
|
522
|
+
reason: string;
|
|
523
|
+
rule?: string;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The model's registries: an exception's `rule` names a registry in any dictionary language — the project could have
|
|
528
|
+
* changed its language.
|
|
529
|
+
*/
|
|
530
|
+
const REGISTRIES: ((n: Names) => string)[] = [
|
|
531
|
+
(n) => n.spec.architecture.dirsRegistry,
|
|
532
|
+
(n) => n.spec.architecture.workspaceRegistry,
|
|
533
|
+
(n) => n.spec.architecture.importsRegistry,
|
|
534
|
+
(n) => n.spec.architecture.allowedRegistry,
|
|
535
|
+
(n) => n.spec.architecture.containersRegistry,
|
|
536
|
+
(n) => n.spec.architecture.edgesRegistry,
|
|
537
|
+
(n) => n.spec.architecture.unitsRegistry,
|
|
538
|
+
(n) => n.spec.architecture.clientsRegistry,
|
|
539
|
+
(n) => n.spec.architecture.hostsRegistry,
|
|
540
|
+
(n) => n.spec.architecture.packagesRegistry,
|
|
541
|
+
(n) => n.spec.architecture.envRegistry,
|
|
542
|
+
(n) => n.spec.architecture.headersRegistry,
|
|
543
|
+
];
|
|
544
|
+
/** The registry name in the project language for a `rule` in any dictionary language; not a model registry — as is. */
|
|
545
|
+
const registryOf = (rule: string, n: Names): string => REGISTRIES.find((pick) => locale.every(pick).includes(rule))?.(n) ?? rule;
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Exceptions and "outside scope" by registry: with `rule` — to the registry with that name, without — to the registries
|
|
549
|
+
* that have its element; the element is nowhere — to the first registry, so the ratchet says "remove the exception"
|
|
550
|
+
* ("remove from scope") instead of staying silent.
|
|
551
|
+
*/
|
|
552
|
+
function route<X extends { item: string; rule?: string }>(xs: readonly X[], specs: Invariant<unknown>[]): Omit<X, "rule">[][] {
|
|
553
|
+
const keys = specs.map((sp) => new Set(sp.items.map((x) => String((sp.key ?? sp.name)(x)))));
|
|
554
|
+
const out: Omit<X, "rule">[][] = specs.map(() => []);
|
|
555
|
+
const n = locale.names(locale.projectLanguage());
|
|
556
|
+
for (const { rule, ...x } of xs) {
|
|
557
|
+
const byRule = rule ? specs.findIndex((sp) => sp.registry === registryOf(rule, n)) : -1;
|
|
558
|
+
let to = byRule >= 0 ? [byRule] : keys.flatMap((k, i) => (!rule && k.has(x.item) ? [i] : []));
|
|
559
|
+
if (!to.length) to = [0];
|
|
560
|
+
for (const i of to) out[i]!.push(x);
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Each module's containers: from the containers' `modules`, for a library — also the containers of the modules that
|
|
567
|
+
* depend on it (transitively: a library's library is built into the same ones). The lists are sorted.
|
|
568
|
+
*/
|
|
569
|
+
export function moduleContainers(model: Model): Map<string, string[]> {
|
|
570
|
+
const cont = model.containers ?? {};
|
|
571
|
+
const out = new Map(Object.keys(model.modules).map((m) => [m, new Set(Object.keys(cont).filter((c) => (cont[c]!.modules ?? []).includes(m)))]));
|
|
572
|
+
for (let changed = true; changed; ) {
|
|
573
|
+
changed = false;
|
|
574
|
+
for (const [m, x] of Object.entries(model.modules)) {
|
|
575
|
+
for (const d of x.dependsOn ?? []) {
|
|
576
|
+
if (!model.modules[d]?.library) continue;
|
|
577
|
+
const to = out.get(d)!;
|
|
578
|
+
for (const c of out.get(m)!) {
|
|
579
|
+
if (to.has(c)) continue;
|
|
580
|
+
to.add(c);
|
|
581
|
+
changed = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return new Map([...out].map(([m, cs]) => [m, sorted([...cs])]));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* C2: a module is in exactly one container, a library — in at least one; module dependencies stay inside a container:
|
|
591
|
+
* the target is in every container of the source (across containers — only `uses`); the deployable units of the configs
|
|
592
|
+
* and of the model match both ways; a storage client is only in a container with a `uses` relation to that storage.
|
|
593
|
+
*/
|
|
594
|
+
function c2(add: Add, N: Names, root: string, model: Model, src: Source[]): void {
|
|
595
|
+
const cont = model.containers!;
|
|
596
|
+
const of = moduleContainers(model);
|
|
597
|
+
const containerOf = (m: string) => of.get(m) ?? [];
|
|
598
|
+
const isLibrary = (m: string) => !!model.modules[m]?.library;
|
|
599
|
+
add({
|
|
600
|
+
registry: N.spec.architecture.containersRegistry,
|
|
601
|
+
items: names(model),
|
|
602
|
+
name: (m) =>
|
|
603
|
+
isLibrary(m)
|
|
604
|
+
? locale.fill(N.spec.architecture.libraryName, { module: m, containers: containerOf(m).join(", ") || "?" })
|
|
605
|
+
: locale.fill(N.spec.architecture.moduleName, { module: m, container: containerOf(m).length === 1 ? containerOf(m)[0]! : "?" }),
|
|
606
|
+
key: (m) => m,
|
|
607
|
+
check: (m) => {
|
|
608
|
+
const c = containerOf(m);
|
|
609
|
+
if (isLibrary(m)) {
|
|
610
|
+
if (!c.length) throw new Error(`library ${m} is in no container: no module of the containers depends on it`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (!c.length) throw new Error(`module ${m} is in no container of the model`);
|
|
614
|
+
if (c.length > 1) throw new Error(`module ${m} is in several containers: ${c.join(", ")} — mark a shared package with library: true`);
|
|
615
|
+
},
|
|
616
|
+
violator: { name: N.spec.architecture.containersViolator, item: "__outside_containers__" },
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const edges = names(model).flatMap((m) => (model.modules[m]!.dependsOn ?? []).map((d) => ({ m, d })));
|
|
620
|
+
if (edges.length) {
|
|
621
|
+
add({
|
|
622
|
+
registry: N.spec.architecture.edgesRegistry,
|
|
623
|
+
items: edges,
|
|
624
|
+
name: (e) => locale.fill(N.spec.architecture.edgeName, { from: e.m, to: e.d }),
|
|
625
|
+
key: (e) => `${e.m}→${e.d}`,
|
|
626
|
+
check: (e) => {
|
|
627
|
+
const a = containerOf(e.m);
|
|
628
|
+
const b = containerOf(e.d);
|
|
629
|
+
// a module outside containers isn't "inside a container" either: else a violator would pass on an empty list
|
|
630
|
+
if (!a.length || a.some((c) => !b.includes(c))) {
|
|
631
|
+
throw new Error(`${e.m} (${a.join(", ") || "?"}) depends on ${e.d} (${b.join(", ") || "?"}) — across containers only a uses link`);
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
violator: { name: N.spec.architecture.edgeViolator, item: { m: "__a__", d: "__b__" } },
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const found = deployUnits(root);
|
|
639
|
+
const declared = new Map<string, string>();
|
|
640
|
+
for (const [c, x] of Object.entries(cont)) for (const d of x.deploy ?? []) declared.set(d, c);
|
|
641
|
+
add({
|
|
642
|
+
registry: N.spec.architecture.unitsRegistry,
|
|
643
|
+
items: sorted([...new Set([...found, ...declared.keys()])]),
|
|
644
|
+
name: (u) => locale.fill(N.spec.architecture.unitName, { unit: u, container: declared.get(u) ?? "?" }),
|
|
645
|
+
key: (u) => u,
|
|
646
|
+
check: (u) => {
|
|
647
|
+
if (!declared.has(u)) throw new Error(`${u} is in the deploy configs but not in the model — add a container to tests/architecture/model.ts`);
|
|
648
|
+
if (!found.includes(u)) throw new Error(`${u} of container ${declared.get(u)} is not in the deploy configs — the model diverged from the deploy`);
|
|
649
|
+
},
|
|
650
|
+
violator: { name: N.spec.architecture.unitViolator, item: "__outside_model__" },
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// storage clients: which container actually imports them (a library — each of its containers)
|
|
654
|
+
const storages = Object.entries(cont).flatMap(([s, x]) => (x.clients ?? []).map((pkg) => ({ s, pkg })));
|
|
655
|
+
if (storages.length) {
|
|
656
|
+
const uses = new Map<string, { s: string; pkg: string; c: string; files: string[] }>();
|
|
657
|
+
for (const src1 of src) {
|
|
658
|
+
const mod = owners(model, src1.dir);
|
|
659
|
+
if (mod.length !== 1) continue;
|
|
660
|
+
for (const c of containerOf(mod[0]!)) {
|
|
661
|
+
for (const st of storages) {
|
|
662
|
+
if (!src1.packages.includes(st.pkg)) continue;
|
|
663
|
+
const k = `${st.pkg}:${st.s}:${c}`;
|
|
664
|
+
const u = uses.get(k) ?? { ...st, c, files: [] };
|
|
665
|
+
u.files.push(src1.file);
|
|
666
|
+
uses.set(k, u);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
add({
|
|
671
|
+
registry: N.spec.architecture.clientsRegistry,
|
|
672
|
+
items: [...uses.values()],
|
|
673
|
+
name: (u) => locale.fill(N.spec.architecture.clientName, { package: u.pkg, storage: u.s, container: u.c }),
|
|
674
|
+
key: (u) => `${u.pkg}:${u.c}`,
|
|
675
|
+
check: (u) => {
|
|
676
|
+
if (u.c !== u.s && !(cont[u.c]?.uses ?? []).includes(u.s)) {
|
|
677
|
+
throw new Error(`container ${u.c} imports client ${u.pkg} of storage ${u.s} without a uses link: ${u.files.join(", ")}`);
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
violator: { name: N.spec.architecture.clientViolator, item: { s: "__storage__", pkg: "__client__", c: "__container__", files: [] } },
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The external system's adapter modules: none — a perimeter. */
|
|
686
|
+
export const adaptersOf = (e: External): string[] => (e.adapter === undefined ? [] : Array.isArray(e.adapter) ? e.adapter : [e.adapter]);
|
|
687
|
+
|
|
688
|
+
/** "adapter a" / "adapters a, b" — the form by the number of adapters. */
|
|
689
|
+
const adapterWord = (a: string[], one: string, many: string): string => `${a.length === 1 ? one : many} ${a.join(", ")}`;
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* C1: an external system's host is only in its adapters (a host outside the model — red), an external system's package
|
|
693
|
+
* is only with the adapters (a model without contradictions), only adapters read an external system's environment key
|
|
694
|
+
* and header; a perimeter (no adapter) has none of these in the code. Registries come from the model and the code; a
|
|
695
|
+
* registry the model doesn't declare (the external systems have no packages) isn't registered.
|
|
696
|
+
*/
|
|
697
|
+
function c1(add: Add, N: Names, model: Model, src: Source[]): void {
|
|
698
|
+
const ext = model.externals!;
|
|
699
|
+
const inAdapters = (s: Source, a: string[]) => owners(model, s.dir).some((m) => a.includes(m));
|
|
700
|
+
const files = (xs: Source[]) => xs.map((s) => s.file).join(", ");
|
|
701
|
+
const hostOwner = new Map<string, string>();
|
|
702
|
+
for (const [name, e] of Object.entries(ext)) for (const h of e.hosts ?? []) hostOwner.set(h.toLowerCase(), name);
|
|
703
|
+
const hostFiles = new Map<string, Source[]>();
|
|
704
|
+
for (const s of src) for (const h of s.hosts) hostFiles.set(h, [...(hostFiles.get(h) ?? []), s]);
|
|
705
|
+
const hosts = sorted([...new Set([...hostOwner.keys(), ...hostFiles.keys()])]);
|
|
706
|
+
if (hosts.length) {
|
|
707
|
+
add({
|
|
708
|
+
registry: N.spec.architecture.hostsRegistry,
|
|
709
|
+
items: hosts,
|
|
710
|
+
name: (h) => {
|
|
711
|
+
const x = hostOwner.get(h);
|
|
712
|
+
if (!x) return locale.fill(N.spec.architecture.hostName, { host: h, adapters: locale.fill(N.spec.architecture.inAdapter, { list: "?" }) });
|
|
713
|
+
const a = adaptersOf(ext[x]!);
|
|
714
|
+
const adapters = locale.fill(a.length === 1 ? N.spec.architecture.inAdapter : N.spec.architecture.inAdapters, { list: a.join(", ") });
|
|
715
|
+
return a.length ? locale.fill(N.spec.architecture.hostName, { host: h, adapters }) : locale.fill(N.spec.architecture.hostPerimeter, { host: h, system: x });
|
|
716
|
+
},
|
|
717
|
+
key: (h) => h,
|
|
718
|
+
check: (h) => {
|
|
719
|
+
const found = hostFiles.get(h) ?? [];
|
|
720
|
+
const x = hostOwner.get(h);
|
|
721
|
+
if (!x) throw new Error(`host ${h} is declared by no external system of the model: ${files(found)}`);
|
|
722
|
+
const a = adaptersOf(ext[x]!);
|
|
723
|
+
const outside = found.filter((s) => !inAdapters(s, a));
|
|
724
|
+
if (!outside.length) return;
|
|
725
|
+
if (!a.length) throw new Error(`external system ${x} has no adapter, yet host ${h} is in the code: ${files(outside)}`);
|
|
726
|
+
throw new Error(`host ${h} of external system ${x} is outside its ${adapterWord(a, "adapter", "adapters")}: ${files(outside)}`);
|
|
727
|
+
},
|
|
728
|
+
violator: { name: N.spec.architecture.hostViolator, item: "__outside_model__.example" },
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const pkgs = Object.entries(ext).flatMap(([x, e]) => (e.packages ?? []).map((p) => ({ x, p, a: adaptersOf(e) })));
|
|
732
|
+
if (pkgs.length) {
|
|
733
|
+
add({
|
|
734
|
+
registry: N.spec.architecture.packagesRegistry,
|
|
735
|
+
items: pkgs,
|
|
736
|
+
name: (i) =>
|
|
737
|
+
i.a.length
|
|
738
|
+
? locale.fill(N.spec.architecture.packageName, { package: i.p, system: i.x, adapters: locale.fill(i.a.length === 1 ? N.spec.architecture.withAdapter : N.spec.architecture.withAdapters, { list: i.a.join(", ") }) })
|
|
739
|
+
: locale.fill(N.spec.architecture.packagePerimeter, { package: i.p, system: i.x }),
|
|
740
|
+
key: (i) => `${i.x}:${i.p}`,
|
|
741
|
+
check: (i) => {
|
|
742
|
+
const allowedTo = names(model).filter((n) => (model.modules[n]!.packages ?? []).includes(i.p));
|
|
743
|
+
if (!i.a.length) {
|
|
744
|
+
if (allowedTo.length) throw new Error(`external system ${i.x} has no adapter, yet package ${i.p} is allowed for modules: ${allowedTo.join(", ")}`);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (!i.a.some((a) => allowedTo.includes(a))) {
|
|
748
|
+
throw new Error(`package ${i.p} of external system ${i.x} is not allowed in the model for ${i.a.length === 1 ? `adapter ${i.a[0]}` : `any of adapters ${i.a.join(", ")}`}`);
|
|
749
|
+
}
|
|
750
|
+
const others = allowedTo.filter((n) => !i.a.includes(n));
|
|
751
|
+
if (others.length) throw new Error(`package ${i.p} of external system ${i.x} is allowed not only for ${i.a.length === 1 ? "the adapter" : "the adapters"}: ${others.join(", ")}`);
|
|
752
|
+
},
|
|
753
|
+
violator: { name: N.spec.architecture.packageViolator, item: { x: "__violator__", p: "__none__", a: ["__no_module__"] } },
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// an environment key and a header are one convention: only the adapter reads them; the violator gets its own source
|
|
758
|
+
// that reads outside the adapter
|
|
759
|
+
type Read = { x: string; k: string; a: string[]; src?: Source[] };
|
|
760
|
+
const readOnlyByAdapters = (registry: string, noun: string, word: string, list: (e: External) => string[] | undefined, reads: (s: Source, k: string) => boolean, fake: Partial<Source>) => {
|
|
761
|
+
const items: Read[] = Object.entries(ext).flatMap(([x, e]) => (list(e) ?? []).map((k) => ({ x, k, a: adaptersOf(e) })));
|
|
762
|
+
if (!items.length) return;
|
|
763
|
+
add({
|
|
764
|
+
registry,
|
|
765
|
+
items,
|
|
766
|
+
name: (i) =>
|
|
767
|
+
i.a.length
|
|
768
|
+
? locale.fill(i.a.length === 1 ? N.spec.architecture.readName : N.spec.architecture.readNameMany, { noun, key: i.k, system: i.x, list: i.a.join(", ") })
|
|
769
|
+
: locale.fill(N.spec.architecture.readPerimeter, { noun, key: i.k, system: i.x }),
|
|
770
|
+
key: (i) => `${i.x}:${i.k}`,
|
|
771
|
+
check: (i) => {
|
|
772
|
+
const outside = (i.src ?? src).filter((s) => reads(s, i.k) && !inAdapters(s, i.a));
|
|
773
|
+
if (!outside.length) return;
|
|
774
|
+
if (!i.a.length) throw new Error(`external system ${i.x} has no adapter, yet ${word} ${i.k} is read in the code: ${files(outside)}`);
|
|
775
|
+
throw new Error(`${word} ${i.k} of external system ${i.x} is read outside its ${adapterWord(i.a, "adapter", "adapters")}: ${files(outside)}`);
|
|
776
|
+
},
|
|
777
|
+
violator: {
|
|
778
|
+
name: locale.fill(N.spec.architecture.readViolator, { noun }),
|
|
779
|
+
item: { x: "__violator__", k: "__KEY__", a: ["__no_module__"], src: [{ file: "__violator__.ts", dir: "__outside_model__", packages: [], hosts: [], env: [], headers: [], ...fake }] },
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
};
|
|
783
|
+
readOnlyByAdapters(N.spec.architecture.envRegistry, N.spec.architecture.envNoun, "key", (e) => e.env, (s, k) => s.env.includes(k), { env: ["__KEY__"] });
|
|
784
|
+
readOnlyByAdapters(N.spec.architecture.headersRegistry, N.spec.architecture.headerNoun, "header", (e) => e.headers, (s, k) => s.headers.includes(k.toLowerCase()), { headers: ["__key__"] });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
788
|
+
|
|
789
|
+
/** `fetch` under the network guard: rejected requests accumulate until the check after the test. */
|
|
790
|
+
export interface GuardedFetch extends Fetch {
|
|
791
|
+
/** Hosts of rejected requests since the last check, in order. */
|
|
792
|
+
rejected: string[];
|
|
793
|
+
/** The check after a test (`afterEach`): rejected requests — throws with the list of hosts; the list is cleared. */
|
|
794
|
+
check(): void;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* `fetch` for tests — the network under mocks: whatever reached the guard, nobody mocked. Only local addresses
|
|
799
|
+
* (loopback) and the `allow` hosts (an integration test with a sandbox) pass through to `fetchImpl`; a request to a
|
|
800
|
+
* declared external system fails with the host and system name — a forgotten mock doesn't go to the network; to a host
|
|
801
|
+
* outside the model — "declare the external system". The code under test may catch the `fetch` exception — so
|
|
802
|
+
* rejections accumulate in `rejected`, and `check()` after the test throws them. Setup — in the test setup, before the
|
|
803
|
+
* mocks (`vi.spyOn`, MSW `server.listen()` — on top of it):
|
|
804
|
+
* `const guard = networkGuard(model, globalThis.fetch); globalThis.fetch = guard; afterEach(() => guard.check())`.
|
|
805
|
+
*/
|
|
806
|
+
export function networkGuard(model: Model, fetchImpl: Fetch = globalThis.fetch, opts: { allow?: string[] } = {}): GuardedFetch {
|
|
807
|
+
const owner = new Map<string, string>();
|
|
808
|
+
for (const [name, e] of Object.entries(model.externals ?? {})) for (const h of e.hosts ?? []) owner.set(h.toLowerCase(), name);
|
|
809
|
+
const allow = new Set((opts.allow ?? []).map((h) => h.toLowerCase()));
|
|
810
|
+
const rejected: string[] = [];
|
|
811
|
+
const guard: Fetch = async (input, init) => {
|
|
812
|
+
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
813
|
+
const host = new URL(href).hostname.toLowerCase();
|
|
814
|
+
if (isLocal(host) || allow.has(host)) return fetchImpl(input, init);
|
|
815
|
+
rejected.push(host);
|
|
816
|
+
const system = owner.get(host);
|
|
817
|
+
throw new Error(
|
|
818
|
+
system
|
|
819
|
+
? `request to host ${host} of external system ${system} without a mock — mock the request: tests go to the network only to local addresses and allow`
|
|
820
|
+
: `request to host ${host} outside the model — declare the external system in tests/architecture/model.ts and mock the request`,
|
|
821
|
+
);
|
|
822
|
+
};
|
|
823
|
+
const check = () => {
|
|
824
|
+
if (!rejected.length) return;
|
|
825
|
+
const hosts = [...new Set(rejected)].join(", ");
|
|
826
|
+
rejected.length = 0;
|
|
827
|
+
throw new Error(`network requests rejected by the guard: ${hosts} — mock them (the code under test may have caught the fetch exception)`);
|
|
828
|
+
};
|
|
829
|
+
return Object.assign(guard, { rejected, check });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* The browser's CSP `connect-src` from the hosts of the model's external systems: a test checks the project's header
|
|
834
|
+
* against it.
|
|
835
|
+
*/
|
|
836
|
+
export function cspConnectSrc(model: Model, self: string[] = ["'self'"]): string {
|
|
837
|
+
const hosts = sorted([...new Set(Object.values(model.externals ?? {}).flatMap((e) => (e.hosts ?? []).map((h) => h.toLowerCase())))]);
|
|
838
|
+
return ["connect-src", ...self, ...hosts.map((h) => `https://${h}`)].join(" ");
|
|
839
|
+
}
|