@goodbones/core 0.1.0-beta.3 → 0.1.0-beta.4
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/core/graph.d.ts +1 -0
- package/build/dts/core/graph.d.ts.map +1 -1
- package/build/dts/index.d.ts +3 -2
- package/build/dts/index.d.ts.map +1 -1
- package/build/dts/infrastructure/walk.d.ts +6 -0
- package/build/dts/infrastructure/walk.d.ts.map +1 -1
- package/build/dts/manifest/infer.d.ts +55 -0
- package/build/dts/manifest/infer.d.ts.map +1 -0
- package/build/dts/ports/language.d.ts +2 -0
- package/build/dts/ports/language.d.ts.map +1 -1
- package/build/esm/core/graph.js +8 -0
- package/build/esm/core/graph.js.map +1 -1
- package/build/esm/index.js +3 -2
- package/build/esm/index.js.map +1 -1
- package/build/esm/infrastructure/walk.js +57 -1
- package/build/esm/infrastructure/walk.js.map +1 -1
- package/build/esm/manifest/infer.js +455 -0
- package/build/esm/manifest/infer.js.map +1 -0
- package/package.json +1 -1
- package/src/core/graph.ts +12 -0
- package/src/index.ts +20 -1
- package/src/infrastructure/walk.ts +70 -1
- package/src/manifest/infer.ts +643 -0
- package/src/ports/language.ts +11 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import type { Manifest, ManifestNode } from "./manifest.js";
|
|
2
|
+
|
|
3
|
+
// The as-built manifest: the inverse of a probe.
|
|
4
|
+
//
|
|
5
|
+
// A probe goes from a rule to a synthetic fact and proves the rule can fire.
|
|
6
|
+
// This goes from the real facts — every walked file and every edge it has — to
|
|
7
|
+
// a manifest in the policy's own vocabulary that describes what the tree does
|
|
8
|
+
// today: one node per folder to a depth, every folder `layout: open`, every
|
|
9
|
+
// node `unrestricted: true` with its `imports.allow` filled from the edges its
|
|
10
|
+
// files actually have. `check` accepts it with zero violations, by
|
|
11
|
+
// construction; the human edits it down, and every edit is a constraint.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here reads a file or resolves a specifier. The host walks, parses
|
|
14
|
+
// and resolves, and hands the answers in; this tier decides what they say.
|
|
15
|
+
|
|
16
|
+
export type InferredTarget =
|
|
17
|
+
// A file on disk, repo-relative — walked or not. One outside every walk root
|
|
18
|
+
// is admitted by its exact path, since no node describes it.
|
|
19
|
+
| { readonly kind: "local"; readonly path: string }
|
|
20
|
+
// An npm package, a Go module: named as `imports.external` names it.
|
|
21
|
+
| { readonly kind: "external"; readonly package: string }
|
|
22
|
+
// The runtime's own — `node:fs` — admitted by its exact name, so the
|
|
23
|
+
// manifest names no runtime the tree does not use.
|
|
24
|
+
| { readonly kind: "builtin"; readonly path: string };
|
|
25
|
+
|
|
26
|
+
export type InferPackage = {
|
|
27
|
+
// Repo-relative folder holding a package marker; `""` is the repository.
|
|
28
|
+
readonly root: string;
|
|
29
|
+
// Its source folder when the language keeps one; `infer` counts depth from
|
|
30
|
+
// there, so `packages/core/src/domain` is one below `src`, not four below
|
|
31
|
+
// the root.
|
|
32
|
+
readonly source: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type InferInput = {
|
|
36
|
+
// Every walked file, repo-relative with forward slashes.
|
|
37
|
+
readonly files: ReadonlyArray<string>;
|
|
38
|
+
readonly targetsOf: (file: string) => ReadonlyArray<InferredTarget>;
|
|
39
|
+
// The walk roots: folders, repo-relative, no trailing slash. Each becomes a
|
|
40
|
+
// top-level key of the tree.
|
|
41
|
+
readonly roots: ReadonlyArray<string>;
|
|
42
|
+
readonly packages: ReadonlyArray<InferPackage>;
|
|
43
|
+
// How many folders below a package's source root (or the walk root, outside
|
|
44
|
+
// any package) become nodes of their own. Deeper folders are governed by
|
|
45
|
+
// the node above them.
|
|
46
|
+
readonly depth: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// A set of sibling folders that look like members of one layer — each with
|
|
50
|
+
// the same subfolders, or the same kinds of file — and could be one
|
|
51
|
+
// wildcarded node instead of one node each.
|
|
52
|
+
export type Candidate = {
|
|
53
|
+
readonly parent: string;
|
|
54
|
+
readonly members: ReadonlyArray<string>;
|
|
55
|
+
readonly sharedFolders: ReadonlyArray<string>;
|
|
56
|
+
readonly sharedStereotypes: ReadonlyArray<string>;
|
|
57
|
+
// The capture the wildcarded key would declare: `{module}` under `modules/`.
|
|
58
|
+
readonly capture: string;
|
|
59
|
+
// How many edges run from one member into another, and — when every one of
|
|
60
|
+
// them lands on the same member-relative path, such as `index.ts` — that
|
|
61
|
+
// path, which is the tightening a human would write next.
|
|
62
|
+
readonly crossEdges: number;
|
|
63
|
+
readonly crossReach: string | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// A candidate the host accepted, with or without the tightening.
|
|
67
|
+
export type Generalization = {
|
|
68
|
+
readonly parent: string;
|
|
69
|
+
readonly members: ReadonlyArray<string>;
|
|
70
|
+
readonly capture: string;
|
|
71
|
+
readonly crossReach: string | null;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type InferOptions = {
|
|
75
|
+
readonly generalize: ReadonlyArray<Generalization>;
|
|
76
|
+
// Fold a node's children into it when every child has the same allow set.
|
|
77
|
+
readonly collapse: boolean;
|
|
78
|
+
// Written into every node's message, so the reader knows how old it is.
|
|
79
|
+
readonly date: string;
|
|
80
|
+
readonly resolve: Manifest["resolve"];
|
|
81
|
+
// Carried over from an existing manifest, and used to shorten what is
|
|
82
|
+
// written: `packages/core/src/**` reads better as `@core/**`.
|
|
83
|
+
readonly aliases?: Readonly<Record<string, string>> | undefined;
|
|
84
|
+
readonly baseline: string;
|
|
85
|
+
// Whether the graph has a cycle today. A no-cycles rule is written only
|
|
86
|
+
// when the tree passes it: a rule it fails on day one is debt, not shape.
|
|
87
|
+
readonly hasCycles: boolean;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type Inferred = {
|
|
91
|
+
readonly manifest: Manifest;
|
|
92
|
+
readonly nodes: number;
|
|
93
|
+
readonly files: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Paths
|
|
98
|
+
|
|
99
|
+
const dirnameOf = (file: string): string => {
|
|
100
|
+
const at = file.lastIndexOf("/");
|
|
101
|
+
return at === -1 ? "" : file.slice(0, at);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const basenameOf = (file: string): string => file.slice(file.lastIndexOf("/") + 1);
|
|
105
|
+
|
|
106
|
+
// `folder` is `p` or an ancestor of it. The empty folder is every path's.
|
|
107
|
+
const isUnder = (p: string, folder: string): boolean =>
|
|
108
|
+
folder === "" || p === folder || p.startsWith(`${folder}/`);
|
|
109
|
+
|
|
110
|
+
const depthBelow = (p: string, folder: string): number =>
|
|
111
|
+
p === folder ? 0 : (folder === "" ? p : p.slice(folder.length + 1)).split("/").length;
|
|
112
|
+
|
|
113
|
+
const sorted = <T>(items: Iterable<T>): ReadonlyArray<T> => [...items].sort();
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Generalization: a member folder is rewritten as its capture, so the tree is
|
|
117
|
+
// built once over the rewritten paths and the members merge on their own.
|
|
118
|
+
|
|
119
|
+
type Membership = { readonly group: Generalization; readonly member: string };
|
|
120
|
+
|
|
121
|
+
const memberOf = (p: string, generalize: ReadonlyArray<Generalization>): Membership | null => {
|
|
122
|
+
for (const group of generalize) {
|
|
123
|
+
for (const member of group.members) {
|
|
124
|
+
const folder = group.parent === "" ? member : `${group.parent}/${member}`;
|
|
125
|
+
if (isUnder(p, folder)) return { group, member };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const capturedPathOf = (group: Generalization): string =>
|
|
132
|
+
group.parent === "" ? `{${group.capture}}` : `${group.parent}/{${group.capture}}`;
|
|
133
|
+
|
|
134
|
+
const rewrite = (p: string, generalize: ReadonlyArray<Generalization>): string => {
|
|
135
|
+
const membership = memberOf(p, generalize);
|
|
136
|
+
if (membership === null) return p;
|
|
137
|
+
const { group, member } = membership;
|
|
138
|
+
const folder = group.parent === "" ? member : `${group.parent}/${member}`;
|
|
139
|
+
const captured = capturedPathOf(group);
|
|
140
|
+
return p === folder ? captured : `${captured}${p.slice(folder.length)}`;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// The folder trie of the walked files, over rewritten paths.
|
|
145
|
+
|
|
146
|
+
type Folder = {
|
|
147
|
+
readonly path: string;
|
|
148
|
+
readonly name: string;
|
|
149
|
+
// Original paths of the files directly in it.
|
|
150
|
+
readonly files: Array<string>;
|
|
151
|
+
readonly children: Map<string, Folder>;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const folderAt = (root: Folder, p: string): Folder => {
|
|
155
|
+
if (p === root.path) return root;
|
|
156
|
+
const rest = root.path === "" ? p : p.slice(root.path.length + 1);
|
|
157
|
+
let at = root;
|
|
158
|
+
for (const segment of rest.split("/")) {
|
|
159
|
+
const existing = at.children.get(segment);
|
|
160
|
+
if (existing !== undefined) {
|
|
161
|
+
at = existing;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const made: Folder = {
|
|
165
|
+
path: at.path === "" ? segment : `${at.path}/${segment}`,
|
|
166
|
+
name: segment,
|
|
167
|
+
files: [],
|
|
168
|
+
children: new Map(),
|
|
169
|
+
};
|
|
170
|
+
at.children.set(segment, made);
|
|
171
|
+
at = made;
|
|
172
|
+
}
|
|
173
|
+
return at;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
type Trie = ReadonlyArray<Folder>;
|
|
177
|
+
|
|
178
|
+
const trieOf = (input: InferInput, generalize: ReadonlyArray<Generalization>): Trie => {
|
|
179
|
+
const roots = input.roots.map((root): Folder => ({
|
|
180
|
+
path: root,
|
|
181
|
+
name: basenameOf(root),
|
|
182
|
+
files: [],
|
|
183
|
+
children: new Map(),
|
|
184
|
+
}));
|
|
185
|
+
for (const file of input.files) {
|
|
186
|
+
const folder = rewrite(dirnameOf(file), generalize);
|
|
187
|
+
const root = roots.find((one) => isUnder(folder, one.path));
|
|
188
|
+
if (root === undefined) continue;
|
|
189
|
+
folderAt(root, folder).files.push(file);
|
|
190
|
+
}
|
|
191
|
+
return roots;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Which folders are nodes.
|
|
196
|
+
|
|
197
|
+
type Bases = {
|
|
198
|
+
// Every folder a depth is counted from: the walk roots, the package roots,
|
|
199
|
+
// the package source roots — all rewritten.
|
|
200
|
+
readonly all: ReadonlyArray<string>;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const basesOf = (input: InferInput, generalize: ReadonlyArray<Generalization>): Bases => {
|
|
204
|
+
const all = new Set<string>(input.roots);
|
|
205
|
+
for (const one of input.packages) {
|
|
206
|
+
all.add(rewrite(one.root, generalize));
|
|
207
|
+
if (one.source !== null) all.add(rewrite(one.source, generalize));
|
|
208
|
+
}
|
|
209
|
+
return { all: sorted(all) };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const baseOf = (folder: string, bases: Bases): string => {
|
|
213
|
+
let best: string | null = null;
|
|
214
|
+
for (const base of bases.all) {
|
|
215
|
+
if (!isUnder(folder, base)) continue;
|
|
216
|
+
if (best === null || base.length > best.length) best = base;
|
|
217
|
+
}
|
|
218
|
+
return best ?? "";
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// A folder is a node when it is within `depth` of the base it counts from, or
|
|
222
|
+
// when it lies on the way to one — the folders between a walk root and a
|
|
223
|
+
// package are structure, and a node each whatever the depth.
|
|
224
|
+
const isNode = (folder: Folder, input: InferInput, bases: Bases): boolean =>
|
|
225
|
+
input.roots.includes(folder.path) ||
|
|
226
|
+
bases.all.some((base) => isUnder(base, folder.path)) ||
|
|
227
|
+
depthBelow(folder.path, baseOf(folder.path, bases)) <= input.depth;
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// The node tree.
|
|
231
|
+
|
|
232
|
+
type Node = {
|
|
233
|
+
readonly path: string;
|
|
234
|
+
readonly key: string;
|
|
235
|
+
readonly folder: Folder;
|
|
236
|
+
// Original paths of every file this node governs: its own, and those in
|
|
237
|
+
// the folders beneath it that are not nodes.
|
|
238
|
+
files: Array<string>;
|
|
239
|
+
children: Map<string, Node>;
|
|
240
|
+
// Whether a folder beneath it holds files without being a node — which the
|
|
241
|
+
// manifest must admit with a catch-all, or the structure family fires.
|
|
242
|
+
stray: boolean;
|
|
243
|
+
allow: Set<string>;
|
|
244
|
+
external: Set<string>;
|
|
245
|
+
edges: number;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const nodeOf = (folder: Folder, input: InferInput, bases: Bases): Node => {
|
|
249
|
+
const node: Node = {
|
|
250
|
+
path: folder.path,
|
|
251
|
+
key: `${folder.name}/`,
|
|
252
|
+
folder,
|
|
253
|
+
files: [...folder.files],
|
|
254
|
+
children: new Map(),
|
|
255
|
+
stray: false,
|
|
256
|
+
allow: new Set(),
|
|
257
|
+
external: new Set(),
|
|
258
|
+
edges: 0,
|
|
259
|
+
};
|
|
260
|
+
const absorb = (from: Folder): void => {
|
|
261
|
+
for (const child of from.children.values()) {
|
|
262
|
+
if (isNode(child, input, bases)) {
|
|
263
|
+
node.children.set(child.name, nodeOf(child, input, bases));
|
|
264
|
+
} else {
|
|
265
|
+
node.stray = true;
|
|
266
|
+
for (const file of child.files) node.files.push(file);
|
|
267
|
+
absorb(child);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
absorb(folder);
|
|
272
|
+
return node;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// The deepest node whose folder holds `p` (a rewritten path), or null when
|
|
276
|
+
// no walk root does.
|
|
277
|
+
const governorOf = (p: string, roots: ReadonlyArray<Node>): Node | null => {
|
|
278
|
+
const root = roots.find((one) => isUnder(p, one.path));
|
|
279
|
+
if (root === undefined) return null;
|
|
280
|
+
const descend = (at: Node): Node => {
|
|
281
|
+
for (const child of at.children.values()) if (isUnder(p, child.path)) return descend(child);
|
|
282
|
+
return at;
|
|
283
|
+
};
|
|
284
|
+
return descend(root);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const eachNode = (roots: ReadonlyArray<Node>, visit: (node: Node) => void): void => {
|
|
288
|
+
const walk = (node: Node): void => {
|
|
289
|
+
visit(node);
|
|
290
|
+
for (const child of node.children.values()) walk(child);
|
|
291
|
+
};
|
|
292
|
+
for (const root of roots) walk(root);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Allow sets.
|
|
297
|
+
|
|
298
|
+
// What a local target is written as: the node that governs it, as a glob. A
|
|
299
|
+
// file directly in a node that has children of its own is `node/*` — the
|
|
300
|
+
// layer's own files, such as a barrel, and not the tiers beneath — and
|
|
301
|
+
// anything else is `node/**`. A capture in that path survives only when the
|
|
302
|
+
// importer is in the same member; from anywhere else it is `*`.
|
|
303
|
+
const entryFor = (
|
|
304
|
+
importer: string,
|
|
305
|
+
target: string,
|
|
306
|
+
roots: ReadonlyArray<Node>,
|
|
307
|
+
generalize: ReadonlyArray<Generalization>,
|
|
308
|
+
): string => {
|
|
309
|
+
const rewritten = rewrite(target, generalize);
|
|
310
|
+
const node = governorOf(rewritten, roots);
|
|
311
|
+
if (node === null) return target;
|
|
312
|
+
|
|
313
|
+
const from = memberOf(importer, generalize);
|
|
314
|
+
const to = memberOf(target, generalize);
|
|
315
|
+
const sameGroup =
|
|
316
|
+
from !== null &&
|
|
317
|
+
to !== null &&
|
|
318
|
+
from.group.parent === to.group.parent &&
|
|
319
|
+
from.group.capture === to.group.capture;
|
|
320
|
+
if (to !== null && sameGroup && to.member !== from.member) {
|
|
321
|
+
const reach = to.group.crossReach;
|
|
322
|
+
const folder = to.group.parent === "" ? to.member : `${to.group.parent}/${to.member}`;
|
|
323
|
+
if (reach !== null && target === `${folder}/${reach}`) {
|
|
324
|
+
return `${to.group.parent === "" ? "" : `${to.group.parent}/`}*/${reach}`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const direct = dirnameOf(rewritten) === node.path && node.children.size > 0;
|
|
329
|
+
const glob = `${node.path}/${direct ? "*" : "**"}`;
|
|
330
|
+
if (to === null) return glob;
|
|
331
|
+
const sameMember = sameGroup && from.member === to.member;
|
|
332
|
+
return sameMember ? glob : glob.replace(`{${to.group.capture}}`, "*");
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const fillAllowSets = (
|
|
336
|
+
roots: ReadonlyArray<Node>,
|
|
337
|
+
input: InferInput,
|
|
338
|
+
generalize: ReadonlyArray<Generalization>,
|
|
339
|
+
): void => {
|
|
340
|
+
eachNode(roots, (node) => {
|
|
341
|
+
for (const file of node.files) {
|
|
342
|
+
for (const target of input.targetsOf(file)) {
|
|
343
|
+
node.edges += 1;
|
|
344
|
+
switch (target.kind) {
|
|
345
|
+
case "local":
|
|
346
|
+
node.allow.add(entryFor(file, target.path, roots, generalize));
|
|
347
|
+
break;
|
|
348
|
+
case "builtin":
|
|
349
|
+
node.allow.add(target.path);
|
|
350
|
+
break;
|
|
351
|
+
case "external":
|
|
352
|
+
node.external.add(target.package);
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
// Candidates: siblings that share a shape.
|
|
362
|
+
|
|
363
|
+
// `auth.module.ts` and `user.module.ts` are one kind of file; `index.ts` is
|
|
364
|
+
// its own. The first segment is the name, the rest is what it is.
|
|
365
|
+
const stereotypeOf = (file: string): string => {
|
|
366
|
+
const parts = basenameOf(file).split(".");
|
|
367
|
+
return parts.length >= 3 ? `*.${parts.slice(1).join(".")}` : basenameOf(file);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const intersect = (a: ReadonlySet<string>, b: ReadonlySet<string>): ReadonlyArray<string> =>
|
|
371
|
+
sorted([...a].filter((one) => b.has(one)));
|
|
372
|
+
|
|
373
|
+
const alike = (a: Shape, b: Shape): boolean => {
|
|
374
|
+
if (intersect(a.folders, b.folders).length >= 2) return true;
|
|
375
|
+
const union = new Set([...a.stereotypes, ...b.stereotypes]);
|
|
376
|
+
return union.size > 0 && intersect(a.stereotypes, b.stereotypes).length / union.size >= 0.5;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
type Shape = { readonly folders: ReadonlySet<string>; readonly stereotypes: ReadonlySet<string> };
|
|
380
|
+
|
|
381
|
+
const shapeOf = (folder: Folder): Shape => ({
|
|
382
|
+
folders: new Set(folder.children.keys()),
|
|
383
|
+
stereotypes: new Set(folder.files.map(stereotypeOf)),
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// `modules` → `module`; `entities` → `entity`; `packages` → `package`. What a
|
|
387
|
+
// human would name the capture; anything unpronounceable is `name`.
|
|
388
|
+
export const singularOf = (plural: string): string => {
|
|
389
|
+
const word = plural.replace(/[^A-Za-z0-9]/g, "");
|
|
390
|
+
const singular = /ies$/.test(word)
|
|
391
|
+
? word.replace(/ies$/, "y")
|
|
392
|
+
: /(ses|xes|zes|shes|ches)$/.test(word)
|
|
393
|
+
? word.slice(0, -2)
|
|
394
|
+
: /[^s]s$/.test(word)
|
|
395
|
+
? word.slice(0, -1)
|
|
396
|
+
: word;
|
|
397
|
+
return singular === "" || /^[0-9]/.test(singular) ? "name" : singular;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const groupsOf = (
|
|
401
|
+
members: ReadonlyArray<[string, Shape]>,
|
|
402
|
+
): ReadonlyArray<ReadonlyArray<string>> => {
|
|
403
|
+
const parent = new Map<string, string>(members.map(([name]) => [name, name]));
|
|
404
|
+
const find = (name: string): string => {
|
|
405
|
+
let at = name;
|
|
406
|
+
while ((parent.get(at) ?? at) !== at) at = parent.get(at) ?? at;
|
|
407
|
+
return at;
|
|
408
|
+
};
|
|
409
|
+
for (const [i, [a, shapeA]] of members.entries()) {
|
|
410
|
+
for (const [b, shapeB] of members.slice(i + 1)) {
|
|
411
|
+
if (alike(shapeA, shapeB)) parent.set(find(a), find(b));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const groups = new Map<string, Array<string>>();
|
|
415
|
+
for (const [name] of members) {
|
|
416
|
+
const key = find(name);
|
|
417
|
+
groups.set(key, [...(groups.get(key) ?? []), name]);
|
|
418
|
+
}
|
|
419
|
+
return [...groups.values()]
|
|
420
|
+
.filter((group) => group.length >= 2)
|
|
421
|
+
.map((group) => sorted(group))
|
|
422
|
+
.sort((a, b) => (a[0] ?? "").localeCompare(b[0] ?? ""));
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// The sibling groups under every node of the tree as it stands with the
|
|
426
|
+
// generalizations already accepted — never inside one, since a generalized
|
|
427
|
+
// node's children are already a merge.
|
|
428
|
+
export const candidatesOf = (
|
|
429
|
+
input: InferInput,
|
|
430
|
+
generalize: ReadonlyArray<Generalization> = [],
|
|
431
|
+
): ReadonlyArray<Candidate> => {
|
|
432
|
+
const trie = trieOf(input, generalize);
|
|
433
|
+
const bases = basesOf(input, generalize);
|
|
434
|
+
const roots = trie.map((folder) => nodeOf(folder, input, bases));
|
|
435
|
+
const candidates: Array<Candidate> = [];
|
|
436
|
+
|
|
437
|
+
eachNode(roots, (node) => {
|
|
438
|
+
if (node.path.includes("{") || node.children.size < 2) return;
|
|
439
|
+
const members: ReadonlyArray<[string, Shape]> = sorted(node.children.keys()).flatMap((name) => {
|
|
440
|
+
const folder = node.folder.children.get(name);
|
|
441
|
+
return folder === undefined ? [] : [[name, shapeOf(folder)] as [string, Shape]];
|
|
442
|
+
});
|
|
443
|
+
const shapes = new Map(members);
|
|
444
|
+
const taken = new Set<string>();
|
|
445
|
+
for (const group of groupsOf(members)) {
|
|
446
|
+
const shapesOfGroup = group.flatMap((name) => {
|
|
447
|
+
const shape = shapes.get(name);
|
|
448
|
+
return shape === undefined ? [] : [shape];
|
|
449
|
+
});
|
|
450
|
+
const sharedOf = (pick: (shape: Shape) => ReadonlySet<string>): ReadonlyArray<string> => {
|
|
451
|
+
const [head, ...rest] = shapesOfGroup;
|
|
452
|
+
if (head === undefined) return [];
|
|
453
|
+
return sorted(
|
|
454
|
+
rest.reduce<ReadonlySet<string>>(
|
|
455
|
+
(acc, shape) => new Set(intersect(acc, pick(shape))),
|
|
456
|
+
pick(head),
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
};
|
|
460
|
+
const sharedFolders = sharedOf((shape) => shape.folders);
|
|
461
|
+
const sharedStereotypes = sharedOf((shape) => shape.stereotypes);
|
|
462
|
+
let capture = singularOf(node.folder.name);
|
|
463
|
+
for (let n = 2; taken.has(capture); n += 1)
|
|
464
|
+
capture = `${singularOf(node.folder.name)}${String(n)}`;
|
|
465
|
+
taken.add(capture);
|
|
466
|
+
|
|
467
|
+
// Edges between members, and whether they all land on one place.
|
|
468
|
+
const memberFolder = (name: string): string =>
|
|
469
|
+
node.path === "" ? name : `${node.path}/${name}`;
|
|
470
|
+
const isIn = (p: string): string | null =>
|
|
471
|
+
group.find((name) => isUnder(p, memberFolder(name))) ?? null;
|
|
472
|
+
let crossEdges = 0;
|
|
473
|
+
const landings = new Set<string>();
|
|
474
|
+
for (const file of input.files) {
|
|
475
|
+
const from = isIn(file);
|
|
476
|
+
if (from === null) continue;
|
|
477
|
+
for (const target of input.targetsOf(file)) {
|
|
478
|
+
if (target.kind !== "local") continue;
|
|
479
|
+
const to = isIn(target.path);
|
|
480
|
+
if (to === null || to === from) continue;
|
|
481
|
+
crossEdges += 1;
|
|
482
|
+
landings.add(target.path.slice(memberFolder(to).length + 1));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const [landing] = sorted(landings);
|
|
486
|
+
candidates.push({
|
|
487
|
+
parent: node.path,
|
|
488
|
+
members: group,
|
|
489
|
+
sharedFolders,
|
|
490
|
+
sharedStereotypes,
|
|
491
|
+
capture,
|
|
492
|
+
crossEdges,
|
|
493
|
+
crossReach: landings.size === 1 && landing !== undefined ? landing : null,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
return candidates.sort((a, b) => {
|
|
499
|
+
const byParent = a.parent.localeCompare(b.parent);
|
|
500
|
+
return byParent !== 0 ? byParent : (a.members[0] ?? "").localeCompare(b.members[0] ?? "");
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Collapse: children that all reach the same things are one node.
|
|
506
|
+
|
|
507
|
+
const normalized = (node: Node, under: string): ReadonlyArray<string> =>
|
|
508
|
+
sorted(new Set([...node.allow].map((entry) => (isUnder(entry, under) ? `${under}/**` : entry))));
|
|
509
|
+
|
|
510
|
+
const sameList = (a: ReadonlyArray<string>, b: ReadonlyArray<string>): boolean =>
|
|
511
|
+
a.length === b.length && a.every((one, index) => one === b[index]);
|
|
512
|
+
|
|
513
|
+
const collapse = (node: Node): void => {
|
|
514
|
+
for (const child of node.children.values()) collapse(child);
|
|
515
|
+
if (node.children.size === 0) return;
|
|
516
|
+
const children = [...node.children.values()];
|
|
517
|
+
if (children.some((child) => child.children.size > 0)) return;
|
|
518
|
+
|
|
519
|
+
const [first] = children;
|
|
520
|
+
if (first === undefined) return;
|
|
521
|
+
const shared = normalized(first, node.path);
|
|
522
|
+
const externals = sorted(first.external);
|
|
523
|
+
const agree = children.every(
|
|
524
|
+
(child) =>
|
|
525
|
+
sameList(normalized(child, node.path), shared) && sameList(sorted(child.external), externals),
|
|
526
|
+
);
|
|
527
|
+
if (!agree) return;
|
|
528
|
+
const own = normalized(node, node.path);
|
|
529
|
+
if (!own.every((entry) => shared.includes(entry))) return;
|
|
530
|
+
if (!sorted(node.external).every((entry) => externals.includes(entry))) return;
|
|
531
|
+
|
|
532
|
+
node.allow = new Set(shared);
|
|
533
|
+
node.external = new Set(externals);
|
|
534
|
+
for (const child of children) {
|
|
535
|
+
for (const file of child.files) node.files.push(file);
|
|
536
|
+
node.edges += child.edges;
|
|
537
|
+
}
|
|
538
|
+
node.children = new Map();
|
|
539
|
+
node.stray = true;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Emitting.
|
|
544
|
+
|
|
545
|
+
const shortenWith = (aliases: Readonly<Record<string, string>>) => {
|
|
546
|
+
const entries = Object.entries(aliases).sort(([, a], [, b]) => b.length - a.length);
|
|
547
|
+
return (p: string): string => {
|
|
548
|
+
for (const [alias, target] of entries) {
|
|
549
|
+
if (p === target) return alias;
|
|
550
|
+
if (p.startsWith(`${target}/`)) return `${alias}${p.slice(target.length)}`;
|
|
551
|
+
}
|
|
552
|
+
return p;
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const CATCH_ALL = "**/";
|
|
557
|
+
|
|
558
|
+
export const inferManifest = (input: InferInput, options: InferOptions): Inferred => {
|
|
559
|
+
const { generalize } = options;
|
|
560
|
+
const trie = trieOf(input, generalize);
|
|
561
|
+
const bases = basesOf(input, generalize);
|
|
562
|
+
const roots = trie.map((folder) => nodeOf(folder, input, bases));
|
|
563
|
+
fillAllowSets(roots, input, generalize);
|
|
564
|
+
if (options.collapse) for (const root of roots) collapse(root);
|
|
565
|
+
|
|
566
|
+
const shorten = shortenWith(options.aliases ?? {});
|
|
567
|
+
let unrestricted = 0;
|
|
568
|
+
let nodes = 0;
|
|
569
|
+
let files = 0;
|
|
570
|
+
|
|
571
|
+
const describe = (node: Node): string => {
|
|
572
|
+
const membership = generalize.find((group) => capturedPathOf(group) === node.path);
|
|
573
|
+
const across =
|
|
574
|
+
membership === undefined
|
|
575
|
+
? ""
|
|
576
|
+
: ` across ${String(membership.members.length)} ${basenameOf(membership.parent)} (${membership.members.join(", ")})`;
|
|
577
|
+
const count = (n: number, noun: string): string => `${String(n)} ${noun}${n === 1 ? "" : "s"}`;
|
|
578
|
+
return node.files.length === 0
|
|
579
|
+
? `As built on ${options.date}: no files of its own.`
|
|
580
|
+
: `As built on ${options.date}${across}: ${count(node.files.length, "file")}, ${count(node.edges, "edge")}.`;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const emit = (node: Node): ManifestNode => {
|
|
584
|
+
nodes += 1;
|
|
585
|
+
files += node.files.length;
|
|
586
|
+
const children: Record<string, ManifestNode> = {};
|
|
587
|
+
for (const name of sorted(node.children.keys())) {
|
|
588
|
+
const child = node.children.get(name);
|
|
589
|
+
if (child !== undefined) children[child.key] = emit(child);
|
|
590
|
+
}
|
|
591
|
+
if (node.stray) children[CATCH_ALL] = { layout: "open", children: {} };
|
|
592
|
+
|
|
593
|
+
const imports =
|
|
594
|
+
node.files.length === 0
|
|
595
|
+
? {}
|
|
596
|
+
: {
|
|
597
|
+
imports: {
|
|
598
|
+
message: `This import is not on the allowlist infer wrote for ${node.path}/ on ${options.date}. Add it by name if it belongs here.`,
|
|
599
|
+
unrestricted: true,
|
|
600
|
+
...(node.allow.size > 0 ? { allow: sorted([...node.allow].map(shorten)) } : {}),
|
|
601
|
+
...(node.external.size > 0 ? { external: sorted(node.external) } : {}),
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
if (node.files.length > 0) unrestricted += 1;
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
message: describe(node),
|
|
608
|
+
layout: "open",
|
|
609
|
+
...imports,
|
|
610
|
+
children,
|
|
611
|
+
};
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const tree: Record<string, ManifestNode> = {};
|
|
615
|
+
for (const root of roots) tree[`${shorten(root.path)}/`] = emit(root);
|
|
616
|
+
|
|
617
|
+
const within = input.roots.map((root) => shorten(`${root}/**`));
|
|
618
|
+
const manifest: Manifest = {
|
|
619
|
+
resolve: options.resolve,
|
|
620
|
+
baseline: options.baseline,
|
|
621
|
+
...(options.aliases === undefined || Object.keys(options.aliases).length === 0
|
|
622
|
+
? {}
|
|
623
|
+
: { aliases: options.aliases }),
|
|
624
|
+
limits: { unrestricted, partial: 0 },
|
|
625
|
+
...(options.hasCycles
|
|
626
|
+
? {}
|
|
627
|
+
: {
|
|
628
|
+
graph: {
|
|
629
|
+
cycles: [
|
|
630
|
+
{
|
|
631
|
+
name: "no-cycles",
|
|
632
|
+
message:
|
|
633
|
+
"These files import each other, directly or through others. The tree had no cycle when this manifest was inferred; keep it that way.",
|
|
634
|
+
within: within.length === 1 ? (within[0] ?? "") : within,
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
},
|
|
638
|
+
}),
|
|
639
|
+
tree,
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
return { manifest, nodes, files };
|
|
643
|
+
};
|
package/src/ports/language.ts
CHANGED
|
@@ -21,6 +21,17 @@ export type Language = {
|
|
|
21
21
|
// Files carrying one of those extensions that are not source — for
|
|
22
22
|
// TypeScript, a declaration file states types and no linter visits one.
|
|
23
23
|
readonly ignoredFiles: ReadonlyArray<RegExp>;
|
|
24
|
+
// The files whose presence makes a folder a package of this language —
|
|
25
|
+
// `package.json`, `go.mod`, `Cargo.toml`. `infer` counts its depth from a
|
|
26
|
+
// package rather than from the repository root, so a monorepo of twenty
|
|
27
|
+
// packages is described one package at a time. Empty if the ecosystem has no
|
|
28
|
+
// such marker.
|
|
29
|
+
readonly packageMarkers: ReadonlyArray<string>;
|
|
30
|
+
// The folder names a package of this language keeps its source under, in
|
|
31
|
+
// order of preference — `src` for TypeScript, nothing for Go, where source
|
|
32
|
+
// sits at the package root. The first one that exists is where `infer`
|
|
33
|
+
// starts counting.
|
|
34
|
+
readonly sourceRoots: ReadonlyArray<string>;
|
|
24
35
|
// Reads the facts out of one source text. The CLI reads every file through
|
|
25
36
|
// it, and a probe carrying a `source` snippet is parsed by it at load.
|
|
26
37
|
readonly extractor: FactExtractor;
|