@remnic/core 9.69.4 → 9.69.5
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 +20 -20
- package/dist/access-authorization-probe.js +21 -21
- package/dist/access-boundary.js +20 -20
- package/dist/access-cli.js +68 -68
- package/dist/access-extraction-force-flush.js +20 -20
- package/dist/access-http-query.js +22 -22
- package/dist/access-http.js +36 -36
- package/dist/access-identity-continuity-surface.js +20 -20
- package/dist/access-lcm-surface.js +20 -20
- package/dist/access-mcp.js +29 -29
- package/dist/access-namespace-preflight.js +20 -20
- package/dist/access-observe-write-surface.js +20 -20
- package/dist/access-operations-batch.js +23 -23
- package/dist/access-operations.js +28 -28
- package/dist/access-recall-concurrency.js +20 -20
- package/dist/access-recall-response.js +20 -20
- package/dist/access-recall-surface.js +20 -20
- package/dist/access-schema.js +4 -4
- package/dist/access-service.js +20 -20
- package/dist/access-surface-catalog.js +22 -22
- package/dist/briefing.js +8 -8
- package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
- package/dist/causal-consolidation.js +9 -9
- package/dist/{chunk-N3VPKMU3.js → chunk-3X7Z467X.js} +2 -2
- package/dist/{chunk-EB3V7PJC.js → chunk-4OH6FGAO.js} +13 -13
- package/dist/{chunk-EA2C5Z7V.js → chunk-5C4LXMSK.js} +26 -26
- package/dist/{chunk-YSD4MK5Q.js → chunk-5HP6LVWU.js} +2 -2
- package/dist/{chunk-OM4VYQPH.js → chunk-6EXRODDF.js} +2 -2
- package/dist/{chunk-2L5FSNPC.js → chunk-6F5WBXE2.js} +4 -4
- package/dist/{chunk-DZPX5WSG.js → chunk-6JYTP3JP.js} +3 -3
- package/dist/{chunk-RFYKLES2.js → chunk-6KQLDPKA.js} +2 -2
- package/dist/{chunk-J2R275FN.js → chunk-7DXW5V2W.js} +2 -2
- package/dist/chunk-7O5EW7KX.js +27 -0
- package/dist/chunk-7O5EW7KX.js.map +1 -0
- package/dist/{chunk-227UFSEQ.js → chunk-7RZJWZ46.js} +5 -5
- package/dist/{chunk-WOA7DS7O.js → chunk-7TCISOAJ.js} +210 -47
- package/dist/chunk-7TCISOAJ.js.map +1 -0
- package/dist/{chunk-3KURGLO7.js → chunk-7VA7PARC.js} +2 -2
- package/dist/{chunk-TOUZM2RQ.js → chunk-AJFKQJQK.js} +2 -2
- package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
- package/dist/{chunk-WZBDOI2C.js → chunk-C5FRCM4B.js} +2 -2
- package/dist/{chunk-PRCJDCDD.js → chunk-CGFDT752.js} +4 -4
- package/dist/{chunk-V63BBOFS.js → chunk-DD2BKHSD.js} +2 -2
- package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
- package/dist/{chunk-WJTB4SF5.js → chunk-EAG7YMIS.js} +2 -2
- package/dist/{chunk-DA2XDNS5.js → chunk-EJF6AV2U.js} +7 -7
- package/dist/{chunk-3HEYRDNF.js → chunk-EJJ57PTY.js} +2 -2
- package/dist/{chunk-YHSZTMM3.js → chunk-EVLHZ6F3.js} +2 -2
- package/dist/{chunk-35J5ABJC.js → chunk-FGV7V2N4.js} +2 -2
- package/dist/{chunk-6AYPXHOF.js → chunk-GM35IHF7.js} +2 -2
- package/dist/{chunk-MU4FAKGV.js → chunk-HDEQRI2O.js} +2 -2
- package/dist/{chunk-PBSZQBFM.js → chunk-IFHT77WK.js} +2 -2
- package/dist/{chunk-OZFHE6FY.js → chunk-IHFN3JTR.js} +3 -3
- package/dist/{chunk-LXMYIJOO.js → chunk-ISDSX7GW.js} +1 -1
- package/dist/{chunk-EZPBQVGZ.js → chunk-JJAOVJ3E.js} +5 -5
- package/dist/{chunk-76X3F4WV.js → chunk-LGVIKA7O.js} +6 -6
- package/dist/{chunk-7DQQYJKU.js → chunk-MDQXW235.js} +2 -2
- package/dist/{chunk-ASDK4GEV.js → chunk-MOUCCUET.js} +2 -2
- package/dist/{chunk-DT7LKLET.js → chunk-MVH7YAWY.js} +2 -2
- package/dist/{chunk-GH477IGR.js → chunk-NAB4MKTJ.js} +3 -3
- package/dist/{chunk-FGORNFTZ.js → chunk-NM5EOKN7.js} +2 -2
- package/dist/{chunk-LXBVTRBG.js → chunk-OUAK37ID.js} +2 -2
- package/dist/{chunk-HCERYDRI.js → chunk-PGD3DXZP.js} +2 -2
- package/dist/{chunk-6CT4HSVJ.js → chunk-PY5E3UOD.js} +2 -2
- package/dist/{chunk-HBOQO4GB.js → chunk-QDBG45NK.js} +7 -7
- package/dist/{chunk-EJCKJS4X.js → chunk-R3YVNAJB.js} +2 -2
- package/dist/{chunk-XVI2TR26.js → chunk-R4FPBSFD.js} +2 -2
- package/dist/{chunk-VVE5SLO5.js → chunk-SKVSBTFQ.js} +2 -2
- package/dist/{chunk-S7363OIV.js → chunk-SZ3SRRYZ.js} +2 -2
- package/dist/{chunk-WRPRUMUI.js → chunk-SZZGVXKC.js} +2 -2
- package/dist/{chunk-TFN3PPYO.js → chunk-TGDFKC7X.js} +2 -2
- package/dist/{chunk-7SL6EJLT.js → chunk-TP3N5364.js} +2 -2
- package/dist/{chunk-IPLYGWQF.js → chunk-VDX2J7OX.js} +6 -6
- package/dist/{chunk-VJERIMIX.js → chunk-W6V2XVUO.js} +3 -3
- package/dist/{chunk-5FBA6UXN.js → chunk-WTWHORVW.js} +7 -7
- package/dist/{chunk-UFY4G6EC.js → chunk-XOGYSWQZ.js} +10 -10
- package/dist/{chunk-J6SWAE74.js → chunk-YERBUSU2.js} +2 -2
- package/dist/{chunk-D65XGENZ.js → chunk-YPNTBYEV.js} +3 -3
- package/dist/{chunk-FNNWK7G7.js → chunk-ZET6A3OF.js} +2 -2
- package/dist/cli.js +51 -51
- package/dist/coding/export-okf-codegraph.js +8 -8
- package/dist/compounding/engine.js +8 -8
- package/dist/connectors/codex-materialize-runner.js +8 -8
- package/dist/connectors/index.js +8 -8
- package/dist/convergence-refresh.js +10 -10
- package/dist/conversation-index/backend.js +2 -2
- package/dist/corpus-watermark.js +10 -10
- package/dist/entity-retrieval.js +8 -8
- package/dist/external-wiki-access.js +21 -21
- package/dist/extraction.js +8 -8
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +77 -1
- package/dist/index.js +101 -91
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +5 -5
- package/dist/location/index.js +10 -10
- package/dist/maintenance/memory-governance.js +8 -8
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -8
- package/dist/maintenance/rebuild-memory-projection.js +9 -9
- package/dist/namespaces/migrate.js +9 -9
- package/dist/namespaces/search.js +6 -6
- package/dist/namespaces/storage.js +8 -8
- package/dist/operator-doctor-corpus.js +11 -11
- package/dist/operator-toolkit.js +16 -16
- package/dist/orchestration/maintenance.js +16 -16
- package/dist/orchestrator.js +68 -68
- package/dist/recall-navigate-link.d.ts +9 -3
- package/dist/recall-navigate-link.js +3 -15
- package/dist/recall-navigate-link.js.map +1 -1
- package/dist/recall-navigate-traverse.d.ts +21 -0
- package/dist/recall-navigate-traverse.js +44 -0
- package/dist/recall-navigate-traverse.js.map +1 -0
- package/dist/search/factory.js +5 -5
- package/dist/search/index.js +9 -9
- package/dist/semantic-consolidation.js +9 -9
- package/dist/semantic-rule-promotion.js +8 -8
- package/dist/semantic-rule-verifier.js +8 -8
- package/dist/source-agent-qualifier.js +8 -8
- package/dist/standup/build.js +8 -8
- package/dist/storage.js +7 -7
- package/dist/support-passport/index.js +23 -23
- package/dist/transfer/autodetect.js +1 -1
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +3 -3
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/transfer/export-okf.js +9 -9
- package/dist/verified-recall.js +8 -8
- package/dist/who-knows-cli.js +9 -9
- package/dist/who-knows.js +8 -8
- package/package.json +2 -2
- package/src/activity/index.ts +2 -0
- package/src/activity/privacy-gate-resolve.test.ts +175 -0
- package/src/activity/privacy-gate-resolve.ts +84 -0
- package/src/activity/timeline/index.ts +1 -0
- package/src/activity/timeline/recap-fallback.test.ts +184 -0
- package/src/activity/timeline/recap-fallback.ts +106 -0
- package/src/activity/vault-dry-run.test.ts +114 -0
- package/src/activity/vault-dry-run.ts +76 -0
- package/src/recall-navigate-link.test.ts +28 -6
- package/src/recall-navigate-link.ts +16 -2
- package/src/recall-navigate-traverse.test.ts +247 -0
- package/src/recall-navigate-traverse.ts +73 -0
- package/dist/chunk-WOA7DS7O.js.map +0 -1
- /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
- /package/dist/{chunk-N3VPKMU3.js.map → chunk-3X7Z467X.js.map} +0 -0
- /package/dist/{chunk-EB3V7PJC.js.map → chunk-4OH6FGAO.js.map} +0 -0
- /package/dist/{chunk-EA2C5Z7V.js.map → chunk-5C4LXMSK.js.map} +0 -0
- /package/dist/{chunk-YSD4MK5Q.js.map → chunk-5HP6LVWU.js.map} +0 -0
- /package/dist/{chunk-OM4VYQPH.js.map → chunk-6EXRODDF.js.map} +0 -0
- /package/dist/{chunk-2L5FSNPC.js.map → chunk-6F5WBXE2.js.map} +0 -0
- /package/dist/{chunk-DZPX5WSG.js.map → chunk-6JYTP3JP.js.map} +0 -0
- /package/dist/{chunk-RFYKLES2.js.map → chunk-6KQLDPKA.js.map} +0 -0
- /package/dist/{chunk-J2R275FN.js.map → chunk-7DXW5V2W.js.map} +0 -0
- /package/dist/{chunk-227UFSEQ.js.map → chunk-7RZJWZ46.js.map} +0 -0
- /package/dist/{chunk-3KURGLO7.js.map → chunk-7VA7PARC.js.map} +0 -0
- /package/dist/{chunk-TOUZM2RQ.js.map → chunk-AJFKQJQK.js.map} +0 -0
- /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
- /package/dist/{chunk-WZBDOI2C.js.map → chunk-C5FRCM4B.js.map} +0 -0
- /package/dist/{chunk-PRCJDCDD.js.map → chunk-CGFDT752.js.map} +0 -0
- /package/dist/{chunk-V63BBOFS.js.map → chunk-DD2BKHSD.js.map} +0 -0
- /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
- /package/dist/{chunk-WJTB4SF5.js.map → chunk-EAG7YMIS.js.map} +0 -0
- /package/dist/{chunk-DA2XDNS5.js.map → chunk-EJF6AV2U.js.map} +0 -0
- /package/dist/{chunk-3HEYRDNF.js.map → chunk-EJJ57PTY.js.map} +0 -0
- /package/dist/{chunk-YHSZTMM3.js.map → chunk-EVLHZ6F3.js.map} +0 -0
- /package/dist/{chunk-35J5ABJC.js.map → chunk-FGV7V2N4.js.map} +0 -0
- /package/dist/{chunk-6AYPXHOF.js.map → chunk-GM35IHF7.js.map} +0 -0
- /package/dist/{chunk-MU4FAKGV.js.map → chunk-HDEQRI2O.js.map} +0 -0
- /package/dist/{chunk-PBSZQBFM.js.map → chunk-IFHT77WK.js.map} +0 -0
- /package/dist/{chunk-OZFHE6FY.js.map → chunk-IHFN3JTR.js.map} +0 -0
- /package/dist/{chunk-LXMYIJOO.js.map → chunk-ISDSX7GW.js.map} +0 -0
- /package/dist/{chunk-EZPBQVGZ.js.map → chunk-JJAOVJ3E.js.map} +0 -0
- /package/dist/{chunk-76X3F4WV.js.map → chunk-LGVIKA7O.js.map} +0 -0
- /package/dist/{chunk-7DQQYJKU.js.map → chunk-MDQXW235.js.map} +0 -0
- /package/dist/{chunk-ASDK4GEV.js.map → chunk-MOUCCUET.js.map} +0 -0
- /package/dist/{chunk-DT7LKLET.js.map → chunk-MVH7YAWY.js.map} +0 -0
- /package/dist/{chunk-GH477IGR.js.map → chunk-NAB4MKTJ.js.map} +0 -0
- /package/dist/{chunk-FGORNFTZ.js.map → chunk-NM5EOKN7.js.map} +0 -0
- /package/dist/{chunk-LXBVTRBG.js.map → chunk-OUAK37ID.js.map} +0 -0
- /package/dist/{chunk-HCERYDRI.js.map → chunk-PGD3DXZP.js.map} +0 -0
- /package/dist/{chunk-6CT4HSVJ.js.map → chunk-PY5E3UOD.js.map} +0 -0
- /package/dist/{chunk-HBOQO4GB.js.map → chunk-QDBG45NK.js.map} +0 -0
- /package/dist/{chunk-EJCKJS4X.js.map → chunk-R3YVNAJB.js.map} +0 -0
- /package/dist/{chunk-XVI2TR26.js.map → chunk-R4FPBSFD.js.map} +0 -0
- /package/dist/{chunk-VVE5SLO5.js.map → chunk-SKVSBTFQ.js.map} +0 -0
- /package/dist/{chunk-S7363OIV.js.map → chunk-SZ3SRRYZ.js.map} +0 -0
- /package/dist/{chunk-WRPRUMUI.js.map → chunk-SZZGVXKC.js.map} +0 -0
- /package/dist/{chunk-TFN3PPYO.js.map → chunk-TGDFKC7X.js.map} +0 -0
- /package/dist/{chunk-7SL6EJLT.js.map → chunk-TP3N5364.js.map} +0 -0
- /package/dist/{chunk-IPLYGWQF.js.map → chunk-VDX2J7OX.js.map} +0 -0
- /package/dist/{chunk-VJERIMIX.js.map → chunk-W6V2XVUO.js.map} +0 -0
- /package/dist/{chunk-5FBA6UXN.js.map → chunk-WTWHORVW.js.map} +0 -0
- /package/dist/{chunk-UFY4G6EC.js.map → chunk-XOGYSWQZ.js.map} +0 -0
- /package/dist/{chunk-J6SWAE74.js.map → chunk-YERBUSU2.js.map} +0 -0
- /package/dist/{chunk-D65XGENZ.js.map → chunk-YPNTBYEV.js.map} +0 -0
- /package/dist/{chunk-FNNWK7G7.js.map → chunk-ZET6A3OF.js.map} +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { planVaultDryRun } from "./vault-dry-run.js";
|
|
5
|
+
import type { VaultDryRunInput } from "./vault-dry-run.js";
|
|
6
|
+
import { VAULT_PUBLISH_OUTCOMES } from "./vault-status.js";
|
|
7
|
+
|
|
8
|
+
test("planVaultDryRun predicts a mixed batch with correct per-file outcomes and counts", () => {
|
|
9
|
+
const status = planVaultDryRun([
|
|
10
|
+
{ path: "notes/b.md", currentText: "same", nextText: "same" },
|
|
11
|
+
{ path: "notes/a.md", currentText: null, nextText: "new body" },
|
|
12
|
+
{ path: "notes/c.md", currentText: "old", nextText: "new" },
|
|
13
|
+
{ path: "notes/d.md", currentText: "text", nextText: null, skipReason: "no_marker" },
|
|
14
|
+
]);
|
|
15
|
+
assert.deepEqual(status.counts, { updated: 1, unchanged: 1, skipped: 2, error: 0 });
|
|
16
|
+
assert.equal(status.hasError, false);
|
|
17
|
+
assert.deepEqual(status.results, [
|
|
18
|
+
{ path: "notes/a.md", outcome: "skipped", reason: "missing_file" },
|
|
19
|
+
{ path: "notes/b.md", outcome: "unchanged" },
|
|
20
|
+
{ path: "notes/c.md", outcome: "updated" },
|
|
21
|
+
{ path: "notes/d.md", outcome: "skipped", reason: "no_marker" },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The real publisher refuses to create missing notes (missing_file), so a
|
|
26
|
+
// dry run must predict that skip rather than promise an update.
|
|
27
|
+
test("a missing note predicts the publisher's skip, not a create", () => {
|
|
28
|
+
const status = planVaultDryRun([{ path: "new.md", currentText: null, nextText: "body" }]);
|
|
29
|
+
assert.deepEqual(status.counts, { updated: 0, unchanged: 0, skipped: 1, error: 0 });
|
|
30
|
+
assert.deepEqual(status.results, [{ path: "new.md", outcome: "skipped", reason: "missing_file" }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("byte-identical text is unchanged", () => {
|
|
34
|
+
const status = planVaultDryRun([{ path: "same.md", currentText: "line\nline\n", nextText: "line\nline\n" }]);
|
|
35
|
+
assert.deepEqual(status.counts, { updated: 0, unchanged: 1, skipped: 0, error: 0 });
|
|
36
|
+
assert.deepEqual(status.results, [{ path: "same.md", outcome: "unchanged" }]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a whitespace-only difference is updated, not unchanged", () => {
|
|
40
|
+
const status = planVaultDryRun([
|
|
41
|
+
{ path: "trailing.md", currentText: "body", nextText: "body " },
|
|
42
|
+
{ path: "newline.md", currentText: "body\n", nextText: "body" },
|
|
43
|
+
]);
|
|
44
|
+
assert.deepEqual(status.counts, { updated: 2, unchanged: 0, skipped: 0, error: 0 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("nextText null with a reason is skipped carrying that exact reason", () => {
|
|
48
|
+
const status = planVaultDryRun([
|
|
49
|
+
{ path: "skip.md", currentText: "text", nextText: null, skipReason: "no_marker" },
|
|
50
|
+
]);
|
|
51
|
+
assert.deepEqual(status.results, [{ path: "skip.md", outcome: "skipped", reason: "no_marker" }]);
|
|
52
|
+
assert.equal(status.counts.skipped, 1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("nextText null without a reason throws TypeError naming skipReason", () => {
|
|
56
|
+
assert.throws(
|
|
57
|
+
() => planVaultDryRun([{ path: "skip.md", currentText: "text", nextText: null }]),
|
|
58
|
+
(err: unknown) => err instanceof TypeError && /skipReason/.test(err.message),
|
|
59
|
+
);
|
|
60
|
+
assert.throws(
|
|
61
|
+
() => planVaultDryRun([{ path: "skip.md", currentText: "text", nextText: null, skipReason: " " }]),
|
|
62
|
+
(err: unknown) => err instanceof TypeError && /skipReason/.test(err.message),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("skipReason with a non-null nextText throws TypeError", () => {
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => planVaultDryRun([{ path: "conflict.md", currentText: "a", nextText: "b", skipReason: "no_marker" }]),
|
|
69
|
+
(err: unknown) => err instanceof TypeError && /skipReason/.test(err.message),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("blank path throws RangeError naming path", () => {
|
|
74
|
+
assert.throws(
|
|
75
|
+
() => planVaultDryRun([{ path: "", currentText: "a", nextText: "a" }]),
|
|
76
|
+
(err: unknown) => err instanceof RangeError && /path/.test(err.message),
|
|
77
|
+
);
|
|
78
|
+
assert.throws(
|
|
79
|
+
() => planVaultDryRun([{ path: " ", currentText: "a", nextText: "a" }]),
|
|
80
|
+
(err: unknown) => err instanceof RangeError && /path/.test(err.message),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("unused outcome keys are present at zero", () => {
|
|
85
|
+
const status = planVaultDryRun([{ path: "only.md", currentText: "a", nextText: "a" }]);
|
|
86
|
+
for (const outcome of VAULT_PUBLISH_OUTCOMES) {
|
|
87
|
+
assert.ok(outcome in status.counts, `count key ${outcome} must exist`);
|
|
88
|
+
assert.equal(status.counts[outcome], outcome === "unchanged" ? 1 : 0);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("input is not mutated", () => {
|
|
93
|
+
const inputs: VaultDryRunInput[] = [
|
|
94
|
+
{ path: "a.md", currentText: "old", nextText: "new" },
|
|
95
|
+
{ path: "b.md", currentText: "x", nextText: null, skipReason: "no_marker" },
|
|
96
|
+
];
|
|
97
|
+
const snapshot = JSON.parse(JSON.stringify(inputs));
|
|
98
|
+
planVaultDryRun(inputs);
|
|
99
|
+
assert.deepEqual(inputs, snapshot);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Round 3: undefined and non-string text fields are malformed input, never a
|
|
103
|
+
// prediction. Two undefined fields used to compare equal and report unchanged.
|
|
104
|
+
test("undefined and non-string text fields throw rather than predict", () => {
|
|
105
|
+
for (const bad of [
|
|
106
|
+
{ path: "a.md", currentText: undefined as unknown as string, nextText: "x" },
|
|
107
|
+
{ path: "a.md", currentText: "x", nextText: undefined as unknown as string },
|
|
108
|
+
{ path: "a.md", currentText: 5 as unknown as string, nextText: "x" },
|
|
109
|
+
{ path: "a.md", currentText: "x", nextText: {} as unknown as string },
|
|
110
|
+
{ path: "a.md", currentText: undefined as unknown as string, nextText: undefined as unknown as string },
|
|
111
|
+
]) {
|
|
112
|
+
assert.throws(() => planVaultDryRun([bad]), /must be a string or null/);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure vault publish dry-run planner (issue #1985).
|
|
3
|
+
*
|
|
4
|
+
* `planVaultDryRun` predicts per-note publish outcomes from text the caller
|
|
5
|
+
* has already read. It takes strings, not paths, and returns only a report:
|
|
6
|
+
* zero writes is a property of the signature, not a promise. Reporting a
|
|
7
|
+
* note as `skipped` with reason `missing_file` when `currentText` is null,
|
|
8
|
+
* matching the real publisher, which refuses to create a missing note.
|
|
9
|
+
*
|
|
10
|
+
* Internal helper: nothing calls it yet. CLI/HTTP wiring for the `--dry-run`
|
|
11
|
+
* flag is a later slice of #1985.
|
|
12
|
+
*/
|
|
13
|
+
import { summarizeVaultPublish } from "./vault-status.js";
|
|
14
|
+
import type { VaultPublishOutcome, VaultPublishResult, VaultPublishStatus } from "./vault-status.js";
|
|
15
|
+
|
|
16
|
+
export interface VaultDryRunInput {
|
|
17
|
+
/** Vault-relative note path, for reporting only. */
|
|
18
|
+
path: string;
|
|
19
|
+
/** Current note text, or null when the note does not exist. */
|
|
20
|
+
currentText: string | null;
|
|
21
|
+
/** The text a real publish would produce, or null when it would skip. */
|
|
22
|
+
nextText: string | null;
|
|
23
|
+
/** Required when nextText is null: why the publish would skip. */
|
|
24
|
+
skipReason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function planVaultDryRun(inputs: readonly VaultDryRunInput[]): VaultPublishStatus {
|
|
28
|
+
const results: VaultPublishResult[] = [];
|
|
29
|
+
for (const input of inputs) {
|
|
30
|
+
if (typeof input !== "object" || input === null) {
|
|
31
|
+
throw new TypeError(`vault dry-run inputs must be objects (got ${typeof input})`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof input.path !== "string" || input.path.trim() === "") {
|
|
34
|
+
throw new RangeError(`vault dry-run input requires a non-blank path (got ${JSON.stringify(input.path)})`);
|
|
35
|
+
}
|
|
36
|
+
// Validate the exact values before classification: a non-string or
|
|
37
|
+
// undefined text field is malformed input, and letting it reach the
|
|
38
|
+
// comparison would silently report a prediction for data the contract
|
|
39
|
+
// does not permit (two undefined fields would read as "unchanged").
|
|
40
|
+
for (const field of ["currentText", "nextText"] as const) {
|
|
41
|
+
const value = input[field];
|
|
42
|
+
if (value !== null && typeof value !== "string") {
|
|
43
|
+
throw new TypeError(
|
|
44
|
+
`vault dry-run input ${field} must be a string or null (got ${typeof value})`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (input.nextText === null) {
|
|
49
|
+
if (typeof input.skipReason !== "string" || input.skipReason.trim() === "") {
|
|
50
|
+
throw new TypeError(
|
|
51
|
+
`vault dry-run skip (nextText null) requires a non-blank skipReason (got ${JSON.stringify(input.skipReason)})`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
results.push({ path: input.path, outcome: "skipped", reason: input.skipReason });
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (input.skipReason !== undefined) {
|
|
58
|
+
throw new TypeError(
|
|
59
|
+
`vault dry-run skipReason is only valid when nextText is null (path ${JSON.stringify(input.path)})`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
// A missing note is NOT a create: the real publisher (vault-publish.ts
|
|
63
|
+
// publishVaultRegion) refuses to create missing files and returns
|
|
64
|
+
// missing_file, so the dry run must predict that skip rather than promise
|
|
65
|
+
// an update the real run will not perform.
|
|
66
|
+
if (input.currentText === null) {
|
|
67
|
+
results.push({ path: input.path, outcome: "skipped", reason: "missing_file" });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// Byte-exact compare: a publish that rewrites only trailing whitespace is
|
|
71
|
+
// still a write.
|
|
72
|
+
const outcome: VaultPublishOutcome = input.currentText === input.nextText ? "unchanged" : "updated";
|
|
73
|
+
results.push({ path: input.path, outcome });
|
|
74
|
+
}
|
|
75
|
+
return summarizeVaultPublish(results);
|
|
76
|
+
}
|
|
@@ -2,18 +2,40 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
4
|
import { parseNavigateLinkType } from "./recall-navigate-link.js";
|
|
5
|
+
import { selectTraverseNeighbors } from "./recall-navigate-traverse.js";
|
|
5
6
|
|
|
6
|
-
test("
|
|
7
|
-
for (const type of ["supports", "contradicts", "elaborates", "causes", "caused_by"]
|
|
7
|
+
test("every navigation relation parses", () => {
|
|
8
|
+
for (const type of ["supports", "contradicts", "elaborates", "causes", "caused_by"]) {
|
|
8
9
|
assert.deepEqual(parseNavigateLinkType(type), { ok: true, type });
|
|
9
10
|
}
|
|
10
11
|
});
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Persisted MemoryLinkType values must parse too: real frontmatter carries
|
|
14
|
+
// them, and a traversal over stored links loses those neighbors otherwise.
|
|
15
|
+
test("persisted MemoryLinkType values parse", () => {
|
|
16
|
+
for (const type of ["follows", "references", "related"]) {
|
|
17
|
+
assert.deepEqual(parseNavigateLinkType(type), { ok: true, type });
|
|
18
|
+
}
|
|
15
19
|
});
|
|
16
20
|
|
|
17
|
-
test("empty link
|
|
21
|
+
test("unknown or empty link types are refused", () => {
|
|
22
|
+
assert.deepEqual(parseNavigateLinkType("supersedesX"), { ok: false, error: "unknown_link" });
|
|
18
23
|
assert.deepEqual(parseNavigateLinkType(""), { ok: false, error: "unknown_link" });
|
|
24
|
+
assert.deepEqual(parseNavigateLinkType("Supports"), { ok: false, error: "unknown_link" });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
// Round 3: the stepper's navigation contract accepts supersedes, so the
|
|
29
|
+
// shared selector vocabulary must too.
|
|
30
|
+
test("supersedes parses and filters", () => {
|
|
31
|
+
assert.deepEqual(parseNavigateLinkType("supersedes"), { ok: true, type: "supersedes" });
|
|
32
|
+
const result = selectTraverseNeighbors({
|
|
33
|
+
links: [
|
|
34
|
+
{ targetId: "m-1", linkType: "supersedes" },
|
|
35
|
+
{ targetId: "m-2", linkType: "supports" },
|
|
36
|
+
],
|
|
37
|
+
relation: "supersedes",
|
|
38
|
+
});
|
|
39
|
+
assert.ok(result.ok);
|
|
40
|
+
assert.deepEqual(result.neighbors, [{ targetId: "m-1", linkType: "supersedes" }]);
|
|
19
41
|
});
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recall navigation link-type parse (issue #1956
|
|
2
|
+
* Recall navigation link-type parse (issue #1956).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* One allow-list covering BOTH vocabularies a traversal can meet:
|
|
5
|
+
* the navigation set this module originally defined and the persisted
|
|
6
|
+
* `MemoryLinkType` values (`follows | references | related`) that real
|
|
7
|
+
* memory frontmatter carries. Review found the narrower list silently
|
|
8
|
+
* dropped every persisted `follows`/`references`/`related` edge and
|
|
9
|
+
* rejected `relation: "related"` as unknown — a traversal over actual
|
|
10
|
+
* stored links would lose valid neighbors. Surfaces wait.
|
|
5
11
|
*/
|
|
6
12
|
|
|
7
13
|
const NAVIGATE_LINK_TYPES = [
|
|
@@ -10,6 +16,14 @@ const NAVIGATE_LINK_TYPES = [
|
|
|
10
16
|
"elaborates",
|
|
11
17
|
"causes",
|
|
12
18
|
"caused_by",
|
|
19
|
+
// Persisted MemoryLinkType values (types.ts) that real frontmatter carries.
|
|
20
|
+
"follows",
|
|
21
|
+
"references",
|
|
22
|
+
"related",
|
|
23
|
+
// The stepper's own navigation contract (recall-navigate.ts) accepts
|
|
24
|
+
// supersedes; omitting it here would reject a relation that surface is
|
|
25
|
+
// prepared to handle.
|
|
26
|
+
"supersedes",
|
|
13
27
|
] as const;
|
|
14
28
|
|
|
15
29
|
export type NavigateLinkType = (typeof NAVIGATE_LINK_TYPES)[number];
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_TRAVERSE_LIMIT } from "./recall-navigate-traverse.js";
|
|
5
|
+
import { selectTraverseNeighbors } from "./recall-navigate-traverse.js";
|
|
6
|
+
|
|
7
|
+
test("no relation returns all known-type links sorted", () => {
|
|
8
|
+
const result = selectTraverseNeighbors({
|
|
9
|
+
links: [
|
|
10
|
+
{ targetId: "m-3", linkType: "supports" },
|
|
11
|
+
{ targetId: "m-1", linkType: "elaborates" },
|
|
12
|
+
{ targetId: "m-2", linkType: "causes" },
|
|
13
|
+
],
|
|
14
|
+
});
|
|
15
|
+
assert.ok(result.ok);
|
|
16
|
+
assert.equal(result.truncated, false);
|
|
17
|
+
assert.deepEqual(result.neighbors, [
|
|
18
|
+
{ targetId: "m-2", linkType: "causes" },
|
|
19
|
+
{ targetId: "m-1", linkType: "elaborates" },
|
|
20
|
+
{ targetId: "m-3", linkType: "supports" },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("relation filter returns only that relation", () => {
|
|
25
|
+
const result = selectTraverseNeighbors({
|
|
26
|
+
links: [
|
|
27
|
+
{ targetId: "m-1", linkType: "supports" },
|
|
28
|
+
{ targetId: "m-2", linkType: "contradicts" },
|
|
29
|
+
{ targetId: "m-3", linkType: "supports" },
|
|
30
|
+
],
|
|
31
|
+
relation: "supports",
|
|
32
|
+
});
|
|
33
|
+
assert.ok(result.ok);
|
|
34
|
+
assert.deepEqual(result.neighbors, [
|
|
35
|
+
{ targetId: "m-1", linkType: "supports" },
|
|
36
|
+
{ targetId: "m-3", linkType: "supports" },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("unknown relation is refused with unknown_relation", () => {
|
|
41
|
+
const result = selectTraverseNeighbors({
|
|
42
|
+
links: [{ targetId: "m-1", linkType: "supports" }],
|
|
43
|
+
relation: "destroys",
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(result, { ok: false, error: "unknown_relation" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unknown linkType link is skipped while siblings survive", () => {
|
|
49
|
+
const result = selectTraverseNeighbors({
|
|
50
|
+
links: [
|
|
51
|
+
{ targetId: "m-1", linkType: "hyperlinks" },
|
|
52
|
+
{ targetId: "m-2", linkType: "supports" },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
assert.ok(result.ok);
|
|
56
|
+
assert.deepEqual(result.neighbors, [{ targetId: "m-2", linkType: "supports" }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("duplicate targetIds collapse to the first occurrence", () => {
|
|
60
|
+
const result = selectTraverseNeighbors({
|
|
61
|
+
links: [
|
|
62
|
+
{ targetId: "m-1", linkType: "causes" },
|
|
63
|
+
{ targetId: "m-1", linkType: "supports" },
|
|
64
|
+
{ targetId: "m-2", linkType: "elaborates" },
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
assert.ok(result.ok);
|
|
68
|
+
assert.deepEqual(result.neighbors, [
|
|
69
|
+
{ targetId: "m-1", linkType: "causes" },
|
|
70
|
+
{ targetId: "m-2", linkType: "elaborates" },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("blank targetId is skipped", () => {
|
|
75
|
+
const result = selectTraverseNeighbors({
|
|
76
|
+
links: [
|
|
77
|
+
{ targetId: "", linkType: "supports" },
|
|
78
|
+
{ targetId: " ", linkType: "supports" },
|
|
79
|
+
{ targetId: "m-1", linkType: "supports" },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
assert.ok(result.ok);
|
|
83
|
+
assert.deepEqual(result.neighbors, [{ targetId: "m-1", linkType: "supports" }]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("limit truncates after sorting and sets truncated", () => {
|
|
87
|
+
const result = selectTraverseNeighbors({
|
|
88
|
+
links: [
|
|
89
|
+
{ targetId: "m-9", linkType: "supports" },
|
|
90
|
+
{ targetId: "m-2", linkType: "causes" },
|
|
91
|
+
{ targetId: "m-5", linkType: "elaborates" },
|
|
92
|
+
],
|
|
93
|
+
limit: 2,
|
|
94
|
+
});
|
|
95
|
+
assert.ok(result.ok);
|
|
96
|
+
assert.equal(result.truncated, true);
|
|
97
|
+
assert.deepEqual(result.neighbors, [
|
|
98
|
+
{ targetId: "m-2", linkType: "causes" },
|
|
99
|
+
{ targetId: "m-5", linkType: "elaborates" },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("default limit caps at DEFAULT_TRAVERSE_LIMIT", () => {
|
|
104
|
+
const links = Array.from({ length: DEFAULT_TRAVERSE_LIMIT + 3 }, (_, i) => ({
|
|
105
|
+
targetId: `m-${i}`,
|
|
106
|
+
linkType: "supports",
|
|
107
|
+
}));
|
|
108
|
+
const result = selectTraverseNeighbors({ links });
|
|
109
|
+
assert.ok(result.ok);
|
|
110
|
+
assert.equal(result.truncated, true);
|
|
111
|
+
assert.equal(result.neighbors.length, DEFAULT_TRAVERSE_LIMIT);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("limit 0 is invalid_limit", () => {
|
|
115
|
+
assert.deepEqual(
|
|
116
|
+
selectTraverseNeighbors({ links: [], limit: 0 }),
|
|
117
|
+
{ ok: false, error: "invalid_limit" },
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("limit -1 is invalid_limit", () => {
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
selectTraverseNeighbors({ links: [], limit: -1 }),
|
|
124
|
+
{ ok: false, error: "invalid_limit" },
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("limit 1.5 is invalid_limit", () => {
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
selectTraverseNeighbors({ links: [], limit: 1.5 }),
|
|
131
|
+
{ ok: false, error: "invalid_limit" },
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("limit NaN is invalid_limit", () => {
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
selectTraverseNeighbors({ links: [], limit: Number.NaN }),
|
|
138
|
+
{ ok: false, error: "invalid_limit" },
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("shuffled input is deep-equal to sorted-input result", () => {
|
|
143
|
+
const sorted = [
|
|
144
|
+
{ targetId: "m-2", linkType: "caused_by" },
|
|
145
|
+
{ targetId: "m-1", linkType: "causes" },
|
|
146
|
+
{ targetId: "m-3", linkType: "elaborates" },
|
|
147
|
+
{ targetId: "m-4", linkType: "supports" },
|
|
148
|
+
];
|
|
149
|
+
const result = selectTraverseNeighbors({
|
|
150
|
+
links: [...sorted].reverse(),
|
|
151
|
+
});
|
|
152
|
+
assert.ok(result.ok);
|
|
153
|
+
assert.deepEqual(result.neighbors, sorted);
|
|
154
|
+
assert.deepEqual(selectTraverseNeighbors({ links: sorted }), result);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("inputs are not mutated", () => {
|
|
158
|
+
const links = [
|
|
159
|
+
{ targetId: "m-2", linkType: "supports" },
|
|
160
|
+
{ targetId: "m-1", linkType: "causes" },
|
|
161
|
+
];
|
|
162
|
+
selectTraverseNeighbors({ links, relation: "causes", limit: 1 });
|
|
163
|
+
assert.deepEqual(links, [
|
|
164
|
+
{ targetId: "m-2", linkType: "supports" },
|
|
165
|
+
{ targetId: "m-1", linkType: "causes" },
|
|
166
|
+
]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Review: the contract said bad stored data is skipped, but a nullish
|
|
170
|
+
// targetId threw before the skip checks could run.
|
|
171
|
+
test("a nullish or non-string targetId is skipped, never thrown", () => {
|
|
172
|
+
const result = selectTraverseNeighbors({
|
|
173
|
+
links: [
|
|
174
|
+
{ targetId: undefined as unknown as string, linkType: "supports" },
|
|
175
|
+
{ targetId: null as unknown as string, linkType: "supports" },
|
|
176
|
+
{ targetId: 42 as unknown as string, linkType: "supports" },
|
|
177
|
+
{ targetId: "m-real", linkType: "supports" },
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
assert.ok(result.ok);
|
|
181
|
+
assert.deepEqual(result.neighbors, [{ targetId: "m-real", linkType: "supports" }]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// A padded id is malformed, not a variant of the trimmed id: trimming would
|
|
185
|
+
// guess at an identity, so the row is skipped.
|
|
186
|
+
test("a whitespace-padded targetId is skipped rather than trimmed", () => {
|
|
187
|
+
const result = selectTraverseNeighbors({
|
|
188
|
+
links: [
|
|
189
|
+
{ targetId: " m-1", linkType: "supports" },
|
|
190
|
+
{ targetId: "m-2", linkType: "supports" },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
assert.ok(result.ok);
|
|
194
|
+
assert.deepEqual(result.neighbors, [{ targetId: "m-2", linkType: "supports" }]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Review: dedup ran before sorting, so input order decided which relation
|
|
198
|
+
// survived for a doubly-linked target. Sorting first fixes the survivor.
|
|
199
|
+
test("a target under two relations keeps the smallest linkType regardless of input order", () => {
|
|
200
|
+
const a = { targetId: "m-1", linkType: "supports" };
|
|
201
|
+
const b = { targetId: "m-1", linkType: "contradicts" };
|
|
202
|
+
const forward = selectTraverseNeighbors({ links: [a, b] });
|
|
203
|
+
const reversed = selectTraverseNeighbors({ links: [b, a] });
|
|
204
|
+
assert.ok(forward.ok && reversed.ok);
|
|
205
|
+
assert.deepEqual(forward.neighbors, reversed.neighbors);
|
|
206
|
+
assert.deepEqual(forward.neighbors, [{ targetId: "m-1", linkType: "contradicts" }]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("sort-then-dedup also decides which target survives a tight limit", () => {
|
|
210
|
+
const links = [
|
|
211
|
+
{ targetId: "m-z", linkType: "supports" },
|
|
212
|
+
{ targetId: "m-a", linkType: "supports" },
|
|
213
|
+
{ targetId: "m-a", linkType: "elaborates" },
|
|
214
|
+
];
|
|
215
|
+
const shuffled = selectTraverseNeighbors({ links: [...links].reverse(), limit: 1 });
|
|
216
|
+
const straight = selectTraverseNeighbors({ links, limit: 1 });
|
|
217
|
+
assert.ok(shuffled.ok && straight.ok);
|
|
218
|
+
assert.deepEqual(shuffled.neighbors, straight.neighbors);
|
|
219
|
+
assert.deepEqual(straight.neighbors, [{ targetId: "m-a", linkType: "elaborates" }]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
// Round 2: the persisted MemoryLinkType values are real neighbors, not
|
|
224
|
+
// unknown rows to drop. The selector gets them from the widened parser.
|
|
225
|
+
test("persisted link types survive an unfiltered traversal", () => {
|
|
226
|
+
const result = selectTraverseNeighbors({
|
|
227
|
+
links: [
|
|
228
|
+
{ targetId: "m-1", linkType: "follows" },
|
|
229
|
+
{ targetId: "m-2", linkType: "references" },
|
|
230
|
+
{ targetId: "m-3", linkType: "related" },
|
|
231
|
+
],
|
|
232
|
+
});
|
|
233
|
+
assert.ok(result.ok);
|
|
234
|
+
assert.equal(result.neighbors.length, 3);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("relation: related is a valid filter, not unknown", () => {
|
|
238
|
+
const result = selectTraverseNeighbors({
|
|
239
|
+
links: [
|
|
240
|
+
{ targetId: "m-1", linkType: "related" },
|
|
241
|
+
{ targetId: "m-2", linkType: "supports" },
|
|
242
|
+
],
|
|
243
|
+
relation: "related",
|
|
244
|
+
});
|
|
245
|
+
assert.ok(result.ok);
|
|
246
|
+
assert.deepEqual(result.neighbors, [{ targetId: "m-1", linkType: "related" }]);
|
|
247
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall navigation traverse selection (issue #1956).
|
|
3
|
+
*
|
|
4
|
+
* Pure selection step for `traverseLinks(id, relation?, limit)`: given a
|
|
5
|
+
* recalled memory's links, pick which neighbors a traverse would visit.
|
|
6
|
+
* Internal; surfaces wire it in a later slice.
|
|
7
|
+
*/
|
|
8
|
+
import { parseNavigateLinkType } from "./recall-navigate-link.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_TRAVERSE_LIMIT = 10;
|
|
11
|
+
|
|
12
|
+
export interface TraverseNeighbor {
|
|
13
|
+
targetId: string;
|
|
14
|
+
linkType: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TraverseSelectionResult =
|
|
18
|
+
| { ok: true; neighbors: TraverseNeighbor[]; truncated: boolean }
|
|
19
|
+
| { ok: false; error: "unknown_relation" | "invalid_limit" };
|
|
20
|
+
|
|
21
|
+
export function selectTraverseNeighbors(input: {
|
|
22
|
+
links: readonly TraverseNeighbor[];
|
|
23
|
+
/** Optional relation filter; omitted means every known relation. */
|
|
24
|
+
relation?: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
}): TraverseSelectionResult {
|
|
27
|
+
const limit = input.limit ?? DEFAULT_TRAVERSE_LIMIT;
|
|
28
|
+
if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) {
|
|
29
|
+
return { ok: false, error: "invalid_limit" };
|
|
30
|
+
}
|
|
31
|
+
if (input.relation !== undefined && !parseNavigateLinkType(input.relation).ok) {
|
|
32
|
+
return { ok: false, error: "unknown_relation" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const kept: TraverseNeighbor[] = [];
|
|
36
|
+
// Asymmetry is intentional: a bad request (unknown relation, invalid
|
|
37
|
+
// limit) is refused, but bad stored data is skipped — stored data may
|
|
38
|
+
// predate a type or carry a malformed id, and one bad row must not fail
|
|
39
|
+
// the whole traverse. A targetId that is not a non-blank string, including
|
|
40
|
+
// undefined/null and whitespace-only, is malformed and skipped. Padded ids
|
|
41
|
+
// are NOT trimmed into validity: an id is an exact identity, and guessing
|
|
42
|
+
// the intended target is worse than skipping the row.
|
|
43
|
+
for (const link of input.links) {
|
|
44
|
+
if (!parseNavigateLinkType(link.linkType).ok) continue;
|
|
45
|
+
if (input.relation !== undefined && link.linkType !== input.relation) continue;
|
|
46
|
+
if (typeof link.targetId !== "string" || link.targetId.trim() === "" || link.targetId.trim() !== link.targetId) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
kept.push({ targetId: link.targetId, linkType: link.linkType });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Sort BEFORE deduplicating: first-occurrence dedup on an unsorted list
|
|
53
|
+
// lets input order decide which relation survives for a target that is
|
|
54
|
+
// linked under two types, and after the cap is applied, which target is
|
|
55
|
+
// returned at all. Sorting first makes the survivor deterministic
|
|
56
|
+
// (smallest linkType wins the tie).
|
|
57
|
+
kept.sort((a, b) => {
|
|
58
|
+
if (a.linkType !== b.linkType) return a.linkType < b.linkType ? -1 : 1;
|
|
59
|
+
if (a.targetId !== b.targetId) return a.targetId < b.targetId ? -1 : 1;
|
|
60
|
+
return 0;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
const deduped: TraverseNeighbor[] = [];
|
|
65
|
+
for (const link of kept) {
|
|
66
|
+
if (seen.has(link.targetId)) continue;
|
|
67
|
+
seen.add(link.targetId);
|
|
68
|
+
deduped.push(link);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const truncated = deduped.length > limit;
|
|
72
|
+
return { ok: true, neighbors: truncated ? deduped.slice(0, limit) : deduped, truncated };
|
|
73
|
+
}
|