@remnic/core 9.69.5 → 9.69.7
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.d.ts +1 -1
- package/dist/access-admin-ops-surface.js +24 -24
- package/dist/access-authorization-probe.d.ts +1 -1
- package/dist/access-authorization-probe.js +25 -25
- package/dist/access-boundary.d.ts +1 -1
- package/dist/access-boundary.js +24 -24
- package/dist/access-cli.js +80 -80
- package/dist/access-extraction-force-flush.d.ts +1 -1
- package/dist/access-extraction-force-flush.js +24 -24
- package/dist/access-http-lcm-compaction.d.ts +1 -1
- package/dist/access-http-lifecycle-flush.d.ts +1 -1
- package/dist/access-http-offline-stream.d.ts +1 -1
- package/dist/access-http-query.js +26 -26
- package/dist/access-http.d.ts +2 -2
- package/dist/access-http.js +42 -42
- package/dist/access-identity-continuity-surface.js +24 -24
- package/dist/access-lcm-surface.d.ts +1 -1
- package/dist/access-lcm-surface.js +24 -24
- package/dist/access-mcp.d.ts +1 -1
- package/dist/access-mcp.js +33 -33
- package/dist/access-namespace-preflight.js +24 -24
- package/dist/access-observe-write-surface.d.ts +1 -1
- package/dist/access-observe-write-surface.js +24 -24
- package/dist/access-offline-manifest.js +3 -3
- package/dist/access-operations-batch.js +27 -27
- package/dist/access-operations.d.ts +6 -6
- package/dist/access-operations.js +32 -32
- package/dist/access-recall-concurrency.d.ts +1 -1
- package/dist/access-recall-concurrency.js +24 -24
- package/dist/access-recall-response.d.ts +1 -1
- package/dist/access-recall-response.js +24 -24
- package/dist/access-recall-surface.d.ts +1 -1
- package/dist/access-recall-surface.js +24 -24
- package/dist/access-schema.d.ts +89 -89
- package/dist/access-schema.js +5 -5
- package/dist/access-service-helpers.d.ts +1 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +24 -24
- package/dist/access-surface-catalog.d.ts +1 -1
- package/dist/access-surface-catalog.js +26 -26
- package/dist/active-recall.js +6 -6
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/briefing.js +6 -6
- 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-YNF4ZT3X.js → chunk-26JWBSWD.js} +4 -4
- 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-NM5EOKN7.js → chunk-6C57JBJ3.js} +2 -2
- package/dist/{chunk-DD2BKHSD.js → chunk-6EE5LJSZ.js} +2 -2
- 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-TP3N5364.js → chunk-APGIIJRE.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-XOGYSWQZ.js → chunk-DZNNWPQ7.js} +17 -17
- package/dist/{chunk-6JYTP3JP.js → chunk-E5WTGEWX.js} +3 -3
- package/dist/{chunk-CGFDT752.js → chunk-E6Z4LWNJ.js} +4 -4
- package/dist/{chunk-PY5E3UOD.js → chunk-EG6NUYY3.js} +2 -2
- package/dist/{chunk-EJJ57PTY.js → chunk-GD7D7WP2.js} +2 -2
- package/dist/{chunk-QDBG45NK.js → chunk-GFGQOZGG.js} +10 -10
- package/dist/{chunk-7TCISOAJ.js → chunk-GH7UF5BV.js} +136 -45
- package/dist/chunk-GH7UF5BV.js.map +1 -0
- package/dist/{chunk-EVLHZ6F3.js → chunk-GISV63M2.js} +2 -2
- package/dist/{chunk-MDQXW235.js → chunk-GTZLPFAT.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-SKVSBTFQ.js → chunk-IWO4HEOL.js} +2 -2
- package/dist/{chunk-6EXRODDF.js → chunk-J2TT3RV6.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-VSWRJQQW.js → chunk-KJUWWN3T.js} +2 -1
- package/dist/{chunk-W6V2XVUO.js → chunk-KLLSFJBJ.js} +3 -3
- package/dist/{chunk-C5FRCM4B.js → chunk-LHGIBXVX.js} +2 -2
- package/dist/{chunk-PGD3DXZP.js → chunk-M5UJJUON.js} +3 -3
- package/dist/{chunk-5C4LXMSK.js → chunk-MWHSWK4W.js} +34 -34
- package/dist/{chunk-WTWHORVW.js → chunk-N27L5IY2.js} +5 -5
- package/dist/{chunk-4OH6FGAO.js → chunk-N5VXPTXC.js} +12 -12
- 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-GM35IHF7.js → chunk-PRK4WTPU.js} +2 -2
- package/dist/{chunk-LGVIKA7O.js → chunk-QAITDA5B.js} +6 -6
- 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-NAB4MKTJ.js → chunk-S6DX26S3.js} +3 -3
- package/dist/{chunk-R4FPBSFD.js → chunk-SO73GYOZ.js} +2 -2
- package/dist/{chunk-V3KLIYTW.js → chunk-SXQZMC7R.js} +2 -2
- package/dist/{chunk-JHTF22NQ.js → chunk-TA7UZZMW.js} +2 -2
- package/dist/{chunk-3X7Z467X.js → chunk-UJMOG5LL.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-EJF6AV2U.js → chunk-XHGDFN7A.js} +6 -6
- package/dist/{chunk-FGV7V2N4.js → chunk-Y3WMJAZ3.js} +2 -2
- package/dist/{chunk-ZET6A3OF.js → chunk-YHCBJULX.js} +2 -2
- package/dist/{chunk-EAG7YMIS.js → chunk-YVMTXBJJ.js} +2 -2
- package/dist/{chunk-OUKBMQ2O.js → chunk-Z3SQEEB6.js} +2 -2
- package/dist/{chunk-OUAK37ID.js → chunk-ZNXPGZJQ.js} +2 -2
- package/dist/{chunk-MVH7YAWY.js → chunk-ZPN6SSWA.js} +2 -2
- package/dist/{cli-Bvy4jdBu.d.ts → cli-CFpMhqz5.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +61 -61
- 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 +6 -6
- 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.d.ts +1 -1
- package/dist/external-wiki-access.js +25 -25
- package/dist/external-wiki-mcp-tools.d.ts +1 -1
- 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 +6 -6
- 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 +706 -681
- package/dist/index.js +92 -86
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +2 -2
- package/dist/location/index.js +8 -8
- 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/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/memory-subject-cli.d.ts +1 -1
- package/dist/memory-subject.d.ts +1 -1
- 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 +22 -22
- package/dist/orchestration/maintenance.js +15 -15
- package/dist/orchestrator.js +82 -82
- package/dist/{public-http-2MyshWxl.d.ts → public-http-C9-CHc40.d.ts} +1 -1
- 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/resume-bundles.js +7 -7
- package/dist/schemas.d.ts +176 -176
- 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/shared-context/manager.d.ts +6 -6
- package/dist/source-agent-qualifier.js +6 -6
- package/dist/standup/build.js +6 -6
- package/dist/storage.js +5 -5
- package/dist/support-passport/index.d.ts +4 -4
- package/dist/support-passport/index.js +27 -27
- 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 +7 -7
- package/dist/transfer/types.d.ts +78 -78
- package/dist/verified-recall.js +6 -6
- package/dist/who-knows-cli.d.ts +1 -1
- package/dist/who-knows-cli.js +7 -7
- package/dist/who-knows.d.ts +1 -1
- package/dist/who-knows.js +6 -6
- package/package.json +2 -2
- package/src/activity/index.ts +1 -0
- package/src/activity/journal-vault-provenance.test.ts +120 -0
- package/src/activity/journal-vault-provenance.ts +67 -0
- 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/recall-state-view-zero-diff.test.ts +282 -0
- package/src/recall-state-view-zero-diff.ts +125 -0
- package/src/shared-context/authority-precedence.test.ts +127 -0
- package/src/shared-context/authority-precedence.ts +68 -0
- package/dist/chunk-7TCISOAJ.js.map +0 -1
- package/dist/{access-service-LbwWl05g.d.ts → access-service-DcfM0Vf0.d.ts} +64 -64
- /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-YNF4ZT3X.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-NM5EOKN7.js.map → chunk-6C57JBJ3.js.map} +0 -0
- /package/dist/{chunk-DD2BKHSD.js.map → chunk-6EE5LJSZ.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-TP3N5364.js.map → chunk-APGIIJRE.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-XOGYSWQZ.js.map → chunk-DZNNWPQ7.js.map} +0 -0
- /package/dist/{chunk-6JYTP3JP.js.map → chunk-E5WTGEWX.js.map} +0 -0
- /package/dist/{chunk-CGFDT752.js.map → chunk-E6Z4LWNJ.js.map} +0 -0
- /package/dist/{chunk-PY5E3UOD.js.map → chunk-EG6NUYY3.js.map} +0 -0
- /package/dist/{chunk-EJJ57PTY.js.map → chunk-GD7D7WP2.js.map} +0 -0
- /package/dist/{chunk-QDBG45NK.js.map → chunk-GFGQOZGG.js.map} +0 -0
- /package/dist/{chunk-EVLHZ6F3.js.map → chunk-GISV63M2.js.map} +0 -0
- /package/dist/{chunk-MDQXW235.js.map → chunk-GTZLPFAT.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-SKVSBTFQ.js.map → chunk-IWO4HEOL.js.map} +0 -0
- /package/dist/{chunk-6EXRODDF.js.map → chunk-J2TT3RV6.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-VSWRJQQW.js.map → chunk-KJUWWN3T.js.map} +0 -0
- /package/dist/{chunk-W6V2XVUO.js.map → chunk-KLLSFJBJ.js.map} +0 -0
- /package/dist/{chunk-C5FRCM4B.js.map → chunk-LHGIBXVX.js.map} +0 -0
- /package/dist/{chunk-PGD3DXZP.js.map → chunk-M5UJJUON.js.map} +0 -0
- /package/dist/{chunk-5C4LXMSK.js.map → chunk-MWHSWK4W.js.map} +0 -0
- /package/dist/{chunk-WTWHORVW.js.map → chunk-N27L5IY2.js.map} +0 -0
- /package/dist/{chunk-4OH6FGAO.js.map → chunk-N5VXPTXC.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-GM35IHF7.js.map → chunk-PRK4WTPU.js.map} +0 -0
- /package/dist/{chunk-LGVIKA7O.js.map → chunk-QAITDA5B.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-NAB4MKTJ.js.map → chunk-S6DX26S3.js.map} +0 -0
- /package/dist/{chunk-R4FPBSFD.js.map → chunk-SO73GYOZ.js.map} +0 -0
- /package/dist/{chunk-V3KLIYTW.js.map → chunk-SXQZMC7R.js.map} +0 -0
- /package/dist/{chunk-JHTF22NQ.js.map → chunk-TA7UZZMW.js.map} +0 -0
- /package/dist/{chunk-3X7Z467X.js.map → chunk-UJMOG5LL.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-EJF6AV2U.js.map → chunk-XHGDFN7A.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-EAG7YMIS.js.map → chunk-YVMTXBJJ.js.map} +0 -0
- /package/dist/{chunk-OUKBMQ2O.js.map → chunk-Z3SQEEB6.js.map} +0 -0
- /package/dist/{chunk-OUAK37ID.js.map → chunk-ZNXPGZJQ.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,120 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { buildJournalMemoryProvenance } from "./journal-vault-provenance.js";
|
|
5
|
+
|
|
6
|
+
test("vault source produces the documented tags, attributes, and validAt", () => {
|
|
7
|
+
assert.deepEqual(
|
|
8
|
+
buildJournalMemoryProvenance({ source: "vault", date: "2026-08-01" }),
|
|
9
|
+
{
|
|
10
|
+
tags: ["journal", "journal-day:2026-08-01"],
|
|
11
|
+
structuredAttributes: { journalSource: "vault" },
|
|
12
|
+
validAt: "2026-08-01T00:00:00.000Z",
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("file source differs only in the attribute value", () => {
|
|
18
|
+
const vault = buildJournalMemoryProvenance({ source: "vault", date: "2026-08-01" });
|
|
19
|
+
const file = buildJournalMemoryProvenance({ source: "file", date: "2026-08-01" });
|
|
20
|
+
assert.deepEqual(file.structuredAttributes, { journalSource: "file" });
|
|
21
|
+
assert.deepEqual(file.tags, vault.tags);
|
|
22
|
+
assert.equal(file.validAt, vault.validAt);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("every structured attribute value is a string", () => {
|
|
26
|
+
for (const source of ["file", "vault"] as const) {
|
|
27
|
+
const { structuredAttributes } = buildJournalMemoryProvenance({
|
|
28
|
+
source,
|
|
29
|
+
date: "2026-08-01",
|
|
30
|
+
});
|
|
31
|
+
for (const [key, value] of Object.entries(structuredAttributes)) {
|
|
32
|
+
assert.equal(typeof value, "string", `${key} must be a string`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("unknown source throws RangeError naming source", () => {
|
|
38
|
+
for (const source of ["memoryDir", "vault ", "Vault", ""]) {
|
|
39
|
+
assert.throws(
|
|
40
|
+
() => buildJournalMemoryProvenance({ source, date: "2026-08-01" }),
|
|
41
|
+
RangeError,
|
|
42
|
+
);
|
|
43
|
+
assert.throws(
|
|
44
|
+
() => buildJournalMemoryProvenance({ source, date: "2026-08-01" }),
|
|
45
|
+
/source/,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("non-string source is rejected, not defaulted", () => {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() =>
|
|
53
|
+
buildJournalMemoryProvenance({
|
|
54
|
+
source: undefined as unknown as string,
|
|
55
|
+
date: "2026-08-01",
|
|
56
|
+
}),
|
|
57
|
+
RangeError,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("malformed date throws RangeError naming date", () => {
|
|
62
|
+
for (const date of [
|
|
63
|
+
"2026-8-1",
|
|
64
|
+
"2026-02-30",
|
|
65
|
+
"2026-13-01",
|
|
66
|
+
"2026-02-29",
|
|
67
|
+
"",
|
|
68
|
+
" 2026-08-01",
|
|
69
|
+
"2026-08-01 ",
|
|
70
|
+
"0026-01-01",
|
|
71
|
+
]) {
|
|
72
|
+
assert.throws(
|
|
73
|
+
() => buildJournalMemoryProvenance({ source: "vault", date }),
|
|
74
|
+
RangeError,
|
|
75
|
+
);
|
|
76
|
+
assert.throws(
|
|
77
|
+
() => buildJournalMemoryProvenance({ source: "vault", date }),
|
|
78
|
+
/date/,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("non-string date is rejected, not defaulted", () => {
|
|
84
|
+
assert.throws(
|
|
85
|
+
() =>
|
|
86
|
+
buildJournalMemoryProvenance({
|
|
87
|
+
source: "vault",
|
|
88
|
+
date: 20260801 as unknown as string,
|
|
89
|
+
}),
|
|
90
|
+
RangeError,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("real leap day is accepted", () => {
|
|
95
|
+
assert.equal(
|
|
96
|
+
buildJournalMemoryProvenance({ source: "vault", date: "2024-02-29" }).validAt,
|
|
97
|
+
"2024-02-29T00:00:00.000Z",
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("tags are exactly two entries in order", () => {
|
|
102
|
+
const { tags } = buildJournalMemoryProvenance({ source: "vault", date: "2026-08-01" });
|
|
103
|
+
assert.equal(tags.length, 2);
|
|
104
|
+
assert.deepEqual(tags, ["journal", "journal-day:2026-08-01"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("two calls are deep-equal and share no mutable state", () => {
|
|
108
|
+
const first = buildJournalMemoryProvenance({ source: "vault", date: "2026-08-01" });
|
|
109
|
+
first.tags.push("mutated");
|
|
110
|
+
first.structuredAttributes.journalSource = "mutated";
|
|
111
|
+
first.validAt = "mutated";
|
|
112
|
+
assert.deepEqual(
|
|
113
|
+
buildJournalMemoryProvenance({ source: "vault", date: "2026-08-01" }),
|
|
114
|
+
{
|
|
115
|
+
tags: ["journal", "journal-day:2026-08-01"],
|
|
116
|
+
structuredAttributes: { journalSource: "vault" },
|
|
117
|
+
validAt: "2026-08-01T00:00:00.000Z",
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build provenance for journal-derived memories (issue #1987).
|
|
3
|
+
*
|
|
4
|
+
* Pure: no I/O, no clock — the day comes from the caller. The source is
|
|
5
|
+
* validated through the journal-source parser, so `structuredAttributes.
|
|
6
|
+
* journalSource` is always a canonical journal source value ("file" |
|
|
7
|
+
* "vault"), never a re-declared copy of the allow-list. Exported helper;
|
|
8
|
+
* caller wiring into journal memory generation is a later slice.
|
|
9
|
+
*/
|
|
10
|
+
import { resolveJournalSource } from "./journal-source.js";
|
|
11
|
+
|
|
12
|
+
export interface JournalMemoryProvenance {
|
|
13
|
+
tags: string[];
|
|
14
|
+
structuredAttributes: Record<string, string>;
|
|
15
|
+
validAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
19
|
+
|
|
20
|
+
export function buildJournalMemoryProvenance(input: {
|
|
21
|
+
/** Journal source; must be one of the journal-source allow-list values. */
|
|
22
|
+
source: string;
|
|
23
|
+
/** Journal day, YYYY-MM-DD. */
|
|
24
|
+
date: string;
|
|
25
|
+
}): JournalMemoryProvenance {
|
|
26
|
+
if (typeof input.source !== "string") {
|
|
27
|
+
throw new RangeError("journal provenance source must be a string");
|
|
28
|
+
}
|
|
29
|
+
const resolved = resolveJournalSource({
|
|
30
|
+
source: input.source,
|
|
31
|
+
// Provenance never consumes the heading; vault mode just requires a
|
|
32
|
+
// non-empty one to resolve.
|
|
33
|
+
heading: "journal",
|
|
34
|
+
});
|
|
35
|
+
if (!resolved.ok) {
|
|
36
|
+
throw new RangeError(
|
|
37
|
+
`journal provenance source must be one of "file", "vault": ${JSON.stringify(input.source)}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof input.date !== "string" || !DAY_PATTERN.test(input.date)) {
|
|
42
|
+
throw new RangeError(
|
|
43
|
+
`journal provenance date must be exactly YYYY-MM-DD: ${JSON.stringify(input.date)}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const [year, month, day] = input.date
|
|
47
|
+
.split("-")
|
|
48
|
+
.map(Number) as [number, number, number];
|
|
49
|
+
// Round-trip through UTC rejects non-calendar days (2026-02-30) and the
|
|
50
|
+
// two-digit-year Date.UTC remap (0026 -> 1926) without reading a clock.
|
|
51
|
+
const utc = new Date(Date.UTC(year, month - 1, day));
|
|
52
|
+
if (
|
|
53
|
+
utc.getUTCFullYear() !== year ||
|
|
54
|
+
utc.getUTCMonth() !== month - 1 ||
|
|
55
|
+
utc.getUTCDate() !== day
|
|
56
|
+
) {
|
|
57
|
+
throw new RangeError(
|
|
58
|
+
`journal provenance date must be a real calendar day: ${input.date}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
tags: ["journal", `journal-day:${input.date}`],
|
|
64
|
+
structuredAttributes: { journalSource: resolved.mode },
|
|
65
|
+
validAt: `${input.date}T00:00:00.000Z`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -16,6 +16,7 @@ export * from "./week-export.js";
|
|
|
16
16
|
export * from "./week-snapshot.js";
|
|
17
17
|
export * from "./week-previous.js";
|
|
18
18
|
export * from "./week-recurring.js";
|
|
19
|
+
export * from "./week-config-hash.js";
|
|
19
20
|
export * from "./analysis-batch.js";
|
|
20
21
|
export * from "./analysis-evidence.js";
|
|
21
22
|
export * from "./analysis-prompt.js";
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { computeWeeklyConfigHash, WEEK_START_DAYS, type WeeklyConfigInput } from "./week-config-hash.js";
|
|
5
|
+
|
|
6
|
+
const BASE: WeeklyConfigInput = {
|
|
7
|
+
timezone: "Europe/Paris",
|
|
8
|
+
weekStartsOn: "monday",
|
|
9
|
+
categories: [
|
|
10
|
+
{ id: "development", name: "Development" },
|
|
11
|
+
{ id: "communication", name: "Communication" },
|
|
12
|
+
{ id: "system.unknown", name: "Uncategorized" },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("identical inputs produce identical hashes", () => {
|
|
17
|
+
const again: WeeklyConfigInput = {
|
|
18
|
+
timezone: "Europe/Paris",
|
|
19
|
+
weekStartsOn: "monday",
|
|
20
|
+
categories: BASE.categories.map((category) => ({ id: category.id, name: category.name })),
|
|
21
|
+
};
|
|
22
|
+
assert.strictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(again));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("category order does not change the hash", () => {
|
|
26
|
+
const reordered: WeeklyConfigInput = {
|
|
27
|
+
...BASE,
|
|
28
|
+
categories: [...BASE.categories].reverse(),
|
|
29
|
+
};
|
|
30
|
+
assert.strictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(reordered));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("key insertion order does not change the hash", () => {
|
|
34
|
+
const rotated: WeeklyConfigInput = {
|
|
35
|
+
categories: [
|
|
36
|
+
{ name: "Development", id: "development" },
|
|
37
|
+
{ name: "Communication", id: "communication" },
|
|
38
|
+
{ name: "Uncategorized", id: "system.unknown" },
|
|
39
|
+
],
|
|
40
|
+
weekStartsOn: "monday",
|
|
41
|
+
timezone: "Europe/Paris",
|
|
42
|
+
};
|
|
43
|
+
assert.strictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(rotated));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a renamed category changes the hash", () => {
|
|
47
|
+
const renamed: WeeklyConfigInput = {
|
|
48
|
+
...BASE,
|
|
49
|
+
categories: BASE.categories.map((category) =>
|
|
50
|
+
category.id === "development" ? { id: "development", name: "Coding" } : category,
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
assert.notStrictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(renamed));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("an added category changes the hash", () => {
|
|
57
|
+
const added: WeeklyConfigInput = {
|
|
58
|
+
...BASE,
|
|
59
|
+
categories: [...BASE.categories, { id: "design", name: "Design" }],
|
|
60
|
+
};
|
|
61
|
+
assert.notStrictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(added));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a removed category changes the hash", () => {
|
|
65
|
+
const removed: WeeklyConfigInput = {
|
|
66
|
+
...BASE,
|
|
67
|
+
categories: BASE.categories.slice(0, 2),
|
|
68
|
+
};
|
|
69
|
+
assert.notStrictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(removed));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a different timezone changes the hash", () => {
|
|
73
|
+
const shifted: WeeklyConfigInput = { ...BASE, timezone: "UTC" };
|
|
74
|
+
assert.notStrictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(shifted));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a different weekStartsOn changes the hash", () => {
|
|
78
|
+
const shifted: WeeklyConfigInput = { ...BASE, weekStartsOn: "sunday" };
|
|
79
|
+
assert.notStrictEqual(computeWeeklyConfigHash(BASE), computeWeeklyConfigHash(shifted));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("duplicate category ids throw RangeError", () => {
|
|
83
|
+
const duplicated: WeeklyConfigInput = {
|
|
84
|
+
...BASE,
|
|
85
|
+
categories: [
|
|
86
|
+
{ id: "development", name: "Development" },
|
|
87
|
+
{ id: "development", name: "Dev" },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
assert.throws(() => computeWeeklyConfigHash(duplicated), RangeError);
|
|
91
|
+
assert.throws(() => computeWeeklyConfigHash(duplicated), /duplicate/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("blank fields throw RangeError naming the field", () => {
|
|
95
|
+
assert.throws(
|
|
96
|
+
() => computeWeeklyConfigHash({ ...BASE, timezone: "" }),
|
|
97
|
+
RangeError,
|
|
98
|
+
);
|
|
99
|
+
assert.throws(
|
|
100
|
+
() => computeWeeklyConfigHash({ ...BASE, timezone: " " }),
|
|
101
|
+
/timezone/,
|
|
102
|
+
);
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => computeWeeklyConfigHash({ ...BASE, weekStartsOn: "" }),
|
|
105
|
+
/weekStartsOn/,
|
|
106
|
+
);
|
|
107
|
+
assert.throws(
|
|
108
|
+
() => computeWeeklyConfigHash({ ...BASE, categories: [{ id: "", name: "Dev" }] }),
|
|
109
|
+
/categories\[0\]\.id/,
|
|
110
|
+
);
|
|
111
|
+
assert.throws(
|
|
112
|
+
() => computeWeeklyConfigHash({ ...BASE, categories: [{ id: "dev", name: " " }] }),
|
|
113
|
+
/categories\[0\]\.name/,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("digest is lowercase hex of the documented length", () => {
|
|
118
|
+
const digest = computeWeeklyConfigHash(BASE);
|
|
119
|
+
assert.match(digest, /^[0-9a-f]{64}$/);
|
|
120
|
+
assert.strictEqual(digest.length, 64);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("the caller's category array is not mutated", () => {
|
|
124
|
+
const input: WeeklyConfigInput = {
|
|
125
|
+
timezone: "UTC",
|
|
126
|
+
weekStartsOn: "monday",
|
|
127
|
+
categories: [
|
|
128
|
+
{ id: "b", name: "B" },
|
|
129
|
+
{ id: "a", name: "A" },
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
const before = structuredClone(input);
|
|
133
|
+
computeWeeklyConfigHash(input);
|
|
134
|
+
assert.deepStrictEqual(input, before);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Review: a non-blank check let a typo acquire a durable snapshot identity,
|
|
138
|
+
// so correcting the typo later forked the stored history.
|
|
139
|
+
test("an invalid IANA timezone is refused before hashing", () => {
|
|
140
|
+
// "utc" is deliberately absent: IANA zone names are case-insensitive, so
|
|
141
|
+
// the shared validator accepts it and it is not a typo.
|
|
142
|
+
for (const timezone of ["Not/AZone", "America/Nowhere", "Chicago"]) {
|
|
143
|
+
assert.throws(
|
|
144
|
+
() =>
|
|
145
|
+
computeWeeklyConfigHash({
|
|
146
|
+
timezone,
|
|
147
|
+
weekStartsOn: "monday",
|
|
148
|
+
categories: [{ id: "dev", name: "Development" }],
|
|
149
|
+
}),
|
|
150
|
+
/timezone/i,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
// A real zone still works.
|
|
154
|
+
assert.match(
|
|
155
|
+
computeWeeklyConfigHash({
|
|
156
|
+
timezone: "America/Chicago",
|
|
157
|
+
weekStartsOn: "monday",
|
|
158
|
+
categories: [{ id: "dev", name: "Development" }],
|
|
159
|
+
}),
|
|
160
|
+
/^[0-9a-f]+$/,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("weekStartsOn must be a known weekday", () => {
|
|
165
|
+
for (const weekStartsOn of ["monady", "Monday", "mon", ""]) {
|
|
166
|
+
assert.throws(
|
|
167
|
+
() =>
|
|
168
|
+
computeWeeklyConfigHash({
|
|
169
|
+
timezone: "UTC",
|
|
170
|
+
weekStartsOn,
|
|
171
|
+
categories: [{ id: "dev", name: "Development" }],
|
|
172
|
+
}),
|
|
173
|
+
/weekStartsOn/,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
for (const weekStartsOn of WEEK_START_DAYS) {
|
|
177
|
+
assert.match(
|
|
178
|
+
computeWeeklyConfigHash({
|
|
179
|
+
timezone: "UTC",
|
|
180
|
+
weekStartsOn,
|
|
181
|
+
categories: [{ id: "dev", name: "Development" }],
|
|
182
|
+
}),
|
|
183
|
+
/^[0-9a-f]+$/,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Review round 2: equivalent zone identifiers must not fork one snapshot
|
|
189
|
+
// history. Canonicalization happens before hashing.
|
|
190
|
+
test("equivalent timezone identifiers hash identically", () => {
|
|
191
|
+
const base = { weekStartsOn: "monday", categories: [{ id: "c", name: "Code" }] };
|
|
192
|
+
assert.equal(
|
|
193
|
+
computeWeeklyConfigHash({ ...base, timezone: "utc" }),
|
|
194
|
+
computeWeeklyConfigHash({ ...base, timezone: "UTC" }),
|
|
195
|
+
"casing is not a semantic change",
|
|
196
|
+
);
|
|
197
|
+
assert.equal(
|
|
198
|
+
computeWeeklyConfigHash({ ...base, timezone: "US/Eastern" }),
|
|
199
|
+
computeWeeklyConfigHash({ ...base, timezone: "America/New_York" }),
|
|
200
|
+
"an alias is not a semantic change",
|
|
201
|
+
);
|
|
202
|
+
assert.notEqual(
|
|
203
|
+
computeWeeklyConfigHash({ ...base, timezone: "UTC" }),
|
|
204
|
+
computeWeeklyConfigHash({ ...base, timezone: "America/New_York" }),
|
|
205
|
+
"genuinely different zones still differ",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weekly snapshot config hash (issue #2052).
|
|
3
|
+
*
|
|
4
|
+
* `persistWeeklySnapshot` keys each snapshot file by a `configHash`; this
|
|
5
|
+
* computes that digest deterministically from the weekly config so every
|
|
6
|
+
* caller derives the same value for the same inputs. Stable across category
|
|
7
|
+
* order and key insertion order; any semantic change produces a new digest,
|
|
8
|
+
* which keys a new snapshot file instead of mutating an unrelated period.
|
|
9
|
+
*
|
|
10
|
+
* Values are validated, not merely non-blank: a typo like `"Not/AZone"` or
|
|
11
|
+
* `"monady"` would otherwise get a durable snapshot identity, and correcting
|
|
12
|
+
* the typo later would fork the stored history instead of continuing it.
|
|
13
|
+
*/
|
|
14
|
+
import { assertValidTimezone, hashActivityBody } from "../digest.js";
|
|
15
|
+
|
|
16
|
+
export interface WeeklyConfigInput {
|
|
17
|
+
timezone: string;
|
|
18
|
+
weekStartsOn: string;
|
|
19
|
+
/** Category definitions that shape the snapshot. */
|
|
20
|
+
categories: readonly { id: string; name: string }[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Week-start vocabulary. Lowercase day names, matching the config surface. */
|
|
24
|
+
export const WEEK_START_DAYS = Object.freeze([
|
|
25
|
+
"monday",
|
|
26
|
+
"tuesday",
|
|
27
|
+
"wednesday",
|
|
28
|
+
"thursday",
|
|
29
|
+
"friday",
|
|
30
|
+
"saturday",
|
|
31
|
+
"sunday",
|
|
32
|
+
] as const);
|
|
33
|
+
|
|
34
|
+
function requireNonBlank(value: unknown, field: string): string {
|
|
35
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
36
|
+
throw new RangeError(`${field} must be a non-blank string`);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeWeeklyConfigHash(input: WeeklyConfigInput): string {
|
|
42
|
+
if (typeof input !== "object" || input === null) {
|
|
43
|
+
throw new RangeError("input must be a WeeklyConfigInput object");
|
|
44
|
+
}
|
|
45
|
+
const rawTimezone = requireNonBlank(input.timezone, "timezone");
|
|
46
|
+
assertValidTimezone(rawTimezone);
|
|
47
|
+
// Canonicalize before hashing: `utc` and `UTC` are the same zone, and
|
|
48
|
+
// `US/Eastern` is an alias of `America/New_York`. Hashing the raw string
|
|
49
|
+
// gives two callers two identities for one zone, which would fork one
|
|
50
|
+
// snapshot history on a harmless casing or alias change.
|
|
51
|
+
const timezone = new Intl.DateTimeFormat("en-US", { timeZone: rawTimezone })
|
|
52
|
+
.resolvedOptions().timeZone;
|
|
53
|
+
const weekStartsOn = requireNonBlank(input.weekStartsOn, "weekStartsOn");
|
|
54
|
+
if (!(WEEK_START_DAYS as readonly string[]).includes(weekStartsOn)) {
|
|
55
|
+
throw new RangeError(
|
|
56
|
+
`weekStartsOn must be one of ${WEEK_START_DAYS.join(", ")}; got ${JSON.stringify(weekStartsOn)}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(input.categories)) {
|
|
60
|
+
throw new RangeError("categories must be an array of category definitions");
|
|
61
|
+
}
|
|
62
|
+
// Copy + sort: never sort the caller's array in place, and hash category
|
|
63
|
+
// order-independently so a harmless reorder cannot orphan stored snapshots.
|
|
64
|
+
const sorted = input.categories
|
|
65
|
+
.map((category, index) => ({
|
|
66
|
+
id: requireNonBlank(category?.id, `categories[${index}].id`),
|
|
67
|
+
name: requireNonBlank(category?.name, `categories[${index}].name`),
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
70
|
+
for (let index = 1; index < sorted.length; index++) {
|
|
71
|
+
if (sorted[index].id === sorted[index - 1].id) {
|
|
72
|
+
throw new RangeError(`duplicate category id: ${sorted[index].id}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Fixed field order; JSON.stringify each part so no separator can collide
|
|
76
|
+
// with category content.
|
|
77
|
+
const body = [
|
|
78
|
+
JSON.stringify(timezone),
|
|
79
|
+
JSON.stringify(weekStartsOn),
|
|
80
|
+
...sorted.map((category) => JSON.stringify([category.id, category.name])),
|
|
81
|
+
].join("\n");
|
|
82
|
+
return hashActivityBody(body);
|
|
83
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
stampSpanSource,
|
|
5
|
+
verifySpanSource,
|
|
6
|
+
type SpanSourceStamp,
|
|
7
|
+
} from "./extraction-span-source-hash.js";
|
|
8
|
+
|
|
9
|
+
const TEXT = "The canary offset string, version one.";
|
|
10
|
+
|
|
11
|
+
test("stampSpanSource: round trip verifies ok", () => {
|
|
12
|
+
const stamp = stampSpanSource(TEXT);
|
|
13
|
+
const check = verifySpanSource(TEXT, stamp);
|
|
14
|
+
assert.deepEqual(check, { ok: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("stampSpanSource: empty string gets a real stamp and round trips", () => {
|
|
18
|
+
const stamp = stampSpanSource("");
|
|
19
|
+
assert.match(stamp.hash, /^[0-9a-f]{64}$/);
|
|
20
|
+
assert.equal(stamp.length, 0);
|
|
21
|
+
assert.equal(
|
|
22
|
+
stamp.hash,
|
|
23
|
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
24
|
+
);
|
|
25
|
+
assert.deepEqual(verifySpanSource("", stamp), { ok: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("verifySpanSource: same-length single-character edit is hash_mismatch", () => {
|
|
29
|
+
const stamp = stampSpanSource(TEXT);
|
|
30
|
+
const edited = TEXT.replace("canary", "canarz");
|
|
31
|
+
assert.equal(edited.length, TEXT.length);
|
|
32
|
+
const check = verifySpanSource(edited, stamp);
|
|
33
|
+
assert.equal(check.ok, false);
|
|
34
|
+
if (!check.ok) {
|
|
35
|
+
assert.equal(check.error, "hash_mismatch");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("verifySpanSource: added character is length_mismatch even though hash also differs", () => {
|
|
40
|
+
const stamp = stampSpanSource(TEXT);
|
|
41
|
+
const longer = `${TEXT}!`;
|
|
42
|
+
const check = verifySpanSource(longer, stamp);
|
|
43
|
+
assert.equal(check.ok, false);
|
|
44
|
+
if (!check.ok) {
|
|
45
|
+
assert.equal(check.error, "length_mismatch");
|
|
46
|
+
assert.equal(check.expected.length, TEXT.length);
|
|
47
|
+
assert.equal(check.actual.length, longer.length);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("verifySpanSource: failure result carries no source text", () => {
|
|
52
|
+
const original = "original driftmarker-alpha payload";
|
|
53
|
+
const edited = "original driftmarker-beta payload";
|
|
54
|
+
const stamp = stampSpanSource(original);
|
|
55
|
+
const check = verifySpanSource(edited, stamp);
|
|
56
|
+
assert.equal(check.ok, false);
|
|
57
|
+
const serialized = JSON.stringify(check);
|
|
58
|
+
assert.ok(!serialized.includes("driftmarker-alpha"));
|
|
59
|
+
assert.ok(!serialized.includes("driftmarker-beta"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("stampSpanSource: non-string text throws TypeError naming text", () => {
|
|
63
|
+
assert.throws(() => stampSpanSource(42 as unknown as string), /text/);
|
|
64
|
+
assert.throws(() => stampSpanSource(null as unknown as string), /text/);
|
|
65
|
+
assert.throws(
|
|
66
|
+
() => stampSpanSource(undefined as unknown as string),
|
|
67
|
+
/text/,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("verifySpanSource: non-string text throws TypeError naming text", () => {
|
|
72
|
+
const stamp = stampSpanSource(TEXT);
|
|
73
|
+
assert.throws(
|
|
74
|
+
() => verifySpanSource(7 as unknown as string, stamp),
|
|
75
|
+
/text/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function stampWithHash(hash: unknown): SpanSourceStamp {
|
|
80
|
+
return { hash: hash as string, length: TEXT.length };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("verifySpanSource: malformed expected hash throws RangeError naming the field", () => {
|
|
84
|
+
const good = stampSpanSource(TEXT);
|
|
85
|
+
const tooShort = good.hash.slice(0, 63);
|
|
86
|
+
const upper = good.hash.toUpperCase();
|
|
87
|
+
const nonHex = `${good.hash.slice(0, 63)}z`;
|
|
88
|
+
for (const bad of [tooShort, upper, nonHex, 123, null]) {
|
|
89
|
+
assert.throws(
|
|
90
|
+
() => verifySpanSource(TEXT, stampWithHash(bad)),
|
|
91
|
+
(err: unknown) => {
|
|
92
|
+
assert.ok(err instanceof RangeError);
|
|
93
|
+
assert.match((err as Error).message, /hash/);
|
|
94
|
+
return true;
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("verifySpanSource: inherited stamp fields are rejected", () => {
|
|
101
|
+
const good = stampSpanSource(TEXT);
|
|
102
|
+
const inherited = Object.create(good) as SpanSourceStamp;
|
|
103
|
+
assert.throws(
|
|
104
|
+
() => verifySpanSource(TEXT, inherited),
|
|
105
|
+
(err: unknown) => {
|
|
106
|
+
assert.ok(err instanceof RangeError);
|
|
107
|
+
assert.match((err as Error).message, /hash/);
|
|
108
|
+
return true;
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("verifySpanSource: negative or fractional expected length throws", () => {
|
|
114
|
+
const good = stampSpanSource(TEXT);
|
|
115
|
+
for (const bad of [-1, -0.5, 4.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
116
|
+
assert.throws(
|
|
117
|
+
() => verifySpanSource(TEXT, { hash: good.hash, length: bad }),
|
|
118
|
+
(err: unknown) => {
|
|
119
|
+
assert.ok(err instanceof RangeError);
|
|
120
|
+
assert.match((err as Error).message, /length/);
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("stampSpanSource: two stamps of the same string are identical", () => {
|
|
128
|
+
const a = stampSpanSource(TEXT);
|
|
129
|
+
const b = stampSpanSource(TEXT);
|
|
130
|
+
assert.deepEqual(a, b);
|
|
131
|
+
assert.equal(JSON.stringify(a), JSON.stringify(b));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Review: Node's UTF-8 encoder replaces an unpaired surrogate with U+FFFD, so
|
|
135
|
+
// hashing utf8 gave these three distinct strings ONE digest and drift between
|
|
136
|
+
// them verified as unchanged. Unpaired surrogates arrive via JSON escapes.
|
|
137
|
+
test("unpaired surrogates produce distinct stamps", () => {
|
|
138
|
+
const a = stampSpanSource("\ud800");
|
|
139
|
+
const b = stampSpanSource("\ud801");
|
|
140
|
+
const replacement = stampSpanSource("\ufffd");
|
|
141
|
+
assert.notEqual(a.hash, b.hash);
|
|
142
|
+
assert.notEqual(a.hash, replacement.hash);
|
|
143
|
+
assert.notEqual(b.hash, replacement.hash);
|
|
144
|
+
// And drift between them is now caught rather than passing.
|
|
145
|
+
assert.deepEqual(verifySpanSource("\ud801", a), {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: "hash_mismatch",
|
|
148
|
+
expected: { hash: a.hash, length: a.length },
|
|
149
|
+
actual: b,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Review: the result echoed the caller's stamp object, so an extra property
|
|
154
|
+
// holding the source text would travel with the failure.
|
|
155
|
+
test("a failure result carries only the two stamp fields", () => {
|
|
156
|
+
const stamped = stampSpanSource("the original source text");
|
|
157
|
+
const contaminated = { ...stamped, source: "SENSITIVE-SOURCE-TEXT" } as typeof stamped;
|
|
158
|
+
const result = verifySpanSource("a different source text!", contaminated);
|
|
159
|
+
assert.equal(result.ok, false);
|
|
160
|
+
const serialized = JSON.stringify(result);
|
|
161
|
+
assert.equal(serialized.includes("SENSITIVE-SOURCE-TEXT"), false);
|
|
162
|
+
assert.equal(serialized.includes("source"), false);
|
|
163
|
+
assert.ok(!result.ok && result.error === "hash_mismatch");
|
|
164
|
+
assert.deepEqual(Object.keys(result.ok === false ? result.expected : {}).sort(), ["hash", "length"]);
|
|
165
|
+
});
|