@remnic/core 9.69.6 → 9.69.8
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/dist/access-admin-ops-surface.js +26 -26
- package/dist/access-authorization-probe.js +27 -27
- package/dist/access-boundary.js +26 -26
- package/dist/access-cli.js +84 -84
- package/dist/access-extraction-force-flush.js +26 -26
- package/dist/access-http-query.js +28 -28
- package/dist/access-http.js +44 -44
- package/dist/access-identity-continuity-surface.js +26 -26
- package/dist/access-lcm-surface.js +26 -26
- package/dist/access-mcp.js +35 -35
- package/dist/access-namespace-preflight.js +26 -26
- package/dist/access-observe-write-surface.js +26 -26
- package/dist/access-offline-manifest.js +3 -3
- package/dist/access-operations-batch.js +29 -29
- package/dist/access-operations.js +34 -34
- package/dist/access-recall-concurrency.js +26 -26
- package/dist/access-recall-response.js +26 -26
- package/dist/access-recall-surface.js +26 -26
- package/dist/access-schema.js +5 -5
- package/dist/access-service.js +26 -26
- package/dist/access-surface-catalog.js +28 -28
- package/dist/active-recall.js +5 -5
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/briefing.js +6 -6
- package/dist/buffer.js +3 -3
- package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-7FJQINUR.js} +2 -2
- package/dist/causal-behavior.js +2 -2
- package/dist/causal-chain.js +2 -2
- package/dist/causal-consolidation.js +9 -9
- package/dist/causal-retrieval.js +2 -2
- package/dist/causal-trajectory-graph.js +2 -2
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-7RZJWZ46.js → chunk-227UFSEQ.js} +5 -5
- package/dist/{chunk-QA6H4PIO.js → chunk-26JWBSWD.js} +3 -3
- package/dist/{chunk-7VA7PARC.js → chunk-2MW3UQNX.js} +2 -2
- package/dist/{chunk-BXLOS5AJ.js → chunk-2NLLXCJG.js} +2 -2
- package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
- package/dist/{chunk-P6GKRT2H.js → chunk-2Z3DG5CQ.js} +1 -1
- package/dist/{chunk-6EXRODDF.js → chunk-3FXXFLVK.js} +2 -2
- package/dist/{chunk-XOGYSWQZ.js → chunk-3QWF54M4.js} +18 -18
- package/dist/{chunk-YTCE2JQL.js → chunk-44HBCMOA.js} +99 -48
- package/dist/chunk-44HBCMOA.js.map +1 -0
- package/dist/{chunk-IE7N2YK6.js → chunk-4PJYD2L3.js} +2 -2
- package/dist/{chunk-MWLBRFLY.js → chunk-5PTS43CH.js} +2 -2
- package/dist/{chunk-NM5EOKN7.js → chunk-6C57JBJ3.js} +2 -2
- package/dist/{chunk-WTWHORVW.js → chunk-754GA2UE.js} +5 -5
- package/dist/{chunk-VDX2J7OX.js → chunk-7LCGFUMB.js} +4 -4
- package/dist/{chunk-YPNTBYEV.js → chunk-A3WMMQAH.js} +3 -3
- package/dist/{chunk-SZZGVXKC.js → chunk-ANN5VMHT.js} +2 -2
- package/dist/{chunk-OLBZ2WGG.js → chunk-C2JCVZWX.js} +2 -2
- package/dist/{chunk-7DXW5V2W.js → chunk-CTCGHNWN.js} +5 -5
- package/dist/{chunk-JJAOVJ3E.js → chunk-DJZWNKFO.js} +2 -2
- package/dist/{chunk-PY5E3UOD.js → chunk-DKZR56A3.js} +2 -2
- package/dist/{chunk-6JYTP3JP.js → chunk-E5WTGEWX.js} +3 -3
- package/dist/{chunk-WAPSYHNR.js → chunk-EN75MUTO.js} +5 -5
- package/dist/{chunk-OMA2B7GY.js → chunk-G4MCAHMF.js} +2 -2
- package/dist/{chunk-EJJ57PTY.js → chunk-GD7D7WP2.js} +2 -2
- package/dist/{chunk-X3DTN2RC.js → chunk-GFGQOZGG.js} +10 -10
- package/dist/{chunk-EVLHZ6F3.js → chunk-GISV63M2.js} +2 -2
- package/dist/{chunk-YERBUSU2.js → chunk-H3HOC6UT.js} +2 -2
- package/dist/{chunk-AJFKQJQK.js → chunk-HINMCD7V.js} +2 -2
- package/dist/{chunk-ISDSX7GW.js → chunk-IGUWU5BD.js} +1 -1
- package/dist/{chunk-W6V2XVUO.js → chunk-IIO4C3YL.js} +4 -4
- package/dist/{chunk-SKVSBTFQ.js → chunk-IWO4HEOL.js} +2 -2
- package/dist/{chunk-TB2MJRI6.js → chunk-J3NGQ5SC.js} +2 -2
- package/dist/chunk-J3NGQ5SC.js.map +1 -0
- package/dist/{chunk-ZIZUB6VB.js → chunk-J55NAXDH.js} +34 -34
- package/dist/{chunk-3X7Z467X.js → chunk-JAU4MRKB.js} +2 -2
- package/dist/{chunk-XJJYSX6F.js → chunk-JCUG7AXZ.js} +4 -4
- package/dist/{chunk-BX6GW4KC.js → chunk-JMAG7WZ7.js} +2 -2
- package/dist/{chunk-NAB4MKTJ.js → chunk-KAVQ5GYY.js} +6 -5
- package/dist/{chunk-NAB4MKTJ.js.map → chunk-KAVQ5GYY.js.map} +1 -1
- package/dist/{chunk-MDQXW235.js → chunk-KLL5SPER.js} +2 -2
- package/dist/{chunk-DD2BKHSD.js → chunk-KZMFU7YL.js} +2 -2
- package/dist/{chunk-C5FRCM4B.js → chunk-LHGIBXVX.js} +2 -2
- package/dist/{chunk-CGFDT752.js → chunk-LVYO2HZJ.js} +4 -4
- package/dist/{chunk-TP3N5364.js → chunk-M5GBTETG.js} +2 -2
- package/dist/{chunk-PGD3DXZP.js → chunk-M5UJJUON.js} +3 -3
- package/dist/{chunk-HDEQRI2O.js → chunk-NAZROP6I.js} +2 -2
- package/dist/{chunk-R3YVNAJB.js → chunk-NDE7CH6C.js} +2 -2
- package/dist/{chunk-BIJAWZYW.js → chunk-OLT3YTDH.js} +4 -4
- package/dist/{chunk-4OH6FGAO.js → chunk-PEOFTEUC.js} +12 -12
- package/dist/{chunk-GM35IHF7.js → chunk-PRK4WTPU.js} +2 -2
- package/dist/{chunk-MOUCCUET.js → chunk-QGUFN25Q.js} +2 -2
- package/dist/{chunk-5HP6LVWU.js → chunk-QHYSECIG.js} +2 -2
- package/dist/{chunk-SZ3SRRYZ.js → chunk-RXS4SQ5S.js} +2 -2
- package/dist/{chunk-6KQLDPKA.js → chunk-S6AHHQ6C.js} +2 -2
- package/dist/{chunk-R4FPBSFD.js → chunk-SO73GYOZ.js} +2 -2
- package/dist/{chunk-M4MNLPO7.js → chunk-SXQZMC7R.js} +2 -2
- package/dist/{chunk-JHTF22NQ.js → chunk-TA7UZZMW.js} +2 -2
- package/dist/{chunk-UJICQ77Y.js → chunk-UCNQV43K.js} +2 -2
- package/dist/{chunk-OUAK37ID.js → chunk-VGTXHTWU.js} +2 -2
- package/dist/{chunk-TGDFKC7X.js → chunk-VOPOPTZ7.js} +2 -2
- package/dist/{chunk-IFHT77WK.js → chunk-WF2WCZCR.js} +2 -2
- package/dist/{chunk-6F5WBXE2.js → chunk-X2NFFBTO.js} +4 -4
- package/dist/{chunk-IHFN3JTR.js → chunk-X5TGZAP2.js} +3 -3
- package/dist/{chunk-RJ57OOCD.js → chunk-X66BHMAU.js} +2 -2
- package/dist/{chunk-DQEMWVMT.js → chunk-X7Y7WX73.js} +1 -1
- package/dist/{chunk-FGV7V2N4.js → chunk-Y3WMJAZ3.js} +2 -2
- package/dist/{chunk-ZET6A3OF.js → chunk-YHCBJULX.js} +2 -2
- package/dist/{chunk-LGVIKA7O.js → chunk-YJPFTF57.js} +6 -6
- package/dist/{chunk-EAG7YMIS.js → chunk-YVMTXBJJ.js} +2 -2
- package/dist/{chunk-OUKBMQ2O.js → chunk-Z3SQEEB6.js} +2 -2
- package/dist/{chunk-MVH7YAWY.js → chunk-ZPN6SSWA.js} +2 -2
- package/dist/cli.js +62 -62
- package/dist/coding/export-okf-codegraph.js +6 -6
- package/dist/coding/pre-action-gate.js +2 -2
- package/dist/compounding/engine.js +6 -6
- package/dist/config.js +5 -5
- package/dist/connectors/codex-materialize-runner.js +6 -6
- package/dist/connectors/index.js +6 -6
- package/dist/convergence-refresh.js +8 -8
- package/dist/conversation-index/backend.js +2 -2
- package/dist/corpus-watermark.js +8 -8
- package/dist/entity-retrieval.js +6 -6
- package/dist/external-wiki-access.js +27 -27
- package/dist/extraction-span-source-hash.d.ts +45 -0
- package/dist/extraction-span-source-hash.js +52 -0
- package/dist/extraction-span-source-hash.js.map +1 -0
- package/dist/extraction.js +10 -10
- package/dist/{graph-edge-decay-FXGELBFA.js → graph-edge-decay-DGTBFALQ.js} +4 -4
- package/dist/graph-snapshot.js +3 -3
- package/dist/graph.js +2 -2
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +94 -90
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +2 -2
- package/dist/location/index.js +9 -9
- package/dist/maintenance/memory-governance.js +6 -6
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -6
- package/dist/maintenance/rebuild-memory-projection.js +7 -7
- package/dist/namespaces/migrate.js +7 -7
- package/dist/namespaces/search.js +4 -4
- package/dist/namespaces/storage.js +6 -6
- package/dist/offline-sync.js +2 -2
- package/dist/operator-doctor-corpus.js +9 -9
- package/dist/operator-toolkit.js +21 -21
- package/dist/orchestration/maintenance.js +15 -15
- package/dist/orchestrator.js +86 -86
- package/dist/recall-state-view-zero-diff.d.ts +23 -0
- package/dist/recall-state-view-zero-diff.js +84 -0
- package/dist/recall-state-view-zero-diff.js.map +1 -0
- package/dist/reconcile/cursor.js +7 -2
- package/dist/reconcile/cursor.js.map +1 -1
- package/dist/resume-bundles.js +6 -6
- package/dist/search/factory.js +3 -3
- package/dist/search/index.js +3 -3
- package/dist/semantic-consolidation.js +7 -7
- package/dist/semantic-rule-promotion.js +6 -6
- package/dist/semantic-rule-verifier.js +6 -6
- package/dist/source-agent-qualifier.js +9 -9
- package/dist/standup/build.js +6 -6
- package/dist/storage.js +5 -5
- package/dist/support-passport/index.js +29 -29
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/transfer/export-okf.d.ts +2 -1
- package/dist/transfer/export-okf.js +9 -7
- package/dist/verified-recall.js +6 -6
- package/dist/who-knows-cli.js +7 -7
- package/dist/who-knows.js +6 -6
- package/package.json +2 -2
- package/src/activity/timeline/index.ts +1 -0
- package/src/activity/timeline/week-config-hash.test.ts +207 -0
- package/src/activity/timeline/week-config-hash.ts +83 -0
- package/src/extraction-span-source-hash.test.ts +165 -0
- package/src/extraction-span-source-hash.ts +100 -0
- package/src/location/matching.test.ts +31 -1
- package/src/location/matching.ts +6 -1
- package/src/recall-state-view-zero-diff.test.ts +282 -0
- package/src/recall-state-view-zero-diff.ts +125 -0
- package/src/reconcile/cursor.test.ts +18 -0
- package/src/reconcile/cursor.ts +13 -2
- package/src/shared-context/authority-precedence.test.ts +127 -0
- package/src/shared-context/authority-precedence.ts +68 -0
- package/src/transfer/export-okf.test.ts +22 -0
- package/src/transfer/export-okf.ts +6 -2
- package/dist/chunk-TB2MJRI6.js.map +0 -1
- package/dist/chunk-YTCE2JQL.js.map +0 -1
- /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
- /package/dist/{chunk-7RZJWZ46.js.map → chunk-227UFSEQ.js.map} +0 -0
- /package/dist/{chunk-QA6H4PIO.js.map → chunk-26JWBSWD.js.map} +0 -0
- /package/dist/{chunk-7VA7PARC.js.map → chunk-2MW3UQNX.js.map} +0 -0
- /package/dist/{chunk-BXLOS5AJ.js.map → chunk-2NLLXCJG.js.map} +0 -0
- /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
- /package/dist/{chunk-P6GKRT2H.js.map → chunk-2Z3DG5CQ.js.map} +0 -0
- /package/dist/{chunk-6EXRODDF.js.map → chunk-3FXXFLVK.js.map} +0 -0
- /package/dist/{chunk-XOGYSWQZ.js.map → chunk-3QWF54M4.js.map} +0 -0
- /package/dist/{chunk-IE7N2YK6.js.map → chunk-4PJYD2L3.js.map} +0 -0
- /package/dist/{chunk-MWLBRFLY.js.map → chunk-5PTS43CH.js.map} +0 -0
- /package/dist/{chunk-NM5EOKN7.js.map → chunk-6C57JBJ3.js.map} +0 -0
- /package/dist/{chunk-WTWHORVW.js.map → chunk-754GA2UE.js.map} +0 -0
- /package/dist/{chunk-VDX2J7OX.js.map → chunk-7LCGFUMB.js.map} +0 -0
- /package/dist/{chunk-YPNTBYEV.js.map → chunk-A3WMMQAH.js.map} +0 -0
- /package/dist/{chunk-SZZGVXKC.js.map → chunk-ANN5VMHT.js.map} +0 -0
- /package/dist/{chunk-OLBZ2WGG.js.map → chunk-C2JCVZWX.js.map} +0 -0
- /package/dist/{chunk-7DXW5V2W.js.map → chunk-CTCGHNWN.js.map} +0 -0
- /package/dist/{chunk-JJAOVJ3E.js.map → chunk-DJZWNKFO.js.map} +0 -0
- /package/dist/{chunk-PY5E3UOD.js.map → chunk-DKZR56A3.js.map} +0 -0
- /package/dist/{chunk-6JYTP3JP.js.map → chunk-E5WTGEWX.js.map} +0 -0
- /package/dist/{chunk-WAPSYHNR.js.map → chunk-EN75MUTO.js.map} +0 -0
- /package/dist/{chunk-OMA2B7GY.js.map → chunk-G4MCAHMF.js.map} +0 -0
- /package/dist/{chunk-EJJ57PTY.js.map → chunk-GD7D7WP2.js.map} +0 -0
- /package/dist/{chunk-X3DTN2RC.js.map → chunk-GFGQOZGG.js.map} +0 -0
- /package/dist/{chunk-EVLHZ6F3.js.map → chunk-GISV63M2.js.map} +0 -0
- /package/dist/{chunk-YERBUSU2.js.map → chunk-H3HOC6UT.js.map} +0 -0
- /package/dist/{chunk-AJFKQJQK.js.map → chunk-HINMCD7V.js.map} +0 -0
- /package/dist/{chunk-ISDSX7GW.js.map → chunk-IGUWU5BD.js.map} +0 -0
- /package/dist/{chunk-W6V2XVUO.js.map → chunk-IIO4C3YL.js.map} +0 -0
- /package/dist/{chunk-SKVSBTFQ.js.map → chunk-IWO4HEOL.js.map} +0 -0
- /package/dist/{chunk-ZIZUB6VB.js.map → chunk-J55NAXDH.js.map} +0 -0
- /package/dist/{chunk-3X7Z467X.js.map → chunk-JAU4MRKB.js.map} +0 -0
- /package/dist/{chunk-XJJYSX6F.js.map → chunk-JCUG7AXZ.js.map} +0 -0
- /package/dist/{chunk-BX6GW4KC.js.map → chunk-JMAG7WZ7.js.map} +0 -0
- /package/dist/{chunk-MDQXW235.js.map → chunk-KLL5SPER.js.map} +0 -0
- /package/dist/{chunk-DD2BKHSD.js.map → chunk-KZMFU7YL.js.map} +0 -0
- /package/dist/{chunk-C5FRCM4B.js.map → chunk-LHGIBXVX.js.map} +0 -0
- /package/dist/{chunk-CGFDT752.js.map → chunk-LVYO2HZJ.js.map} +0 -0
- /package/dist/{chunk-TP3N5364.js.map → chunk-M5GBTETG.js.map} +0 -0
- /package/dist/{chunk-PGD3DXZP.js.map → chunk-M5UJJUON.js.map} +0 -0
- /package/dist/{chunk-HDEQRI2O.js.map → chunk-NAZROP6I.js.map} +0 -0
- /package/dist/{chunk-R3YVNAJB.js.map → chunk-NDE7CH6C.js.map} +0 -0
- /package/dist/{chunk-BIJAWZYW.js.map → chunk-OLT3YTDH.js.map} +0 -0
- /package/dist/{chunk-4OH6FGAO.js.map → chunk-PEOFTEUC.js.map} +0 -0
- /package/dist/{chunk-GM35IHF7.js.map → chunk-PRK4WTPU.js.map} +0 -0
- /package/dist/{chunk-MOUCCUET.js.map → chunk-QGUFN25Q.js.map} +0 -0
- /package/dist/{chunk-5HP6LVWU.js.map → chunk-QHYSECIG.js.map} +0 -0
- /package/dist/{chunk-SZ3SRRYZ.js.map → chunk-RXS4SQ5S.js.map} +0 -0
- /package/dist/{chunk-6KQLDPKA.js.map → chunk-S6AHHQ6C.js.map} +0 -0
- /package/dist/{chunk-R4FPBSFD.js.map → chunk-SO73GYOZ.js.map} +0 -0
- /package/dist/{chunk-M4MNLPO7.js.map → chunk-SXQZMC7R.js.map} +0 -0
- /package/dist/{chunk-JHTF22NQ.js.map → chunk-TA7UZZMW.js.map} +0 -0
- /package/dist/{chunk-UJICQ77Y.js.map → chunk-UCNQV43K.js.map} +0 -0
- /package/dist/{chunk-OUAK37ID.js.map → chunk-VGTXHTWU.js.map} +0 -0
- /package/dist/{chunk-TGDFKC7X.js.map → chunk-VOPOPTZ7.js.map} +0 -0
- /package/dist/{chunk-IFHT77WK.js.map → chunk-WF2WCZCR.js.map} +0 -0
- /package/dist/{chunk-6F5WBXE2.js.map → chunk-X2NFFBTO.js.map} +0 -0
- /package/dist/{chunk-IHFN3JTR.js.map → chunk-X5TGZAP2.js.map} +0 -0
- /package/dist/{chunk-RJ57OOCD.js.map → chunk-X66BHMAU.js.map} +0 -0
- /package/dist/{chunk-DQEMWVMT.js.map → chunk-X7Y7WX73.js.map} +0 -0
- /package/dist/{chunk-FGV7V2N4.js.map → chunk-Y3WMJAZ3.js.map} +0 -0
- /package/dist/{chunk-ZET6A3OF.js.map → chunk-YHCBJULX.js.map} +0 -0
- /package/dist/{chunk-LGVIKA7O.js.map → chunk-YJPFTF57.js.map} +0 -0
- /package/dist/{chunk-EAG7YMIS.js.map → chunk-YVMTXBJJ.js.map} +0 -0
- /package/dist/{chunk-OUKBMQ2O.js.map → chunk-Z3SQEEB6.js.map} +0 -0
- /package/dist/{chunk-MVH7YAWY.js.map → chunk-ZPN6SSWA.js.map} +0 -0
- /package/dist/{graph-edge-decay-FXGELBFA.js.map → graph-edge-decay-DGTBFALQ.js.map} +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { parseConfig } from "./config.js";
|
|
5
|
+
import { RecallResultFormatter } from "./orchestration/recall-result-formatter.js";
|
|
6
|
+
import { applyRecallStateViews } from "./recall-state-view-wire.js";
|
|
7
|
+
import { checkStateViewZeroDiff, type StateViewLine } from "./recall-state-view-zero-diff.js";
|
|
8
|
+
|
|
9
|
+
function line(memoryId: string, text: string, stateLabel = "current"): StateViewLine {
|
|
10
|
+
return { memoryId, text, stateLabel };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("identical current-only lists pass", () => {
|
|
14
|
+
const baseline = [line("a", "Works at Acme"), line("b", "Prefers dark mode")];
|
|
15
|
+
const annotated = baseline.map((l) => ({ ...l }));
|
|
16
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated }), {
|
|
17
|
+
ok: true,
|
|
18
|
+
reason: "verified",
|
|
19
|
+
});
|
|
20
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline: [], annotated: [] }), {
|
|
21
|
+
ok: true,
|
|
22
|
+
reason: "verified",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("changed current line reports current_line_changed with the id", () => {
|
|
27
|
+
const baseline = [line("a", "Was a baker"), line("b", "Runs daily")];
|
|
28
|
+
const annotated = [line("a", "Was a baker at a shop"), line("b", "Runs daily")];
|
|
29
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated }), {
|
|
30
|
+
ok: false,
|
|
31
|
+
error: "current_line_changed",
|
|
32
|
+
memoryId: "a",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("trailing space on a current line is a diff", () => {
|
|
37
|
+
const baseline = [line("a", "Was a baker")];
|
|
38
|
+
const annotated = [line("a", "Was a baker ")];
|
|
39
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated }), {
|
|
40
|
+
ok: false,
|
|
41
|
+
error: "current_line_changed",
|
|
42
|
+
memoryId: "a",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("reordered lines report order_changed", () => {
|
|
47
|
+
const baseline = [line("a", "A"), line("b", "B")];
|
|
48
|
+
const reordered = [line("b", "B"), line("a", "A")];
|
|
49
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated: reordered }), {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: "order_changed",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("dropped or added current lines report order_changed", () => {
|
|
56
|
+
const baseline = [line("a", "A"), line("b", "B")];
|
|
57
|
+
const dropped = [line("a", "A")];
|
|
58
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated: dropped }), {
|
|
59
|
+
ok: false,
|
|
60
|
+
error: "order_changed",
|
|
61
|
+
});
|
|
62
|
+
const added = [line("a", "A"), line("b", "B"), line("c", "C")];
|
|
63
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated: added }), {
|
|
64
|
+
ok: false,
|
|
65
|
+
error: "order_changed",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("text mismatch wins over order mismatch", () => {
|
|
70
|
+
const baseline = [line("a", "A"), line("b", "B")];
|
|
71
|
+
const annotated = [line("b", "B"), line("a", "Changed")];
|
|
72
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated }), {
|
|
73
|
+
ok: false,
|
|
74
|
+
error: "current_line_changed",
|
|
75
|
+
memoryId: "a",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("historical or transition item means the guarantee does not apply", () => {
|
|
80
|
+
const baseline = [line("new", "Current job")];
|
|
81
|
+
const withHistorical = [line("old", "Was a baker", "historical"), line("new", "Current job")];
|
|
82
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated: withHistorical }), {
|
|
83
|
+
ok: true,
|
|
84
|
+
reason: "not_applicable",
|
|
85
|
+
});
|
|
86
|
+
const withTransition = [line("mid", "Bridge job", "transition"), line("new", "Current job")];
|
|
87
|
+
assert.deepEqual(checkStateViewZeroDiff({ baseline, annotated: withTransition }), {
|
|
88
|
+
ok: true,
|
|
89
|
+
reason: "not_applicable",
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("non-current label in baseline throws RangeError", () => {
|
|
94
|
+
const baseline = [line("old", "Was a baker", "historical")];
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => checkStateViewZeroDiff({ baseline, annotated: [] }),
|
|
97
|
+
(err: unknown) => err instanceof RangeError && /baseline/.test(String(err.message)),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("unknown state label throws TypeError listing allowed values", () => {
|
|
102
|
+
const annotated = [line("a", "Text", "bogus")];
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => checkStateViewZeroDiff({ baseline: [], annotated }),
|
|
105
|
+
(err: unknown) =>
|
|
106
|
+
err instanceof TypeError &&
|
|
107
|
+
/state label/.test(String(err.message)) &&
|
|
108
|
+
/current, historical, transition/.test(String(err.message)),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("duplicate memoryId throws RangeError in either list", () => {
|
|
113
|
+
assert.throws(
|
|
114
|
+
() =>
|
|
115
|
+
checkStateViewZeroDiff({
|
|
116
|
+
baseline: [line("a", "A"), line("a", "A")],
|
|
117
|
+
annotated: [],
|
|
118
|
+
}),
|
|
119
|
+
(err: unknown) => err instanceof RangeError && /duplicate/.test(String(err.message)),
|
|
120
|
+
);
|
|
121
|
+
assert.throws(
|
|
122
|
+
() =>
|
|
123
|
+
checkStateViewZeroDiff({
|
|
124
|
+
baseline: [line("a", "A")],
|
|
125
|
+
annotated: [line("b", "B"), line("b", "B again")],
|
|
126
|
+
}),
|
|
127
|
+
(err: unknown) => err instanceof RangeError && /duplicate/.test(String(err.message)),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("inputs are not mutated", () => {
|
|
132
|
+
const baseline = [line("a", "A"), line("b", "B")];
|
|
133
|
+
const annotated = [line("old", "Old", "historical"), line("a", "A"), line("b", "B")];
|
|
134
|
+
const baselineCopy = structuredClone(baseline);
|
|
135
|
+
const annotatedCopy = structuredClone(annotated);
|
|
136
|
+
checkStateViewZeroDiff({ baseline, annotated });
|
|
137
|
+
assert.deepEqual(baseline, baselineCopy);
|
|
138
|
+
assert.deepEqual(annotated, annotatedCopy);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** A fixture row: the guard's line shape plus the fields the live route reads. */
|
|
142
|
+
type LiveFixtureRow = StateViewLine & {
|
|
143
|
+
score: number;
|
|
144
|
+
supersededAt?: string;
|
|
145
|
+
supersededBy?: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Review round 2: the earlier follow-up ran `injectStateViewLines`, which has
|
|
149
|
+
// no production caller. The live route in orchestration/recall-entry.ts is
|
|
150
|
+
// applyRecallStateViews -> RecallResultFormatter.formatQmdResultEntries, so
|
|
151
|
+
// these cases drive those two real stages. A regression that reorders, drops,
|
|
152
|
+
// or perturbs current-only entries in widening OR in live formatting now
|
|
153
|
+
// fails here.
|
|
154
|
+
function liveEntries(
|
|
155
|
+
results: readonly LiveFixtureRow[],
|
|
156
|
+
query: string,
|
|
157
|
+
stateViewsEnabled: boolean,
|
|
158
|
+
): { entries: string[]; widened: { id?: string; stateLabel?: string }[] } {
|
|
159
|
+
// parseConfig intentionally does not carry `recallStateViews` (see
|
|
160
|
+
// recall-state-view-wire.ts: "parseConfig cannot grow"), so operators set it
|
|
161
|
+
// on the live config object. Setting it INSIDE parseConfig silently yields a
|
|
162
|
+
// disabled render, which makes a zero-diff assertion vacuous.
|
|
163
|
+
const parsed = parseConfig({ memoryDir: "/tmp/remnic-zero-diff-test" });
|
|
164
|
+
const config = stateViewsEnabled
|
|
165
|
+
? ({ ...parsed, recallStateViews: true } as typeof parsed)
|
|
166
|
+
: parsed;
|
|
167
|
+
const qmdResults = results.map((result) => ({
|
|
168
|
+
path: `/tmp/remnic-zero-diff-test/facts/${result.memoryId}.md`,
|
|
169
|
+
score: result.score,
|
|
170
|
+
snippet: result.text,
|
|
171
|
+
// `resultStateViewId` reads `id ?? docid` — NOT memoryId. A fixture keyed
|
|
172
|
+
// only on memoryId gives every row the empty id, so widening drops any
|
|
173
|
+
// superseded row and the test silently measures nothing.
|
|
174
|
+
id: result.memoryId,
|
|
175
|
+
memoryId: result.memoryId,
|
|
176
|
+
stateLabel: result.stateLabel,
|
|
177
|
+
...(result.supersededAt ? { supersededAt: result.supersededAt } : {}),
|
|
178
|
+
...(result.supersededBy ? { supersededBy: result.supersededBy } : {}),
|
|
179
|
+
}));
|
|
180
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixture
|
|
181
|
+
const widened = applyRecallStateViews(qmdResults as any[], query, config);
|
|
182
|
+
const formatter = new RecallResultFormatter(config);
|
|
183
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixture
|
|
184
|
+
const entries = formatter.formatQmdResultEntries("Relevant Memories", widened as any[]).entries;
|
|
185
|
+
return { entries, widened: widened as unknown as { id?: string; stateLabel?: string }[] };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function linesFrom(
|
|
189
|
+
entries: readonly string[],
|
|
190
|
+
results: readonly LiveFixtureRow[],
|
|
191
|
+
): StateViewLine[] {
|
|
192
|
+
return entries.map((text, index) => ({
|
|
193
|
+
memoryId: results[index]!.memoryId,
|
|
194
|
+
text,
|
|
195
|
+
stateLabel: results[index]!.stateLabel,
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
test("the enabled route labels an admitted superseded row; disabled leaves labels alone", () => {
|
|
200
|
+
const results = [
|
|
201
|
+
{ memoryId: "m-1", text: "we use PostgreSQL", stateLabel: "current" as const, score: 0.9 },
|
|
202
|
+
{
|
|
203
|
+
memoryId: "m-0",
|
|
204
|
+
text: "we use SQLite",
|
|
205
|
+
stateLabel: "current" as const,
|
|
206
|
+
supersededAt: "2026-08-01",
|
|
207
|
+
supersededBy: "m-1",
|
|
208
|
+
score: 0.7,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
const query = "what changed about the database?";
|
|
212
|
+
const enabled = liveEntries(results, query, true);
|
|
213
|
+
const disabled = liveEntries(results, query, false);
|
|
214
|
+
|
|
215
|
+
// Observable effect #1: widening relabels the superseded row, and only when
|
|
216
|
+
// the live config carries the flag. This fails if recallStateViews is ignored.
|
|
217
|
+
assert.equal(
|
|
218
|
+
enabled.widened.find((row) => row.id === "m-0")?.stateLabel,
|
|
219
|
+
"historical",
|
|
220
|
+
"the enabled route must relabel the superseded row",
|
|
221
|
+
);
|
|
222
|
+
assert.equal(
|
|
223
|
+
disabled.widened.find((row) => row.id === "m-0")?.stateLabel,
|
|
224
|
+
"current",
|
|
225
|
+
"the disabled route must not touch labels",
|
|
226
|
+
);
|
|
227
|
+
// NOTE: the live formatter renders no superseded prefix — formatSupersededPrefix
|
|
228
|
+
// is reachable only from renderStateViewLine, which has no production caller.
|
|
229
|
+
// So the rendered entries are identical here, and asserting a rendered
|
|
230
|
+
// annotation through this route would be asserting a feature that is not
|
|
231
|
+
// wired. That gap belongs to the rendering slice, not to this guard.
|
|
232
|
+
assert.deepEqual(enabled.entries, disabled.entries);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("the enabled route drops a superseded row whose successor is absent", () => {
|
|
236
|
+
// Observable effect #2, and the sharper one: an orphaned superseded row is
|
|
237
|
+
// admitted when state views are off and dropped when they are on, so the
|
|
238
|
+
// entry count itself depends on the live config reaching widening.
|
|
239
|
+
const results = [
|
|
240
|
+
{ memoryId: "m-9", text: "unrelated current fact", stateLabel: "current" as const, score: 0.9 },
|
|
241
|
+
{
|
|
242
|
+
memoryId: "m-0",
|
|
243
|
+
text: "we use SQLite",
|
|
244
|
+
stateLabel: "current" as const,
|
|
245
|
+
supersededAt: "2026-08-01",
|
|
246
|
+
supersededBy: "m-absent",
|
|
247
|
+
score: 0.7,
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
const query = "what changed about the database?";
|
|
251
|
+
assert.equal(liveEntries(results, query, false).entries.length, 2, "disabled admits both rows");
|
|
252
|
+
assert.equal(liveEntries(results, query, true).entries.length, 1, "enabled drops the orphan");
|
|
253
|
+
});
|
|
254
|
+
test("the live recall route is zero-diff for a current-only result set", () => {
|
|
255
|
+
const results = [
|
|
256
|
+
{ memoryId: "m-1", text: "the API limit is 100/min", stateLabel: "current" as const, score: 0.9 },
|
|
257
|
+
{ memoryId: "m-2", text: "we chose SQLite", stateLabel: "current" as const, score: 0.8 },
|
|
258
|
+
];
|
|
259
|
+
const query = "what changed about the database?";
|
|
260
|
+
const baseline = liveEntries(results, query, false).entries;
|
|
261
|
+
const annotated = liveEntries(results, query, true).entries;
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(annotated, baseline, "state views must not touch a current-only render");
|
|
264
|
+
const check = checkStateViewZeroDiff({
|
|
265
|
+
baseline: linesFrom(baseline, results),
|
|
266
|
+
annotated: linesFrom(annotated, results),
|
|
267
|
+
});
|
|
268
|
+
assert.deepEqual(check, { ok: true, reason: "verified" });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("the live route still renders every current entry it was given", () => {
|
|
272
|
+
// Guards the inverse failure: an empty or dropped render would satisfy a
|
|
273
|
+
// naive zero-diff comparison, so assert the payload actually survives.
|
|
274
|
+
const results = [
|
|
275
|
+
{ memoryId: "m-1", text: "the API limit is 100/min", stateLabel: "current" as const, score: 0.9 },
|
|
276
|
+
{ memoryId: "m-2", text: "we chose SQLite", stateLabel: "current" as const, score: 0.8 },
|
|
277
|
+
];
|
|
278
|
+
const entries = liveEntries(results, "what changed about the database?", true).entries;
|
|
279
|
+
assert.equal(entries.length, 2);
|
|
280
|
+
assert.match(entries[0]!, /the API limit is 100\/min/);
|
|
281
|
+
assert.match(entries[1]!, /we chose SQLite/);
|
|
282
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-diff guard for recall state views (issue #1952).
|
|
3
|
+
*
|
|
4
|
+
* The issue promises: current items are unchanged — zero diff for existing
|
|
5
|
+
* users when no historical item qualifies. This module makes that promise
|
|
6
|
+
* checkable. It compares the lines the pipeline would inject WITHOUT state
|
|
7
|
+
* views (baseline) against the lines it would inject WITH them (annotated).
|
|
8
|
+
*
|
|
9
|
+
* Ok reasons are named for what a reader will assume they mean:
|
|
10
|
+
* - `verified`: no historical or transition item is present, so the promise
|
|
11
|
+
* applies, and the byte-exact comparison ran and held.
|
|
12
|
+
* - `not_applicable`: at least one historical or transition item is present,
|
|
13
|
+
* so the "when no historical item qualifies" precondition is false and
|
|
14
|
+
* nothing was compared.
|
|
15
|
+
*
|
|
16
|
+
* Text comparison is byte-exact; a trailing space added by a renderer is a
|
|
17
|
+
* diff. Current-line text mismatches are reported before order mismatches,
|
|
18
|
+
* so `order_changed` means every line matched by memoryId had identical
|
|
19
|
+
* text and only the memoryId sequence moved.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no I/O, inputs are never mutated. Tests drive it through the live
|
|
22
|
+
* recall route (`applyRecallStateViews` then
|
|
23
|
+
* `RecallResultFormatter.formatQmdResultEntries`); wiring the guard into a
|
|
24
|
+
* runtime caller is a later slice.
|
|
25
|
+
*/
|
|
26
|
+
import type { StateLabel } from "./recall-state-view.js";
|
|
27
|
+
|
|
28
|
+
export interface StateViewLine {
|
|
29
|
+
memoryId: string;
|
|
30
|
+
/** The line as it would be injected. */
|
|
31
|
+
text: string;
|
|
32
|
+
stateLabel: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ZeroDiffCheck =
|
|
36
|
+
| { ok: true; reason: "verified" | "not_applicable" }
|
|
37
|
+
| { ok: false; error: "current_line_changed"; memoryId: string }
|
|
38
|
+
| { ok: false; error: "order_changed" };
|
|
39
|
+
|
|
40
|
+
const STATE_LABELS = Object.freeze(
|
|
41
|
+
["current", "historical", "transition"] as const satisfies readonly StateLabel[],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function isStateLabel(value: string): value is StateLabel {
|
|
45
|
+
return (STATE_LABELS as readonly string[]).includes(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validateLines(lines: readonly StateViewLine[], name: string): void {
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
if (line === null || typeof line !== "object") {
|
|
51
|
+
throw new TypeError(`${name} line must be an object`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof line.memoryId !== "string") {
|
|
54
|
+
throw new TypeError(`${name} line memoryId must be a string`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof line.text !== "string") {
|
|
57
|
+
throw new TypeError(`${name} line text must be a string`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof line.stateLabel !== "string" || !isStateLabel(line.stateLabel)) {
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
`unknown state label: ${JSON.stringify(line.stateLabel)} (allowed: ${STATE_LABELS.join(", ")})`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (seen.has(line.memoryId)) {
|
|
68
|
+
throw new RangeError(`duplicate memoryId in ${name}: ${line.memoryId}`);
|
|
69
|
+
}
|
|
70
|
+
seen.add(line.memoryId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function checkStateViewZeroDiff(input: {
|
|
75
|
+
baseline: readonly StateViewLine[];
|
|
76
|
+
annotated: readonly StateViewLine[];
|
|
77
|
+
}): ZeroDiffCheck {
|
|
78
|
+
if (input === null || typeof input !== "object") {
|
|
79
|
+
throw new TypeError("input must be an object");
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(input.baseline)) {
|
|
82
|
+
throw new TypeError("baseline must be an array");
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(input.annotated)) {
|
|
85
|
+
throw new TypeError("annotated must be an array");
|
|
86
|
+
}
|
|
87
|
+
const baseline = input.baseline;
|
|
88
|
+
const annotated = input.annotated;
|
|
89
|
+
validateLines(baseline, "baseline");
|
|
90
|
+
validateLines(annotated, "annotated");
|
|
91
|
+
for (const line of baseline) {
|
|
92
|
+
if (line.stateLabel !== "current") {
|
|
93
|
+
throw new RangeError(
|
|
94
|
+
`baseline may only contain current lines, got ${JSON.stringify(line.stateLabel)}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const hasHistorical = annotated.some(
|
|
100
|
+
(line) => line.stateLabel === "historical" || line.stateLabel === "transition",
|
|
101
|
+
);
|
|
102
|
+
if (hasHistorical) {
|
|
103
|
+
return { ok: true, reason: "not_applicable" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const baselineText = new Map<string, string>();
|
|
107
|
+
for (const line of baseline) {
|
|
108
|
+
baselineText.set(line.memoryId, line.text);
|
|
109
|
+
}
|
|
110
|
+
for (const line of annotated) {
|
|
111
|
+
if (baselineText.has(line.memoryId) && baselineText.get(line.memoryId) !== line.text) {
|
|
112
|
+
return { ok: false, error: "current_line_changed", memoryId: line.memoryId };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (baseline.length !== annotated.length) {
|
|
117
|
+
return { ok: false, error: "order_changed" };
|
|
118
|
+
}
|
|
119
|
+
for (let i = 0; i < baseline.length; i++) {
|
|
120
|
+
if (baseline[i].memoryId !== annotated[i].memoryId) {
|
|
121
|
+
return { ok: false, error: "order_changed" };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, reason: "verified" };
|
|
125
|
+
}
|
|
@@ -199,3 +199,21 @@ test("converge refresh obligations survive restart and clear independently", asy
|
|
|
199
199
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
200
200
|
}
|
|
201
201
|
});
|
|
202
|
+
|
|
203
|
+
test("normalizeConvergePeerUrl: non-URL peers trim trailing slashes", () => {
|
|
204
|
+
assert.equal(normalizeConvergePeerUrl("local///"), "local");
|
|
205
|
+
assert.equal(normalizeConvergePeerUrl("peer-a/"), "peer-a");
|
|
206
|
+
assert.equal(normalizeConvergePeerUrl("peer-a"), "peer-a");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("normalizeConvergePeerUrl: stays linear on long unparseable slash runs", () => {
|
|
210
|
+
// No scheme, so the URL constructor throws and the fallback branch runs;
|
|
211
|
+
// the trailing "a" means no suffix of slashes can match, which is the
|
|
212
|
+
// backtracking worst case for the old /\/+$/ trim.
|
|
213
|
+
const peerUrl = "/".repeat(50_000) + "a";
|
|
214
|
+
const startedAt = Date.now();
|
|
215
|
+
const normalized = normalizeConvergePeerUrl(peerUrl);
|
|
216
|
+
const elapsedMs = Date.now() - startedAt;
|
|
217
|
+
assert.ok(elapsedMs < 1000, `normalizeConvergePeerUrl took ${elapsedMs}ms on a ${peerUrl.length}-char slash run`);
|
|
218
|
+
assert.equal(normalized, peerUrl);
|
|
219
|
+
});
|
package/src/reconcile/cursor.ts
CHANGED
|
@@ -27,6 +27,17 @@ export interface ConvergeCursorState {
|
|
|
27
27
|
pendingRefreshes?: ConvergeRefreshTarget[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Linear trailing-slash trim. `/\/+$/` backtracks quadratically on long
|
|
32
|
+
* slash runs that do not end in a slash (CodeQL js/polynomial-redos); a
|
|
33
|
+
* bounded character loop cannot.
|
|
34
|
+
*/
|
|
35
|
+
function stripTrailingSlashes(value: string): string {
|
|
36
|
+
let end = value.length;
|
|
37
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
38
|
+
return end === value.length ? value : value.slice(0, end);
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export function normalizeConvergePeerUrl(peerUrl: string): string {
|
|
31
42
|
const trimmed = peerUrl.trim();
|
|
32
43
|
try {
|
|
@@ -35,10 +46,10 @@ export function normalizeConvergePeerUrl(peerUrl: string): string {
|
|
|
35
46
|
url.password = "";
|
|
36
47
|
url.search = "";
|
|
37
48
|
url.hash = "";
|
|
38
|
-
url.pathname = url.pathname
|
|
49
|
+
url.pathname = stripTrailingSlashes(url.pathname);
|
|
39
50
|
return url.toString().replace(/\/$/, "");
|
|
40
51
|
} catch {
|
|
41
|
-
return trimmed
|
|
52
|
+
return stripTrailingSlashes(trimmed);
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
55
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { SHARED_AUTHORITIES } from "./governance.js";
|
|
5
|
+
import {
|
|
6
|
+
compareSharedAuthority,
|
|
7
|
+
resolveSharedAuthority,
|
|
8
|
+
SHARED_AUTHORITY_CLASSES,
|
|
9
|
+
} from "./authority-precedence.js";
|
|
10
|
+
|
|
11
|
+
test("SHARED_AUTHORITY_CLASSES is the canonical governance list, not a copy", () => {
|
|
12
|
+
assert.equal(SHARED_AUTHORITY_CLASSES, SHARED_AUTHORITIES);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("resolveSharedAuthority defaults absent authority to informational", () => {
|
|
16
|
+
assert.equal(resolveSharedAuthority({}), "informational");
|
|
17
|
+
assert.equal(resolveSharedAuthority({ authority: undefined }), "informational");
|
|
18
|
+
assert.equal(resolveSharedAuthority({ authority: null }), "informational");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("resolveSharedAuthority honors binding only with the explicit boolean flag", () => {
|
|
22
|
+
assert.equal(resolveSharedAuthority({ authority: "binding", allowBinding: true }), "binding");
|
|
23
|
+
assert.equal(resolveSharedAuthority({ authority: "binding" }), "advisory");
|
|
24
|
+
assert.equal(resolveSharedAuthority({ authority: "binding", allowBinding: undefined }), "advisory");
|
|
25
|
+
assert.equal(resolveSharedAuthority({ authority: "binding", allowBinding: false }), "advisory");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("resolveSharedAuthority keeps advisory and informational regardless of the flag", () => {
|
|
29
|
+
assert.equal(resolveSharedAuthority({ authority: "advisory" }), "advisory");
|
|
30
|
+
assert.equal(resolveSharedAuthority({ authority: "advisory", allowBinding: true }), "advisory");
|
|
31
|
+
assert.equal(resolveSharedAuthority({ authority: "informational" }), "informational");
|
|
32
|
+
assert.equal(resolveSharedAuthority({ authority: "informational", allowBinding: true }), "informational");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("resolveSharedAuthority maps unrecognized strings to informational without normalizing", () => {
|
|
36
|
+
// Flag on proves these stay informational because of the exact-value
|
|
37
|
+
// check, not because binding was denied.
|
|
38
|
+
for (const authority of ["BINDING", "mandatory", " binding", "binding ", ""]) {
|
|
39
|
+
assert.equal(resolveSharedAuthority({ authority, allowBinding: true }), "informational");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("resolveSharedAuthority maps non-string authority values to informational", () => {
|
|
44
|
+
for (const authority of [3, 0, true, false, { authority: "binding" }, ["binding"], Number.NaN]) {
|
|
45
|
+
assert.equal(resolveSharedAuthority({ authority }), "informational");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("resolveSharedAuthority rejects a non-boolean allowBinding", () => {
|
|
50
|
+
for (const allowBinding of ["true", 1, null, "yes"] as unknown[]) {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() => resolveSharedAuthority({ authority: "binding", allowBinding: allowBinding as never }),
|
|
53
|
+
{ name: "TypeError", message: /allowBinding/ },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("compareSharedAuthority orders every class pair by ascending privilege", () => {
|
|
58
|
+
const expected: Record<string, number> = { informational: 0, advisory: 1, binding: 2 };
|
|
59
|
+
for (const a of SHARED_AUTHORITY_CLASSES) {
|
|
60
|
+
for (const b of SHARED_AUTHORITY_CLASSES) {
|
|
61
|
+
const forward = compareSharedAuthority(a, b);
|
|
62
|
+
const backward = compareSharedAuthority(b, a);
|
|
63
|
+
assert.equal(Math.sign(forward), Math.sign(expected[a] - expected[b]));
|
|
64
|
+
assert.equal(Math.sign(backward) || 0, (-Math.sign(forward)) || 0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
assert.equal(compareSharedAuthority("informational", "informational"), 0);
|
|
68
|
+
assert.equal(compareSharedAuthority("advisory", "advisory"), 0);
|
|
69
|
+
assert.equal(compareSharedAuthority("binding", "binding"), 0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("compareSharedAuthority sorts unknown classes as informational", () => {
|
|
73
|
+
assert.equal(compareSharedAuthority("mandatory", "informational"), 0);
|
|
74
|
+
assert.equal(compareSharedAuthority("informational", "mandatory"), 0);
|
|
75
|
+
assert.equal(compareSharedAuthority("mandatory", "mandatory"), 0);
|
|
76
|
+
assert.equal(compareSharedAuthority("mandatory", "advisory") < 0, true);
|
|
77
|
+
assert.equal(compareSharedAuthority("advisory", "mandatory") > 0, true);
|
|
78
|
+
assert.equal(compareSharedAuthority("mandatory", "binding") < 0, true);
|
|
79
|
+
assert.equal(compareSharedAuthority("BINDING", "advisory") < 0, true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("sorting a shuffled mixed list is deterministic", () => {
|
|
83
|
+
const shuffled = [
|
|
84
|
+
"binding",
|
|
85
|
+
"informational",
|
|
86
|
+
"advisory",
|
|
87
|
+
"mandatory",
|
|
88
|
+
"advisory",
|
|
89
|
+
"informational",
|
|
90
|
+
"binding",
|
|
91
|
+
"informational",
|
|
92
|
+
];
|
|
93
|
+
const first = [...shuffled].sort(compareSharedAuthority);
|
|
94
|
+
const second = [...shuffled].sort(compareSharedAuthority);
|
|
95
|
+
assert.deepEqual(first, [
|
|
96
|
+
"informational",
|
|
97
|
+
"mandatory",
|
|
98
|
+
"informational",
|
|
99
|
+
"informational",
|
|
100
|
+
"advisory",
|
|
101
|
+
"advisory",
|
|
102
|
+
"binding",
|
|
103
|
+
"binding",
|
|
104
|
+
]);
|
|
105
|
+
assert.deepEqual(first, second);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Review round 2: the flag was read twice — once to validate, once to decide.
|
|
109
|
+
// A getter that changes between those reads is the whole attack.
|
|
110
|
+
test("a changing allowBinding getter cannot smuggle binding through", () => {
|
|
111
|
+
let reads = 0;
|
|
112
|
+
const input = {
|
|
113
|
+
authority: "binding",
|
|
114
|
+
get allowBinding() {
|
|
115
|
+
reads += 1;
|
|
116
|
+
// undefined for the validation read, true for the decision read.
|
|
117
|
+
return reads === 1 ? undefined : true;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
assert.equal(resolveSharedAuthority(input), "advisory");
|
|
121
|
+
assert.equal(reads, 1, "the switch must be read exactly once");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an explicit boolean opt-in still honors binding", () => {
|
|
125
|
+
assert.equal(resolveSharedAuthority({ authority: "binding", allowBinding: true }), "binding");
|
|
126
|
+
assert.equal(resolveSharedAuthority({ authority: "binding", allowBinding: false }), "advisory");
|
|
127
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-item authority precedence (issue #1957).
|
|
3
|
+
*
|
|
4
|
+
* Pure helpers over the authority classes owned by `governance.ts`
|
|
5
|
+
* (`SHARED_AUTHORITIES`). Resolution is least-privilege: an absent,
|
|
6
|
+
* malformed, or unrecognized authority never resolves above
|
|
7
|
+
* `informational`, and `binding` additionally requires the explicit
|
|
8
|
+
* `allowBinding` flag. The helpers are exported for callers; wiring them
|
|
9
|
+
* into the shared-context read and write paths is a later slice.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { SHARED_AUTHORITIES, type SharedAuthority } from "./governance.js";
|
|
13
|
+
|
|
14
|
+
export { SHARED_AUTHORITIES as SHARED_AUTHORITY_CLASSES };
|
|
15
|
+
export type { SharedAuthority as SharedAuthorityClass };
|
|
16
|
+
|
|
17
|
+
export interface ResolveSharedAuthorityInput {
|
|
18
|
+
/** Raw authority value from an item's envelope, if any. */
|
|
19
|
+
authority?: unknown;
|
|
20
|
+
/** Explicit opt-in required before "binding" is honored. */
|
|
21
|
+
allowBinding?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AUTHORITY_RANK: Readonly<Record<SharedAuthority, number>> = {
|
|
25
|
+
informational: 0,
|
|
26
|
+
advisory: 1,
|
|
27
|
+
binding: 2,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function isKnownAuthority(value: string): value is SharedAuthority {
|
|
31
|
+
return (SHARED_AUTHORITIES as readonly string[]).includes(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export function resolveSharedAuthority(input: ResolveSharedAuthorityInput): SharedAuthority {
|
|
36
|
+
// Read the security switch ONCE. A getter (or a Proxy) can return undefined
|
|
37
|
+
// during validation and true at the decision, which would let binding
|
|
38
|
+
// through without a validated opt-in.
|
|
39
|
+
const allowBinding = input.allowBinding;
|
|
40
|
+
if (allowBinding !== undefined && typeof allowBinding !== "boolean") {
|
|
41
|
+
// The flag is the security switch, so a truthy string or number must
|
|
42
|
+
// not enable binding: reject it instead of coercing.
|
|
43
|
+
throw new TypeError(
|
|
44
|
+
`resolveSharedAuthority: allowBinding must be a boolean when supplied, got ${typeof allowBinding}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const authority = input.authority;
|
|
48
|
+
// Absent (missing key, undefined, null) and present-but-invalid both land
|
|
49
|
+
// on informational — the least-privileged class — so an unmarked or
|
|
50
|
+
// malformed legacy item can never be treated as binding. Validate the
|
|
51
|
+
// exact value: no trim, no case-fold, because " binding" and "BINDING"
|
|
52
|
+
// are unrecognized data, not normalized matches for a known class.
|
|
53
|
+
if (typeof authority !== "string") return "informational";
|
|
54
|
+
if (!isKnownAuthority(authority)) return "informational";
|
|
55
|
+
if (authority === "binding" && allowBinding !== true) {
|
|
56
|
+
// Downgrade rather than throw: a legacy item already carrying "binding"
|
|
57
|
+
// is stored data, not a caller bug, so it resolves to the highest class
|
|
58
|
+
// that does not require the explicit opt-in.
|
|
59
|
+
return "advisory";
|
|
60
|
+
}
|
|
61
|
+
return authority;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function compareSharedAuthority(a: string, b: string): number {
|
|
65
|
+
const rankA = isKnownAuthority(a) ? AUTHORITY_RANK[a] : AUTHORITY_RANK.informational;
|
|
66
|
+
const rankB = isKnownAuthority(b) ? AUTHORITY_RANK[b] : AUTHORITY_RANK.informational;
|
|
67
|
+
return Math.sign(rankA - rankB);
|
|
68
|
+
}
|