@fenglimg/fabric-server 1.0.0 → 1.2.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.
@@ -0,0 +1,851 @@
1
+ // src/services/doctor.ts
2
+ import { createHash as createHash2 } from "crypto";
3
+ import { existsSync, readFileSync as readFileSync2, readdirSync, statSync } from "fs";
4
+ import { readFile as readFile4 } from "fs/promises";
5
+ import { isAbsolute as isAbsolute2, join as join5, posix as posix2, resolve as resolve3 } from "path";
6
+ import { forensicReportSchema } from "@fenglimg/fabric-shared";
7
+ import { detectFramework } from "@fenglimg/fabric-shared/node";
8
+
9
+ // src/meta-reader.ts
10
+ import { readFileSync } from "fs";
11
+ import { join } from "path";
12
+ import { agentsMetaSchema } from "@fenglimg/fabric-shared";
13
+ import { agentsMetaNodeSchema, agentsMetaSchema as agentsMetaSchema2 } from "@fenglimg/fabric-shared";
14
+ var AgentsMetaFileMissingError = class extends Error {
15
+ constructor(metaPath) {
16
+ super(`Fabric agents metadata file is missing: ${metaPath}`);
17
+ this.metaPath = metaPath;
18
+ this.name = "AgentsMetaFileMissingError";
19
+ }
20
+ metaPath;
21
+ code = "FABRIC_META_MISSING";
22
+ };
23
+ var AgentsMetaInvalidError = class extends Error {
24
+ constructor(metaPath, cause) {
25
+ const detail = cause instanceof Error ? cause.message : String(cause);
26
+ super(`Fabric agents metadata file is invalid: ${metaPath}. ${detail}`);
27
+ this.metaPath = metaPath;
28
+ this.name = "AgentsMetaInvalidError";
29
+ }
30
+ metaPath;
31
+ code = "FABRIC_META_INVALID";
32
+ };
33
+ function getAgentsMetaPath(projectRoot) {
34
+ return join(projectRoot, ".fabric", "agents.meta.json");
35
+ }
36
+ function resolveProjectRoot() {
37
+ return process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
38
+ }
39
+ function readAgentsMeta(projectRoot) {
40
+ const metaPath = getAgentsMetaPath(projectRoot);
41
+ let raw;
42
+ try {
43
+ raw = readFileSync(metaPath, "utf8");
44
+ } catch (error) {
45
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
46
+ throw new AgentsMetaFileMissingError(metaPath);
47
+ }
48
+ throw error;
49
+ }
50
+ try {
51
+ return agentsMetaSchema.parse(JSON.parse(raw));
52
+ } catch (error) {
53
+ throw new AgentsMetaInvalidError(metaPath, error);
54
+ }
55
+ }
56
+
57
+ // src/services/audit-log.ts
58
+ import { appendFile, mkdir, readFile } from "fs/promises";
59
+ import { isAbsolute, join as join2, posix, relative, resolve as resolve2 } from "path";
60
+
61
+ // src/services/_shared.ts
62
+ import { resolve, sep } from "path";
63
+ import { createHash } from "crypto";
64
+ import { rename, writeFile } from "fs/promises";
65
+ var FABRIC_DIR = ".fabric";
66
+ var HUMAN_LOCK_FILE = "human-lock.json";
67
+ var LEDGER_FILE = ".intent-ledger.jsonl";
68
+ async function atomicWriteText(path, content) {
69
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
70
+ await writeFile(tempPath, content, "utf8");
71
+ await rename(tempPath, path);
72
+ }
73
+ function sha256(content) {
74
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
75
+ }
76
+ function isNodeError(error) {
77
+ return error instanceof Error;
78
+ }
79
+ function assertPathWithinProjectRoot(projectRoot, file) {
80
+ const normalizedProjectRoot = resolve(projectRoot);
81
+ const absolutePath = resolve(normalizedProjectRoot, file);
82
+ const rootPrefix = normalizedProjectRoot.endsWith(sep) ? normalizedProjectRoot : `${normalizedProjectRoot}${sep}`;
83
+ if (!absolutePath.startsWith(rootPrefix)) {
84
+ throw new Error(`Path escapes project root: ${file}`);
85
+ }
86
+ return absolutePath;
87
+ }
88
+
89
+ // src/services/audit-log.ts
90
+ var AUDIT_LOG_FILE = `${FABRIC_DIR}/audit.jsonl`;
91
+ var DEFAULT_AUDIT_WINDOW_MS = 5 * 60 * 1e3;
92
+ async function appendGetRulesAuditEvent(projectRoot, input) {
93
+ const entry = {
94
+ kind: "audit-event",
95
+ event: "get_rules",
96
+ ts: input.ts ?? Date.now(),
97
+ path: normalizeAuditPath(projectRoot, input.path),
98
+ client_hash: input.client_hash
99
+ };
100
+ await appendAuditLogEntries(projectRoot, [entry]);
101
+ return entry;
102
+ }
103
+ async function appendEditIntentAuditEvents(projectRoot, input) {
104
+ const ts = input.ts ?? Date.now();
105
+ const windowMs = input.window_ms ?? DEFAULT_AUDIT_WINDOW_MS;
106
+ const getRulesEntries = (await readAuditLog(projectRoot)).filter(isGetRulesAuditEntry);
107
+ const entries = input.affected_paths.map((affectedPath) => {
108
+ const path = normalizeAuditPath(projectRoot, affectedPath);
109
+ const matchedGetRules = findPrecedingGetRulesEvent(getRulesEntries, path, ts, windowMs);
110
+ return {
111
+ kind: "audit-event",
112
+ event: "edit_intent",
113
+ ts,
114
+ path,
115
+ compliant: matchedGetRules !== null,
116
+ intent: input.intent,
117
+ ledger_entry_id: input.ledger_entry_id,
118
+ matched_get_rules_ts: matchedGetRules?.ts ?? null,
119
+ window_ms: windowMs
120
+ };
121
+ });
122
+ if (entries.length === 0) {
123
+ return [];
124
+ }
125
+ await appendAuditLogEntries(projectRoot, entries);
126
+ return entries;
127
+ }
128
+ async function readAuditLog(projectRoot) {
129
+ const auditPath = join2(projectRoot, AUDIT_LOG_FILE);
130
+ let raw;
131
+ try {
132
+ raw = await readFile(auditPath, "utf8");
133
+ } catch (error) {
134
+ if (isNodeError(error) && error.code === "ENOENT") {
135
+ return [];
136
+ }
137
+ throw error;
138
+ }
139
+ return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0).map(parseAuditLogLine).filter((entry) => entry !== null);
140
+ }
141
+ function findPrecedingGetRulesEvent(entries, path, ts, windowMs) {
142
+ let matched = null;
143
+ for (const entry of entries) {
144
+ if (entry.path !== path) {
145
+ continue;
146
+ }
147
+ if (entry.ts > ts) {
148
+ continue;
149
+ }
150
+ if (ts - entry.ts > windowMs) {
151
+ continue;
152
+ }
153
+ if (matched === null || entry.ts > matched.ts) {
154
+ matched = entry;
155
+ }
156
+ }
157
+ return matched;
158
+ }
159
+ function normalizeAuditPath(projectRoot, value) {
160
+ const normalizedProjectRoot = resolve2(projectRoot);
161
+ const candidate = isAbsolute(value) ? resolve2(value) : resolve2(normalizedProjectRoot, value);
162
+ const relativePath = relative(normalizedProjectRoot, candidate);
163
+ if (relativePath.length > 0 && relativePath !== "." && !relativePath.startsWith("..") && !isAbsolute(relativePath)) {
164
+ return posix.normalize(relativePath.split("\\").join("/"));
165
+ }
166
+ return posix.normalize(value.replaceAll("\\", "/"));
167
+ }
168
+ function isGetRulesAuditEntry(entry) {
169
+ return entry.event === "get_rules";
170
+ }
171
+ async function appendAuditLogEntries(projectRoot, entries) {
172
+ const auditPath = join2(projectRoot, AUDIT_LOG_FILE);
173
+ const auditDir = join2(projectRoot, FABRIC_DIR);
174
+ await mkdir(auditDir, { recursive: true });
175
+ await appendFile(auditPath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}
176
+ `, "utf8");
177
+ }
178
+ function parseAuditLogLine(line) {
179
+ try {
180
+ const parsed = JSON.parse(line);
181
+ if (parsed.kind !== "audit-event" || typeof parsed.ts !== "number" || typeof parsed.path !== "string") {
182
+ return null;
183
+ }
184
+ if (parsed.event === "get_rules") {
185
+ return {
186
+ kind: "audit-event",
187
+ event: "get_rules",
188
+ ts: parsed.ts,
189
+ path: parsed.path,
190
+ client_hash: typeof parsed.client_hash === "string" ? parsed.client_hash : void 0
191
+ };
192
+ }
193
+ if (parsed.event === "edit_intent" && typeof parsed.compliant === "boolean" && typeof parsed.intent === "string" && typeof parsed.ledger_entry_id === "string" && (typeof parsed.matched_get_rules_ts === "number" || parsed.matched_get_rules_ts === null) && typeof parsed.window_ms === "number") {
194
+ return {
195
+ kind: "audit-event",
196
+ event: "edit_intent",
197
+ ts: parsed.ts,
198
+ path: parsed.path,
199
+ compliant: parsed.compliant,
200
+ intent: parsed.intent,
201
+ ledger_entry_id: parsed.ledger_entry_id,
202
+ matched_get_rules_ts: parsed.matched_get_rules_ts,
203
+ window_ms: parsed.window_ms
204
+ };
205
+ }
206
+ return null;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ // src/services/read-human-lock.ts
213
+ import { readFile as readFile2 } from "fs/promises";
214
+ import { join as join3 } from "path";
215
+ import { humanLockEntrySchema } from "@fenglimg/fabric-shared";
216
+ async function readHumanLock(projectRoot) {
217
+ const document = await readHumanLockDocument(projectRoot);
218
+ return await Promise.all(
219
+ document.locked.map(async (entry) => {
220
+ const currentHash = await hashHumanLockedContent(projectRoot, entry);
221
+ return {
222
+ ...entry,
223
+ drift: currentHash !== entry.hash,
224
+ current_hash: currentHash
225
+ };
226
+ })
227
+ );
228
+ }
229
+ async function readHumanLockEntry(projectRoot, file) {
230
+ const entries = await readHumanLock(projectRoot);
231
+ return entries.find((entry) => entry.file === file) ?? null;
232
+ }
233
+ async function readHumanLockDocument(projectRoot) {
234
+ const humanLockPath = join3(projectRoot, FABRIC_DIR, HUMAN_LOCK_FILE);
235
+ const raw = await readFile2(humanLockPath, "utf8");
236
+ const parsed = JSON.parse(raw);
237
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
238
+ throw new Error(`Fabric human lock file is invalid: ${humanLockPath}`);
239
+ }
240
+ const rawObject = parsed;
241
+ const lockedResult = humanLockEntrySchema.array().safeParse(rawObject.locked ?? []);
242
+ if (!lockedResult.success) {
243
+ throw new Error(`Fabric human lock file is invalid: ${humanLockPath}`);
244
+ }
245
+ return {
246
+ path: humanLockPath,
247
+ rawObject,
248
+ locked: lockedResult.data
249
+ };
250
+ }
251
+ async function hashHumanLockedContent(projectRoot, entry) {
252
+ const targetPath = assertPathWithinProjectRoot(projectRoot, entry.file);
253
+ let content;
254
+ try {
255
+ content = await readFile2(targetPath, "utf8");
256
+ } catch (error) {
257
+ if (isNodeError(error) && error.code === "ENOENT") {
258
+ return "missing";
259
+ }
260
+ throw error;
261
+ }
262
+ const lines = content.split(/\r?\n/);
263
+ const slice = lines.slice(Math.max(entry.start_line - 1, 0), Math.max(entry.end_line, 0)).join("\n");
264
+ return sha256(slice);
265
+ }
266
+
267
+ // src/services/read-ledger.ts
268
+ import { randomUUID } from "crypto";
269
+ import { appendFile as appendFile2, readFile as readFile3 } from "fs/promises";
270
+ import { join as join4 } from "path";
271
+ import { ledgerEntrySchema } from "@fenglimg/fabric-shared";
272
+ async function readLedger(projectRoot, options = {}) {
273
+ const ledgerPath = join4(projectRoot, LEDGER_FILE);
274
+ let raw;
275
+ try {
276
+ raw = await readFile3(ledgerPath, "utf8");
277
+ } catch (error) {
278
+ if (isNodeError(error) && error.code === "ENOENT") {
279
+ return [];
280
+ }
281
+ throw error;
282
+ }
283
+ return raw.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0).map((line, index) => parseLedgerLine(line, index)).filter((entry) => entry !== null).filter((entry) => options.source === void 0 || entry.source === options.source).filter((entry) => options.since === void 0 || entry.ts >= options.since);
284
+ }
285
+ async function appendLedgerEntry(projectRoot, entry) {
286
+ const ledgerPath = join4(projectRoot, LEDGER_FILE);
287
+ const nextEntry = ledgerEntrySchema.parse({
288
+ ...entry,
289
+ id: entry.id ?? `ledger:${randomUUID()}`
290
+ });
291
+ await appendFile2(ledgerPath, `${JSON.stringify(nextEntry)}
292
+ `, "utf8");
293
+ return nextEntry;
294
+ }
295
+ function parseLedgerLine(line, index) {
296
+ try {
297
+ const parsed = JSON.parse(line);
298
+ if (parsed.kind === "mcp-event") {
299
+ return null;
300
+ }
301
+ const result = ledgerEntrySchema.safeParse(parsed);
302
+ if (!result.success) {
303
+ return null;
304
+ }
305
+ return {
306
+ ...result.data,
307
+ id: result.data.id ?? createDerivedId(index, line)
308
+ };
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+ function createDerivedId(index, line) {
314
+ return `ledger:${index + 1}:${sha256(line).slice("sha256:".length)}`;
315
+ }
316
+
317
+ // src/services/doctor.ts
318
+ var SCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
319
+ var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
320
+ ".fabric",
321
+ ".git",
322
+ ".next",
323
+ ".turbo",
324
+ "Library",
325
+ "Temp",
326
+ "build",
327
+ "coverage",
328
+ "dist",
329
+ "node_modules"
330
+ ]);
331
+ var LEDGER_WARN_AFTER_MS = 3 * 24 * 60 * 60 * 1e3;
332
+ var LEDGER_ERROR_AFTER_MS = 7 * 24 * 60 * 60 * 1e3;
333
+ async function runDoctorReport(target) {
334
+ const projectRoot = normalizeTarget(target);
335
+ const framework = detectFramework(projectRoot);
336
+ const entryPoints = collectEntryPoints(projectRoot);
337
+ const [savedForensic, metaSnapshot, humanLockSnapshot, ledgerSnapshot, auditReport] = await Promise.all([
338
+ readSavedForensic(projectRoot),
339
+ inspectMetaRevision(projectRoot),
340
+ inspectHumanLock(projectRoot),
341
+ inspectLedger(projectRoot),
342
+ runDoctorAuditReport(projectRoot)
343
+ ]);
344
+ const checks = [
345
+ createForensicCheck(savedForensic, framework, entryPoints),
346
+ createFrameworkCheck(savedForensic, framework, entryPoints),
347
+ createMetaRevisionCheck(metaSnapshot),
348
+ createProtectedPathsCheck(humanLockSnapshot),
349
+ createLedgerCheck(ledgerSnapshot)
350
+ ];
351
+ if (!auditReport.skipped) {
352
+ checks.push(createAuditCheck(auditReport));
353
+ }
354
+ return {
355
+ status: reduceStatus(checks.map((check) => check.status)),
356
+ checks,
357
+ summary: {
358
+ target: projectRoot,
359
+ framework: {
360
+ kind: framework.kind,
361
+ version: framework.version,
362
+ subkind: framework.subkind
363
+ },
364
+ entryPoints,
365
+ driftCount: humanLockSnapshot.driftCount,
366
+ protectedPathCount: humanLockSnapshot.protectedPathCount,
367
+ protectedPathsIntact: humanLockSnapshot.present && humanLockSnapshot.driftCount === 0,
368
+ lastLedgerEntryTs: ledgerSnapshot.lastEntryTs,
369
+ lastLedgerEntryAgeMs: ledgerSnapshot.lastEntryAgeMs,
370
+ metaRevision: metaSnapshot.revision,
371
+ audit: auditReport.skipped ? null : {
372
+ enabled: true,
373
+ mode: auditReport.mode,
374
+ checkedPathCount: auditReport.checkedPathCount,
375
+ violationCount: auditReport.violationCount,
376
+ windowMs: auditReport.windowMs
377
+ }
378
+ },
379
+ audit: auditReport.skipped ? null : auditReport
380
+ };
381
+ }
382
+ async function runDoctorAuditReport(target, options = {}) {
383
+ const projectRoot = normalizeTarget(target);
384
+ const mode = options.mode ?? readDoctorAuditMode(projectRoot);
385
+ const windowMs = options.windowMs ?? DEFAULT_AUDIT_WINDOW_MS;
386
+ if (mode === "off" && options.force !== true) {
387
+ return {
388
+ mode,
389
+ skipped: true,
390
+ windowMs,
391
+ checkedPathCount: 0,
392
+ violationCount: 0,
393
+ violations: []
394
+ };
395
+ }
396
+ const [ledgerEntries, auditEntries] = await Promise.all([
397
+ readLedger(projectRoot, { source: "ai" }),
398
+ readAuditLog(projectRoot)
399
+ ]);
400
+ const getRulesEntries = auditEntries.filter(
401
+ (entry) => entry.event === "get_rules"
402
+ );
403
+ const { checkedPathCount, violations } = collectAuditViolations(
404
+ projectRoot,
405
+ ledgerEntries,
406
+ getRulesEntries,
407
+ windowMs
408
+ );
409
+ return {
410
+ mode,
411
+ skipped: false,
412
+ windowMs,
413
+ checkedPathCount,
414
+ violationCount: violations.length,
415
+ violations
416
+ };
417
+ }
418
+ function createForensicCheck(forensic, framework, entryPoints) {
419
+ if (!forensic.present) {
420
+ return {
421
+ name: "Forensic snapshot",
422
+ status: "error",
423
+ message: `${forensic.reason} Live scan detects ${formatFramework(framework)} with ${entryPoints.length} entry point${entryPoints.length === 1 ? "" : "s"}.`
424
+ };
425
+ }
426
+ return {
427
+ name: "Forensic snapshot",
428
+ status: "ok",
429
+ message: `Loaded .fabric/forensic.json for ${formatFramework(forensic.report.framework)} with ${forensic.report.entry_points.length} recorded entry point${forensic.report.entry_points.length === 1 ? "" : "s"}.`
430
+ };
431
+ }
432
+ function createFrameworkCheck(forensic, framework, entryPoints) {
433
+ if (framework.kind === "unknown") {
434
+ return {
435
+ name: "Framework fingerprint",
436
+ status: "warn",
437
+ message: "Unable to identify the project framework from current files."
438
+ };
439
+ }
440
+ if (!forensic.present) {
441
+ return {
442
+ name: "Framework fingerprint",
443
+ status: "warn",
444
+ message: `Live detection sees ${formatFramework(framework)} and ${entryPoints.length} entry point${entryPoints.length === 1 ? "" : "s"}, but no forensic baseline exists yet.`
445
+ };
446
+ }
447
+ const matches = forensic.report.framework.kind === framework.kind && forensic.report.framework.version === framework.version && forensic.report.framework.subkind === framework.subkind;
448
+ if (!matches) {
449
+ return {
450
+ name: "Framework fingerprint",
451
+ status: "warn",
452
+ message: `Forensic baseline says ${formatFramework(forensic.report.framework)}; live scan says ${formatFramework(framework)}.`
453
+ };
454
+ }
455
+ return {
456
+ name: "Framework fingerprint",
457
+ status: "ok",
458
+ message: `Framework baseline matches live scan: ${formatFramework(framework)} \xB7 ${entryPoints.length} current entry point${entryPoints.length === 1 ? "" : "s"}.`
459
+ };
460
+ }
461
+ function createMetaRevisionCheck(snapshot) {
462
+ if (!snapshot.present) {
463
+ return {
464
+ name: "Meta revision",
465
+ status: "error",
466
+ message: snapshot.unexpectedError ?? "agents.meta.json is missing."
467
+ };
468
+ }
469
+ if (snapshot.driftCount > 0 || snapshot.missingFiles.length > 0) {
470
+ const parts = [
471
+ `${snapshot.driftCount} tracked AGENTS file drift`,
472
+ snapshot.missingFiles.length > 0 ? `${snapshot.missingFiles.length} missing tracked file` : null
473
+ ].filter((part) => part !== null);
474
+ return {
475
+ name: "Meta revision",
476
+ status: "error",
477
+ message: `agents.meta.json revision ${snapshot.revision} is stale: ${parts.join(" \xB7 ")}.`
478
+ };
479
+ }
480
+ return {
481
+ name: "Meta revision",
482
+ status: "ok",
483
+ message: `agents.meta.json revision ${snapshot.revision} matches ${snapshot.nodeCount} tracked AGENTS file${snapshot.nodeCount === 1 ? "" : "s"}.`
484
+ };
485
+ }
486
+ function createProtectedPathsCheck(snapshot) {
487
+ if (!snapshot.present) {
488
+ return {
489
+ name: "Protected paths",
490
+ status: "warn",
491
+ message: snapshot.reason
492
+ };
493
+ }
494
+ if (snapshot.driftCount > 0) {
495
+ return {
496
+ name: "Protected paths",
497
+ status: "warn",
498
+ message: `${snapshot.driftCount} of ${snapshot.protectedPathCount} protected path${snapshot.protectedPathCount === 1 ? "" : "s"} drifted from approved hashes.`
499
+ };
500
+ }
501
+ return {
502
+ name: "Protected paths",
503
+ status: "ok",
504
+ message: `${snapshot.protectedPathCount} protected path${snapshot.protectedPathCount === 1 ? "" : "s"} intact with zero hash drift.`
505
+ };
506
+ }
507
+ function createLedgerCheck(snapshot) {
508
+ if (snapshot.lastEntryTs === null || snapshot.lastEntryAgeMs === null) {
509
+ return {
510
+ name: "Intent ledger",
511
+ status: "warn",
512
+ message: "No ledger entries recorded yet."
513
+ };
514
+ }
515
+ if (snapshot.lastEntryAgeMs >= LEDGER_ERROR_AFTER_MS) {
516
+ return {
517
+ name: "Intent ledger",
518
+ status: "error",
519
+ message: `Last ledger entry is ${formatAge(snapshot.lastEntryAgeMs)} old (${new Date(snapshot.lastEntryTs).toISOString()}).`
520
+ };
521
+ }
522
+ if (snapshot.lastEntryAgeMs >= LEDGER_WARN_AFTER_MS) {
523
+ return {
524
+ name: "Intent ledger",
525
+ status: "warn",
526
+ message: `Last ledger entry is ${formatAge(snapshot.lastEntryAgeMs)} old (${new Date(snapshot.lastEntryTs).toISOString()}).`
527
+ };
528
+ }
529
+ return {
530
+ name: "Intent ledger",
531
+ status: "ok",
532
+ message: `Last ledger entry is ${formatAge(snapshot.lastEntryAgeMs)} old (${snapshot.count} total entr${snapshot.count === 1 ? "y" : "ies"}).`
533
+ };
534
+ }
535
+ function createAuditCheck(report) {
536
+ if (report.checkedPathCount === 0) {
537
+ return {
538
+ name: "Rules fetch audit",
539
+ status: "warn",
540
+ message: "No AI edit intents recorded yet for compliance audit."
541
+ };
542
+ }
543
+ if (report.violationCount > 0) {
544
+ return {
545
+ name: "Rules fetch audit",
546
+ status: report.mode === "strict" ? "error" : "warn",
547
+ message: `${report.violationCount} edit path${report.violationCount === 1 ? "" : "s"} lack a preceding fab_get_rules call within ${formatDuration(report.windowMs)}.`
548
+ };
549
+ }
550
+ return {
551
+ name: "Rules fetch audit",
552
+ status: "ok",
553
+ message: `All ${report.checkedPathCount} audited edit path${report.checkedPathCount === 1 ? "" : "s"} have a preceding fab_get_rules call within ${formatDuration(report.windowMs)}.`
554
+ };
555
+ }
556
+ async function readSavedForensic(projectRoot) {
557
+ const forensicPath = join5(projectRoot, ".fabric", "forensic.json");
558
+ try {
559
+ const raw = await readFile4(forensicPath, "utf8");
560
+ const parsed = forensicReportSchema.safeParse(JSON.parse(raw));
561
+ if (!parsed.success) {
562
+ return {
563
+ present: false,
564
+ reason: "forensic.json is invalid."
565
+ };
566
+ }
567
+ return {
568
+ present: true,
569
+ report: parsed.data
570
+ };
571
+ } catch (error) {
572
+ if (isMissingFileError(error)) {
573
+ return {
574
+ present: false,
575
+ reason: ".fabric/forensic.json is missing."
576
+ };
577
+ }
578
+ return {
579
+ present: false,
580
+ reason: error instanceof Error ? error.message : String(error)
581
+ };
582
+ }
583
+ }
584
+ async function inspectMetaRevision(projectRoot) {
585
+ try {
586
+ const meta = readAgentsMeta(projectRoot);
587
+ const entries = Object.entries(meta.nodes).sort(([left], [right]) => left.localeCompare(right));
588
+ const missingFiles = [];
589
+ let driftCount = 0;
590
+ const revisionSource = entries.map(([, node]) => {
591
+ const absolutePath = join5(projectRoot, node.file);
592
+ if (!existsSync(absolutePath)) {
593
+ missingFiles.push(node.file);
594
+ driftCount += 1;
595
+ return "missing";
596
+ }
597
+ const actualHash = sha2562(readFileSync2(absolutePath, "utf8"));
598
+ if (actualHash !== node.hash) {
599
+ driftCount += 1;
600
+ }
601
+ return actualHash;
602
+ }).join("");
603
+ const revision = sha2562(revisionSource);
604
+ return {
605
+ present: true,
606
+ revision: meta.revision,
607
+ nodeCount: entries.length,
608
+ driftCount: revision === meta.revision ? driftCount : Math.max(driftCount, 1),
609
+ missingFiles
610
+ };
611
+ } catch (error) {
612
+ return {
613
+ present: false,
614
+ revision: null,
615
+ nodeCount: 0,
616
+ driftCount: 0,
617
+ missingFiles: [],
618
+ unexpectedError: error instanceof Error ? error.message : String(error)
619
+ };
620
+ }
621
+ }
622
+ async function inspectHumanLock(projectRoot) {
623
+ try {
624
+ const entries = await readHumanLock(projectRoot);
625
+ return {
626
+ present: true,
627
+ driftCount: entries.filter((entry) => entry.drift).length,
628
+ protectedPathCount: entries.length
629
+ };
630
+ } catch (error) {
631
+ if (isMissingFileError(error)) {
632
+ return {
633
+ present: false,
634
+ driftCount: 0,
635
+ protectedPathCount: 0,
636
+ reason: ".fabric/human-lock.json is missing; no protected paths are being tracked."
637
+ };
638
+ }
639
+ return {
640
+ present: false,
641
+ driftCount: 0,
642
+ protectedPathCount: 0,
643
+ reason: error instanceof Error ? error.message : String(error)
644
+ };
645
+ }
646
+ }
647
+ async function inspectLedger(projectRoot) {
648
+ const entries = await readLedger(projectRoot);
649
+ const lastEntry = entries.reduce(
650
+ (latest, entry) => latest === null || entry.ts > latest ? entry.ts : latest,
651
+ null
652
+ );
653
+ return {
654
+ count: entries.length,
655
+ lastEntryTs: lastEntry,
656
+ lastEntryAgeMs: lastEntry === null ? null : Math.max(Date.now() - lastEntry, 0)
657
+ };
658
+ }
659
+ function collectAuditViolations(projectRoot, ledgerEntries, getRulesEntries, windowMs) {
660
+ let checkedPathCount = 0;
661
+ const violations = [];
662
+ for (const entry of ledgerEntries) {
663
+ for (const affectedPath of entry.affected_paths) {
664
+ const normalizedPath = normalizeAuditPath(projectRoot, affectedPath);
665
+ const matched = findPrecedingGetRulesEvent(getRulesEntries, normalizedPath, entry.ts, windowMs);
666
+ checkedPathCount += 1;
667
+ if (matched !== null) {
668
+ continue;
669
+ }
670
+ violations.push({
671
+ editTs: entry.ts,
672
+ entryId: entry.id,
673
+ intent: entry.intent,
674
+ lastGetRulesTs: findLatestGetRulesTs(getRulesEntries, normalizedPath, entry.ts),
675
+ path: normalizedPath
676
+ });
677
+ }
678
+ }
679
+ return {
680
+ checkedPathCount,
681
+ violations
682
+ };
683
+ }
684
+ function findLatestGetRulesTs(entries, path, ts) {
685
+ let latest = null;
686
+ for (const entry of entries) {
687
+ if (entry.path !== path || entry.ts > ts) {
688
+ continue;
689
+ }
690
+ latest = latest === null || entry.ts > latest ? entry.ts : latest;
691
+ }
692
+ return latest;
693
+ }
694
+ function readDoctorAuditMode(projectRoot) {
695
+ const configPath = join5(projectRoot, "fabric.config.json");
696
+ try {
697
+ const parsed = JSON.parse(readFileSync2(configPath, "utf8"));
698
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
699
+ return "off";
700
+ }
701
+ const configuredMode = readAuditModeValue(parsed.auditMode) ?? readAuditModeValue(parsed.audit_mode);
702
+ return configuredMode ?? "off";
703
+ } catch (error) {
704
+ if (isMissingFileError(error)) {
705
+ return "off";
706
+ }
707
+ return "off";
708
+ }
709
+ }
710
+ function normalizeTarget(targetInput) {
711
+ return isAbsolute2(targetInput) ? targetInput : resolve3(process.cwd(), targetInput);
712
+ }
713
+ function collectEntryPoints(root) {
714
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
715
+ return [];
716
+ }
717
+ const entries = [];
718
+ const stack = [root];
719
+ while (stack.length > 0) {
720
+ const current = stack.pop();
721
+ if (current === void 0) {
722
+ continue;
723
+ }
724
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
725
+ const absolutePath = join5(current, entry.name);
726
+ const relativePath = posix2.normalize(absolutePath.slice(root.length + 1).split("\\").join("/"));
727
+ if (relativePath.length === 0) {
728
+ continue;
729
+ }
730
+ if (entry.isDirectory()) {
731
+ if (IGNORED_DIRECTORIES.has(entry.name)) {
732
+ continue;
733
+ }
734
+ stack.push(absolutePath);
735
+ continue;
736
+ }
737
+ if (!entry.isFile()) {
738
+ continue;
739
+ }
740
+ const reason = getEntryPointReason(relativePath);
741
+ if (reason !== null) {
742
+ entries.push({
743
+ path: relativePath,
744
+ reason
745
+ });
746
+ }
747
+ }
748
+ }
749
+ return entries.sort((left, right) => left.path.localeCompare(right.path));
750
+ }
751
+ function getEntryPointReason(relativePath) {
752
+ const extension = relativePath.slice(relativePath.lastIndexOf("."));
753
+ if (!SCRIPT_EXTENSIONS.has(extension)) {
754
+ return null;
755
+ }
756
+ const directory = posix2.dirname(relativePath);
757
+ const fileName = posix2.basename(relativePath);
758
+ const fileBase = fileName.slice(0, Math.max(fileName.lastIndexOf("."), 0));
759
+ if (directory === "assets/scripts" || directory === "scripts") {
760
+ return "top-level script";
761
+ }
762
+ if (directory === "src" && /^(App|app|index|main)$/.test(fileBase)) {
763
+ return "application entry";
764
+ }
765
+ if ((directory === "app" || directory.startsWith("app/")) && /^(layout|page|route)$/.test(fileBase)) {
766
+ return "next app route";
767
+ }
768
+ if ((directory === "pages" || directory.startsWith("pages/")) && fileName !== "_app.d.ts") {
769
+ return "next page route";
770
+ }
771
+ return null;
772
+ }
773
+ function reduceStatus(statuses) {
774
+ if (statuses.includes("error")) {
775
+ return "error";
776
+ }
777
+ if (statuses.includes("warn")) {
778
+ return "warn";
779
+ }
780
+ return "ok";
781
+ }
782
+ function formatFramework(framework) {
783
+ const pieces = [framework.kind, framework.version !== "unknown" ? framework.version : null, framework.subkind].filter((piece) => piece !== null && piece !== "unknown");
784
+ return pieces.length > 0 ? pieces.join(" \xB7 ") : "unknown";
785
+ }
786
+ function formatAge(ageMs) {
787
+ const seconds = Math.floor(ageMs / 1e3);
788
+ if (seconds < 60) {
789
+ return `${seconds}s`;
790
+ }
791
+ const minutes = Math.floor(seconds / 60);
792
+ if (minutes < 60) {
793
+ return `${minutes}m`;
794
+ }
795
+ const hours = Math.floor(minutes / 60);
796
+ if (hours < 48) {
797
+ return `${hours}h`;
798
+ }
799
+ const days = Math.floor(hours / 24);
800
+ if (days < 14) {
801
+ return `${days}d`;
802
+ }
803
+ return `${Math.floor(days / 7)}w`;
804
+ }
805
+ function formatDuration(durationMs) {
806
+ const minutes = Math.floor(durationMs / (60 * 1e3));
807
+ if (minutes < 1) {
808
+ return `${Math.max(Math.floor(durationMs / 1e3), 1)}s`;
809
+ }
810
+ if (minutes < 60) {
811
+ return `${minutes}m`;
812
+ }
813
+ const hours = Math.floor(minutes / 60);
814
+ if (hours < 24) {
815
+ return `${hours}h`;
816
+ }
817
+ return `${Math.floor(hours / 24)}d`;
818
+ }
819
+ function readAuditModeValue(value) {
820
+ if (value === "strict" || value === "warn" || value === "off") {
821
+ return value;
822
+ }
823
+ return null;
824
+ }
825
+ function sha2562(content) {
826
+ return `sha256:${createHash2("sha256").update(content).digest("hex")}`;
827
+ }
828
+ function isMissingFileError(error) {
829
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
830
+ }
831
+
832
+ export {
833
+ AgentsMetaFileMissingError,
834
+ AgentsMetaInvalidError,
835
+ resolveProjectRoot,
836
+ readAgentsMeta,
837
+ FABRIC_DIR,
838
+ atomicWriteText,
839
+ sha256,
840
+ assertPathWithinProjectRoot,
841
+ appendGetRulesAuditEvent,
842
+ appendEditIntentAuditEvents,
843
+ readLedger,
844
+ appendLedgerEntry,
845
+ readHumanLock,
846
+ readHumanLockEntry,
847
+ readHumanLockDocument,
848
+ hashHumanLockedContent,
849
+ runDoctorReport,
850
+ runDoctorAuditReport
851
+ };