rcf-lite 0.28.1 → 0.28.4
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/CHANGELOG.md +310 -0
- package/bin/rcf.js +4 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +1 -1
- package/rcf/adrs/adr-4120-canonical-json-hashing.json +30 -0
- package/rcf/adrs/adr-4121-define-sidecars.json +30 -0
- package/rcf/adrs/adr-4122-define-gates-blocking-and-optout.json +30 -0
- package/rcf/adrs/adr-4123-readiness-single-object.json +30 -0
- package/rcf/code-nodes/cn-602.json +3 -2
- package/rcf/code-nodes/cn-700.json +23 -0
- package/rcf/code-nodes/cn-701.json +14 -0
- package/rcf/code-nodes/cn-702.json +14 -0
- package/rcf/code-nodes/cn-703.json +16 -0
- package/rcf/code-nodes/cn-720.json +15 -0
- package/rcf/code-nodes/cn-721.json +15 -0
- package/rcf/code-nodes/cn-722.json +15 -0
- package/rcf/code-nodes/cn-723.json +18 -0
- package/rcf/code-nodes/cn-724.json +14 -0
- package/rcf/code-nodes/cn-725.json +21 -0
- package/rcf/code-nodes/cn-726.json +17 -0
- package/rcf/code-nodes/cn-727.json +14 -0
- package/rcf/code-nodes/cn-728.json +21 -0
- package/rcf/code-nodes/cn-729.json +18 -0
- package/rcf/code-nodes/cn-730.json +14 -0
- package/rcf/fbs/fbs-181.json +3 -2
- package/rcf/fbs/fbs-190.json +37 -0
- package/rcf/fbs/fbs-191.json +34 -0
- package/rcf/fbs/fbs-192.json +17 -0
- package/rcf/fbs/fbs-193.json +37 -0
- package/rcf/fbs/fbs-194.json +35 -0
- package/rcf/fbs/fbs-195.json +37 -0
- package/rcf/fbs/fbs-196.json +37 -0
- package/rcf/fbs/fbs-197.json +41 -0
- package/rcf/requirements/req-001.json +2 -1
- package/rcf/requirements/req-002.json +2 -1
- package/rcf/requirements/req-003.json +2 -1
- package/rcf/requirements/req-004.json +2 -1
- package/rcf/requirements/req-005.json +2 -1
- package/rcf/requirements/req-006.json +2 -1
- package/rcf/requirements/req-007.json +2 -1
- package/rcf/requirements/req-008.json +2 -1
- package/rcf/requirements/req-009.json +2 -1
- package/rcf/requirements/req-010.json +5 -2
- package/rcf/requirements/req-011.json +2 -1
- package/rcf/requirements/req-012.json +2 -1
- package/rcf/requirements/req-013.json +2 -1
- package/rcf/requirements/req-014.json +2 -1
- package/rcf/requirements/req-015.json +2 -1
- package/rcf/requirements/req-016.json +5 -2
- package/rcf/requirements/req-017.json +6 -2
- package/rcf/requirements/req-018.json +5 -2
- package/rcf/requirements/req-019.json +5 -2
- package/rcf/requirements/req-020.json +5 -2
- package/rcf/requirements/req-021.json +5 -2
- package/rcf/requirements/req-022.json +5 -2
- package/rcf/requirements/req-023.json +6 -4
- package/rcf/requirements/req-024.json +5 -2
- package/rcf/requirements/req-025.json +5 -2
- package/rcf/requirements/req-026.json +5 -2
- package/rcf/requirements/req-027.json +5 -2
- package/rcf/requirements/req-028.json +5 -2
- package/rcf/requirements/req-029.json +5 -2
- package/rcf/requirements/req-030.json +5 -2
- package/rcf/requirements/req-040.json +5 -2
- package/rcf/requirements/req-041.json +5 -2
- package/rcf/requirements/req-042.json +5 -2
- package/rcf/requirements/req-043.json +5 -2
- package/rcf/requirements/req-050.json +5 -2
- package/rcf/requirements/req-051.json +5 -2
- package/rcf/requirements/req-052.json +5 -2
- package/rcf/requirements/req-053.json +5 -2
- package/rcf/requirements/req-054.json +5 -2
- package/rcf/requirements/req-060.json +5 -2
- package/rcf/requirements/req-061.json +5 -2
- package/rcf/requirements/req-062.json +5 -2
- package/rcf/requirements/req-063.json +5 -2
- package/rcf/requirements/req-070.json +5 -2
- package/rcf/requirements/req-071.json +5 -2
- package/rcf/requirements/req-072.json +5 -2
- package/rcf/requirements/req-073.json +5 -2
- package/rcf/requirements/req-074.json +5 -2
- package/rcf/requirements/req-075.json +5 -2
- package/rcf/requirements/req-076.json +5 -2
- package/rcf/requirements/req-077.json +5 -2
- package/rcf/requirements/req-080.json +5 -2
- package/rcf/requirements/req-081.json +5 -2
- package/rcf/requirements/req-082.json +5 -2
- package/rcf/requirements/req-083.json +5 -2
- package/rcf/requirements/req-084.json +5 -2
- package/rcf/requirements/req-085.json +5 -2
- package/rcf/requirements/req-086.json +5 -2
- package/rcf/requirements/req-090.json +5 -2
- package/rcf/requirements/req-091.json +5 -2
- package/rcf/requirements/req-092.json +5 -2
- package/rcf/requirements/req-093.json +5 -2
- package/rcf/requirements/req-094.json +5 -2
- package/rcf/requirements/req-100.json +5 -2
- package/rcf/requirements/req-101.json +5 -2
- package/rcf/requirements/req-102.json +5 -2
- package/rcf/requirements/req-103.json +5 -2
- package/rcf/requirements/req-104.json +5 -2
- package/rcf/requirements/req-110.json +5 -2
- package/rcf/requirements/req-111.json +5 -2
- package/rcf/requirements/req-112.json +5 -2
- package/rcf/requirements/req-113.json +5 -2
- package/rcf/requirements/req-114.json +5 -2
- package/rcf/requirements/req-115.json +5 -2
- package/rcf/requirements/req-120.json +5 -2
- package/rcf/requirements/req-121.json +5 -2
- package/rcf/requirements/req-122.json +5 -2
- package/rcf/requirements/req-123.json +5 -2
- package/rcf/requirements/req-124.json +5 -2
- package/rcf/requirements/req-125.json +5 -2
- package/rcf/requirements/req-130.json +6 -2
- package/rcf/requirements/req-131.json +6 -2
- package/rcf/requirements/req-132.json +6 -2
- package/rcf/requirements/req-133.json +6 -2
- package/rcf/requirements/req-134.json +6 -2
- package/rcf/requirements/req-140.json +5 -2
- package/rcf/requirements/req-145.json +5 -2
- package/rcf/requirements/req-150.json +6 -2
- package/rcf/requirements/req-151.json +6 -2
- package/rcf/requirements/req-152.json +6 -2
- package/rcf/requirements/req-153.json +6 -2
- package/rcf/requirements/req-154.json +5 -2
- package/rcf/requirements/req-155.json +4 -1
- package/rcf/requirements/req-156.json +4 -1
- package/rcf/requirements/req-157.json +4 -1
- package/rcf/requirements/req-158.json +4 -1
- package/rcf/requirements/req-159.json +4 -1
- package/rcf/requirements/req-160.json +4 -1
- package/rcf/requirements/req-161.json +4 -1
- package/rcf/requirements/req-162.json +4 -1
- package/rcf/requirements/req-163.json +5 -2
- package/rcf/requirements/req-164.json +5 -2
- package/rcf/requirements/req-165.json +5 -2
- package/rcf/requirements/req-170.json +33 -0
- package/rcf/requirements/req-171.json +44 -0
- package/rcf/requirements/req-172.json +25 -0
- package/rcf/requirements/req-173.json +25 -0
- package/rcf/requirements/req-174.json +25 -0
- package/rcf/requirements/req-175.json +25 -0
- package/rcf/tacs/tac-4103-feedback-redact.json +2 -2
- package/rcf/tacs/tac-4120-define-delta-and-freeze.json +21 -0
- package/rcf/tacs/tac-4121-define-ledgers.json +23 -0
- package/rcf/tacs/tac-4122-define-gates.json +25 -0
- package/rcf/tacs/tac-4123-define-readiness.json +25 -0
- package/rcf/test-suites/ts-188.json +10 -2
- package/rcf/test-suites/ts-191.json +19 -5
- package/rcf/test-suites/ts-204.json +30 -0
- package/rcf/test-suites/ts-205.json +22 -0
- package/rcf/test-suites/ts-206.json +22 -0
- package/rcf/test-suites/ts-207.json +22 -0
- package/rcf/test-suites/ts-208.json +22 -0
- package/rcf/test-suites/ts-209.json +22 -0
- package/rcf/test-suites/ts-210.json +22 -0
- package/rcf/test-suites/ts-211.json +22 -0
- package/rcf/test-suites/ts-212.json +22 -0
- package/rcf/test-suites/ts-213.json +50 -0
- package/rcf/test-suites/ts-214.json +74 -0
- package/rcf/test-suites/ts-215.json +67 -0
- package/rcf/test-suites/ts-216.json +78 -0
- package/rcf/test-suites/ts-217.json +62 -0
- package/rcf/user-stories/us-15601.json +10 -1
- package/rcf/user-stories/us-15701.json +4 -4
- package/rcf/user-stories/us-17001.json +27 -0
- package/rcf/user-stories/us-17002.json +22 -0
- package/rcf/user-stories/us-17003.json +22 -0
- package/rcf/user-stories/us-17004.json +22 -0
- package/rcf/user-stories/us-17005.json +22 -0
- package/rcf/user-stories/us-17006.json +21 -0
- package/rcf/user-stories/us-17007.json +21 -0
- package/rcf/user-stories/us-17008.json +22 -0
- package/rcf/user-stories/us-17101.json +22 -0
- package/rcf/user-stories/us-17102.json +22 -0
- package/rcf/user-stories/us-17201.json +38 -0
- package/rcf/user-stories/us-17301.json +38 -0
- package/rcf/user-stories/us-17401.json +58 -0
- package/rcf/user-stories/us-17501.json +48 -0
- package/releases/releases.yaml +31 -1
- package/src/browser-verify/manifest-writer.js +9 -5
- package/src/cli/browser-verify.js +9 -1
- package/src/cli/feedback.js +316 -32
- package/src/cli/help.js +11 -0
- package/src/cli/intake.js +25 -4
- package/src/cli/ledger.js +399 -0
- package/src/cli/preflight.js +24 -3
- package/src/cli/readiness.js +280 -0
- package/src/cli/review.js +9 -1
- package/src/cli/ui-baseline.js +14 -1
- package/src/core/store/index.js +5 -0
- package/src/core/store/validate-composed-record.js +95 -0
- package/src/core/store/writer.js +86 -6
- package/src/define/freeze-record.js +232 -0
- package/src/define/ledgers.js +513 -0
- package/src/feedback/gh.js +27 -1
- package/src/feedback/redact-allowlist.json +7 -0
- package/src/feedback/redact.js +126 -11
- package/src/feedback/render.js +34 -12
- package/src/intake/manifest-writer.js +6 -3
- package/src/intake/orchestrator.js +44 -7
- package/src/preflight/manifest-writer.js +11 -11
- package/src/query/delta.js +276 -0
- package/src/query/gates.js +832 -0
- package/src/query/index.js +36 -0
- package/src/query/readiness.js +333 -0
- package/src/req-baseline/sweep.js +34 -5
- package/src/server/index.js +37 -0
- package/src/server/routes.js +65 -3
- package/src/ui-baseline/manifest-writer.js +11 -10
- package/src/view/doc-renderers/helpers.js +39 -2
- package/src/view/doc-renderers/req.js +10 -2
- package/src/view/doc-renderers/tad.js +67 -6
- package/src/view/doc-renderers/user-story.js +10 -5
- package/src/view/html-page.js +347 -28
- package/src/view/index.js +14 -1
- package/src/view/live-client.js +22 -2
- package/src/view/product-map.js +751 -0
- package/src/view/style.css +383 -0
package/src/feedback/redact.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// secret-shape check the last line of defence, which is what the
|
|
21
21
|
// residual-match refusal in rule 8 leans on.
|
|
22
22
|
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
23
24
|
import { readFileSync } from 'node:fs';
|
|
24
25
|
import { dirname, resolve } from 'node:path';
|
|
25
26
|
import { fileURLToPath } from 'node:url';
|
|
@@ -43,6 +44,14 @@ export const BODY_CAP_BYTES = 8 * 1024;
|
|
|
43
44
|
* conservative: anything a normal profile.md ## Name line might carry
|
|
44
45
|
* as a common English or engineering noun.
|
|
45
46
|
*/
|
|
47
|
+
|
|
48
|
+
// Codex P1-4: per-call collision-safe stash marker suffix. The suffix is
|
|
49
|
+
// 32 hex chars from randomUUID, which is astronomically unlikely to
|
|
50
|
+
// appear as a literal substring in prose the operator would submit.
|
|
51
|
+
function gitStashUuid() {
|
|
52
|
+
return randomUUID().replace(/-/g, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
46
55
|
export const RESERVED_IDENTITY_TOKENS = Object.freeze(new Set([
|
|
47
56
|
'agent', 'agents', 'user', 'users', 'operator', 'operators',
|
|
48
57
|
'admin', 'admins', 'root', 'client', 'clients', 'server', 'servers',
|
|
@@ -435,18 +444,98 @@ export function redact(input, context = {}) {
|
|
|
435
444
|
}
|
|
436
445
|
}
|
|
437
446
|
|
|
447
|
+
// 0.28.3 (Codex P1-1): move the rule-4 allowlist plumbing up so
|
|
448
|
+
// R5f's bare-remote stash can decide whether to preserve or fold the
|
|
449
|
+
// host before it hides the token. Duplicated later in the rule-4
|
|
450
|
+
// block was removed at the same time.
|
|
451
|
+
const allowHosts = new Set([
|
|
452
|
+
...BUNDLED_ALLOWLIST.hosts,
|
|
453
|
+
...(Array.isArray(context.allowHosts) ? context.allowHosts : []),
|
|
454
|
+
].map((h) => h.toLowerCase()));
|
|
455
|
+
const allowExtensions = new Set([
|
|
456
|
+
...(Array.isArray(BUNDLED_ALLOWLIST.extensions) ? BUNDLED_ALLOWLIST.extensions : []),
|
|
457
|
+
...(Array.isArray(context.allowExtensions) ? context.allowExtensions : []),
|
|
458
|
+
].map((e) => String(e).toLowerCase()));
|
|
459
|
+
|
|
460
|
+
// Rule 3-guard (design amendment R5f, issue #243, 0.28.3): protect the
|
|
461
|
+
// git protocol sentinel `git@<host>` before rule 3 runs. In a URL whose
|
|
462
|
+
// scheme is git+ssh/ssh/git, and in the bare git-remote shape
|
|
463
|
+
// `git@<host>:<owner>/<repo>`, `git@<host>` is URI userinfo, not an
|
|
464
|
+
// email. Folding it destroys evidence pointers copied from git remotes
|
|
465
|
+
// and clone URLs. We stash the full matched span before the email regex
|
|
466
|
+
// runs and restore it before rule 4 so the host inside the span is
|
|
467
|
+
// still eligible for the URL / bare-host fold.
|
|
468
|
+
const gitProtoRe = /(?:git\+ssh|ssh|git):\/\/git@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
469
|
+
const gitRemoteBareRe = /(?<![A-Za-z0-9._%+-])git@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?=:[^\s]+\/)/g;
|
|
470
|
+
const gitStash = [];
|
|
471
|
+
// Use a per-invocation random marker so a literal `GITPROTO0GITPROTO`
|
|
472
|
+
// in the operator's prose cannot be replayed as a restore token
|
|
473
|
+
// (Codex P1-4). The marker is 32 hex chars from randomUUID, wrapped
|
|
474
|
+
// in an unusual sigil (`\u0002...\u0003`) that would not survive
|
|
475
|
+
// the ctrl-char strip pass at rule 8a-late anyway, guaranteeing
|
|
476
|
+
// collision-free restoration for the duration of this call.
|
|
477
|
+
const GIT_STASH_TAG = `GITPROTO_${gitStashUuid()}`;
|
|
478
|
+
// Precompute a bare-remote host allowlist check per candidate:
|
|
479
|
+
// Codex P1-1 called out that a bare `git@<host>:...` remote used
|
|
480
|
+
// to have its host folded via the pre-fix email rule, and the R5f
|
|
481
|
+
// protection accidentally shielded the host too. Restore the host-
|
|
482
|
+
// level allowlist gate here: an allowlisted host stays verbatim,
|
|
483
|
+
// an unallowlisted host has the host portion folded to `<host>`
|
|
484
|
+
// before we stash, so the sentinel-plus-<host> shape survives
|
|
485
|
+
// through the pipeline.
|
|
486
|
+
function stashBareRemote(m) {
|
|
487
|
+
const at = m.indexOf('@');
|
|
488
|
+
const host = m.slice(at + 1);
|
|
489
|
+
if (isHostAllowed(host, allowHosts)) {
|
|
490
|
+
gitStash.push(m);
|
|
491
|
+
} else {
|
|
492
|
+
// Record a hostname ledger row so the operator disclosure names
|
|
493
|
+
// what was folded, matching the shape rule 4 would have used.
|
|
494
|
+
const before = host;
|
|
495
|
+
const after = '<host>';
|
|
496
|
+
// We push a synthetic ledger row for the hostname family (one
|
|
497
|
+
// per bare-remote fold). Deduplicated within the pass.
|
|
498
|
+
ledger.push({ rule: 'hostname', before, after, count: 1 });
|
|
499
|
+
gitStash.push(`git@${after}`);
|
|
500
|
+
}
|
|
501
|
+
return `${GIT_STASH_TAG}${gitStash.length - 1}${GIT_STASH_TAG}`;
|
|
502
|
+
}
|
|
503
|
+
text = text.replace(gitProtoRe, (m) => {
|
|
504
|
+
gitStash.push(m);
|
|
505
|
+
return `${GIT_STASH_TAG}${gitStash.length - 1}${GIT_STASH_TAG}`;
|
|
506
|
+
});
|
|
507
|
+
text = text.replace(gitRemoteBareRe, stashBareRemote);
|
|
508
|
+
|
|
438
509
|
// Rule 3: emails -> <email>. RFC-5322 lite; case-insensitive.
|
|
439
510
|
const emailRe = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
440
511
|
text = replaceRegex(text, emailRe, () => '<email>', 'email', ledger);
|
|
441
512
|
|
|
513
|
+
// R5f: restore git protocol sentinels. The stashed spans now re-enter
|
|
514
|
+
// the pipeline for rule 4 (URL / bare-host). Rule 4's URL regex is
|
|
515
|
+
// extended below to match git+ssh/ssh/git schemes with optional
|
|
516
|
+
// userinfo, so a non-allowlisted host inside a restored clone URL
|
|
517
|
+
// (e.g. `ssh://git@example.internal/owner/repo.git`) folds via that
|
|
518
|
+
// path while the `git@` userinfo is preserved. A bare
|
|
519
|
+
// `git@<host>:<owner>/<repo>` remote is left as-is by rule 4 because
|
|
520
|
+
// it has no scheme prefix and the bareHost lookbehind excludes `@`;
|
|
521
|
+
// the sentinel-protection posture is sufficient for that shape.
|
|
522
|
+
if (gitStash.length > 0) {
|
|
523
|
+
text = text.replace(new RegExp(`${GIT_STASH_TAG}(\\d+)${GIT_STASH_TAG}`, 'g'), (_m, i) => gitStash[Number(i)]);
|
|
524
|
+
}
|
|
525
|
+
|
|
442
526
|
// Rule 4: hostnames and URLs whose registrable domain is not on the
|
|
443
527
|
// allowlist. URLs keep their path suffix; bare hostnames collapse.
|
|
444
528
|
// Loopback (localhost, 127.0.0.1) and everything on the allowlist
|
|
445
529
|
// survive; every other public hostname or URL loses its host.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
530
|
+
// allowHosts / allowExtensions declared earlier (Codex P1-1) so the
|
|
531
|
+
// rule-3 bare-remote stash can gate on the host allowlist too. Rule
|
|
532
|
+
// 4 narrowing (0.28.2, issue #233, design amendment R5d): a token
|
|
533
|
+
// whose trailing dot-label is a known file extension is NOT a
|
|
534
|
+
// hostname, killing the `Backstory-product-brief.md` and
|
|
535
|
+
// `intake.json` false positives Barry hit in the WSD round-trip.
|
|
536
|
+
// The bundled shortlist lives in redact-allowlist.json under the
|
|
537
|
+
// `extensions` field; the operator overlay merges via
|
|
538
|
+
// context.allowExtensions.
|
|
450
539
|
|
|
451
540
|
// Rule 9: URL-embedded credentials (design amendment R3b, added
|
|
452
541
|
// round 4 for F-P0-B). Runs BEFORE the rule-4 hostname pass so a
|
|
@@ -459,20 +548,46 @@ export function redact(input, context = {}) {
|
|
|
459
548
|
// `url-credential`; false positives are visible in the disclosure.
|
|
460
549
|
text = redactUrlCredentials(text, ledger);
|
|
461
550
|
|
|
462
|
-
// URLs first (http/https), then bare hostnames.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
551
|
+
// URLs first (http/https/git+ssh/ssh/git), then bare hostnames.
|
|
552
|
+
// 0.28.3 (R5f, issue #243): the URL rule accepts git+ssh/ssh/git
|
|
553
|
+
// schemes with an optional userinfo prefix so the host inside a git
|
|
554
|
+
// clone URL can be folded by the same code path that folds an https
|
|
555
|
+
// URL host. The rule-3 guard above has already protected the `git@`
|
|
556
|
+
// sentinel from the email rule; here the sentinel is preserved and
|
|
557
|
+
// only the host portion is folded.
|
|
558
|
+
const urlRe = /(https?|git\+ssh|ssh|git):\/\/((?:[A-Za-z0-9._%+-]+@)?)([A-Za-z0-9.-]+)((?::[0-9]+)?(?:\/[^\s)]*)?)/g;
|
|
559
|
+
text = replaceRegex(text, urlRe, (_m, scheme, userinfo, host, tail) => {
|
|
560
|
+
if (isHostAllowed(host, allowHosts)) return `${scheme}://${userinfo}${host}${tail}`;
|
|
561
|
+
return `${scheme}://${userinfo}<host>${tail}`;
|
|
467
562
|
}, 'hostname', ledger);
|
|
468
563
|
// Bare hostnames: a word that looks like a domain (has a dot,
|
|
469
564
|
// ends with a 2+ letter TLD) and is not on the allowlist. Skip if
|
|
470
565
|
// preceded by @ (already handled by rule 3), a slash or backslash
|
|
471
566
|
// (path fragment; the winAbsRe substitution leaves `<path>\file.ext`
|
|
472
567
|
// which must not fold to `<host>`), a digit-dot (IP handled by
|
|
473
|
-
// rule 5b), or another word character (a longer host
|
|
474
|
-
|
|
568
|
+
// rule 5b), a hyphen or another word character (a longer host
|
|
569
|
+
// suffix or a filename-internal segment). Adding `-` to the
|
|
570
|
+
// lookbehind is the 0.28.2 (issue #233) fix that stops
|
|
571
|
+
// `Backstory-product-brief.md` folding on its internal
|
|
572
|
+
// `product-brief.md` slice; the extension-refusal step below is the
|
|
573
|
+
// second layer. Design amendment R5e (0.28.3, issue #247): the
|
|
574
|
+
// `{2,}` trailing class also functions as the single-letter-TLD
|
|
575
|
+
// exclusion - a token like `a.b.c` has a one-letter tail and is
|
|
576
|
+
// treated as a filename fragment, not a hostname.
|
|
577
|
+
const bareHostRe = /(?<![@/\\\w.\-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?![\w.-])/g;
|
|
475
578
|
text = replaceRegex(text, bareHostRe, (host) => {
|
|
579
|
+
// 0.28.2 (issue #233, design amendment R5d): a candidate whose
|
|
580
|
+
// trailing dot-label is a known file extension is not a hostname;
|
|
581
|
+
// leave it intact and record no ledger row (we are not redacting,
|
|
582
|
+
// we are correctly leaving alone). This narrowing catches shapes
|
|
583
|
+
// like `report.log`, `intake.json` and `Backstory-example.md`
|
|
584
|
+
// that pass the lookbehind guard because they start after a
|
|
585
|
+
// whitespace or line boundary.
|
|
586
|
+
const lastDot = host.lastIndexOf('.');
|
|
587
|
+
if (lastDot > 0) {
|
|
588
|
+
const trailingLabel = host.slice(lastDot + 1).toLowerCase();
|
|
589
|
+
if (allowExtensions.has(trailingLabel)) return host;
|
|
590
|
+
}
|
|
476
591
|
if (isHostAllowed(host, allowHosts)) return host;
|
|
477
592
|
return '<host>';
|
|
478
593
|
}, 'hostname', ledger);
|
package/src/feedback/render.js
CHANGED
|
@@ -110,27 +110,39 @@ function truncateToBytes(text, maxBytes) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* Render the
|
|
114
|
-
*
|
|
115
|
-
*
|
|
113
|
+
* Render the fold comment body used on the dedupe path (slice 4).
|
|
114
|
+
* 0.28.2 (issue #234, Barry ruling 2026-09-21): the retired
|
|
115
|
+
* "+1 from another reporter." payload is gone; the comment always
|
|
116
|
+
* carries the full report so a human can judge whether the fold is
|
|
117
|
+
* correct. The header line names the fold decision. The visible
|
|
118
|
+
* fingerprint line at the bottom stays so a later submit can still
|
|
119
|
+
* match on it.
|
|
116
120
|
*
|
|
117
121
|
* @param {object} entry
|
|
118
|
-
* @param {{ body: string, ledger: Array<{ rule: string, count: number }> }} redacted
|
|
119
|
-
* @param {{ fingerprint: string,
|
|
122
|
+
* @param {{ title: string, body: string, evidence: Array<{ kind: string, value: string }>, ledger: Array<{ rule: string, count: number }> }} redacted
|
|
123
|
+
* @param {{ fingerprint: string, matchedTitle?: string }} meta
|
|
120
124
|
* @returns {RenderedComment}
|
|
121
125
|
*/
|
|
122
126
|
export function renderComment(entry, redacted, meta) {
|
|
123
127
|
const lines = [];
|
|
124
|
-
lines.push('
|
|
128
|
+
lines.push('Apparent duplicate of the issue subject; posting the full report so a human can judge.');
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(`## Report: ${renderTitle(entry, redacted.title)}`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(redacted.body);
|
|
125
133
|
lines.push('');
|
|
126
|
-
lines.push(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
lines.push('---');
|
|
135
|
+
lines.push('**Evidence**');
|
|
136
|
+
if (Array.isArray(redacted.evidence) && redacted.evidence.length > 0) {
|
|
137
|
+
for (const ev of redacted.evidence) {
|
|
138
|
+
lines.push(`- \`${ev.value}\` (${ev.kind})`);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
lines.push('- (none)');
|
|
132
142
|
}
|
|
133
143
|
lines.push('');
|
|
144
|
+
lines.push(renderEnvironmentTable(entry));
|
|
145
|
+
lines.push('');
|
|
134
146
|
lines.push(`rcf-feedback-fingerprint: ${meta.fingerprint}`);
|
|
135
147
|
return { body: `${lines.join('\n')}\n` };
|
|
136
148
|
}
|
|
@@ -241,10 +253,20 @@ function renderEnvironmentTable(entry) {
|
|
|
241
253
|
t.libraryPrefix ? `(library ${t.libraryPrefix}${t.libraryRef ? ` ${t.libraryRef}` : ''}${t.resolvedSha ? `, git ${String(t.resolvedSha).slice(0, 7)}` : t.tarballSha256 ? `, tarball ${String(t.tarballSha256).slice(0, 7)}` : ''})` : null,
|
|
242
254
|
].filter(Boolean).join(' ')
|
|
243
255
|
: 'n/a';
|
|
256
|
+
// 0.28.2 (issue #234): a "target" row goes on the environment
|
|
257
|
+
// table for core entries too, so submit's fingerprint-verification
|
|
258
|
+
// step can compare the entry's verb path against a candidate's
|
|
259
|
+
// recorded target and refuse to fold across differing targets. For
|
|
260
|
+
// blueprint entries the blueprint cell already carries the target
|
|
261
|
+
// identity, so the target row is left as "-" to avoid duplication.
|
|
262
|
+
const targetCell = kind === 'blueprint'
|
|
263
|
+
? '-'
|
|
264
|
+
: (typeof t.ref === 'string' && t.ref.length > 0 ? t.ref : '-');
|
|
244
265
|
const rows = [
|
|
245
266
|
['field', 'value'],
|
|
246
267
|
['kind', kind],
|
|
247
268
|
['blueprint', blueprintCell],
|
|
269
|
+
['target', targetCell],
|
|
248
270
|
['anchor', entry?.anchor ?? '-'],
|
|
249
271
|
['symptom class', entry?.symptomClass ?? '-'],
|
|
250
272
|
['severity', entry?.severity ?? '-'],
|
|
@@ -9,7 +9,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { rcfError } from '#core/errors';
|
|
12
|
-
import { validateDocument } from '#core/store';
|
|
12
|
+
import { buildNextManifest, validateDocument } from '#core/store';
|
|
13
13
|
|
|
14
14
|
const ID_RE = /^ic-\d{4}-\d{2}-\d{2}-(\d{3})$/;
|
|
15
15
|
|
|
@@ -73,8 +73,11 @@ export function composeIntakeRecord({ manifest, fidelity, artefacts, validationF
|
|
|
73
73
|
* @returns {Promise<{ record: object } | import('#core/errors').RcfError>}
|
|
74
74
|
*/
|
|
75
75
|
export async function writeIntakeRecord({ projectRoot, tree, record }) {
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// 0.28.2 (Codex review follow-up on #230): the writer synthesises
|
|
77
|
+
// the next manifest via the same `buildNextManifest` helper the
|
|
78
|
+
// dry-run branch uses, so preview and write share one composition
|
|
79
|
+
// path. `validateDocument` then runs on the identical shape.
|
|
80
|
+
const nextManifest = buildNextManifest({ manifest: tree?.manifest ?? {}, record, verb: 'intake' });
|
|
78
81
|
const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: 'rcf/manifest.json' });
|
|
79
82
|
if (validation) return validation;
|
|
80
83
|
const abs = join(projectRoot, 'rcf', 'manifest.json');
|
|
@@ -82,23 +82,60 @@ export async function runIntakePhases({ projectRoot, artefactPaths, kindHint = n
|
|
|
82
82
|
|
|
83
83
|
// Fold non-interactive input: extra findings, operator responses,
|
|
84
84
|
// and elicitationScope hints all land here.
|
|
85
|
+
//
|
|
86
|
+
// 0.28.2 (issue #229): findings are material-scoped, not
|
|
87
|
+
// artefact-scoped. Two artefacts that both trip the same branch
|
|
88
|
+
// ("web UI but no sign-in surface named") emit the same finding
|
|
89
|
+
// twice with byte-identical detail text, and downstream that
|
|
90
|
+
// inflated `findings=2` on a record with one finding. Dedupe by
|
|
91
|
+
// `${kind}::${detail}` (first-wins) before timestamping so the
|
|
92
|
+
// material carries one finding, not N.
|
|
93
|
+
//
|
|
94
|
+
// Same commit fixes the second half of #229: operator-supplied
|
|
95
|
+
// `validationFindings` on --input were dropped unless the kind was
|
|
96
|
+
// `otherDeclared`. Fold every input finding into the same map so
|
|
97
|
+
// an operator-authored `impliedButNotStated`, `contradiction` or
|
|
98
|
+
// `missingLoadBearingConstraint` survives the round-trip and
|
|
99
|
+
// coalesces with a scanned duplicate (the operator's response is
|
|
100
|
+
// preserved when it does).
|
|
85
101
|
const inputFindings = Array.isArray(input?.validationFindings) ? input.validationFindings : [];
|
|
102
|
+
|
|
103
|
+
/** @type {Map<string, object>} */
|
|
104
|
+
const foldedFindings = new Map();
|
|
105
|
+
const foldKey = (f) => `${String(f.kind ?? '')}::${String(f.detail ?? '')}`;
|
|
106
|
+
for (const f of allFindings) {
|
|
107
|
+
const k = foldKey(f);
|
|
108
|
+
if (!foldedFindings.has(k)) foldedFindings.set(k, f);
|
|
109
|
+
}
|
|
110
|
+
for (const f of inputFindings) {
|
|
111
|
+
if (typeof f?.kind !== 'string' || typeof f?.detail !== 'string') continue;
|
|
112
|
+
const k = foldKey(f);
|
|
113
|
+
if (!foldedFindings.has(k)) foldedFindings.set(k, f);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 0.28.2 (Codex review follow-up on #229): the response lookup is
|
|
117
|
+
// keyed by (kind, detail) too, so resolving one finding never
|
|
118
|
+
// silently marks a different-kind finding with the same detail as
|
|
119
|
+
// resolved. Previously the lookup was `detail` only and an
|
|
120
|
+
// answered `impliedButNotStated` leaked its response onto an
|
|
121
|
+
// unanswered `contradiction` with the same detail string.
|
|
86
122
|
const inputResponses = new Map();
|
|
87
123
|
for (const f of inputFindings) {
|
|
88
|
-
if (typeof f?.
|
|
89
|
-
|
|
124
|
+
if (typeof f?.kind === 'string'
|
|
125
|
+
&& typeof f?.detail === 'string'
|
|
126
|
+
&& typeof f?.operatorResponse === 'string') {
|
|
127
|
+
inputResponses.set(foldKey(f), f.operatorResponse);
|
|
90
128
|
}
|
|
91
|
-
if (f?.kind === 'otherDeclared') allFindings.push(f);
|
|
92
129
|
}
|
|
93
130
|
|
|
94
131
|
const isoNow = now.toISOString();
|
|
95
|
-
const findingsWithTimestamps =
|
|
96
|
-
const entry = { kind: f.kind, detail: f.detail, raisedAt: isoNow };
|
|
132
|
+
const findingsWithTimestamps = Array.from(foldedFindings.values()).map((f) => {
|
|
133
|
+
const entry = { kind: f.kind, detail: f.detail, raisedAt: f.raisedAt ?? isoNow };
|
|
97
134
|
if (f.kindDescription) entry.kindDescription = f.kindDescription;
|
|
98
|
-
const response = inputResponses.get(f
|
|
135
|
+
const response = f.operatorResponse ?? inputResponses.get(foldKey(f)) ?? input?.operatorResponse;
|
|
99
136
|
if (typeof response === 'string' && response.length > 0) {
|
|
100
137
|
entry.operatorResponse = response;
|
|
101
|
-
entry.resolvedAt = isoNow;
|
|
138
|
+
entry.resolvedAt = f.resolvedAt ?? isoNow;
|
|
102
139
|
}
|
|
103
140
|
return entry;
|
|
104
141
|
});
|
|
@@ -19,7 +19,7 @@ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
|
|
21
21
|
import { rcfError } from '#core/errors';
|
|
22
|
-
import { validateDocument } from '#core/store';
|
|
22
|
+
import { buildNextManifest, validateDocument } from '#core/store';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* @typedef {object} PreflightServiceRuling
|
|
@@ -210,16 +210,16 @@ export function composeOptOutRecord({
|
|
|
210
210
|
export async function writePreflightRecord({
|
|
211
211
|
projectRoot, tree, record, optOuts = [], options = {},
|
|
212
212
|
}) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
nextManifest
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
213
|
+
// 0.28.2 (Codex review follow-up on #232): compose the next
|
|
214
|
+
// manifest via the shared `buildNextManifest` helper so the write
|
|
215
|
+
// and dry-run branches produce byte-identical shapes; validation
|
|
216
|
+
// then runs on the same result.
|
|
217
|
+
const nextManifest = buildNextManifest({
|
|
218
|
+
manifest: tree.manifest ?? {},
|
|
219
|
+
record,
|
|
220
|
+
verb: 'preflight',
|
|
221
|
+
extraRecords: optOuts,
|
|
222
|
+
});
|
|
223
223
|
|
|
224
224
|
const relPath = 'rcf/manifest.json';
|
|
225
225
|
const validation = validateDocument({ doc: nextManifest, kind: 'manifest', filePath: relPath });
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Detection model foundation (REQ-172; proposal 2026-09-22 §2.2 v3).
|
|
2
|
+
//
|
|
3
|
+
// Three pure functions:
|
|
4
|
+
//
|
|
5
|
+
// - `hashDocument(doc)` SHA-256 over the canonical JSON of the
|
|
6
|
+
// parsed document. `sha256:<hex>` string.
|
|
7
|
+
// - `computeTreeHash(hashes)` SHA-256 over the sorted `(id, docHash)`
|
|
8
|
+
// pairs, canonically JSON-encoded.
|
|
9
|
+
// - `computeDelta(tree, freeze, ledgers)`
|
|
10
|
+
// the whole change model, returning the
|
|
11
|
+
// shape the proposal contracts in §2.2.
|
|
12
|
+
//
|
|
13
|
+
// Canonical JSON, chosen at proposal §2.1: keys sorted recursively, no
|
|
14
|
+
// whitespace, no trailing separators. A hand reformat that changes
|
|
15
|
+
// nothing is not a change; that is what an operator expects, and it
|
|
16
|
+
// costs one canonicalise per document per read (milliseconds at the
|
|
17
|
+
// backstory-scale tree of 700 criteria). Hashing raw bytes would be
|
|
18
|
+
// one line shorter and would report drift after `rcf define update`
|
|
19
|
+
// rewrote a file it did not semantically touch (ADR-4120).
|
|
20
|
+
//
|
|
21
|
+
// Document granularity (proposal §2.2, ADR-4120): the delta is a set of
|
|
22
|
+
// standalone-document ids -- one entry per document the walker exposes
|
|
23
|
+
// on `tree.byId` (PRD, TAD, BS, REQ, US, TAC, ADR, FBS, TS, CN, EVAL),
|
|
24
|
+
// plus one `ledger:<name>` entry per sidecar ledger the caller passes.
|
|
25
|
+
// Inline AC and TC entries ride inside the parent hash: editing
|
|
26
|
+
// `AC-207-4` shows as `US-207 changed`, and the criterion-level diff is
|
|
27
|
+
// git's job.
|
|
28
|
+
//
|
|
29
|
+
// Pure: no filesystem I/O, no state, no time. Every call takes plain
|
|
30
|
+
// data in and returns plain data out.
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Canonicalise a value to the JSON string the hash is taken over.
|
|
36
|
+
*
|
|
37
|
+
* - Objects: keys sorted lexicographically, then each `<key>:<value>`
|
|
38
|
+
* joined by `,` and wrapped in `{}`.
|
|
39
|
+
* - Arrays: elements in source order, canonicalised, joined by `,`
|
|
40
|
+
* and wrapped in `[]`.
|
|
41
|
+
* - Strings: `JSON.stringify` (RFC 8259 escaping).
|
|
42
|
+
* - Numbers and booleans: `JSON.stringify` (finite numbers only; NaN
|
|
43
|
+
* and +/-Infinity yield `null` in JSON, which is honest for a value
|
|
44
|
+
* the parsed document could not legally hold).
|
|
45
|
+
* - `null` and `undefined`: `undefined` is omitted from objects and
|
|
46
|
+
* arrays (matches `JSON.stringify` semantics); a top-level
|
|
47
|
+
* `undefined` yields the empty string, which the caller treats as a
|
|
48
|
+
* distinct value from `null` (never expected in a parsed document).
|
|
49
|
+
*
|
|
50
|
+
* No whitespace anywhere. Deterministic on the input tree.
|
|
51
|
+
*
|
|
52
|
+
* @param {unknown} value
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function canonicaliseJson(value) {
|
|
56
|
+
if (value === null) return 'null';
|
|
57
|
+
const t = typeof value;
|
|
58
|
+
if (t === 'string') return JSON.stringify(value);
|
|
59
|
+
if (t === 'number') return Number.isFinite(value) ? JSON.stringify(value) : 'null';
|
|
60
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
61
|
+
if (t === 'undefined') return '';
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
const parts = [];
|
|
64
|
+
for (const el of value) {
|
|
65
|
+
// JSON.stringify drops undefined elements to null in arrays; keep
|
|
66
|
+
// that shape so an accidentally-sparse array hashes the same as
|
|
67
|
+
// its JSON.stringify form (never expected in a parsed document,
|
|
68
|
+
// where JSON.parse cannot produce one).
|
|
69
|
+
parts.push(el === undefined ? 'null' : canonicaliseJson(el));
|
|
70
|
+
}
|
|
71
|
+
return `[${parts.join(',')}]`;
|
|
72
|
+
}
|
|
73
|
+
if (t === 'object') {
|
|
74
|
+
const keys = Object.keys(/** @type {object} */ (value)).sort();
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const k of keys) {
|
|
77
|
+
const v = (/** @type {Record<string, unknown>} */ (value))[k];
|
|
78
|
+
if (v === undefined) continue; // JSON.stringify omits undefined in objects.
|
|
79
|
+
parts.push(`${JSON.stringify(k)}:${canonicaliseJson(v)}`);
|
|
80
|
+
}
|
|
81
|
+
return `{${parts.join(',')}}`;
|
|
82
|
+
}
|
|
83
|
+
// Functions, symbols, bigints: never legally present in a parsed
|
|
84
|
+
// document. Fall back to JSON.stringify's own null coercion.
|
|
85
|
+
return 'null';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Hash one parsed document. Returns `sha256:<64-hex>` per proposal
|
|
90
|
+
* §2.1. Accepts any value the walker's `tree.byId` map holds (an
|
|
91
|
+
* object) and also `null` / primitives (a ledger sidecar hashed as a
|
|
92
|
+
* whole, for example, may pass a plain array).
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} doc
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function hashDocument(doc) {
|
|
98
|
+
const canonical = canonicaliseJson(doc);
|
|
99
|
+
const hex = createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
100
|
+
return `sha256:${hex}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hash a `docHashes` map. Sorts the `(id, docHash)` pairs
|
|
105
|
+
* lexicographically by id, canonically encodes the array of pairs,
|
|
106
|
+
* and hashes that string. Matches proposal §2.1 ("the sorted (id,
|
|
107
|
+
* docHash) pairs"). Accepts a plain object or a Map.
|
|
108
|
+
*
|
|
109
|
+
* @param {Record<string, string> | Map<string, string>} docHashes
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function computeTreeHash(docHashes) {
|
|
113
|
+
/** @type {[string, string][]} */
|
|
114
|
+
const pairs = [];
|
|
115
|
+
if (docHashes instanceof Map) {
|
|
116
|
+
for (const [id, h] of docHashes) pairs.push([String(id), String(h)]);
|
|
117
|
+
} else if (docHashes && typeof docHashes === 'object') {
|
|
118
|
+
for (const [id, h] of Object.entries(docHashes)) pairs.push([id, String(h)]);
|
|
119
|
+
}
|
|
120
|
+
pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
121
|
+
const hex = createHash('sha256').update(canonicaliseJson(pairs), 'utf8').digest('hex');
|
|
122
|
+
return `sha256:${hex}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every standalone-document id the walker holds, sorted. Every id in
|
|
127
|
+
* `tree.byId` (PRD, TAD, BS, REQ, US, TAC, ADR, FBS, TS, CN, EVAL).
|
|
128
|
+
* Inline AC / TC ids ride inside their parent's hash and are NOT
|
|
129
|
+
* enumerated here; delta granularity is the standalone document
|
|
130
|
+
* (proposal §2.2, ADR-4120).
|
|
131
|
+
*
|
|
132
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
function standaloneIds(tree) {
|
|
136
|
+
if (!tree || !tree.byId) return [];
|
|
137
|
+
return [...tree.byId.keys()].sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {object} LedgerBundle
|
|
142
|
+
* @property {unknown} [brief] brief-ledger.json body (null / absent = untouched)
|
|
143
|
+
* @property {unknown} [decisions] decisions-ledger.json body
|
|
144
|
+
* @property {unknown} [concerns] concern-ledger.json body
|
|
145
|
+
* @property {unknown} [probes] probe-ledger.json body
|
|
146
|
+
*
|
|
147
|
+
* The delta hashes only the ledgers the caller passes -- if a project
|
|
148
|
+
* has not authored any decisions, the caller passes no `decisions`
|
|
149
|
+
* key and the delta records no `ledger:decisions` docHash. This
|
|
150
|
+
* keeps the model additive: sidecars appear when they exist, never
|
|
151
|
+
* before.
|
|
152
|
+
*/
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @typedef {object} FreezeRecord
|
|
156
|
+
* @property {string} [frozenAt]
|
|
157
|
+
* @property {string} [treeHash]
|
|
158
|
+
* @property {Record<string, string>} [docHashes]
|
|
159
|
+
* @property {number} [briefStatements]
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* @typedef {object} DeltaResult
|
|
164
|
+
* @property {boolean} frozen
|
|
165
|
+
* false when the freeze record is absent (a fresh tree; everything
|
|
166
|
+
* is `added`, per proposal §2.2 "before the first freeze the delta
|
|
167
|
+
* is the whole tree").
|
|
168
|
+
* @property {string | null} frozenAt
|
|
169
|
+
* @property {string | null} treeHash
|
|
170
|
+
* The tree hash the freeze record stored, or null when unfrozen.
|
|
171
|
+
* @property {string} currentTreeHash
|
|
172
|
+
* The tree hash recomputed from the live tree.
|
|
173
|
+
* @property {string[]} changed ids whose hash differs
|
|
174
|
+
* @property {string[]} added ids not in `docHashes`
|
|
175
|
+
* @property {string[]} removed ids in `docHashes` with no live document
|
|
176
|
+
* @property {number[]} briefSince
|
|
177
|
+
* Ledger-brief statement ids above the freeze high-water mark.
|
|
178
|
+
* @property {number} unchanged count of ids present in both maps with
|
|
179
|
+
* the same hash (excludes ledgers).
|
|
180
|
+
*/
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Compute the delta between the live tree and the freeze record.
|
|
184
|
+
*
|
|
185
|
+
* Pure. No filesystem, no time, no state. Runs on every read (the
|
|
186
|
+
* proposal's whole point: the freeze record and the tree are the only
|
|
187
|
+
* inputs to the change model).
|
|
188
|
+
*
|
|
189
|
+
* @param {import('#core/store/walker.js').TreeModel} tree
|
|
190
|
+
* @param {FreezeRecord | null | undefined} freeze
|
|
191
|
+
* @param {LedgerBundle | null | undefined} [ledgers]
|
|
192
|
+
* @returns {DeltaResult}
|
|
193
|
+
*/
|
|
194
|
+
export function computeDelta(tree, freeze, ledgers) {
|
|
195
|
+
/** @type {Record<string, string>} */
|
|
196
|
+
const currentHashes = {};
|
|
197
|
+
for (const id of standaloneIds(tree)) {
|
|
198
|
+
currentHashes[id] = hashDocument(tree.byId.get(id));
|
|
199
|
+
}
|
|
200
|
+
const ledgerBundle = ledgers ?? {};
|
|
201
|
+
for (const [name, body] of Object.entries(ledgerBundle)) {
|
|
202
|
+
if (body === undefined || body === null) continue;
|
|
203
|
+
currentHashes[`ledger:${name}`] = hashDocument(body);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const currentTreeHash = computeTreeHash(currentHashes);
|
|
207
|
+
|
|
208
|
+
// Unfrozen path (proposal §2.2): the delta is the whole tree,
|
|
209
|
+
// reported as `added`. frozenAt / treeHash null; briefSince empty
|
|
210
|
+
// (there is no high-water mark to compare against yet, and D1's
|
|
211
|
+
// scoping rule keeps the greenfield brief in the delta by definition
|
|
212
|
+
// -- the ledger itself is a docHash entry when the caller passes one).
|
|
213
|
+
if (!freeze || typeof freeze !== 'object' || !freeze.docHashes) {
|
|
214
|
+
return {
|
|
215
|
+
frozen: false,
|
|
216
|
+
frozenAt: null,
|
|
217
|
+
treeHash: null,
|
|
218
|
+
currentTreeHash,
|
|
219
|
+
changed: [],
|
|
220
|
+
added: Object.keys(currentHashes).sort(),
|
|
221
|
+
removed: [],
|
|
222
|
+
briefSince: [],
|
|
223
|
+
unchanged: 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const frozenHashes = freeze.docHashes;
|
|
228
|
+
/** @type {string[]} */
|
|
229
|
+
const changed = [];
|
|
230
|
+
/** @type {string[]} */
|
|
231
|
+
const added = [];
|
|
232
|
+
/** @type {string[]} */
|
|
233
|
+
const removed = [];
|
|
234
|
+
let unchanged = 0;
|
|
235
|
+
|
|
236
|
+
const liveIds = Object.keys(currentHashes);
|
|
237
|
+
for (const id of liveIds) {
|
|
238
|
+
if (Object.prototype.hasOwnProperty.call(frozenHashes, id)) {
|
|
239
|
+
if (frozenHashes[id] === currentHashes[id]) unchanged += 1;
|
|
240
|
+
else changed.push(id);
|
|
241
|
+
} else {
|
|
242
|
+
added.push(id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const id of Object.keys(frozenHashes)) {
|
|
246
|
+
if (!Object.prototype.hasOwnProperty.call(currentHashes, id)) removed.push(id);
|
|
247
|
+
}
|
|
248
|
+
changed.sort();
|
|
249
|
+
added.sort();
|
|
250
|
+
removed.sort();
|
|
251
|
+
|
|
252
|
+
const highWater = Number.isFinite(freeze.briefStatements) ? Number(freeze.briefStatements) : 0;
|
|
253
|
+
/** @type {number[]} */
|
|
254
|
+
const briefSince = [];
|
|
255
|
+
const brief = ledgerBundle.brief;
|
|
256
|
+
if (brief && typeof brief === 'object' && Array.isArray(/** @type {any} */ (brief).statements)) {
|
|
257
|
+
for (const s of /** @type {any} */ (brief).statements) {
|
|
258
|
+
if (!s || typeof s !== 'object') continue;
|
|
259
|
+
const n = Number(s.id);
|
|
260
|
+
if (Number.isFinite(n) && n > highWater) briefSince.push(n);
|
|
261
|
+
}
|
|
262
|
+
briefSince.sort((a, b) => a - b);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
frozen: true,
|
|
267
|
+
frozenAt: typeof freeze.frozenAt === 'string' ? freeze.frozenAt : null,
|
|
268
|
+
treeHash: typeof freeze.treeHash === 'string' ? freeze.treeHash : null,
|
|
269
|
+
currentTreeHash,
|
|
270
|
+
changed,
|
|
271
|
+
added,
|
|
272
|
+
removed,
|
|
273
|
+
briefSince,
|
|
274
|
+
unchanged,
|
|
275
|
+
};
|
|
276
|
+
}
|