knosky 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
package/core/cross-repo.mjs
CHANGED
|
@@ -1,111 +1,111 @@
|
|
|
1
|
-
// Cross-repo graph utilities (SAT-448): edge detection, boundary extraction, and rendering metadata.
|
|
2
|
-
// Pure logic — no filesystem access, no new deps. Works on the in-memory city/byId structures
|
|
3
|
-
// produced by retrieve.load() or any equivalent.
|
|
4
|
-
//
|
|
5
|
-
// A node's *repo* identity is determined by provenance.repo when present, falling back to
|
|
6
|
-
// provenance.store (e.g. "fs", "gh:owner/repo") so existing city data stays fully compatible —
|
|
7
|
-
// single-repo graphs have no cross-repo edges because every node shares the same store value.
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// repoOf
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Return the repository identity of a node.
|
|
15
|
-
* Preference order:
|
|
16
|
-
* 1. node.provenance.repo — explicit multi-repo label (new field, optional)
|
|
17
|
-
* 2. node.provenance.store — always present; used as the identity when repo is absent
|
|
18
|
-
* (works correctly for single-repo graphs: all nodes share one store → no cross edges)
|
|
19
|
-
*
|
|
20
|
-
* Returns null if provenance is absent or malformed.
|
|
21
|
-
*
|
|
22
|
-
* @param {object} node
|
|
23
|
-
* @returns {string|null}
|
|
24
|
-
*/
|
|
25
|
-
export function repoOf(node) {
|
|
26
|
-
if (!node || typeof node !== 'object') return null;
|
|
27
|
-
const prov = node.provenance;
|
|
28
|
-
if (!prov || typeof prov !== 'object') return null;
|
|
29
|
-
if (typeof prov.repo === 'string' && prov.repo.length > 0) return prov.repo;
|
|
30
|
-
if (typeof prov.store === 'string' && prov.store.length > 0) return prov.store;
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// isCrossRepoEdge
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Return true when the edge (fromId → toId) crosses a repo boundary.
|
|
40
|
-
* An edge is cross-repo when repoOf(fromNode) !== repoOf(toNode) AND both are non-null.
|
|
41
|
-
* Edges where either endpoint is missing or has no repo identity are treated as same-repo
|
|
42
|
-
* (conservative: no false positives).
|
|
43
|
-
*
|
|
44
|
-
* @param {Map<string, object>} byId — node map from retrieve.load()
|
|
45
|
-
* @param {string} fromId
|
|
46
|
-
* @param {string} toId
|
|
47
|
-
* @returns {boolean}
|
|
48
|
-
*/
|
|
49
|
-
export function isCrossRepoEdge(byId, fromId, toId) {
|
|
50
|
-
const src = byId.get(fromId);
|
|
51
|
-
const dst = byId.get(toId);
|
|
52
|
-
if (!src || !dst) return false;
|
|
53
|
-
const r1 = repoOf(src);
|
|
54
|
-
const r2 = repoOf(dst);
|
|
55
|
-
if (r1 === null || r2 === null) return false;
|
|
56
|
-
return r1 !== r2;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// getCrossRepoEdges
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Walk every node's `links` array and return all edges that cross a repo boundary.
|
|
65
|
-
*
|
|
66
|
-
* @param {object} ctx — { city: { nodes: [] }, byId: Map }
|
|
67
|
-
* @returns {Array<{ from: string, to: string, fromRepo: string, toRepo: string }>}
|
|
68
|
-
*/
|
|
69
|
-
export function getCrossRepoEdges(ctx) {
|
|
70
|
-
const edges = [];
|
|
71
|
-
for (const node of (ctx.city.nodes || [])) {
|
|
72
|
-
if (!Array.isArray(node.links)) continue;
|
|
73
|
-
const fromRepo = repoOf(node);
|
|
74
|
-
if (fromRepo === null) continue;
|
|
75
|
-
for (const toId of node.links) {
|
|
76
|
-
const dst = ctx.byId.get(toId);
|
|
77
|
-
if (!dst) continue;
|
|
78
|
-
const toRepo = repoOf(dst);
|
|
79
|
-
if (toRepo === null) continue;
|
|
80
|
-
if (fromRepo !== toRepo) {
|
|
81
|
-
edges.push({ from: node.id, to: toId, fromRepo, toRepo });
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return edges;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// getRepoBoundaries
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Return a summary of every distinct repo present in the graph and its node membership.
|
|
94
|
-
*
|
|
95
|
-
* @param {object} ctx — { city: { nodes: [] }, byId: Map }
|
|
96
|
-
* @returns {Array<{ repo: string, nodeIds: string[], count: number }>}
|
|
97
|
-
* Sorted by count desc, then repo asc.
|
|
98
|
-
*/
|
|
99
|
-
export function getRepoBoundaries(ctx) {
|
|
100
|
-
/** @type {Map<string, string[]>} */
|
|
101
|
-
const byRepo = new Map();
|
|
102
|
-
for (const node of (ctx.city.nodes || [])) {
|
|
103
|
-
const r = repoOf(node);
|
|
104
|
-
if (r === null) continue;
|
|
105
|
-
if (!byRepo.has(r)) byRepo.set(r, []);
|
|
106
|
-
byRepo.get(r).push(node.id);
|
|
107
|
-
}
|
|
108
|
-
return [...byRepo.entries()]
|
|
109
|
-
.map(([repo, nodeIds]) => ({ repo, nodeIds, count: nodeIds.length }))
|
|
110
|
-
.sort((a, b) => b.count - a.count || String(a.repo).localeCompare(String(b.repo)));
|
|
111
|
-
}
|
|
1
|
+
// Cross-repo graph utilities (SAT-448): edge detection, boundary extraction, and rendering metadata.
|
|
2
|
+
// Pure logic — no filesystem access, no new deps. Works on the in-memory city/byId structures
|
|
3
|
+
// produced by retrieve.load() or any equivalent.
|
|
4
|
+
//
|
|
5
|
+
// A node's *repo* identity is determined by provenance.repo when present, falling back to
|
|
6
|
+
// provenance.store (e.g. "fs", "gh:owner/repo") so existing city data stays fully compatible —
|
|
7
|
+
// single-repo graphs have no cross-repo edges because every node shares the same store value.
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// repoOf
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return the repository identity of a node.
|
|
15
|
+
* Preference order:
|
|
16
|
+
* 1. node.provenance.repo — explicit multi-repo label (new field, optional)
|
|
17
|
+
* 2. node.provenance.store — always present; used as the identity when repo is absent
|
|
18
|
+
* (works correctly for single-repo graphs: all nodes share one store → no cross edges)
|
|
19
|
+
*
|
|
20
|
+
* Returns null if provenance is absent or malformed.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} node
|
|
23
|
+
* @returns {string|null}
|
|
24
|
+
*/
|
|
25
|
+
export function repoOf(node) {
|
|
26
|
+
if (!node || typeof node !== 'object') return null;
|
|
27
|
+
const prov = node.provenance;
|
|
28
|
+
if (!prov || typeof prov !== 'object') return null;
|
|
29
|
+
if (typeof prov.repo === 'string' && prov.repo.length > 0) return prov.repo;
|
|
30
|
+
if (typeof prov.store === 'string' && prov.store.length > 0) return prov.store;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// isCrossRepoEdge
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return true when the edge (fromId → toId) crosses a repo boundary.
|
|
40
|
+
* An edge is cross-repo when repoOf(fromNode) !== repoOf(toNode) AND both are non-null.
|
|
41
|
+
* Edges where either endpoint is missing or has no repo identity are treated as same-repo
|
|
42
|
+
* (conservative: no false positives).
|
|
43
|
+
*
|
|
44
|
+
* @param {Map<string, object>} byId — node map from retrieve.load()
|
|
45
|
+
* @param {string} fromId
|
|
46
|
+
* @param {string} toId
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function isCrossRepoEdge(byId, fromId, toId) {
|
|
50
|
+
const src = byId.get(fromId);
|
|
51
|
+
const dst = byId.get(toId);
|
|
52
|
+
if (!src || !dst) return false;
|
|
53
|
+
const r1 = repoOf(src);
|
|
54
|
+
const r2 = repoOf(dst);
|
|
55
|
+
if (r1 === null || r2 === null) return false;
|
|
56
|
+
return r1 !== r2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// getCrossRepoEdges
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk every node's `links` array and return all edges that cross a repo boundary.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} ctx — { city: { nodes: [] }, byId: Map }
|
|
67
|
+
* @returns {Array<{ from: string, to: string, fromRepo: string, toRepo: string }>}
|
|
68
|
+
*/
|
|
69
|
+
export function getCrossRepoEdges(ctx) {
|
|
70
|
+
const edges = [];
|
|
71
|
+
for (const node of (ctx.city.nodes || [])) {
|
|
72
|
+
if (!Array.isArray(node.links)) continue;
|
|
73
|
+
const fromRepo = repoOf(node);
|
|
74
|
+
if (fromRepo === null) continue;
|
|
75
|
+
for (const toId of node.links) {
|
|
76
|
+
const dst = ctx.byId.get(toId);
|
|
77
|
+
if (!dst) continue;
|
|
78
|
+
const toRepo = repoOf(dst);
|
|
79
|
+
if (toRepo === null) continue;
|
|
80
|
+
if (fromRepo !== toRepo) {
|
|
81
|
+
edges.push({ from: node.id, to: toId, fromRepo, toRepo });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return edges;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// getRepoBoundaries
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return a summary of every distinct repo present in the graph and its node membership.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} ctx — { city: { nodes: [] }, byId: Map }
|
|
96
|
+
* @returns {Array<{ repo: string, nodeIds: string[], count: number }>}
|
|
97
|
+
* Sorted by count desc, then repo asc.
|
|
98
|
+
*/
|
|
99
|
+
export function getRepoBoundaries(ctx) {
|
|
100
|
+
/** @type {Map<string, string[]>} */
|
|
101
|
+
const byRepo = new Map();
|
|
102
|
+
for (const node of (ctx.city.nodes || [])) {
|
|
103
|
+
const r = repoOf(node);
|
|
104
|
+
if (r === null) continue;
|
|
105
|
+
if (!byRepo.has(r)) byRepo.set(r, []);
|
|
106
|
+
byRepo.get(r).push(node.id);
|
|
107
|
+
}
|
|
108
|
+
return [...byRepo.entries()]
|
|
109
|
+
.map(([repo, nodeIds]) => ({ repo, nodeIds, count: nodeIds.length }))
|
|
110
|
+
.sort((a, b) => b.count - a.count || String(a.repo).localeCompare(String(b.repo)));
|
|
111
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// KnoSky DEC-108 closed decision-code set (loaded from ssot/decision-codes.json).
|
|
2
|
+
// ESM, Node stdlib only. Fail-closed closed set — no open-string codes on the wire.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const SSOT_PATH = join(HERE, '..', 'ssot', 'decision-codes.json');
|
|
10
|
+
|
|
11
|
+
let _doc;
|
|
12
|
+
function doc() {
|
|
13
|
+
if (!_doc) {
|
|
14
|
+
_doc = JSON.parse(readFileSync(SSOT_PATH, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
return _doc;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @returns {readonly string[]} */
|
|
20
|
+
export function closedSet() {
|
|
21
|
+
return Object.freeze([...(doc().closed_set || [])]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {string} code */
|
|
25
|
+
export function isKnownCode(code) {
|
|
26
|
+
return closedSet().includes(code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} code
|
|
31
|
+
* @returns {object|null}
|
|
32
|
+
*/
|
|
33
|
+
export function codeMeta(code) {
|
|
34
|
+
const hit = (doc().codes || []).find((c) => c.code === code);
|
|
35
|
+
return hit || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a wire-safe decision envelope.
|
|
40
|
+
* DENY* never attach restricted structures.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.code
|
|
44
|
+
* @param {'A'|'B'} [opts.mode]
|
|
45
|
+
* @param {string} [opts.receipt_id]
|
|
46
|
+
* @param {string} [opts.next_action]
|
|
47
|
+
* @param {string} [opts.request_id]
|
|
48
|
+
* @param {object} [opts.payload] only attached when code is ALLOW or ADVISORY_UNAUTH
|
|
49
|
+
*/
|
|
50
|
+
export function envelope({
|
|
51
|
+
code,
|
|
52
|
+
mode,
|
|
53
|
+
receipt_id,
|
|
54
|
+
next_action,
|
|
55
|
+
request_id,
|
|
56
|
+
payload,
|
|
57
|
+
} = {}) {
|
|
58
|
+
if (!isKnownCode(code)) {
|
|
59
|
+
throw new Error(`unknown decision code: ${code}`);
|
|
60
|
+
}
|
|
61
|
+
const meta = codeMeta(code) || {};
|
|
62
|
+
const resolvedMode = mode || (Array.isArray(meta.mode) ? meta.mode[0] : meta.mode) || 'B';
|
|
63
|
+
const out = {
|
|
64
|
+
decision_code: code,
|
|
65
|
+
mode: resolvedMode,
|
|
66
|
+
metadata_disclosed: !!meta.metadata_disclosed,
|
|
67
|
+
authorizing: !!meta.authorizing,
|
|
68
|
+
};
|
|
69
|
+
if (request_id) out.request_id = request_id;
|
|
70
|
+
if (receipt_id) out.receipt_id = receipt_id;
|
|
71
|
+
if (next_action) out.next_action = next_action;
|
|
72
|
+
else if (meta.client_hint) out.next_action = meta.client_hint;
|
|
73
|
+
|
|
74
|
+
const allowPayload = code === 'ALLOW' || code === 'ADVISORY_UNAUTH';
|
|
75
|
+
if (allowPayload && payload && typeof payload === 'object') {
|
|
76
|
+
out.payload = payload;
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const CODES = Object.freeze({
|
|
82
|
+
ALLOW: 'ALLOW',
|
|
83
|
+
DENY: 'DENY',
|
|
84
|
+
DENY_IDENTITY: 'DENY_IDENTITY',
|
|
85
|
+
DENY_POLICY: 'DENY_POLICY',
|
|
86
|
+
DENY_AUDIT: 'DENY_AUDIT',
|
|
87
|
+
DENY_FRESHNESS: 'DENY_FRESHNESS',
|
|
88
|
+
DENY_EVIDENCE: 'DENY_EVIDENCE',
|
|
89
|
+
ERROR_INVALID_INPUT: 'ERROR_INVALID_INPUT',
|
|
90
|
+
ERROR_INDEX: 'ERROR_INDEX',
|
|
91
|
+
ADVISORY_UNAUTH: 'ADVISORY_UNAUTH',
|
|
92
|
+
});
|
package/core/destination.mjs
CHANGED
|
@@ -1,161 +1,161 @@
|
|
|
1
|
-
// KnoSky destination parser (KSV2-R2) — extended prefix set for kc_route.
|
|
2
|
-
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
-
import { search } from './retrieve.mjs';
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Internal helpers
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Resolve a single node by id "fs:<rest>" OR by provenance.ref === <rest>.
|
|
11
|
-
* Also accepts a fully-qualified id (already has "fs:" prefix).
|
|
12
|
-
* @param {object} ctx
|
|
13
|
-
* @param {string} idOrRef
|
|
14
|
-
* @returns {object|null}
|
|
15
|
-
*/
|
|
16
|
-
function resolveOne(ctx, idOrRef) {
|
|
17
|
-
// Try constructing the canonical node id
|
|
18
|
-
const byConstructed = ctx.byId.get('fs:' + idOrRef);
|
|
19
|
-
if (byConstructed) return byConstructed;
|
|
20
|
-
// Try the raw string as-is (already fully qualified, e.g. "fs:src/a.js")
|
|
21
|
-
const byRaw = ctx.byId.get(idOrRef);
|
|
22
|
-
if (byRaw) return byRaw;
|
|
23
|
-
// Fallback: linear scan for provenance.ref match
|
|
24
|
-
return ctx.city.nodes.find(n => n.provenance && n.provenance.ref === idOrRef) || null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// parseDestination — main export
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Resolve the destination string to an initial set of matching nodes.
|
|
33
|
-
* Returns { matched: Node[], matchStrength: 'direct'|'folder'|'district'|'edges'|'chain'|'keyword' }
|
|
34
|
-
*
|
|
35
|
-
* Supported prefixes (ALL metadata-only, no filesystem access):
|
|
36
|
-
* file:<idOrRef> — node by id "fs:<rest>" OR by provenance.ref === rest; 'direct'
|
|
37
|
-
* folder:<prefix> — nodes whose provenance.ref starts with prefix; 'folder'
|
|
38
|
-
* district:<name> — nodes whose category equals name (case-insensitive) or
|
|
39
|
-
* whose category label matches; 'district'
|
|
40
|
-
* importsOf:<idOrRef> — out-edge targets of that node (node.links through ctx.byId); 'edges'
|
|
41
|
-
* depChainTo:<idOrRef> — nodes that TRANSITIVELY import/reach the target via
|
|
42
|
-
* reverse-edge BFS; bounded (max depth 6, max 500 visited,
|
|
43
|
-
* visited-set prevents cycles); 'chain'
|
|
44
|
-
* <anything else> — keyword fallback via search(); 'keyword'
|
|
45
|
-
*
|
|
46
|
-
* @param {object} ctx — retrieve context {city:{nodes,categories}, byId:Map}
|
|
47
|
-
* @param {string} destination
|
|
48
|
-
* @param {number} limit — passed through to keyword search
|
|
49
|
-
* @returns {{ matched: object[], matchStrength: string }}
|
|
50
|
-
*/
|
|
51
|
-
export function parseDestination(ctx, destination, limit) {
|
|
52
|
-
const dest = String(destination || '').trim();
|
|
53
|
-
|
|
54
|
-
// ------------------------------------------------------------------
|
|
55
|
-
// file:<idOrRef>
|
|
56
|
-
// ------------------------------------------------------------------
|
|
57
|
-
if (dest.startsWith('file:')) {
|
|
58
|
-
const rest = dest.slice('file:'.length);
|
|
59
|
-
const node = resolveOne(ctx, rest);
|
|
60
|
-
return { matched: node ? [node] : [], matchStrength: 'direct' };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ------------------------------------------------------------------
|
|
64
|
-
// folder:<prefix>
|
|
65
|
-
// ------------------------------------------------------------------
|
|
66
|
-
if (dest.startsWith('folder:')) {
|
|
67
|
-
const rest = dest.slice('folder:'.length);
|
|
68
|
-
// Normalise: ensure trailing slash for prefix match (unless rest already ends with /)
|
|
69
|
-
const prefix = rest.endsWith('/') ? rest : rest + '/';
|
|
70
|
-
const matched = ctx.city.nodes.filter(n => {
|
|
71
|
-
const ref = n.provenance && n.provenance.ref;
|
|
72
|
-
return typeof ref === 'string' && (ref.startsWith(prefix) || ref === rest);
|
|
73
|
-
});
|
|
74
|
-
return { matched, matchStrength: 'folder' };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ------------------------------------------------------------------
|
|
78
|
-
// district:<name>
|
|
79
|
-
// ------------------------------------------------------------------
|
|
80
|
-
if (dest.startsWith('district:')) {
|
|
81
|
-
const name = dest.slice('district:'.length).toLowerCase();
|
|
82
|
-
// Build label->id map from city.categories
|
|
83
|
-
const labelToId = new Map();
|
|
84
|
-
for (const cat of (ctx.city.categories || [])) {
|
|
85
|
-
if (cat.label) labelToId.set(String(cat.label).toLowerCase(), String(cat.id).toLowerCase());
|
|
86
|
-
}
|
|
87
|
-
const resolvedCatId = labelToId.get(name) || null;
|
|
88
|
-
const matched = ctx.city.nodes.filter(n => {
|
|
89
|
-
const cat = String(n.category || '').toLowerCase();
|
|
90
|
-
return cat === name || (resolvedCatId !== null && cat === resolvedCatId);
|
|
91
|
-
});
|
|
92
|
-
return { matched, matchStrength: 'district' };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ------------------------------------------------------------------
|
|
96
|
-
// importsOf:<idOrRef>
|
|
97
|
-
// ------------------------------------------------------------------
|
|
98
|
-
if (dest.startsWith('importsOf:')) {
|
|
99
|
-
const rest = dest.slice('importsOf:'.length);
|
|
100
|
-
const node = resolveOne(ctx, rest);
|
|
101
|
-
if (!node) return { matched: [], matchStrength: 'edges' };
|
|
102
|
-
const matched = (node.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean);
|
|
103
|
-
return { matched, matchStrength: 'edges' };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ------------------------------------------------------------------
|
|
107
|
-
// depChainTo:<idOrRef>
|
|
108
|
-
// Bounded reverse-edge BFS: finds all nodes that transitively import the target.
|
|
109
|
-
// Safety invariants:
|
|
110
|
-
// - visited Set prevents revisiting nodes (handles cycles A→B→A)
|
|
111
|
-
// - max depth 6 per BFS level
|
|
112
|
-
// - hard cap of 500 nodes visited (includes the target seed)
|
|
113
|
-
// ------------------------------------------------------------------
|
|
114
|
-
if (dest.startsWith('depChainTo:')) {
|
|
115
|
-
const rest = dest.slice('depChainTo:'.length);
|
|
116
|
-
const target = resolveOne(ctx, rest);
|
|
117
|
-
if (!target) return { matched: [], matchStrength: 'chain' };
|
|
118
|
-
const targetId = target.id;
|
|
119
|
-
|
|
120
|
-
// Build reverse-edge index: nodeId -> Set<callerId>
|
|
121
|
-
// (which nodes have this id in their links)
|
|
122
|
-
const importedBy = new Map();
|
|
123
|
-
for (const n of ctx.city.nodes) {
|
|
124
|
-
for (const link of (n.links || [])) {
|
|
125
|
-
if (!importedBy.has(link)) importedBy.set(link, new Set());
|
|
126
|
-
importedBy.get(link).add(n.id);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// BFS — each queue entry carries { id, depth }
|
|
131
|
-
const visited = new Set();
|
|
132
|
-
visited.add(targetId);
|
|
133
|
-
const queue = [{ id: targetId, depth: 0 }];
|
|
134
|
-
|
|
135
|
-
while (queue.length > 0 && visited.size < 500) {
|
|
136
|
-
const { id, depth } = queue.shift();
|
|
137
|
-
if (depth >= 6) continue;
|
|
138
|
-
const parents = importedBy.get(id);
|
|
139
|
-
if (!parents) continue;
|
|
140
|
-
for (const pid of parents) {
|
|
141
|
-
if (!visited.has(pid)) {
|
|
142
|
-
visited.add(pid);
|
|
143
|
-
queue.push({ id: pid, depth: depth + 1 });
|
|
144
|
-
if (visited.size >= 500) break;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Return callers of target, not the target itself
|
|
150
|
-
visited.delete(targetId);
|
|
151
|
-
const matched = [...visited].map(id => ctx.byId.get(id)).filter(Boolean);
|
|
152
|
-
return { matched, matchStrength: 'chain' };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ------------------------------------------------------------------
|
|
156
|
-
// no recognized prefix — keyword fallback
|
|
157
|
-
// ------------------------------------------------------------------
|
|
158
|
-
const hits = search(ctx, dest, { limit });
|
|
159
|
-
const matched = hits.map(h => ctx.byId.get(h.id)).filter(Boolean);
|
|
160
|
-
return { matched, matchStrength: 'keyword' };
|
|
161
|
-
}
|
|
1
|
+
// KnoSky destination parser (KSV2-R2) — extended prefix set for kc_route.
|
|
2
|
+
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
+
import { search } from './retrieve.mjs';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Internal helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a single node by id "fs:<rest>" OR by provenance.ref === <rest>.
|
|
11
|
+
* Also accepts a fully-qualified id (already has "fs:" prefix).
|
|
12
|
+
* @param {object} ctx
|
|
13
|
+
* @param {string} idOrRef
|
|
14
|
+
* @returns {object|null}
|
|
15
|
+
*/
|
|
16
|
+
function resolveOne(ctx, idOrRef) {
|
|
17
|
+
// Try constructing the canonical node id
|
|
18
|
+
const byConstructed = ctx.byId.get('fs:' + idOrRef);
|
|
19
|
+
if (byConstructed) return byConstructed;
|
|
20
|
+
// Try the raw string as-is (already fully qualified, e.g. "fs:src/a.js")
|
|
21
|
+
const byRaw = ctx.byId.get(idOrRef);
|
|
22
|
+
if (byRaw) return byRaw;
|
|
23
|
+
// Fallback: linear scan for provenance.ref match
|
|
24
|
+
return ctx.city.nodes.find(n => n.provenance && n.provenance.ref === idOrRef) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// parseDestination — main export
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the destination string to an initial set of matching nodes.
|
|
33
|
+
* Returns { matched: Node[], matchStrength: 'direct'|'folder'|'district'|'edges'|'chain'|'keyword' }
|
|
34
|
+
*
|
|
35
|
+
* Supported prefixes (ALL metadata-only, no filesystem access):
|
|
36
|
+
* file:<idOrRef> — node by id "fs:<rest>" OR by provenance.ref === rest; 'direct'
|
|
37
|
+
* folder:<prefix> — nodes whose provenance.ref starts with prefix; 'folder'
|
|
38
|
+
* district:<name> — nodes whose category equals name (case-insensitive) or
|
|
39
|
+
* whose category label matches; 'district'
|
|
40
|
+
* importsOf:<idOrRef> — out-edge targets of that node (node.links through ctx.byId); 'edges'
|
|
41
|
+
* depChainTo:<idOrRef> — nodes that TRANSITIVELY import/reach the target via
|
|
42
|
+
* reverse-edge BFS; bounded (max depth 6, max 500 visited,
|
|
43
|
+
* visited-set prevents cycles); 'chain'
|
|
44
|
+
* <anything else> — keyword fallback via search(); 'keyword'
|
|
45
|
+
*
|
|
46
|
+
* @param {object} ctx — retrieve context {city:{nodes,categories}, byId:Map}
|
|
47
|
+
* @param {string} destination
|
|
48
|
+
* @param {number} limit — passed through to keyword search
|
|
49
|
+
* @returns {{ matched: object[], matchStrength: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function parseDestination(ctx, destination, limit) {
|
|
52
|
+
const dest = String(destination || '').trim();
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------------
|
|
55
|
+
// file:<idOrRef>
|
|
56
|
+
// ------------------------------------------------------------------
|
|
57
|
+
if (dest.startsWith('file:')) {
|
|
58
|
+
const rest = dest.slice('file:'.length);
|
|
59
|
+
const node = resolveOne(ctx, rest);
|
|
60
|
+
return { matched: node ? [node] : [], matchStrength: 'direct' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ------------------------------------------------------------------
|
|
64
|
+
// folder:<prefix>
|
|
65
|
+
// ------------------------------------------------------------------
|
|
66
|
+
if (dest.startsWith('folder:')) {
|
|
67
|
+
const rest = dest.slice('folder:'.length);
|
|
68
|
+
// Normalise: ensure trailing slash for prefix match (unless rest already ends with /)
|
|
69
|
+
const prefix = rest.endsWith('/') ? rest : rest + '/';
|
|
70
|
+
const matched = ctx.city.nodes.filter(n => {
|
|
71
|
+
const ref = n.provenance && n.provenance.ref;
|
|
72
|
+
return typeof ref === 'string' && (ref.startsWith(prefix) || ref === rest);
|
|
73
|
+
});
|
|
74
|
+
return { matched, matchStrength: 'folder' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ------------------------------------------------------------------
|
|
78
|
+
// district:<name>
|
|
79
|
+
// ------------------------------------------------------------------
|
|
80
|
+
if (dest.startsWith('district:')) {
|
|
81
|
+
const name = dest.slice('district:'.length).toLowerCase();
|
|
82
|
+
// Build label->id map from city.categories
|
|
83
|
+
const labelToId = new Map();
|
|
84
|
+
for (const cat of (ctx.city.categories || [])) {
|
|
85
|
+
if (cat.label) labelToId.set(String(cat.label).toLowerCase(), String(cat.id).toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
const resolvedCatId = labelToId.get(name) || null;
|
|
88
|
+
const matched = ctx.city.nodes.filter(n => {
|
|
89
|
+
const cat = String(n.category || '').toLowerCase();
|
|
90
|
+
return cat === name || (resolvedCatId !== null && cat === resolvedCatId);
|
|
91
|
+
});
|
|
92
|
+
return { matched, matchStrength: 'district' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ------------------------------------------------------------------
|
|
96
|
+
// importsOf:<idOrRef>
|
|
97
|
+
// ------------------------------------------------------------------
|
|
98
|
+
if (dest.startsWith('importsOf:')) {
|
|
99
|
+
const rest = dest.slice('importsOf:'.length);
|
|
100
|
+
const node = resolveOne(ctx, rest);
|
|
101
|
+
if (!node) return { matched: [], matchStrength: 'edges' };
|
|
102
|
+
const matched = (node.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean);
|
|
103
|
+
return { matched, matchStrength: 'edges' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------------
|
|
107
|
+
// depChainTo:<idOrRef>
|
|
108
|
+
// Bounded reverse-edge BFS: finds all nodes that transitively import the target.
|
|
109
|
+
// Safety invariants:
|
|
110
|
+
// - visited Set prevents revisiting nodes (handles cycles A→B→A)
|
|
111
|
+
// - max depth 6 per BFS level
|
|
112
|
+
// - hard cap of 500 nodes visited (includes the target seed)
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
if (dest.startsWith('depChainTo:')) {
|
|
115
|
+
const rest = dest.slice('depChainTo:'.length);
|
|
116
|
+
const target = resolveOne(ctx, rest);
|
|
117
|
+
if (!target) return { matched: [], matchStrength: 'chain' };
|
|
118
|
+
const targetId = target.id;
|
|
119
|
+
|
|
120
|
+
// Build reverse-edge index: nodeId -> Set<callerId>
|
|
121
|
+
// (which nodes have this id in their links)
|
|
122
|
+
const importedBy = new Map();
|
|
123
|
+
for (const n of ctx.city.nodes) {
|
|
124
|
+
for (const link of (n.links || [])) {
|
|
125
|
+
if (!importedBy.has(link)) importedBy.set(link, new Set());
|
|
126
|
+
importedBy.get(link).add(n.id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// BFS — each queue entry carries { id, depth }
|
|
131
|
+
const visited = new Set();
|
|
132
|
+
visited.add(targetId);
|
|
133
|
+
const queue = [{ id: targetId, depth: 0 }];
|
|
134
|
+
|
|
135
|
+
while (queue.length > 0 && visited.size < 500) {
|
|
136
|
+
const { id, depth } = queue.shift();
|
|
137
|
+
if (depth >= 6) continue;
|
|
138
|
+
const parents = importedBy.get(id);
|
|
139
|
+
if (!parents) continue;
|
|
140
|
+
for (const pid of parents) {
|
|
141
|
+
if (!visited.has(pid)) {
|
|
142
|
+
visited.add(pid);
|
|
143
|
+
queue.push({ id: pid, depth: depth + 1 });
|
|
144
|
+
if (visited.size >= 500) break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Return callers of target, not the target itself
|
|
150
|
+
visited.delete(targetId);
|
|
151
|
+
const matched = [...visited].map(id => ctx.byId.get(id)).filter(Boolean);
|
|
152
|
+
return { matched, matchStrength: 'chain' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ------------------------------------------------------------------
|
|
156
|
+
// no recognized prefix — keyword fallback
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
const hits = search(ctx, dest, { limit });
|
|
159
|
+
const matched = hits.map(h => ctx.byId.get(h.id)).filter(Boolean);
|
|
160
|
+
return { matched, matchStrength: 'keyword' };
|
|
161
|
+
}
|