@blkc-code/mapgen 0.3.0 → 0.6.0
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/dist/index.d.ts +34 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +108 -54
- package/dist/index.js.map +1 -1
- package/dist/plan.d.ts +2 -0
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +1 -0
- package/dist/plan.js.map +1 -1
- package/dist/project-files.d.ts +13 -0
- package/dist/project-files.d.ts.map +1 -0
- package/dist/project-files.js +33 -0
- package/dist/project-files.js.map +1 -0
- package/dist/roads.d.ts +15 -0
- package/dist/roads.d.ts.map +1 -0
- package/dist/roads.js +328 -0
- package/dist/roads.js.map +1 -0
- package/dist/tree.d.ts +55 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +252 -0
- package/dist/tree.js.map +1 -0
- package/package.json +2 -2
- package/src/index.ts +132 -53
- package/src/plan.ts +3 -0
- package/src/project-files.ts +39 -0
- package/src/roads.ts +326 -0
- package/src/tree.ts +285 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Reading what's in a project: names and counts, and package manifests. Never source code. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
type Json = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
/** What marks a folder as an application, out of the box. */
|
|
9
|
+
export const DEFAULT_APP_MARKERS = ["block.json", "kit.json", "circuit.json", "package.json", "pyproject.toml", "setup.py", "requirements.txt", "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "Gemfile", "composer.json", "mix.exs", "deno.json", "pubspec.yaml", "Package.swift", "CMakeLists.txt", "*.csproj", "*.sln"];
|
|
10
|
+
|
|
11
|
+
export const markerMatcher = (marker: string): RegExp =>
|
|
12
|
+
new RegExp(`^${marker.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`);
|
|
13
|
+
|
|
14
|
+
export const IGNORE = new Set(["node_modules", ".git", "dist", "build", "out", "target", "coverage", ".next", ".turbo", ".cache", "vendor", "venv", "__pycache__", ".venv", ".idea", ".vscode", ".claude", ".blkc", ".gemini", ".cursor"]);
|
|
15
|
+
|
|
16
|
+
/** The plain folder names in a .gitignore. Patterns with wildcards aren't followed. */
|
|
17
|
+
export function gitignoredNames(root: string): Set<string> {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
try {
|
|
20
|
+
for (const line of readFileSync(path.join(root, ".gitignore"), "utf8").split("\n")) {
|
|
21
|
+
const name = line.trim().replace(/^\//, "").replace(/\/$/, "");
|
|
22
|
+
if (name && !/[*!#/\s]/.test(name)) names.add(name);
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// no .gitignore
|
|
26
|
+
}
|
|
27
|
+
return names;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A folder with its own .git is another project, even if it sits inside this one. */
|
|
31
|
+
export const isOwnRepo = (dir: string): boolean => existsSync(path.join(dir, ".git"));
|
|
32
|
+
|
|
33
|
+
export const readPackage = (dir: string): Json | undefined => {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as Json;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
};
|
package/src/roads.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A map that grows from the project tree, with no size limit. Each road is sized from what's
|
|
3
|
+
* on it, and its parent road makes room. Buildings are as big as their contents need and
|
|
4
|
+
* never shrink. Once placed, nothing moves: a new folder takes a spare lot on its road, or
|
|
5
|
+
* a new stretch of road at the edge of the map.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { deskCount, furnish, rng, slug, themeFor, titled, type MapDoor, type MapItem, type MapRoom, type OfficeMap } from "./index.js";
|
|
9
|
+
import { OFFICE_PLAN, worldPlan, type WorldPlan } from "./plan.js";
|
|
10
|
+
import type { ProjectTree, TreeNode } from "./tree.js";
|
|
11
|
+
|
|
12
|
+
const ROAD_H = 2;
|
|
13
|
+
const CELL = 4;
|
|
14
|
+
const SPARE = { w: 9, h: 6 };
|
|
15
|
+
const PILE_STEP = 8;
|
|
16
|
+
const MARGIN = 2;
|
|
17
|
+
const MARGIN_TOP = 3;
|
|
18
|
+
|
|
19
|
+
interface Placed { room: MapRoom; node?: TreeNode; side?: "n" | "s" }
|
|
20
|
+
interface Block { w: number; h: number; rooms: Placed[]; doors: MapDoor[] }
|
|
21
|
+
interface Lot { w: number; h: number; key: string; room?: () => MapRoom; node?: TreeNode; block?: Block }
|
|
22
|
+
|
|
23
|
+
const road = (id: string, name: string, x: number, y: number, w: number, h: number, paths: string[]): MapRoom =>
|
|
24
|
+
({ id, name, x, y, w, h, floor: "hall", paths, role: "hall" });
|
|
25
|
+
|
|
26
|
+
const leadsToBuilding = (node: TreeNode): boolean =>
|
|
27
|
+
node.children.some((child) => child.kind === "building" || (child.kind === "road" && leadsToBuilding(child)));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Only a road that leads to an application is drawn. Other folders fold into a Folders group
|
|
31
|
+
* on their parent's road, so tooling and notes can't crowd out the project.
|
|
32
|
+
*/
|
|
33
|
+
function foldFolders(node: TreeNode): TreeNode {
|
|
34
|
+
const folded = node.children.filter((child) => child.kind === "road" && !leadsToBuilding(child));
|
|
35
|
+
const kept = node.children.filter((child) => !folded.includes(child)).map((child) => (child.kind === "road" ? foldFolders(child) : child));
|
|
36
|
+
return {
|
|
37
|
+
...node,
|
|
38
|
+
children: folded.length > 0 ? [...kept, { id: `group:${node.path}:Folders`, kind: "group", name: "Folders", path: node.path, children: folded }] : kept,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A project that is one application has its own folders as buildings. */
|
|
43
|
+
function asRoads(tree: ProjectTree): TreeNode {
|
|
44
|
+
if (tree.root.kind === "road") return foldFolders(tree.root);
|
|
45
|
+
return {
|
|
46
|
+
...tree.root,
|
|
47
|
+
id: "road:",
|
|
48
|
+
kind: "road",
|
|
49
|
+
name: "/",
|
|
50
|
+
children: tree.root.children.map((child) => (child.kind === "part" ? { ...child, id: `building:${child.path}`, kind: "building" as const, children: [] } : child)),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const partsIn = (node: TreeNode): TreeNode[] => node.children.filter((child) => child.kind === "part");
|
|
55
|
+
|
|
56
|
+
/** Big enough for every part, in cells an agent can walk between. */
|
|
57
|
+
function buildingSize(node: TreeNode): { w: number; h: number } {
|
|
58
|
+
const parts = partsIn(node).length || deskCount(node.files ?? 1);
|
|
59
|
+
const across = Math.ceil(Math.sqrt(parts * 1.4));
|
|
60
|
+
const down = Math.ceil(parts / across);
|
|
61
|
+
return { w: Math.max(9, across * CELL + 1), h: Math.max(6, down * CELL + 1) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One item for each real part, all reachable: every cell has a free row and column around it. */
|
|
65
|
+
function fillBuilding(room: MapRoom, node: TreeNode, plan: WorldPlan): MapItem[] {
|
|
66
|
+
const across = Math.max(1, Math.floor((room.w - 1) / CELL));
|
|
67
|
+
const down = Math.max(1, Math.floor((room.h - 1) / CELL));
|
|
68
|
+
const parts = partsIn(node);
|
|
69
|
+
const entries = parts.length > 0
|
|
70
|
+
? parts.map((part) => ({ type: plan.partItems?.[part.item ?? ""] ?? plan.workstation, part: part.path }))
|
|
71
|
+
: Array.from({ length: deskCount(node.files ?? 1) }, () => ({ type: plan.workstation, part: node.path }));
|
|
72
|
+
return entries.slice(0, across * down).map((entry, index) => ({
|
|
73
|
+
type: entry.type,
|
|
74
|
+
x: room.x + 1 + (index % across) * CELL,
|
|
75
|
+
y: room.y + 1 + Math.floor(index / across) * CELL,
|
|
76
|
+
room: room.id,
|
|
77
|
+
part: entry.part,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildBlock(node: TreeNode, seed: string, plan: WorldPlan, suffix: string, top: boolean, spares: boolean): Block {
|
|
82
|
+
const id = slug(node.path) || "root";
|
|
83
|
+
const lots: Lot[] = [];
|
|
84
|
+
if (top) {
|
|
85
|
+
plan.specials.forEach((special, index) => {
|
|
86
|
+
lots.push({ w: special.w, h: special.h, key: special.id, room: () => ({ id: special.id, name: special.name, role: special.role, floor: special.floor, paths: index === 0 ? [""] : [], road: "", x: 0, y: 0, w: special.w, h: special.h }) });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
let buildings = 0;
|
|
90
|
+
for (const child of node.children) {
|
|
91
|
+
if (child.kind === "building") {
|
|
92
|
+
buildings += 1;
|
|
93
|
+
const size = buildingSize(child);
|
|
94
|
+
lots.push({
|
|
95
|
+
...size,
|
|
96
|
+
key: child.id,
|
|
97
|
+
node: child,
|
|
98
|
+
room: () => ({ id: slug(child.path), name: titled(child.name), floor: themeFor(child.path, plan, rng(`${seed}:theme:${child.path}`)).floor, paths: [`${child.path}/`], files: child.files ?? 0, road: node.path, x: 0, y: 0, ...size }),
|
|
99
|
+
});
|
|
100
|
+
} else if (child.kind === "road") {
|
|
101
|
+
const block = buildBlock(child, seed, plan, suffix, false, spares);
|
|
102
|
+
lots.push({ w: block.w, h: block.h, key: child.id, block });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (spares && buildings > 0) {
|
|
106
|
+
const count = Math.ceil(buildings / 4);
|
|
107
|
+
for (let n = 1; n <= count; n++) {
|
|
108
|
+
lots.push({ ...SPARE, key: `spare:${node.path}:${n}`, room: () => ({ id: `spare-${id}-${n}${suffix}`, name: "Empty", floor: "concrete", paths: [], role: "vacant", road: node.path, x: 0, y: 0, ...SPARE }) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Roads wrap into bands, so a long one stays roughly square rather than running forever.
|
|
113
|
+
const area = lots.reduce((sum, lot) => sum + (lot.w + 1) * (lot.h + 1), 0);
|
|
114
|
+
const limit = Math.max(44, Math.ceil(Math.sqrt(area) * 1.5), ...lots.map((lot) => lot.w + 4));
|
|
115
|
+
interface Band { north: Lot[]; south: Lot[]; n: number; s: number }
|
|
116
|
+
const bands: Band[] = [{ north: [], south: [], n: 3, s: 3 }];
|
|
117
|
+
for (const lot of lots) {
|
|
118
|
+
let band = bands[bands.length - 1] as Band;
|
|
119
|
+
if (Math.min(band.n, band.s) + lot.w > limit) {
|
|
120
|
+
band = { north: [], south: [], n: 3, s: 3 };
|
|
121
|
+
bands.push(band);
|
|
122
|
+
}
|
|
123
|
+
if (band.n <= band.s) { band.north.push(lot); band.n += lot.w + 1; }
|
|
124
|
+
else { band.south.push(lot); band.s += lot.w + 1; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const piles = node.children.filter((child) => child.kind === "group");
|
|
128
|
+
const label = node.path ? `${node.path}/` : "";
|
|
129
|
+
const wanted = 4 + Math.ceil(label.length * 0.4) + piles.length * PILE_STEP;
|
|
130
|
+
const width = Math.max(wanted, ...bands.map((band) => Math.max(band.n, band.s) - 1));
|
|
131
|
+
|
|
132
|
+
const out: Block = { w: width, h: 0, rooms: [], doors: [] };
|
|
133
|
+
const place = (lot: Lot, x: number, y: number, side: "n" | "s", wall: number): void => {
|
|
134
|
+
const random = rng(`${seed}:door:${lot.key}`);
|
|
135
|
+
if (lot.block) {
|
|
136
|
+
for (const placed of lot.block.rooms) out.rooms.push({ ...placed, room: { ...placed.room, x: placed.room.x + x, y: placed.room.y + y } });
|
|
137
|
+
for (const door of lot.block.doors) out.doors.push({ x: door.x + x, y: door.y + y });
|
|
138
|
+
// The side road's spine runs through the wall to meet this road.
|
|
139
|
+
out.rooms.push({ room: road(`link-${slug(lot.key)}${suffix}`, "", x, wall, ROAD_H, 1, []) });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const room = (lot.room as () => MapRoom)();
|
|
143
|
+
room.x = x;
|
|
144
|
+
room.y = y;
|
|
145
|
+
out.rooms.push({ room, ...(lot.node ? { node: lot.node } : {}), side });
|
|
146
|
+
out.doors.push({ x: x + 1 + Math.floor(random() * Math.max(1, lot.w - 2)), y: wall });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let y = 0;
|
|
150
|
+
bands.forEach((band, index) => {
|
|
151
|
+
const north = Math.max(0, ...band.north.map((lot) => lot.h));
|
|
152
|
+
const south = Math.max(0, ...band.south.map((lot) => lot.h));
|
|
153
|
+
const street = y + north + (north > 0 ? 1 : 0);
|
|
154
|
+
let x = 3;
|
|
155
|
+
for (const lot of band.north) {
|
|
156
|
+
place(lot, x, street - 1 - lot.h, "n", street - 1);
|
|
157
|
+
x += lot.w + 1;
|
|
158
|
+
}
|
|
159
|
+
x = 3;
|
|
160
|
+
for (const lot of band.south) {
|
|
161
|
+
place(lot, x, street + ROAD_H + 1, "s", street + ROAD_H);
|
|
162
|
+
x += lot.w + 1;
|
|
163
|
+
}
|
|
164
|
+
out.rooms.push({ room: road(`road-${id}-${index}${suffix}`, index === 0 ? label : "", 2, street, width - 2, ROAD_H, node.path ? [`${node.path}/`] : []) });
|
|
165
|
+
y = street + ROAD_H + 1 + (south > 0 ? south + 1 : 0);
|
|
166
|
+
});
|
|
167
|
+
out.h = Math.max(1, y - 1);
|
|
168
|
+
out.rooms.push({ room: road(`road-${id}-spine${suffix}`, "", 0, 0, ROAD_H, out.h, node.path ? [`${node.path}/`] : []) });
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Loose files, in groups, on the first stretch of their own road. Redrawn whenever they change. */
|
|
173
|
+
function placePiles(map: OfficeMap, top: TreeNode): void {
|
|
174
|
+
map.furniture = map.furniture.filter((item) => item.type !== "pile");
|
|
175
|
+
const visit = (node: TreeNode): void => {
|
|
176
|
+
const street = map.rooms.find((room) => room.role === "hall" && room.id === `road-${slug(node.path) || "root"}-0`);
|
|
177
|
+
if (street) {
|
|
178
|
+
const start = street.x + 2 + Math.ceil((node.path ? node.path.length + 1 : 0) * 0.4);
|
|
179
|
+
const fits = Math.max(1, Math.floor((street.x + street.w - start) / PILE_STEP));
|
|
180
|
+
node.children.filter((child) => child.kind === "group").slice(0, fits).forEach((group, index) => {
|
|
181
|
+
map.furniture.push({ type: "pile", x: start + index * PILE_STEP, y: street.y, room: street.id, part: group.id, label: `${group.name} ${group.children.length}` });
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
for (const child of node.children) if (child.kind === "road") visit(child);
|
|
185
|
+
};
|
|
186
|
+
visit(top);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function shift(map: OfficeMap): void {
|
|
190
|
+
if (map.rooms.length === 0) return;
|
|
191
|
+
const minX = Math.min(...map.rooms.map((room) => room.x));
|
|
192
|
+
const minY = Math.min(...map.rooms.map((room) => room.y));
|
|
193
|
+
const dx = MARGIN - minX;
|
|
194
|
+
const dy = MARGIN_TOP - minY;
|
|
195
|
+
for (const room of map.rooms) { room.x += dx; room.y += dy; }
|
|
196
|
+
for (const door of map.doors) { door.x += dx; door.y += dy; }
|
|
197
|
+
for (const item of map.furniture) { item.x += dx; item.y += dy; }
|
|
198
|
+
map.cols = Math.max(...map.rooms.map((room) => room.x + room.w)) + MARGIN;
|
|
199
|
+
map.rows = Math.max(...map.rooms.map((room) => room.y + room.h)) + MARGIN;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function collectBuildings(node: TreeNode, into: Map<string, { node: TreeNode; road: TreeNode }> = new Map()): Map<string, { node: TreeNode; road: TreeNode }> {
|
|
203
|
+
for (const child of node.children) {
|
|
204
|
+
if (child.kind === "building") into.set(slug(child.path), { node: child, road: node });
|
|
205
|
+
else if (child.kind === "road") collectBuildings(child, into);
|
|
206
|
+
}
|
|
207
|
+
return into;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const setLinks = (map: OfficeMap, tree: ProjectTree): void => {
|
|
211
|
+
const on = new Set(map.rooms.map((room) => room.id));
|
|
212
|
+
const links = tree.links
|
|
213
|
+
.map((link) => ({ from: slug(link.from.replace(/^building:/, "")), to: slug(link.to.replace(/^building:/, "")), door: false }))
|
|
214
|
+
.filter((link) => on.has(link.from) && on.has(link.to));
|
|
215
|
+
if (links.length > 0) map.links = links;
|
|
216
|
+
else delete map.links;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** Absorb a block into the map, furnishing what it holds. */
|
|
220
|
+
function addBlock(map: OfficeMap, block: Block, x: number, y: number, seed: string, plan: WorldPlan): void {
|
|
221
|
+
for (const placed of block.rooms) {
|
|
222
|
+
const room = { ...placed.room, x: placed.room.x + x, y: placed.room.y + y };
|
|
223
|
+
map.rooms.push(room);
|
|
224
|
+
if (placed.node) map.furniture.push(...fillBuilding(room, placed.node, plan));
|
|
225
|
+
}
|
|
226
|
+
for (const door of block.doors) map.doors.push({ x: door.x + x, y: door.y + y });
|
|
227
|
+
for (const placed of block.rooms) {
|
|
228
|
+
const { role, id } = placed.room;
|
|
229
|
+
if (!role || role === "hall") continue;
|
|
230
|
+
const room = map.rooms.find((candidate) => candidate.id === id) as MapRoom;
|
|
231
|
+
map.furniture.push(...furnish(room, map.doors, seed, plan));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function layoutRoads(tree: ProjectTree, seed: string, plan: WorldPlan = OFFICE_PLAN): OfficeMap {
|
|
236
|
+
const top = asRoads(tree);
|
|
237
|
+
const map: OfficeMap = { version: 1, ...(plan === OFFICE_PLAN ? {} : { world: plan.id }), layout: "roads", seed, evolve: true, cols: 0, rows: 0, rooms: [], doors: [], furniture: [], log: [] };
|
|
238
|
+
addBlock(map, buildBlock(top, seed, plan, "", true, true), 0, 0, seed, plan);
|
|
239
|
+
// The first room stays first in the file.
|
|
240
|
+
map.rooms.sort((a, b) => Number(b.role === "lobby") - Number(a.role === "lobby"));
|
|
241
|
+
placePiles(map, top);
|
|
242
|
+
setLinks(map, tree);
|
|
243
|
+
shift(map);
|
|
244
|
+
return map;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function evolveRoads(previous: OfficeMap | undefined, tree: ProjectTree, seed: string, now = new Date().toISOString()): { map: OfficeMap; changes: string[] } {
|
|
248
|
+
if (!previous) return { map: layoutRoads(tree, seed), changes: [] };
|
|
249
|
+
const plan = worldPlan(previous.world);
|
|
250
|
+
const map = JSON.parse(JSON.stringify(previous)) as OfficeMap;
|
|
251
|
+
const changes: string[] = [];
|
|
252
|
+
const top = asRoads(tree);
|
|
253
|
+
const wanted = collectBuildings(top);
|
|
254
|
+
|
|
255
|
+
const clearItems = (id: string): void => { map.furniture = map.furniture.filter((item) => item.room !== id); };
|
|
256
|
+
for (const room of map.rooms) {
|
|
257
|
+
if (room.role || room.locked || wanted.has(room.id)) continue;
|
|
258
|
+
changes.push(`${room.name} is now empty: its folder has gone`);
|
|
259
|
+
clearItems(room.id);
|
|
260
|
+
room.role = "vacant";
|
|
261
|
+
room.name = "Empty";
|
|
262
|
+
room.paths = [];
|
|
263
|
+
delete room.files;
|
|
264
|
+
map.furniture.push(...furnish(room, map.doors, map.seed, plan));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const newOnRoad = new Map<string, TreeNode[]>();
|
|
268
|
+
const signature = (items: MapItem[]): string => items.map((item) => `${item.type}:${item.part}`).sort().join("|");
|
|
269
|
+
for (const [id, { node, road: parent }] of wanted) {
|
|
270
|
+
const room = map.rooms.find((candidate) => candidate.id === id);
|
|
271
|
+
if (room?.locked) continue;
|
|
272
|
+
if (room) {
|
|
273
|
+
const back = room.role === "vacant";
|
|
274
|
+
if (back) {
|
|
275
|
+
delete room.role;
|
|
276
|
+
room.name = titled(node.name);
|
|
277
|
+
room.paths = [`${node.path}/`];
|
|
278
|
+
room.floor = themeFor(node.path, plan, rng(`${map.seed}:theme:${node.path}`)).floor;
|
|
279
|
+
changes.push(`${room.name} is back`);
|
|
280
|
+
}
|
|
281
|
+
room.files = node.files ?? 0;
|
|
282
|
+
const items = fillBuilding(room, node, plan);
|
|
283
|
+
if (back || signature(items) !== signature(map.furniture.filter((item) => item.room === id))) {
|
|
284
|
+
clearItems(id);
|
|
285
|
+
map.furniture.push(...items);
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// A spare lot on its own road, if there's one big enough.
|
|
290
|
+
const size = buildingSize(node);
|
|
291
|
+
const lot = map.rooms.find((candidate) => candidate.role === "vacant" && !candidate.locked && candidate.road === parent.path && candidate.w >= size.w && candidate.h >= size.h);
|
|
292
|
+
if (lot) {
|
|
293
|
+
clearItems(lot.id);
|
|
294
|
+
lot.id = id;
|
|
295
|
+
delete lot.role;
|
|
296
|
+
lot.name = titled(node.name);
|
|
297
|
+
lot.floor = themeFor(node.path, plan, rng(`${map.seed}:theme:${node.path}`)).floor;
|
|
298
|
+
lot.paths = [`${node.path}/`];
|
|
299
|
+
lot.files = node.files ?? 0;
|
|
300
|
+
map.furniture.push(...fillBuilding(lot, node, plan));
|
|
301
|
+
changes.push(`New room: ${lot.name}`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
newOnRoad.set(parent.path, [...(newOnRoad.get(parent.path) ?? []), node]);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// No lot left: a new stretch of road, below everything, so nothing else has to move.
|
|
308
|
+
const spine = map.rooms.find((room) => room.id === "road-root-spine");
|
|
309
|
+
for (const [path, nodes] of newOnRoad) {
|
|
310
|
+
const bottom = Math.max(...map.rooms.map((room) => room.y + room.h));
|
|
311
|
+
const startX = spine?.x ?? Math.min(...map.rooms.map((room) => room.x));
|
|
312
|
+
const stretch = map.rooms.filter((room) => room.id.startsWith("road-") && room.id.endsWith("spine")).length;
|
|
313
|
+
const node: TreeNode = { id: `road:${path}`, kind: "road", name: `${path}/`, path, children: nodes };
|
|
314
|
+
const y = bottom + 1;
|
|
315
|
+
addBlock(map, buildBlock(node, map.seed, plan, `-more${stretch}`, false, false), startX, y, map.seed, plan);
|
|
316
|
+
if (spine) spine.h = y - spine.y;
|
|
317
|
+
for (const added of nodes) changes.push(`New room: ${titled(added.name)}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
placePiles(map, top);
|
|
321
|
+
|
|
322
|
+
setLinks(map, tree);
|
|
323
|
+
shift(map);
|
|
324
|
+
if (changes.length > 0) map.log = [...map.log, ...changes.map((text) => ({ at: now, text }))].slice(-30);
|
|
325
|
+
return { map, changes };
|
|
326
|
+
}
|
package/src/tree.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project as a world, before anything is placed. A folder holding an application is a
|
|
3
|
+
* building. A folder holding none is a road, at any depth, named by its literal path. Loose
|
|
4
|
+
* files sit on the road of their own folder, grouped by type. Files that look like secrets
|
|
5
|
+
* are kept apart, for the vault.
|
|
6
|
+
*
|
|
7
|
+
* Only names and counts are read here, and package manifests for links. Never source code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { readdirSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { DEFAULT_APP_MARKERS, IGNORE, gitignoredNames, isOwnRepo, markerMatcher, readPackage } from "./project-files.js";
|
|
14
|
+
|
|
15
|
+
export type TreeKind = "road" | "building" | "part" | "group" | "file";
|
|
16
|
+
|
|
17
|
+
export interface TreeNode {
|
|
18
|
+
/** Stable, from the path: "road:apps", "building:apps/web", "part:apps/web/src/api", "group:apps:Docs", "file:apps/x.md". */
|
|
19
|
+
id: string;
|
|
20
|
+
kind: TreeKind;
|
|
21
|
+
name: string;
|
|
22
|
+
/** From the top of the project. The root road's is "". */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Files git tracks here, or on disk if it isn't tracked yet. */
|
|
25
|
+
files?: number;
|
|
26
|
+
/** Which item a part is, such as "Desk". */
|
|
27
|
+
item?: string;
|
|
28
|
+
children: TreeNode[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProjectTree {
|
|
32
|
+
root: TreeNode;
|
|
33
|
+
/** Files that matched the Secrets group: their paths only. */
|
|
34
|
+
secrets: TreeNode[];
|
|
35
|
+
/** `from` depends on `to`, by building id. */
|
|
36
|
+
links: { from: string; to: string }[];
|
|
37
|
+
/** The scan hit its limit, so this isn't all of the project. */
|
|
38
|
+
truncated: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TreeOptions {
|
|
42
|
+
markers?: string[];
|
|
43
|
+
replaceDefaults?: boolean;
|
|
44
|
+
/** Folders that are buildings even with no marker. */
|
|
45
|
+
include?: string[];
|
|
46
|
+
/** Never shown: a folder name, or a path from the top. */
|
|
47
|
+
ignore?: string[];
|
|
48
|
+
/** Walk these even if gitignored or separate repos. */
|
|
49
|
+
roads?: string[];
|
|
50
|
+
/** Added to the built-in groups. Giving a built-in group's name replaces its patterns. */
|
|
51
|
+
fileGroups?: Record<string, string[]>;
|
|
52
|
+
/** Added to the built-in items. Each entry is a folder name, or a path ending that folder's path. */
|
|
53
|
+
partTypes?: Record<string, string[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const SECRETS_GROUP = "Secrets";
|
|
57
|
+
|
|
58
|
+
/** Secrets comes first, so a file like .env.json is never left in Config. */
|
|
59
|
+
export const DEFAULT_FILE_GROUPS: Record<string, string[]> = {
|
|
60
|
+
[SECRETS_GROUP]: [".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa", "id_ed25519", ".npmrc", ".netrc"],
|
|
61
|
+
Docs: ["*.md", "*.mdx", "*.rst", "LICENSE*", "NOTICE*"],
|
|
62
|
+
Config: [".env.*", "*.json", "*.jsonc", "*.yaml", "*.yml", "*.toml", "*.config.*", ".*ignore", ".*rc", ".*rc.*", ".editorconfig", "Dockerfile*", "Makefile"],
|
|
63
|
+
Pages: ["*.html", "*.htm"],
|
|
64
|
+
Images: ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.webp", "*.ico"],
|
|
65
|
+
Logs: ["*.log", "*.txt", "*history*"],
|
|
66
|
+
Code: ["*.ts", "*.tsx", "*.js", "*.mjs", "*.cjs", "*.py", "*.go", "*.rs", "*.java", "*.rb", "*.php", "*.sh", "*.css", "*.sql"],
|
|
67
|
+
};
|
|
68
|
+
const OTHER_GROUP = "Other";
|
|
69
|
+
/** Templates are meant to be committed, and hold no real values. */
|
|
70
|
+
const SECRET_TEMPLATE = /\.(example|sample|template|dist)$/i;
|
|
71
|
+
|
|
72
|
+
/** What a folder inside an application is, by its name. Anything else under src/ is a Desk. */
|
|
73
|
+
export const DEFAULT_PART_TYPES: Record<string, string[]> = {
|
|
74
|
+
"Server rack": ["api", "server", "servers", "backend", "supabase", "migrations", "db", "database", "prisma"],
|
|
75
|
+
Workbench: ["lib", "libs", "shared", "utils", "core", "common", "kernel"],
|
|
76
|
+
Whiteboard: ["test", "tests", "__tests__", "e2e", "spec", "specs"],
|
|
77
|
+
Bookshelf: ["docs", "doc", "documentation"],
|
|
78
|
+
Toolbox: ["scripts", "tools", "bin", "skills"],
|
|
79
|
+
Plants: ["public", "assets", "static", "images"],
|
|
80
|
+
};
|
|
81
|
+
const FEATURE_ITEM = "Desk";
|
|
82
|
+
const OTHER_ITEM = "Filing cabinet";
|
|
83
|
+
/** Folders inside an application that hold its parts, rather than being one. */
|
|
84
|
+
const PART_CONTAINERS = new Set(["src", "app", "pages", "features", "modules"]);
|
|
85
|
+
|
|
86
|
+
const MAX_ENTRIES = 400000;
|
|
87
|
+
const MAX_DEPTH = 12;
|
|
88
|
+
|
|
89
|
+
const clean = (value: string): string => value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
90
|
+
const join = (base: string, name: string): string => (base ? `${base}/${name}` : name);
|
|
91
|
+
const within = (rel: string, parent: string): boolean => rel === parent || rel.startsWith(`${parent}/`);
|
|
92
|
+
|
|
93
|
+
export function scanTree(root: string, options: TreeOptions = {}): ProjectTree {
|
|
94
|
+
const gitignored = gitignoredNames(root);
|
|
95
|
+
const skipped = new Set((options.ignore ?? []).map(clean));
|
|
96
|
+
const isSkipped = (rel: string): boolean => skipped.has(rel) || skipped.has(path.posix.basename(rel));
|
|
97
|
+
const roads = (options.roads ?? []).map(clean);
|
|
98
|
+
/** An added road, somewhere above or below this folder, opens the way through. */
|
|
99
|
+
const onAddedRoad = (rel: string): boolean => roads.some((road) => within(rel, road) || within(road, rel));
|
|
100
|
+
|
|
101
|
+
const markers = [...(options.replaceDefaults ? [] : DEFAULT_APP_MARKERS), ...(options.markers ?? [])].map(markerMatcher);
|
|
102
|
+
const includes = new Set((options.include ?? []).map(clean));
|
|
103
|
+
|
|
104
|
+
const groupMatchers = Object.entries({ ...DEFAULT_FILE_GROUPS, ...options.fileGroups })
|
|
105
|
+
.sort(([a], [b]) => Number(b === SECRETS_GROUP) - Number(a === SECRETS_GROUP))
|
|
106
|
+
.map(([name, patterns]) => ({ name, patterns: patterns.map(markerMatcher) }));
|
|
107
|
+
const groupOf = (file: string): string =>
|
|
108
|
+
groupMatchers.find((group) => !(group.name === SECRETS_GROUP && SECRET_TEMPLATE.test(file)) && group.patterns.some((pattern) => pattern.test(file)))?.name ?? OTHER_GROUP;
|
|
109
|
+
|
|
110
|
+
const customParts = Object.entries(options.partTypes ?? {}).map(([item, folders]) => ({ item, folders: folders.map(clean) }));
|
|
111
|
+
const itemFor = (rel: string, nested: boolean): string => {
|
|
112
|
+
const name = path.posix.basename(rel);
|
|
113
|
+
const custom = customParts.find(({ folders }) => folders.some((folder) => folder === name || rel === folder || rel.endsWith(`/${folder}`)));
|
|
114
|
+
if (custom) return custom.item;
|
|
115
|
+
const known = Object.entries(DEFAULT_PART_TYPES).find(([, folders]) => folders.includes(name));
|
|
116
|
+
if (known) return known[0];
|
|
117
|
+
return nested ? FEATURE_ITEM : OTHER_ITEM;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// What git tracks is the truth, repo by repo, since an added road can hold separate repos.
|
|
121
|
+
const trackedIn = new Map<string, string[]>();
|
|
122
|
+
const tracked = (repo: string): string[] => {
|
|
123
|
+
let files = trackedIn.get(repo);
|
|
124
|
+
if (!files) {
|
|
125
|
+
try {
|
|
126
|
+
files = execFileSync("git", ["ls-files", "-z"], { cwd: path.join(root, repo), encoding: "utf8", maxBuffer: 512 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).split("\0").filter(Boolean);
|
|
127
|
+
} catch {
|
|
128
|
+
files = [];
|
|
129
|
+
}
|
|
130
|
+
trackedIn.set(repo, files);
|
|
131
|
+
}
|
|
132
|
+
return files;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let budget = MAX_ENTRIES;
|
|
136
|
+
let truncated = false;
|
|
137
|
+
const list = (rel: string) => {
|
|
138
|
+
try {
|
|
139
|
+
const entries = readdirSync(path.join(root, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
140
|
+
budget -= entries.length;
|
|
141
|
+
if (budget <= 0) truncated = true;
|
|
142
|
+
return entries;
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const countOnDisk = (rel: string, depth = 0): number => {
|
|
148
|
+
if (budget <= 0 || depth > MAX_DEPTH) return 0;
|
|
149
|
+
let total = 0;
|
|
150
|
+
for (const entry of list(rel)) {
|
|
151
|
+
if (entry.isFile()) total += 1;
|
|
152
|
+
else if (entry.isDirectory() && !entry.name.startsWith(".") && !IGNORE.has(entry.name) && !gitignored.has(entry.name)) total += countOnDisk(join(rel, entry.name), depth + 1);
|
|
153
|
+
}
|
|
154
|
+
return total;
|
|
155
|
+
};
|
|
156
|
+
const countFiles = (rel: string, repo: string): number => {
|
|
157
|
+
const inside = repo ? rel.slice(repo.length + 1) : rel;
|
|
158
|
+
const prefix = inside ? `${inside}/` : "";
|
|
159
|
+
const count = tracked(repo).filter((file) => file.startsWith(prefix)).length;
|
|
160
|
+
return count > 0 ? count : countOnDisk(rel);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const secrets: TreeNode[] = [];
|
|
164
|
+
const looseFiles = (owner: string, files: string[]): TreeNode[] => {
|
|
165
|
+
const groups = new Map<string, TreeNode[]>();
|
|
166
|
+
for (const file of files) {
|
|
167
|
+
const rel = join(owner, file);
|
|
168
|
+
const node: TreeNode = { id: `file:${rel}`, kind: "file", name: file, path: rel, children: [] };
|
|
169
|
+
const group = groupOf(file);
|
|
170
|
+
if (group === SECRETS_GROUP) {
|
|
171
|
+
secrets.push(node);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
groups.set(group, [...(groups.get(group) ?? []), node]);
|
|
175
|
+
}
|
|
176
|
+
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, children]) => ({ id: `group:${owner}:${name}`, kind: "group" as const, name, path: owner, children }));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** A folder is shown unless it's hidden, junk, ignored, or another repo no road opened. */
|
|
180
|
+
const showFolder = (rel: string, name: string): boolean => {
|
|
181
|
+
if (IGNORE.has(name) || isSkipped(rel)) return false;
|
|
182
|
+
const opened = onAddedRoad(rel);
|
|
183
|
+
if (name.startsWith(".") && name !== ".github" && !opened) return false;
|
|
184
|
+
if (gitignored.has(name) && !opened) return false;
|
|
185
|
+
return opened || !isOwnRepo(path.join(root, rel));
|
|
186
|
+
};
|
|
187
|
+
const isApp = (rel: string, files: string[]): boolean =>
|
|
188
|
+
includes.has(rel) || files.some((file) => markers.some((marker) => marker.test(file)));
|
|
189
|
+
|
|
190
|
+
const partsOf = (rel: string, repo: string, nested: boolean, depth: number): TreeNode[] => {
|
|
191
|
+
const parts: TreeNode[] = [];
|
|
192
|
+
for (const entry of list(rel)) {
|
|
193
|
+
if (!entry.isDirectory()) continue;
|
|
194
|
+
const child = join(rel, entry.name);
|
|
195
|
+
if (entry.name.startsWith(".") || !showFolder(child, entry.name)) continue;
|
|
196
|
+
if (PART_CONTAINERS.has(entry.name) && depth < 3) {
|
|
197
|
+
const inner = partsOf(child, repo, true, depth + 1);
|
|
198
|
+
if (inner.length > 0) {
|
|
199
|
+
parts.push(...inner);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const files = countFiles(child, repo);
|
|
204
|
+
if (files > 0) parts.push({ id: `part:${child}`, kind: "part", name: path.posix.basename(child), path: child, files, item: itemFor(child, nested), children: [] });
|
|
205
|
+
}
|
|
206
|
+
return parts;
|
|
207
|
+
};
|
|
208
|
+
const building = (rel: string, repo: string, files: string[]): TreeNode => ({
|
|
209
|
+
id: `building:${rel}`,
|
|
210
|
+
kind: "building",
|
|
211
|
+
name: path.posix.basename(rel) || path.basename(path.resolve(root)),
|
|
212
|
+
path: rel,
|
|
213
|
+
files: countFiles(rel, repo),
|
|
214
|
+
children: [...partsOf(rel, repo, false, 0), ...looseFiles(rel, files)],
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const walk = (rel: string, repo: string, depth: number): TreeNode => {
|
|
218
|
+
const entries = list(rel);
|
|
219
|
+
const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
220
|
+
const children: TreeNode[] = [];
|
|
221
|
+
if (depth < MAX_DEPTH && budget > 0) {
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (!entry.isDirectory()) continue;
|
|
224
|
+
const child = join(rel, entry.name);
|
|
225
|
+
if (!showFolder(child, entry.name)) continue;
|
|
226
|
+
const childRepo = isOwnRepo(path.join(root, child)) ? child : repo;
|
|
227
|
+
const childFiles = list(child).filter((item) => item.isFile()).map((item) => item.name);
|
|
228
|
+
if (isApp(child, childFiles)) {
|
|
229
|
+
children.push(building(child, childRepo, childFiles));
|
|
230
|
+
} else {
|
|
231
|
+
const road = walk(child, childRepo, depth + 1);
|
|
232
|
+
if (road.children.length > 0) children.push(road);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} else if (depth >= MAX_DEPTH) {
|
|
236
|
+
truncated = true;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
id: `road:${rel}`,
|
|
240
|
+
kind: "road",
|
|
241
|
+
name: rel ? `${path.posix.basename(rel)}/` : "/",
|
|
242
|
+
path: rel,
|
|
243
|
+
children: [...children, ...looseFiles(rel, files)],
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const hasBuildings = (node: TreeNode): boolean => node.children.some((child) => child.kind === "building" || (child.kind === "road" && hasBuildings(child)));
|
|
248
|
+
const topFiles = list("").filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
249
|
+
let top = walk("", "", 0);
|
|
250
|
+
// With no application inside it, the whole project is one, marked or not.
|
|
251
|
+
if (!hasBuildings(top)) {
|
|
252
|
+
secrets.length = 0;
|
|
253
|
+
top = building("", "", topFiles);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const buildings: TreeNode[] = [];
|
|
257
|
+
const collect = (node: TreeNode): void => {
|
|
258
|
+
if (node.kind === "building") buildings.push(node);
|
|
259
|
+
for (const child of node.children) if (child.kind === "road") collect(child);
|
|
260
|
+
if (node.kind === "road") for (const child of node.children) if (child.kind === "building") buildings.push(child);
|
|
261
|
+
};
|
|
262
|
+
collect(top);
|
|
263
|
+
const owners = new Map<string, string>();
|
|
264
|
+
const manifests = new Map<string, Record<string, unknown>>();
|
|
265
|
+
for (const node of buildings) {
|
|
266
|
+
const manifest = readPackage(path.join(root, node.path));
|
|
267
|
+
if (!manifest) continue;
|
|
268
|
+
manifests.set(node.id, manifest);
|
|
269
|
+
if (typeof manifest["name"] === "string") owners.set(manifest["name"], node.id);
|
|
270
|
+
}
|
|
271
|
+
const links: { from: string; to: string }[] = [];
|
|
272
|
+
for (const [from, manifest] of manifests) {
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
275
|
+
for (const name of Object.keys((manifest[field] ?? {}) as Record<string, unknown>)) {
|
|
276
|
+
const to = owners.get(name);
|
|
277
|
+
if (to && to !== from && !seen.has(to)) {
|
|
278
|
+
seen.add(to);
|
|
279
|
+
links.push({ from, to });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { root: top, secrets, links, truncated };
|
|
285
|
+
}
|