@goodbones/cli 0.1.0-beta.1 → 0.1.0-beta.11
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/build/dts/config-loader.d.ts +6 -1
- package/build/dts/config-loader.d.ts.map +1 -1
- package/build/dts/infer.d.ts +34 -0
- package/build/dts/infer.d.ts.map +1 -0
- package/build/dts/run.d.ts +80 -6
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/config-loader.js +52 -5
- package/build/esm/config-loader.js.map +1 -1
- package/build/esm/infer.js +378 -0
- package/build/esm/infer.js.map +1 -0
- package/build/esm/main.js +1 -1
- package/build/esm/main.js.map +1 -1
- package/build/esm/run.js +983 -70
- package/build/esm/run.js.map +1 -1
- package/package.json +5 -3
- package/src/config-loader.ts +67 -5
- package/src/infer.ts +511 -0
- package/src/main.ts +3 -1
- package/src/run.ts +1401 -92
package/src/infer.ts
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as readline from "node:readline/promises";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Candidate,
|
|
7
|
+
candidatesOf,
|
|
8
|
+
coverageOf,
|
|
9
|
+
cyclesIn,
|
|
10
|
+
formatManifestYaml,
|
|
11
|
+
fractionsOf,
|
|
12
|
+
type Generalization,
|
|
13
|
+
inferManifest,
|
|
14
|
+
type InferredTarget,
|
|
15
|
+
listPackageRoots,
|
|
16
|
+
listSourceFiles,
|
|
17
|
+
type LoadedPolicy,
|
|
18
|
+
type Manifest,
|
|
19
|
+
MANIFEST_FILENAMES,
|
|
20
|
+
MANIFEST_SCHEMA_ID,
|
|
21
|
+
type SourceFacts,
|
|
22
|
+
} from "@goodbones/core";
|
|
23
|
+
import * as Effect from "effect/Effect";
|
|
24
|
+
import * as Result from "effect/Result";
|
|
25
|
+
|
|
26
|
+
import { hostLanguages, loadPolicyFromFile, loadPolicyFromManifest } from "./config-loader.js";
|
|
27
|
+
import { buildGraph } from "./graph.js";
|
|
28
|
+
import { sourceFactsOf } from "./source-facts.js";
|
|
29
|
+
|
|
30
|
+
// `architecture infer`: the as-built manifest, for a repository that has none.
|
|
31
|
+
//
|
|
32
|
+
// The core decides what the tree says (`inferManifest`); this command is the
|
|
33
|
+
// host around it — it walks, parses and resolves every file, asks the human
|
|
34
|
+
// the questions only a human can answer, proves the result loads, and writes
|
|
35
|
+
// the YAML. Nothing in the core knows a terminal exists.
|
|
36
|
+
|
|
37
|
+
export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
|
|
38
|
+
const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
|
|
39
|
+
|
|
40
|
+
export type InferFlags = {
|
|
41
|
+
readonly depth: number;
|
|
42
|
+
readonly roots: ReadonlyArray<string>;
|
|
43
|
+
readonly tsconfig: string | null;
|
|
44
|
+
readonly write: boolean;
|
|
45
|
+
// How the questions are answered: at the terminal, all yes, or not asked.
|
|
46
|
+
readonly answers: "ask" | "yes" | "exhaustive";
|
|
47
|
+
readonly collapse: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const INFER_USAGE =
|
|
51
|
+
"infer [--depth N] [--root DIR]... [--tsconfig PATH] [--write] [--yes | --exhaustive] [--collapse]";
|
|
52
|
+
|
|
53
|
+
export const parseInferFlags = (argv: ReadonlyArray<string>): Result.Result<InferFlags, string> => {
|
|
54
|
+
let depth = 2;
|
|
55
|
+
const roots: Array<string> = [];
|
|
56
|
+
let tsconfig: string | null = null;
|
|
57
|
+
let write = false;
|
|
58
|
+
let answers: InferFlags["answers"] = "ask";
|
|
59
|
+
let collapse = false;
|
|
60
|
+
|
|
61
|
+
const args = [...argv];
|
|
62
|
+
while (args.length > 0) {
|
|
63
|
+
const arg = args.shift();
|
|
64
|
+
if (arg === undefined) break;
|
|
65
|
+
const value = (): Result.Result<string, string> => {
|
|
66
|
+
const next = args.shift();
|
|
67
|
+
return next === undefined || next.startsWith("--")
|
|
68
|
+
? Result.fail(`${arg} needs a value`)
|
|
69
|
+
: Result.succeed(next);
|
|
70
|
+
};
|
|
71
|
+
switch (arg) {
|
|
72
|
+
case "--depth": {
|
|
73
|
+
const given = value();
|
|
74
|
+
if (Result.isFailure(given)) return Result.fail(given.failure);
|
|
75
|
+
const parsed = Number(given.success);
|
|
76
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
77
|
+
return Result.fail(`--depth takes a whole number, not "${given.success}"`);
|
|
78
|
+
}
|
|
79
|
+
depth = parsed;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "--root": {
|
|
83
|
+
const given = value();
|
|
84
|
+
if (Result.isFailure(given)) return Result.fail(given.failure);
|
|
85
|
+
roots.push(given.success);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "--tsconfig": {
|
|
89
|
+
const given = value();
|
|
90
|
+
if (Result.isFailure(given)) return Result.fail(given.failure);
|
|
91
|
+
tsconfig = given.success;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "--write":
|
|
95
|
+
write = true;
|
|
96
|
+
break;
|
|
97
|
+
case "--yes":
|
|
98
|
+
answers = "yes";
|
|
99
|
+
break;
|
|
100
|
+
case "--exhaustive":
|
|
101
|
+
answers = "exhaustive";
|
|
102
|
+
break;
|
|
103
|
+
case "--collapse":
|
|
104
|
+
collapse = true;
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
if (arg.startsWith("--")) return Result.fail(`unknown flag ${arg}. Usage: ${INFER_USAGE}`);
|
|
108
|
+
roots.push(arg);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return Result.succeed({ depth, roots, tsconfig, write, answers, collapse });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// What the command talks through, so a test can answer the questions and
|
|
115
|
+
// read the output without a terminal.
|
|
116
|
+
export type InferIo = {
|
|
117
|
+
readonly out: (text: string) => void;
|
|
118
|
+
readonly err: (line: string) => void;
|
|
119
|
+
readonly ask: (question: string) => Promise<boolean>;
|
|
120
|
+
readonly interactive: boolean;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const terminalIo = (): InferIo => ({
|
|
124
|
+
out: (text) => process.stdout.write(text),
|
|
125
|
+
err: (line) => process.stderr.write(`${line}\n`),
|
|
126
|
+
ask: async (question) => {
|
|
127
|
+
// The questions go to stderr so stdout stays the manifest.
|
|
128
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
129
|
+
try {
|
|
130
|
+
const answer = (await rl.question(`${question} [Y/n] `)).trim().toLowerCase();
|
|
131
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
132
|
+
} finally {
|
|
133
|
+
rl.close();
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
interactive: process.stdin.isTTY === true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const normalizeRoot = (root: string): string =>
|
|
140
|
+
root.replaceAll(path.sep, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
141
|
+
|
|
142
|
+
const isGlob = (key: string): boolean => /[*{}|[\]]/.test(key);
|
|
143
|
+
|
|
144
|
+
// The top-level keys of an existing manifest, as folders: aliases expanded,
|
|
145
|
+
// the trailing slash dropped, and any key that is a pattern left out.
|
|
146
|
+
const rootsOfManifest = (manifest: Manifest): ReadonlyArray<string> => {
|
|
147
|
+
const aliases = Object.entries(manifest.aliases ?? {}).sort(([a], [b]) => b.length - a.length);
|
|
148
|
+
const expand = (key: string): string => {
|
|
149
|
+
for (const [alias, target] of aliases) {
|
|
150
|
+
if (key === alias) return target;
|
|
151
|
+
if (key.startsWith(`${alias}/`)) return `${target}${key.slice(alias.length)}`;
|
|
152
|
+
}
|
|
153
|
+
return key;
|
|
154
|
+
};
|
|
155
|
+
return Object.keys(manifest.tree)
|
|
156
|
+
.filter((key) => !isGlob(key))
|
|
157
|
+
.map((key) => normalizeRoot(expand(key)))
|
|
158
|
+
.filter((key) => key !== "");
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// With no manifest to read them from: `src/` when there is one, otherwise
|
|
162
|
+
// every top-level folder that holds a source file.
|
|
163
|
+
const rootsOfRepository = (
|
|
164
|
+
repoRoot: string,
|
|
165
|
+
languages: LoadedPolicy["languages"],
|
|
166
|
+
): ReadonlyArray<string> => {
|
|
167
|
+
if (existsSync(path.join(repoRoot, "src"))) return ["src"];
|
|
168
|
+
const folders = readdirSync(repoRoot).filter((entry) => {
|
|
169
|
+
if (entry.startsWith(".")) return false;
|
|
170
|
+
try {
|
|
171
|
+
return statSync(path.join(repoRoot, entry)).isDirectory();
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
return folders.filter((folder) => listSourceFiles(repoRoot, [folder], languages).length > 0);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// A policy that can resolve the tree and nothing else: one open root per
|
|
180
|
+
// walk root, no allowlist, resolution through the tsconfig the flag names or
|
|
181
|
+
// the one at the repository root.
|
|
182
|
+
const bootstrapManifest = (roots: ReadonlyArray<string>, tsconfig: string): Manifest => ({
|
|
183
|
+
resolve: {
|
|
184
|
+
scopes: [{ files: "", language: "typescript", options: { tsconfig } }],
|
|
185
|
+
unresolved: "off",
|
|
186
|
+
},
|
|
187
|
+
tree: Object.fromEntries(
|
|
188
|
+
roots.map((root) => [
|
|
189
|
+
`${root}/`,
|
|
190
|
+
{ layout: "open" as const, imports: { unrestricted: true }, children: {} },
|
|
191
|
+
]),
|
|
192
|
+
),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
type Setting = {
|
|
196
|
+
readonly policy: LoadedPolicy;
|
|
197
|
+
readonly roots: ReadonlyArray<string>;
|
|
198
|
+
readonly resolve: Manifest["resolve"];
|
|
199
|
+
readonly aliases: Readonly<Record<string, string>> | undefined;
|
|
200
|
+
readonly baseline: string;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const settingOf = async (
|
|
204
|
+
repoRoot: string,
|
|
205
|
+
configFilename: string | undefined,
|
|
206
|
+
flags: InferFlags,
|
|
207
|
+
): Promise<Result.Result<Setting, string>> => {
|
|
208
|
+
const hasManifest =
|
|
209
|
+
configFilename !== undefined ||
|
|
210
|
+
MANIFEST_FILENAMES.some((name) => existsSync(path.resolve(repoRoot, name)));
|
|
211
|
+
|
|
212
|
+
if (hasManifest && flags.tsconfig === null) {
|
|
213
|
+
const policy = await loadPolicyFromFile(repoRoot, configFilename);
|
|
214
|
+
const fromManifest = rootsOfManifest(policy.config);
|
|
215
|
+
const roots =
|
|
216
|
+
flags.roots.length > 0
|
|
217
|
+
? flags.roots.map(normalizeRoot)
|
|
218
|
+
: fromManifest.length > 0
|
|
219
|
+
? fromManifest
|
|
220
|
+
: rootsOfRepository(repoRoot, policy.languages);
|
|
221
|
+
return Result.succeed({
|
|
222
|
+
policy,
|
|
223
|
+
roots,
|
|
224
|
+
resolve: policy.config.resolve,
|
|
225
|
+
aliases: policy.config.aliases,
|
|
226
|
+
baseline: policy.config.baseline ?? ".architecture-baseline.json",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const tsconfig = flags.tsconfig ?? "tsconfig.json";
|
|
231
|
+
if (!existsSync(path.resolve(repoRoot, tsconfig))) {
|
|
232
|
+
return Result.fail(
|
|
233
|
+
`${tsconfig} does not exist, and the TypeScript resolver needs one to turn a specifier ` +
|
|
234
|
+
`into a file. Name the right one with --tsconfig <path>.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const roots =
|
|
238
|
+
flags.roots.length > 0
|
|
239
|
+
? flags.roots.map(normalizeRoot)
|
|
240
|
+
: rootsOfRepository(repoRoot, hostLanguages());
|
|
241
|
+
if (roots.length === 0) {
|
|
242
|
+
return Result.fail(
|
|
243
|
+
"no source files found under any top-level folder. Name the folder to describe with --root <dir>.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const loaded = loadPolicyFromManifest(repoRoot, bootstrapManifest(roots, tsconfig));
|
|
247
|
+
if (Result.isFailure(loaded)) return Result.fail(String(loaded.failure));
|
|
248
|
+
return Result.succeed({
|
|
249
|
+
policy: loaded.success,
|
|
250
|
+
roots,
|
|
251
|
+
resolve: { ...loaded.success.config.resolve, unresolved: "error" },
|
|
252
|
+
aliases: undefined,
|
|
253
|
+
baseline: ".architecture-baseline.json",
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const describeCandidate = (candidate: Candidate): string => {
|
|
258
|
+
const parent = candidate.parent === "" ? "the root" : `${candidate.parent}/`;
|
|
259
|
+
const shared = [
|
|
260
|
+
...candidate.sharedFolders.map((one) => `${one}/`),
|
|
261
|
+
...candidate.sharedStereotypes,
|
|
262
|
+
];
|
|
263
|
+
const key =
|
|
264
|
+
candidate.parent === ""
|
|
265
|
+
? `{${candidate.capture}}/`
|
|
266
|
+
: `${candidate.parent}/{${candidate.capture}}/`;
|
|
267
|
+
return (
|
|
268
|
+
`\n${parent}: ${candidate.members.join(", ")} each have ${shared.join(", ")}.\n` +
|
|
269
|
+
` Describe them as one node, ${key}?`
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const describeReach = (candidate: Candidate): string => {
|
|
274
|
+
const glob =
|
|
275
|
+
candidate.parent === ""
|
|
276
|
+
? `*/${candidate.crossReach ?? ""}`
|
|
277
|
+
: `${candidate.parent}/*/${candidate.crossReach ?? ""}`;
|
|
278
|
+
return (
|
|
279
|
+
` All ${String(candidate.crossEdges)} imports between them land on ${candidate.crossReach ?? ""}.\n` +
|
|
280
|
+
` Restrict what one may reach in another to ${glob}?`
|
|
281
|
+
);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const keyOf = (candidate: Candidate): string =>
|
|
285
|
+
`${candidate.parent}:${candidate.members.join(",")}`;
|
|
286
|
+
|
|
287
|
+
// The questionnaire. Each round recomputes the candidates over the tree as
|
|
288
|
+
// it now stands, so a group accepted at one level can reveal one above it;
|
|
289
|
+
// a declined group is not asked twice.
|
|
290
|
+
const decide = async (
|
|
291
|
+
input: Parameters<typeof candidatesOf>[0],
|
|
292
|
+
flags: InferFlags,
|
|
293
|
+
io: InferIo,
|
|
294
|
+
): Promise<ReadonlyArray<Generalization>> => {
|
|
295
|
+
if (flags.answers === "exhaustive") return [];
|
|
296
|
+
const accepted: Array<Generalization> = [];
|
|
297
|
+
const declined = new Set<string>();
|
|
298
|
+
for (;;) {
|
|
299
|
+
const next = candidatesOf(input, accepted).find((one) => !declined.has(keyOf(one)));
|
|
300
|
+
if (next === undefined) break;
|
|
301
|
+
const yes = flags.answers === "yes" || (await io.ask(describeCandidate(next)));
|
|
302
|
+
if (!yes) {
|
|
303
|
+
declined.add(keyOf(next));
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const tighten =
|
|
307
|
+
next.crossReach !== null && (flags.answers === "yes" || (await io.ask(describeReach(next))));
|
|
308
|
+
accepted.push({
|
|
309
|
+
parent: next.parent,
|
|
310
|
+
members: next.members,
|
|
311
|
+
capture: next.capture,
|
|
312
|
+
crossReach: tighten ? next.crossReach : null,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return accepted;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const today = (): string => new Date().toISOString().slice(0, 10);
|
|
319
|
+
|
|
320
|
+
const header = (date: string): string =>
|
|
321
|
+
`# yaml-language-server: $schema=${MANIFEST_SCHEMA_ID}
|
|
322
|
+
#
|
|
323
|
+
# The as-built architecture of this repository, inferred on ${date} by
|
|
324
|
+
# \`architecture infer\`. Every node says what its folder does today — what it
|
|
325
|
+
# imports, and nothing about what it may not. Each \`allow\` entry is a fact;
|
|
326
|
+
# each one you delete is a decision. \`unrestricted: true\` marks a node nobody
|
|
327
|
+
# has reviewed yet, and \`limits.unrestricted\` counts how many remain: lower it
|
|
328
|
+
# as you go, and the policy refuses to drift back.
|
|
329
|
+
#
|
|
330
|
+
# https://dataquail.github.io/goodbones/architecture-rules/getting-started/infer/
|
|
331
|
+
|
|
332
|
+
`;
|
|
333
|
+
|
|
334
|
+
export type InferOutcome = {
|
|
335
|
+
readonly manifest: Manifest;
|
|
336
|
+
readonly yaml: string;
|
|
337
|
+
readonly files: number;
|
|
338
|
+
readonly nodes: number;
|
|
339
|
+
readonly unresolved: ReadonlyArray<string>;
|
|
340
|
+
readonly generalized: ReadonlyArray<Generalization>;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
export const infer = (
|
|
344
|
+
repoRoot: string,
|
|
345
|
+
argv: ReadonlyArray<string>,
|
|
346
|
+
configFilename: string | undefined,
|
|
347
|
+
io: InferIo = terminalIo(),
|
|
348
|
+
): Effect.Effect<InferOutcome, CliFailure> =>
|
|
349
|
+
Effect.gen(function* () {
|
|
350
|
+
const parsed = parseInferFlags(argv);
|
|
351
|
+
if (Result.isFailure(parsed)) return yield* Effect.fail(fail(parsed.failure));
|
|
352
|
+
const flags = parsed.success;
|
|
353
|
+
|
|
354
|
+
if (flags.write) {
|
|
355
|
+
const present = MANIFEST_FILENAMES.filter((name) => existsSync(path.resolve(repoRoot, name)));
|
|
356
|
+
if (present.length > 0) {
|
|
357
|
+
return yield* Effect.fail(
|
|
358
|
+
fail(
|
|
359
|
+
`${present.join(", ")} already exists. \`infer --write\` writes a manifest for a ` +
|
|
360
|
+
`repository that has none, and does not overwrite one; leave --write off to print it.`,
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const setting = yield* Effect.tryPromise({
|
|
367
|
+
try: () => settingOf(repoRoot, configFilename, flags),
|
|
368
|
+
catch: (cause) => fail(String(cause)),
|
|
369
|
+
});
|
|
370
|
+
if (Result.isFailure(setting)) return yield* Effect.fail(fail(setting.failure));
|
|
371
|
+
const { aliases, baseline, policy, resolve, roots } = setting.success;
|
|
372
|
+
|
|
373
|
+
for (const root of roots) {
|
|
374
|
+
const absolute = path.resolve(repoRoot, root);
|
|
375
|
+
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
|
|
376
|
+
return yield* Effect.fail(fail(`--root ${root} is not a folder under ${repoRoot}`));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const files = listSourceFiles(repoRoot, roots, policy.languages);
|
|
381
|
+
const packages = listPackageRoots(repoRoot, roots, policy.languages);
|
|
382
|
+
|
|
383
|
+
const parsedFacts = new Map<string, SourceFacts>();
|
|
384
|
+
const factsOf = (file: string): SourceFacts => {
|
|
385
|
+
const cached = parsedFacts.get(file);
|
|
386
|
+
if (cached !== undefined) return cached;
|
|
387
|
+
const facts = sourceFactsOf(repoRoot, file, policy.extractor);
|
|
388
|
+
parsedFacts.set(file, facts);
|
|
389
|
+
return facts;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const targets = new Map<string, ReadonlyArray<InferredTarget>>();
|
|
393
|
+
const unresolved: Array<string> = [];
|
|
394
|
+
for (const file of files) {
|
|
395
|
+
const found: Array<InferredTarget> = [];
|
|
396
|
+
for (const specifier of factsOf(file).specifiers) {
|
|
397
|
+
const resolved = policy.resolver.resolve(file, specifier);
|
|
398
|
+
if (Result.isFailure(resolved)) {
|
|
399
|
+
if (!policy.ignoreUnresolved.some((pattern) => pattern.test(specifier))) {
|
|
400
|
+
unresolved.push(`${file} → ${specifier} (${resolved.failure.detail})`);
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const target = resolved.success;
|
|
405
|
+
found.push(
|
|
406
|
+
target.kind === "local"
|
|
407
|
+
? { kind: "local", path: target.path }
|
|
408
|
+
: target.kind === "builtin"
|
|
409
|
+
? { kind: "builtin", path: target.path }
|
|
410
|
+
: { kind: "external", package: target.package ?? target.path },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
targets.set(file, found);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const input = {
|
|
417
|
+
files,
|
|
418
|
+
targetsOf: (file: string) => targets.get(file) ?? [],
|
|
419
|
+
roots,
|
|
420
|
+
packages,
|
|
421
|
+
depth: flags.depth,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
if (flags.answers === "ask" && !io.interactive) {
|
|
425
|
+
io.err(
|
|
426
|
+
"not a terminal, so no questions: every folder gets a node of its own. " +
|
|
427
|
+
"Pass --yes to accept every generalization, or run this at a terminal.",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const generalized = yield* Effect.promise(() =>
|
|
431
|
+
decide(
|
|
432
|
+
input,
|
|
433
|
+
flags.answers === "ask" && !io.interactive ? { ...flags, answers: "exhaustive" } : flags,
|
|
434
|
+
io,
|
|
435
|
+
),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const date = today();
|
|
439
|
+
const hasCycles = cyclesIn(buildGraph(files, policy.resolver, factsOf)).length > 0;
|
|
440
|
+
const inferred = inferManifest(input, {
|
|
441
|
+
generalize: generalized,
|
|
442
|
+
collapse: flags.collapse,
|
|
443
|
+
date,
|
|
444
|
+
resolve,
|
|
445
|
+
aliases,
|
|
446
|
+
baseline,
|
|
447
|
+
hasCycles,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// The proof: what was written loads, every probe passes, and the floors
|
|
451
|
+
// are the numbers it reaches today.
|
|
452
|
+
const loaded = loadPolicyFromManifest(repoRoot, inferred.manifest);
|
|
453
|
+
if (Result.isFailure(loaded)) {
|
|
454
|
+
return yield* Effect.fail(
|
|
455
|
+
fail(
|
|
456
|
+
`infer wrote a manifest that does not load — this is a bug: ${String(loaded.failure)}`,
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
const fractions = fractionsOf(coverageOf(loaded.success, files));
|
|
461
|
+
const floor = (fraction: number): number => Math.floor(fraction * 100) / 100;
|
|
462
|
+
const coverage: Partial<Record<keyof typeof fractions, number>> = {};
|
|
463
|
+
for (const family of ["imports", "structure", "members", "surface", "graph"] as const) {
|
|
464
|
+
if (fractions[family] > 0) coverage[family] = floor(fractions[family]);
|
|
465
|
+
}
|
|
466
|
+
const manifest: Manifest = {
|
|
467
|
+
...inferred.manifest,
|
|
468
|
+
limits: { ...inferred.manifest.limits, coverage },
|
|
469
|
+
};
|
|
470
|
+
const yaml = `${header(date)}${formatManifestYaml(manifest)}`;
|
|
471
|
+
|
|
472
|
+
if (flags.write) {
|
|
473
|
+
yield* Effect.sync(() => {
|
|
474
|
+
writeFileSync(path.resolve(repoRoot, "architecture.yaml"), yaml);
|
|
475
|
+
});
|
|
476
|
+
} else {
|
|
477
|
+
yield* Effect.sync(() => {
|
|
478
|
+
io.out(yaml);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
yield* Effect.sync(() => {
|
|
483
|
+
const summary =
|
|
484
|
+
`${String(files.length)} files under ${roots.join(", ")}: ${String(inferred.nodes)} nodes` +
|
|
485
|
+
(generalized.length > 0 ? `, ${String(generalized.length)} generalized` : "") +
|
|
486
|
+
(hasCycles ? "; the graph has a cycle, so no no-cycles rule was written" : "");
|
|
487
|
+
io.err(flags.write ? `wrote architecture.yaml. ${summary}.` : summary);
|
|
488
|
+
if (unresolved.length > 0) {
|
|
489
|
+
io.err("");
|
|
490
|
+
io.err(
|
|
491
|
+
`${String(unresolved.length)} imports could not be resolved, and are in no allowlist. ` +
|
|
492
|
+
"`check` will report them until the resolver can see them or `ignoreUnresolved` names them:",
|
|
493
|
+
);
|
|
494
|
+
for (const one of unresolved) io.err(` ${one}`);
|
|
495
|
+
}
|
|
496
|
+
if (flags.write) {
|
|
497
|
+
io.err("");
|
|
498
|
+
io.err(" architecture check # should be clean: the manifest describes today");
|
|
499
|
+
io.err(" architecture coverage # every node is unrestricted; review them one by one");
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
manifest,
|
|
505
|
+
yaml,
|
|
506
|
+
files: files.length,
|
|
507
|
+
nodes: inferred.nodes,
|
|
508
|
+
unresolved,
|
|
509
|
+
generalized,
|
|
510
|
+
};
|
|
511
|
+
});
|
package/src/main.ts
CHANGED
|
@@ -5,7 +5,9 @@ import * as Exit from "effect/Exit";
|
|
|
5
5
|
|
|
6
6
|
import { run } from "./run.js";
|
|
7
7
|
|
|
8
|
-
const exit = await Effect.runPromiseExit(
|
|
8
|
+
const exit = await Effect.runPromiseExit(
|
|
9
|
+
run(process.cwd(), process.argv.slice(2), process.env.ARCHITECTURE_CONFIG),
|
|
10
|
+
);
|
|
9
11
|
|
|
10
12
|
if (Exit.isFailure(exit)) {
|
|
11
13
|
// The expected failures carry their own sentence; anything else is a defect
|