@forwardimpact/libwiki 0.2.29 → 0.2.31

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,26 @@ 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";
9
+
10
+ /**
11
+ * A git `insteadOf` rule that maps a URL to itself. Some sandboxed
12
+ * environments install a broad global rewrite —
13
+ * `url.<local-proxy>.insteadOf = https://github.com/` — that diverts every
14
+ * github.com URL, the wiki included, to a proxy that serves only the main repo
15
+ * and returns 403 for the wiki. An identity rule keyed to the full wiki URL is
16
+ * a longer prefix match than the broad `https://github.com/` rule, so git wins
17
+ * it by longest-match and leaves the URL untouched; the request then reaches
18
+ * github.com over the ambient HTTPS proxy. Where no such broad rewrite exists,
19
+ * the rule is a harmless no-op. Applied inline on clone and persisted into the
20
+ * clone's local config for every later network op (see {@link WikiSync#pinTransport}).
21
+ * @param {string} url - The wiki clone URL.
22
+ * @returns {string} A `-c`-form `url.<url>.insteadOf=<url>` entry.
23
+ */
24
+ function selfInsteadOf(url) {
25
+ return `url.${url}.insteadOf=${url}`;
26
+ }
7
27
 
8
28
  /** The branch the wiki clone publishes (hard-coded in fetch / rebase / push). */
9
29
  const BRANCH = "master";
@@ -30,6 +50,7 @@ export const PUSH_REASONS = Object.freeze({
30
50
  TRANSPORT: "transport",
31
51
  PRECONDITION: "precondition",
32
52
  CONSERVATION: "conservation",
53
+ BUDGET: "budget",
33
54
  });
34
55
 
35
56
  /** Error thrown when a wiki pull encounters a rebase conflict that cannot be resolved automatically. */
@@ -132,10 +153,12 @@ function pushFenceExempt(filePath) {
132
153
  }
133
154
 
134
155
  /**
135
- * Error thrown when `commitAndPush` cannot honestly report a landed push
136
- * `reason` is one of {@link PUSH_REASONS} other than `landed` /
156
+ * Error thrown when `commitAndPush` cannot honestly report a landed push.
157
+ * `reason` is one of {@link PUSH_REASONS} other than `landed` /
137
158
  * `nothing-to-push`; `stashSha` names a preserved autostash on a
138
- * `residue-conflict`.
159
+ * `residue-conflict`; on a `budget` refusal `refusals` and `surfaced` carry the
160
+ * offending and surfaced-only `(file, ruleId, baseline, value)` tuples from the
161
+ * size-axis re-validation gate.
139
162
  */
140
163
  export class WikiPushFailure extends Error {
141
164
  /**
@@ -143,12 +166,16 @@ export class WikiPushFailure extends Error {
143
166
  * @param {string} message - Operator message naming the reason and recovery.
144
167
  * @param {object} [opts]
145
168
  * @param {string} [opts.stashSha] - Preserved stash SHA (residue-conflict).
169
+ * @param {Array<object>} [opts.refusals] - Budget refusal tuples (budget).
170
+ * @param {Array<object>} [opts.surfaced] - Surfaced-only budget tuples (budget).
146
171
  */
147
- constructor(reason, message, { stashSha } = {}) {
172
+ constructor(reason, message, { stashSha, refusals, surfaced } = {}) {
148
173
  super(message);
149
174
  this.name = "WikiPushFailure";
150
175
  this.reason = reason;
151
176
  if (stashSha) this.stashSha = stashSha;
177
+ if (refusals) this.refusals = refusals;
178
+ if (surfaced) this.surfaced = surfaced;
152
179
  }
153
180
  }
154
181
 
@@ -206,15 +233,36 @@ export class WikiSync {
206
233
 
207
234
  /** Clone the wiki from `url` if it is not already cloned. */
208
235
  async ensureCloned(url) {
209
- if (this.isCloned()) return { cloned: true, reason: "already-cloned" };
236
+ if (this.isCloned()) {
237
+ await this.#pinTransport(url);
238
+ return { cloned: true, reason: "already-cloned" };
239
+ }
210
240
  try {
211
- await this.#authed().clone(url, this.#wikiDir);
241
+ await this.#authed().clone(url, this.#wikiDir, {
242
+ config: [selfInsteadOf(url)],
243
+ });
244
+ await this.#pinTransport(url);
212
245
  return { cloned: true, reason: "cloned" };
213
246
  } catch (err) {
214
247
  return { cloned: false, reason: err.stderr?.trim() || err.message };
215
248
  }
216
249
  }
217
250
 
251
+ /**
252
+ * Persist the identity-`insteadOf` for the wiki's own URL into the clone's
253
+ * local config, so every later network op (fetch / push / ls-remote) that
254
+ * runs against the stored remote URL — and re-applies `insteadOf` at
255
+ * transport time — is covered without threading `-c` through each call. The
256
+ * clone command itself takes the same rule inline (the `.git/config` does not
257
+ * exist yet). Idempotent; safe to re-run on a resumed clone. See
258
+ * {@link selfInsteadOf} for why the rule is needed.
259
+ */
260
+ async #pinTransport(url) {
261
+ await this.#git.configSet(`url.${url}.insteadOf`, url, {
262
+ cwd: this.#wikiDir,
263
+ });
264
+ }
265
+
218
266
  /** Copy git user.name and user.email from the parent repository into the wiki repository. */
219
267
  async inheritIdentity() {
220
268
  const name = await this.#git.configGet("user.name", {
@@ -269,10 +317,13 @@ export class WikiSync {
269
317
  * push — reporting an honest outcome The commit gate and the
270
318
  * push gate are independent so a clean tree with local commits still pushes.
271
319
  *
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.
320
+ * The commit is always pathspec-scoped: with `paths` the scope is the
321
+ * caller's declared write-set, and without `paths` it is the session's own
322
+ * dirty set (`#dirtyPaths`, the `fit-wiki push` contract under per-session
323
+ * checkout isolation). The whole-tree `add -A` sweep is gone, so foreign
324
+ * content on undeclared paths is never staged and the stale fast-forward
325
+ * eraser it carried no longer exists; the rebase runs with --autostash
326
+ * because any foreign residue stays uncommitted.
276
327
  *
277
328
  * Outcome contract (D2 taxonomy):
278
329
  * - Returns `{ landed: true, reason: "landed" }` only when the push is
@@ -342,14 +393,34 @@ export class WikiSync {
342
393
  * failure is distinct: it still degrades to "saved locally" (the preserved
343
394
  * fire-and-forget behaviour).
344
395
  *
396
+ * **Budget re-validation gate (size axis).** After the reconcile
397
+ * (rebase / singleton re-apply re-derives content against the fresh tip) and
398
+ * after the secret gate, before the push, the flow re-runs the wiki audit's
399
+ * budget predicates over the **outgoing committed `HEAD`** (the tree that
400
+ * publishes — not the working dir, so autostash residue never counts). The
401
+ * push is refused with {@link WikiPushFailure} `budget` when it introduces or
402
+ * deepens a per-file/per-predicate budget breach relative to the worse of the
403
+ * writer's pre-fetch session base and the landed origin tip; the failure
404
+ * carries the offending and surfaced `(file, ruleId, baseline, value)` tuples.
405
+ * Equal-or-better states and foreign pre-existing breaches the writer did not
406
+ * worsen pass. Summary breaches on `exemptSummaryFiles` are surfaced (rode on
407
+ * the landed result's `surfaced`) rather than refused — the memo-delivery
408
+ * seam, so a delivery into deficient headroom is reported, not blocked.
409
+ * An unreadable baseline ref aborts the gate without refusing (the gate only
410
+ * refuses a regression it can prove), never fabricating a value-0 baseline.
411
+ * Inheriting the audit's rule objects by id, a future predicate change flows
412
+ * through the gate with no gate-code change.
413
+ *
345
414
  * @param {string} message - The commit message.
346
415
  * @param {string[]} [paths] - Pathspecs limiting what gets committed.
347
- * @param {{reapply?: (freshText: string) => string | null, maxReapply?: number}} [options]
416
+ * @param {{reapply?: (freshText: string) => string | null, maxReapply?: number, exemptSummaryFiles?: string[]}} [options]
348
417
  * `reapply` re-derives the registered file's content from the operation's
349
418
  * 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
419
+ * the op is already satisfied on the tip. `exemptSummaryFiles` lists files
420
+ * (relative to the wiki root) whose summary budget breach is surfaced rather
421
+ * than refused (the memo-delivery seam).
422
+ * @returns {Promise<{landed?: boolean, pushed?: boolean, reason: string, findings?: Array<{file: string, line: number, rule: string}>, detections?: object[], surfaced?: object[], workAt?: string}>}
423
+ * A grounded landing (`{landed: true, reason: "landed", surfaced}`), a grounded
353
424
  * nothing-to-push (`{landed: false, reason: "nothing-to-push"}`), a
354
425
  * re-apply landing (`{pushed: true, reason: "reapplied"}` / `already-satisfied`),
355
426
  * or a pre-push gate refusal ({@link WikiSyncRefusal}: `mid-merge`,
@@ -357,11 +428,15 @@ export class WikiSync {
357
428
  * `scanner-unavailable`).
358
429
  * @throws {WikiPushFailure} On a non-landed push outcome (D2 taxonomy:
359
430
  * `precondition`, `conflict`, `residue-conflict`, `conservation`,
360
- * `rejected`, `transport`).
431
+ * `rejected`, `transport`) or a `budget` breach.
361
432
  * @throws {AncestryRefusal} When the published history cannot be verified.
362
433
  * @throws {WikiSyncConflict} When the re-apply budget is exhausted.
363
434
  */
364
- async commitAndPush(message, paths, { reapply, maxReapply = 3 } = {}) {
435
+ async commitAndPush(
436
+ message,
437
+ paths,
438
+ { reapply, maxReapply = 3, exemptSummaryFiles = [] } = {},
439
+ ) {
365
440
  // Precondition (D7): refuse mid-rebase before mutating. A
366
441
  // detached HEAD is judged by the ancestry guard below (its `unverifiable`
367
442
  // refusal and this `precondition` collapse to one observable refusal); the
@@ -374,6 +449,13 @@ export class WikiSync {
374
449
  if (await this.#git.isMidMerge({ cwd: this.#wikiDir })) {
375
450
  return WikiSyncRefusal.result("mid-merge");
376
451
  }
452
+ // Capture the writer's branch point before the reconcile's fetch advances
453
+ // origin/master. The local commit below does not move origin/master, so
454
+ // reading it here yields the pre-edit session base; "" means an unborn
455
+ // origin (a fresh clone), which the gate treats as a value-0 baseline.
456
+ const sessionBaseSha = await this.#git.revParse("origin/master", {
457
+ cwd: this.#wikiDir,
458
+ });
377
459
  // Ancestry guard: refuse a detached/unborn/unrelated history
378
460
  // before any mutation.
379
461
  await this.#assertPublishable();
@@ -381,22 +463,25 @@ export class WikiSync {
381
463
  this.#wikiDir,
382
464
  this.#runtime.fsSync,
383
465
  ).changed;
466
+ // Attribute the commit's write-set. A caller that knows
467
+ // its narrower write-set passes `paths` (the claim/release path, scoped to
468
+ // MEMORY.md). The session-close `fit-wiki push` passes none and the
469
+ // write-set is the session's own dirty set, read from the working tree —
470
+ // correct because the canonical mechanism runs it in a per-session isolated
471
+ // checkout where the dirty set holds no foreign content. The whole-tree
472
+ // `commitAll` sweep is gone: it carried the stale fast-forward eraser, a
473
+ // clean fast-forward no loud-conflict contract could reach, so the scoped
474
+ // commit closes it at the source rather than relying on a later conflict.
475
+ const writeSet = paths ?? (await this.#dirtyPaths());
384
476
  // The metrics-CSV declaration must be committed when it was just written,
385
477
  // 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
478
  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
- }
479
+ ? [...writeSet, GITATTRIBUTES_FILE]
480
+ : writeSet;
481
+ if (commitPaths.length && !(await this.isClean(commitPaths))) {
482
+ await this.#git.commitPaths(message, commitPaths, {
483
+ cwd: this.#wikiDir,
484
+ });
400
485
  }
401
486
 
402
487
  // Grounded nothing-to-push (D2): assert it only when the
@@ -413,6 +498,8 @@ export class WikiSync {
413
498
  return this.#reconcileAndPush(message, paths, preTip, {
414
499
  reapply,
415
500
  maxReapply,
501
+ sessionBaseSha,
502
+ exemptSummaryFiles,
416
503
  });
417
504
  }
418
505
 
@@ -429,7 +516,9 @@ export class WikiSync {
429
516
  * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
430
517
  * @param {string[]} [commitPaths] - The effective commit pathspec.
431
518
  * @param {string} preTip - The remote tip observed before the first reconcile.
432
- * @param {{reapply?: function, maxReapply: number}} options
519
+ * @param {{reapply?: function, maxReapply: number, sessionBaseSha?: string, exemptSummaryFiles?: string[]}} opts
520
+ * `sessionBaseSha` is the writer's pre-fetch branch point and
521
+ * `exemptSummaryFiles` the memo-delivery seam set, both for the budget gate.
433
522
  */
434
523
  async #reconcileAndPush(message, paths, preTip, opts) {
435
524
  // Bounded retry (D3): at most one reconcile-and-retry on `rejected`. The
@@ -463,7 +552,7 @@ export class WikiSync {
463
552
  * @param {string} message
464
553
  * @param {string[]} [paths] - The caller's pathspec (singleton-discipline scope).
465
554
  * @param {string} tip - The remote tip observed before this attempt's reconcile.
466
- * @param {{reapply?: function, maxReapply: number}} opts
555
+ * @param {{reapply?: function, maxReapply: number, sessionBaseSha?: string, exemptSummaryFiles?: string[]}} opts
467
556
  */
468
557
  async #reconcileAttempt(message, paths, tip, opts) {
469
558
  const fetched = await this.#fetchObserved();
@@ -507,19 +596,79 @@ export class WikiSync {
507
596
  const refusal = await this.#gateOrRefuse();
508
597
  if (refusal) return { result: refusal };
509
598
 
599
+ // Budget re-validation gate (size axis). The reconcile above made HEAD the
600
+ // final outgoing tree (rebase / singleton re-apply re-derived its
601
+ // content against the fresh tip), so measuring HEAD here measures exactly
602
+ // what publishes. A breach this push introduces or deepens throws `budget`;
603
+ // surfaced-only memo-delivery breaches ride the landed result.
604
+ const gate = await this.#revalidateBudgets(opts.sessionBaseSha, {
605
+ exemptSummaryFiles: opts.exemptSummaryFiles ?? [],
606
+ });
607
+ if (gate.refusals.length > 0) {
608
+ throw this.#budgetFailure(gate);
609
+ }
610
+
510
611
  const verdict = await this.#groundedPush(fetched);
511
612
  if (verdict.landed) {
512
613
  // The push landed; any declared removal it carried is now published, so
513
614
  // clear the intent sidecar — it must not leak into an unrelated push.
514
615
  this.#clearIntentSidecar();
515
616
  const detections = await this.#tier1Probe(pushedDelta);
516
- return {
517
- result: { landed: true, reason: PUSH_REASONS.LANDED, detections },
518
- };
617
+ const result = { landed: true, reason: PUSH_REASONS.LANDED, detections };
618
+ // Attach `surfaced` only when the gate surfaced a (memo-delivery exempt)
619
+ // breach, so a clean under-budget sync's landed result is byte-identical
620
+ // to today's — the gate adds no happy-path behaviour change (criterion 10).
621
+ if (gate.surfaced.length > 0) result.surfaced = gate.surfaced;
622
+ return { result };
519
623
  }
520
624
  return { verdict };
521
625
  }
522
626
 
627
+ /**
628
+ * Build the `budget` {@link WikiPushFailure} for a refusing gate result,
629
+ * naming the worst offending file and carrying every refusal and surfaced
630
+ * tuple so the operator can adjudicate (trim own content, or surface the
631
+ * carried content to its owner). The gate never edits — it refuses, keeping
632
+ * the commits local for re-push after the breach is resolved.
633
+ * @param {{refusals: Array<object>, surfaced: Array<object>}} gate
634
+ * @returns {WikiPushFailure}
635
+ */
636
+ #budgetFailure(gate) {
637
+ const lead = gate.refusals[0];
638
+ const files = [...new Set(gate.refusals.map((r) => r.file))].join(", ");
639
+ return new WikiPushFailure(
640
+ PUSH_REASONS.BUDGET,
641
+ "fit-wiki: refusing to push — it would introduce or deepen a budget " +
642
+ `breach in ${files} (${lead.ruleId}: ${lead.value} vs baseline ` +
643
+ `${lead.baseline}). Trim your own content or surface the carried ` +
644
+ "content to its owner, then re-push; your work is committed locally.",
645
+ { refusals: gate.refusals, surfaced: gate.surfaced },
646
+ );
647
+ }
648
+
649
+ /**
650
+ * Run the budget gate over the outgoing tree, measuring the committed `HEAD`
651
+ * against the pre-fetch session base and the landed origin tip. Delegates the
652
+ * measurement, the unreadable-ref fail-visible posture, and the delta to
653
+ * {@link runBudgetGate}; this method binds only the clone's git/fs/clock.
654
+ * @param {string} sessionBaseSha - origin/master before fetch, or "" when unborn.
655
+ * @param {{exemptSummaryFiles: string[]}} options
656
+ * @returns {Promise<{refusals: Array<object>, surfaced: Array<object>}>}
657
+ */
658
+ async #revalidateBudgets(sessionBaseSha, { exemptSummaryFiles }) {
659
+ return runBudgetGate({
660
+ showFile: (ref, file) =>
661
+ this.#git.showFile(ref, file, { cwd: this.#wikiDir }),
662
+ wikiRoot: this.#wikiDir,
663
+ today: currentDayIso(this.#runtime),
664
+ fs: this.#runtime.fsSync,
665
+ headRef: "HEAD",
666
+ originRef: REMOTE_BRANCH,
667
+ sessionBaseSha,
668
+ exemptSummaryFiles,
669
+ });
670
+ }
671
+
523
672
  /**
524
673
  * Refuse (`residue-conflict`) when the reconcile left unmerged paths — the
525
674
  * autostash pop conflicted under an exit-0 rebase (D9). The stash is left
@@ -823,6 +972,31 @@ export class WikiSync {
823
972
  .some((line) => UNMERGED_CODES.has(line.slice(0, 2)));
824
973
  }
825
974
 
975
+ /**
976
+ * The paths dirty in the working tree — the session's own write-set when no
977
+ * explicit pathspec was supplied. Each porcelain v1 line is `XY <path>` or,
978
+ * for a rename, `XY <orig> -> <new>`; the destination is the path that exists
979
+ * in the tree and is the one emitted (a `git mv` source no longer exists, so
980
+ * adding it to the pathspec would fault). A `"`-quoted path (a name with a
981
+ * space or non-ASCII byte) is unquoted so the pathspec matches the
982
+ * working-tree entry. Under the canonical per-session isolated checkout this
983
+ * set holds no foreign content, so committing exactly it stages the session's
984
+ * own work and nothing else; wiki session writes are edits and appends, not
985
+ * renames, so the destination-only scope covers the real workload.
986
+ */
987
+ async #dirtyPaths() {
988
+ const r = await this.#git.status({ cwd: this.#wikiDir });
989
+ return r.stdout
990
+ .split("\n")
991
+ .map((line) => line.slice(3))
992
+ .map((entry) => {
993
+ const arrow = entry.indexOf(" -> ");
994
+ return arrow === -1 ? entry : entry.slice(arrow + 4);
995
+ })
996
+ .map((p) => p.trim().replace(/^"(.*)"$/, "$1"))
997
+ .filter(Boolean);
998
+ }
999
+
826
1000
  /**
827
1001
  * Refuse (`conservation`) when the would-be-pushed tree drops foreign content
828
1002
  * present at the observed remote tip, unless a deliberate removal carries it