@remnic/core 9.67.0 → 9.69.0
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-audit.js +2 -2
- package/dist/access-authorization-probe.js +27 -27
- package/dist/access-boundary.js +26 -26
- package/dist/access-cli.js +87 -87
- 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 +34 -34
- package/dist/access-namespace-preflight.js +26 -26
- package/dist/access-observe-write-surface.js +26 -26
- package/dist/access-operations-batch.js +28 -28
- package/dist/access-operations.js +33 -33
- 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 +3 -3
- package/dist/access-service.js +26 -26
- package/dist/access-surface-catalog.js +28 -28
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/briefing.js +11 -11
- package/dist/{capsule-crypto-GWVG7LGC.js → capsule-crypto-7FJQINUR.js} +2 -2
- package/dist/causal-consolidation.js +12 -12
- package/dist/{chunk-LEPOP46W.js → chunk-227UFSEQ.js} +4 -4
- package/dist/{chunk-FJP6MVRK.js → chunk-25P3LW6U.js} +2 -2
- package/dist/{chunk-JKXPFIPC.js → chunk-2MHMHF76.js} +2 -2
- package/dist/{chunk-OWHERGF2.js → chunk-2NLLXCJG.js} +2 -2
- package/dist/{chunk-QMIKXIAM.js → chunk-327JMA2N.js} +2 -2
- package/dist/{chunk-H6VYVOVP.js → chunk-363VZTVH.js} +2 -2
- package/dist/{chunk-PKRYQ222.js → chunk-3IRH5MYM.js} +6 -6
- package/dist/{chunk-GEHEBLVY.js → chunk-4PEZMGZD.js} +2 -2
- package/dist/{chunk-EU5PDHU2.js → chunk-4PMPOZK3.js} +2 -2
- package/dist/{chunk-6SRUPUTY.js → chunk-52GQNSLC.js} +5 -5
- package/dist/{chunk-SV3MDTJI.js → chunk-5UI7WQA6.js} +2 -2
- package/dist/{chunk-JGXMA6HL.js → chunk-6FUMA6AA.js} +2 -2
- package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
- package/dist/{chunk-WGVAEVT7.js → chunk-7KG5V4VV.js} +2 -2
- package/dist/{chunk-A6HVSRGM.js → chunk-A7NMS5II.js} +3 -3
- package/dist/{chunk-M3BFW3VX.js → chunk-ABPCF4MW.js} +1 -1
- package/dist/{chunk-NNBZS3DY.js → chunk-AG46P24C.js} +4 -4
- package/dist/{chunk-I7FVQMAA.js → chunk-BGD3T743.js} +2 -2
- package/dist/{chunk-QRHKJT7F.js → chunk-BHH736UQ.js} +4 -4
- package/dist/{chunk-4L7OKRGP.js → chunk-BS5BD3DF.js} +2 -2
- package/dist/{chunk-BT7AQZQX.js → chunk-BTRY6UH3.js} +2 -2
- package/dist/{chunk-QJLTAH5X.js → chunk-BZ26443Y.js} +211 -43
- package/dist/chunk-BZ26443Y.js.map +1 -0
- package/dist/{chunk-BE4Y6GK6.js → chunk-D35XLYQ7.js} +11 -11
- package/dist/{chunk-CJTZO5MW.js → chunk-D6Q5EPOV.js} +2 -2
- package/dist/{chunk-6KGBI54C.js → chunk-DJ7TZLGU.js} +2 -2
- package/dist/{chunk-IDPFBRPJ.js → chunk-ERKFG4RV.js} +38 -38
- package/dist/{chunk-K7VTD5AZ.js → chunk-F25CL3IQ.js} +2 -2
- package/dist/{chunk-QZPSIQRB.js → chunk-FOT5GP2U.js} +2 -2
- package/dist/{chunk-OMA2B7GY.js → chunk-HACVBEAP.js} +2 -2
- package/dist/{chunk-IEZ6CGTW.js → chunk-HCM7ZYJD.js} +2 -2
- package/dist/{chunk-46XRYYOJ.js → chunk-HDQQRRCZ.js} +3 -3
- package/dist/{chunk-QXDICB4J.js → chunk-I6T4SYFW.js} +3 -3
- package/dist/{chunk-HQJM3BKH.js → chunk-J2R275FN.js} +2 -2
- package/dist/{chunk-STUPJFG7.js → chunk-KAY2C6VX.js} +2 -2
- package/dist/{chunk-I3X5NWDU.js → chunk-KJUH3QIQ.js} +2 -2
- package/dist/{chunk-BUKK5SWA.js → chunk-KQAFEZQX.js} +2 -2
- package/dist/{chunk-KZAN4JXY.js → chunk-KRNFJKMH.js} +2 -2
- package/dist/{chunk-SBUQ4KIE.js → chunk-L7MJJAG2.js} +2 -2
- package/dist/{chunk-IVKIODHO.js → chunk-MB6GLPRY.js} +4 -4
- package/dist/{chunk-NROLO2HY.js → chunk-ND6NL2YR.js} +2 -2
- package/dist/{chunk-O2HDDAUM.js → chunk-NFITGYMQ.js} +3 -3
- package/dist/{chunk-GRZYF6KK.js → chunk-NYQHD7H4.js} +12 -12
- package/dist/{chunk-REJCULWA.js → chunk-P342D5BP.js} +2 -2
- package/dist/{chunk-HGRRJDUH.js → chunk-PIBQIYF2.js} +2 -2
- package/dist/{chunk-UUGU26P2.js → chunk-QOE3EM5Z.js} +2 -2
- package/dist/{chunk-N37X5RU7.js → chunk-RACZB5HT.js} +4 -4
- package/dist/{chunk-AU7Q3LSC.js → chunk-RKNJBZ55.js} +7 -7
- package/dist/{chunk-F3H7VNM4.js → chunk-USQAH6PN.js} +2 -2
- package/dist/{chunk-ZFE7NXB7.js → chunk-VLSUKXCJ.js} +4 -4
- package/dist/{chunk-YWD5UIWY.js → chunk-WQCET3N3.js} +2 -2
- package/dist/{chunk-MUYJQ273.js → chunk-WSYACIKD.js} +2 -2
- package/dist/{chunk-UVYI6VIX.js → chunk-X7Y7WX73.js} +1 -1
- package/dist/{chunk-AWMS3IFB.js → chunk-XPDWNWH6.js} +3 -3
- package/dist/{chunk-K5JFIA44.js → chunk-XW3G4BJI.js} +7 -7
- package/dist/{chunk-GIC6BASP.js → chunk-XZBXINXZ.js} +4 -4
- package/dist/{chunk-KDCPIXIY.js → chunk-YFK64FYK.js} +2 -2
- package/dist/{chunk-DDAHG5ZD.js → chunk-YYAWJRGL.js} +2 -2
- package/dist/{chunk-IYFL3534.js → chunk-Z2FXSE5P.js} +2 -2
- package/dist/{chunk-CR73YTWC.js → chunk-ZDJMCXC7.js} +11 -11
- package/dist/{chunk-OQT34EVH.js → chunk-ZVW724ZK.js} +2 -2
- package/dist/cli.js +63 -63
- package/dist/coding/export-okf-codegraph.js +11 -11
- package/dist/compounding/engine.js +11 -11
- package/dist/connectors/codex-materialize-runner.js +11 -11
- package/dist/connectors/index.js +11 -11
- package/dist/convergence-refresh.js +13 -13
- package/dist/conversation-index/backend.js +2 -2
- package/dist/corpus-watermark.js +13 -13
- package/dist/entity-retrieval.js +11 -11
- package/dist/external-wiki-access.js +27 -27
- package/dist/extraction-span-gate.d.ts +31 -0
- package/dist/extraction-span-gate.js +37 -0
- package/dist/extraction-span-gate.js.map +1 -0
- package/dist/extraction.js +14 -14
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +79 -1
- package/dist/index.js +110 -100
- package/dist/lcm/index.js +3 -3
- package/dist/local-llm.js +2 -2
- package/dist/location/index.js +13 -13
- package/dist/maintenance/memory-governance.js +11 -11
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +11 -11
- package/dist/maintenance/rebuild-memory-projection.js +12 -12
- package/dist/namespaces/migrate.js +12 -12
- package/dist/namespaces/search.js +9 -9
- package/dist/namespaces/storage.js +11 -11
- package/dist/operator-doctor-corpus.js +14 -14
- package/dist/operator-toolkit.js +19 -19
- package/dist/orchestration/maintenance.js +19 -19
- package/dist/orchestrator.js +87 -87
- package/dist/recall-navigate-window.d.ts +26 -0
- package/dist/recall-navigate-window.js +32 -0
- package/dist/recall-navigate-window.js.map +1 -0
- package/dist/search/factory.js +8 -8
- package/dist/search/index.js +13 -13
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/semantic-consolidation.js +12 -12
- package/dist/semantic-rule-promotion.js +11 -11
- package/dist/semantic-rule-verifier.js +11 -11
- package/dist/source-agent-qualifier.js +11 -11
- package/dist/standup/build.js +11 -11
- package/dist/storage.js +10 -10
- package/dist/summarizer.js +3 -3
- 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.js +12 -12
- package/dist/verified-recall.js +11 -11
- package/dist/who-knows-cli.js +12 -12
- package/dist/who-knows.js +11 -11
- package/package.json +2 -2
- package/src/activity/index.ts +2 -0
- package/src/activity/privacy-delete-plan.test.ts +215 -0
- package/src/activity/privacy-delete-plan.ts +116 -0
- package/src/activity/timeline/index.ts +1 -0
- package/src/activity/timeline/recap-merge-edits.test.ts +181 -0
- package/src/activity/timeline/recap-merge-edits.ts +70 -0
- package/src/activity/vault-status.test.ts +138 -0
- package/src/activity/vault-status.ts +96 -0
- package/src/extraction-span-gate.test.ts +202 -0
- package/src/extraction-span-gate.ts +58 -0
- package/src/recall-navigate-window.test.ts +104 -0
- package/src/recall-navigate-window.ts +48 -0
- package/src/shared-context/supersession-chain.test.ts +158 -0
- package/src/shared-context/supersession-chain.ts +95 -0
- package/dist/chunk-QJLTAH5X.js.map +0 -1
- /package/dist/{capsule-crypto-GWVG7LGC.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
- /package/dist/{chunk-LEPOP46W.js.map → chunk-227UFSEQ.js.map} +0 -0
- /package/dist/{chunk-FJP6MVRK.js.map → chunk-25P3LW6U.js.map} +0 -0
- /package/dist/{chunk-JKXPFIPC.js.map → chunk-2MHMHF76.js.map} +0 -0
- /package/dist/{chunk-OWHERGF2.js.map → chunk-2NLLXCJG.js.map} +0 -0
- /package/dist/{chunk-QMIKXIAM.js.map → chunk-327JMA2N.js.map} +0 -0
- /package/dist/{chunk-H6VYVOVP.js.map → chunk-363VZTVH.js.map} +0 -0
- /package/dist/{chunk-PKRYQ222.js.map → chunk-3IRH5MYM.js.map} +0 -0
- /package/dist/{chunk-GEHEBLVY.js.map → chunk-4PEZMGZD.js.map} +0 -0
- /package/dist/{chunk-EU5PDHU2.js.map → chunk-4PMPOZK3.js.map} +0 -0
- /package/dist/{chunk-6SRUPUTY.js.map → chunk-52GQNSLC.js.map} +0 -0
- /package/dist/{chunk-SV3MDTJI.js.map → chunk-5UI7WQA6.js.map} +0 -0
- /package/dist/{chunk-JGXMA6HL.js.map → chunk-6FUMA6AA.js.map} +0 -0
- /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
- /package/dist/{chunk-WGVAEVT7.js.map → chunk-7KG5V4VV.js.map} +0 -0
- /package/dist/{chunk-A6HVSRGM.js.map → chunk-A7NMS5II.js.map} +0 -0
- /package/dist/{chunk-M3BFW3VX.js.map → chunk-ABPCF4MW.js.map} +0 -0
- /package/dist/{chunk-NNBZS3DY.js.map → chunk-AG46P24C.js.map} +0 -0
- /package/dist/{chunk-I7FVQMAA.js.map → chunk-BGD3T743.js.map} +0 -0
- /package/dist/{chunk-QRHKJT7F.js.map → chunk-BHH736UQ.js.map} +0 -0
- /package/dist/{chunk-4L7OKRGP.js.map → chunk-BS5BD3DF.js.map} +0 -0
- /package/dist/{chunk-BT7AQZQX.js.map → chunk-BTRY6UH3.js.map} +0 -0
- /package/dist/{chunk-BE4Y6GK6.js.map → chunk-D35XLYQ7.js.map} +0 -0
- /package/dist/{chunk-CJTZO5MW.js.map → chunk-D6Q5EPOV.js.map} +0 -0
- /package/dist/{chunk-6KGBI54C.js.map → chunk-DJ7TZLGU.js.map} +0 -0
- /package/dist/{chunk-IDPFBRPJ.js.map → chunk-ERKFG4RV.js.map} +0 -0
- /package/dist/{chunk-K7VTD5AZ.js.map → chunk-F25CL3IQ.js.map} +0 -0
- /package/dist/{chunk-QZPSIQRB.js.map → chunk-FOT5GP2U.js.map} +0 -0
- /package/dist/{chunk-OMA2B7GY.js.map → chunk-HACVBEAP.js.map} +0 -0
- /package/dist/{chunk-IEZ6CGTW.js.map → chunk-HCM7ZYJD.js.map} +0 -0
- /package/dist/{chunk-46XRYYOJ.js.map → chunk-HDQQRRCZ.js.map} +0 -0
- /package/dist/{chunk-QXDICB4J.js.map → chunk-I6T4SYFW.js.map} +0 -0
- /package/dist/{chunk-HQJM3BKH.js.map → chunk-J2R275FN.js.map} +0 -0
- /package/dist/{chunk-STUPJFG7.js.map → chunk-KAY2C6VX.js.map} +0 -0
- /package/dist/{chunk-I3X5NWDU.js.map → chunk-KJUH3QIQ.js.map} +0 -0
- /package/dist/{chunk-BUKK5SWA.js.map → chunk-KQAFEZQX.js.map} +0 -0
- /package/dist/{chunk-KZAN4JXY.js.map → chunk-KRNFJKMH.js.map} +0 -0
- /package/dist/{chunk-SBUQ4KIE.js.map → chunk-L7MJJAG2.js.map} +0 -0
- /package/dist/{chunk-IVKIODHO.js.map → chunk-MB6GLPRY.js.map} +0 -0
- /package/dist/{chunk-NROLO2HY.js.map → chunk-ND6NL2YR.js.map} +0 -0
- /package/dist/{chunk-O2HDDAUM.js.map → chunk-NFITGYMQ.js.map} +0 -0
- /package/dist/{chunk-GRZYF6KK.js.map → chunk-NYQHD7H4.js.map} +0 -0
- /package/dist/{chunk-REJCULWA.js.map → chunk-P342D5BP.js.map} +0 -0
- /package/dist/{chunk-HGRRJDUH.js.map → chunk-PIBQIYF2.js.map} +0 -0
- /package/dist/{chunk-UUGU26P2.js.map → chunk-QOE3EM5Z.js.map} +0 -0
- /package/dist/{chunk-N37X5RU7.js.map → chunk-RACZB5HT.js.map} +0 -0
- /package/dist/{chunk-AU7Q3LSC.js.map → chunk-RKNJBZ55.js.map} +0 -0
- /package/dist/{chunk-F3H7VNM4.js.map → chunk-USQAH6PN.js.map} +0 -0
- /package/dist/{chunk-ZFE7NXB7.js.map → chunk-VLSUKXCJ.js.map} +0 -0
- /package/dist/{chunk-YWD5UIWY.js.map → chunk-WQCET3N3.js.map} +0 -0
- /package/dist/{chunk-MUYJQ273.js.map → chunk-WSYACIKD.js.map} +0 -0
- /package/dist/{chunk-UVYI6VIX.js.map → chunk-X7Y7WX73.js.map} +0 -0
- /package/dist/{chunk-AWMS3IFB.js.map → chunk-XPDWNWH6.js.map} +0 -0
- /package/dist/{chunk-K5JFIA44.js.map → chunk-XW3G4BJI.js.map} +0 -0
- /package/dist/{chunk-GIC6BASP.js.map → chunk-XZBXINXZ.js.map} +0 -0
- /package/dist/{chunk-KDCPIXIY.js.map → chunk-YFK64FYK.js.map} +0 -0
- /package/dist/{chunk-DDAHG5ZD.js.map → chunk-YYAWJRGL.js.map} +0 -0
- /package/dist/{chunk-IYFL3534.js.map → chunk-Z2FXSE5P.js.map} +0 -0
- /package/dist/{chunk-CR73YTWC.js.map → chunk-ZDJMCXC7.js.map} +0 -0
- /package/dist/{chunk-OQT34EVH.js.map → chunk-ZVW724ZK.js.map} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { VAULT_PUBLISH_OUTCOMES, summarizeVaultPublish } from "./vault-status.js";
|
|
5
|
+
import type { VaultPublishResult } from "./vault-status.js";
|
|
6
|
+
|
|
7
|
+
test("summarizeVaultPublish counts a mixed list, sorts by path, and flags the error", () => {
|
|
8
|
+
const status = summarizeVaultPublish([
|
|
9
|
+
{ path: "notes/e.md", outcome: "updated" },
|
|
10
|
+
{ path: "notes/d.md", outcome: "error", reason: "missing_file" },
|
|
11
|
+
{ path: "notes/a.md", outcome: "updated" },
|
|
12
|
+
{ path: "notes/c.md", outcome: "skipped", reason: "no_marker" },
|
|
13
|
+
{ path: "notes/b.md", outcome: "unchanged" },
|
|
14
|
+
]);
|
|
15
|
+
assert.deepEqual(status.counts, { updated: 2, unchanged: 1, skipped: 1, error: 1 });
|
|
16
|
+
assert.equal(status.hasError, true);
|
|
17
|
+
assert.deepEqual(status.results, [
|
|
18
|
+
{ path: "notes/a.md", outcome: "updated" },
|
|
19
|
+
{ path: "notes/b.md", outcome: "unchanged" },
|
|
20
|
+
{ path: "notes/c.md", outcome: "skipped", reason: "no_marker" },
|
|
21
|
+
{ path: "notes/d.md", outcome: "error", reason: "missing_file" },
|
|
22
|
+
{ path: "notes/e.md", outcome: "updated" },
|
|
23
|
+
]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("empty input returns zeroed counts, no results, and hasError false", () => {
|
|
27
|
+
const status = summarizeVaultPublish([]);
|
|
28
|
+
assert.deepEqual(status.counts, { updated: 0, unchanged: 0, skipped: 0, error: 0 });
|
|
29
|
+
for (const outcome of VAULT_PUBLISH_OUTCOMES) {
|
|
30
|
+
assert.equal(status.counts[outcome], 0, `count key ${outcome} must exist at zero`);
|
|
31
|
+
}
|
|
32
|
+
assert.deepEqual(status.results, []);
|
|
33
|
+
assert.equal(status.hasError, false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("unknown outcome throws TypeError listing the allow-list", () => {
|
|
37
|
+
assert.throws(
|
|
38
|
+
() => summarizeVaultPublish([{ path: "notes/a.md", outcome: "deleted" }]),
|
|
39
|
+
(err: unknown) => {
|
|
40
|
+
assert.ok(err instanceof TypeError);
|
|
41
|
+
assert.match(err.message, /unknown vault publish outcome/);
|
|
42
|
+
for (const outcome of VAULT_PUBLISH_OUTCOMES) {
|
|
43
|
+
assert.ok(err.message.includes(outcome), `message lists ${outcome}`);
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("skipped without a reason throws TypeError", () => {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() => summarizeVaultPublish([{ path: "notes/a.md", outcome: "skipped" }]),
|
|
53
|
+
(err: unknown) => err instanceof TypeError && /reason/.test(err.message),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("error with a missing or whitespace-only reason throws TypeError", () => {
|
|
58
|
+
assert.throws(
|
|
59
|
+
() => summarizeVaultPublish([{ path: "notes/a.md", outcome: "error" }]),
|
|
60
|
+
(err: unknown) => err instanceof TypeError && /reason/.test(err.message),
|
|
61
|
+
);
|
|
62
|
+
assert.throws(
|
|
63
|
+
() => summarizeVaultPublish([{ path: "notes/a.md", outcome: "error", reason: " " }]),
|
|
64
|
+
(err: unknown) => err instanceof TypeError && /reason/.test(err.message),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("updated and unchanged must not carry a reason", () => {
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => summarizeVaultPublish([{ path: "notes/a.md", outcome: "updated", reason: "no_marker" }]),
|
|
71
|
+
(err: unknown) => err instanceof TypeError && /reason/.test(err.message),
|
|
72
|
+
);
|
|
73
|
+
assert.throws(
|
|
74
|
+
() => summarizeVaultPublish([{ path: "notes/a.md", outcome: "unchanged", reason: "x" }]),
|
|
75
|
+
(err: unknown) => err instanceof TypeError && /reason/.test(err.message),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("blank path throws RangeError", () => {
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => summarizeVaultPublish([{ path: "", outcome: "updated" }]),
|
|
82
|
+
(err: unknown) => err instanceof RangeError && /path/.test(err.message),
|
|
83
|
+
);
|
|
84
|
+
assert.throws(
|
|
85
|
+
() => summarizeVaultPublish([{ path: " \t ", outcome: "updated" }]),
|
|
86
|
+
(err: unknown) => err instanceof RangeError && /path/.test(err.message),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("duplicate paths are all preserved, not deduplicated", () => {
|
|
91
|
+
const status = summarizeVaultPublish([
|
|
92
|
+
{ path: "notes/a.md", outcome: "updated" },
|
|
93
|
+
{ path: "notes/a.md", outcome: "skipped", reason: "no_marker" },
|
|
94
|
+
{ path: "notes/a.md", outcome: "updated" },
|
|
95
|
+
]);
|
|
96
|
+
assert.equal(status.results.length, 3);
|
|
97
|
+
assert.deepEqual(status.counts, { updated: 2, unchanged: 0, skipped: 1, error: 0 });
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
status.results.map((entry) => entry.outcome),
|
|
100
|
+
["updated", "updated", "skipped"],
|
|
101
|
+
);
|
|
102
|
+
assert.equal(status.hasError, false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("order is deterministic across shuffled input", () => {
|
|
106
|
+
const base: VaultPublishResult[] = [
|
|
107
|
+
{ path: "days/2026-08-18.md", outcome: "error", reason: "missing_file" },
|
|
108
|
+
{ path: "days/2026-08-18.md", outcome: "updated" },
|
|
109
|
+
{ path: "days/2026-08-18.md", outcome: "skipped", reason: "no_marker" },
|
|
110
|
+
{ path: "days/2026-08-18.md", outcome: "skipped", reason: "duplicate_heading" },
|
|
111
|
+
{ path: "days/2026-08-17.md", outcome: "unchanged" },
|
|
112
|
+
{ path: "places/office.md", outcome: "updated" },
|
|
113
|
+
];
|
|
114
|
+
const forward = summarizeVaultPublish(base);
|
|
115
|
+
const reversed = summarizeVaultPublish([...base].reverse());
|
|
116
|
+
assert.deepEqual(forward, reversed);
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
forward.results.map((entry) => `${entry.path}:${entry.outcome}:${entry.reason ?? ""}`),
|
|
119
|
+
[
|
|
120
|
+
"days/2026-08-17.md:unchanged:",
|
|
121
|
+
"days/2026-08-18.md:updated:",
|
|
122
|
+
"days/2026-08-18.md:skipped:duplicate_heading",
|
|
123
|
+
"days/2026-08-18.md:skipped:no_marker",
|
|
124
|
+
"days/2026-08-18.md:error:missing_file",
|
|
125
|
+
"places/office.md:updated:",
|
|
126
|
+
],
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("input is not mutated", () => {
|
|
131
|
+
const input: VaultPublishResult[] = [
|
|
132
|
+
{ path: "notes/b.md", outcome: "updated" },
|
|
133
|
+
{ path: "notes/a.md", outcome: "skipped", reason: "no_marker" },
|
|
134
|
+
];
|
|
135
|
+
const snapshot = structuredClone(input);
|
|
136
|
+
summarizeVaultPublish(input);
|
|
137
|
+
assert.deepEqual(input, snapshot);
|
|
138
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure vault-publish outcome aggregator (issue #1985).
|
|
3
|
+
*
|
|
4
|
+
* `summarizeVaultPublish` folds per-file publish results into the status
|
|
5
|
+
* shape the publish report and the dry-run print: sorted per-file outcomes
|
|
6
|
+
* plus zero-filled counts. It performs no I/O and never mutates its input.
|
|
7
|
+
*
|
|
8
|
+
* Fail-closed contract: an unrecognized outcome is refused, never coerced
|
|
9
|
+
* into `skipped` or `error`, and `skipped`/`error` must carry a non-blank
|
|
10
|
+
* reason so every un-actionable row says why.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const VAULT_PUBLISH_OUTCOMES = ["updated", "unchanged", "skipped", "error"] as const;
|
|
14
|
+
export type VaultPublishOutcome = (typeof VAULT_PUBLISH_OUTCOMES)[number];
|
|
15
|
+
|
|
16
|
+
export interface VaultPublishResult {
|
|
17
|
+
/** Vault-relative note path. */
|
|
18
|
+
path: string;
|
|
19
|
+
outcome: string;
|
|
20
|
+
/** Required for skipped and error, e.g. "no_marker". */
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface VaultPublishStatus {
|
|
25
|
+
results: Array<{ path: string; outcome: VaultPublishOutcome; reason?: string }>;
|
|
26
|
+
counts: Record<VaultPublishOutcome, number>;
|
|
27
|
+
/** True when at least one entry is an error. */
|
|
28
|
+
hasError: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ValidatedEntry {
|
|
32
|
+
path: string;
|
|
33
|
+
outcome: VaultPublishOutcome;
|
|
34
|
+
reason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Total order: path asc, then outcome in allow-list order, then reason. */
|
|
38
|
+
function compareEntries(a: ValidatedEntry, b: ValidatedEntry): number {
|
|
39
|
+
if (a.path !== b.path) return a.path < b.path ? -1 : 1;
|
|
40
|
+
const rankA = VAULT_PUBLISH_OUTCOMES.indexOf(a.outcome);
|
|
41
|
+
const rankB = VAULT_PUBLISH_OUTCOMES.indexOf(b.outcome);
|
|
42
|
+
if (rankA !== rankB) return rankA < rankB ? -1 : 1;
|
|
43
|
+
const reasonA = a.reason ?? "";
|
|
44
|
+
const reasonB = b.reason ?? "";
|
|
45
|
+
if (reasonA !== reasonB) return reasonA < reasonB ? -1 : 1;
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function summarizeVaultPublish(results: readonly VaultPublishResult[]): VaultPublishStatus {
|
|
50
|
+
if (!Array.isArray(results)) {
|
|
51
|
+
throw new TypeError(`summarizeVaultPublish expects an array of results (got ${typeof results})`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const entries: ValidatedEntry[] = [];
|
|
55
|
+
for (const entry of results) {
|
|
56
|
+
if (typeof entry !== "object" || entry === null) {
|
|
57
|
+
throw new TypeError(`vault publish result entries must be objects (got ${typeof entry})`);
|
|
58
|
+
}
|
|
59
|
+
if (typeof entry.path !== "string" || entry.path.trim() === "") {
|
|
60
|
+
throw new RangeError(`vault publish result requires a non-blank path (got ${JSON.stringify(entry.path)})`);
|
|
61
|
+
}
|
|
62
|
+
const outcome = VAULT_PUBLISH_OUTCOMES.find((candidate) => candidate === entry.outcome);
|
|
63
|
+
if (outcome === undefined) {
|
|
64
|
+
throw new TypeError(
|
|
65
|
+
`unknown vault publish outcome ${JSON.stringify(entry.outcome)}; expected one of: ${VAULT_PUBLISH_OUTCOMES.join(", ")}`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (outcome === "skipped" || outcome === "error") {
|
|
69
|
+
if (typeof entry.reason !== "string" || entry.reason.trim() === "") {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
`vault publish outcome "${outcome}" requires a non-blank reason, e.g. "no_marker" (got ${JSON.stringify(entry.reason)})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
entries.push({ path: entry.path, outcome, reason: entry.reason });
|
|
75
|
+
} else {
|
|
76
|
+
if (entry.reason !== undefined) {
|
|
77
|
+
throw new TypeError(
|
|
78
|
+
`vault publish outcome "${outcome}" must not carry a reason (got ${JSON.stringify(entry.reason)})`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
entries.push({ path: entry.path, outcome });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const sorted = [...entries].sort(compareEntries);
|
|
86
|
+
|
|
87
|
+
const counts = {} as Record<VaultPublishOutcome, number>;
|
|
88
|
+
for (const outcome of VAULT_PUBLISH_OUTCOMES) counts[outcome] = 0;
|
|
89
|
+
for (const entry of sorted) counts[entry.outcome] += 1;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
results: sorted,
|
|
93
|
+
counts,
|
|
94
|
+
hasError: counts.error > 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
evaluateSpanPhaseGate,
|
|
6
|
+
SPAN_GATE_MAX_FALLBACK_RATE_PCT,
|
|
7
|
+
SPAN_GATE_MAX_JUDGE_DROP_POINTS,
|
|
8
|
+
SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT,
|
|
9
|
+
} from "./extraction-span-gate.js";
|
|
10
|
+
|
|
11
|
+
function assertRangeError(fn: () => unknown, field: string): void {
|
|
12
|
+
assert.throws(fn, (err: unknown) => {
|
|
13
|
+
assert.ok(err instanceof RangeError, `expected RangeError, got ${String(err)}`);
|
|
14
|
+
assert.match((err as RangeError).message, new RegExp(field));
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("evaluateSpanPhaseGate", () => {
|
|
20
|
+
it("passes the Phase A bench numbers from the issue", () => {
|
|
21
|
+
const verdict = evaluateSpanPhaseGate({
|
|
22
|
+
wallClockReductionPct: 45,
|
|
23
|
+
judgeScoreDropPoints: 0.3,
|
|
24
|
+
fallbackRatePct: 4,
|
|
25
|
+
});
|
|
26
|
+
assert.deepEqual(verdict, {
|
|
27
|
+
pass: true,
|
|
28
|
+
conditions: { wallClockReduction: true, judgeQuality: true, fallbackRate: true },
|
|
29
|
+
failed: [],
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("treats the wall-clock boundary 20.0 as a pass (>= 20)", () => {
|
|
34
|
+
const verdict = evaluateSpanPhaseGate({
|
|
35
|
+
wallClockReductionPct: SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT,
|
|
36
|
+
judgeScoreDropPoints: 0,
|
|
37
|
+
fallbackRatePct: 0,
|
|
38
|
+
});
|
|
39
|
+
assert.equal(verdict.conditions.wallClockReduction, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("treats a judge drop of exactly 2.0 as a failure (< 2)", () => {
|
|
43
|
+
const verdict = evaluateSpanPhaseGate({
|
|
44
|
+
wallClockReductionPct: 45,
|
|
45
|
+
judgeScoreDropPoints: SPAN_GATE_MAX_JUDGE_DROP_POINTS,
|
|
46
|
+
fallbackRatePct: 4,
|
|
47
|
+
});
|
|
48
|
+
assert.equal(verdict.pass, false);
|
|
49
|
+
assert.deepEqual(verdict.failed, ["judgeQuality"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("treats a fallback rate of exactly 15.0 as a failure (< 15)", () => {
|
|
53
|
+
const verdict = evaluateSpanPhaseGate({
|
|
54
|
+
wallClockReductionPct: 45,
|
|
55
|
+
judgeScoreDropPoints: 0.3,
|
|
56
|
+
fallbackRatePct: SPAN_GATE_MAX_FALLBACK_RATE_PCT,
|
|
57
|
+
});
|
|
58
|
+
assert.equal(verdict.pass, false);
|
|
59
|
+
assert.deepEqual(verdict.failed, ["fallbackRate"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("fails on wall-clock reduction alone", () => {
|
|
63
|
+
const verdict = evaluateSpanPhaseGate({
|
|
64
|
+
wallClockReductionPct: 19.9,
|
|
65
|
+
judgeScoreDropPoints: 0.3,
|
|
66
|
+
fallbackRatePct: 4,
|
|
67
|
+
});
|
|
68
|
+
assert.equal(verdict.pass, false);
|
|
69
|
+
assert.deepEqual(verdict.failed, ["wallClockReduction"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("fails on judge quality alone", () => {
|
|
73
|
+
const verdict = evaluateSpanPhaseGate({
|
|
74
|
+
wallClockReductionPct: 45,
|
|
75
|
+
judgeScoreDropPoints: 2.5,
|
|
76
|
+
fallbackRatePct: 4,
|
|
77
|
+
});
|
|
78
|
+
assert.equal(verdict.pass, false);
|
|
79
|
+
assert.deepEqual(verdict.failed, ["judgeQuality"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("fails on fallback rate alone", () => {
|
|
83
|
+
const verdict = evaluateSpanPhaseGate({
|
|
84
|
+
wallClockReductionPct: 45,
|
|
85
|
+
judgeScoreDropPoints: 0.3,
|
|
86
|
+
fallbackRatePct: 15.1,
|
|
87
|
+
});
|
|
88
|
+
assert.equal(verdict.pass, false);
|
|
89
|
+
assert.deepEqual(verdict.failed, ["fallbackRate"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lists all three failures in declaration order", () => {
|
|
93
|
+
const verdict = evaluateSpanPhaseGate({
|
|
94
|
+
wallClockReductionPct: 10,
|
|
95
|
+
judgeScoreDropPoints: 3,
|
|
96
|
+
fallbackRatePct: 20,
|
|
97
|
+
});
|
|
98
|
+
assert.equal(verdict.pass, false);
|
|
99
|
+
assert.deepEqual(verdict.conditions, {
|
|
100
|
+
wallClockReduction: false,
|
|
101
|
+
judgeQuality: false,
|
|
102
|
+
fallbackRate: false,
|
|
103
|
+
});
|
|
104
|
+
assert.deepEqual(verdict.failed, ["wallClockReduction", "judgeQuality", "fallbackRate"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("fails a negative wall-clock reduction without throwing", () => {
|
|
108
|
+
const verdict = evaluateSpanPhaseGate({
|
|
109
|
+
wallClockReductionPct: -5,
|
|
110
|
+
judgeScoreDropPoints: 0.3,
|
|
111
|
+
fallbackRatePct: 4,
|
|
112
|
+
});
|
|
113
|
+
assert.equal(verdict.pass, false);
|
|
114
|
+
assert.deepEqual(verdict.failed, ["wallClockReduction"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("passes a negative judge drop (quality improved)", () => {
|
|
118
|
+
const verdict = evaluateSpanPhaseGate({
|
|
119
|
+
wallClockReductionPct: 45,
|
|
120
|
+
judgeScoreDropPoints: -0.5,
|
|
121
|
+
fallbackRatePct: 4,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(verdict.conditions.judgeQuality, true);
|
|
124
|
+
assert.equal(verdict.pass, true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("throws RangeError naming the field on non-finite wall-clock reduction", () => {
|
|
128
|
+
assertRangeError(
|
|
129
|
+
() =>
|
|
130
|
+
evaluateSpanPhaseGate({
|
|
131
|
+
wallClockReductionPct: Number.NaN,
|
|
132
|
+
judgeScoreDropPoints: 0.3,
|
|
133
|
+
fallbackRatePct: 4,
|
|
134
|
+
}),
|
|
135
|
+
"wallClockReductionPct",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws RangeError naming the field on non-finite judge drop", () => {
|
|
140
|
+
assertRangeError(
|
|
141
|
+
() =>
|
|
142
|
+
evaluateSpanPhaseGate({
|
|
143
|
+
wallClockReductionPct: 45,
|
|
144
|
+
judgeScoreDropPoints: Number.POSITIVE_INFINITY,
|
|
145
|
+
fallbackRatePct: 4,
|
|
146
|
+
}),
|
|
147
|
+
"judgeScoreDropPoints",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("throws RangeError naming the field on non-finite fallback rate", () => {
|
|
152
|
+
assertRangeError(
|
|
153
|
+
() =>
|
|
154
|
+
evaluateSpanPhaseGate({
|
|
155
|
+
wallClockReductionPct: 45,
|
|
156
|
+
judgeScoreDropPoints: 0.3,
|
|
157
|
+
fallbackRatePct: Number.NEGATIVE_INFINITY,
|
|
158
|
+
}),
|
|
159
|
+
"fallbackRatePct",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("throws RangeError on a fallback rate of 101", () => {
|
|
164
|
+
assertRangeError(
|
|
165
|
+
() =>
|
|
166
|
+
evaluateSpanPhaseGate({
|
|
167
|
+
wallClockReductionPct: 45,
|
|
168
|
+
judgeScoreDropPoints: 0.3,
|
|
169
|
+
fallbackRatePct: 101,
|
|
170
|
+
}),
|
|
171
|
+
"fallbackRatePct",
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("throws RangeError on a fallback rate of -1", () => {
|
|
176
|
+
assertRangeError(
|
|
177
|
+
() =>
|
|
178
|
+
evaluateSpanPhaseGate({
|
|
179
|
+
wallClockReductionPct: 45,
|
|
180
|
+
judgeScoreDropPoints: 0.3,
|
|
181
|
+
fallbackRatePct: -1,
|
|
182
|
+
}),
|
|
183
|
+
"fallbackRatePct",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("accepts the fallback-rate endpoints 0 and 100 as measurements", () => {
|
|
188
|
+
assert.doesNotThrow(() =>
|
|
189
|
+
evaluateSpanPhaseGate({
|
|
190
|
+
wallClockReductionPct: 45,
|
|
191
|
+
judgeScoreDropPoints: 0.3,
|
|
192
|
+
fallbackRatePct: 0,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
const verdict = evaluateSpanPhaseGate({
|
|
196
|
+
wallClockReductionPct: 45,
|
|
197
|
+
judgeScoreDropPoints: 0.3,
|
|
198
|
+
fallbackRatePct: 100,
|
|
199
|
+
});
|
|
200
|
+
assert.deepEqual(verdict.failed, ["fallbackRate"]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Span-mode Phase B gate (issue #2333).
|
|
3
|
+
*
|
|
4
|
+
* Phase A must earn >= 20% extraction wall-clock reduction, keep the LLM-judge
|
|
5
|
+
* score drop under 2 points, and keep the span fallback rate under 15% before
|
|
6
|
+
* Phase B wiring starts. A failed gate is reported, never rounded into a pass.
|
|
7
|
+
*
|
|
8
|
+
* Pure. No I/O, no clock, no randomness.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT = 20;
|
|
12
|
+
export const SPAN_GATE_MAX_JUDGE_DROP_POINTS = 2;
|
|
13
|
+
export const SPAN_GATE_MAX_FALLBACK_RATE_PCT = 15;
|
|
14
|
+
|
|
15
|
+
export interface SpanGateConditions {
|
|
16
|
+
wallClockReduction: boolean;
|
|
17
|
+
judgeQuality: boolean;
|
|
18
|
+
fallbackRate: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SpanGateVerdict {
|
|
22
|
+
/** True only when every condition passes. */
|
|
23
|
+
pass: boolean;
|
|
24
|
+
conditions: SpanGateConditions;
|
|
25
|
+
/** Condition names that failed, in declaration order. Empty when pass. */
|
|
26
|
+
failed: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireFinite(value: number, field: string): void {
|
|
30
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
31
|
+
throw new RangeError(`span gate input ${field} must be a finite number, got ${value}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function evaluateSpanPhaseGate(input: {
|
|
36
|
+
wallClockReductionPct: number;
|
|
37
|
+
judgeScoreDropPoints: number;
|
|
38
|
+
fallbackRatePct: number;
|
|
39
|
+
}): SpanGateVerdict {
|
|
40
|
+
requireFinite(input.wallClockReductionPct, "wallClockReductionPct");
|
|
41
|
+
requireFinite(input.judgeScoreDropPoints, "judgeScoreDropPoints");
|
|
42
|
+
requireFinite(input.fallbackRatePct, "fallbackRatePct");
|
|
43
|
+
if (input.fallbackRatePct < 0 || input.fallbackRatePct > 100) {
|
|
44
|
+
throw new RangeError(
|
|
45
|
+
`span gate input fallbackRatePct must be within [0, 100], got ${input.fallbackRatePct}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const conditions: SpanGateConditions = {
|
|
50
|
+
wallClockReduction: input.wallClockReductionPct >= SPAN_GATE_MIN_WALLCLOCK_REDUCTION_PCT,
|
|
51
|
+
judgeQuality: input.judgeScoreDropPoints < SPAN_GATE_MAX_JUDGE_DROP_POINTS,
|
|
52
|
+
fallbackRate: input.fallbackRatePct < SPAN_GATE_MAX_FALLBACK_RATE_PCT,
|
|
53
|
+
};
|
|
54
|
+
const failed = (Object.keys(conditions) as (keyof SpanGateConditions)[]).filter(
|
|
55
|
+
(name) => !conditions[name],
|
|
56
|
+
);
|
|
57
|
+
return { pass: failed.length === 0, conditions, failed };
|
|
58
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS,
|
|
6
|
+
assertIdInNavigationWindow,
|
|
7
|
+
type NavigationSnapshot,
|
|
8
|
+
} from "./recall-navigate-window.js";
|
|
9
|
+
|
|
10
|
+
const snap = (...servedIds: string[]): NavigationSnapshot => ({ servedIds });
|
|
11
|
+
const malformed = (value: unknown): NavigationSnapshot => value as NavigationSnapshot;
|
|
12
|
+
|
|
13
|
+
test("default window is 3 snapshots", () => {
|
|
14
|
+
assert.equal(DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS, 3);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("id in the newest snapshot is ok", () => {
|
|
18
|
+
assert.deepEqual(
|
|
19
|
+
assertIdInNavigationWindow({ snapshots: [snap("fact-1"), snap("fact-2")], memoryId: "fact-1" }),
|
|
20
|
+
{ ok: true, memoryId: "fact-1" },
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("id in the 3rd snapshot is ok at the default window", () => {
|
|
25
|
+
const snapshots = [snap("fact-a"), snap("fact-b"), snap("fact-c"), snap("fact-d")];
|
|
26
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: "fact-c" }), {
|
|
27
|
+
ok: true,
|
|
28
|
+
memoryId: "fact-c",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("id only in a 4th snapshot is not_served", () => {
|
|
33
|
+
const snapshots = [snap("fact-a"), snap("fact-b"), snap("fact-c"), snap("fact-d")];
|
|
34
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: "fact-d" }), {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: "not_served",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("windowSnapshots 1 rejects an id from the 2nd snapshot", () => {
|
|
41
|
+
const snapshots = [snap("fact-a"), snap("fact-b")];
|
|
42
|
+
assert.deepEqual(
|
|
43
|
+
assertIdInNavigationWindow({ snapshots, memoryId: "fact-b", windowSnapshots: 1 }),
|
|
44
|
+
{ ok: false, error: "not_served" },
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("windowSnapshots 0, negative, fractional, NaN, Infinity throw", () => {
|
|
49
|
+
const snapshots = [snap("fact-a")];
|
|
50
|
+
for (const windowSnapshots of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() => assertIdInNavigationWindow({ snapshots, memoryId: "fact-a", windowSnapshots }),
|
|
53
|
+
{ name: "RangeError", message: /windowSnapshots/ },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("non-string and whitespace ids are empty_id, never not_served", () => {
|
|
59
|
+
const snapshots = [snap("fact-a")];
|
|
60
|
+
for (const memoryId of [null, undefined, 42, {}, "", " ", "\t\n"]) {
|
|
61
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId }), {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: "empty_id",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("padded id matches after trim", () => {
|
|
69
|
+
const snapshots = [snap("fact-1")];
|
|
70
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: " fact-1 \t" }), {
|
|
71
|
+
ok: true,
|
|
72
|
+
memoryId: "fact-1",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("case-different id is not_served", () => {
|
|
77
|
+
const snapshots = [snap("Fact-1")];
|
|
78
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots, memoryId: "fact-1" }), {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: "not_served",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("empty snapshots list is not_served", () => {
|
|
85
|
+
assert.deepEqual(assertIdInNavigationWindow({ snapshots: [], memoryId: "fact-1" }), {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: "not_served",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("malformed snapshot consumes its window slot without throwing", () => {
|
|
92
|
+
// Missing servedIds on the newest snapshot: it still burns the only
|
|
93
|
+
// window slot, so the id served by the 2nd snapshot stays not_served.
|
|
94
|
+
assert.doesNotThrow(() => {
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
assertIdInNavigationWindow({
|
|
97
|
+
snapshots: [malformed({}), malformed({ servedIds: "fact-1" })],
|
|
98
|
+
memoryId: "fact-1",
|
|
99
|
+
windowSnapshots: 1,
|
|
100
|
+
}),
|
|
101
|
+
{ ok: false, error: "not_served" },
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall navigation window authority (issue #1956).
|
|
3
|
+
*
|
|
4
|
+
* Pure. An expandable id must have been served by one of the session's
|
|
5
|
+
* last `windowSnapshots` recall snapshots, ordered newest first. Unknown
|
|
6
|
+
* or foreign ids are rejected, not reinterpreted (pattern 39).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS = 3;
|
|
10
|
+
|
|
11
|
+
/** Ids served by one recall snapshot, newest snapshot first when passed in. */
|
|
12
|
+
export interface NavigationSnapshot {
|
|
13
|
+
servedIds: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type NavigationAuthorityResult =
|
|
17
|
+
| { ok: true; memoryId: string }
|
|
18
|
+
| { ok: false; error: "empty_id" | "not_served" };
|
|
19
|
+
|
|
20
|
+
export function assertIdInNavigationWindow(input: {
|
|
21
|
+
snapshots: readonly NavigationSnapshot[];
|
|
22
|
+
memoryId: unknown;
|
|
23
|
+
windowSnapshots?: number;
|
|
24
|
+
}): NavigationAuthorityResult {
|
|
25
|
+
const windowSnapshots = input.windowSnapshots ?? DEFAULT_NAVIGATION_WINDOW_SNAPSHOTS;
|
|
26
|
+
if (!Number.isInteger(windowSnapshots) || windowSnapshots < 1) {
|
|
27
|
+
throw new RangeError(
|
|
28
|
+
`windowSnapshots must be a positive integer; got ${JSON.stringify(windowSnapshots)}`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
if (typeof input.memoryId !== "string" || input.memoryId.trim() === "") {
|
|
32
|
+
return { ok: false, error: "empty_id" };
|
|
33
|
+
}
|
|
34
|
+
const wanted = input.memoryId.trim();
|
|
35
|
+
for (const snapshot of input.snapshots.slice(0, windowSnapshots)) {
|
|
36
|
+
// A malformed entry (missing or non-array servedIds) was still a real
|
|
37
|
+
// recall turn: it consumes a window slot but contributes no ids.
|
|
38
|
+
if (!snapshot || !Array.isArray(snapshot.servedIds)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const served of snapshot.servedIds) {
|
|
42
|
+
if (typeof served === "string" && served.trim() === wanted) {
|
|
43
|
+
return { ok: true, memoryId: wanted };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ok: false, error: "not_served" };
|
|
48
|
+
}
|