@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.
Files changed (250) hide show
  1. package/dist/access-admin-ops-surface.js +26 -26
  2. package/dist/access-authorization-probe.js +27 -27
  3. package/dist/access-boundary.js +26 -26
  4. package/dist/access-cli.js +84 -84
  5. package/dist/access-extraction-force-flush.js +26 -26
  6. package/dist/access-http-query.js +28 -28
  7. package/dist/access-http.js +44 -44
  8. package/dist/access-identity-continuity-surface.js +26 -26
  9. package/dist/access-lcm-surface.js +26 -26
  10. package/dist/access-mcp.js +35 -35
  11. package/dist/access-namespace-preflight.js +26 -26
  12. package/dist/access-observe-write-surface.js +26 -26
  13. package/dist/access-offline-manifest.js +3 -3
  14. package/dist/access-operations-batch.js +29 -29
  15. package/dist/access-operations.js +34 -34
  16. package/dist/access-recall-concurrency.js +26 -26
  17. package/dist/access-recall-response.js +26 -26
  18. package/dist/access-recall-surface.js +26 -26
  19. package/dist/access-schema.js +5 -5
  20. package/dist/access-service.js +26 -26
  21. package/dist/access-surface-catalog.js +28 -28
  22. package/dist/active-recall.js +5 -5
  23. package/dist/adapters/index.js +7 -7
  24. package/dist/adapters/registry.js +3 -3
  25. package/dist/briefing.js +6 -6
  26. package/dist/buffer.js +3 -3
  27. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-7FJQINUR.js} +2 -2
  28. package/dist/causal-behavior.js +2 -2
  29. package/dist/causal-chain.js +2 -2
  30. package/dist/causal-consolidation.js +9 -9
  31. package/dist/causal-retrieval.js +2 -2
  32. package/dist/causal-trajectory-graph.js +2 -2
  33. package/dist/causal-trajectory.js +1 -1
  34. package/dist/{chunk-7RZJWZ46.js → chunk-227UFSEQ.js} +5 -5
  35. package/dist/{chunk-QA6H4PIO.js → chunk-26JWBSWD.js} +3 -3
  36. package/dist/{chunk-7VA7PARC.js → chunk-2MW3UQNX.js} +2 -2
  37. package/dist/{chunk-BXLOS5AJ.js → chunk-2NLLXCJG.js} +2 -2
  38. package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
  39. package/dist/{chunk-P6GKRT2H.js → chunk-2Z3DG5CQ.js} +1 -1
  40. package/dist/{chunk-6EXRODDF.js → chunk-3FXXFLVK.js} +2 -2
  41. package/dist/{chunk-XOGYSWQZ.js → chunk-3QWF54M4.js} +18 -18
  42. package/dist/{chunk-YTCE2JQL.js → chunk-44HBCMOA.js} +99 -48
  43. package/dist/chunk-44HBCMOA.js.map +1 -0
  44. package/dist/{chunk-IE7N2YK6.js → chunk-4PJYD2L3.js} +2 -2
  45. package/dist/{chunk-MWLBRFLY.js → chunk-5PTS43CH.js} +2 -2
  46. package/dist/{chunk-NM5EOKN7.js → chunk-6C57JBJ3.js} +2 -2
  47. package/dist/{chunk-WTWHORVW.js → chunk-754GA2UE.js} +5 -5
  48. package/dist/{chunk-VDX2J7OX.js → chunk-7LCGFUMB.js} +4 -4
  49. package/dist/{chunk-YPNTBYEV.js → chunk-A3WMMQAH.js} +3 -3
  50. package/dist/{chunk-SZZGVXKC.js → chunk-ANN5VMHT.js} +2 -2
  51. package/dist/{chunk-OLBZ2WGG.js → chunk-C2JCVZWX.js} +2 -2
  52. package/dist/{chunk-7DXW5V2W.js → chunk-CTCGHNWN.js} +5 -5
  53. package/dist/{chunk-JJAOVJ3E.js → chunk-DJZWNKFO.js} +2 -2
  54. package/dist/{chunk-PY5E3UOD.js → chunk-DKZR56A3.js} +2 -2
  55. package/dist/{chunk-6JYTP3JP.js → chunk-E5WTGEWX.js} +3 -3
  56. package/dist/{chunk-WAPSYHNR.js → chunk-EN75MUTO.js} +5 -5
  57. package/dist/{chunk-OMA2B7GY.js → chunk-G4MCAHMF.js} +2 -2
  58. package/dist/{chunk-EJJ57PTY.js → chunk-GD7D7WP2.js} +2 -2
  59. package/dist/{chunk-X3DTN2RC.js → chunk-GFGQOZGG.js} +10 -10
  60. package/dist/{chunk-EVLHZ6F3.js → chunk-GISV63M2.js} +2 -2
  61. package/dist/{chunk-YERBUSU2.js → chunk-H3HOC6UT.js} +2 -2
  62. package/dist/{chunk-AJFKQJQK.js → chunk-HINMCD7V.js} +2 -2
  63. package/dist/{chunk-ISDSX7GW.js → chunk-IGUWU5BD.js} +1 -1
  64. package/dist/{chunk-W6V2XVUO.js → chunk-IIO4C3YL.js} +4 -4
  65. package/dist/{chunk-SKVSBTFQ.js → chunk-IWO4HEOL.js} +2 -2
  66. package/dist/{chunk-TB2MJRI6.js → chunk-J3NGQ5SC.js} +2 -2
  67. package/dist/chunk-J3NGQ5SC.js.map +1 -0
  68. package/dist/{chunk-ZIZUB6VB.js → chunk-J55NAXDH.js} +34 -34
  69. package/dist/{chunk-3X7Z467X.js → chunk-JAU4MRKB.js} +2 -2
  70. package/dist/{chunk-XJJYSX6F.js → chunk-JCUG7AXZ.js} +4 -4
  71. package/dist/{chunk-BX6GW4KC.js → chunk-JMAG7WZ7.js} +2 -2
  72. package/dist/{chunk-NAB4MKTJ.js → chunk-KAVQ5GYY.js} +6 -5
  73. package/dist/{chunk-NAB4MKTJ.js.map → chunk-KAVQ5GYY.js.map} +1 -1
  74. package/dist/{chunk-MDQXW235.js → chunk-KLL5SPER.js} +2 -2
  75. package/dist/{chunk-DD2BKHSD.js → chunk-KZMFU7YL.js} +2 -2
  76. package/dist/{chunk-C5FRCM4B.js → chunk-LHGIBXVX.js} +2 -2
  77. package/dist/{chunk-CGFDT752.js → chunk-LVYO2HZJ.js} +4 -4
  78. package/dist/{chunk-TP3N5364.js → chunk-M5GBTETG.js} +2 -2
  79. package/dist/{chunk-PGD3DXZP.js → chunk-M5UJJUON.js} +3 -3
  80. package/dist/{chunk-HDEQRI2O.js → chunk-NAZROP6I.js} +2 -2
  81. package/dist/{chunk-R3YVNAJB.js → chunk-NDE7CH6C.js} +2 -2
  82. package/dist/{chunk-BIJAWZYW.js → chunk-OLT3YTDH.js} +4 -4
  83. package/dist/{chunk-4OH6FGAO.js → chunk-PEOFTEUC.js} +12 -12
  84. package/dist/{chunk-GM35IHF7.js → chunk-PRK4WTPU.js} +2 -2
  85. package/dist/{chunk-MOUCCUET.js → chunk-QGUFN25Q.js} +2 -2
  86. package/dist/{chunk-5HP6LVWU.js → chunk-QHYSECIG.js} +2 -2
  87. package/dist/{chunk-SZ3SRRYZ.js → chunk-RXS4SQ5S.js} +2 -2
  88. package/dist/{chunk-6KQLDPKA.js → chunk-S6AHHQ6C.js} +2 -2
  89. package/dist/{chunk-R4FPBSFD.js → chunk-SO73GYOZ.js} +2 -2
  90. package/dist/{chunk-M4MNLPO7.js → chunk-SXQZMC7R.js} +2 -2
  91. package/dist/{chunk-JHTF22NQ.js → chunk-TA7UZZMW.js} +2 -2
  92. package/dist/{chunk-UJICQ77Y.js → chunk-UCNQV43K.js} +2 -2
  93. package/dist/{chunk-OUAK37ID.js → chunk-VGTXHTWU.js} +2 -2
  94. package/dist/{chunk-TGDFKC7X.js → chunk-VOPOPTZ7.js} +2 -2
  95. package/dist/{chunk-IFHT77WK.js → chunk-WF2WCZCR.js} +2 -2
  96. package/dist/{chunk-6F5WBXE2.js → chunk-X2NFFBTO.js} +4 -4
  97. package/dist/{chunk-IHFN3JTR.js → chunk-X5TGZAP2.js} +3 -3
  98. package/dist/{chunk-RJ57OOCD.js → chunk-X66BHMAU.js} +2 -2
  99. package/dist/{chunk-DQEMWVMT.js → chunk-X7Y7WX73.js} +1 -1
  100. package/dist/{chunk-FGV7V2N4.js → chunk-Y3WMJAZ3.js} +2 -2
  101. package/dist/{chunk-ZET6A3OF.js → chunk-YHCBJULX.js} +2 -2
  102. package/dist/{chunk-LGVIKA7O.js → chunk-YJPFTF57.js} +6 -6
  103. package/dist/{chunk-EAG7YMIS.js → chunk-YVMTXBJJ.js} +2 -2
  104. package/dist/{chunk-OUKBMQ2O.js → chunk-Z3SQEEB6.js} +2 -2
  105. package/dist/{chunk-MVH7YAWY.js → chunk-ZPN6SSWA.js} +2 -2
  106. package/dist/cli.js +62 -62
  107. package/dist/coding/export-okf-codegraph.js +6 -6
  108. package/dist/coding/pre-action-gate.js +2 -2
  109. package/dist/compounding/engine.js +6 -6
  110. package/dist/config.js +5 -5
  111. package/dist/connectors/codex-materialize-runner.js +6 -6
  112. package/dist/connectors/index.js +6 -6
  113. package/dist/convergence-refresh.js +8 -8
  114. package/dist/conversation-index/backend.js +2 -2
  115. package/dist/corpus-watermark.js +8 -8
  116. package/dist/entity-retrieval.js +6 -6
  117. package/dist/external-wiki-access.js +27 -27
  118. package/dist/extraction-span-source-hash.d.ts +45 -0
  119. package/dist/extraction-span-source-hash.js +52 -0
  120. package/dist/extraction-span-source-hash.js.map +1 -0
  121. package/dist/extraction.js +10 -10
  122. package/dist/{graph-edge-decay-FXGELBFA.js → graph-edge-decay-DGTBFALQ.js} +4 -4
  123. package/dist/graph-snapshot.js +3 -3
  124. package/dist/graph.js +2 -2
  125. package/dist/harmonic-retrieval.js +1 -1
  126. package/dist/index.d.ts +14 -1
  127. package/dist/index.js +94 -90
  128. package/dist/lcm/engine.js +2 -2
  129. package/dist/lcm/index.js +2 -2
  130. package/dist/location/index.js +9 -9
  131. package/dist/maintenance/memory-governance.js +6 -6
  132. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +6 -6
  133. package/dist/maintenance/rebuild-memory-projection.js +7 -7
  134. package/dist/namespaces/migrate.js +7 -7
  135. package/dist/namespaces/search.js +4 -4
  136. package/dist/namespaces/storage.js +6 -6
  137. package/dist/offline-sync.js +2 -2
  138. package/dist/operator-doctor-corpus.js +9 -9
  139. package/dist/operator-toolkit.js +21 -21
  140. package/dist/orchestration/maintenance.js +15 -15
  141. package/dist/orchestrator.js +86 -86
  142. package/dist/recall-state-view-zero-diff.d.ts +23 -0
  143. package/dist/recall-state-view-zero-diff.js +84 -0
  144. package/dist/recall-state-view-zero-diff.js.map +1 -0
  145. package/dist/reconcile/cursor.js +7 -2
  146. package/dist/reconcile/cursor.js.map +1 -1
  147. package/dist/resume-bundles.js +6 -6
  148. package/dist/search/factory.js +3 -3
  149. package/dist/search/index.js +3 -3
  150. package/dist/semantic-consolidation.js +7 -7
  151. package/dist/semantic-rule-promotion.js +6 -6
  152. package/dist/semantic-rule-verifier.js +6 -6
  153. package/dist/source-agent-qualifier.js +9 -9
  154. package/dist/standup/build.js +6 -6
  155. package/dist/storage.js +5 -5
  156. package/dist/support-passport/index.js +29 -29
  157. package/dist/transfer/backup.js +2 -2
  158. package/dist/transfer/capsule-export.js +2 -2
  159. package/dist/transfer/capsule-import.js +2 -2
  160. package/dist/transfer/export-okf.d.ts +2 -1
  161. package/dist/transfer/export-okf.js +9 -7
  162. package/dist/verified-recall.js +6 -6
  163. package/dist/who-knows-cli.js +7 -7
  164. package/dist/who-knows.js +6 -6
  165. package/package.json +2 -2
  166. package/src/activity/timeline/index.ts +1 -0
  167. package/src/activity/timeline/week-config-hash.test.ts +207 -0
  168. package/src/activity/timeline/week-config-hash.ts +83 -0
  169. package/src/extraction-span-source-hash.test.ts +165 -0
  170. package/src/extraction-span-source-hash.ts +100 -0
  171. package/src/location/matching.test.ts +31 -1
  172. package/src/location/matching.ts +6 -1
  173. package/src/recall-state-view-zero-diff.test.ts +282 -0
  174. package/src/recall-state-view-zero-diff.ts +125 -0
  175. package/src/reconcile/cursor.test.ts +18 -0
  176. package/src/reconcile/cursor.ts +13 -2
  177. package/src/shared-context/authority-precedence.test.ts +127 -0
  178. package/src/shared-context/authority-precedence.ts +68 -0
  179. package/src/transfer/export-okf.test.ts +22 -0
  180. package/src/transfer/export-okf.ts +6 -2
  181. package/dist/chunk-TB2MJRI6.js.map +0 -1
  182. package/dist/chunk-YTCE2JQL.js.map +0 -1
  183. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
  184. /package/dist/{chunk-7RZJWZ46.js.map → chunk-227UFSEQ.js.map} +0 -0
  185. /package/dist/{chunk-QA6H4PIO.js.map → chunk-26JWBSWD.js.map} +0 -0
  186. /package/dist/{chunk-7VA7PARC.js.map → chunk-2MW3UQNX.js.map} +0 -0
  187. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-2NLLXCJG.js.map} +0 -0
  188. /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
  189. /package/dist/{chunk-P6GKRT2H.js.map → chunk-2Z3DG5CQ.js.map} +0 -0
  190. /package/dist/{chunk-6EXRODDF.js.map → chunk-3FXXFLVK.js.map} +0 -0
  191. /package/dist/{chunk-XOGYSWQZ.js.map → chunk-3QWF54M4.js.map} +0 -0
  192. /package/dist/{chunk-IE7N2YK6.js.map → chunk-4PJYD2L3.js.map} +0 -0
  193. /package/dist/{chunk-MWLBRFLY.js.map → chunk-5PTS43CH.js.map} +0 -0
  194. /package/dist/{chunk-NM5EOKN7.js.map → chunk-6C57JBJ3.js.map} +0 -0
  195. /package/dist/{chunk-WTWHORVW.js.map → chunk-754GA2UE.js.map} +0 -0
  196. /package/dist/{chunk-VDX2J7OX.js.map → chunk-7LCGFUMB.js.map} +0 -0
  197. /package/dist/{chunk-YPNTBYEV.js.map → chunk-A3WMMQAH.js.map} +0 -0
  198. /package/dist/{chunk-SZZGVXKC.js.map → chunk-ANN5VMHT.js.map} +0 -0
  199. /package/dist/{chunk-OLBZ2WGG.js.map → chunk-C2JCVZWX.js.map} +0 -0
  200. /package/dist/{chunk-7DXW5V2W.js.map → chunk-CTCGHNWN.js.map} +0 -0
  201. /package/dist/{chunk-JJAOVJ3E.js.map → chunk-DJZWNKFO.js.map} +0 -0
  202. /package/dist/{chunk-PY5E3UOD.js.map → chunk-DKZR56A3.js.map} +0 -0
  203. /package/dist/{chunk-6JYTP3JP.js.map → chunk-E5WTGEWX.js.map} +0 -0
  204. /package/dist/{chunk-WAPSYHNR.js.map → chunk-EN75MUTO.js.map} +0 -0
  205. /package/dist/{chunk-OMA2B7GY.js.map → chunk-G4MCAHMF.js.map} +0 -0
  206. /package/dist/{chunk-EJJ57PTY.js.map → chunk-GD7D7WP2.js.map} +0 -0
  207. /package/dist/{chunk-X3DTN2RC.js.map → chunk-GFGQOZGG.js.map} +0 -0
  208. /package/dist/{chunk-EVLHZ6F3.js.map → chunk-GISV63M2.js.map} +0 -0
  209. /package/dist/{chunk-YERBUSU2.js.map → chunk-H3HOC6UT.js.map} +0 -0
  210. /package/dist/{chunk-AJFKQJQK.js.map → chunk-HINMCD7V.js.map} +0 -0
  211. /package/dist/{chunk-ISDSX7GW.js.map → chunk-IGUWU5BD.js.map} +0 -0
  212. /package/dist/{chunk-W6V2XVUO.js.map → chunk-IIO4C3YL.js.map} +0 -0
  213. /package/dist/{chunk-SKVSBTFQ.js.map → chunk-IWO4HEOL.js.map} +0 -0
  214. /package/dist/{chunk-ZIZUB6VB.js.map → chunk-J55NAXDH.js.map} +0 -0
  215. /package/dist/{chunk-3X7Z467X.js.map → chunk-JAU4MRKB.js.map} +0 -0
  216. /package/dist/{chunk-XJJYSX6F.js.map → chunk-JCUG7AXZ.js.map} +0 -0
  217. /package/dist/{chunk-BX6GW4KC.js.map → chunk-JMAG7WZ7.js.map} +0 -0
  218. /package/dist/{chunk-MDQXW235.js.map → chunk-KLL5SPER.js.map} +0 -0
  219. /package/dist/{chunk-DD2BKHSD.js.map → chunk-KZMFU7YL.js.map} +0 -0
  220. /package/dist/{chunk-C5FRCM4B.js.map → chunk-LHGIBXVX.js.map} +0 -0
  221. /package/dist/{chunk-CGFDT752.js.map → chunk-LVYO2HZJ.js.map} +0 -0
  222. /package/dist/{chunk-TP3N5364.js.map → chunk-M5GBTETG.js.map} +0 -0
  223. /package/dist/{chunk-PGD3DXZP.js.map → chunk-M5UJJUON.js.map} +0 -0
  224. /package/dist/{chunk-HDEQRI2O.js.map → chunk-NAZROP6I.js.map} +0 -0
  225. /package/dist/{chunk-R3YVNAJB.js.map → chunk-NDE7CH6C.js.map} +0 -0
  226. /package/dist/{chunk-BIJAWZYW.js.map → chunk-OLT3YTDH.js.map} +0 -0
  227. /package/dist/{chunk-4OH6FGAO.js.map → chunk-PEOFTEUC.js.map} +0 -0
  228. /package/dist/{chunk-GM35IHF7.js.map → chunk-PRK4WTPU.js.map} +0 -0
  229. /package/dist/{chunk-MOUCCUET.js.map → chunk-QGUFN25Q.js.map} +0 -0
  230. /package/dist/{chunk-5HP6LVWU.js.map → chunk-QHYSECIG.js.map} +0 -0
  231. /package/dist/{chunk-SZ3SRRYZ.js.map → chunk-RXS4SQ5S.js.map} +0 -0
  232. /package/dist/{chunk-6KQLDPKA.js.map → chunk-S6AHHQ6C.js.map} +0 -0
  233. /package/dist/{chunk-R4FPBSFD.js.map → chunk-SO73GYOZ.js.map} +0 -0
  234. /package/dist/{chunk-M4MNLPO7.js.map → chunk-SXQZMC7R.js.map} +0 -0
  235. /package/dist/{chunk-JHTF22NQ.js.map → chunk-TA7UZZMW.js.map} +0 -0
  236. /package/dist/{chunk-UJICQ77Y.js.map → chunk-UCNQV43K.js.map} +0 -0
  237. /package/dist/{chunk-OUAK37ID.js.map → chunk-VGTXHTWU.js.map} +0 -0
  238. /package/dist/{chunk-TGDFKC7X.js.map → chunk-VOPOPTZ7.js.map} +0 -0
  239. /package/dist/{chunk-IFHT77WK.js.map → chunk-WF2WCZCR.js.map} +0 -0
  240. /package/dist/{chunk-6F5WBXE2.js.map → chunk-X2NFFBTO.js.map} +0 -0
  241. /package/dist/{chunk-IHFN3JTR.js.map → chunk-X5TGZAP2.js.map} +0 -0
  242. /package/dist/{chunk-RJ57OOCD.js.map → chunk-X66BHMAU.js.map} +0 -0
  243. /package/dist/{chunk-DQEMWVMT.js.map → chunk-X7Y7WX73.js.map} +0 -0
  244. /package/dist/{chunk-FGV7V2N4.js.map → chunk-Y3WMJAZ3.js.map} +0 -0
  245. /package/dist/{chunk-ZET6A3OF.js.map → chunk-YHCBJULX.js.map} +0 -0
  246. /package/dist/{chunk-LGVIKA7O.js.map → chunk-YJPFTF57.js.map} +0 -0
  247. /package/dist/{chunk-EAG7YMIS.js.map → chunk-YVMTXBJJ.js.map} +0 -0
  248. /package/dist/{chunk-OUKBMQ2O.js.map → chunk-Z3SQEEB6.js.map} +0 -0
  249. /package/dist/{chunk-MVH7YAWY.js.map → chunk-ZPN6SSWA.js.map} +0 -0
  250. /package/dist/{graph-edge-decay-FXGELBFA.js.map → graph-edge-decay-DGTBFALQ.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remnic/core",
3
- "version": "9.69.6",
3
+ "version": "9.69.8",
4
4
  "description": "Framework-agnostic Remnic memory engine — orchestrator, storage, extraction, search, trust zones",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -3217,7 +3217,7 @@
3217
3217
  "core"
3218
3218
  ],
3219
3219
  "peerDependencies": {
3220
- "@remnic/coding-graph": "^9.69.6"
3220
+ "@remnic/coding-graph": "^9.69.8"
3221
3221
  },
3222
3222
  "peerDependenciesMeta": {
3223
3223
  "@remnic/coding-graph": {
@@ -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
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Span source stamp/verify (issue #2333).
3
+ *
4
+ * Offsets index the exact normalized string sent to the model. If that string
5
+ * drifts before materialization, the offsets point at different characters and
6
+ * a wrong quote is persisted as verbatim source. The stamp lets materialization
7
+ * detect the drift without carrying the source text alongside it.
8
+ *
9
+ * Pure apart from hashing: no I/O, no clock, no randomness. The input text is
10
+ * never mutated and never appears in a result or error message.
11
+ */
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ export interface SpanSourceStamp {
16
+ /** Hex digest of the exact string the model was shown. */
17
+ hash: string;
18
+ /** Length of that string, so a mismatch reports which dimension drifted. */
19
+ length: number;
20
+ }
21
+
22
+ export type SpanSourceCheck =
23
+ | { ok: true }
24
+ | {
25
+ ok: false;
26
+ error: "length_mismatch" | "hash_mismatch";
27
+ expected: SpanSourceStamp;
28
+ actual: SpanSourceStamp;
29
+ };
30
+
31
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
32
+
33
+ function requireText(text: string): void {
34
+ if (typeof text !== "string") {
35
+ throw new TypeError(`text must be a string, got ${typeof text}`);
36
+ }
37
+ }
38
+
39
+ function requireStamp(expected: SpanSourceStamp): void {
40
+ if (!Object.hasOwn(expected, "hash")) {
41
+ throw new RangeError("expected stamp is missing field hash");
42
+ }
43
+ if (!Object.hasOwn(expected, "length")) {
44
+ throw new RangeError("expected stamp is missing field length");
45
+ }
46
+ const { hash, length } = expected;
47
+ if (typeof hash !== "string" || !HASH_PATTERN.test(hash)) {
48
+ throw new RangeError(
49
+ "expected stamp field hash must be 64 lowercase hex characters",
50
+ );
51
+ }
52
+ if (!Number.isInteger(length) || length < 0) {
53
+ throw new RangeError(
54
+ "expected stamp field length must be a non-negative integer",
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Hash the exact string. No trimming, no newline normalization.
61
+ *
62
+ * The digest covers UTF-16LE code units, not UTF-8 bytes: Node's UTF-8
63
+ * encoder replaces an unpaired surrogate with U+FFFD before hashing, so
64
+ * "\ud800", "\ud801", and "\ufffd" all produce ONE digest under utf8 and
65
+ * drift between them would verify as unchanged. Unpaired surrogates reach
66
+ * real memory text through JSON escapes, and this stamp exists to catch
67
+ * exactly that class of silent substitution, so it hashes a lossless
68
+ * representation of the string instead.
69
+ */
70
+ export function stampSpanSource(text: string): SpanSourceStamp {
71
+ requireText(text);
72
+ const hash = createHash("sha256").update(Buffer.from(text, "utf16le")).digest("hex");
73
+ return { hash, length: text.length };
74
+ }
75
+
76
+ /**
77
+ * Verify the materialized string against its stamp. Length is compared first:
78
+ * it is the cheaper, more diagnostic dimension. Mismatch results carry both
79
+ * stamps and never the text itself.
80
+ */
81
+ export function verifySpanSource(
82
+ text: string,
83
+ expected: SpanSourceStamp,
84
+ ): SpanSourceCheck {
85
+ requireText(text);
86
+ requireStamp(expected);
87
+ const actual = stampSpanSource(text);
88
+ // Project the caller's stamp down to exactly the two stamp fields. Echoing
89
+ // the original object would re-export any extra property it carries — a
90
+ // `source` field holding the text defeats the whole point of a stamp, which
91
+ // exists so the content does not have to travel.
92
+ const safeExpected: SpanSourceStamp = { hash: expected.hash, length: expected.length };
93
+ if (actual.length !== safeExpected.length) {
94
+ return { ok: false, error: "length_mismatch", expected: safeExpected, actual };
95
+ }
96
+ if (actual.hash !== safeExpected.hash) {
97
+ return { ok: false, error: "hash_mismatch", expected: safeExpected, actual };
98
+ }
99
+ return { ok: true };
100
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
- import { matchDominantPlace, memoryLocationWindow, type LocationTagPolicy } from "./matching.js";
4
+ import { locationTagSlug, matchDominantPlace, memoryLocationWindow, type LocationTagPolicy } from "./matching.js";
5
5
  import type { LocationPlace, LocationSegment } from "./types.js";
6
6
 
7
7
  const policy: LocationTagPolicy = {
@@ -63,3 +63,33 @@ test("dominant named place wins; ties stay unmatched", () => {
63
63
  assert.equal(tied.status, "unmatched");
64
64
  if (tied.status === "unmatched") assert.equal(tied.reason, "ambiguous");
65
65
  });
66
+
67
+ test("locationTagSlug: pins slug collapsing and edge trimming", () => {
68
+ assert.equal(locationTagSlug("reitti:place:7"), "reitti-place-7");
69
+ assert.equal(locationTagSlug(" --Mixed CASE-- "), "mixed-case");
70
+ assert.equal(locationTagSlug("---"), "");
71
+ assert.equal(locationTagSlug("a---b"), "a-b");
72
+ assert.equal(locationTagSlug("Café Central"), "caf-central");
73
+ });
74
+
75
+ test("locationTagSlug: the collapse keeps edges single-dash, so the trim is complete", () => {
76
+ // This is the invariant the single-character edge strips depend on: the
77
+ // /[^a-z0-9]+/ collapse cannot leave two dashes at an edge. Relax or reorder
78
+ // the collapse and the strips silently stop being complete, so assert the
79
+ // OUTPUT contract directly rather than timing something the collapse has
80
+ // already bounded.
81
+ const adversarial = [
82
+ "-".repeat(5_000),
83
+ "a" + "-".repeat(5_000) + "b",
84
+ "-".repeat(5_000) + "a",
85
+ "a" + "-".repeat(5_000),
86
+ "___---... a ...---___",
87
+ "\u2014\u2014a\u2014\u2014",
88
+ ];
89
+ for (const placeId of adversarial) {
90
+ const slug = locationTagSlug(placeId);
91
+ assert.ok(!slug.startsWith("-"), `leading dash survived: ${JSON.stringify(slug)}`);
92
+ assert.ok(!slug.endsWith("-"), `trailing dash survived: ${JSON.stringify(slug)}`);
93
+ assert.doesNotMatch(slug, /--/, `adjacent dashes survived: ${JSON.stringify(slug)}`);
94
+ }
95
+ });
@@ -233,7 +233,12 @@ export function locationTagSlug(placeId: string): string {
233
233
  const slug = placeId
234
234
  .toLowerCase()
235
235
  .replace(/[^a-z0-9]+/g, "-")
236
- .replace(/^-+|-+$/g, "");
236
+ // The collapse above leaves at most one dash at each edge, so single-char
237
+ // strips are complete. The old /^-+|-+$/ alternation backtracked
238
+ // quadratically on unsanitized dash runs (CodeQL js/polynomial-redos);
239
+ // single-character anchors are linear.
240
+ .replace(/^-/, "")
241
+ .replace(/-$/, "");
237
242
  return slug.slice(0, TAG_LIMITS.maxTagLength - LOCATION_TAG_PREFIX.length);
238
243
  }
239
244