@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/README.md +5 -2
- package/bin/fit-wiki.js +11 -1
- package/package.json +1 -1
- package/src/audit/rule-builders.js +247 -0
- package/src/audit/rules.js +89 -196
- package/src/audit/scopes.js +34 -2
- package/src/budget-gate.js +193 -0
- package/src/cli-definition.js +53 -3
- package/src/commands/claim.js +3 -1
- package/src/commands/ledger.js +208 -0
- package/src/commands/refresh.js +32 -2
- package/src/commands/sync.js +6 -1
- package/src/constants.js +18 -5
- package/src/ledger/anchor.js +96 -0
- package/src/ledger/projection.js +242 -0
- package/src/ledger/reader.js +45 -0
- package/src/wiki-sync.js +167 -32
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
|
-
*
|
|
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
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
-
*
|
|
352
|
-
*
|
|
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(
|
|
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
|
-
? [...
|
|
391
|
-
:
|
|
392
|
-
if (!(await this.isClean(commitPaths))) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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}}
|
|
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
|
-
|
|
517
|
-
|
|
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
|