@goodbones/cli 0.1.0-beta.2 → 0.1.0-beta.20
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/campaigns/host.d.ts +51 -0
- package/build/dts/campaigns/host.d.ts.map +1 -0
- package/build/dts/campaigns/live.d.ts +3 -0
- package/build/dts/campaigns/live.d.ts.map +1 -0
- package/build/dts/campaigns/none.d.ts +7 -0
- package/build/dts/campaigns/none.d.ts.map +1 -0
- package/build/dts/campaigns/verbs.d.ts +6 -0
- package/build/dts/campaigns/verbs.d.ts.map +1 -0
- package/build/dts/config-loader.d.ts +10 -3
- 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/output.d.ts +9 -0
- package/build/dts/output.d.ts.map +1 -0
- package/build/dts/run.d.ts +67 -8
- package/build/dts/run.d.ts.map +1 -1
- package/build/esm/campaigns/host.js +2 -0
- package/build/esm/campaigns/host.js.map +1 -0
- package/build/esm/campaigns/live.js +106 -0
- package/build/esm/campaigns/live.js.map +1 -0
- package/build/esm/campaigns/none.js +34 -0
- package/build/esm/campaigns/none.js.map +1 -0
- package/build/esm/campaigns/verbs.js +489 -0
- package/build/esm/campaigns/verbs.js.map +1 -0
- package/build/esm/config-loader.js +66 -10
- 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/output.js +8 -0
- package/build/esm/output.js.map +1 -0
- package/build/esm/run.js +555 -78
- package/build/esm/run.js.map +1 -1
- package/package.json +19 -5
- package/src/campaigns/host.ts +120 -0
- package/src/campaigns/live.ts +136 -0
- package/src/campaigns/none.ts +47 -0
- package/src/campaigns/verbs.ts +620 -0
- package/src/config-loader.ts +81 -11
- package/src/infer.ts +511 -0
- package/src/output.ts +18 -0
- package/src/run.ts +870 -103
package/src/config-loader.ts
CHANGED
|
@@ -1,40 +1,110 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
type ConfigInvalid,
|
|
4
5
|
findManifestFile,
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
77
|
+
manifest: composed.manifest,
|
|
34
78
|
locate: read.locate,
|
|
35
|
-
languages:
|
|
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}`;
|