@forwardimpact/libwiki 0.2.28 → 0.2.30

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/src/wiki-sync.js CHANGED
@@ -4,6 +4,8 @@ import { GITATTRIBUTES_FILE, SINGLETON_PATHS } from "./constants.js";
4
4
  import { ensureMetricsCsvMergeAttribute } from "./gitattributes.js";
5
5
  import { parseDiff, findAbsent, makeDetection, normLine } from "./integrity.js";
6
6
  import { scanPushWindow, appendOverrideRecord } from "./secret-gate.js";
7
+ import { runBudgetGate } from "./budget-gate.js";
8
+ import { currentDayIso } from "./util/clock.js";
7
9
 
8
10
  /** The branch the wiki clone publishes (hard-coded in fetch / rebase / push). */
9
11
  const BRANCH = "master";
@@ -30,6 +32,7 @@ export const PUSH_REASONS = Object.freeze({
30
32
  TRANSPORT: "transport",
31
33
  PRECONDITION: "precondition",
32
34
  CONSERVATION: "conservation",
35
+ BUDGET: "budget",
33
36
  });
34
37
 
35
38
  /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
@@ -132,10 +135,12 @@ function pushFenceExempt(filePath) {
132
135
  }
133
136
 
134
137
  /**
135
- * Error thrown when `commitAndPush` cannot honestly report a landed push
136
- * `reason` is one of {@link PUSH_REASONS} other than `landed` /
138
+ * Error thrown when `commitAndPush` cannot honestly report a landed push.
139
+ * `reason` is one of {@link PUSH_REASONS} other than `landed` /
137
140
  * `nothing-to-push`; `stashSha` names a preserved autostash on a
138
- * `residue-conflict`.
141
+ * `residue-conflict`; on a `budget` refusal `refusals` and `surfaced` carry the
142
+ * offending and surfaced-only `(file, ruleId, baseline, value)` tuples from the
143
+ * size-axis re-validation gate.
139
144
  */
140
145
  export class WikiPushFailure extends Error {
141
146
  /**
@@ -143,12 +148,16 @@ export class WikiPushFailure extends Error {
143
148
  * @param {string} message - Operator message naming the reason and recovery.
144
149
  * @param {object} [opts]
145
150
  * @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
151
+ * @param {Array<object>} [opts.refusals] - Budget refusal tuples (budget).
152
+ * @param {Array<object>} [opts.surfaced] - Surfaced-only budget tuples (budget).
146
153
  */
147
- constructor(reason, message, { stashSha } = {}) {
154
+ constructor(reason, message, { stashSha, refusals, surfaced } = {}) {
148
155
  super(message);
149
156
  this.name = "WikiPushFailure";
150
157
  this.reason = reason;
151
158
  if (stashSha) this.stashSha = stashSha;
159
+ if (refusals) this.refusals = refusals;
160
+ if (surfaced) this.surfaced = surfaced;
152
161
  }
153
162
  }
154
163
 
@@ -269,10 +278,13 @@ export class WikiSync {
269
278
  * push — reporting an honest outcome The commit gate and the
270
279
  * push gate are independent so a clean tree with local commits still pushes.
271
280
  *
272
- * Without `paths` the commit sweeps the whole tree (`fit-wiki push`
273
- * contract). With `paths` the commit is pathspec-scoped so foreign residue
274
- * from parallel writers in the shared workspace is never swept in; the
275
- * rebase runs with --autostash because that residue stays uncommitted.
281
+ * The commit is always pathspec-scoped: with `paths` the scope is the
282
+ * caller's declared write-set, and without `paths` it is the session's own
283
+ * dirty set (`#dirtyPaths`, the `fit-wiki push` contract under per-session
284
+ * checkout isolation). The whole-tree `add -A` sweep is gone, so foreign
285
+ * content on undeclared paths is never staged and the stale fast-forward
286
+ * eraser it carried no longer exists; the rebase runs with --autostash
287
+ * because any foreign residue stays uncommitted.
276
288
  *
277
289
  * Outcome contract (D2 taxonomy):
278
290
  * - Returns `{ landed: true, reason: "landed" }` only when the push is
@@ -342,14 +354,34 @@ export class WikiSync {
342
354
  * failure is distinct: it still degrades to "saved locally" (the preserved
343
355
  * fire-and-forget behaviour).
344
356
  *
357
+ * **Budget re-validation gate (size axis).** After the reconcile
358
+ * (rebase / singleton re-apply re-derives content against the fresh tip) and
359
+ * after the secret gate, before the push, the flow re-runs the wiki audit's
360
+ * budget predicates over the **outgoing committed `HEAD`** (the tree that
361
+ * publishes — not the working dir, so autostash residue never counts). The
362
+ * push is refused with {@link WikiPushFailure} `budget` when it introduces or
363
+ * deepens a per-file/per-predicate budget breach relative to the worse of the
364
+ * writer's pre-fetch session base and the landed origin tip; the failure
365
+ * carries the offending and surfaced `(file, ruleId, baseline, value)` tuples.
366
+ * Equal-or-better states and foreign pre-existing breaches the writer did not
367
+ * worsen pass. Summary breaches on `exemptSummaryFiles` are surfaced (rode on
368
+ * the landed result's `surfaced`) rather than refused — the memo-delivery
369
+ * seam, so a delivery into deficient headroom is reported, not blocked.
370
+ * An unreadable baseline ref aborts the gate without refusing (the gate only
371
+ * refuses a regression it can prove), never fabricating a value-0 baseline.
372
+ * Inheriting the audit's rule objects by id, a future predicate change flows
373
+ * through the gate with no gate-code change.
374
+ *
345
375
  * @param {string} message - The commit message.
346
376
  * @param {string[]} [paths] - Pathspecs limiting what gets committed.
347
- * @param {{reapply?: (freshText: string) => string | null, maxReapply?: number}} [options]
377
+ * @param {{reapply?: (freshText: string) => string | null, maxReapply?: number, exemptSummaryFiles?: string[]}} [options]
348
378
  * `reapply` re-derives the registered file's content from the operation's
349
379
  * own row edit against the fresh tip text; returns the new text or null when
350
- * the op is already satisfied on the tip.
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
380
+ * the op is already satisfied on the tip. `exemptSummaryFiles` lists files
381
+ * (relative to the wiki root) whose summary budget breach is surfaced rather
382
+ * than refused (the memo-delivery seam).
383
+ * @returns {Promise<{landed?: boolean, pushed?: boolean, reason: string, findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], surfaced?: object[], workAt?: string}>}
384
+ * A grounded landing (`{landed: true, reason: "landed", surfaced}`), a grounded
353
385
  * nothing-to-push (`{landed: false, reason: "nothing-to-push"}`), a
354
386
  * re-apply landing (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
355
387
  * or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
@@ -357,11 +389,15 @@ export class WikiSync {
357
389
  * `scanner-unavailable`).
358
390
  * @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
359
391
  * `precondition`, `conflict`, `residue-conflict`, `conservation`,
360
- * `rejected`, `transport`).
392
+ * `rejected`, `transport`) or a `budget` breach.
361
393
  * @throws {AncestryRefusal} When the published history cannot be verified.
362
394
  * @throws {WikiSyncConflict} When the re-apply budget is exhausted.
363
395
  */
364
- async commitAndPush(message, paths, { reapply, maxReapply = 3 } = {}) {
396
+ async commitAndPush(
397
+ message,
398
+ paths,
399
+ { reapply, maxReapply = 3, exemptSummaryFiles = [] } = {},
400
+ ) {
365
401
  // Precondition (D7): refuse mid-rebase before mutating. A
366
402
  // detached HEAD is judged by the ancestry guard below (its `unverifiable`
367
403
  // refusal and this `precondition` collapse to one observable refusal); the
@@ -374,6 +410,13 @@ export class WikiSync {
374
410
  if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
375
411
  return WikiSyncRefusal.result("mid-merge");
376
412
  }
413
+ // Capture the writer's branch point before the reconcile's fetch advances
414
+ // origin/master. The local commit below does not move origin/master, so
415
+ // reading it here yields the pre-edit session base; "" means an unborn
416
+ // origin (a fresh clone), which the gate treats as a value-0 baseline.
417
+ const sessionBaseSha = await this.#git.revParse("origin/master", {
418
+ cwd: this.#wikiDir,
419
+ });
377
420
  // Ancestry guard: refuse a detached/unborn/unrelated history
378
421
  // before any mutation.
379
422
  await this.#assertPublishable();
@@ -381,22 +424,25 @@ export class WikiSync {
381
424
  this.#wikiDir,
382
425
  this.#runtime.fsSync,
383
426
  ).changed;
427
+ // Attribute the commit's write-set. A caller that knows
428
+ // its narrower write-set passes `paths` (the claim/release path, scoped to
429
+ // MEMORY.md). The session-close `fit-wiki push` passes none and the
430
+ // write-set is the session's own dirty set, read from the working tree —
431
+ // correct because the canonical mechanism runs it in a per-session isolated
432
+ // checkout where the dirty set holds no foreign content. The whole-tree
433
+ // `commitAll` sweep is gone: it carried the stale fast-forward eraser, a
434
+ // clean fast-forward no loud-conflict contract could reach, so the scoped
435
+ // commit closes it at the source rather than relying on a later conflict.
436
+ const writeSet = paths ?? (await this.#dirtyPaths());
384
437
  // The metrics-CSV declaration must be committed when it was just written,
385
438
  // even on the pathspec-scoped path; fold it into the effective pathspec.
386
- // On a no-payload sweep (`paths` absent), this becomes the sole pathspec
387
- // [GITATTRIBUTES_FILE], so provisioning still produces exactly one commit
388
- // rather than sweeping the whole tree via commitAll.
389
439
  const commitPaths = gitattributesChanged
390
- ? [...(paths ?? []), GITATTRIBUTES_FILE]
391
- : paths;
392
- if (!(await this.isClean(commitPaths))) {
393
- if (commitPaths?.length) {
394
- await this.#git.commitPaths(message, commitPaths, {
395
- cwd: this.#wikiDir,
396
- });
397
- } else {
398
- await this.#git.commitAll(message, { cwd: this.#wikiDir });
399
- }
440
+ ? [...writeSet, GITATTRIBUTES_FILE]
441
+ : writeSet;
442
+ if (commitPaths.length && !(await this.isClean(commitPaths))) {
443
+ await this.#git.commitPaths(message, commitPaths, {
444
+ cwd: this.#wikiDir,
445
+ });
400
446
  }
401
447
 
402
448
  // Grounded nothing-to-push (D2): assert it only when the
@@ -413,6 +459,8 @@ export class WikiSync {
413
459
  return this.#reconcileAndPush(message, paths, preTip, {
414
460
  reapply,
415
461
  maxReapply,
462
+ sessionBaseSha,
463
+ exemptSummaryFiles,
416
464
  });
417
465
  }
418
466
 
@@ -429,7 +477,9 @@ export class WikiSync {
429
477
  * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
430
478
  * @param {string[]} [commitPaths] - The effective commit pathspec.
431
479
  * @param {string} preTip - The remote tip observed before the first reconcile.
432
- * @param {{reapply?: function, maxReapply: number}} options
480
+ * @param {{reapply?: function, maxReapply: number, sessionBaseSha?: string, exemptSummaryFiles?: string[]}} opts
481
+ * `sessionBaseSha` is the writer's pre-fetch branch point and
482
+ * `exemptSummaryFiles` the memo-delivery seam set, both for the budget gate.
433
483
  */
434
484
  async #reconcileAndPush(message, paths, preTip, opts) {
435
485
  // Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
@@ -463,7 +513,7 @@ export class WikiSync {
463
513
  * @param {string} message
464
514
  * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
465
515
  * @param {string} tip - The remote tip observed before this attempt's reconcile.
466
- * @param {{reapply?: function, maxReapply: number}} opts
516
+ * @param {{reapply?: function, maxReapply: number, sessionBaseSha?: string, exemptSummaryFiles?: string[]}} opts
467
517
  */
468
518
  async #reconcileAttempt(message, paths, tip, opts) {
469
519
  const fetched = await this.#fetchObserved();
@@ -507,19 +557,79 @@ export class WikiSync {
507
557
  const refusal = await this.#gateOrRefuse();
508
558
  if (refusal) return { result: refusal };
509
559
 
560
+ // Budget re-validation gate (size axis). The reconcile above made HEAD the
561
+ // final outgoing tree (rebase / singleton re-apply re-derived its
562
+ // content against the fresh tip), so measuring HEAD here measures exactly
563
+ // what publishes. A breach this push introduces or deepens throws `budget`;
564
+ // surfaced-only memo-delivery breaches ride the landed result.
565
+ const gate = await this.#revalidateBudgets(opts.sessionBaseSha, {
566
+ exemptSummaryFiles: opts.exemptSummaryFiles ?? [],
567
+ });
568
+ if (gate.refusals.length > 0) {
569
+ throw this.#budgetFailure(gate);
570
+ }
571
+
510
572
  const verdict = await this.#groundedPush(fetched);
511
573
  if (verdict.landed) {
512
574
  // The push landed; any declared removal it carried is now published, so
513
575
  // clear the intent sidecar — it must not leak into an unrelated push.
514
576
  this.#clearIntentSidecar();
515
577
  const detections = await this.#tier1Probe(pushedDelta);
516
- return {
517
- result: { landed: true, reason: PUSH_REASONS.LANDED, detections },
518
- };
578
+ const result = { landed: true, reason: PUSH_REASONS.LANDED, detections };
579
+ // Attach `surfaced` only when the gate surfaced a (memo-delivery exempt)
580
+ // breach, so a clean under-budget sync's landed result is byte-identical
581
+ // to today's — the gate adds no happy-path behaviour change (criterion 10).
582
+ if (gate.surfaced.length > 0) result.surfaced = gate.surfaced;
583
+ return { result };
519
584
  }
520
585
  return { verdict };
521
586
  }
522
587
 
588
+ /**
589
+ * Build the `budget` {@link WikiPushFailure} for a refusing gate result,
590
+ * naming the worst offending file and carrying every refusal and surfaced
591
+ * tuple so the operator can adjudicate (trim own content, or surface the
592
+ * carried content to its owner). The gate never edits — it refuses, keeping
593
+ * the commits local for re-push after the breach is resolved.
594
+ * @param {{refusals: Array<object>, surfaced: Array<object>}} gate
595
+ * @returns {WikiPushFailure}
596
+ */
597
+ #budgetFailure(gate) {
598
+ const lead = gate.refusals[0];
599
+ const files = [...new Set(gate.refusals.map((r) => r.file))].join(", ");
600
+ return new WikiPushFailure(
601
+ PUSH_REASONS.BUDGET,
602
+ "fit-wiki: refusing to push — it would introduce or deepen a budget " +
603
+ `breach in ${files} (${lead.ruleId}: ${lead.value} vs baseline ` +
604
+ `${lead.baseline}). Trim your own content or surface the carried ` +
605
+ "content to its owner, then re-push; your work is committed locally.",
606
+ { refusals: gate.refusals, surfaced: gate.surfaced },
607
+ );
608
+ }
609
+
610
+ /**
611
+ * Run the budget gate over the outgoing tree, measuring the committed `HEAD`
612
+ * against the pre-fetch session base and the landed origin tip. Delegates the
613
+ * measurement, the unreadable-ref fail-visible posture, and the delta to
614
+ * {@link runBudgetGate}; this method binds only the clone's git/fs/clock.
615
+ * @param {string} sessionBaseSha - origin/master before fetch, or "" when unborn.
616
+ * @param {{exemptSummaryFiles: string[]}} options
617
+ * @returns {Promise<{refusals: Array<object>, surfaced: Array<object>}>}
618
+ */
619
+ async #revalidateBudgets(sessionBaseSha, { exemptSummaryFiles }) {
620
+ return runBudgetGate({
621
+ showFile: (ref, file) =>
622
+ this.#git.showFile(ref, file, { cwd: this.#wikiDir }),
623
+ wikiRoot: this.#wikiDir,
624
+ today: currentDayIso(this.#runtime),
625
+ fs: this.#runtime.fsSync,
626
+ headRef: "HEAD",
627
+ originRef: REMOTE_BRANCH,
628
+ sessionBaseSha,
629
+ exemptSummaryFiles,
630
+ });
631
+ }
632
+
523
633
  /**
524
634
  * Refuse (`residue-conflict`) when the reconcile left unmerged paths — the
525
635
  * autostash pop conflicted under an exit-0 rebase (D9). The stash is left
@@ -823,6 +933,31 @@ export class WikiSync {
823
933
  .some((line) => UNMERGED_CODES.has(line.slice(0, 2)));
824
934
  }
825
935
 
936
+ /**
937
+ * The paths dirty in the working tree — the session's own write-set when no
938
+ * explicit pathspec was supplied. Each porcelain v1 line is `XY <path>` or,
939
+ * for a rename, `XY <orig> -> <new>`; the destination is the path that exists
940
+ * in the tree and is the one emitted (a `git mv` source no longer exists, so
941
+ * adding it to the pathspec would fault). A `"`-quoted path (a name with a
942
+ * space or non-ASCII byte) is unquoted so the pathspec matches the
943
+ * working-tree entry. Under the canonical per-session isolated checkout this
944
+ * set holds no foreign content, so committing exactly it stages the session's
945
+ * own work and nothing else; wiki session writes are edits and appends, not
946
+ * renames, so the destination-only scope covers the real workload.
947
+ */
948
+ async #dirtyPaths() {
949
+ const r = await this.#git.status({ cwd: this.#wikiDir });
950
+ return r.stdout
951
+ .split("\n")
952
+ .map((line) => line.slice(3))
953
+ .map((entry) => {
954
+ const arrow = entry.indexOf(" -> ");
955
+ return arrow === -1 ? entry : entry.slice(arrow + 4);
956
+ })
957
+ .map((p) => p.trim().replace(/^"(.*)"$/, "$1"))
958
+ .filter(Boolean);
959
+ }
960
+
826
961
  /**
827
962
  * Refuse (`conservation`) when the would-be-pushed tree drops foreign content
828
963
  * present at the observed remote tip, unless a deliberate removal carries it