@goodbones/cli 0.1.0-beta.2 → 0.1.0-beta.21

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 (41) hide show
  1. package/build/dts/campaigns/host.d.ts +51 -0
  2. package/build/dts/campaigns/host.d.ts.map +1 -0
  3. package/build/dts/campaigns/live.d.ts +3 -0
  4. package/build/dts/campaigns/live.d.ts.map +1 -0
  5. package/build/dts/campaigns/none.d.ts +7 -0
  6. package/build/dts/campaigns/none.d.ts.map +1 -0
  7. package/build/dts/campaigns/verbs.d.ts +6 -0
  8. package/build/dts/campaigns/verbs.d.ts.map +1 -0
  9. package/build/dts/config-loader.d.ts +10 -3
  10. package/build/dts/config-loader.d.ts.map +1 -1
  11. package/build/dts/infer.d.ts +34 -0
  12. package/build/dts/infer.d.ts.map +1 -0
  13. package/build/dts/output.d.ts +9 -0
  14. package/build/dts/output.d.ts.map +1 -0
  15. package/build/dts/run.d.ts +67 -8
  16. package/build/dts/run.d.ts.map +1 -1
  17. package/build/esm/campaigns/host.js +2 -0
  18. package/build/esm/campaigns/host.js.map +1 -0
  19. package/build/esm/campaigns/live.js +106 -0
  20. package/build/esm/campaigns/live.js.map +1 -0
  21. package/build/esm/campaigns/none.js +34 -0
  22. package/build/esm/campaigns/none.js.map +1 -0
  23. package/build/esm/campaigns/verbs.js +489 -0
  24. package/build/esm/campaigns/verbs.js.map +1 -0
  25. package/build/esm/config-loader.js +66 -10
  26. package/build/esm/config-loader.js.map +1 -1
  27. package/build/esm/infer.js +378 -0
  28. package/build/esm/infer.js.map +1 -0
  29. package/build/esm/output.js +8 -0
  30. package/build/esm/output.js.map +1 -0
  31. package/build/esm/run.js +555 -78
  32. package/build/esm/run.js.map +1 -1
  33. package/package.json +20 -6
  34. package/src/campaigns/host.ts +120 -0
  35. package/src/campaigns/live.ts +136 -0
  36. package/src/campaigns/none.ts +47 -0
  37. package/src/campaigns/verbs.ts +620 -0
  38. package/src/config-loader.ts +81 -11
  39. package/src/infer.ts +511 -0
  40. package/src/output.ts +18 -0
  41. package/src/run.ts +870 -103
@@ -1,40 +1,110 @@
1
1
  import * as path from "node:path";
2
2
 
3
3
  import {
4
+ type ConfigInvalid,
4
5
  findManifestFile,
5
- type LoadedPolicy,
6
+ importOptional,
7
+ type Language,
6
8
  loadPolicy,
7
9
  makeFileSystemLive,
10
+ type PatternInvalid,
8
11
  readManifestFile,
9
12
  } from "@goodbones/core";
10
13
  import { typescriptLanguage } from "@goodbones/typescript";
11
14
  import * as Result from "effect/Result";
12
15
 
13
- export type { LoadedPolicy } from "@goodbones/core";
16
+ import type { CampaignsFamily, LoadedPolicy } from "./campaigns/host.js";
17
+ import { campaignsNotInstalled } from "./campaigns/none.js";
18
+
19
+ export type { LoadedPolicy } from "./campaigns/host.js";
20
+
21
+ // The language packs this host is composed with. Constructed here and handed
22
+ // down as the `Language` port, so nothing below this file names TypeScript —
23
+ // or ast-grep, the syntax matcher for the campaigns family's `syntax` term.
24
+ // The matcher is an optional peer, with a native binary: composed into the
25
+ // pack when it is installed, and a campaign that needs it without it is
26
+ // refused at load, naming the package.
27
+ export const hostLanguages = async (): Promise<ReadonlyArray<Language>> => {
28
+ const astGrep = await importOptional(() => import("@goodbones/ast-grep"), "@goodbones/ast-grep");
29
+ return [typescriptLanguage(astGrep === null ? {} : { syntax: astGrep.astGrepMatcher() })];
30
+ };
31
+
32
+ // The campaigns family, when `@goodbones/campaigns` is installed beside this
33
+ // host, and `none` when it is not. Everything that names the package is
34
+ // under `campaigns/`, reached through this one import.
35
+ export const hostCampaigns = async (): Promise<CampaignsFamily> =>
36
+ (await importOptional(() => import("./campaigns/live.js"), "@goodbones/campaigns"))
37
+ ?.liveCampaigns ?? campaignsNotInstalled;
38
+
39
+ // The clock the campaigns are judged by. `ARCHITECTURE_NOW` (an ISO date or
40
+ // epoch milliseconds) pins it, for a CI that replays a day or a test that
41
+ // needs a stall to have happened.
42
+ export const hostNow = (): number => {
43
+ const pinned = process.env.ARCHITECTURE_NOW;
44
+ if (pinned === undefined || pinned === "") return Date.now();
45
+ const parsed = /^\d+$/.test(pinned) ? Number(pinned) : Date.parse(pinned);
46
+ if (Number.isNaN(parsed)) {
47
+ throw new Error(`ARCHITECTURE_NOW is ${JSON.stringify(pinned)}, which is not a date.`);
48
+ }
49
+ return parsed;
50
+ };
14
51
 
15
52
  // The CLI's composition root: read the manifest file, construct the language
16
53
  // packs and the live file system, and hand them to the loader. The plugin has
17
54
  // one of its own with the same three lines, on purpose — the two hosts share
18
55
  // the core and never each other.
56
+ // Named, or discovered: architecture.yaml, .yml, .json, or .config.mjs,
57
+ // exactly one of which may be present. Absolute.
58
+ export const manifestPathOf = (repoRoot: string, configFilename?: string): string =>
59
+ configFilename === undefined
60
+ ? findManifestFile(repoRoot)
61
+ : path.resolve(repoRoot, configFilename);
62
+
63
+ // `given` is the family to compose: found by `hostCampaigns` unless a
64
+ // test names one, which is how the host is run as if the package were absent.
19
65
  export const loadPolicyFromFile = async (
20
66
  repoRoot: string,
21
67
  configFilename?: string,
68
+ given?: CampaignsFamily,
22
69
  ): Promise<LoadedPolicy> => {
23
- // Named, or discovered: architecture.yaml, .yml, .json, or .config.mjs,
24
- // exactly one of which may be present.
25
- const configPath =
26
- configFilename === undefined
27
- ? findManifestFile(repoRoot)
28
- : path.resolve(repoRoot, configFilename);
70
+ const configPath = manifestPathOf(repoRoot, configFilename);
29
71
  const read = await readManifestFile(configPath);
72
+ const campaigns = given ?? (await hostCampaigns());
73
+ const composed = await campaigns.compose(repoRoot, configPath, read.manifest);
30
74
  const loaded = loadPolicy({
31
75
  repoRoot,
32
76
  configPath,
33
- manifest: read.manifest,
77
+ manifest: composed.manifest,
34
78
  locate: read.locate,
35
- languages: [typescriptLanguage()],
79
+ languages: await hostLanguages(),
36
80
  fileSystem: makeFileSystemLive(repoRoot),
81
+ extensions: composed.extensions,
82
+ uninstalled: composed.uninstalled,
83
+ now: hostNow(),
37
84
  });
38
85
  if (Result.isFailure(loaded)) throw loaded.failure;
39
- return loaded.success;
86
+ return { ...loaded.success, campaigns: campaigns.host };
87
+ };
88
+
89
+ // The same composition, for a manifest the host already holds as a value
90
+ // rather than a file: the bootstrap `infer` resolves through, and the
91
+ // manifest it then proves loads. Failure is returned, not thrown — the caller
92
+ // has a sentence to put around it.
93
+ export const loadPolicyFromManifest = async (
94
+ repoRoot: string,
95
+ manifest: unknown,
96
+ ): Promise<Result.Result<LoadedPolicy, ConfigInvalid | PatternInvalid>> => {
97
+ const campaigns = await hostCampaigns();
98
+ const composed = await campaigns.compose(repoRoot, null, manifest);
99
+ const loaded = loadPolicy({
100
+ repoRoot,
101
+ configPath: path.resolve(repoRoot, "architecture.yaml"),
102
+ manifest: composed.manifest,
103
+ languages: await hostLanguages(),
104
+ fileSystem: makeFileSystemLive(repoRoot),
105
+ extensions: composed.extensions,
106
+ uninstalled: composed.uninstalled,
107
+ now: hostNow(),
108
+ });
109
+ return Result.map(loaded, (policy) => ({ ...policy, campaigns: campaigns.host }));
40
110
  };
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, await 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 = await 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 = yield* Effect.promise(() => 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/output.ts ADDED
@@ -0,0 +1,18 @@
1
+ import * as Effect from "effect/Effect";
2
+
3
+ // What every command shares: how it fails, and how it writes. Its own module
4
+ // so the campaigns glue under `campaigns/` speaks the same way without
5
+ // reaching `run.ts`, which would close a cycle through the import that loads
6
+ // the glue.
7
+
8
+ export type CliFailure = { readonly _tag: "CliFailure"; readonly message: string };
9
+
10
+ export const fail = (message: string): CliFailure => ({ _tag: "CliFailure", message });
11
+
12
+ export const report = (lines: ReadonlyArray<string>): Effect.Effect<void> =>
13
+ Effect.sync(() => {
14
+ for (const line of lines) process.stdout.write(`${line}\n`);
15
+ });
16
+
17
+ export const count = (n: number, noun: string, plural = `${noun}s`): string =>
18
+ `${String(n)} ${n === 1 ? noun : plural}`;