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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
@@ -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
+ });
@@ -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
+ }