arcshape 0.0.0-stage → 0.1.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 +201 -0
- package/README.md +91 -2
- package/bin/arcshape.mjs +93 -0
- package/bin/confluence-pages.mjs +569 -0
- package/bin/confluence.mjs +263 -0
- package/bin/init.mjs +160 -0
- package/bin/product-citations.mjs +104 -0
- package/bin/session.mjs +753 -0
- package/bin/site.mjs +59 -0
- package/bin/sync.mjs +352 -0
- package/bin/validate-links.mjs +98 -0
- package/bin/validate.mjs +126 -0
- package/package.json +62 -4
- package/templates/architecture/01-introduction-and-goals.md +13 -0
- package/templates/architecture/02-constraints.md +13 -0
- package/templates/architecture/03-context-and-scope.md +11 -0
- package/templates/architecture/04-solution-strategy.md +13 -0
- package/templates/architecture/05-building-block-view.md +11 -0
- package/templates/architecture/06-runtime-view.md +9 -0
- package/templates/architecture/07-deployment-view.md +11 -0
- package/templates/architecture/08-crosscutting-concepts.md +13 -0
- package/templates/architecture/09-architecture-decisions.md +9 -0
- package/templates/architecture/10-quality-requirements.md +11 -0
- package/templates/architecture/11-risks-and-technical-debt.md +11 -0
- package/templates/architecture/12-glossary.md +10 -0
- package/templates/architecture/README.md +20 -0
- package/templates/assistants/.agents/skills/arcshape-adr/SKILL.md +16 -0
- package/templates/assistants/.agents/skills/arcshape-architecture/SKILL.md +35 -0
- package/templates/assistants/.agents/skills/arcshape-c4/SKILL.md +13 -0
- package/templates/assistants/.agents/skills/arcshape-docs-sync/SKILL.md +12 -0
- package/templates/assistants/.claude/commands/arcshape/adr.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/c4.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/document.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/reverse-engineer.md +5 -0
- package/templates/assistants/.claude/commands/arcshape/sync.md +5 -0
- package/templates/assistants/.claude/skills/arcshape-adr/SKILL.md +16 -0
- package/templates/assistants/.claude/skills/arcshape-architecture/SKILL.md +35 -0
- package/templates/assistants/.claude/skills/arcshape-c4/SKILL.md +13 -0
- package/templates/assistants/.claude/skills/arcshape-docs-sync/SKILL.md +12 -0
- package/tools/arc42-site/LICENSE +21 -0
- package/tools/arc42-site/NOTICE.md +3 -0
- package/tools/arc42-site/README.md +3 -0
- package/tools/arc42-site/browser-options.mjs +14 -0
- package/tools/arc42-site/capture-evidence.mjs +84 -0
- package/tools/arc42-site/generate.mjs +227 -0
- package/tools/arc42-site/loadDocs.mjs +196 -0
- package/tools/arc42-site/render.mjs +411 -0
- package/tools/arc42-site/shell.mjs +761 -0
- package/tools/arc42-site/validate-diagrams.mjs +27 -0
package/bin/session.mjs
ADDED
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
open,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
rename,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { basename, join, resolve } from "node:path";
|
|
14
|
+
import ignore from "ignore";
|
|
15
|
+
|
|
16
|
+
const SESSION_SCHEMA = "arcshape/repository-analysis-session/v1";
|
|
17
|
+
const DEFAULT_BATCH_SIZE = 10;
|
|
18
|
+
const MAX_BATCH_SIZE = 50;
|
|
19
|
+
const MAX_TEXT_FILE_BYTES = 10 * 1024 * 1024;
|
|
20
|
+
const PROTECTED_PARTS = new Set([
|
|
21
|
+
".aws",
|
|
22
|
+
".git",
|
|
23
|
+
".gnupg",
|
|
24
|
+
".kube",
|
|
25
|
+
".ssh",
|
|
26
|
+
".arcshape",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"secrets",
|
|
29
|
+
]);
|
|
30
|
+
const IGNORED_BUILD_DIRS = new Set(["build", "coverage", "dist", "out"]);
|
|
31
|
+
const IGNORED_PATH_PREFIXES = ["docs/architecture-site/"];
|
|
32
|
+
const CLASSIFICATIONS = new Set([
|
|
33
|
+
"represented",
|
|
34
|
+
"duplicate",
|
|
35
|
+
"contradiction",
|
|
36
|
+
"question",
|
|
37
|
+
"out-of-scope",
|
|
38
|
+
"no-architecture-evidence",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
export async function runSession(args) {
|
|
42
|
+
const [action, ...rest] = args;
|
|
43
|
+
if (!action || action === "--help" || action === "-h") {
|
|
44
|
+
printHelp();
|
|
45
|
+
return { exitCode: 0 };
|
|
46
|
+
}
|
|
47
|
+
const { positionals, options } = parseArgs(rest);
|
|
48
|
+
if (options.help) {
|
|
49
|
+
printHelp();
|
|
50
|
+
return { exitCode: 0 };
|
|
51
|
+
}
|
|
52
|
+
const root = resolve(process.cwd(), options.target ?? ".");
|
|
53
|
+
|
|
54
|
+
if (action === "start") return startSession(root, options);
|
|
55
|
+
if (action === "help") {
|
|
56
|
+
printHelp();
|
|
57
|
+
return { exitCode: 0 };
|
|
58
|
+
}
|
|
59
|
+
const session = await loadSession(root, options.session);
|
|
60
|
+
|
|
61
|
+
if (["status", "next", "report"].includes(action)) {
|
|
62
|
+
await refreshInventory(root, session.state);
|
|
63
|
+
await saveSession(session.directory, session.state);
|
|
64
|
+
}
|
|
65
|
+
if (action === "status") return showStatus(session.state);
|
|
66
|
+
if (action === "next") return showNext(session.state, options);
|
|
67
|
+
if (action === "report") return showReport(session.state);
|
|
68
|
+
if (action === "mark") {
|
|
69
|
+
await refreshInventory(root, session.state);
|
|
70
|
+
await saveSession(session.directory, session.state);
|
|
71
|
+
return markEvidence(root, session, positionals[0], options);
|
|
72
|
+
}
|
|
73
|
+
if (action === "unmark") {
|
|
74
|
+
await refreshInventory(root, session.state);
|
|
75
|
+
await saveSession(session.directory, session.state);
|
|
76
|
+
return unmarkEvidence(session, positionals[0], options);
|
|
77
|
+
}
|
|
78
|
+
if (action === "lead") return addLead(session, positionals, options);
|
|
79
|
+
if (action === "question")
|
|
80
|
+
return manageQuestion(session, positionals, options);
|
|
81
|
+
throw new Error(
|
|
82
|
+
`unknown session command: ${action}; run arcshape session --help`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printHelp() {
|
|
87
|
+
process.stdout.write(
|
|
88
|
+
[
|
|
89
|
+
"ArcShape repository-analysis sessions",
|
|
90
|
+
"",
|
|
91
|
+
"Usage: arcshape session <command> [options]",
|
|
92
|
+
"",
|
|
93
|
+
"Commands:",
|
|
94
|
+
" start [--target <dir>] [--id <name>] [--batch-size <n>]",
|
|
95
|
+
" status [--target <dir>] [--session <id>]",
|
|
96
|
+
" next [--target <dir>] [--session <id>] [--limit <n>]",
|
|
97
|
+
" mark <evidence-id> --as <classification> [--note <text>] [--accept-changed]",
|
|
98
|
+
" unmark <evidence-id> [--all] [--note <reason>]",
|
|
99
|
+
" lead add --text <text> [--source <evidence-id-or-path>]",
|
|
100
|
+
" question add --text <text> [--source <evidence-id-or-path>]",
|
|
101
|
+
" question answer <question-id> --text <answer>",
|
|
102
|
+
" report [--target <dir>] [--session <id>]",
|
|
103
|
+
"",
|
|
104
|
+
"State is local under .arcshape/sessions/. Each `next` call returns one bounded round.",
|
|
105
|
+
"The CLI records classifications and notes; it does not interpret repository content.",
|
|
106
|
+
"",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function startSession(root, options) {
|
|
112
|
+
await requireDirectory(root);
|
|
113
|
+
const batchSize = parsePositiveInteger(
|
|
114
|
+
options["batch-size"] ?? DEFAULT_BATCH_SIZE,
|
|
115
|
+
"--batch-size",
|
|
116
|
+
MAX_BATCH_SIZE,
|
|
117
|
+
);
|
|
118
|
+
const id = validateSessionId(
|
|
119
|
+
options.id ??
|
|
120
|
+
`${slug(basename(root))}-${new Date().toISOString().slice(0, 10)}`,
|
|
121
|
+
);
|
|
122
|
+
const directory = sessionDirectory(root, id);
|
|
123
|
+
try {
|
|
124
|
+
await lstat(directory);
|
|
125
|
+
throw new Error(
|
|
126
|
+
`session '${id}' already exists; resume it with arcshape session status --session ${id}`,
|
|
127
|
+
);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code !== "ENOENT") throw error;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
const state = {
|
|
134
|
+
schema: SESSION_SCHEMA,
|
|
135
|
+
id,
|
|
136
|
+
createdAt: now,
|
|
137
|
+
updatedAt: now,
|
|
138
|
+
batchSize,
|
|
139
|
+
evidence: await inventory(root),
|
|
140
|
+
leads: [],
|
|
141
|
+
questions: [],
|
|
142
|
+
};
|
|
143
|
+
await saveSession(directory, state);
|
|
144
|
+
return printResult({
|
|
145
|
+
action: "started",
|
|
146
|
+
session: id,
|
|
147
|
+
evidenceCount: state.evidence.length,
|
|
148
|
+
next: `arcshape session next --session ${id}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function loadSession(root, requestedId) {
|
|
153
|
+
const sessionsRoot = join(root, ".arcshape", "sessions");
|
|
154
|
+
let id = requestedId;
|
|
155
|
+
if (id) id = validateSessionId(id);
|
|
156
|
+
else {
|
|
157
|
+
const entries = await readdir(sessionsRoot, { withFileTypes: true }).catch(
|
|
158
|
+
(error) => {
|
|
159
|
+
if (error.code === "ENOENT") return [];
|
|
160
|
+
throw error;
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
const ids = entries
|
|
164
|
+
.filter((entry) => entry.isDirectory())
|
|
165
|
+
.map((entry) => entry.name)
|
|
166
|
+
.sort();
|
|
167
|
+
if (ids.length === 0)
|
|
168
|
+
throw new Error(
|
|
169
|
+
`no session found under .arcshape/sessions; start one with arcshape session start`,
|
|
170
|
+
);
|
|
171
|
+
if (ids.length > 1)
|
|
172
|
+
throw new Error(
|
|
173
|
+
`multiple sessions exist (${ids.join(", ")}); pass --session <id>`,
|
|
174
|
+
);
|
|
175
|
+
id = ids[0];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const directory = sessionDirectory(root, id);
|
|
179
|
+
let state;
|
|
180
|
+
try {
|
|
181
|
+
state = JSON.parse(await readFile(join(directory, "session.json"), "utf8"));
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error.code === "ENOENT")
|
|
184
|
+
throw new Error(`session '${id}' was not found under .arcshape/sessions`);
|
|
185
|
+
if (error instanceof SyntaxError)
|
|
186
|
+
throw new Error(
|
|
187
|
+
`session '${id}' has invalid JSON state; preserve the file and inspect it manually`,
|
|
188
|
+
);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
if (state.schema !== SESSION_SCHEMA || state.id !== id)
|
|
192
|
+
throw new Error(
|
|
193
|
+
`session '${id}' has an unsupported or mismatched state file`,
|
|
194
|
+
);
|
|
195
|
+
return { id, directory, state };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function refreshInventory(root, state) {
|
|
199
|
+
const current = await inventory(root);
|
|
200
|
+
const byPath = new Map(state.evidence.map((item) => [item.path, item]));
|
|
201
|
+
const currentPaths = new Set();
|
|
202
|
+
let changed = false;
|
|
203
|
+
for (const item of current) {
|
|
204
|
+
currentPaths.add(item.path);
|
|
205
|
+
const previous = byPath.get(item.path);
|
|
206
|
+
if (!previous) {
|
|
207
|
+
state.evidence.push(item);
|
|
208
|
+
changed = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (
|
|
212
|
+
previous.status === "excluded" &&
|
|
213
|
+
previous.exclusionReason === "ignored by repository .gitignore rules"
|
|
214
|
+
) {
|
|
215
|
+
previous.previousStatus = "excluded";
|
|
216
|
+
previous.status = "changed";
|
|
217
|
+
previous.currentDigest = item.digest;
|
|
218
|
+
previous.currentSize = item.size;
|
|
219
|
+
previous.change = "unignored";
|
|
220
|
+
delete previous.exclusionReason;
|
|
221
|
+
changed = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (previous.digest !== item.digest || previous.size !== item.size) {
|
|
225
|
+
if (previous.status !== "changed")
|
|
226
|
+
previous.previousStatus = previous.status;
|
|
227
|
+
previous.status = "changed";
|
|
228
|
+
previous.currentDigest = item.digest;
|
|
229
|
+
previous.currentSize = item.size;
|
|
230
|
+
changed = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const item of state.evidence) {
|
|
234
|
+
if (currentPaths.has(item.path)) continue;
|
|
235
|
+
if (await isIgnoredFile(root, item.path)) {
|
|
236
|
+
if (
|
|
237
|
+
item.status !== "excluded" ||
|
|
238
|
+
item.exclusionReason !== "ignored by repository .gitignore rules"
|
|
239
|
+
) {
|
|
240
|
+
item.previousStatus = item.status;
|
|
241
|
+
item.status = "excluded";
|
|
242
|
+
item.exclusionReason = "ignored by repository .gitignore rules";
|
|
243
|
+
delete item.currentDigest;
|
|
244
|
+
delete item.currentSize;
|
|
245
|
+
delete item.change;
|
|
246
|
+
changed = true;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (item.status !== "changed") {
|
|
251
|
+
item.previousStatus = item.status;
|
|
252
|
+
item.status = "changed";
|
|
253
|
+
item.currentDigest = null;
|
|
254
|
+
item.currentSize = null;
|
|
255
|
+
item.change = "removed";
|
|
256
|
+
changed = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
state.evidence.sort((left, right) => left.path.localeCompare(right.path));
|
|
260
|
+
state.updatedAt = new Date().toISOString();
|
|
261
|
+
return changed;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function inventory(root) {
|
|
265
|
+
const paths = await walk(root);
|
|
266
|
+
const evidence = [];
|
|
267
|
+
for (const path of paths) {
|
|
268
|
+
const absolute = join(root, ...path.split("/"));
|
|
269
|
+
const fileStat = await stat(absolute);
|
|
270
|
+
const sample = await readSample(absolute);
|
|
271
|
+
const binary = sample.includes(0);
|
|
272
|
+
const oversized = fileStat.size > MAX_TEXT_FILE_BYTES;
|
|
273
|
+
const digest = await hashFile(absolute);
|
|
274
|
+
const reason = binary
|
|
275
|
+
? "binary file; content not included in analysis rounds"
|
|
276
|
+
: oversized
|
|
277
|
+
? `larger than ${MAX_TEXT_FILE_BYTES} bytes; content not included in analysis rounds`
|
|
278
|
+
: null;
|
|
279
|
+
evidence.push({
|
|
280
|
+
id: evidenceId(path),
|
|
281
|
+
path,
|
|
282
|
+
tier: evidenceTier(path),
|
|
283
|
+
digest,
|
|
284
|
+
size: fileStat.size,
|
|
285
|
+
status: reason ? "excluded" : "pending",
|
|
286
|
+
exclusionReason: reason,
|
|
287
|
+
findings: [],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return evidence.sort((left, right) => left.path.localeCompare(right.path));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function walk(root, parent = "", inheritedIgnoreRules = []) {
|
|
294
|
+
const ignoreRules = await loadIgnoreRules(root, parent, inheritedIgnoreRules);
|
|
295
|
+
const entries = await readdir(join(root, parent), { withFileTypes: true });
|
|
296
|
+
const paths = [];
|
|
297
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
298
|
+
const relativePath = parent ? `${parent}/${entry.name}` : entry.name;
|
|
299
|
+
if (relativePath.split("/").some((part) => PROTECTED_PARTS.has(part)))
|
|
300
|
+
continue;
|
|
301
|
+
if (IGNORED_PATH_PREFIXES.some((prefix) => relativePath.startsWith(prefix)))
|
|
302
|
+
continue;
|
|
303
|
+
if (relativePath.split("/").some(isProtectedFileName)) continue;
|
|
304
|
+
if (entry.isSymbolicLink()) continue;
|
|
305
|
+
if (isIgnoredByRules(relativePath, entry.isDirectory(), ignoreRules))
|
|
306
|
+
continue;
|
|
307
|
+
if (entry.isDirectory()) {
|
|
308
|
+
if (parent === "" && IGNORED_BUILD_DIRS.has(entry.name)) continue;
|
|
309
|
+
paths.push(...(await walk(root, relativePath, ignoreRules)));
|
|
310
|
+
} else if (entry.isFile()) paths.push(relativePath);
|
|
311
|
+
}
|
|
312
|
+
return paths;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function loadIgnoreRules(root, parent, inheritedRules) {
|
|
316
|
+
const ignorePath = parent ? `${parent}/.gitignore` : ".gitignore";
|
|
317
|
+
if (parent && isIgnoredByRules(ignorePath, false, inheritedRules))
|
|
318
|
+
return inheritedRules;
|
|
319
|
+
|
|
320
|
+
const absolutePath = join(root, ...ignorePath.split("/"));
|
|
321
|
+
try {
|
|
322
|
+
const metadata = await lstat(absolutePath);
|
|
323
|
+
if (!metadata.isFile()) return inheritedRules;
|
|
324
|
+
const patterns = await readFile(absolutePath, "utf8");
|
|
325
|
+
if (!patterns.trim()) return inheritedRules;
|
|
326
|
+
return [
|
|
327
|
+
...inheritedRules,
|
|
328
|
+
{ base: parent, matcher: ignore().add(patterns) },
|
|
329
|
+
];
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error.code === "ENOENT") return inheritedRules;
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isIgnoredByRules(path, isDirectory, rules) {
|
|
337
|
+
let ignored = false;
|
|
338
|
+
for (const rule of rules) {
|
|
339
|
+
const relativePath = rule.base ? path.slice(rule.base.length + 1) : path;
|
|
340
|
+
const candidate = isDirectory ? `${relativePath}/` : relativePath;
|
|
341
|
+
const result = rule.matcher.test(candidate);
|
|
342
|
+
if (result.ignored) ignored = true;
|
|
343
|
+
else if (result.unignored) ignored = false;
|
|
344
|
+
}
|
|
345
|
+
return ignored;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function isIgnoredFile(root, path) {
|
|
349
|
+
try {
|
|
350
|
+
if (!(await lstat(join(root, ...path.split("/")))).isFile()) return false;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error.code === "ENOENT") return false;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
const parts = path.split("/");
|
|
356
|
+
let parent = "";
|
|
357
|
+
let rules = [];
|
|
358
|
+
for (const directory of parts.slice(0, -1)) {
|
|
359
|
+
rules = await loadIgnoreRules(root, parent, rules);
|
|
360
|
+
parent = parent ? `${parent}/${directory}` : directory;
|
|
361
|
+
if (isIgnoredByRules(parent, true, rules)) return true;
|
|
362
|
+
}
|
|
363
|
+
rules = await loadIgnoreRules(root, parent, rules);
|
|
364
|
+
return isIgnoredByRules(path, false, rules);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function isProtectedFileName(name) {
|
|
368
|
+
return (
|
|
369
|
+
name === ".env" ||
|
|
370
|
+
name.startsWith(".env.") ||
|
|
371
|
+
[".npmrc", ".netrc", "credentials", "id_rsa", "id_ed25519"].includes(
|
|
372
|
+
name.toLowerCase(),
|
|
373
|
+
) ||
|
|
374
|
+
/(?:^|[-_.])credentials(?:$|[-_.])/i.test(name) ||
|
|
375
|
+
/\.(?:pem|key|p12|pfx|crt|cer|token)$/i.test(name)
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function markEvidence(root, session, id, options) {
|
|
380
|
+
if (!id) throw new Error("mark requires an evidence id from `session next`");
|
|
381
|
+
const item = session.state.evidence.find((entry) => entry.id === id);
|
|
382
|
+
if (!item) throw new Error(`unknown evidence id '${id}'`);
|
|
383
|
+
const classification = options.as;
|
|
384
|
+
if (!CLASSIFICATIONS.has(classification))
|
|
385
|
+
throw new Error(`--as must be one of: ${[...CLASSIFICATIONS].join(", ")}`);
|
|
386
|
+
if (
|
|
387
|
+
["contradiction", "out-of-scope", "no-architecture-evidence"].includes(
|
|
388
|
+
classification,
|
|
389
|
+
) &&
|
|
390
|
+
!options.note
|
|
391
|
+
)
|
|
392
|
+
throw new Error(`--as ${classification} requires --note`);
|
|
393
|
+
|
|
394
|
+
if (item.status === "changed") {
|
|
395
|
+
if (!options["accept-changed"])
|
|
396
|
+
throw new Error(
|
|
397
|
+
`${item.path} changed since it was inventoried; re-read it, then mark with --accept-changed`,
|
|
398
|
+
);
|
|
399
|
+
const refreshed = await inspectFile(root, item.path);
|
|
400
|
+
if (!refreshed)
|
|
401
|
+
throw new Error(
|
|
402
|
+
`${item.path} no longer exists; refresh status before marking`,
|
|
403
|
+
);
|
|
404
|
+
item.digest = refreshed.digest;
|
|
405
|
+
item.size = refreshed.size;
|
|
406
|
+
item.status = refreshed.exclusionReason ? "excluded" : "pending";
|
|
407
|
+
item.exclusionReason = refreshed.exclusionReason;
|
|
408
|
+
item.change = undefined;
|
|
409
|
+
delete item.currentDigest;
|
|
410
|
+
delete item.currentSize;
|
|
411
|
+
delete item.previousStatus;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (item.status === "excluded")
|
|
415
|
+
throw new Error(
|
|
416
|
+
`${item.path} is excluded from readable evidence: ${item.exclusionReason}`,
|
|
417
|
+
);
|
|
418
|
+
item.findings.push({
|
|
419
|
+
classification,
|
|
420
|
+
note: options.note ?? "",
|
|
421
|
+
recordedAt: new Date().toISOString(),
|
|
422
|
+
});
|
|
423
|
+
item.status = classification === "out-of-scope" ? "excluded" : "reviewed";
|
|
424
|
+
item.exclusionReason =
|
|
425
|
+
classification === "out-of-scope" ? options.note : null;
|
|
426
|
+
await saveSession(session.directory, session.state);
|
|
427
|
+
return printResult({ action: "marked", evidence: item });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function inspectFile(root, path) {
|
|
431
|
+
try {
|
|
432
|
+
const absolute = join(root, ...path.split("/"));
|
|
433
|
+
const info = await stat(absolute);
|
|
434
|
+
const binary = (await readSample(absolute)).includes(0);
|
|
435
|
+
const oversized = info.size > MAX_TEXT_FILE_BYTES;
|
|
436
|
+
return {
|
|
437
|
+
digest: await hashFile(absolute),
|
|
438
|
+
size: info.size,
|
|
439
|
+
exclusionReason: binary
|
|
440
|
+
? "binary file; content not included in analysis rounds"
|
|
441
|
+
: oversized
|
|
442
|
+
? `larger than ${MAX_TEXT_FILE_BYTES} bytes; content not included in analysis rounds`
|
|
443
|
+
: null,
|
|
444
|
+
};
|
|
445
|
+
} catch (error) {
|
|
446
|
+
if (error.code === "ENOENT") return null;
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function unmarkEvidence(session, id, options) {
|
|
452
|
+
if (!id) throw new Error("unmark requires an evidence id");
|
|
453
|
+
const item = session.state.evidence.find((entry) => entry.id === id);
|
|
454
|
+
if (!item) throw new Error(`unknown evidence id '${id}'`);
|
|
455
|
+
if (!item.findings.length)
|
|
456
|
+
throw new Error(`${item.path} has no finding to retract`);
|
|
457
|
+
const retracted = options.all
|
|
458
|
+
? item.findings.splice(0)
|
|
459
|
+
: [item.findings.pop()];
|
|
460
|
+
item.history ??= [];
|
|
461
|
+
item.history.push({
|
|
462
|
+
action: "retracted",
|
|
463
|
+
findings: retracted,
|
|
464
|
+
note: options.note ?? "",
|
|
465
|
+
recordedAt: new Date().toISOString(),
|
|
466
|
+
});
|
|
467
|
+
item.status = "pending";
|
|
468
|
+
item.exclusionReason = null;
|
|
469
|
+
await saveSession(session.directory, session.state);
|
|
470
|
+
return printResult({
|
|
471
|
+
action: "unmarked",
|
|
472
|
+
evidence: item.id,
|
|
473
|
+
retracted: retracted.length,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function addLead(session, positionals, options) {
|
|
478
|
+
if (positionals[0] !== "add")
|
|
479
|
+
throw new Error("use `lead add --text <finding>`");
|
|
480
|
+
const text = requiredText(options.text, "--text");
|
|
481
|
+
const lead = {
|
|
482
|
+
id: sequentialId("L", session.state.leads),
|
|
483
|
+
text,
|
|
484
|
+
source: options.source ?? null,
|
|
485
|
+
recordedAt: new Date().toISOString(),
|
|
486
|
+
};
|
|
487
|
+
session.state.leads.push(lead);
|
|
488
|
+
await saveSession(session.directory, session.state);
|
|
489
|
+
return printResult({ action: "lead-added", lead });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function manageQuestion(session, positionals, options) {
|
|
493
|
+
const [subcommand, id] = positionals;
|
|
494
|
+
if (subcommand === "add") {
|
|
495
|
+
const question = {
|
|
496
|
+
id: sequentialId("Q", session.state.questions),
|
|
497
|
+
text: requiredText(options.text, "--text"),
|
|
498
|
+
source: options.source ?? null,
|
|
499
|
+
answer: null,
|
|
500
|
+
status: "open",
|
|
501
|
+
recordedAt: new Date().toISOString(),
|
|
502
|
+
};
|
|
503
|
+
session.state.questions.push(question);
|
|
504
|
+
await saveSession(session.directory, session.state);
|
|
505
|
+
return printResult({ action: "question-added", question });
|
|
506
|
+
}
|
|
507
|
+
if (subcommand === "answer") {
|
|
508
|
+
const question = session.state.questions.find((entry) => entry.id === id);
|
|
509
|
+
if (!question) throw new Error(`unknown question id '${id ?? ""}'`);
|
|
510
|
+
question.answer = requiredText(options.text, "--text");
|
|
511
|
+
question.status = "answered";
|
|
512
|
+
question.answeredAt = new Date().toISOString();
|
|
513
|
+
await saveSession(session.directory, session.state);
|
|
514
|
+
return printResult({ action: "question-answered", question });
|
|
515
|
+
}
|
|
516
|
+
throw new Error(
|
|
517
|
+
"use `question add --text <question>` or `question answer <id> --text <answer>`",
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function showStatus(state) {
|
|
522
|
+
return printResult({
|
|
523
|
+
action: "status",
|
|
524
|
+
session: state.id,
|
|
525
|
+
total: state.evidence.length,
|
|
526
|
+
coverage: countBy(state.evidence, "status"),
|
|
527
|
+
tiers: countBy(state.evidence, "tier"),
|
|
528
|
+
openQuestions: state.questions.filter(
|
|
529
|
+
(question) => question.status === "open",
|
|
530
|
+
).length,
|
|
531
|
+
leads: state.leads.length,
|
|
532
|
+
updatedAt: state.updatedAt,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function showNext(state, options) {
|
|
537
|
+
const limit = parsePositiveInteger(
|
|
538
|
+
options.limit ?? state.batchSize,
|
|
539
|
+
"--limit",
|
|
540
|
+
MAX_BATCH_SIZE,
|
|
541
|
+
);
|
|
542
|
+
const pending = state.evidence
|
|
543
|
+
.filter((item) => item.status === "pending")
|
|
544
|
+
.sort(
|
|
545
|
+
(left, right) =>
|
|
546
|
+
tierOrder(left.tier) - tierOrder(right.tier) ||
|
|
547
|
+
left.path.localeCompare(right.path),
|
|
548
|
+
);
|
|
549
|
+
const first = pending[0];
|
|
550
|
+
if (!first) {
|
|
551
|
+
const changed = state.evidence.filter(
|
|
552
|
+
(item) => item.status === "changed",
|
|
553
|
+
).length;
|
|
554
|
+
return printResult({
|
|
555
|
+
action: changed ? "awaiting-changed-evidence" : "coverage-complete",
|
|
556
|
+
session: state.id,
|
|
557
|
+
pending: 0,
|
|
558
|
+
changed,
|
|
559
|
+
coverage: countBy(state.evidence, "status"),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const round = pending
|
|
563
|
+
.filter((item) => item.tier === first.tier)
|
|
564
|
+
.slice(0, limit);
|
|
565
|
+
return printResult({
|
|
566
|
+
action: "next-round",
|
|
567
|
+
session: state.id,
|
|
568
|
+
round: first.tier,
|
|
569
|
+
limit,
|
|
570
|
+
evidence: round.map(({ id, path, digest, size, tier }) => ({
|
|
571
|
+
id,
|
|
572
|
+
path,
|
|
573
|
+
digest,
|
|
574
|
+
size,
|
|
575
|
+
tier,
|
|
576
|
+
})),
|
|
577
|
+
remainingInRound:
|
|
578
|
+
pending.filter((item) => item.tier === first.tier).length - round.length,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function showReport(state) {
|
|
583
|
+
return printResult({
|
|
584
|
+
action: "report",
|
|
585
|
+
session: state.id,
|
|
586
|
+
coverage: countBy(state.evidence, "status"),
|
|
587
|
+
tiers: countBy(state.evidence, "tier"),
|
|
588
|
+
openQuestions: state.questions.filter(
|
|
589
|
+
(question) => question.status === "open",
|
|
590
|
+
),
|
|
591
|
+
answeredQuestions: state.questions.filter(
|
|
592
|
+
(question) => question.status === "answered",
|
|
593
|
+
),
|
|
594
|
+
leads: state.leads,
|
|
595
|
+
changedEvidence: state.evidence
|
|
596
|
+
.filter((item) => item.status === "changed")
|
|
597
|
+
.map(({ id, path, change }) => ({
|
|
598
|
+
id,
|
|
599
|
+
path,
|
|
600
|
+
change: change ?? "modified",
|
|
601
|
+
})),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function countBy(items, key) {
|
|
606
|
+
return items.reduce((counts, item) => {
|
|
607
|
+
counts[item[key]] = (counts[item[key]] ?? 0) + 1;
|
|
608
|
+
return counts;
|
|
609
|
+
}, {});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function evidenceTier(path) {
|
|
613
|
+
const lower = path.toLowerCase();
|
|
614
|
+
const name = lower.split("/").at(-1);
|
|
615
|
+
if (
|
|
616
|
+
lower.startsWith("docs/") ||
|
|
617
|
+
lower.startsWith("doc/") ||
|
|
618
|
+
/\.(?:md|mdx|rst|adoc|asciidoc|txt)$/.test(name)
|
|
619
|
+
)
|
|
620
|
+
return "documentation";
|
|
621
|
+
if (
|
|
622
|
+
lower.includes("/config/") ||
|
|
623
|
+
lower.startsWith("config/") ||
|
|
624
|
+
/\.(?:json|ya?ml|toml|xml|ini|conf|properties)$/.test(name) ||
|
|
625
|
+
[
|
|
626
|
+
"dockerfile",
|
|
627
|
+
"makefile",
|
|
628
|
+
"justfile",
|
|
629
|
+
"package-lock.json",
|
|
630
|
+
"pnpm-lock.yaml",
|
|
631
|
+
"yarn.lock",
|
|
632
|
+
].includes(name)
|
|
633
|
+
)
|
|
634
|
+
return "repository-configuration";
|
|
635
|
+
return "implementation-and-tests";
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function tierOrder(tier) {
|
|
639
|
+
return [
|
|
640
|
+
"documentation",
|
|
641
|
+
"repository-configuration",
|
|
642
|
+
"implementation-and-tests",
|
|
643
|
+
].indexOf(tier);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function saveSession(directory, state) {
|
|
647
|
+
await mkdir(directory, { recursive: true });
|
|
648
|
+
state.updatedAt = new Date().toISOString();
|
|
649
|
+
const path = join(directory, "session.json");
|
|
650
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
651
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
|
|
652
|
+
encoding: "utf8",
|
|
653
|
+
flag: "wx",
|
|
654
|
+
});
|
|
655
|
+
await rename(temporary, path);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function requireDirectory(path) {
|
|
659
|
+
const info = await stat(path).catch((error) => {
|
|
660
|
+
if (error.code === "ENOENT")
|
|
661
|
+
throw new Error(`target directory does not exist: ${path}`);
|
|
662
|
+
throw error;
|
|
663
|
+
});
|
|
664
|
+
if (!info.isDirectory())
|
|
665
|
+
throw new Error(`target is not a directory: ${path}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function sessionDirectory(root, id) {
|
|
669
|
+
return join(root, ".arcshape", "sessions", id);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function evidenceId(path) {
|
|
673
|
+
return `E-${createHash("sha256").update(path).digest("hex").slice(0, 12)}`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function hashFile(path) {
|
|
677
|
+
const digest = createHash("sha256");
|
|
678
|
+
for await (const chunk of createReadStream(path)) digest.update(chunk);
|
|
679
|
+
return `sha256:${digest.digest("hex")}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function readSample(path) {
|
|
683
|
+
const handle = await open(path, "r");
|
|
684
|
+
try {
|
|
685
|
+
const buffer = Buffer.alloc(8192);
|
|
686
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
687
|
+
return buffer.subarray(0, bytesRead);
|
|
688
|
+
} finally {
|
|
689
|
+
await handle.close();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function sequentialId(prefix, values) {
|
|
694
|
+
return `${prefix}-${String(values.length + 1).padStart(4, "0")}`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function slug(value) {
|
|
698
|
+
return (
|
|
699
|
+
value
|
|
700
|
+
.toLowerCase()
|
|
701
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
702
|
+
.replace(/^-|-$/g, "") || "repository"
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function validateSessionId(value) {
|
|
707
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value ?? ""))
|
|
708
|
+
throw new Error(
|
|
709
|
+
"session id must contain lowercase letters, digits, and hyphens",
|
|
710
|
+
);
|
|
711
|
+
return value;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function parsePositiveInteger(value, flag, maximum) {
|
|
715
|
+
const parsed = Number(value);
|
|
716
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum)
|
|
717
|
+
throw new Error(`${flag} must be an integer from 1 to ${maximum}`);
|
|
718
|
+
return parsed;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function requiredText(value, flag) {
|
|
722
|
+
if (typeof value !== "string" || !value.trim())
|
|
723
|
+
throw new Error(`${flag} requires nonempty text`);
|
|
724
|
+
return value.trim();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function parseArgs(args) {
|
|
728
|
+
const positionals = [];
|
|
729
|
+
const options = {};
|
|
730
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
731
|
+
const arg = args[index];
|
|
732
|
+
if (!arg.startsWith("--")) {
|
|
733
|
+
positionals.push(arg);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const key = arg.slice(2);
|
|
737
|
+
if (key === "accept-changed" || key === "all" || key === "help") {
|
|
738
|
+
options[key] = true;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const value = args[index + 1];
|
|
742
|
+
if (!value || value.startsWith("--"))
|
|
743
|
+
throw new Error(`${arg} requires a value`);
|
|
744
|
+
options[key] = value;
|
|
745
|
+
index += 1;
|
|
746
|
+
}
|
|
747
|
+
return { positionals, options };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function printResult(value) {
|
|
751
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
752
|
+
return { exitCode: 0 };
|
|
753
|
+
}
|