@git-stunts/git-warp 10.8.0 → 11.2.1

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.
Files changed (70) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +6 -0
  24. package/bin/presenters/text.js +136 -0
  25. package/bin/warp-graph.js +5 -2346
  26. package/index.d.ts +32 -2
  27. package/index.js +2 -0
  28. package/package.json +8 -7
  29. package/src/domain/WarpGraph.js +106 -3252
  30. package/src/domain/errors/QueryError.js +2 -2
  31. package/src/domain/errors/TrustError.js +29 -0
  32. package/src/domain/errors/index.js +1 -0
  33. package/src/domain/services/AuditMessageCodec.js +137 -0
  34. package/src/domain/services/AuditReceiptService.js +471 -0
  35. package/src/domain/services/AuditVerifierService.js +693 -0
  36. package/src/domain/services/HttpSyncServer.js +36 -22
  37. package/src/domain/services/MessageCodecInternal.js +3 -0
  38. package/src/domain/services/MessageSchemaDetector.js +2 -2
  39. package/src/domain/services/SyncAuthService.js +69 -3
  40. package/src/domain/services/WarpMessageCodec.js +4 -1
  41. package/src/domain/trust/TrustCanonical.js +42 -0
  42. package/src/domain/trust/TrustCrypto.js +111 -0
  43. package/src/domain/trust/TrustEvaluator.js +180 -0
  44. package/src/domain/trust/TrustRecordService.js +274 -0
  45. package/src/domain/trust/TrustStateBuilder.js +209 -0
  46. package/src/domain/trust/canonical.js +68 -0
  47. package/src/domain/trust/reasonCodes.js +64 -0
  48. package/src/domain/trust/schemas.js +160 -0
  49. package/src/domain/trust/verdict.js +42 -0
  50. package/src/domain/types/git-cas.d.ts +20 -0
  51. package/src/domain/utils/RefLayout.js +59 -0
  52. package/src/domain/warp/PatchSession.js +18 -0
  53. package/src/domain/warp/Writer.js +18 -3
  54. package/src/domain/warp/_internal.js +26 -0
  55. package/src/domain/warp/_wire.js +58 -0
  56. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  57. package/src/domain/warp/checkpoint.methods.js +397 -0
  58. package/src/domain/warp/fork.methods.js +323 -0
  59. package/src/domain/warp/materialize.methods.js +188 -0
  60. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  61. package/src/domain/warp/patch.methods.js +529 -0
  62. package/src/domain/warp/provenance.methods.js +284 -0
  63. package/src/domain/warp/query.methods.js +279 -0
  64. package/src/domain/warp/subscribe.methods.js +272 -0
  65. package/src/domain/warp/sync.methods.js +549 -0
  66. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  67. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  68. package/src/ports/CommitPort.js +10 -0
  69. package/src/ports/RefPort.js +17 -0
  70. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Diagnostic check functions for `git warp doctor`.
3
+ *
4
+ * Each check follows the DoctorCheck callback signature and NEVER throws.
5
+ * Internal errors are captured as `CHECK_INTERNAL_ERROR` findings.
6
+ *
7
+ * @module cli/commands/doctor/checks
8
+ */
9
+
10
+ import HealthCheckService from '../../../../src/domain/services/HealthCheckService.js';
11
+ import ClockAdapter from '../../../../src/infrastructure/adapters/ClockAdapter.js';
12
+ import {
13
+ buildCheckpointRef,
14
+ buildCoverageRef,
15
+ buildAuditPrefix,
16
+ } from '../../../../src/domain/utils/RefLayout.js';
17
+ import { createHookInstaller } from '../../shared.js';
18
+ import { CODES } from './codes.js';
19
+
20
+ /** @typedef {import('./types.js').DoctorFinding} DoctorFinding */
21
+ /** @typedef {import('./types.js').DoctorContext} DoctorContext */
22
+
23
+ // ── helpers ─────────────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * @param {string} id
27
+ * @param {*} err TODO(ts-cleanup): narrow error type
28
+ * @returns {DoctorFinding}
29
+ */
30
+ function internalError(id, err) {
31
+ return {
32
+ id,
33
+ status: 'fail',
34
+ code: CODES.CHECK_INTERNAL_ERROR,
35
+ impact: 'data_integrity',
36
+ message: `Internal error: ${err?.message || String(err)}`,
37
+ };
38
+ }
39
+
40
+ // ── repo-accessible ─────────────────────────────────────────────────────────
41
+
42
+ /** @param {DoctorContext} ctx @returns {Promise<DoctorFinding>} */
43
+ export async function checkRepoAccessible(ctx) {
44
+ try {
45
+ const clock = ClockAdapter.global();
46
+ const svc = new HealthCheckService({ persistence: /** @type {*} TODO(ts-cleanup): narrow port type */ (ctx.persistence), clock });
47
+ const health = await svc.getHealth();
48
+ if (health.components.repository.status === 'unhealthy') {
49
+ return {
50
+ id: 'repo-accessible', status: 'fail', code: CODES.REPO_UNREACHABLE,
51
+ impact: 'operability', message: 'Repository is not accessible',
52
+ fix: 'Check that the --repo path points to a valid git repository',
53
+ };
54
+ }
55
+ return {
56
+ id: 'repo-accessible', status: 'ok', code: CODES.REPO_OK,
57
+ impact: 'operability', message: 'Repository is accessible',
58
+ };
59
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
60
+ return internalError('repo-accessible', err);
61
+ }
62
+ }
63
+
64
+ // ── refs-consistent ─────────────────────────────────────────────────────────
65
+
66
+ /** @param {DoctorContext} ctx @returns {Promise<DoctorFinding[]>} */
67
+ export async function checkRefsConsistent(ctx) {
68
+ try {
69
+ const findings = /** @type {DoctorFinding[]} */ ([]);
70
+ const allRefs = ctx.writerHeads.map((h) => ({
71
+ ref: h.ref, sha: h.sha, label: `writer ${h.writerId}`,
72
+ }));
73
+ let allOk = true;
74
+ let checkedCount = 0;
75
+
76
+ for (const { ref, sha, label } of allRefs) {
77
+ if (!sha) {
78
+ allOk = false;
79
+ findings.push({
80
+ id: 'refs-consistent', status: 'fail', code: CODES.REFS_DANGLING_OBJECT,
81
+ impact: 'data_integrity',
82
+ message: `Ref ${ref} points to a missing or unreadable object`,
83
+ fix: `Investigate broken ref for ${label}`, evidence: { ref },
84
+ });
85
+ continue;
86
+ }
87
+ checkedCount++;
88
+ const exists = await ctx.persistence.nodeExists(sha);
89
+ if (!exists) {
90
+ allOk = false;
91
+ findings.push({
92
+ id: 'refs-consistent', status: 'fail', code: CODES.REFS_DANGLING_OBJECT,
93
+ impact: 'data_integrity',
94
+ message: `Ref ${ref} points to missing object ${sha.slice(0, 7)}`,
95
+ fix: `Investigate missing object for ${label}`, evidence: { ref, sha },
96
+ });
97
+ }
98
+ }
99
+
100
+ if (allOk) {
101
+ findings.push({
102
+ id: 'refs-consistent', status: 'ok', code: CODES.REFS_OK,
103
+ impact: 'data_integrity', message: `All ${checkedCount} ref(s) point to existing objects`,
104
+ });
105
+ }
106
+ return findings;
107
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
108
+ return [internalError('refs-consistent', err)];
109
+ }
110
+ }
111
+
112
+ // ── coverage-complete ───────────────────────────────────────────────────────
113
+
114
+ /** @param {DoctorContext} ctx @returns {Promise<DoctorFinding>} */
115
+ export async function checkCoverageComplete(ctx) {
116
+ try {
117
+ const coverageRef = buildCoverageRef(ctx.graphName);
118
+ const coverageSha = await ctx.persistence.readRef(coverageRef);
119
+
120
+ if (!coverageSha) {
121
+ return {
122
+ id: 'coverage-complete', status: 'warn', code: CODES.COVERAGE_NO_REF,
123
+ impact: 'operability', message: 'No coverage ref found',
124
+ fix: 'Run `git warp materialize` to create a coverage anchor',
125
+ };
126
+ }
127
+
128
+ const missing = [];
129
+ for (const head of ctx.writerHeads) {
130
+ if (!head.sha) {
131
+ missing.push(head.writerId);
132
+ continue;
133
+ }
134
+ const reachable = await ctx.persistence.isAncestor(head.sha, coverageSha);
135
+ if (!reachable) {
136
+ missing.push(head.writerId);
137
+ }
138
+ }
139
+
140
+ if (missing.length > 0) {
141
+ return {
142
+ id: 'coverage-complete', status: 'warn', code: CODES.COVERAGE_MISSING_WRITERS,
143
+ impact: 'operability',
144
+ message: `Coverage anchor is missing ${missing.length} writer(s): ${missing.join(', ')}`,
145
+ fix: 'Run `git warp materialize` to update the coverage anchor',
146
+ evidence: { missingWriters: missing },
147
+ };
148
+ }
149
+
150
+ return {
151
+ id: 'coverage-complete', status: 'ok', code: CODES.COVERAGE_OK,
152
+ impact: 'operability', message: 'Coverage anchor includes all writers',
153
+ };
154
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
155
+ return internalError('coverage-complete', err);
156
+ }
157
+ }
158
+
159
+ // ── checkpoint-fresh ────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * @param {import('../../types.js').Persistence} persistence
163
+ * @param {string} checkpointSha
164
+ * @returns {Promise<{date: string|null, ageHours: number|null}>}
165
+ */
166
+ async function getCheckpointAge(persistence, checkpointSha) {
167
+ const info = await persistence.getNodeInfo(checkpointSha);
168
+ const date = info.date || null;
169
+ if (!date) {
170
+ return { date: null, ageHours: null };
171
+ }
172
+ const parsed = Date.parse(date);
173
+ if (Number.isNaN(parsed)) {
174
+ return { date, ageHours: null };
175
+ }
176
+ return { date, ageHours: (Date.now() - parsed) / (1000 * 60 * 60) };
177
+ }
178
+
179
+ /** @param {DoctorContext} ctx @returns {Promise<DoctorFinding>} */
180
+ export async function checkCheckpointFresh(ctx) {
181
+ try {
182
+ const ref = buildCheckpointRef(ctx.graphName);
183
+ const sha = await ctx.persistence.readRef(ref);
184
+
185
+ if (!sha) {
186
+ return {
187
+ id: 'checkpoint-fresh', status: 'warn', code: CODES.CHECKPOINT_MISSING,
188
+ impact: 'operability', message: 'No checkpoint found',
189
+ fix: 'Run `git warp materialize` to create a checkpoint',
190
+ };
191
+ }
192
+
193
+ const { date, ageHours } = await getCheckpointAge(ctx.persistence, sha);
194
+ return buildCheckpointFinding({ sha, date, ageHours, maxAge: ctx.policy.checkpointMaxAgeHours });
195
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
196
+ return internalError('checkpoint-fresh', err);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * @param {{sha: string, date: string|null, ageHours: number|null, maxAge: number}} p
202
+ * @returns {DoctorFinding}
203
+ */
204
+ function buildCheckpointFinding({ sha, date, ageHours, maxAge }) {
205
+ if (ageHours === null) {
206
+ return {
207
+ id: 'checkpoint-fresh', status: 'ok', code: CODES.CHECKPOINT_OK,
208
+ impact: 'operability', message: 'Checkpoint exists (age unknown)',
209
+ evidence: { sha, date },
210
+ };
211
+ }
212
+ if (ageHours > maxAge) {
213
+ return {
214
+ id: 'checkpoint-fresh', status: 'warn', code: CODES.CHECKPOINT_STALE,
215
+ impact: 'operability',
216
+ message: `Checkpoint is ${Math.round(ageHours)} hours old (threshold: ${maxAge}h)`,
217
+ fix: 'Run `git warp materialize` to refresh the checkpoint',
218
+ evidence: { sha, date, ageHours: Math.round(ageHours) },
219
+ };
220
+ }
221
+ return {
222
+ id: 'checkpoint-fresh', status: 'ok', code: CODES.CHECKPOINT_OK,
223
+ impact: 'operability', message: 'Checkpoint is fresh',
224
+ evidence: { sha, date, ageHours: Math.round(ageHours) },
225
+ };
226
+ }
227
+
228
+ // ── audit-consistent ────────────────────────────────────────────────────────
229
+
230
+ /**
231
+ * @param {DoctorContext} ctx
232
+ * @param {string[]} auditRefs
233
+ * @param {string} auditPrefix
234
+ * @returns {Promise<DoctorFinding[]>}
235
+ */
236
+ async function probeAuditRefs(ctx, auditRefs, auditPrefix) {
237
+ const findings = /** @type {DoctorFinding[]} */ ([]);
238
+
239
+ for (const ref of auditRefs) {
240
+ const sha = await ctx.persistence.readRef(ref);
241
+ if (!sha) {
242
+ continue;
243
+ }
244
+ const exists = await ctx.persistence.nodeExists(sha);
245
+ if (!exists) {
246
+ findings.push({
247
+ id: 'audit-consistent', status: 'warn', code: CODES.AUDIT_DANGLING,
248
+ impact: 'data_integrity',
249
+ message: `Audit ref ${ref} points to missing object ${sha.slice(0, 7)}`,
250
+ evidence: { ref, sha },
251
+ });
252
+ }
253
+ }
254
+
255
+ const writerIds = new Set(ctx.writerHeads.map((h) => h.writerId));
256
+ const auditIdSet = new Set(auditRefs.map((r) => r.slice(auditPrefix.length)).filter((id) => id.length > 0));
257
+ const missing = [...writerIds].filter((id) => !auditIdSet.has(id));
258
+
259
+ if (missing.length > 0 && auditIdSet.size > 0) {
260
+ findings.push({
261
+ id: 'audit-consistent', status: 'warn', code: CODES.AUDIT_PARTIAL,
262
+ impact: 'data_integrity',
263
+ message: `Audit coverage is partial: writers without audit refs: ${missing.join(', ')}`,
264
+ fix: 'Run `git warp verify-audit` to verify existing chains',
265
+ evidence: { writersWithoutAudit: missing },
266
+ });
267
+ }
268
+
269
+ return findings;
270
+ }
271
+
272
+ /** @param {DoctorContext} ctx @returns {Promise<DoctorFinding[]>} */
273
+ export async function checkAuditConsistent(ctx) {
274
+ try {
275
+ const auditPrefix = buildAuditPrefix(ctx.graphName);
276
+ const auditRefs = await ctx.persistence.listRefs(auditPrefix);
277
+
278
+ if (auditRefs.length === 0) {
279
+ return [{
280
+ id: 'audit-consistent', status: 'ok', code: CODES.AUDIT_OK,
281
+ impact: 'data_integrity', message: 'No audit refs present (none expected)',
282
+ }];
283
+ }
284
+
285
+ const findings = await probeAuditRefs(ctx, auditRefs, auditPrefix);
286
+ if (findings.length === 0) {
287
+ findings.push({
288
+ id: 'audit-consistent', status: 'ok', code: CODES.AUDIT_OK,
289
+ impact: 'data_integrity', message: `All ${auditRefs.length} audit ref(s) are consistent`,
290
+ });
291
+ }
292
+ return findings;
293
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
294
+ return [internalError('audit-consistent', err)];
295
+ }
296
+ }
297
+
298
+ // ── clock-skew ──────────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * @param {DoctorContext} ctx
302
+ * @returns {Promise<Array<{writerId: string, ms: number}>>}
303
+ */
304
+ async function collectWriterDates(ctx) {
305
+ const dates = [];
306
+ for (const head of ctx.writerHeads) {
307
+ if (!head.sha) {
308
+ continue;
309
+ }
310
+ const info = await ctx.persistence.getNodeInfo(head.sha);
311
+ const ms = info.date ? Date.parse(info.date) : NaN;
312
+ if (!Number.isNaN(ms)) {
313
+ dates.push({ writerId: head.writerId, ms });
314
+ }
315
+ }
316
+ return dates;
317
+ }
318
+
319
+ /** @param {DoctorContext} ctx @returns {Promise<DoctorFinding>} */
320
+ export async function checkClockSkew(ctx) {
321
+ try {
322
+ if (ctx.writerHeads.length < 2) {
323
+ return {
324
+ id: 'clock-skew', status: 'ok', code: CODES.CLOCK_SYNCED,
325
+ impact: 'operability', message: 'Clock skew check skipped (fewer than 2 writers)',
326
+ };
327
+ }
328
+
329
+ const dates = await collectWriterDates(ctx);
330
+ if (dates.length < 2) {
331
+ return {
332
+ id: 'clock-skew', status: 'ok', code: CODES.CLOCK_SYNCED,
333
+ impact: 'operability', message: 'Clock skew check skipped (insufficient date data)',
334
+ };
335
+ }
336
+
337
+ const spreadMs = Math.max(...dates.map((d) => d.ms)) - Math.min(...dates.map((d) => d.ms));
338
+ if (spreadMs > ctx.policy.clockSkewMs) {
339
+ return {
340
+ id: 'clock-skew', status: 'warn', code: CODES.CLOCK_SKEW_EXCEEDED,
341
+ impact: 'operability',
342
+ message: `Clock skew is ${Math.round(spreadMs / 1000)}s (threshold: ${Math.round(ctx.policy.clockSkewMs / 1000)}s)`,
343
+ evidence: { spreadMs, thresholdMs: ctx.policy.clockSkewMs },
344
+ };
345
+ }
346
+
347
+ return {
348
+ id: 'clock-skew', status: 'ok', code: CODES.CLOCK_SYNCED,
349
+ impact: 'operability',
350
+ message: `Clock skew is within threshold (${Math.round(spreadMs / 1000)}s)`,
351
+ evidence: { spreadMs },
352
+ };
353
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
354
+ return internalError('clock-skew', err);
355
+ }
356
+ }
357
+
358
+ // ── hooks-installed ─────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * @param {DoctorContext} ctx
362
+ * @returns {Promise<DoctorFinding>}
363
+ */
364
+ // eslint-disable-next-line @typescript-eslint/require-await -- sync body, async contract
365
+ export async function checkHooksInstalled(ctx) {
366
+ try {
367
+ const installer = createHookInstaller();
368
+ const s = installer.getHookStatus(ctx.repoPath);
369
+ return buildHookFinding(s);
370
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow error type
371
+ return internalError('hooks-installed', err);
372
+ }
373
+ }
374
+
375
+ /**
376
+ * @param {*} s TODO(ts-cleanup): narrow hook status type
377
+ * @returns {DoctorFinding}
378
+ */
379
+ function buildHookFinding(s) {
380
+ if (!s.installed && s.foreign) {
381
+ return {
382
+ id: 'hooks-installed', status: 'warn', code: CODES.HOOKS_MISSING,
383
+ impact: 'hygiene', message: 'Foreign hook present; warp hook not installed',
384
+ fix: 'Run `git warp install-hooks` (use --force to replace existing hook)',
385
+ };
386
+ }
387
+ if (!s.installed) {
388
+ return {
389
+ id: 'hooks-installed', status: 'warn', code: CODES.HOOKS_MISSING,
390
+ impact: 'hygiene', message: 'Post-merge hook is not installed',
391
+ fix: 'Run `git warp install-hooks`',
392
+ };
393
+ }
394
+ if (!s.current) {
395
+ return {
396
+ id: 'hooks-installed', status: 'warn', code: CODES.HOOKS_OUTDATED,
397
+ impact: 'hygiene', message: `Hook is outdated (v${s.version})`,
398
+ fix: 'Run `git warp install-hooks` to upgrade',
399
+ evidence: { version: s.version },
400
+ };
401
+ }
402
+ return {
403
+ id: 'hooks-installed', status: 'ok', code: CODES.HOOKS_OK,
404
+ impact: 'hygiene', message: `Hook is installed and current (v${s.version})`,
405
+ };
406
+ }
407
+
408
+ // ── registry ────────────────────────────────────────────────────────────────
409
+
410
+ /**
411
+ * All checks in execution order.
412
+ * @type {Array<{id: string, fn: function(DoctorContext): Promise<DoctorFinding|DoctorFinding[]|null>}>}
413
+ */
414
+ export const ALL_CHECKS = [
415
+ { id: 'repo-accessible', fn: checkRepoAccessible },
416
+ { id: 'refs-consistent', fn: checkRefsConsistent },
417
+ { id: 'coverage-complete', fn: checkCoverageComplete },
418
+ { id: 'checkpoint-fresh', fn: checkCheckpointFresh },
419
+ { id: 'audit-consistent', fn: checkAuditConsistent },
420
+ { id: 'clock-skew', fn: checkClockSkew },
421
+ { id: 'hooks-installed', fn: checkHooksInstalled },
422
+ ];
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Single source of truth for all doctor finding codes.
3
+ *
4
+ * Every code string referenced in checks.js and tests MUST come from here.
5
+ * Prevents drift and typos across the codebase.
6
+ *
7
+ * @module cli/commands/doctor/codes
8
+ */
9
+
10
+ export const CODES = {
11
+ // repo-accessible
12
+ REPO_OK: 'REPO_OK',
13
+ REPO_UNREACHABLE: 'REPO_UNREACHABLE',
14
+
15
+ // refs-consistent
16
+ REFS_OK: 'REFS_OK',
17
+ REFS_DANGLING_OBJECT: 'REFS_DANGLING_OBJECT',
18
+
19
+ // coverage-complete
20
+ COVERAGE_OK: 'COVERAGE_OK',
21
+ COVERAGE_MISSING_WRITERS: 'COVERAGE_MISSING_WRITERS',
22
+ COVERAGE_NO_REF: 'COVERAGE_NO_REF',
23
+
24
+ // checkpoint-fresh
25
+ CHECKPOINT_OK: 'CHECKPOINT_OK',
26
+ CHECKPOINT_MISSING: 'CHECKPOINT_MISSING',
27
+ CHECKPOINT_STALE: 'CHECKPOINT_STALE',
28
+
29
+ // audit-consistent
30
+ AUDIT_OK: 'AUDIT_OK',
31
+ AUDIT_DANGLING: 'AUDIT_DANGLING',
32
+ AUDIT_PARTIAL: 'AUDIT_PARTIAL',
33
+
34
+ // clock-skew
35
+ CLOCK_SYNCED: 'CLOCK_SYNCED',
36
+ CLOCK_SKEW_EXCEEDED: 'CLOCK_SKEW_EXCEEDED',
37
+
38
+ // hooks-installed
39
+ HOOKS_OK: 'HOOKS_OK',
40
+ HOOKS_MISSING: 'HOOKS_MISSING',
41
+ HOOKS_OUTDATED: 'HOOKS_OUTDATED',
42
+
43
+ // meta
44
+ CHECK_SKIPPED_BUDGET_EXHAUSTED: 'CHECK_SKIPPED_BUDGET_EXHAUSTED',
45
+ CHECK_INTERNAL_ERROR: 'CHECK_INTERNAL_ERROR',
46
+ };
@@ -0,0 +1,239 @@
1
+ /**
2
+ * `git warp doctor` — diagnose structural anomalies and suggest fixes.
3
+ *
4
+ * Orchestrator: builds context, runs checks with budget tracking,
5
+ * assembles payload, sorts findings, derives health.
6
+ *
7
+ * @module cli/commands/doctor
8
+ */
9
+
10
+ import { buildWritersPrefix } from '../../../../src/domain/utils/RefLayout.js';
11
+ import { parseCommandArgs } from '../../infrastructure.js';
12
+ import { doctorSchema } from '../../schemas.js';
13
+ import { createPersistence, resolveGraphName } from '../../shared.js';
14
+ import { ALL_CHECKS } from './checks.js';
15
+ import { CODES } from './codes.js';
16
+ import { DOCTOR_EXIT_CODES } from './types.js';
17
+
18
+ /** @typedef {import('../../types.js').CliOptions} CliOptions */
19
+ /** @typedef {import('./types.js').DoctorFinding} DoctorFinding */
20
+ /** @typedef {import('./types.js').DoctorPolicy} DoctorPolicy */
21
+ /** @typedef {import('./types.js').DoctorPayload} DoctorPayload */
22
+
23
+ const DOCTOR_OPTIONS = {
24
+ strict: { type: 'boolean', default: false },
25
+ };
26
+
27
+ /** @type {DoctorPolicy} */
28
+ const DEFAULT_POLICY = {
29
+ strict: false,
30
+ clockSkewMs: 300_000,
31
+ checkpointMaxAgeHours: 168,
32
+ globalDeadlineMs: 10_000,
33
+ checkTimeouts: {},
34
+ };
35
+
36
+ const STATUS_ORDER = /** @type {const} */ ({ fail: 0, warn: 1, ok: 2 });
37
+ const IMPACT_ORDER = /** @type {const} */ ({
38
+ data_integrity: 0,
39
+ security: 1,
40
+ operability: 2,
41
+ hygiene: 3,
42
+ });
43
+
44
+ /**
45
+ * @param {{options: CliOptions, args: string[]}} params
46
+ * @returns {Promise<{payload: DoctorPayload, exitCode: number}>}
47
+ */
48
+ export default async function handleDoctor({ options, args }) {
49
+ const { values } = parseCommandArgs(args, DOCTOR_OPTIONS, doctorSchema);
50
+ const startMs = Date.now();
51
+
52
+ const { persistence } = await createPersistence(options.repo);
53
+ const graphName = await resolveGraphName(persistence, options.graph);
54
+ const policy = { ...DEFAULT_POLICY, strict: Boolean(values.strict) };
55
+ const writerHeads = await collectWriterHeads(persistence, graphName);
56
+
57
+ /** @type {import('./types.js').DoctorContext} */
58
+ const ctx = { persistence, graphName, writerHeads, policy, repoPath: options.repo };
59
+
60
+ const { findings, checksRun } = await runChecks(ctx, startMs);
61
+ findings.sort(compareFinding);
62
+
63
+ const payload = assemblePayload({ repo: options.repo, graph: graphName, policy, findings, checksRun, startMs });
64
+ const exitCode = computeExitCode(payload.health, policy.strict);
65
+ return { payload, exitCode };
66
+ }
67
+
68
+ /**
69
+ * Assembles the final DoctorPayload from sorted findings.
70
+ * @param {{repo: string, graph: string, policy: DoctorPolicy, findings: DoctorFinding[], checksRun: number, startMs: number}} p
71
+ * @returns {DoctorPayload}
72
+ */
73
+ function assemblePayload({ repo, graph, policy, findings, checksRun, startMs }) {
74
+ let ok = 0;
75
+ let warn = 0;
76
+ let fail = 0;
77
+ for (const f of findings) {
78
+ if (f.status === 'ok') { ok++; }
79
+ else if (f.status === 'warn') { warn++; }
80
+ else if (f.status === 'fail') { fail++; }
81
+ }
82
+ const priorityActions = [
83
+ ...new Set(
84
+ findings.filter((f) => f.status !== 'ok' && f.fix).map((f) => /** @type {string} */ (f.fix)),
85
+ ),
86
+ ];
87
+
88
+ return {
89
+ doctorVersion: 1,
90
+ repo,
91
+ graph,
92
+ checkedAt: new Date().toISOString(),
93
+ health: deriveHealth(fail, warn),
94
+ policy,
95
+ summary: { checksRun, findingsTotal: findings.length, ok, warn, fail, priorityActions },
96
+ findings,
97
+ durationMs: Date.now() - startMs,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * @param {import('../../types.js').Persistence} persistence
103
+ * @param {string} graphName
104
+ * @returns {Promise<Array<{writerId: string, sha: string|null, ref: string}>>}
105
+ */
106
+ async function collectWriterHeads(persistence, graphName) {
107
+ const prefix = buildWritersPrefix(graphName);
108
+ const refs = await persistence.listRefs(prefix);
109
+ const heads = [];
110
+ for (const ref of refs) {
111
+ const writerId = ref.slice(prefix.length);
112
+ if (!writerId) {
113
+ continue;
114
+ }
115
+ let sha = null;
116
+ try {
117
+ sha = await persistence.readRef(ref);
118
+ } catch {
119
+ // Dangling ref — readRef may fail (e.g. show-ref exits 128 for missing objects).
120
+ // Include the head with sha=null so downstream checks can report it.
121
+ }
122
+ heads.push({ writerId, sha, ref });
123
+ }
124
+ return heads.sort((a, b) => a.writerId.localeCompare(b.writerId));
125
+ }
126
+
127
+ /**
128
+ * Runs all checks with global deadline enforcement.
129
+ * @param {import('./types.js').DoctorContext} ctx
130
+ * @param {number} startMs
131
+ * @returns {Promise<{findings: DoctorFinding[], checksRun: number}>}
132
+ */
133
+ async function runChecks(ctx, startMs) {
134
+ const findings = /** @type {DoctorFinding[]} */ ([]);
135
+ let checksRun = 0;
136
+
137
+ for (const check of ALL_CHECKS) {
138
+ const elapsed = Date.now() - startMs;
139
+ if (elapsed >= ctx.policy.globalDeadlineMs) {
140
+ findings.push({
141
+ id: check.id,
142
+ status: 'warn',
143
+ code: CODES.CHECK_SKIPPED_BUDGET_EXHAUSTED,
144
+ impact: 'operability',
145
+ message: `Check skipped: global deadline exceeded (${elapsed}ms >= ${ctx.policy.globalDeadlineMs}ms)`,
146
+ });
147
+ checksRun++;
148
+ continue;
149
+ }
150
+
151
+ let checkDuration;
152
+ try {
153
+ const checkStart = Date.now();
154
+ const result = await check.fn(ctx);
155
+ checkDuration = Date.now() - checkStart;
156
+ checksRun++;
157
+
158
+ const resultArray = normalizeResult(result);
159
+ for (const f of resultArray) {
160
+ f.durationMs = checkDuration;
161
+ findings.push(f);
162
+ }
163
+ } catch (/** @type {*} TODO(ts-cleanup): narrow error type */ err) {
164
+ checkDuration = checkDuration ?? 0;
165
+ checksRun++;
166
+ findings.push({
167
+ id: check.id,
168
+ status: 'fail',
169
+ code: CODES.CHECK_INTERNAL_ERROR,
170
+ impact: 'data_integrity',
171
+ message: `Internal error in ${check.id}: ${err?.message || String(err)}`,
172
+ durationMs: checkDuration,
173
+ });
174
+ }
175
+ }
176
+
177
+ return { findings, checksRun };
178
+ }
179
+
180
+ /**
181
+ * @param {DoctorFinding|DoctorFinding[]|null} result
182
+ * @returns {DoctorFinding[]}
183
+ */
184
+ function normalizeResult(result) {
185
+ if (!result) {
186
+ return [];
187
+ }
188
+ if (Array.isArray(result)) {
189
+ return result;
190
+ }
191
+ return [result];
192
+ }
193
+
194
+ /**
195
+ * @param {number} fail
196
+ * @param {number} warn
197
+ * @returns {'ok'|'degraded'|'failed'}
198
+ */
199
+ function deriveHealth(fail, warn) {
200
+ if (fail > 0) {
201
+ return 'failed';
202
+ }
203
+ if (warn > 0) {
204
+ return 'degraded';
205
+ }
206
+ return 'ok';
207
+ }
208
+
209
+ /**
210
+ * @param {'ok'|'degraded'|'failed'} health
211
+ * @param {boolean} strict
212
+ * @returns {number}
213
+ */
214
+ function computeExitCode(health, strict) {
215
+ if (health === 'ok') {
216
+ return DOCTOR_EXIT_CODES.OK;
217
+ }
218
+ if (strict) {
219
+ return DOCTOR_EXIT_CODES.STRICT_FINDINGS;
220
+ }
221
+ return DOCTOR_EXIT_CODES.FINDINGS;
222
+ }
223
+
224
+ /**
225
+ * @param {DoctorFinding} a
226
+ * @param {DoctorFinding} b
227
+ * @returns {number}
228
+ */
229
+ function compareFinding(a, b) {
230
+ const statusDiff = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
231
+ if (statusDiff !== 0) {
232
+ return statusDiff;
233
+ }
234
+ const impactDiff = (IMPACT_ORDER[a.impact] ?? 9) - (IMPACT_ORDER[b.impact] ?? 9);
235
+ if (impactDiff !== 0) {
236
+ return impactDiff;
237
+ }
238
+ return a.id.localeCompare(b.id);
239
+ }