@git-stunts/git-warp 10.8.0 → 11.3.3
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/README.md +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +80 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/patch.js +142 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +235 -0
- package/bin/cli/commands/registry.js +32 -0
- package/bin/cli/commands/seek.js +598 -0
- package/bin/cli/commands/tree.js +230 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +114 -0
- package/bin/cli/commands/view.js +46 -0
- package/bin/cli/infrastructure.js +350 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +96 -0
- package/bin/presenters/index.js +41 -9
- package/bin/presenters/json.js +14 -12
- package/bin/presenters/text.js +286 -28
- package/bin/warp-graph.js +5 -2346
- package/index.d.ts +111 -21
- package/index.js +2 -0
- package/package.json +10 -8
- package/src/domain/WarpGraph.js +109 -3252
- package/src/domain/crdt/ORSet.js +8 -8
- package/src/domain/errors/EmptyMessageError.js +2 -2
- package/src/domain/errors/ForkError.js +1 -1
- package/src/domain/errors/IndexError.js +1 -1
- package/src/domain/errors/OperationAbortedError.js +1 -1
- package/src/domain/errors/QueryError.js +3 -3
- package/src/domain/errors/SchemaUnsupportedError.js +1 -1
- package/src/domain/errors/ShardCorruptionError.js +2 -2
- package/src/domain/errors/ShardLoadError.js +2 -2
- package/src/domain/errors/ShardValidationError.js +4 -4
- package/src/domain/errors/StorageError.js +2 -2
- package/src/domain/errors/SyncError.js +1 -1
- package/src/domain/errors/TraversalError.js +1 -1
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/WarpError.js +2 -2
- package/src/domain/errors/WormholeError.js +1 -1
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +707 -0
- package/src/domain/services/BitmapIndexBuilder.js +3 -3
- package/src/domain/services/BitmapIndexReader.js +28 -19
- package/src/domain/services/BoundaryTransitionRecord.js +18 -17
- package/src/domain/services/CheckpointSerializerV5.js +17 -16
- package/src/domain/services/CheckpointService.js +2 -2
- package/src/domain/services/CommitDagTraversalService.js +13 -13
- package/src/domain/services/DagPathFinding.js +7 -7
- package/src/domain/services/DagTopology.js +1 -1
- package/src/domain/services/DagTraversal.js +1 -1
- package/src/domain/services/HealthCheckService.js +1 -1
- package/src/domain/services/HookInstaller.js +1 -1
- package/src/domain/services/HttpSyncServer.js +120 -55
- package/src/domain/services/IndexRebuildService.js +7 -7
- package/src/domain/services/IndexStalenessChecker.js +4 -3
- package/src/domain/services/JoinReducer.js +11 -11
- package/src/domain/services/LogicalTraversal.js +1 -1
- package/src/domain/services/MessageCodecInternal.js +4 -1
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/MigrationService.js +1 -1
- package/src/domain/services/ObserverView.js +8 -8
- package/src/domain/services/PatchBuilderV2.js +42 -26
- package/src/domain/services/ProvenanceIndex.js +1 -1
- package/src/domain/services/ProvenancePayload.js +1 -1
- package/src/domain/services/QueryBuilder.js +3 -3
- package/src/domain/services/StateDiff.js +14 -11
- package/src/domain/services/StateSerializerV5.js +2 -2
- package/src/domain/services/StreamingBitmapIndexBuilder.js +26 -24
- package/src/domain/services/SyncAuthService.js +71 -4
- package/src/domain/services/SyncProtocol.js +25 -11
- package/src/domain/services/TemporalQuery.js +9 -6
- package/src/domain/services/TranslationCost.js +7 -5
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/services/WormholeService.js +16 -7
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +195 -0
- package/src/domain/trust/TrustRecordService.js +281 -0
- package/src/domain/trust/TrustStateBuilder.js +222 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/TickReceipt.js +1 -1
- package/src/domain/types/WarpErrors.js +45 -0
- package/src/domain/types/WarpOptions.js +29 -0
- package/src/domain/types/WarpPersistence.js +41 -0
- package/src/domain/types/WarpTypes.js +2 -2
- package/src/domain/types/WarpTypesV2.js +2 -2
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/MinHeap.js +6 -5
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/utils/canonicalStringify.js +5 -4
- package/src/domain/utils/roaring.js +31 -5
- package/src/domain/warp/PatchSession.js +26 -17
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +254 -0
- package/src/domain/warp/checkpoint.methods.js +401 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +238 -0
- package/src/domain/warp/materializeAdvanced.methods.js +350 -0
- package/src/domain/warp/patch.methods.js +554 -0
- package/src/domain/warp/provenance.methods.js +286 -0
- package/src/domain/warp/query.methods.js +280 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +554 -0
- package/src/globals.d.ts +64 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +14 -9
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +9 -4
- package/src/infrastructure/adapters/DenoHttpAdapter.js +5 -6
- package/src/infrastructure/adapters/GitGraphAdapter.js +79 -11
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +2 -2
- package/src/infrastructure/adapters/WebCryptoAdapter.js +2 -2
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/visualization/layouts/converters.js +2 -2
- package/src/visualization/layouts/elkAdapter.js +1 -1
- package/src/visualization/layouts/elkLayout.js +10 -7
- package/src/visualization/layouts/index.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +16 -6
- package/src/visualization/renderers/svg/index.js +1 -1
- 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 {unknown} err
|
|
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 instanceof 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 {import('../../../../src/domain/types/WarpPersistence.js').CorePersistence} */ (/** @type {unknown} */ (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 (err) {
|
|
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 (err) {
|
|
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 (err) {
|
|
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 (err) {
|
|
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 (err) {
|
|
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 (err) {
|
|
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 (err) {
|
|
371
|
+
return internalError('hooks-installed', err);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @param {{ installed: boolean, version?: string, current?: boolean, foreign?: boolean, hookPath: string }} s
|
|
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 ?? null },
|
|
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 (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 instanceof Error ? 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
|
+
}
|