@dogfood-lab/atlas 0.0.0 → 1.13.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/LICENSE +21 -0
- package/README.md +54 -1
- package/adapter/artifact.js +157 -0
- package/adapter/boundary-file.js +99 -0
- package/adapter/check.js +200 -0
- package/adapter/commands.js +220 -0
- package/adapter/divergence.js +99 -0
- package/adapter/errors.js +28 -0
- package/adapter/init.js +82 -0
- package/adapter/page.js +741 -0
- package/adapter/propose.js +143 -0
- package/adapter/statistics.js +236 -0
- package/adapter/templates.js +71 -0
- package/adapter/write.js +44 -0
- package/cli.js +4 -0
- package/core/divergence.js +104 -0
- package/core/doors.js +491 -0
- package/core/entry-points.js +100 -0
- package/core/history.js +211 -0
- package/core/index.js +375 -0
- package/core/landings.js +1014 -0
- package/core/reach.js +52 -0
- package/core/resolve.js +576 -0
- package/grammars/manifest.json +10 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/index.js +1 -0
- package/package.json +68 -4
- package/templates/atlas-refresh.yml +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mcp-tool-shop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
1
|
# @dogfood-lab/atlas
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Atlas reads a git repository and writes a page that says how it works: what comes in, what runs, where it lands, who reads it, what breaks what, and where to start reading. Nothing in the page is written by a person. It is derived from the workflows, the manifests, the imports, the writes and reads in the code, and the git history, and it is regenerated whenever the repository changes.
|
|
4
|
+
|
|
5
|
+
The page is for anyone who has to understand a repository they did not write: a new contributor, a reviewer, an operator, or a model.
|
|
6
|
+
|
|
7
|
+
## Use
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx --yes @dogfood-lab/atlas init
|
|
11
|
+
npx --yes @dogfood-lab/atlas map
|
|
12
|
+
npx --yes @dogfood-lab/atlas check
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`init` proposes `atlas/boundaries.yaml`: the named parts of the repository and the globs that own them. Edit the names and globs if the proposal is wrong. The one line a person may add is `summary`.
|
|
16
|
+
|
|
17
|
+
`map` writes four files under `atlas/`:
|
|
18
|
+
|
|
19
|
+
| File | What it is |
|
|
20
|
+
|------|------------|
|
|
21
|
+
| `README.md` | The page. GitHub renders it when anyone opens the folder. |
|
|
22
|
+
| `page.json` | The same sections as data, for sites and tools. |
|
|
23
|
+
| `structure.json` | Every tracked file in its part, the import edges between parts, the doors, the landing places and their readers. Byte-deterministic. |
|
|
24
|
+
| `statistics.json` | What changes together over the last 180 days, dated. |
|
|
25
|
+
|
|
26
|
+
`check` compares the committed map with the working tree and fails when a part gains or loses a dependency, a file changes part, a new file belongs to no part, a named part matches nothing, or a file belongs to two parts. Run it in the test job so the map moves with the code. A repository with no `atlas/` directory is a notice and exit 0, so adopting Atlas reddens nothing.
|
|
27
|
+
|
|
28
|
+
## What it reads
|
|
29
|
+
|
|
30
|
+
- **Doors.** Every workflow under `.github/workflows/`: its triggers, the files its steps execute (following `npm run` through root and workspace scripts), what it stages and pushes, what it dispatches, publishes, releases or deploys.
|
|
31
|
+
- **Parts and imports.** JavaScript, TypeScript, TSX and Python, parsed with tree-sitter; imports resolved with the same rules the runtime uses, including workspace package exports without `node_modules`.
|
|
32
|
+
- **Landing places and readers.** The tracked paths that code writes to and reads from, found in call expressions and followed through joins and helpers; raw GitHub URLs and quoted paths in files it does not parse, marked as found by text.
|
|
33
|
+
- **History.** Files that change together, with a floor that falls when the history is thin, and the confidence stated on the page.
|
|
34
|
+
|
|
35
|
+
## What it cannot see
|
|
36
|
+
|
|
37
|
+
Paths built at run time are counted, not named. Imports that do not resolve are counted. The page ends with the list of what the map could not see for that repository, so a reader knows the edges of the picture.
|
|
38
|
+
|
|
39
|
+
## Errors
|
|
40
|
+
|
|
41
|
+
Every failure prints one shape: the code, one sentence, what changed, what to do, and the exit code. Exit 2 means the input was unusable and nothing was checked; exit 1 means the check ran and the tree disagrees with the committed map.
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
ATLAS_OVERLAP A file belongs to more than one boundary.
|
|
45
|
+
what changed: shared/util.js: alpha, shared
|
|
46
|
+
what to do: narrow one boundary's globs, then atlas map
|
|
47
|
+
exit 1
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Private repositories
|
|
51
|
+
|
|
52
|
+
`templates/atlas-refresh.yml` is a workflow that maps a private repository inside its own CI and commits the page there. Nothing leaves the repository.
|
|
53
|
+
|
|
54
|
+
## Part of testing-os
|
|
55
|
+
|
|
56
|
+
Atlas is one package of [dogfood-lab/testing-os](https://github.com/dogfood-lab/testing-os). The specification of the page, and the reasoning behind it, is `docs/atlas-page.spec.md` there. Licence: MIT.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { isTestMaterial } from '../core/landings.js';
|
|
2
|
+
import { roleFor } from './templates.js';
|
|
3
|
+
|
|
4
|
+
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
5
|
+
|
|
6
|
+
// The artifact describes the tree minus atlas/. Every list below, and every
|
|
7
|
+
// count, is over that set. The directory cannot record a stable hash of
|
|
8
|
+
// itself, and a count that includes it changes on the commit that lands it.
|
|
9
|
+
function inAtlas(path) {
|
|
10
|
+
return path === 'atlas' || path.startsWith('atlas/');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function keep(items) {
|
|
14
|
+
return items.filter((item) => !inAtlas(item.path));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function siteCounts(files) {
|
|
18
|
+
let unresolved = 0;
|
|
19
|
+
let resolved = 0;
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
if (!Array.isArray(file.imports)) continue;
|
|
22
|
+
for (const site of file.imports) {
|
|
23
|
+
const outcome = site.resolved?.outcome;
|
|
24
|
+
if (outcome === 'file' || outcome === 'boundary' || outcome === 'external') resolved += 1;
|
|
25
|
+
else unresolved += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { unresolved, resolved };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Reads and writes whose path is built at run time name no place, so the map
|
|
32
|
+
// can only count them. Test material is left out for the reason landings leave
|
|
33
|
+
// it out: what it names is a temporary copy, not the repository.
|
|
34
|
+
function dynamicCounts(files) {
|
|
35
|
+
let reads = 0;
|
|
36
|
+
let writes = 0;
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
if (isTestMaterial(file.path)) continue;
|
|
39
|
+
reads += file.dynamicReads ?? 0;
|
|
40
|
+
writes += file.dynamicWrites ?? 0;
|
|
41
|
+
}
|
|
42
|
+
return { reads, writes };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A boundary file may leave a role out; the role is then derived from the
|
|
46
|
+
// files, the same way init derives the one it writes.
|
|
47
|
+
export function buildArtifact(mapped, commit) {
|
|
48
|
+
const boundaries = mapped.boundaries.map((boundary) => {
|
|
49
|
+
const files = keep(boundary.files);
|
|
50
|
+
const sites = siteCounts(files);
|
|
51
|
+
const dynamic = dynamicCounts(files);
|
|
52
|
+
return {
|
|
53
|
+
dynamicReads: dynamic.reads,
|
|
54
|
+
dynamicWrites: dynamic.writes,
|
|
55
|
+
entryPoints: [...boundary.entryPoints].filter((path) => !inAtlas(path)).sort(),
|
|
56
|
+
files: files.map((file) => ({ hash: file.hash, path: file.path })).sort(byPath),
|
|
57
|
+
globs: [...boundary.globs].sort(),
|
|
58
|
+
importConfidence: sites.unresolved > sites.resolved ? 'low' : 'full',
|
|
59
|
+
name: boundary.name,
|
|
60
|
+
origin: boundary.origin,
|
|
61
|
+
role: boundary.role ?? roleFor(files.map((file) => file.path)),
|
|
62
|
+
unresolvedSites: sites.unresolved,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
const overlaps = keep(mapped.overlaps)
|
|
66
|
+
.map((overlap) => ({
|
|
67
|
+
boundaries: [...overlap.boundaries].sort(),
|
|
68
|
+
hash: overlap.hash,
|
|
69
|
+
path: overlap.path,
|
|
70
|
+
}))
|
|
71
|
+
.sort(byPath);
|
|
72
|
+
const unassigned = keep(mapped.unassigned).map((file) => ({ hash: file.hash, path: file.path })).sort(byPath);
|
|
73
|
+
const tracked = boundaries.reduce((sum, boundary) => sum + boundary.files.length, 0) + overlaps.length + unassigned.length;
|
|
74
|
+
return {
|
|
75
|
+
boundaries,
|
|
76
|
+
doors: (mapped.doors ?? []).map(carryDoor).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)),
|
|
77
|
+
edges: mapped.edges.map((edge) => ({ from: edge.from, kind: edge.kind, to: edge.to })),
|
|
78
|
+
generatedFrom: { commit, tracked },
|
|
79
|
+
landings: carryLandings(mapped.landings ?? []),
|
|
80
|
+
overlaps,
|
|
81
|
+
submodules: [...mapped.submodules].sort(),
|
|
82
|
+
symlinks: mapped.symlinks.filter((link) => !inAtlas(link.path)).map((link) => ({ path: link.path, target: link.target })).sort(byPath),
|
|
83
|
+
unassigned,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A landing on atlas/ is the map describing itself, and a reader or writer in
|
|
88
|
+
// atlas/ is the map's own files; both are left out for the reason the file
|
|
89
|
+
// lists leave atlas/ out.
|
|
90
|
+
function carryLandings(landings) {
|
|
91
|
+
return landings
|
|
92
|
+
.filter((landing) => !inAtlas(landing.target))
|
|
93
|
+
.map((landing) => ({
|
|
94
|
+
readers: landing.readers.filter((entry) => !inAtlas(entry.by)).map(carryReader),
|
|
95
|
+
target: landing.target,
|
|
96
|
+
writers: landing.writers.filter((entry) => !inAtlas(entry.by)).map(carryWriter),
|
|
97
|
+
}))
|
|
98
|
+
.filter((landing) => landing.readers.length > 0 || landing.writers.length > 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function carryWriter(entry) {
|
|
102
|
+
const out = { by: entry.by };
|
|
103
|
+
if (entry.confidence != null) out.confidence = entry.confidence;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function carryReader(entry) {
|
|
108
|
+
const out = { by: entry.by };
|
|
109
|
+
for (const field of ['call', 'confidence', 'ref', 'repo', 'target']) {
|
|
110
|
+
if (entry[field] != null) out[field] = entry[field];
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Every list a door carries arrives sorted from the core, except commands,
|
|
116
|
+
// whose order is the workflow's own. Copying field by field keeps the
|
|
117
|
+
// artifact's shape the one written here rather than whatever the core adds.
|
|
118
|
+
function carryDoor(door) {
|
|
119
|
+
if (door.parseError) return { file: door.file, name: door.name, parseError: true };
|
|
120
|
+
return {
|
|
121
|
+
commands: door.commands.map((command) => ({ job: command.job, step: command.step, text: command.text })),
|
|
122
|
+
file: door.file,
|
|
123
|
+
landings: door.landings.filter((target) => !inAtlas(target)),
|
|
124
|
+
mentions: door.mentions.map((mention) => ({ job: mention.job, path: mention.path })),
|
|
125
|
+
name: door.name,
|
|
126
|
+
permissions: [...door.permissions],
|
|
127
|
+
pushes: door.pushes,
|
|
128
|
+
reach: door.reach.map((entry) => ({ boundary: entry.boundary, depth: entry.depth, files: entry.files })),
|
|
129
|
+
readers: door.readers.filter((entry) => !inAtlas(entry.target) && !inAtlas(entry.by)).map(carryReader),
|
|
130
|
+
runs: door.runs.map((run) => ({ job: run.job, path: run.path })),
|
|
131
|
+
secrets: [...door.secrets],
|
|
132
|
+
sends: {
|
|
133
|
+
deploysPages: door.sends.deploysPages,
|
|
134
|
+
dispatchesTo: [...door.sends.dispatchesTo],
|
|
135
|
+
publishes: door.sends.publishes,
|
|
136
|
+
releases: door.sends.releases,
|
|
137
|
+
},
|
|
138
|
+
stages: [...door.stages],
|
|
139
|
+
triggers: door.triggers.map((trigger) => ({ ...trigger })),
|
|
140
|
+
uses: [...door.uses],
|
|
141
|
+
usesWorkflowToken: door.usesWorkflowToken,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function serializeArtifact(artifact) {
|
|
146
|
+
return `${JSON.stringify(sortKeys(artifact), null, 2)}\n`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sortKeys(value) {
|
|
150
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
151
|
+
if (value && typeof value === 'object') {
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
|
|
5
|
+
const TOP_LEVEL = new Set(['summary', 'window', 'thresholds', 'boundaries']);
|
|
6
|
+
const BOUNDARY_FIELDS = new Set(['name', 'globs', 'role', 'rebaseline']);
|
|
7
|
+
const ROLES = new Set(['code', 'test', 'docs', 'config']);
|
|
8
|
+
|
|
9
|
+
// Fields an older boundary file carried for the acceptance ladder. The page is
|
|
10
|
+
// written from the recorded facts now, so these are read past, not rejected:
|
|
11
|
+
// a file written before the change still maps.
|
|
12
|
+
const RETIRED_TOP_LEVEL = new Set(['machine_budget']);
|
|
13
|
+
const RETIRED_BOUNDARY = new Set(['status', 'reason', 'why_from', 'will_break', 'will_break_from', 'start_here']);
|
|
14
|
+
|
|
15
|
+
export function readBoundaryFile(repoPath) {
|
|
16
|
+
const path = join(repoPath, 'atlas', 'boundaries.yaml');
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = readFileSync(path, 'utf8');
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code === 'ENOENT') return { ok: false, code: 'ATLAS_NO_BOUNDARY_FILE', details: ['atlas/boundaries.yaml is absent'] };
|
|
22
|
+
return { ok: false, code: 'ATLAS_BOUNDARY_FILE_INVALID', details: [`atlas/boundaries.yaml could not be read`] };
|
|
23
|
+
}
|
|
24
|
+
let doc;
|
|
25
|
+
try {
|
|
26
|
+
doc = parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
return { ok: false, code: 'ATLAS_BOUNDARY_FILE_INVALID', details: ['atlas/boundaries.yaml is not valid YAML'] };
|
|
29
|
+
}
|
|
30
|
+
const problem = validate(doc);
|
|
31
|
+
if (problem) return { ok: false, code: 'ATLAS_BOUNDARY_FILE_INVALID', details: [problem] };
|
|
32
|
+
return {
|
|
33
|
+
ok: true,
|
|
34
|
+
summary: typeof doc.summary === 'string' ? doc.summary : null,
|
|
35
|
+
window: doc.window ?? null,
|
|
36
|
+
thresholds: doc.thresholds ?? null,
|
|
37
|
+
boundaries: doc.boundaries.map(carryBoundary),
|
|
38
|
+
ignored: ignoredKeys(doc),
|
|
39
|
+
personMarked: personMarked(doc),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The one line a command prints when an older file's retired fields were read past. */
|
|
44
|
+
export function ignoredNotice(boundary) {
|
|
45
|
+
if (!boundary.ok || boundary.ignored.length === 0) return '';
|
|
46
|
+
return `atlas: ignored fields no longer read from atlas/boundaries.yaml: ${boundary.ignored.join(', ')}\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function carryBoundary(boundary) {
|
|
50
|
+
const carried = { name: boundary.name, globs: boundary.globs };
|
|
51
|
+
if (boundary.role != null) carried.role = boundary.role;
|
|
52
|
+
if (boundary.rebaseline != null) carried.rebaseline = boundary.rebaseline;
|
|
53
|
+
return carried;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ignoredKeys(doc) {
|
|
57
|
+
const keys = new Set(Object.keys(doc).filter((key) => RETIRED_TOP_LEVEL.has(key)));
|
|
58
|
+
for (const boundary of doc.boundaries) {
|
|
59
|
+
for (const key of Object.keys(boundary)) if (RETIRED_BOUNDARY.has(key)) keys.add(key);
|
|
60
|
+
}
|
|
61
|
+
return [...keys].sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// An older file records a person's hand as a status past proposed or a field
|
|
65
|
+
// marked human. Init keeps its refusal to overwrite either.
|
|
66
|
+
function personMarked(doc) {
|
|
67
|
+
return doc.boundaries.some((boundary) => (
|
|
68
|
+
(boundary.status != null && boundary.status !== 'proposed')
|
|
69
|
+
|| boundary.why_from === 'human'
|
|
70
|
+
|| boundary.will_break_from === 'human'
|
|
71
|
+
));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validate(doc) {
|
|
75
|
+
if (doc == null || typeof doc !== 'object' || Array.isArray(doc)) return 'the boundary file must be a mapping';
|
|
76
|
+
for (const key of Object.keys(doc)) {
|
|
77
|
+
if (!TOP_LEVEL.has(key) && !RETIRED_TOP_LEVEL.has(key)) return `unknown field ${key}`;
|
|
78
|
+
}
|
|
79
|
+
if (doc.summary != null && typeof doc.summary !== 'string') return 'summary must be a string';
|
|
80
|
+
if (!Array.isArray(doc.boundaries)) return 'boundaries must be an array';
|
|
81
|
+
const names = new Set();
|
|
82
|
+
for (let i = 0; i < doc.boundaries.length; i += 1) {
|
|
83
|
+
const boundary = doc.boundaries[i];
|
|
84
|
+
const where = `boundaries[${i}]`;
|
|
85
|
+
if (boundary == null || typeof boundary !== 'object' || Array.isArray(boundary)) return `${where} must be a mapping`;
|
|
86
|
+
for (const key of Object.keys(boundary)) {
|
|
87
|
+
if (!BOUNDARY_FIELDS.has(key) && !RETIRED_BOUNDARY.has(key)) return `${where}.${key} is not a boundary field`;
|
|
88
|
+
}
|
|
89
|
+
if (typeof boundary.name !== 'string' || boundary.name.trim() === '') return `${where}.name is required`;
|
|
90
|
+
if (names.has(boundary.name)) return `${where}.name duplicates ${boundary.name}`;
|
|
91
|
+
names.add(boundary.name);
|
|
92
|
+
if (!Array.isArray(boundary.globs) || boundary.globs.some((glob) => typeof glob !== 'string')) {
|
|
93
|
+
return `${where}.globs must be an array of strings`;
|
|
94
|
+
}
|
|
95
|
+
if (boundary.role != null && !ROLES.has(boundary.role)) return `${where}.role must be code, test, docs, or config`;
|
|
96
|
+
if (boundary.rebaseline != null && typeof boundary.rebaseline !== 'string') return `${where}.rebaseline must be a string`;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
package/adapter/check.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const FLOOR = 100;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compare a committed artifact to the one just derived.
|
|
8
|
+
* An overlap is reported before a glob mismatch: editing the boundary file
|
|
9
|
+
* so two globs claim one file is ATLAS_OVERLAP, which a glob-equality check
|
|
10
|
+
* would hide as a generic drift.
|
|
11
|
+
*/
|
|
12
|
+
export function compareArtifacts(committed, current, repoPath) {
|
|
13
|
+
committed = {
|
|
14
|
+
boundaries: committed.boundaries ?? [],
|
|
15
|
+
edges: committed.edges ?? [],
|
|
16
|
+
overlaps: committed.overlaps ?? [],
|
|
17
|
+
submodules: committed.submodules ?? [],
|
|
18
|
+
unassigned: committed.unassigned ?? [],
|
|
19
|
+
};
|
|
20
|
+
const overlap = overlapFailure(current);
|
|
21
|
+
if (overlap) return overlap;
|
|
22
|
+
const identity = boundaryIdentity(committed, current);
|
|
23
|
+
if (identity) return identity;
|
|
24
|
+
const empty = emptyBoundary(current);
|
|
25
|
+
if (empty) return empty;
|
|
26
|
+
const edges = setDrift('edge', edgeKey, committed.edges, current.edges, (edge) => `${edge.from} → ${edge.to} (${edge.kind})`);
|
|
27
|
+
if (edges) return edges;
|
|
28
|
+
const entries = entryDrift(committed, current);
|
|
29
|
+
if (entries) return entries;
|
|
30
|
+
const unresolved = unresolvedDrift(committed, current);
|
|
31
|
+
if (unresolved) return unresolved;
|
|
32
|
+
const unassigned = unassignedDrift(committed, current, repoPath);
|
|
33
|
+
if (unassigned) return unassigned;
|
|
34
|
+
const moved = moveDrift(committed, current, repoPath);
|
|
35
|
+
if (moved) return moved;
|
|
36
|
+
return setDrift('submodule', (name) => name, committed.submodules, current.submodules, (name) => name, 'submodule');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function overlapFailure(current) {
|
|
40
|
+
if (current.overlaps.length === 0) return null;
|
|
41
|
+
return {
|
|
42
|
+
code: 'ATLAS_OVERLAP',
|
|
43
|
+
details: current.overlaps.map((overlap) => `${overlap.path} is in ${overlap.boundaries.join(' and ')}`),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function boundaryIdentity(committed, current) {
|
|
48
|
+
const details = [];
|
|
49
|
+
const oldNames = new Set(committed.boundaries.map((boundary) => boundary.name));
|
|
50
|
+
const newNames = new Set(current.boundaries.map((boundary) => boundary.name));
|
|
51
|
+
for (const name of [...oldNames].sort()) {
|
|
52
|
+
if (!newNames.has(name)) details.push(`boundary ${name} vanished`);
|
|
53
|
+
}
|
|
54
|
+
for (const name of [...newNames].sort()) {
|
|
55
|
+
if (!oldNames.has(name)) details.push(`boundary ${name} is new`);
|
|
56
|
+
}
|
|
57
|
+
const oldByName = new Map(committed.boundaries.map((boundary) => [boundary.name, boundary]));
|
|
58
|
+
for (const boundary of current.boundaries) {
|
|
59
|
+
const old = oldByName.get(boundary.name);
|
|
60
|
+
if (!old) continue;
|
|
61
|
+
if (old.role !== boundary.role) {
|
|
62
|
+
details.push(`${boundary.name} role is ${boundary.role}; the committed map says ${old.role}`);
|
|
63
|
+
}
|
|
64
|
+
if (listsDiffer(sorted(old.globs), sorted(boundary.globs))) details.push(`${boundary.name} globs changed`);
|
|
65
|
+
if (old.importConfidence !== boundary.importConfidence) {
|
|
66
|
+
details.push(`${boundary.name} import confidence was ${old.importConfidence} and is ${boundary.importConfidence}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (details.length === 0) return null;
|
|
70
|
+
return { code: 'ATLAS_STRUCTURE_DRIFT', details };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emptyBoundary(current) {
|
|
74
|
+
const names = current.boundaries.filter((boundary) => boundary.files.length === 0).map((boundary) => boundary.name);
|
|
75
|
+
if (names.length === 0) return null;
|
|
76
|
+
return { code: 'ATLAS_BOUNDARY_EMPTY', details: names.map((name) => `${name} matches no files`) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function entryDrift(committed, current) {
|
|
80
|
+
const details = [];
|
|
81
|
+
const oldByName = new Map(committed.boundaries.map((boundary) => [boundary.name, boundary]));
|
|
82
|
+
for (const boundary of current.boundaries) {
|
|
83
|
+
const old = oldByName.get(boundary.name);
|
|
84
|
+
if (!old) continue;
|
|
85
|
+
if (listsDiffer(sorted(old.entryPoints), sorted(boundary.entryPoints))) {
|
|
86
|
+
details.push(`${boundary.name} entry points were ${showList(old.entryPoints)} and are ${showList(boundary.entryPoints)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (details.length === 0) return null;
|
|
90
|
+
return { code: 'ATLAS_STRUCTURE_DRIFT', details };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unresolvedDrift(committed, current) {
|
|
94
|
+
const details = [];
|
|
95
|
+
const oldByName = new Map(committed.boundaries.map((boundary) => [boundary.name, boundary]));
|
|
96
|
+
for (const boundary of current.boundaries) {
|
|
97
|
+
const old = oldByName.get(boundary.name);
|
|
98
|
+
if (!old) continue;
|
|
99
|
+
if (old.unresolvedSites !== boundary.unresolvedSites) {
|
|
100
|
+
details.push(`${boundary.name} unresolved sites were ${old.unresolvedSites} and are ${boundary.unresolvedSites}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (details.length === 0) return null;
|
|
104
|
+
return { code: 'ATLAS_STRUCTURE_DRIFT', details };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function unassignedDrift(committed, current, repoPath) {
|
|
108
|
+
const old = new Map(committed.unassigned.map((file) => [file.path, file.hash]));
|
|
109
|
+
const pardons = new Map();
|
|
110
|
+
for (const [path, hash] of old) {
|
|
111
|
+
if (current.unassigned.some((file) => file.path === path)) continue;
|
|
112
|
+
pardons.set(hash, (pardons.get(hash) ?? 0) + 1);
|
|
113
|
+
}
|
|
114
|
+
const offenders = [];
|
|
115
|
+
for (const file of current.unassigned) {
|
|
116
|
+
if (old.has(file.path)) continue;
|
|
117
|
+
const size = fileSize(repoPath, file.path);
|
|
118
|
+
if (size >= FLOOR && (pardons.get(file.hash) ?? 0) > 0) {
|
|
119
|
+
pardons.set(file.hash, pardons.get(file.hash) - 1);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
offenders.push(file.path);
|
|
123
|
+
}
|
|
124
|
+
if (offenders.length === 0) return null;
|
|
125
|
+
return { code: 'ATLAS_UNASSIGNED_NEW', details: offenders };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function moveDrift(committed, current, repoPath) {
|
|
129
|
+
const old = roster(committed);
|
|
130
|
+
const now = roster(current);
|
|
131
|
+
const details = [];
|
|
132
|
+
const disappeared = [];
|
|
133
|
+
for (const [path, entry] of old) {
|
|
134
|
+
if (!now.has(path)) disappeared.push({ ...entry, path });
|
|
135
|
+
}
|
|
136
|
+
for (const [path, entry] of now) {
|
|
137
|
+
const same = old.get(path);
|
|
138
|
+
if (same) {
|
|
139
|
+
if (same.boundary !== entry.boundary) {
|
|
140
|
+
details.push(`${path} was in ${same.boundary} and is in ${entry.boundary}`);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (fileSize(repoPath, path) < FLOOR) continue;
|
|
145
|
+
const matches = disappeared.filter((item) => item.hash === entry.hash);
|
|
146
|
+
const boundaries = new Set(matches.map((item) => item.boundary));
|
|
147
|
+
if (boundaries.size !== 1) continue;
|
|
148
|
+
const from = [...boundaries][0];
|
|
149
|
+
if (from !== entry.boundary) details.push(`${path} was in ${from} and is in ${entry.boundary}`);
|
|
150
|
+
}
|
|
151
|
+
if (details.length === 0) return null;
|
|
152
|
+
return { code: 'ATLAS_FILE_MOVED', details };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function roster(artifact) {
|
|
156
|
+
const map = new Map();
|
|
157
|
+
for (const boundary of artifact.boundaries) {
|
|
158
|
+
for (const file of boundary.files) map.set(file.path, { hash: file.hash, boundary: boundary.name });
|
|
159
|
+
}
|
|
160
|
+
return map;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function setDrift(noun, keyOf, oldList, newList, show) {
|
|
164
|
+
const oldKeys = new Set(oldList.map(keyOf));
|
|
165
|
+
const newKeys = new Set(newList.map(keyOf));
|
|
166
|
+
const details = [];
|
|
167
|
+
for (const item of newList) {
|
|
168
|
+
if (!oldKeys.has(keyOf(item))) details.push(`new ${show(item)}`);
|
|
169
|
+
}
|
|
170
|
+
for (const item of oldList) {
|
|
171
|
+
if (!newKeys.has(keyOf(item))) details.push(`vanished ${show(item)}`);
|
|
172
|
+
}
|
|
173
|
+
if (details.length === 0) return null;
|
|
174
|
+
return { code: 'ATLAS_STRUCTURE_DRIFT', details };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function edgeKey(edge) {
|
|
178
|
+
return `${edge.from}\0${edge.to}\0${edge.kind}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function sorted(list = []) {
|
|
182
|
+
return [...list].sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function listsDiffer(left = [], right = []) {
|
|
186
|
+
if (left.length !== right.length) return true;
|
|
187
|
+
return left.some((item, index) => item !== right[index]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function showList(list) {
|
|
191
|
+
return list.length === 0 ? '(none)' : list.join(', ');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function fileSize(repoPath, path) {
|
|
195
|
+
try {
|
|
196
|
+
return statSync(join(repoPath, path)).size;
|
|
197
|
+
} catch {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
}
|