@forwardimpact/libwiki 0.2.27 → 0.2.29
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/package.json +1 -1
- package/src/audit/rule-builders.js +247 -0
- package/src/audit/rules.js +72 -196
- package/src/audit/scopes.js +21 -1
- package/src/boot.js +79 -19
- package/src/cli-definition.js +27 -0
- package/src/commands/claim.js +59 -22
- package/src/commands/product-mix.js +148 -0
- package/src/commands/refresh.js +37 -2
- package/src/commands/sync.js +10 -3
- package/src/constants.js +29 -0
- package/src/issue-list-renderer.js +71 -0
- package/src/marker-scanner.js +18 -1
- package/src/sanitize.js +53 -0
- package/src/wiki-sync.js +573 -56
package/src/wiki-sync.js
CHANGED
|
@@ -13,6 +13,25 @@ const REMOTE_BRANCH = `${REMOTE}/${BRANCH}`;
|
|
|
13
13
|
/** The commit range a wiki push introduces relative to the remote it reconciles against. */
|
|
14
14
|
const PUSH_RANGE = "origin/master..HEAD";
|
|
15
15
|
|
|
16
|
+
/** Working-tree status XY codes that signal an unmerged (conflicted) path. */
|
|
17
|
+
const UNMERGED_CODES = new Set(["UU", "AA", "DD", "AU", "UA", "DU", "UD"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The honest push-outcome reason taxonomy (D2). Success-shaped
|
|
21
|
+
* outcomes (`landed`, `nothing-to-push`) are returned; every other reason is
|
|
22
|
+
* carried by a thrown {@link WikiPushFailure}.
|
|
23
|
+
*/
|
|
24
|
+
export const PUSH_REASONS = Object.freeze({
|
|
25
|
+
LANDED: "landed",
|
|
26
|
+
NOTHING: "nothing-to-push",
|
|
27
|
+
REJECTED: "rejected",
|
|
28
|
+
CONFLICT: "conflict",
|
|
29
|
+
RESIDUE_CONFLICT: "residue-conflict",
|
|
30
|
+
TRANSPORT: "transport",
|
|
31
|
+
PRECONDITION: "precondition",
|
|
32
|
+
CONSERVATION: "conservation",
|
|
33
|
+
});
|
|
34
|
+
|
|
16
35
|
/** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
|
|
17
36
|
export class WikiPullConflict extends Error {
|
|
18
37
|
/** Create a WikiPullConflict with the stderr output from the failed rebase. */
|
|
@@ -112,6 +131,27 @@ function pushFenceExempt(filePath) {
|
|
|
112
131
|
return filePath.endsWith(".md") && base !== "STATUS.md";
|
|
113
132
|
}
|
|
114
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Error thrown when `commitAndPush` cannot honestly report a landed push
|
|
136
|
+
* `reason` is one of {@link PUSH_REASONS} other than `landed` /
|
|
137
|
+
* `nothing-to-push`; `stashSha` names a preserved autostash on a
|
|
138
|
+
* `residue-conflict`.
|
|
139
|
+
*/
|
|
140
|
+
export class WikiPushFailure extends Error {
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} reason - A {@link PUSH_REASONS} value.
|
|
143
|
+
* @param {string} message - Operator message naming the reason and recovery.
|
|
144
|
+
* @param {object} [opts]
|
|
145
|
+
* @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
|
|
146
|
+
*/
|
|
147
|
+
constructor(reason, message, { stashSha } = {}) {
|
|
148
|
+
super(message);
|
|
149
|
+
this.name = "WikiPushFailure";
|
|
150
|
+
this.reason = reason;
|
|
151
|
+
if (stashSha) this.stashSha = stashSha;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
115
155
|
/**
|
|
116
156
|
* Consolidates the wiki repository's pull / rebase / conflict-resolve / push
|
|
117
157
|
* flow over an injected {@link import('@forwardimpact/libutil').GitClient}.
|
|
@@ -225,16 +265,38 @@ export class WikiSync {
|
|
|
225
265
|
}
|
|
226
266
|
|
|
227
267
|
/**
|
|
228
|
-
* Stage and commit working-tree changes, then
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* gate are independent so a clean tree with local commits still pushes.
|
|
268
|
+
* Stage and commit working-tree changes, then reconcile on origin/master and
|
|
269
|
+
* push — reporting an honest outcome The commit gate and the
|
|
270
|
+
* push gate are independent so a clean tree with local commits still pushes.
|
|
232
271
|
*
|
|
233
272
|
* Without `paths` the commit sweeps the whole tree (`fit-wiki push`
|
|
234
273
|
* contract). With `paths` the commit is pathspec-scoped so foreign residue
|
|
235
274
|
* from parallel writers in the shared workspace is never swept in; the
|
|
236
|
-
* rebase
|
|
237
|
-
*
|
|
275
|
+
* rebase runs with --autostash because that residue stays uncommitted.
|
|
276
|
+
*
|
|
277
|
+
* Outcome contract (D2 taxonomy):
|
|
278
|
+
* - Returns `{ landed: true, reason: "landed" }` only when the push is
|
|
279
|
+
* **grounded** in observed remote state — the per-ref `--porcelain` report,
|
|
280
|
+
* or a post-push read of the remote tip containing HEAD — never inferred
|
|
281
|
+
* from the subprocess's exit or prose.
|
|
282
|
+
* - Returns `{ landed: false, reason: "nothing-to-push" }` only when the
|
|
283
|
+
* observed remote ref already contains local HEAD (never pre-fetch
|
|
284
|
+
* arithmetic), so a stranded-resume tree re-pushes.
|
|
285
|
+
* - Throws {@link WikiPushFailure} for every failure reason: `precondition`
|
|
286
|
+
* (rebase-in-progress / detached HEAD, before mutating), `conflict`
|
|
287
|
+
* (rebase conflict — aborted, the remote side never mechanically
|
|
288
|
+
* discarded), `residue-conflict` (autostash pop left unmerged paths — stash
|
|
289
|
+
* preserved by SHA), `conservation` (the push would drop foreign content),
|
|
290
|
+
* `rejected` (non-fast-forward after a successful fetch), `transport`
|
|
291
|
+
* (push/fetch transport failure). A failed push never loses uncommitted
|
|
292
|
+
* work.
|
|
293
|
+
*
|
|
294
|
+
* Bounded retry (D3) is in contract: the ancestry judgment is present
|
|
295
|
+
* (this is the second lander), so a `rejected` outcome reconciles once and
|
|
296
|
+
* re-pushes, re-entering {@link #assertPublishable} before the replay so the
|
|
297
|
+
* empty-remote allowance is never auto-re-granted. The retry is bounded at
|
|
298
|
+
* one, never re-pops a conflicted autostash, and never masks the final
|
|
299
|
+
* outcome — exhaustion reports `rejected`.
|
|
238
300
|
*
|
|
239
301
|
* Ancestry guard: before the commit and again before the push,
|
|
240
302
|
* {@link AncestryRefusal} is thrown when the published history's relationship
|
|
@@ -286,11 +348,25 @@ export class WikiSync {
|
|
|
286
348
|
* `reapply` re-derives the registered file's content from the operation's
|
|
287
349
|
* own row edit against the fresh tip text; returns the new text or null when
|
|
288
350
|
* the op is already satisfied on the tip.
|
|
289
|
-
* @returns {Promise<{pushed
|
|
351
|
+
* @returns {Promise<{landed?: boolean, pushed?: boolean, reason: string, findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], workAt?: string}>}
|
|
352
|
+
* A grounded landing (`{landed: true, reason: "landed"}`), a grounded
|
|
353
|
+
* nothing-to-push (`{landed: false, reason: "nothing-to-push"}`), a
|
|
354
|
+
* re-apply landing (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
|
|
355
|
+
* or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
|
|
356
|
+
* `would-publish-markers`, `introduced-scan-failed`, `secret-detected`,
|
|
357
|
+
* `scanner-unavailable`).
|
|
358
|
+
* @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
|
|
359
|
+
* `precondition`, `conflict`, `residue-conflict`, `conservation`,
|
|
360
|
+
* `rejected`, `transport`).
|
|
290
361
|
* @throws {AncestryRefusal} When the published history cannot be verified.
|
|
291
362
|
* @throws {WikiSyncConflict} When the re-apply budget is exhausted.
|
|
292
363
|
*/
|
|
293
364
|
async commitAndPush(message, paths, { reapply, maxReapply = 3 } = {}) {
|
|
365
|
+
// Precondition (D7): refuse mid-rebase before mutating. A
|
|
366
|
+
// detached HEAD is judged by the ancestry guard below (its `unverifiable`
|
|
367
|
+
// refusal and this `precondition` collapse to one observable refusal); the
|
|
368
|
+
// rebase-in-progress check is the residual this guard owns.
|
|
369
|
+
await this.#assertPreconditions();
|
|
294
370
|
// Guard 1 (hole 1): refuse mid-merge before staging. An abandoned merge
|
|
295
371
|
// leaves unmerged hunks or a pinned MERGE_HEAD; sweeping them would
|
|
296
372
|
// silently "complete" the merge and publish the markers. Decidable from
|
|
@@ -298,6 +374,8 @@ export class WikiSync {
|
|
|
298
374
|
if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
|
|
299
375
|
return WikiSyncRefusal.result("mid-merge");
|
|
300
376
|
}
|
|
377
|
+
// Ancestry guard: refuse a detached/unborn/unrelated history
|
|
378
|
+
// before any mutation.
|
|
301
379
|
await this.#assertPublishable();
|
|
302
380
|
const gitattributesChanged = ensureMetricsCsvMergeAttribute(
|
|
303
381
|
this.#wikiDir,
|
|
@@ -320,46 +398,147 @@ export class WikiSync {
|
|
|
320
398
|
await this.#git.commitAll(message, { cwd: this.#wikiDir });
|
|
321
399
|
}
|
|
322
400
|
}
|
|
323
|
-
|
|
324
|
-
|
|
401
|
+
|
|
402
|
+
// Grounded nothing-to-push (D2): assert it only when the
|
|
403
|
+
// observed remote ref already contains local HEAD — never pre-fetch
|
|
404
|
+
// arithmetic, so a stranded-resume tree (clean, ahead) re-pushes.
|
|
405
|
+
const preTip = await this.#observeRemoteTip();
|
|
406
|
+
if (preTip && (await this.#headContainedIn(preTip))) {
|
|
407
|
+
return { landed: false, reason: PUSH_REASONS.NOTHING };
|
|
325
408
|
}
|
|
409
|
+
|
|
410
|
+
// Ancestry guard again before the push (the empty-remote allowance is
|
|
411
|
+
// re-derived per call so a failed first publication is re-judged).
|
|
326
412
|
await this.#assertPublishable();
|
|
327
|
-
|
|
328
|
-
|
|
413
|
+
return this.#reconcileAndPush(message, paths, preTip, {
|
|
414
|
+
reapply,
|
|
415
|
+
maxReapply,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Reconcile on the remote and push, grounding the outcome (D2/D3).
|
|
421
|
+
* Split from {@link commitAndPush} so the bounded ×1 retry (D3) re-enters the
|
|
422
|
+
* ancestry judgment and re-reconciles without duplicating the gates. On a
|
|
423
|
+
* `rejected` outcome it retries once: re-asserts {@link #assertPublishable}
|
|
424
|
+
* (no auto-re-grant), refreshes the observed tip, and replays. The retry
|
|
425
|
+
* never re-pops a conflicted autostash — a `residue-conflict` is refused, not
|
|
426
|
+
* retried.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} message
|
|
429
|
+
* @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
|
|
430
|
+
* @param {string[]} [commitPaths] - The effective commit pathspec.
|
|
431
|
+
* @param {string} preTip - The remote tip observed before the first reconcile.
|
|
432
|
+
* @param {{reapply?: function, maxReapply: number}} options
|
|
433
|
+
*/
|
|
434
|
+
async #reconcileAndPush(message, paths, preTip, opts) {
|
|
435
|
+
// Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
|
|
436
|
+
// first iteration uses `preTip`; the retry re-asserts the ancestry judgment
|
|
437
|
+
// (no auto-re-grant) and re-observes the tip before replaying. `transport`,
|
|
438
|
+
// `conflict`, `residue-conflict`, and `conservation` are never retried.
|
|
439
|
+
let tip = preTip;
|
|
440
|
+
for (let attempt = 0; attempt <= 1; attempt++) {
|
|
441
|
+
const outcome = await this.#reconcileAttempt(message, paths, tip, opts);
|
|
442
|
+
if (outcome.result) return outcome.result;
|
|
443
|
+
// A non-landed grounded verdict. `transport` is never retried; `rejected`
|
|
444
|
+
// retries once, re-entering the ancestry judgment first so the
|
|
445
|
+
// empty-remote allowance is never auto-re-granted. Outcome never masked.
|
|
446
|
+
if (outcome.verdict.reason === PUSH_REASONS.TRANSPORT || attempt === 1) {
|
|
447
|
+
throw outcome.verdict.error;
|
|
448
|
+
}
|
|
449
|
+
await this.#assertPublishable();
|
|
450
|
+
tip = await this.#observeRemoteTip();
|
|
451
|
+
}
|
|
452
|
+
// Unreachable: attempt 1 always returns a result or throws above.
|
|
453
|
+
/* c8 ignore next */
|
|
454
|
+
throw new Error("commitAndPush: retry loop fell through");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* One reconcile-guards-push attempt. Returns `{ result }` for a terminal
|
|
459
|
+
* outcome (a landed/re-applied push, or a pre-push gate refusal), or
|
|
460
|
+
* `{ verdict }` carrying a non-landed grounded verdict the retry loop reads
|
|
461
|
+
* (it owns the retry decision). Throws for the unsafe-state refusals that are
|
|
462
|
+
* never retried: `conflict`, `residue-conflict`, `conservation`.
|
|
463
|
+
* @param {string} message
|
|
464
|
+
* @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
|
|
465
|
+
* @param {string} tip - The remote tip observed before this attempt's reconcile.
|
|
466
|
+
* @param {{reapply?: function, maxReapply: number}} opts
|
|
467
|
+
*/
|
|
468
|
+
async #reconcileAttempt(message, paths, tip, opts) {
|
|
469
|
+
const fetched = await this.#fetchObserved();
|
|
470
|
+
const rebase = await this.#git.rebase(REMOTE_BRANCH, {
|
|
329
471
|
cwd: this.#wikiDir,
|
|
330
472
|
autostash: true,
|
|
331
473
|
});
|
|
474
|
+
|
|
475
|
+
// Rebase conflict (D2): a non-zero rebase exit is a conflict on the rebase
|
|
476
|
+
// itself. For a registered singleton with a `reapply` op the singleton merge
|
|
477
|
+
// discipline re-derives the row against the fresh tip; the no-intent path
|
|
478
|
+
// fails loud (the `mergeOursStrategy` clobber fallback is removed — the
|
|
479
|
+
// merge-discipline fail-loud floor applies). Checked before the residue read
|
|
480
|
+
// because a stopped rebase also leaves UU markers.
|
|
332
481
|
if (rebase.exitCode !== 0) {
|
|
333
|
-
const resolved = await this.#resolveRebaseConflict(message, paths,
|
|
334
|
-
|
|
335
|
-
maxReapply,
|
|
336
|
-
});
|
|
337
|
-
if (resolved) return resolved;
|
|
482
|
+
const resolved = await this.#resolveRebaseConflict(message, paths, opts);
|
|
483
|
+
if (resolved) return { result: resolved };
|
|
338
484
|
}
|
|
485
|
+
|
|
486
|
+
// Residue check (D9): the rebase exited 0 but the autostash pop conflicted,
|
|
487
|
+
// leaving unmerged paths — grounded in tree state, the sole conflict-capable
|
|
488
|
+
// autostash site after the clobber fallback's removal. Never retried (D3).
|
|
489
|
+
await this.#assertNoResidue();
|
|
490
|
+
|
|
339
491
|
// Guard 3 (hole 3 / Layer 2): refuse to push commits that introduce an
|
|
340
|
-
// unresolved conflict block.
|
|
492
|
+
// unresolved conflict block (the conflict-marker guard).
|
|
341
493
|
const markerRefusal = await this.#refuseIfIntroducedMarkers();
|
|
342
|
-
if (markerRefusal) return markerRefusal;
|
|
343
|
-
|
|
344
|
-
//
|
|
494
|
+
if (markerRefusal) return { result: markerRefusal };
|
|
495
|
+
|
|
496
|
+
// Conservation guard (D5): refuse to drop foreign content present at the
|
|
497
|
+
// observed remote tip unless the removal is a deliberate act.
|
|
498
|
+
await this.#assertConserved(tip, message);
|
|
499
|
+
|
|
500
|
+
// Capture the pushed delta now: HEAD is the final (rebased) local tip and
|
|
501
|
+
// origin/master is still the pre-push base (the tier-1 integrity probe).
|
|
345
502
|
const pushedDelta = await this.#capturePushedDelta();
|
|
503
|
+
|
|
346
504
|
// Fail-closed secret gate. Scan exactly the commits this push introduces
|
|
347
505
|
// (the reconcile above made the range correct) before any remote contact;
|
|
348
506
|
// a finding or missing scanner refuses unless its own override is set.
|
|
349
507
|
const refusal = await this.#gateOrRefuse();
|
|
350
|
-
if (refusal) return refusal;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
508
|
+
if (refusal) return { result: refusal };
|
|
509
|
+
|
|
510
|
+
const verdict = await this.#groundedPush(fetched);
|
|
511
|
+
if (verdict.landed) {
|
|
512
|
+
// The push landed; any declared removal it carried is now published, so
|
|
513
|
+
// clear the intent sidecar — it must not leak into an unrelated push.
|
|
514
|
+
this.#clearIntentSidecar();
|
|
515
|
+
const detections = await this.#tier1Probe(pushedDelta);
|
|
516
|
+
return {
|
|
517
|
+
result: { landed: true, reason: PUSH_REASONS.LANDED, detections },
|
|
518
|
+
};
|
|
360
519
|
}
|
|
361
|
-
|
|
362
|
-
|
|
520
|
+
return { verdict };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Refuse (`residue-conflict`) when the reconcile left unmerged paths — the
|
|
525
|
+
* autostash pop conflicted under an exit-0 rebase (D9). The stash is left
|
|
526
|
+
* intact (git already kept it) and named by SHA for recovery.
|
|
527
|
+
* @throws {WikiPushFailure} `residue-conflict` when the tree carries UU paths.
|
|
528
|
+
*/
|
|
529
|
+
async #assertNoResidue() {
|
|
530
|
+
if (!(await this.#hasUnmergedPaths())) return;
|
|
531
|
+
const stashSha = await this.#git.revParse("refs/stash", {
|
|
532
|
+
cwd: this.#wikiDir,
|
|
533
|
+
});
|
|
534
|
+
throw new WikiPushFailure(
|
|
535
|
+
PUSH_REASONS.RESIDUE_CONFLICT,
|
|
536
|
+
"fit-wiki: refusing to push — a foreign writer's residue conflicted " +
|
|
537
|
+
"on the autostash pop; your stash is preserved at " +
|
|
538
|
+
`${stashSha || "refs/stash"} (git stash list). Resolve or pop it ` +
|
|
539
|
+
"from the true tip.",
|
|
540
|
+
{ stashSha: stashSha || undefined },
|
|
541
|
+
);
|
|
363
542
|
}
|
|
364
543
|
|
|
365
544
|
/**
|
|
@@ -382,15 +561,19 @@ export class WikiSync {
|
|
|
382
561
|
/**
|
|
383
562
|
* Resolve a failed rebase against the fresh tip. Aborts the rebase, then for a
|
|
384
563
|
* registered singleton with a `reapply` op re-derives the row against the tip
|
|
385
|
-
* via the bounded re-apply loop
|
|
386
|
-
*
|
|
387
|
-
*
|
|
564
|
+
* via the bounded re-apply loop (the singleton merge discipline). Without a
|
|
565
|
+
* registered `reapply` the conflict fails loud: the `-X ours` clobber fallback
|
|
566
|
+
* is **removed** (the merge-discipline fail-loud floor), so the remote side is
|
|
567
|
+
* never mechanically discarded. The rebase is already aborted, leaving the working
|
|
568
|
+
* tree at `orig_head` with the autostash re-applied, so a `conflict` throw
|
|
569
|
+
* loses no uncommitted work.
|
|
388
570
|
* @param {string} message - The commit message.
|
|
389
571
|
* @param {string[]} [paths] - Pathspecs committed.
|
|
390
572
|
* @param {{reapply?: (freshText: string) => string | null, maxReapply: number}} options
|
|
391
|
-
* @returns {Promise<object|null>} A terminal
|
|
392
|
-
*
|
|
393
|
-
*
|
|
573
|
+
* @returns {Promise<object|null>} A terminal re-apply result, or null when the
|
|
574
|
+
* conflict resolved (registered op satisfied on the tip) and the push should
|
|
575
|
+
* proceed.
|
|
576
|
+
* @throws {WikiPushFailure} `conflict` when a non-registered rebase conflicts.
|
|
394
577
|
*/
|
|
395
578
|
async #resolveRebaseConflict(message, paths, { reapply, maxReapply }) {
|
|
396
579
|
await this.#git.rebaseAbort({ cwd: this.#wikiDir });
|
|
@@ -401,21 +584,12 @@ export class WikiSync {
|
|
|
401
584
|
if (registered) {
|
|
402
585
|
return this.#reapplyLoop(message, paths, reapply, maxReapply);
|
|
403
586
|
}
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
ref: "origin/master",
|
|
411
|
-
autostash: true,
|
|
412
|
-
allowFailure: true,
|
|
413
|
-
});
|
|
414
|
-
if (merge.exitCode !== 0) {
|
|
415
|
-
await this.#git.mergeAbort({ cwd: this.#wikiDir });
|
|
416
|
-
return WikiSyncRefusal.result("stranded-merge", { workAt: "stash" });
|
|
417
|
-
}
|
|
418
|
-
return null;
|
|
587
|
+
// No-intent path: fail loud rather than discard the remote side (D2).
|
|
588
|
+
throw new WikiPushFailure(
|
|
589
|
+
PUSH_REASONS.CONFLICT,
|
|
590
|
+
"fit-wiki: refusing to push — rebase conflict with the remote. " +
|
|
591
|
+
"Resolve or retry from the true tip (fit-wiki pull, then push).",
|
|
592
|
+
);
|
|
419
593
|
}
|
|
420
594
|
|
|
421
595
|
/**
|
|
@@ -587,11 +761,354 @@ export class WikiSync {
|
|
|
587
761
|
return null;
|
|
588
762
|
}
|
|
589
763
|
|
|
590
|
-
|
|
591
|
-
|
|
764
|
+
/**
|
|
765
|
+
* Refuse before mutating when a rebase is mid-flight (D7). The other D7
|
|
766
|
+
* fixture — a detached HEAD — is deferred to the ancestry guard
|
|
767
|
+
* ({@link #assertPublishable}), where it surfaces as an `AncestryRefusal`
|
|
768
|
+
* ("unverifiable"): the two refusals collapse to one observable refusal, and
|
|
769
|
+
* the ancestry guard owns the reason naming for that fixture. This guard owns
|
|
770
|
+
* only the rebase-in-progress residual, which the ancestry guard does not
|
|
771
|
+
* cover.
|
|
772
|
+
*/
|
|
773
|
+
async #assertPreconditions() {
|
|
774
|
+
if (this.#rebaseInProgress()) {
|
|
775
|
+
throw new WikiPushFailure(
|
|
776
|
+
PUSH_REASONS.PRECONDITION,
|
|
777
|
+
"fit-wiki: refusing to act — a rebase is in progress. Resolve or " +
|
|
778
|
+
"abort it before retrying; your uncommitted edit is preserved.",
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Whether a rebase is mid-flight (`.git/rebase-merge` or `rebase-apply`). */
|
|
784
|
+
#rebaseInProgress() {
|
|
785
|
+
const gitDir = path.join(this.#wikiDir, ".git");
|
|
786
|
+
return (
|
|
787
|
+
this.#runtime.fsSync.existsSync(path.join(gitDir, "rebase-merge")) ||
|
|
788
|
+
this.#runtime.fsSync.existsSync(path.join(gitDir, "rebase-apply"))
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Read the remote ref tip fresh, or "" when absent/unobservable. */
|
|
793
|
+
async #observeRemoteTip() {
|
|
794
|
+
try {
|
|
795
|
+
return await this.#authed().remoteRefTip(REMOTE, BRANCH, {
|
|
796
|
+
cwd: this.#wikiDir,
|
|
797
|
+
});
|
|
798
|
+
} catch {
|
|
799
|
+
return "";
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Whether HEAD is contained in `tip` (grounded nothing-to-push). */
|
|
804
|
+
async #headContainedIn(tip) {
|
|
805
|
+
return this.#git.isAncestor("HEAD", tip, { cwd: this.#wikiDir });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Fetch, returning whether it succeeded (feeds the rejected-vs-transport split). */
|
|
809
|
+
async #fetchObserved() {
|
|
810
|
+
try {
|
|
811
|
+
await this.#authed().fetch(REMOTE, BRANCH, { cwd: this.#wikiDir });
|
|
812
|
+
return true;
|
|
813
|
+
} catch {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Whether the working tree carries unmerged (conflicted) paths. */
|
|
819
|
+
async #hasUnmergedPaths() {
|
|
820
|
+
const r = await this.#git.statusPorcelain({ cwd: this.#wikiDir });
|
|
821
|
+
return r.stdout
|
|
822
|
+
.split("\n")
|
|
823
|
+
.some((line) => UNMERGED_CODES.has(line.slice(0, 2)));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Refuse (`conservation`) when the would-be-pushed tree drops foreign content
|
|
828
|
+
* present at the observed remote tip, unless a deliberate removal carries it
|
|
829
|
+
* (D5). After a clean rebase HEAD descends from the remote tip, so the
|
|
830
|
+
* tip-first diff (`D`/`M`) is exactly the net effect of the pushed history:
|
|
831
|
+
* a `D` is a foreign file deleted; an `M` carries the pushed history's
|
|
832
|
+
* authored changes, where a row rewritten to a new state is an authored
|
|
833
|
+
* transition (passes) but a row removed without replacement is a drop
|
|
834
|
+
* (refuses). Row identity is the line's leading field, so a `plan approved`
|
|
835
|
+
* written over a foreign row keeps the row key and passes.
|
|
836
|
+
*
|
|
837
|
+
* @param {string} remoteTip - The observed remote tip SHA.
|
|
838
|
+
* @param {string} message - The pushed commit message (carries release intent).
|
|
839
|
+
*/
|
|
840
|
+
async #assertConserved(remoteTip, message) {
|
|
841
|
+
if (!remoteTip) {
|
|
842
|
+
this.#reportConservation("pass");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const status = await this.#git.diffNameStatus(remoteTip, "HEAD", {
|
|
592
846
|
cwd: this.#wikiDir,
|
|
593
847
|
});
|
|
594
|
-
|
|
848
|
+
// When HEAD does not descend from the observed remote tip, the pushed
|
|
849
|
+
// history was written from a stale base and never saw the remote's advance,
|
|
850
|
+
// so a surviving-key row whose value differs from the remote is a stale
|
|
851
|
+
// revert (no authored transition to the restored state in the pushed
|
|
852
|
+
// history), not an approval-propagating transition. Only a HEAD that
|
|
853
|
+
// descends from the remote tip can have authored a transition over it.
|
|
854
|
+
const headAuthoredOverRemote = await this.#git.isAncestor(
|
|
855
|
+
remoteTip,
|
|
856
|
+
"HEAD",
|
|
857
|
+
{ cwd: this.#wikiDir },
|
|
858
|
+
);
|
|
859
|
+
const sidecar = this.#readIntentSidecar();
|
|
860
|
+
let declaredAny = false;
|
|
861
|
+
for (const line of status.split("\n")) {
|
|
862
|
+
if (!line) continue;
|
|
863
|
+
const [code, file] = line.split("\t");
|
|
864
|
+
if (code !== "D" && code !== "M") continue; // A/R/etc. add nothing to drop
|
|
865
|
+
|
|
866
|
+
const remoteContent = await this.#git.showFile(remoteTip, file, {
|
|
867
|
+
cwd: this.#wikiDir,
|
|
868
|
+
});
|
|
869
|
+
const headContent = await this.#git.showFile("HEAD", file, {
|
|
870
|
+
cwd: this.#wikiDir,
|
|
871
|
+
});
|
|
872
|
+
if (
|
|
873
|
+
!this.#dropsForeignContent(
|
|
874
|
+
remoteContent,
|
|
875
|
+
headContent,
|
|
876
|
+
headAuthoredOverRemote,
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
continue;
|
|
880
|
+
|
|
881
|
+
if (
|
|
882
|
+
this.#removalDeclared(file, message, sidecar, headAuthoredOverRemote)
|
|
883
|
+
) {
|
|
884
|
+
declaredAny = true;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
this.#reportConservation("refusal");
|
|
888
|
+
throw new WikiPushFailure(
|
|
889
|
+
PUSH_REASONS.CONSERVATION,
|
|
890
|
+
"fit-wiki: refusing to push — it would drop another writer's " +
|
|
891
|
+
`content in ${file} that is present on the remote. Pull and ` +
|
|
892
|
+
"re-apply, or declare the removal if it is deliberate.",
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
this.#reportConservation(declaredAny ? "declared-removal" : "pass");
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Whether the pushed tree drops foreign content present at the remote tip.
|
|
900
|
+
* A whole-file deletion drops it. Otherwise a remote line is dropped only
|
|
901
|
+
* when neither it **nor a line sharing its identity key** survives in HEAD —
|
|
902
|
+
* so a row rewritten to a new state (an authored transition) is conserved,
|
|
903
|
+
* while a row removed outright is a drop. The pusher's own additive edits
|
|
904
|
+
* never trip this because they remove no remote line.
|
|
905
|
+
*
|
|
906
|
+
* A surviving key with a changed value is an authored transition **only when
|
|
907
|
+
* the pushed history descends from the remote tip** (`headAuthoredOverRemote`).
|
|
908
|
+
* When it does not — a stale-base commit that never saw the remote's advance —
|
|
909
|
+
* the changed value restores a superseded state with no authoring commit, so
|
|
910
|
+
* it is a stale revert and counts as a drop (erases the foreign advance).
|
|
911
|
+
*
|
|
912
|
+
* `showFile` returns `null` for an absent blob; both `null` and `""` mean the
|
|
913
|
+
* file is gone at that ref.
|
|
914
|
+
*/
|
|
915
|
+
#dropsForeignContent(remoteContent, headContent, headAuthoredOverRemote) {
|
|
916
|
+
if (remoteContent == null || remoteContent === "") return false;
|
|
917
|
+
if (headContent == null || headContent === "") return true;
|
|
918
|
+
const headLines = headContent.split("\n");
|
|
919
|
+
const headSet = new Set(headLines);
|
|
920
|
+
const headKeys = new Set(headLines.map((l) => this.#rowKey(l)));
|
|
921
|
+
return remoteContent.split("\n").some((line) => {
|
|
922
|
+
if (line.trim() === "") return false;
|
|
923
|
+
if (headSet.has(line)) return false; // exact line survives
|
|
924
|
+
const key = this.#rowKey(line);
|
|
925
|
+
if (key === null) {
|
|
926
|
+
// Unkeyed prose absent from HEAD. When the pushed history descends from
|
|
927
|
+
// the remote tip, the pusher saw this line and authored its edit — a
|
|
928
|
+
// legitimate prose change, not a foreign drop. Only a stale-base commit
|
|
929
|
+
// that never saw the line (a side-pick / clean-replay erasure) drops it.
|
|
930
|
+
return !headAuthoredOverRemote;
|
|
931
|
+
}
|
|
932
|
+
if (!headKeys.has(key)) return true; // key gone outright ⇒ drop
|
|
933
|
+
// Key survives with a changed value: an authored transition only if the
|
|
934
|
+
// pushed history was built over the remote tip; otherwise a stale revert.
|
|
935
|
+
return !headAuthoredOverRemote;
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The identity key of a structured row, used to tell an authored transition
|
|
941
|
+
* (same row, new state) from a drop (row gone). For a Markdown table row the
|
|
942
|
+
* key is the **first two cells** — the canonical Active Claims table is keyed
|
|
943
|
+
* by `(agent, target)`, and `agent` alone is non-unique (one agent holds many
|
|
944
|
+
* rows), so a single-cell key would let a real foreign-row drop masquerade as
|
|
945
|
+
* a transition. For a tab-delimited ledger row (e.g. STATUS
|
|
946
|
+
* `id<TAB>phase<TAB>status`) the key is the first field, whose later fields
|
|
947
|
+
* are the state that transitions. Unstructured prose has no stable key
|
|
948
|
+
* (`null`) and is conserved by exact-line match only.
|
|
949
|
+
*/
|
|
950
|
+
#rowKey(line) {
|
|
951
|
+
const trimmed = line.trim();
|
|
952
|
+
if (trimmed.startsWith("|")) {
|
|
953
|
+
const cells = trimmed
|
|
954
|
+
.split("|")
|
|
955
|
+
.slice(1, -1)
|
|
956
|
+
.map((c) => c.trim());
|
|
957
|
+
const a = cells[0] ?? "";
|
|
958
|
+
const b = cells[1] ?? "";
|
|
959
|
+
return a || b ? `|${a}|${b}` : null;
|
|
960
|
+
}
|
|
961
|
+
if (line.includes("\t")) return `\t${line.split("\t")[0]}`;
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Whether the removal of `file` is declared deliberate (release/expiry/sidecar). */
|
|
966
|
+
#removalDeclared(file, message, sidecar, headAuthoredOverRemote) {
|
|
967
|
+
// A claim release/expiry records the deliberate act in the commit message,
|
|
968
|
+
// and a claim/release commit is pathspec-scoped to MEMORY.md — so the
|
|
969
|
+
// exemption is confined to that file, never a whole-tree trim. The blanket
|
|
970
|
+
// message exemption is honored only when HEAD descends from the remote tip:
|
|
971
|
+
// a release authored over current state drops exactly the row it released,
|
|
972
|
+
// but a stale-base release never saw a foreign row another writer added, so
|
|
973
|
+
// it must not blanket-exempt that collateral live-row drop (D5 — the
|
|
974
|
+
// deliberate act is the released row, not a file-level pass).
|
|
975
|
+
if (
|
|
976
|
+
headAuthoredOverRemote &&
|
|
977
|
+
/^wiki: release\b/.test(message) &&
|
|
978
|
+
file === "MEMORY.md"
|
|
979
|
+
)
|
|
980
|
+
return true;
|
|
981
|
+
// The intent sidecar names the specific file and survives a stranded-push
|
|
982
|
+
// retry, so it passes regardless of base freshness (D5 retry-survival).
|
|
983
|
+
return sidecar.includes(file);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Declare that the next push deliberately removes foreign content in `paths`
|
|
988
|
+
* (the cross-lane budget-trim shape, D5). The declaration is recorded
|
|
989
|
+
* clone-locally so it survives a stranded-push retry from the same clone, and
|
|
990
|
+
* is cleared only once a push lands (so the declaration never leaks into an
|
|
991
|
+
* unrelated later push).
|
|
992
|
+
* @param {string[]} paths - Files whose foreign-content removal is deliberate.
|
|
993
|
+
*/
|
|
994
|
+
declareRemoval(paths) {
|
|
995
|
+
if (!paths?.length) return;
|
|
996
|
+
const existing = this.#readIntentSidecar();
|
|
997
|
+
const merged = [...new Set([...existing, ...paths])];
|
|
998
|
+
this.#runtime.fsSync.writeFileSync(
|
|
999
|
+
this.#sidecarPath(),
|
|
1000
|
+
`${merged.join("\n")}\n`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
#sidecarPath() {
|
|
1005
|
+
return path.join(this.#wikiDir, ".git", "fit-wiki-removal-intent");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Read the clone-local removal-intent sidecar (declared deliberate removals). */
|
|
1009
|
+
#readIntentSidecar() {
|
|
1010
|
+
const sidecar = this.#sidecarPath();
|
|
1011
|
+
if (!this.#runtime.fsSync.existsSync(sidecar)) return [];
|
|
1012
|
+
return this.#runtime.fsSync
|
|
1013
|
+
.readFileSync(sidecar, "utf-8")
|
|
1014
|
+
.split("\n")
|
|
1015
|
+
.map((l) => l.trim())
|
|
1016
|
+
.filter(Boolean);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** Clear the removal-intent sidecar after a landed push. */
|
|
1020
|
+
#clearIntentSidecar() {
|
|
1021
|
+
const sidecar = this.#sidecarPath();
|
|
1022
|
+
if (this.#runtime.fsSync.existsSync(sidecar)) {
|
|
1023
|
+
this.#runtime.fsSync.unlinkSync(sidecar);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Emit the per-event conservation self-report at the guard seam (D8). */
|
|
1028
|
+
#reportConservation(outcomeClass) {
|
|
1029
|
+
this.#runtime.proc.stderr.write(`wiki-conservation: ${outcomeClass}\n`);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Push once and classify the outcome, grounding *landed* in the
|
|
1034
|
+
* remote-originated per-ref report or a post-push remote-tip read. Returns a
|
|
1035
|
+
* verdict the retry loop reads (it carries the {@link WikiPushFailure} to
|
|
1036
|
+
* throw on a terminal non-land so the loop owns the retry decision): a
|
|
1037
|
+
* non-landed push is `rejected` when the fetch succeeded, `transport` when it
|
|
1038
|
+
* failed or the push itself raised a transport error.
|
|
1039
|
+
* @param {boolean} fetched - Whether the pre-push fetch observed the remote.
|
|
1040
|
+
* @returns {Promise<{landed: boolean, reason: string, error?: WikiPushFailure}>}
|
|
1041
|
+
*/
|
|
1042
|
+
async #groundedPush(fetched) {
|
|
1043
|
+
const client = this.#authed();
|
|
1044
|
+
let result;
|
|
1045
|
+
try {
|
|
1046
|
+
result = await client.pushPorcelain(REMOTE, BRANCH, {
|
|
1047
|
+
cwd: this.#wikiDir,
|
|
1048
|
+
});
|
|
1049
|
+
} catch {
|
|
1050
|
+
return {
|
|
1051
|
+
landed: false,
|
|
1052
|
+
reason: PUSH_REASONS.TRANSPORT,
|
|
1053
|
+
error: new WikiPushFailure(
|
|
1054
|
+
PUSH_REASONS.TRANSPORT,
|
|
1055
|
+
"fit-wiki: push failed at transport (network or credentials). " +
|
|
1056
|
+
"Your work is committed locally; retry when connectivity returns.",
|
|
1057
|
+
),
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
if (await this.#pushLanded(result)) {
|
|
1061
|
+
return { landed: true, reason: PUSH_REASONS.LANDED };
|
|
1062
|
+
}
|
|
1063
|
+
if (!fetched) {
|
|
1064
|
+
return {
|
|
1065
|
+
landed: false,
|
|
1066
|
+
reason: PUSH_REASONS.TRANSPORT,
|
|
1067
|
+
error: new WikiPushFailure(
|
|
1068
|
+
PUSH_REASONS.TRANSPORT,
|
|
1069
|
+
"fit-wiki: push did not land and the remote could not be observed " +
|
|
1070
|
+
"(network or credentials). Your work is committed locally.",
|
|
1071
|
+
),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
return {
|
|
1075
|
+
landed: false,
|
|
1076
|
+
reason: PUSH_REASONS.REJECTED,
|
|
1077
|
+
error: new WikiPushFailure(
|
|
1078
|
+
PUSH_REASONS.REJECTED,
|
|
1079
|
+
"fit-wiki: push rejected — the remote advanced. Rerun from the true " +
|
|
1080
|
+
"tip (fit-wiki pull, then push).",
|
|
1081
|
+
),
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Whether the push landed, grounded in observed remote state: the per-ref
|
|
1087
|
+
* `--porcelain` report for `refs/heads/master` (flag ` `/`=` accepted, `!`
|
|
1088
|
+
* rejected), falling back to a post-push remote-tip read when the report is
|
|
1089
|
+
* unparseable.
|
|
1090
|
+
*/
|
|
1091
|
+
async #pushLanded(result) {
|
|
1092
|
+
const verdict = this.#parsePorcelain(result.stdout);
|
|
1093
|
+
if (verdict === "accepted") return true;
|
|
1094
|
+
if (verdict === "rejected") return false;
|
|
1095
|
+
// Ambiguous report ⇒ ground in a fresh remote-tip read.
|
|
1096
|
+
const tip = await this.#observeRemoteTip();
|
|
1097
|
+
return tip ? this.#headContainedIn(tip) : false;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Classify a `push --porcelain` report for the pushed branch ref. */
|
|
1101
|
+
#parsePorcelain(stdout) {
|
|
1102
|
+
for (const line of stdout.split("\n")) {
|
|
1103
|
+
const fields = line.split("\t");
|
|
1104
|
+
if (fields.length < 2) continue;
|
|
1105
|
+
const flag = fields[0];
|
|
1106
|
+
const refspec = fields[1];
|
|
1107
|
+
if (!refspec.includes(`refs/heads/${BRANCH}`)) continue;
|
|
1108
|
+
if (flag === " " || flag === "=") return "accepted";
|
|
1109
|
+
if (flag === "!") return "rejected";
|
|
1110
|
+
}
|
|
1111
|
+
return "ambiguous";
|
|
595
1112
|
}
|
|
596
1113
|
|
|
597
1114
|
/** Whether the wiki clone is shallow (has a `.git/shallow` file). */
|