@handong66/evidoc-local-app 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/dist/src/index.d.ts +64 -0
- package/dist/src/index.js +1231 -0
- package/dist/src/index.js.map +1 -0
- package/package.json +28 -0
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { access, appendFile, chmod, mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { checkRepository, detectRepositoryEvidocWorkflowText, evidocWorkflowWarnings, fingerprintFileContent, resolveExistingPathInsideRoot, resolveWritablePathInsideRoot } from "@handong66/evidoc-core";
|
|
6
|
+
import { renderLocalAppHtml } from "@handong66/evidoc-dashboard";
|
|
7
|
+
const DEFAULT_PUSH_BRANCHES = ["main", "master"];
|
|
8
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
9
|
+
const DEFAULT_PORT = 4321;
|
|
10
|
+
const DEFAULT_HISTORY_LIMIT = 30;
|
|
11
|
+
const MAX_JSON_BODY_BYTES = 1_000_000;
|
|
12
|
+
const DIRECTORY_PICKER_TIMEOUT_MS = 120_000;
|
|
13
|
+
const LOCAL_HISTORY_GITIGNORE_PATH = ".evidoc/.gitignore";
|
|
14
|
+
const LOCAL_HISTORY_GITIGNORE_ENTRIES = ["history.jsonl", "reports/"];
|
|
15
|
+
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="10" fill="#11100e"/><path d="M14 16h16c13 0 20 6 20 16s-7 16-20 16H14V16zm10 9v14h6c7 0 10-2 10-7s-3-7-10-7h-6z" fill="#f6c76f"/><path d="M48 16v9H32v-9h16zm0 23v9H32v-9h16z" fill="#8cb9ff"/></svg>`;
|
|
16
|
+
class HttpError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
constructor(message, status) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function scanLocalAppRepositories(roots, options = {}) {
|
|
24
|
+
const normalizedRoots = await normalizeRoots(roots);
|
|
25
|
+
const repositories = [];
|
|
26
|
+
for (const root of normalizedRoots) {
|
|
27
|
+
if (options.autoInit) {
|
|
28
|
+
await ensureLocalAppConfig(root);
|
|
29
|
+
}
|
|
30
|
+
const report = await checkRepository(root);
|
|
31
|
+
if (options.writeHistory) {
|
|
32
|
+
await appendHistory(root, report);
|
|
33
|
+
}
|
|
34
|
+
repositories.push({
|
|
35
|
+
root,
|
|
36
|
+
name: basename(root) || root,
|
|
37
|
+
health: healthFromReport(report),
|
|
38
|
+
ci: await inspectGithubAction(root),
|
|
39
|
+
localGit: await inspectLocalGitGate(root, report),
|
|
40
|
+
history: await readHistory(root, options.historyLimit ?? DEFAULT_HISTORY_LIMIT),
|
|
41
|
+
report
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
generatedAt: new Date().toISOString(),
|
|
46
|
+
summary: summarizeRepositories(repositories),
|
|
47
|
+
repositories
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function startLocalAppServer(options) {
|
|
51
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
52
|
+
const requestedPort = options.port ?? DEFAULT_PORT;
|
|
53
|
+
const selectDirectory = options.selectDirectory ?? selectSystemDirectory;
|
|
54
|
+
const roots = await normalizeRoots(options.roots.length > 0 ? options.roots : [process.cwd()]);
|
|
55
|
+
const state = {
|
|
56
|
+
roots,
|
|
57
|
+
current: await scanLocalAppRepositories(roots, {
|
|
58
|
+
autoInit: options.autoInit ?? true,
|
|
59
|
+
writeHistory: options.writeHistory ?? true,
|
|
60
|
+
historyLimit: options.historyLimit
|
|
61
|
+
}),
|
|
62
|
+
clients: new Set()
|
|
63
|
+
};
|
|
64
|
+
async function rescan(root) {
|
|
65
|
+
const scanOptions = {
|
|
66
|
+
autoInit: options.autoInit ?? true,
|
|
67
|
+
writeHistory: options.writeHistory ?? true,
|
|
68
|
+
historyLimit: options.historyLimit
|
|
69
|
+
};
|
|
70
|
+
if (!root) {
|
|
71
|
+
state.current = await scanLocalAppRepositories(state.roots, scanOptions);
|
|
72
|
+
publishEvent(state, "scan", state.current);
|
|
73
|
+
return state.current;
|
|
74
|
+
}
|
|
75
|
+
const targetState = await scanLocalAppRepositories([root], scanOptions);
|
|
76
|
+
const [targetRepository] = targetState.repositories;
|
|
77
|
+
let replaced = false;
|
|
78
|
+
const repositories = state.current.repositories.map((repository) => {
|
|
79
|
+
if (repository.root !== root)
|
|
80
|
+
return repository;
|
|
81
|
+
replaced = true;
|
|
82
|
+
return targetRepository;
|
|
83
|
+
});
|
|
84
|
+
if (!replaced) {
|
|
85
|
+
repositories.push(targetRepository);
|
|
86
|
+
}
|
|
87
|
+
state.current = {
|
|
88
|
+
generatedAt: targetState.generatedAt,
|
|
89
|
+
summary: summarizeRepositories(repositories),
|
|
90
|
+
repositories
|
|
91
|
+
};
|
|
92
|
+
publishEvent(state, "scan", state.current);
|
|
93
|
+
return state.current;
|
|
94
|
+
}
|
|
95
|
+
const server = createServer((request, response) => {
|
|
96
|
+
void handleRequest(request, response, state, rescan, selectDirectory);
|
|
97
|
+
});
|
|
98
|
+
await listenWithFallback(server, requestedPort, host, options.port === undefined);
|
|
99
|
+
const address = server.address();
|
|
100
|
+
const actualPort = typeof address === "object" && address ? address.port : requestedPort;
|
|
101
|
+
const url = `http://${host}:${actualPort}`;
|
|
102
|
+
let watcher;
|
|
103
|
+
if (options.watch) {
|
|
104
|
+
watcher = setInterval(() => {
|
|
105
|
+
void rescan().catch(() => undefined);
|
|
106
|
+
}, options.watchIntervalMs ?? 5000);
|
|
107
|
+
watcher.unref?.();
|
|
108
|
+
}
|
|
109
|
+
if (options.openBrowser !== false) {
|
|
110
|
+
openBrowser(url);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
url,
|
|
114
|
+
port: actualPort,
|
|
115
|
+
state: () => state.current,
|
|
116
|
+
close: async () => {
|
|
117
|
+
if (watcher)
|
|
118
|
+
clearInterval(watcher);
|
|
119
|
+
for (const client of state.clients) {
|
|
120
|
+
client.end();
|
|
121
|
+
}
|
|
122
|
+
state.clients.clear();
|
|
123
|
+
await closeServer(server);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export async function ensureLocalAppConfig(root) {
|
|
128
|
+
const configPath = await resolveWritablePathInsideRoot(root, ".evidoc/config.json");
|
|
129
|
+
if (!configPath) {
|
|
130
|
+
throw new Error("config path must stay inside repository root");
|
|
131
|
+
}
|
|
132
|
+
if (await exists(configPath)) {
|
|
133
|
+
await ensureLocalHistoryGitignore(root);
|
|
134
|
+
return "kept";
|
|
135
|
+
}
|
|
136
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
137
|
+
await writeFile(configPath, `${JSON.stringify(await createDefaultEvidocConfig(root), null, 2)}\n`, "utf8");
|
|
138
|
+
await ensureLocalHistoryGitignore(root);
|
|
139
|
+
return "created";
|
|
140
|
+
}
|
|
141
|
+
export async function ensureLocalHistoryGitignore(root) {
|
|
142
|
+
const target = await resolveWritablePathInsideRoot(root, LOCAL_HISTORY_GITIGNORE_PATH);
|
|
143
|
+
if (!target) {
|
|
144
|
+
throw new Error("local history ignore path must stay inside repository root");
|
|
145
|
+
}
|
|
146
|
+
if (!(await exists(target))) {
|
|
147
|
+
await mkdir(dirname(target), { recursive: true });
|
|
148
|
+
await writeFile(target, `${LOCAL_HISTORY_GITIGNORE_ENTRIES.join("\n")}\n`, "utf8");
|
|
149
|
+
return { path: LOCAL_HISTORY_GITIGNORE_PATH, status: "created" };
|
|
150
|
+
}
|
|
151
|
+
const current = await readFile(target, "utf8");
|
|
152
|
+
const lines = new Set(current.split(/\r?\n/).map((line) => line.trim()).filter(Boolean));
|
|
153
|
+
const missingEntries = LOCAL_HISTORY_GITIGNORE_ENTRIES.filter((entry) => !lines.has(entry));
|
|
154
|
+
if (missingEntries.length === 0) {
|
|
155
|
+
return { path: LOCAL_HISTORY_GITIGNORE_PATH, status: "kept" };
|
|
156
|
+
}
|
|
157
|
+
const separator = current.endsWith("\n") || current.length === 0 ? "" : "\n";
|
|
158
|
+
await writeFile(target, `${current}${separator}${missingEntries.join("\n")}\n`, "utf8");
|
|
159
|
+
return { path: LOCAL_HISTORY_GITIGNORE_PATH, status: "updated" };
|
|
160
|
+
}
|
|
161
|
+
export async function inspectLocalGitGate(root, report) {
|
|
162
|
+
const isRepository = (await runGit(root, ["rev-parse", "--is-inside-work-tree"]))?.trim() === "true";
|
|
163
|
+
if (!isRepository) {
|
|
164
|
+
return {
|
|
165
|
+
isRepository: false,
|
|
166
|
+
ready: false,
|
|
167
|
+
preCommitHook: false,
|
|
168
|
+
prePushHook: false,
|
|
169
|
+
hasCommits: false,
|
|
170
|
+
stagedChangedFiles: [],
|
|
171
|
+
unstagedChangedFiles: [],
|
|
172
|
+
affectedDocuments: [],
|
|
173
|
+
issues: ["not a git repository"]
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const branch = sanitizeBranchName((await runGit(root, ["branch", "--show-current"]))?.trim());
|
|
177
|
+
const hooksPath = (await runGit(root, ["config", "--get", "core.hooksPath"]))?.trim();
|
|
178
|
+
const hasCommits = (await runGit(root, ["rev-parse", "--verify", "HEAD"])) !== undefined;
|
|
179
|
+
const preCommitHook = await exists(join(root, ".githooks", "pre-commit"));
|
|
180
|
+
const prePushHook = await exists(join(root, ".githooks", "pre-push"));
|
|
181
|
+
const stagedChangedFiles = parseGitPathList(await runGit(root, ["diff", "--name-only", "--cached"]));
|
|
182
|
+
const unstagedChangedFiles = parseGitPathList(await runGit(root, ["diff", "--name-only"]));
|
|
183
|
+
const affectedDocuments = await findLocalGitAffectedDocuments(root, report, [
|
|
184
|
+
...new Set([...stagedChangedFiles, ...unstagedChangedFiles])
|
|
185
|
+
]);
|
|
186
|
+
const lastGate = await readLastLocalGitGate(root, { stagedChangedFiles, unstagedChangedFiles });
|
|
187
|
+
const issues = [];
|
|
188
|
+
if (!hasCommits)
|
|
189
|
+
issues.push("no commits yet; create an initial baseline commit");
|
|
190
|
+
if (hooksPath !== ".githooks")
|
|
191
|
+
issues.push("core.hooksPath is not .githooks");
|
|
192
|
+
if (!preCommitHook)
|
|
193
|
+
issues.push("missing .githooks/pre-commit");
|
|
194
|
+
if (!prePushHook)
|
|
195
|
+
issues.push("missing .githooks/pre-push");
|
|
196
|
+
return {
|
|
197
|
+
isRepository,
|
|
198
|
+
ready: issues.length === 0,
|
|
199
|
+
branch,
|
|
200
|
+
hooksPath,
|
|
201
|
+
preCommitHook,
|
|
202
|
+
prePushHook,
|
|
203
|
+
hasCommits,
|
|
204
|
+
baseline: hasCommits ? "HEAD" : undefined,
|
|
205
|
+
stagedChangedFiles,
|
|
206
|
+
unstagedChangedFiles,
|
|
207
|
+
affectedDocuments,
|
|
208
|
+
lastGate,
|
|
209
|
+
issues
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
export async function enableLocalGitGate(root, options = {}) {
|
|
213
|
+
const files = await writeLocalGitHookFiles(root, Boolean(options.force));
|
|
214
|
+
let installed = false;
|
|
215
|
+
if (options.installHooks) {
|
|
216
|
+
installed = await runGitOk(root, ["config", "core.hooksPath", ".githooks"]);
|
|
217
|
+
if (!installed) {
|
|
218
|
+
throw new Error("Unable to set repo-local git config core.hooksPath=.githooks");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
files,
|
|
223
|
+
installed,
|
|
224
|
+
hooksPath: installed ? ".githooks" : (await runGit(root, ["config", "--get", "core.hooksPath"]))?.trim()
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export async function enableGithubAction(root, options = {}) {
|
|
228
|
+
const workflowPath = ".github/workflows/evidoc.yml";
|
|
229
|
+
const target = await resolveWritablePathInsideRoot(root, workflowPath);
|
|
230
|
+
if (!target) {
|
|
231
|
+
throw new Error("workflow path must stay inside repository root");
|
|
232
|
+
}
|
|
233
|
+
const alreadyExists = await exists(target);
|
|
234
|
+
if (alreadyExists && !options.force) {
|
|
235
|
+
return { path: workflowPath, status: "kept" };
|
|
236
|
+
}
|
|
237
|
+
await mkdir(dirname(target), { recursive: true });
|
|
238
|
+
await writeFile(target, defaultGithubWorkflow({ pushBranches: await detectRepositoryPushBranches(root) }), "utf8");
|
|
239
|
+
return { path: workflowPath, status: alreadyExists ? "updated" : "created" };
|
|
240
|
+
}
|
|
241
|
+
async function handleRequest(request, response, state, rescan, selectDirectory) {
|
|
242
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
243
|
+
try {
|
|
244
|
+
if (request.method === "POST" && !isAllowedRequestOrigin(request)) {
|
|
245
|
+
sendJson(response, { error: "cross-origin local app request rejected" }, 403);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
249
|
+
sendHtml(response, renderLocalAppHtml(state.current));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (request.method === "GET" && url.pathname === "/favicon.ico") {
|
|
253
|
+
sendSvg(response, FAVICON_SVG);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (request.method === "GET" && url.pathname === "/api/state") {
|
|
257
|
+
sendJson(response, state.current);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (request.method === "GET" && url.pathname === "/events") {
|
|
261
|
+
response.writeHead(200, {
|
|
262
|
+
...securityHeaders(),
|
|
263
|
+
"content-type": "text/event-stream",
|
|
264
|
+
"cache-control": "no-cache",
|
|
265
|
+
connection: "keep-alive"
|
|
266
|
+
});
|
|
267
|
+
response.write(`event: state\ndata: ${JSON.stringify(state.current)}\n\n`);
|
|
268
|
+
state.clients.add(response);
|
|
269
|
+
request.on("close", () => state.clients.delete(response));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (request.method === "POST" && url.pathname === "/api/scan") {
|
|
273
|
+
const body = await readJsonBody(request);
|
|
274
|
+
if (body.root) {
|
|
275
|
+
const root = await resolveKnownRoot(state, body.root);
|
|
276
|
+
if (!root) {
|
|
277
|
+
sendJson(response, { error: "unknown repository root" }, 400);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
sendJson(response, await rescan(root));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
sendJson(response, await rescan());
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (request.method === "POST" && url.pathname === "/api/repositories") {
|
|
287
|
+
const body = await readJsonBody(request);
|
|
288
|
+
if (!body.root) {
|
|
289
|
+
sendJson(response, { error: "root is required" }, 400);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const root = await resolveExistingDirectory(body.root);
|
|
293
|
+
if (!root) {
|
|
294
|
+
sendJson(response, { error: "repository root must be an existing directory" }, 400);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!state.roots.includes(root)) {
|
|
298
|
+
state.roots.push(root);
|
|
299
|
+
}
|
|
300
|
+
sendJson(response, await rescan(root));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (request.method === "POST" && url.pathname === "/api/select-directory") {
|
|
304
|
+
const selected = await withTimeout(selectDirectory(), DIRECTORY_PICKER_TIMEOUT_MS, "system folder picker timed out");
|
|
305
|
+
if (!selected) {
|
|
306
|
+
sendJson(response, { cancelled: true });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const root = await resolveExistingDirectory(selected);
|
|
310
|
+
if (!root) {
|
|
311
|
+
sendJson(response, { error: "selected folder must be an existing directory" }, 400);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
sendJson(response, { root });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (request.method === "POST" && url.pathname === "/api/enable-ci") {
|
|
318
|
+
const body = await readJsonBody(request);
|
|
319
|
+
if (!body.root) {
|
|
320
|
+
sendJson(response, { error: "root is required" }, 400);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const root = await resolveKnownRoot(state, body.root);
|
|
324
|
+
if (!root) {
|
|
325
|
+
sendJson(response, { error: "unknown repository root" }, 400);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const result = await enableGithubAction(root, { force: body.force });
|
|
329
|
+
await rescan();
|
|
330
|
+
sendJson(response, { result, state: state.current });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (request.method === "POST" && url.pathname === "/api/enable-local-git") {
|
|
334
|
+
const body = await readJsonBody(request);
|
|
335
|
+
if (!body.root) {
|
|
336
|
+
sendJson(response, { error: "root is required" }, 400);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const root = await resolveKnownRoot(state, body.root);
|
|
340
|
+
if (!root) {
|
|
341
|
+
sendJson(response, { error: "unknown repository root" }, 400);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const result = await enableLocalGitGate(root, { force: body.force, installHooks: true });
|
|
345
|
+
await rescan();
|
|
346
|
+
sendJson(response, { result, state: state.current });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (request.method === "POST" && url.pathname === "/api/scaffold") {
|
|
350
|
+
const body = await readJsonBody(request);
|
|
351
|
+
if (!body.root) {
|
|
352
|
+
sendJson(response, { error: "root is required" }, 400);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const root = await resolveKnownRoot(state, body.root);
|
|
356
|
+
if (!root) {
|
|
357
|
+
sendJson(response, { error: "unknown repository root" }, 400);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const result = await scaffoldRepository(root, {
|
|
361
|
+
features: body.features?.length ? body.features : ["agents", "hooks", "badge", "llms"],
|
|
362
|
+
force: body.force
|
|
363
|
+
});
|
|
364
|
+
await rescan();
|
|
365
|
+
sendJson(response, { result, state: state.current });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (request.method === "GET" && url.pathname === "/open-file") {
|
|
369
|
+
const root = await resolveKnownRoot(state, url.searchParams.get("root") ?? state.roots[0] ?? process.cwd());
|
|
370
|
+
if (!root) {
|
|
371
|
+
sendJson(response, { error: "unknown repository root" }, 400);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const relativePath = url.searchParams.get("path") ?? "";
|
|
375
|
+
const target = await resolveExistingPathInsideRoot(root, relativePath);
|
|
376
|
+
if (!target) {
|
|
377
|
+
sendJson(response, { error: "path must stay inside repository root" }, 400);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
openFile(target);
|
|
381
|
+
sendJson(response, { ok: true, path: target });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
sendJson(response, { error: "not found" }, 404);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
388
|
+
sendJson(response, { error: message }, error instanceof HttpError ? error.status : 500);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function normalizeRoots(roots) {
|
|
392
|
+
const normalized = [];
|
|
393
|
+
for (const root of roots) {
|
|
394
|
+
const realRoot = await resolveExistingDirectory(root);
|
|
395
|
+
if (!realRoot) {
|
|
396
|
+
throw new Error(`repository root does not exist or is not a directory: ${root}`);
|
|
397
|
+
}
|
|
398
|
+
if (!normalized.includes(realRoot)) {
|
|
399
|
+
normalized.push(realRoot);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return normalized;
|
|
403
|
+
}
|
|
404
|
+
async function resolveExistingDirectory(root) {
|
|
405
|
+
const target = resolve(root);
|
|
406
|
+
const targetReal = await realpath(target).catch(() => undefined);
|
|
407
|
+
if (!targetReal)
|
|
408
|
+
return undefined;
|
|
409
|
+
const stats = await stat(targetReal).catch(() => undefined);
|
|
410
|
+
if (!stats?.isDirectory())
|
|
411
|
+
return undefined;
|
|
412
|
+
return targetReal;
|
|
413
|
+
}
|
|
414
|
+
async function resolveKnownRoot(state, requestedRoot) {
|
|
415
|
+
const root = await resolveExistingDirectory(requestedRoot);
|
|
416
|
+
if (!root || !state.roots.includes(root))
|
|
417
|
+
return undefined;
|
|
418
|
+
return root;
|
|
419
|
+
}
|
|
420
|
+
function summarizeRepositories(repositories) {
|
|
421
|
+
return {
|
|
422
|
+
repositoriesScanned: repositories.length,
|
|
423
|
+
brokenRepositories: repositories.filter((repository) => repository.health === "broken").length,
|
|
424
|
+
reviewNeededRepositories: repositories.filter((repository) => repository.health === "review_needed").length,
|
|
425
|
+
findings: repositories.reduce((total, repository) => total + repository.report.summary.findings, 0),
|
|
426
|
+
broken: repositories.reduce((total, repository) => total + repository.report.summary.broken, 0),
|
|
427
|
+
reviewNeeded: repositories.reduce((total, repository) => total + repository.report.summary.reviewNeeded, 0)
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function healthFromReport(report) {
|
|
431
|
+
if (report.summary.broken > 0)
|
|
432
|
+
return "broken";
|
|
433
|
+
if (report.summary.reviewNeeded > 0)
|
|
434
|
+
return "review_needed";
|
|
435
|
+
if (report.summary.documentsScanned === 0)
|
|
436
|
+
return "review_needed";
|
|
437
|
+
return "ok";
|
|
438
|
+
}
|
|
439
|
+
async function appendHistory(root, report) {
|
|
440
|
+
const path = await resolveWritablePathInsideRoot(root, ".evidoc/history.jsonl");
|
|
441
|
+
if (!path) {
|
|
442
|
+
throw new Error("history path must stay inside repository root");
|
|
443
|
+
}
|
|
444
|
+
await mkdir(dirname(path), { recursive: true });
|
|
445
|
+
await appendFile(path, `${JSON.stringify({
|
|
446
|
+
scannedAt: report.scannedAt,
|
|
447
|
+
findings: report.summary.findings,
|
|
448
|
+
broken: report.summary.broken,
|
|
449
|
+
reviewNeeded: report.summary.reviewNeeded
|
|
450
|
+
})}\n`, "utf8");
|
|
451
|
+
}
|
|
452
|
+
async function readHistory(root, limit) {
|
|
453
|
+
try {
|
|
454
|
+
const raw = await readFile(join(root, ".evidoc", "history.jsonl"), "utf8");
|
|
455
|
+
const points = [];
|
|
456
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
457
|
+
if (!line.trim())
|
|
458
|
+
continue;
|
|
459
|
+
try {
|
|
460
|
+
const parsed = JSON.parse(line);
|
|
461
|
+
if (typeof parsed.scannedAt === "string") {
|
|
462
|
+
points.push({
|
|
463
|
+
scannedAt: parsed.scannedAt,
|
|
464
|
+
findings: numberOrZero(parsed.findings),
|
|
465
|
+
broken: numberOrZero(parsed.broken),
|
|
466
|
+
reviewNeeded: numberOrZero(parsed.reviewNeeded)
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return points.slice(-limit);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function findLocalGitAffectedDocuments(root, report, changedFiles) {
|
|
481
|
+
if (!report || changedFiles.length === 0)
|
|
482
|
+
return [];
|
|
483
|
+
const affected = new Set();
|
|
484
|
+
const changed = new Set(changedFiles);
|
|
485
|
+
for (const document of report.documents) {
|
|
486
|
+
if (changed.has(document.path)) {
|
|
487
|
+
affected.add(document.path);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const documentPath = await resolveExistingPathInsideRoot(root, document.path);
|
|
491
|
+
if (!documentPath)
|
|
492
|
+
continue;
|
|
493
|
+
let text;
|
|
494
|
+
try {
|
|
495
|
+
text = await readFile(documentPath, "utf8");
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (changedFiles.some((file) => !isMarkdownPath(file) && documentReferencesChangedPath(text, file))) {
|
|
501
|
+
affected.add(document.path);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return [...affected].sort();
|
|
505
|
+
}
|
|
506
|
+
async function readLastLocalGitGate(root, changes) {
|
|
507
|
+
let raw;
|
|
508
|
+
try {
|
|
509
|
+
raw = await readFile(join(root, ".evidoc", "reports", "local-gate.json"), "utf8");
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
const parsed = JSON.parse(raw);
|
|
516
|
+
const summary = parsed.report?.summary;
|
|
517
|
+
if (!summary)
|
|
518
|
+
return undefined;
|
|
519
|
+
const generatedAt = typeof parsed.runtime?.generatedAt === "string" ? parsed.runtime.generatedAt : undefined;
|
|
520
|
+
const runtimeScannedAt = typeof parsed.runtime?.scannedAt === "string" ? parsed.runtime.scannedAt : undefined;
|
|
521
|
+
const scannedAt = runtimeScannedAt ?? (typeof parsed.report?.scannedAt === "string" ? parsed.report.scannedAt : undefined);
|
|
522
|
+
const scope = typeof parsed.scope === "string" ? parsed.scope : undefined;
|
|
523
|
+
const changedFiles = parseStringArray(parsed.changedFiles);
|
|
524
|
+
const freshness = await localGateFreshness(root, {
|
|
525
|
+
scope,
|
|
526
|
+
generatedAt,
|
|
527
|
+
scannedAt,
|
|
528
|
+
changedFiles,
|
|
529
|
+
changedFileFingerprints: parseStringRecord(parsed.runtime?.changedFileFingerprints),
|
|
530
|
+
stagedChangedFiles: changes.stagedChangedFiles,
|
|
531
|
+
unstagedChangedFiles: changes.unstagedChangedFiles
|
|
532
|
+
});
|
|
533
|
+
return {
|
|
534
|
+
event: typeof parsed.event === "string" ? parsed.event : undefined,
|
|
535
|
+
scope,
|
|
536
|
+
since: typeof parsed.since === "string" ? parsed.since : undefined,
|
|
537
|
+
status: typeof parsed.runtime?.status === "string" ? parsed.runtime.status : undefined,
|
|
538
|
+
fingerprint: typeof parsed.runtime?.fingerprint === "string" ? parsed.runtime.fingerprint : undefined,
|
|
539
|
+
baselineCommit: typeof parsed.runtime?.baselineCommit === "string" ? parsed.runtime.baselineCommit : undefined,
|
|
540
|
+
generatedAt,
|
|
541
|
+
scannedAt,
|
|
542
|
+
stale: freshness.stale,
|
|
543
|
+
staleReason: freshness.reason,
|
|
544
|
+
findings: numberOrZero(summary.findings),
|
|
545
|
+
broken: numberOrZero(summary.broken),
|
|
546
|
+
reviewNeeded: numberOrZero(summary.reviewNeeded)
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
async function localGateFreshness(root, input) {
|
|
554
|
+
const currentChangedFiles = uniqueSorted(input.scope === "staged"
|
|
555
|
+
? input.stagedChangedFiles
|
|
556
|
+
: [...input.stagedChangedFiles, ...input.unstagedChangedFiles]);
|
|
557
|
+
if (!sameStringList(currentChangedFiles, uniqueSorted(input.changedFiles))) {
|
|
558
|
+
return { stale: true, reason: "changed file set differs" };
|
|
559
|
+
}
|
|
560
|
+
if (currentChangedFiles.length === 0)
|
|
561
|
+
return { stale: false };
|
|
562
|
+
const expectedFingerprintFiles = Object.keys(input.changedFileFingerprints).sort();
|
|
563
|
+
if (expectedFingerprintFiles.length > 0) {
|
|
564
|
+
if (!sameStringList(currentChangedFiles, expectedFingerprintFiles)) {
|
|
565
|
+
return { stale: true, reason: "changed file fingerprint set differs" };
|
|
566
|
+
}
|
|
567
|
+
const actualFingerprints = await readCurrentChangedFileFingerprints(root, currentChangedFiles, input.scope);
|
|
568
|
+
for (const file of currentChangedFiles) {
|
|
569
|
+
if (actualFingerprints[file] !== input.changedFileFingerprints[file]) {
|
|
570
|
+
return { stale: true, reason: `${file} content changed after gate report` };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return { stale: false };
|
|
574
|
+
}
|
|
575
|
+
const timestamp = Date.parse(input.generatedAt ?? input.scannedAt ?? "");
|
|
576
|
+
if (!Number.isFinite(timestamp)) {
|
|
577
|
+
return { stale: true, reason: "missing runtime timestamp" };
|
|
578
|
+
}
|
|
579
|
+
for (const file of currentChangedFiles) {
|
|
580
|
+
const target = await resolveExistingPathInsideRoot(root, file);
|
|
581
|
+
if (!target)
|
|
582
|
+
continue;
|
|
583
|
+
const stats = await stat(target).catch(() => undefined);
|
|
584
|
+
if (stats && stats.mtimeMs > timestamp) {
|
|
585
|
+
return { stale: true, reason: `${file} changed after gate report` };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return { stale: false };
|
|
589
|
+
}
|
|
590
|
+
async function readCurrentChangedFileFingerprints(root, changedFiles, scope) {
|
|
591
|
+
const fingerprints = {};
|
|
592
|
+
for (const file of changedFiles) {
|
|
593
|
+
fingerprints[file] =
|
|
594
|
+
scope === "staged"
|
|
595
|
+
? await readStagedFileFingerprint(root, file)
|
|
596
|
+
: await readWorktreeFileFingerprint(root, file);
|
|
597
|
+
}
|
|
598
|
+
return fingerprints;
|
|
599
|
+
}
|
|
600
|
+
async function readStagedFileFingerprint(root, file) {
|
|
601
|
+
const content = await runGitBuffer(root, ["show", `:${file}`]);
|
|
602
|
+
return content ? fingerprintFileContent(content) : "missing";
|
|
603
|
+
}
|
|
604
|
+
async function readWorktreeFileFingerprint(root, file) {
|
|
605
|
+
const target = await resolveExistingPathInsideRoot(root, file);
|
|
606
|
+
if (!target)
|
|
607
|
+
return "missing";
|
|
608
|
+
try {
|
|
609
|
+
return fingerprintFileContent(await readFile(target));
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return "unreadable";
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function parseStringArray(value) {
|
|
616
|
+
if (!Array.isArray(value))
|
|
617
|
+
return [];
|
|
618
|
+
return value.filter((item) => typeof item === "string").sort();
|
|
619
|
+
}
|
|
620
|
+
function parseStringRecord(value) {
|
|
621
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
622
|
+
return {};
|
|
623
|
+
return Object.fromEntries(Object.entries(value)
|
|
624
|
+
.map(([key, item]) => [key, typeof item === "string" ? item : "invalid"])
|
|
625
|
+
.sort(([left], [right]) => left.localeCompare(right)));
|
|
626
|
+
}
|
|
627
|
+
function uniqueSorted(value) {
|
|
628
|
+
return [...new Set(value)].sort();
|
|
629
|
+
}
|
|
630
|
+
function sameStringList(left, right) {
|
|
631
|
+
if (left.length !== right.length)
|
|
632
|
+
return false;
|
|
633
|
+
return left.every((item, index) => item === right[index]);
|
|
634
|
+
}
|
|
635
|
+
export async function createDefaultEvidocConfig(root) {
|
|
636
|
+
const docRoots = [];
|
|
637
|
+
if (await exists(join(root, "README.md")))
|
|
638
|
+
docRoots.push("README.md");
|
|
639
|
+
if (await exists(join(root, "AGENTS.md")))
|
|
640
|
+
docRoots.push("AGENTS.md");
|
|
641
|
+
if (await exists(join(root, "CLAUDE.md")))
|
|
642
|
+
docRoots.push("CLAUDE.md");
|
|
643
|
+
if (await exists(join(root, "docs")))
|
|
644
|
+
docRoots.push("docs");
|
|
645
|
+
const apiSpecPaths = [];
|
|
646
|
+
for (const candidate of ["openapi.json", "swagger.json", "docs/openapi.json", "docs/swagger.json"]) {
|
|
647
|
+
if (await exists(join(root, candidate)))
|
|
648
|
+
apiSpecPaths.push(candidate);
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
docRoots,
|
|
652
|
+
ignorePatterns: ["docs/archive/**"],
|
|
653
|
+
severityOverrides: {},
|
|
654
|
+
apiSpecPaths,
|
|
655
|
+
reviewTtlDays: 90,
|
|
656
|
+
requireTestsForSources: false,
|
|
657
|
+
maxFileBytes: 500000
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async function inspectGithubAction(root) {
|
|
661
|
+
const workflowsRoot = join(root, ".github", "workflows");
|
|
662
|
+
let entries;
|
|
663
|
+
try {
|
|
664
|
+
entries = await readdir(workflowsRoot);
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return { enabled: false };
|
|
668
|
+
}
|
|
669
|
+
let workflowPath;
|
|
670
|
+
const warnings = [];
|
|
671
|
+
const defaultBranch = await detectRepositoryDefaultBranch(root);
|
|
672
|
+
for (const entry of entries.sort()) {
|
|
673
|
+
if (!entry.endsWith(".yml") && !entry.endsWith(".yaml"))
|
|
674
|
+
continue;
|
|
675
|
+
const relativePath = `.github/workflows/${entry}`;
|
|
676
|
+
let text;
|
|
677
|
+
try {
|
|
678
|
+
text = await readFile(join(root, relativePath), "utf8");
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const detection = await detectRepositoryEvidocWorkflowText(root, text);
|
|
684
|
+
if (detection.matches) {
|
|
685
|
+
workflowPath ??= relativePath;
|
|
686
|
+
warnings.push(...evidocWorkflowWarnings(relativePath, text, defaultBranch));
|
|
687
|
+
for (const action of detection.localActions) {
|
|
688
|
+
warnings.push(...evidocWorkflowWarnings(action.path, action.text, undefined));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return workflowPath ? { enabled: true, workflowPath, warnings } : { enabled: false };
|
|
693
|
+
}
|
|
694
|
+
export function defaultGithubWorkflow(options = {}) {
|
|
695
|
+
const pushBranches = normalizePushBranches(options.pushBranches);
|
|
696
|
+
const branchComment = pushBranches.join(",") === DEFAULT_PUSH_BRANCHES.join(",")
|
|
697
|
+
? " # If your default branch is not main or master, replace this list."
|
|
698
|
+
: " # Detected from this repository. Update if your default branch differs.";
|
|
699
|
+
return [
|
|
700
|
+
"name: Evidoc",
|
|
701
|
+
"",
|
|
702
|
+
"on:",
|
|
703
|
+
" pull_request:",
|
|
704
|
+
" push:",
|
|
705
|
+
branchComment,
|
|
706
|
+
" branches:",
|
|
707
|
+
...pushBranches.map((branch) => ` - ${branch}`),
|
|
708
|
+
"",
|
|
709
|
+
"permissions:",
|
|
710
|
+
" contents: read",
|
|
711
|
+
" actions: read",
|
|
712
|
+
" pull-requests: write",
|
|
713
|
+
"",
|
|
714
|
+
"jobs:",
|
|
715
|
+
" evidoc:",
|
|
716
|
+
" runs-on: ubuntu-latest",
|
|
717
|
+
" steps:",
|
|
718
|
+
" - uses: actions/checkout@v4",
|
|
719
|
+
" - uses: handong66/Evidoc/packages/github-action@main",
|
|
720
|
+
" with:",
|
|
721
|
+
" fail-on: review_needed",
|
|
722
|
+
' sarif: "false"',
|
|
723
|
+
' pr-comment: "true"',
|
|
724
|
+
" changed-only: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}",
|
|
725
|
+
' since: origin/${{ github.event.repository.default_branch }}',
|
|
726
|
+
""
|
|
727
|
+
].join("\n");
|
|
728
|
+
}
|
|
729
|
+
export async function detectRepositoryDefaultBranch(root) {
|
|
730
|
+
for (const remote of await detectRepositoryRemoteNames(root)) {
|
|
731
|
+
const remoteHead = await runGit(root, ["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`]);
|
|
732
|
+
const remoteBranch = sanitizeBranchName(remoteHead?.replace(new RegExp(`^${escapeRegExp(remote)}/`), "").trim());
|
|
733
|
+
if (remoteBranch)
|
|
734
|
+
return remoteBranch;
|
|
735
|
+
}
|
|
736
|
+
const currentBranch = await runGit(root, ["branch", "--show-current"]);
|
|
737
|
+
return sanitizeBranchName(currentBranch?.trim());
|
|
738
|
+
}
|
|
739
|
+
export async function detectRepositoryPushBranches(root) {
|
|
740
|
+
const branch = await detectRepositoryDefaultBranch(root);
|
|
741
|
+
return branch ? [branch] : DEFAULT_PUSH_BRANCHES;
|
|
742
|
+
}
|
|
743
|
+
function normalizePushBranches(value) {
|
|
744
|
+
const sanitized = (value ?? DEFAULT_PUSH_BRANCHES)
|
|
745
|
+
.map((branch) => sanitizeBranchName(branch))
|
|
746
|
+
.filter((branch) => branch !== undefined);
|
|
747
|
+
const unique = [...new Set(sanitized)];
|
|
748
|
+
return unique.length > 0 ? unique : DEFAULT_PUSH_BRANCHES;
|
|
749
|
+
}
|
|
750
|
+
function sanitizeBranchName(value) {
|
|
751
|
+
const branch = value?.trim();
|
|
752
|
+
if (!branch || !/^[A-Za-z0-9._/-]+$/.test(branch))
|
|
753
|
+
return undefined;
|
|
754
|
+
return branch;
|
|
755
|
+
}
|
|
756
|
+
async function detectRepositoryRemoteNames(root) {
|
|
757
|
+
const rawRemotes = await runGit(root, ["remote"]);
|
|
758
|
+
const remotes = (rawRemotes ?? "")
|
|
759
|
+
.split(/\r?\n/)
|
|
760
|
+
.map((remote) => remote.trim())
|
|
761
|
+
.filter((remote) => /^[A-Za-z0-9._-]+$/.test(remote));
|
|
762
|
+
return [...new Set(["origin", ...remotes])];
|
|
763
|
+
}
|
|
764
|
+
function escapeRegExp(value) {
|
|
765
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
766
|
+
}
|
|
767
|
+
async function runGit(root, args) {
|
|
768
|
+
return new Promise((resolveRun) => {
|
|
769
|
+
const child = spawn("git", args, {
|
|
770
|
+
cwd: root,
|
|
771
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
772
|
+
});
|
|
773
|
+
const stdout = [];
|
|
774
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
775
|
+
child.once("error", () => resolveRun(undefined));
|
|
776
|
+
child.once("close", (exitCode) => {
|
|
777
|
+
if (exitCode !== 0) {
|
|
778
|
+
resolveRun(undefined);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
resolveRun(Buffer.concat(stdout).toString("utf8"));
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
async function runGitBuffer(root, args) {
|
|
786
|
+
return new Promise((resolveRun) => {
|
|
787
|
+
const child = spawn("git", args, {
|
|
788
|
+
cwd: root,
|
|
789
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
790
|
+
});
|
|
791
|
+
const stdout = [];
|
|
792
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
793
|
+
child.once("error", () => resolveRun(undefined));
|
|
794
|
+
child.once("close", (exitCode) => {
|
|
795
|
+
if (exitCode !== 0) {
|
|
796
|
+
resolveRun(undefined);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
resolveRun(Buffer.concat(stdout));
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
async function runGitOk(root, args) {
|
|
804
|
+
return new Promise((resolveRun) => {
|
|
805
|
+
const child = spawn("git", args, {
|
|
806
|
+
cwd: root,
|
|
807
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
808
|
+
});
|
|
809
|
+
child.once("error", () => resolveRun(false));
|
|
810
|
+
child.once("close", (exitCode) => resolveRun(exitCode === 0));
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function parseGitPathList(stdout) {
|
|
814
|
+
return (stdout ?? "")
|
|
815
|
+
.split(/\r?\n/)
|
|
816
|
+
.map((line) => line.trim().replaceAll("\\", "/"))
|
|
817
|
+
.filter(Boolean)
|
|
818
|
+
.sort();
|
|
819
|
+
}
|
|
820
|
+
function isMarkdownPath(path) {
|
|
821
|
+
return path.endsWith(".md") || path.endsWith(".mdx");
|
|
822
|
+
}
|
|
823
|
+
function documentReferencesChangedPath(text, path) {
|
|
824
|
+
const normalizedText = text.replaceAll("\\", "/").toLowerCase();
|
|
825
|
+
const normalizedPath = path.replaceAll("\\", "/").toLowerCase();
|
|
826
|
+
if (normalizedText.includes(normalizedPath))
|
|
827
|
+
return true;
|
|
828
|
+
const parts = normalizedPath.split("/").filter(Boolean);
|
|
829
|
+
for (let length = parts.length - 1; length > 0; length -= 1) {
|
|
830
|
+
const directory = `${parts.slice(0, length).join("/")}/`;
|
|
831
|
+
if (normalizedText.includes(directory) ||
|
|
832
|
+
normalizedText.includes(`./${directory}`) ||
|
|
833
|
+
normalizedText.includes(`../${directory}`)) {
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
export async function scaffoldRepository(root, options) {
|
|
840
|
+
const results = [];
|
|
841
|
+
for (const feature of [...new Set(options.features)]) {
|
|
842
|
+
if (feature === "ci") {
|
|
843
|
+
const workflow = await enableGithubAction(root, { force: options.force });
|
|
844
|
+
results.push({ feature, files: [workflow] });
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
const files = await scaffoldFeature(root, feature, Boolean(options.force));
|
|
848
|
+
results.push({ feature, files });
|
|
849
|
+
}
|
|
850
|
+
return results;
|
|
851
|
+
}
|
|
852
|
+
async function scaffoldFeature(root, feature, force) {
|
|
853
|
+
if (feature === "agents") {
|
|
854
|
+
return Promise.all([
|
|
855
|
+
writeScaffoldFile(root, "AGENTS.md", agentInstructions("AGENTS.md"), force),
|
|
856
|
+
writeScaffoldFile(root, "CLAUDE.md", agentInstructions("CLAUDE.md"), force),
|
|
857
|
+
writeScaffoldFile(root, ".cursor/rules/evidoc.md", agentInstructions("Cursor"), force),
|
|
858
|
+
writeScaffoldFile(root, ".github/copilot-instructions.md", agentInstructions("GitHub Copilot"), force),
|
|
859
|
+
writeScaffoldFile(root, ".evidoc/skills/evidoc/SKILL.md", evidocSkillInstructions(), force)
|
|
860
|
+
]);
|
|
861
|
+
}
|
|
862
|
+
if (feature === "hooks") {
|
|
863
|
+
return writeLocalGitHookFiles(root, force);
|
|
864
|
+
}
|
|
865
|
+
if (feature === "badge") {
|
|
866
|
+
return [
|
|
867
|
+
await writeScaffoldFile(root, ".evidoc/badge.md", "[](https://github.com/handong66/Evidoc)\n", force)
|
|
868
|
+
];
|
|
869
|
+
}
|
|
870
|
+
if (feature === "llms") {
|
|
871
|
+
return Promise.all([
|
|
872
|
+
writeScaffoldFile(root, "llms.txt", [
|
|
873
|
+
"# Evidoc agent context",
|
|
874
|
+
"",
|
|
875
|
+
"- Run `npx evidoc guard --event manual --scope staged --json` before final code, doc, review, or commit summaries.",
|
|
876
|
+
"- If using MCP, call `evidoc.get_drift_status` only when it appears in tools/list; otherwise use the CLI.",
|
|
877
|
+
"- Trust `.evidoc/reports/local-gate.json` only when `changedFiles` and `runtime.changedFileFingerprints` still match current same-scope Git changes; old reports without fingerprints also need fresh timestamps.",
|
|
878
|
+
"- Dedupe repeated replies by `runtime.findings[].fingerprint`.",
|
|
879
|
+
"- Treat Evidoc findings as evidence requests, not automatic permission to rewrite prose.",
|
|
880
|
+
"- Safe patches may be applied only after review; review/blocked patches require human judgment.",
|
|
881
|
+
""
|
|
882
|
+
].join("\n"), force),
|
|
883
|
+
writeScaffoldFile(root, ".evidoc/context-pack.md", [
|
|
884
|
+
"# Evidoc Context Pack",
|
|
885
|
+
"",
|
|
886
|
+
"This repository is configured for local-first documentation drift checks.",
|
|
887
|
+
"",
|
|
888
|
+
"Recommended loop:",
|
|
889
|
+
"",
|
|
890
|
+
"1. `npx evidoc guard --event manual --scope staged --json`",
|
|
891
|
+
"2. Check `runtime.status`, `runtime.fingerprint`, and `runtime.findings[].fingerprint`.",
|
|
892
|
+
"3. `npx evidoc diagnose` before proposing repairs.",
|
|
893
|
+
"4. Apply only evidence-bound changes.",
|
|
894
|
+
"5. Re-run Evidoc before opening a PR or final summary.",
|
|
895
|
+
""
|
|
896
|
+
].join("\n"), force)
|
|
897
|
+
]);
|
|
898
|
+
}
|
|
899
|
+
return [];
|
|
900
|
+
}
|
|
901
|
+
async function writeLocalGitHookFiles(root, force) {
|
|
902
|
+
const files = await Promise.all([
|
|
903
|
+
writeScaffoldFile(root, ".githooks/pre-commit", localGitHookScript("pre-commit"), force),
|
|
904
|
+
writeScaffoldFile(root, ".githooks/pre-push", localGitHookScript("pre-push"), force)
|
|
905
|
+
]);
|
|
906
|
+
await Promise.all([
|
|
907
|
+
chmod(join(root, ".githooks", "pre-commit"), 0o755).catch(() => undefined),
|
|
908
|
+
chmod(join(root, ".githooks", "pre-push"), 0o755).catch(() => undefined)
|
|
909
|
+
]);
|
|
910
|
+
return files;
|
|
911
|
+
}
|
|
912
|
+
function localGitHookScript(event) {
|
|
913
|
+
const fallbackCommand = event === "pre-commit"
|
|
914
|
+
? "check --changed-only --fail-on=review_needed"
|
|
915
|
+
: "check --changed-only --since HEAD --fail-on=review_needed";
|
|
916
|
+
const guardCommand = event === "pre-commit"
|
|
917
|
+
? "guard --event pre-commit --fail-on=review_needed"
|
|
918
|
+
: "guard --event pre-push --since HEAD --fail-on=review_needed";
|
|
919
|
+
return [
|
|
920
|
+
"#!/usr/bin/env sh",
|
|
921
|
+
"set -u",
|
|
922
|
+
"",
|
|
923
|
+
"if ! command -v node >/dev/null 2>&1; then",
|
|
924
|
+
` echo "Evidoc ${event} skipped: install Node.js plus Evidoc, or run npx evidoc ${fallbackCommand}." >&2`,
|
|
925
|
+
" exit 0",
|
|
926
|
+
"elif [ -x ./node_modules/.bin/evidoc ]; then",
|
|
927
|
+
` ./node_modules/.bin/evidoc ${guardCommand}`,
|
|
928
|
+
"elif command -v evidoc >/dev/null 2>&1; then",
|
|
929
|
+
` evidoc ${guardCommand}`,
|
|
930
|
+
"elif command -v npx >/dev/null 2>&1; then",
|
|
931
|
+
` npx evidoc ${guardCommand}`,
|
|
932
|
+
"else",
|
|
933
|
+
` echo "Evidoc ${event} skipped: install Evidoc locally/globally or install npm/npx; local Git gate could not run." >&2`,
|
|
934
|
+
" exit 0",
|
|
935
|
+
"fi",
|
|
936
|
+
""
|
|
937
|
+
].join("\n");
|
|
938
|
+
}
|
|
939
|
+
async function writeScaffoldFile(root, path, content, force) {
|
|
940
|
+
const target = await resolveWritablePathInsideRoot(root, path);
|
|
941
|
+
if (!target) {
|
|
942
|
+
throw new Error("scaffold path must stay inside repository root");
|
|
943
|
+
}
|
|
944
|
+
const alreadyExists = await exists(target);
|
|
945
|
+
if (alreadyExists && !force) {
|
|
946
|
+
return { path, status: "kept" };
|
|
947
|
+
}
|
|
948
|
+
await mkdir(dirname(target), { recursive: true });
|
|
949
|
+
await writeFile(target, content, "utf8");
|
|
950
|
+
return { path, status: alreadyExists ? "updated" : "created" };
|
|
951
|
+
}
|
|
952
|
+
function agentInstructions(surface) {
|
|
953
|
+
return [
|
|
954
|
+
`# ${surface} Evidoc Instructions`,
|
|
955
|
+
"",
|
|
956
|
+
"## Evidoc Agent Runtime Contract",
|
|
957
|
+
"",
|
|
958
|
+
"- Evidoc is the single source of truth for documentation drift. Do not invent drift findings from prose alone.",
|
|
959
|
+
"- Before finalizing code changes, documentation changes, review summaries, or commit summaries, run `npx evidoc guard --event manual --scope staged --json` from the repository root.",
|
|
960
|
+
"- If MCP tools are available, call `evidoc.get_drift_status` before speaking about status; if the tool is not listed, fall back to the local report or CLI.",
|
|
961
|
+
"- Read `.evidoc/reports/local-gate.json` only when its `changedFiles` and `runtime.changedFileFingerprints` match the current same-scope Git changes. For old reports without fingerprints, require `runtime.generatedAt` or `runtime.scannedAt` to be newer than those files; otherwise run another scan.",
|
|
962
|
+
"- Summarize only `runtime.status`, `runtime.mode`, `runtime.fingerprint`, and unique `runtime.findings[]`; dedupe by `runtime.findings[].fingerprint` so the same drift is not repeated.",
|
|
963
|
+
"- Treat hook-triggered `runtime.mode=blocking` as a gate failure. Treat manual, MCP, or IDE results as advisory unless a hook failed.",
|
|
964
|
+
"- When findings exist, report affected documents, evidence-backed cause, and next commands: `npx evidoc diagnose`, `npx evidoc fix --safe --write`, and `git diff`.",
|
|
965
|
+
"- Keep AGENTS.md, CLAUDE.md, Cursor rules, GitHub Copilot instructions, and `.evidoc/skills/evidoc/SKILL.md` consistent.",
|
|
966
|
+
"- Do not silence Evidoc findings without a review-log entry and expiry. Generated patches still require review before merge.",
|
|
967
|
+
"- Prefer deterministic evidence over LLM guesses; every documentation change should cite the source file or API spec it follows.",
|
|
968
|
+
""
|
|
969
|
+
].join("\n");
|
|
970
|
+
}
|
|
971
|
+
function evidocSkillInstructions() {
|
|
972
|
+
return [
|
|
973
|
+
"---",
|
|
974
|
+
"name: evidoc",
|
|
975
|
+
"description: Use when changing code or documentation, preparing a commit summary, reviewing a repository, or answering whether docs drifted.",
|
|
976
|
+
"---",
|
|
977
|
+
"",
|
|
978
|
+
"# Evidoc Agent Runtime",
|
|
979
|
+
"",
|
|
980
|
+
"Use Evidoc as the repository-local documentation drift fact source.",
|
|
981
|
+
"",
|
|
982
|
+
"## When To Run",
|
|
983
|
+
"",
|
|
984
|
+
"- Before finalizing code changes, documentation edits, review summaries, or commit summaries.",
|
|
985
|
+
"- When the user asks whether docs drifted or asks for a documentation repair.",
|
|
986
|
+
"- When a local Git hook reports Evidoc failed.",
|
|
987
|
+
"",
|
|
988
|
+
"## Command Path",
|
|
989
|
+
"",
|
|
990
|
+
"1. If MCP tools are available and `evidoc.get_drift_status` is listed, call it before speaking about status.",
|
|
991
|
+
"2. Prefer `.evidoc/reports/local-gate.json` only when its `changedFiles` and `runtime.changedFileFingerprints` match the current same-scope Git changes. For old reports without fingerprints, require `runtime.generatedAt` or `runtime.scannedAt` to be newer than those files.",
|
|
992
|
+
"3. Otherwise run `npx evidoc guard --event manual --scope staged --json`.",
|
|
993
|
+
"4. If `evidoc.diagnose_drift` is listed, call it before proposing repairs; otherwise use `npx evidoc diagnose`.",
|
|
994
|
+
"",
|
|
995
|
+
"## Response Contract",
|
|
996
|
+
"",
|
|
997
|
+
"- Report `runtime.status`, `runtime.mode`, and `runtime.fingerprint`.",
|
|
998
|
+
"- dedupe by runtime.findings[].fingerprint.",
|
|
999
|
+
"- For each unique finding, summarize affected document, evidence-backed cause, severity, and suggested action.",
|
|
1000
|
+
"- Use `evidoc.suggest_doc_fix` or `npx evidoc fix --safe --write` only for evidence-bound safe fixes.",
|
|
1001
|
+
"- Never upload repository content to third-party models by default, and never merge generated patches automatically.",
|
|
1002
|
+
""
|
|
1003
|
+
].join("\n");
|
|
1004
|
+
}
|
|
1005
|
+
function publishEvent(state, event, payload) {
|
|
1006
|
+
for (const client of state.clients) {
|
|
1007
|
+
client.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
function sendHtml(response, html) {
|
|
1011
|
+
response.writeHead(200, {
|
|
1012
|
+
...securityHeaders(),
|
|
1013
|
+
"content-type": "text/html; charset=utf-8"
|
|
1014
|
+
});
|
|
1015
|
+
response.end(html);
|
|
1016
|
+
}
|
|
1017
|
+
function sendSvg(response, svg) {
|
|
1018
|
+
response.writeHead(200, {
|
|
1019
|
+
...securityHeaders(),
|
|
1020
|
+
"content-type": "image/svg+xml",
|
|
1021
|
+
"cache-control": "public, max-age=86400"
|
|
1022
|
+
});
|
|
1023
|
+
response.end(svg);
|
|
1024
|
+
}
|
|
1025
|
+
function sendJson(response, payload, status = 200) {
|
|
1026
|
+
response.writeHead(status, {
|
|
1027
|
+
...securityHeaders(),
|
|
1028
|
+
"content-type": "application/json; charset=utf-8"
|
|
1029
|
+
});
|
|
1030
|
+
response.end(JSON.stringify(payload));
|
|
1031
|
+
}
|
|
1032
|
+
async function readJsonBody(request) {
|
|
1033
|
+
const chunks = [];
|
|
1034
|
+
let total = 0;
|
|
1035
|
+
let tooLarge = false;
|
|
1036
|
+
for await (const chunk of request) {
|
|
1037
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1038
|
+
total += buffer.byteLength;
|
|
1039
|
+
if (total > MAX_JSON_BODY_BYTES) {
|
|
1040
|
+
tooLarge = true;
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (!tooLarge)
|
|
1044
|
+
chunks.push(buffer);
|
|
1045
|
+
}
|
|
1046
|
+
if (tooLarge) {
|
|
1047
|
+
throw new HttpError("JSON request body is too large.", 413);
|
|
1048
|
+
}
|
|
1049
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1050
|
+
try {
|
|
1051
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
throw new HttpError("Invalid JSON request body.", 400);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
function securityHeaders() {
|
|
1058
|
+
return {
|
|
1059
|
+
"content-security-policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
1060
|
+
"referrer-policy": "no-referrer",
|
|
1061
|
+
"x-content-type-options": "nosniff",
|
|
1062
|
+
"x-frame-options": "DENY"
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
function listen(server, port, host) {
|
|
1066
|
+
return new Promise((resolveListen, reject) => {
|
|
1067
|
+
const onError = (error) => {
|
|
1068
|
+
server.off("listening", onListening);
|
|
1069
|
+
reject(error);
|
|
1070
|
+
};
|
|
1071
|
+
const onListening = () => {
|
|
1072
|
+
server.off("error", onError);
|
|
1073
|
+
resolveListen();
|
|
1074
|
+
};
|
|
1075
|
+
server.once("error", onError);
|
|
1076
|
+
server.listen(port, host, () => {
|
|
1077
|
+
onListening();
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
async function listenWithFallback(server, port, host, allowFallback) {
|
|
1082
|
+
try {
|
|
1083
|
+
await listen(server, port, host);
|
|
1084
|
+
}
|
|
1085
|
+
catch (error) {
|
|
1086
|
+
if (allowFallback && isAddressInUse(error)) {
|
|
1087
|
+
await listen(server, 0, host);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
throw error;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function closeServer(server) {
|
|
1094
|
+
return new Promise((resolveClose, reject) => {
|
|
1095
|
+
server.close((error) => {
|
|
1096
|
+
if (error)
|
|
1097
|
+
reject(error);
|
|
1098
|
+
else
|
|
1099
|
+
resolveClose();
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
function openBrowser(url) {
|
|
1104
|
+
const [command, args] = process.platform === "darwin"
|
|
1105
|
+
? ["open", [url]]
|
|
1106
|
+
: process.platform === "win32"
|
|
1107
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
1108
|
+
: ["xdg-open", [url]];
|
|
1109
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
1110
|
+
child.unref();
|
|
1111
|
+
}
|
|
1112
|
+
function openFile(path) {
|
|
1113
|
+
const [command, args] = process.platform === "darwin"
|
|
1114
|
+
? ["open", [path]]
|
|
1115
|
+
: process.platform === "win32"
|
|
1116
|
+
? ["cmd", ["/c", "start", "", path]]
|
|
1117
|
+
: ["xdg-open", [path]];
|
|
1118
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
1119
|
+
child.unref();
|
|
1120
|
+
}
|
|
1121
|
+
async function selectSystemDirectory() {
|
|
1122
|
+
if (process.platform === "darwin") {
|
|
1123
|
+
return runDirectoryPicker("osascript", [
|
|
1124
|
+
"-e",
|
|
1125
|
+
'POSIX path of (choose folder with prompt "Choose a repository folder for Evidoc")'
|
|
1126
|
+
]);
|
|
1127
|
+
}
|
|
1128
|
+
if (process.platform === "win32") {
|
|
1129
|
+
const command = [
|
|
1130
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
1131
|
+
"$dialog = New-Object System.Windows.Forms.FolderBrowserDialog;",
|
|
1132
|
+
"$dialog.Description = 'Choose a repository folder for Evidoc';",
|
|
1133
|
+
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
|
|
1134
|
+
" [Console]::Out.WriteLine($dialog.SelectedPath)",
|
|
1135
|
+
"}"
|
|
1136
|
+
].join(" ");
|
|
1137
|
+
return runDirectoryPicker("powershell.exe", ["-NoProfile", "-STA", "-Command", command]);
|
|
1138
|
+
}
|
|
1139
|
+
try {
|
|
1140
|
+
return await runDirectoryPicker("zenity", [
|
|
1141
|
+
"--file-selection",
|
|
1142
|
+
"--directory",
|
|
1143
|
+
"--title=Choose a repository folder for Evidoc"
|
|
1144
|
+
]);
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
if (!isCommandMissing(error))
|
|
1148
|
+
throw error;
|
|
1149
|
+
}
|
|
1150
|
+
try {
|
|
1151
|
+
return await runDirectoryPicker("kdialog", ["--getexistingdirectory", process.cwd(), "Choose a repository folder for Evidoc"]);
|
|
1152
|
+
}
|
|
1153
|
+
catch (error) {
|
|
1154
|
+
if (!isCommandMissing(error))
|
|
1155
|
+
throw error;
|
|
1156
|
+
}
|
|
1157
|
+
throw new Error("No supported system folder picker was found. Install zenity or kdialog, or paste a path manually.");
|
|
1158
|
+
}
|
|
1159
|
+
async function runDirectoryPicker(command, args) {
|
|
1160
|
+
const result = await runCommand(command, args);
|
|
1161
|
+
const selected = result.stdout.trim();
|
|
1162
|
+
if (result.exitCode === 0)
|
|
1163
|
+
return selected ? resolve(selected) : undefined;
|
|
1164
|
+
if (result.exitCode === 1)
|
|
1165
|
+
return undefined;
|
|
1166
|
+
throw new Error(result.stderr.trim() || `${command} exited with code ${result.exitCode}`);
|
|
1167
|
+
}
|
|
1168
|
+
function runCommand(command, args) {
|
|
1169
|
+
return new Promise((resolveRun, reject) => {
|
|
1170
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1171
|
+
const stdout = [];
|
|
1172
|
+
const stderr = [];
|
|
1173
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
1174
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
1175
|
+
child.once("error", reject);
|
|
1176
|
+
child.once("close", (exitCode) => {
|
|
1177
|
+
resolveRun({
|
|
1178
|
+
exitCode: exitCode ?? 0,
|
|
1179
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
1180
|
+
stderr: Buffer.concat(stderr).toString("utf8")
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
function isAllowedRequestOrigin(request) {
|
|
1186
|
+
const originHeader = request.headers.origin;
|
|
1187
|
+
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
|
1188
|
+
if (!origin)
|
|
1189
|
+
return true;
|
|
1190
|
+
const host = request.headers.host;
|
|
1191
|
+
if (!host)
|
|
1192
|
+
return false;
|
|
1193
|
+
try {
|
|
1194
|
+
const parsed = new URL(origin);
|
|
1195
|
+
return parsed.host === host && (parsed.protocol === "http:" || parsed.protocol === "https:");
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
1202
|
+
return new Promise((resolveTimeout, reject) => {
|
|
1203
|
+
const timer = setTimeout(() => reject(new HttpError(message, 504)), timeoutMs);
|
|
1204
|
+
promise.then((value) => {
|
|
1205
|
+
clearTimeout(timer);
|
|
1206
|
+
resolveTimeout(value);
|
|
1207
|
+
}, (error) => {
|
|
1208
|
+
clearTimeout(timer);
|
|
1209
|
+
reject(error);
|
|
1210
|
+
});
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
async function exists(path) {
|
|
1214
|
+
try {
|
|
1215
|
+
await access(path);
|
|
1216
|
+
return true;
|
|
1217
|
+
}
|
|
1218
|
+
catch {
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
function numberOrZero(value) {
|
|
1223
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1224
|
+
}
|
|
1225
|
+
function isAddressInUse(error) {
|
|
1226
|
+
return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
|
|
1227
|
+
}
|
|
1228
|
+
function isCommandMissing(error) {
|
|
1229
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1230
|
+
}
|
|
1231
|
+
//# sourceMappingURL=index.js.map
|