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/route.mjs
CHANGED
|
@@ -1,304 +1,304 @@
|
|
|
1
|
-
// KnoSky route engine (KSV2-R1/R2/R3) — structural destination -> ranked advisory route.
|
|
2
|
-
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
-
import { getRelated } from './retrieve.mjs';
|
|
4
|
-
import { makeRouteDoc, validateRouteDoc } from './schema.mjs';
|
|
5
|
-
import { parseDestination } from './destination.mjs';
|
|
6
|
-
import { extractLedgerSeq } from './freshness.mjs';
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Internal helpers
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Guard: a path from provenance.ref should never be absolute or contain "..".
|
|
14
|
-
* Returns true when the path is safe to include in output.
|
|
15
|
-
* @param {string|undefined} ref
|
|
16
|
-
* @returns {boolean}
|
|
17
|
-
*/
|
|
18
|
-
function isSafeRef(ref) {
|
|
19
|
-
if (!ref || typeof ref !== 'string') return false;
|
|
20
|
-
if (ref.startsWith('/')) return false;
|
|
21
|
-
if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
|
|
22
|
-
if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Return the node's provenance.ref, or null if missing/unsafe.
|
|
28
|
-
* @param {object} node
|
|
29
|
-
* @returns {string|null}
|
|
30
|
-
*/
|
|
31
|
-
function safeRef(node) {
|
|
32
|
-
const ref = node.provenance && node.provenance.ref;
|
|
33
|
-
return isSafeRef(ref) ? ref : null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Return true when node looks like a test file.
|
|
38
|
-
* @param {object} node
|
|
39
|
-
*/
|
|
40
|
-
function isTest(node) {
|
|
41
|
-
const ref = safeRef(node) || '';
|
|
42
|
-
const cat = String(node.category || '').toLowerCase();
|
|
43
|
-
return (
|
|
44
|
-
cat === 'test' ||
|
|
45
|
-
/\/test\//.test(ref) ||
|
|
46
|
-
/\.test\./.test(ref) ||
|
|
47
|
-
/\.spec\./.test(ref) ||
|
|
48
|
-
/\/test\//.test(node.id || '')
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Return true when node looks like a docs file.
|
|
54
|
-
* @param {object} node
|
|
55
|
-
*/
|
|
56
|
-
function isDoc(node) {
|
|
57
|
-
const ref = safeRef(node) || '';
|
|
58
|
-
const cat = String(node.category || '').toLowerCase();
|
|
59
|
-
return (
|
|
60
|
-
cat === 'docs' ||
|
|
61
|
-
cat === 'documentation' ||
|
|
62
|
-
ref.endsWith('.md') ||
|
|
63
|
-
ref.endsWith('.rst') ||
|
|
64
|
-
ref.endsWith('.txt')
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Tokenise a string into lowercase alphanumeric tokens, stripping any "word:" prefix.
|
|
70
|
-
* @param {string} str
|
|
71
|
-
* @returns {Set<string>}
|
|
72
|
-
*/
|
|
73
|
-
function tokenise(str) {
|
|
74
|
-
const stripped = String(str || '').replace(/^\w+:/, '');
|
|
75
|
-
return new Set((stripped.toLowerCase().match(/[a-z0-9]+/g) || []));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// kcRoute — main export
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Build a ranked, advisory, metadata-only route doc.
|
|
84
|
-
*
|
|
85
|
-
* @param {object} ctx — retrieve context from load()
|
|
86
|
-
* @param {string} destination — navigation target string
|
|
87
|
-
* @param {object} [opts]
|
|
88
|
-
* @param {number} [opts.limit=8] — max route entries
|
|
89
|
-
* @param {object} [opts.overlays] — optional overlay map from readOverlays():
|
|
90
|
-
* { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }
|
|
91
|
-
* Absence means coverage is unknown — the coverage-unknown caveat fires.
|
|
92
|
-
* @returns {object} route doc (passes validateRouteDoc)
|
|
93
|
-
*/
|
|
94
|
-
export function kcRoute(ctx, destination, { limit = 8, overlays } = {}) {
|
|
95
|
-
const clampedLimit = Math.max(1, Math.min(20, limit));
|
|
96
|
-
const { matched, matchStrength } = parseDestination(ctx, destination, clampedLimit);
|
|
97
|
-
|
|
98
|
-
// Collect direct-match ids + categories for scoring
|
|
99
|
-
const directIds = new Set(matched.map(n => n.id));
|
|
100
|
-
const directCategories = new Set(
|
|
101
|
-
matched.map(n => String(n.category || '').toLowerCase()).filter(Boolean),
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
// Destination keyword tokens (strip any "word:" prefix)
|
|
105
|
-
const destTokens = tokenise(destination);
|
|
106
|
-
|
|
107
|
-
// Expand to 1-hop neighbours via getRelated (imports + importedBy); de-dupe by id
|
|
108
|
-
const candidateMap = new Map();
|
|
109
|
-
for (const n of matched) {
|
|
110
|
-
candidateMap.set(n.id, n);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
for (const n of matched) {
|
|
114
|
-
const rel = getRelated(ctx, n.id);
|
|
115
|
-
if (!rel) continue;
|
|
116
|
-
for (const imp of rel.imports.concat(rel.importedBy)) {
|
|
117
|
-
if (!candidateMap.has(imp.id)) {
|
|
118
|
-
const full = ctx.byId.get(imp.id);
|
|
119
|
-
if (full) candidateMap.set(imp.id, full);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Score candidates
|
|
125
|
-
const caveats = [];
|
|
126
|
-
const lowCoverageCaveats = [];
|
|
127
|
-
const scored = [];
|
|
128
|
-
|
|
129
|
-
for (const [id, node] of candidateMap) {
|
|
130
|
-
let score = 0;
|
|
131
|
-
const reasons = [];
|
|
132
|
-
|
|
133
|
-
// Signal 1: destination match (+5)
|
|
134
|
-
if (directIds.has(id)) {
|
|
135
|
-
score += 5;
|
|
136
|
-
reasons.push('destination');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Signal 2: import proximity (+2)
|
|
140
|
-
const rel = getRelated(ctx, id);
|
|
141
|
-
if (rel) {
|
|
142
|
-
const importsDirectMatch = rel.imports.some(m => directIds.has(m.id));
|
|
143
|
-
const importedByDirectMatch = rel.importedBy.some(m => directIds.has(m.id));
|
|
144
|
-
if (importsDirectMatch || importedByDirectMatch) {
|
|
145
|
-
score += 2;
|
|
146
|
-
reasons.push(importsDirectMatch ? 'imports destination' : 'imported by destination');
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Signal 3: same district (+1.5) — shares category with a direct match but is not itself a direct match
|
|
151
|
-
if (!directIds.has(id) && directCategories.size > 0) {
|
|
152
|
-
const cat = String(node.category || '').toLowerCase();
|
|
153
|
-
if (cat && directCategories.has(cat)) {
|
|
154
|
-
score += 1.5;
|
|
155
|
-
reasons.push('same district');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Signal 4: keyword overlap (+1, capped once)
|
|
160
|
-
if (destTokens.size > 0) {
|
|
161
|
-
const candidateText = [node.title || '', ...(node.headings || [])].join(' ');
|
|
162
|
-
const candidateTokens = tokenise(candidateText);
|
|
163
|
-
const hasOverlap = [...destTokens].some(t => candidateTokens.has(t));
|
|
164
|
-
if (hasOverlap) {
|
|
165
|
-
score += 1;
|
|
166
|
-
reasons.push('name/heading match');
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Signal 5: recently changed (+1)
|
|
171
|
-
if (node.churn && typeof node.churn === 'object' && node.churn.c > 0) {
|
|
172
|
-
score += 1;
|
|
173
|
-
reasons.push('recently changed');
|
|
174
|
-
} else if (typeof node.churn === 'number' && node.churn > 0) {
|
|
175
|
-
score += 1;
|
|
176
|
-
reasons.push('recently changed');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Signal 6: low coverage (+0.5 if overlays provided and coverage < 50)
|
|
180
|
-
if (overlays !== undefined && overlays !== null) {
|
|
181
|
-
const ref = safeRef(node);
|
|
182
|
-
if (ref) {
|
|
183
|
-
const overlay = overlays[ref];
|
|
184
|
-
if (overlay && typeof overlay.coverage === 'number') {
|
|
185
|
-
if (overlay.coverage < 50) {
|
|
186
|
-
score += 0.5;
|
|
187
|
-
lowCoverageCaveats.push(
|
|
188
|
-
'low coverage: ' + ref + ' (' + Math.round(overlay.coverage) + '%) — extra review advised',
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
// coverage >= 50: no change, no penalty
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
scored.push({ id, node, score, reason: reasons.join('; ') || 'neighbourhood match' });
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Sort: score desc, then id asc
|
|
200
|
-
scored.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
|
|
201
|
-
|
|
202
|
-
// Build caveats
|
|
203
|
-
// — churn caveat for any high-churn file in the top results
|
|
204
|
-
const topCandidates = scored.slice(0, clampedLimit + 5); // check a bit beyond route
|
|
205
|
-
for (const s of topCandidates) {
|
|
206
|
-
const churn = s.node.churn;
|
|
207
|
-
const hasChurn = (churn && typeof churn === 'object' && churn.c > 0) ||
|
|
208
|
-
(typeof churn === 'number' && churn > 0);
|
|
209
|
-
if (hasChurn) {
|
|
210
|
-
caveats.push('recently changed: ' + (s.node.provenance && s.node.provenance.ref || s.id) + ' has high churn — verify before acting');
|
|
211
|
-
break; // one caveat entry is sufficient
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// — low-coverage caveats (one per underperforming file, from signal 6)
|
|
216
|
-
for (const c of lowCoverageCaveats) {
|
|
217
|
-
caveats.push(c);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// — coverage unknown caveat: only when overlays were NOT provided
|
|
221
|
-
if (overlays === undefined || overlays === null) {
|
|
222
|
-
const hasCoverageGap = scored.some(s => s.node.coverage === undefined || s.node.coverage === null);
|
|
223
|
-
if (hasCoverageGap) {
|
|
224
|
-
caveats.push('coverage unknown: one or more nodes lack coverage overlay data');
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// — mandatory advisory caveat (always present)
|
|
229
|
-
caveats.push('advisory route — verify before acting; KnoSky does not read code meaning');
|
|
230
|
-
|
|
231
|
-
// — staleness caveat when source_rev is present
|
|
232
|
-
const source_rev = ctx.city.source_rev || null;
|
|
233
|
-
if (source_rev) {
|
|
234
|
-
caveats.push('route is based on rev ' + source_rev + ' and may be stale');
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Ledger-anchored freshness (SAT-444): read the monotone commit count from the city.
|
|
238
|
-
const ledger_seq = extractLedgerSeq(ctx.city);
|
|
239
|
-
|
|
240
|
-
// Slice into route + alternates
|
|
241
|
-
const routeEntries = scored.slice(0, clampedLimit);
|
|
242
|
-
const alternateEntries = scored.slice(clampedLimit, clampedLimit + 5);
|
|
243
|
-
|
|
244
|
-
function toEntry(s) {
|
|
245
|
-
const path = safeRef(s.node) || s.id;
|
|
246
|
-
return { path, id: s.id, reason: s.reason, score: s.score };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Defense in depth: only isSafeRef() gates output (the previous `|| !path.startsWith('/')`
|
|
250
|
-
// clause was a bug — any path lacking a leading '/' but containing '..' would pass through
|
|
251
|
-
// it unfiltered; validateRouteDoc() catches that downstream today, but this filter should
|
|
252
|
-
// not have been able to let it through in the first place).
|
|
253
|
-
const route = routeEntries.map(toEntry).filter(e => isSafeRef(e.path));
|
|
254
|
-
const alternates = alternateEntries.map(toEntry).filter(e => isSafeRef(e.path));
|
|
255
|
-
|
|
256
|
-
// Confidence: direct -> ~0.95 ceiling; folder/district/edges/chain -> ~0.75; keyword -> ~0.6
|
|
257
|
-
let confidence = 0;
|
|
258
|
-
if (routeEntries.length > 0) {
|
|
259
|
-
const topScore = routeEntries[0].score;
|
|
260
|
-
if (matchStrength === 'direct') {
|
|
261
|
-
// direct hit scores at least 5; normalise to ~0.95 max
|
|
262
|
-
confidence = Math.min(0.95, topScore / 10);
|
|
263
|
-
} else if (matchStrength === 'folder' || matchStrength === 'district' ||
|
|
264
|
-
matchStrength === 'edges' || matchStrength === 'chain') {
|
|
265
|
-
confidence = Math.min(0.75, topScore / 10);
|
|
266
|
-
} else {
|
|
267
|
-
// keyword — lower ceiling
|
|
268
|
-
confidence = Math.min(0.6, topScore / 20);
|
|
269
|
-
}
|
|
270
|
-
confidence = Math.max(0, Math.min(1, confidence));
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Classify tests / docs from all candidates
|
|
274
|
-
const tests = [];
|
|
275
|
-
const docs = [];
|
|
276
|
-
for (const [, node] of candidateMap) {
|
|
277
|
-
const ref = safeRef(node);
|
|
278
|
-
if (!ref) continue;
|
|
279
|
-
if (isTest(node)) tests.push({ path: ref, id: node.id });
|
|
280
|
-
else if (isDoc(node)) docs.push({ path: ref, id: node.id });
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const doc = makeRouteDoc({
|
|
284
|
-
destination,
|
|
285
|
-
route,
|
|
286
|
-
alternates,
|
|
287
|
-
caveats,
|
|
288
|
-
confidence,
|
|
289
|
-
source_rev,
|
|
290
|
-
ledger_seq,
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
// Attach tests + docs (extra fields beyond the base envelope — schema.validateRouteDoc does not reject them)
|
|
294
|
-
doc.tests = tests;
|
|
295
|
-
doc.docs = docs;
|
|
296
|
-
|
|
297
|
-
// Invariant: doc MUST pass validateRouteDoc
|
|
298
|
-
const validation = validateRouteDoc(doc);
|
|
299
|
-
if (!validation.ok) {
|
|
300
|
-
throw new Error('kcRoute produced an invalid route doc: ' + validation.errors.join('; '));
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return doc;
|
|
304
|
-
}
|
|
1
|
+
// KnoSky route engine (KSV2-R1/R2/R3) — structural destination -> ranked advisory route.
|
|
2
|
+
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
+
import { getRelated } from './retrieve.mjs';
|
|
4
|
+
import { makeRouteDoc, validateRouteDoc } from './schema.mjs';
|
|
5
|
+
import { parseDestination } from './destination.mjs';
|
|
6
|
+
import { extractLedgerSeq } from './freshness.mjs';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Internal helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Guard: a path from provenance.ref should never be absolute or contain "..".
|
|
14
|
+
* Returns true when the path is safe to include in output.
|
|
15
|
+
* @param {string|undefined} ref
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function isSafeRef(ref) {
|
|
19
|
+
if (!ref || typeof ref !== 'string') return false;
|
|
20
|
+
if (ref.startsWith('/')) return false;
|
|
21
|
+
if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
|
|
22
|
+
if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return the node's provenance.ref, or null if missing/unsafe.
|
|
28
|
+
* @param {object} node
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
function safeRef(node) {
|
|
32
|
+
const ref = node.provenance && node.provenance.ref;
|
|
33
|
+
return isSafeRef(ref) ? ref : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Return true when node looks like a test file.
|
|
38
|
+
* @param {object} node
|
|
39
|
+
*/
|
|
40
|
+
function isTest(node) {
|
|
41
|
+
const ref = safeRef(node) || '';
|
|
42
|
+
const cat = String(node.category || '').toLowerCase();
|
|
43
|
+
return (
|
|
44
|
+
cat === 'test' ||
|
|
45
|
+
/\/test\//.test(ref) ||
|
|
46
|
+
/\.test\./.test(ref) ||
|
|
47
|
+
/\.spec\./.test(ref) ||
|
|
48
|
+
/\/test\//.test(node.id || '')
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Return true when node looks like a docs file.
|
|
54
|
+
* @param {object} node
|
|
55
|
+
*/
|
|
56
|
+
function isDoc(node) {
|
|
57
|
+
const ref = safeRef(node) || '';
|
|
58
|
+
const cat = String(node.category || '').toLowerCase();
|
|
59
|
+
return (
|
|
60
|
+
cat === 'docs' ||
|
|
61
|
+
cat === 'documentation' ||
|
|
62
|
+
ref.endsWith('.md') ||
|
|
63
|
+
ref.endsWith('.rst') ||
|
|
64
|
+
ref.endsWith('.txt')
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tokenise a string into lowercase alphanumeric tokens, stripping any "word:" prefix.
|
|
70
|
+
* @param {string} str
|
|
71
|
+
* @returns {Set<string>}
|
|
72
|
+
*/
|
|
73
|
+
function tokenise(str) {
|
|
74
|
+
const stripped = String(str || '').replace(/^\w+:/, '');
|
|
75
|
+
return new Set((stripped.toLowerCase().match(/[a-z0-9]+/g) || []));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// kcRoute — main export
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a ranked, advisory, metadata-only route doc.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} ctx — retrieve context from load()
|
|
86
|
+
* @param {string} destination — navigation target string
|
|
87
|
+
* @param {object} [opts]
|
|
88
|
+
* @param {number} [opts.limit=8] — max route entries
|
|
89
|
+
* @param {object} [opts.overlays] — optional overlay map from readOverlays():
|
|
90
|
+
* { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }
|
|
91
|
+
* Absence means coverage is unknown — the coverage-unknown caveat fires.
|
|
92
|
+
* @returns {object} route doc (passes validateRouteDoc)
|
|
93
|
+
*/
|
|
94
|
+
export function kcRoute(ctx, destination, { limit = 8, overlays } = {}) {
|
|
95
|
+
const clampedLimit = Math.max(1, Math.min(20, limit));
|
|
96
|
+
const { matched, matchStrength } = parseDestination(ctx, destination, clampedLimit);
|
|
97
|
+
|
|
98
|
+
// Collect direct-match ids + categories for scoring
|
|
99
|
+
const directIds = new Set(matched.map(n => n.id));
|
|
100
|
+
const directCategories = new Set(
|
|
101
|
+
matched.map(n => String(n.category || '').toLowerCase()).filter(Boolean),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Destination keyword tokens (strip any "word:" prefix)
|
|
105
|
+
const destTokens = tokenise(destination);
|
|
106
|
+
|
|
107
|
+
// Expand to 1-hop neighbours via getRelated (imports + importedBy); de-dupe by id
|
|
108
|
+
const candidateMap = new Map();
|
|
109
|
+
for (const n of matched) {
|
|
110
|
+
candidateMap.set(n.id, n);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const n of matched) {
|
|
114
|
+
const rel = getRelated(ctx, n.id);
|
|
115
|
+
if (!rel) continue;
|
|
116
|
+
for (const imp of rel.imports.concat(rel.importedBy)) {
|
|
117
|
+
if (!candidateMap.has(imp.id)) {
|
|
118
|
+
const full = ctx.byId.get(imp.id);
|
|
119
|
+
if (full) candidateMap.set(imp.id, full);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Score candidates
|
|
125
|
+
const caveats = [];
|
|
126
|
+
const lowCoverageCaveats = [];
|
|
127
|
+
const scored = [];
|
|
128
|
+
|
|
129
|
+
for (const [id, node] of candidateMap) {
|
|
130
|
+
let score = 0;
|
|
131
|
+
const reasons = [];
|
|
132
|
+
|
|
133
|
+
// Signal 1: destination match (+5)
|
|
134
|
+
if (directIds.has(id)) {
|
|
135
|
+
score += 5;
|
|
136
|
+
reasons.push('destination');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Signal 2: import proximity (+2)
|
|
140
|
+
const rel = getRelated(ctx, id);
|
|
141
|
+
if (rel) {
|
|
142
|
+
const importsDirectMatch = rel.imports.some(m => directIds.has(m.id));
|
|
143
|
+
const importedByDirectMatch = rel.importedBy.some(m => directIds.has(m.id));
|
|
144
|
+
if (importsDirectMatch || importedByDirectMatch) {
|
|
145
|
+
score += 2;
|
|
146
|
+
reasons.push(importsDirectMatch ? 'imports destination' : 'imported by destination');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Signal 3: same district (+1.5) — shares category with a direct match but is not itself a direct match
|
|
151
|
+
if (!directIds.has(id) && directCategories.size > 0) {
|
|
152
|
+
const cat = String(node.category || '').toLowerCase();
|
|
153
|
+
if (cat && directCategories.has(cat)) {
|
|
154
|
+
score += 1.5;
|
|
155
|
+
reasons.push('same district');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Signal 4: keyword overlap (+1, capped once)
|
|
160
|
+
if (destTokens.size > 0) {
|
|
161
|
+
const candidateText = [node.title || '', ...(node.headings || [])].join(' ');
|
|
162
|
+
const candidateTokens = tokenise(candidateText);
|
|
163
|
+
const hasOverlap = [...destTokens].some(t => candidateTokens.has(t));
|
|
164
|
+
if (hasOverlap) {
|
|
165
|
+
score += 1;
|
|
166
|
+
reasons.push('name/heading match');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Signal 5: recently changed (+1)
|
|
171
|
+
if (node.churn && typeof node.churn === 'object' && node.churn.c > 0) {
|
|
172
|
+
score += 1;
|
|
173
|
+
reasons.push('recently changed');
|
|
174
|
+
} else if (typeof node.churn === 'number' && node.churn > 0) {
|
|
175
|
+
score += 1;
|
|
176
|
+
reasons.push('recently changed');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Signal 6: low coverage (+0.5 if overlays provided and coverage < 50)
|
|
180
|
+
if (overlays !== undefined && overlays !== null) {
|
|
181
|
+
const ref = safeRef(node);
|
|
182
|
+
if (ref) {
|
|
183
|
+
const overlay = overlays[ref];
|
|
184
|
+
if (overlay && typeof overlay.coverage === 'number') {
|
|
185
|
+
if (overlay.coverage < 50) {
|
|
186
|
+
score += 0.5;
|
|
187
|
+
lowCoverageCaveats.push(
|
|
188
|
+
'low coverage: ' + ref + ' (' + Math.round(overlay.coverage) + '%) — extra review advised',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
// coverage >= 50: no change, no penalty
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
scored.push({ id, node, score, reason: reasons.join('; ') || 'neighbourhood match' });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sort: score desc, then id asc
|
|
200
|
+
scored.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
|
|
201
|
+
|
|
202
|
+
// Build caveats
|
|
203
|
+
// — churn caveat for any high-churn file in the top results
|
|
204
|
+
const topCandidates = scored.slice(0, clampedLimit + 5); // check a bit beyond route
|
|
205
|
+
for (const s of topCandidates) {
|
|
206
|
+
const churn = s.node.churn;
|
|
207
|
+
const hasChurn = (churn && typeof churn === 'object' && churn.c > 0) ||
|
|
208
|
+
(typeof churn === 'number' && churn > 0);
|
|
209
|
+
if (hasChurn) {
|
|
210
|
+
caveats.push('recently changed: ' + (s.node.provenance && s.node.provenance.ref || s.id) + ' has high churn — verify before acting');
|
|
211
|
+
break; // one caveat entry is sufficient
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// — low-coverage caveats (one per underperforming file, from signal 6)
|
|
216
|
+
for (const c of lowCoverageCaveats) {
|
|
217
|
+
caveats.push(c);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// — coverage unknown caveat: only when overlays were NOT provided
|
|
221
|
+
if (overlays === undefined || overlays === null) {
|
|
222
|
+
const hasCoverageGap = scored.some(s => s.node.coverage === undefined || s.node.coverage === null);
|
|
223
|
+
if (hasCoverageGap) {
|
|
224
|
+
caveats.push('coverage unknown: one or more nodes lack coverage overlay data');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// — mandatory advisory caveat (always present)
|
|
229
|
+
caveats.push('advisory route — verify before acting; KnoSky does not read code meaning');
|
|
230
|
+
|
|
231
|
+
// — staleness caveat when source_rev is present
|
|
232
|
+
const source_rev = ctx.city.source_rev || null;
|
|
233
|
+
if (source_rev) {
|
|
234
|
+
caveats.push('route is based on rev ' + source_rev + ' and may be stale');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Ledger-anchored freshness (SAT-444): read the monotone commit count from the city.
|
|
238
|
+
const ledger_seq = extractLedgerSeq(ctx.city);
|
|
239
|
+
|
|
240
|
+
// Slice into route + alternates
|
|
241
|
+
const routeEntries = scored.slice(0, clampedLimit);
|
|
242
|
+
const alternateEntries = scored.slice(clampedLimit, clampedLimit + 5);
|
|
243
|
+
|
|
244
|
+
function toEntry(s) {
|
|
245
|
+
const path = safeRef(s.node) || s.id;
|
|
246
|
+
return { path, id: s.id, reason: s.reason, score: s.score };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Defense in depth: only isSafeRef() gates output (the previous `|| !path.startsWith('/')`
|
|
250
|
+
// clause was a bug — any path lacking a leading '/' but containing '..' would pass through
|
|
251
|
+
// it unfiltered; validateRouteDoc() catches that downstream today, but this filter should
|
|
252
|
+
// not have been able to let it through in the first place).
|
|
253
|
+
const route = routeEntries.map(toEntry).filter(e => isSafeRef(e.path));
|
|
254
|
+
const alternates = alternateEntries.map(toEntry).filter(e => isSafeRef(e.path));
|
|
255
|
+
|
|
256
|
+
// Confidence: direct -> ~0.95 ceiling; folder/district/edges/chain -> ~0.75; keyword -> ~0.6
|
|
257
|
+
let confidence = 0;
|
|
258
|
+
if (routeEntries.length > 0) {
|
|
259
|
+
const topScore = routeEntries[0].score;
|
|
260
|
+
if (matchStrength === 'direct') {
|
|
261
|
+
// direct hit scores at least 5; normalise to ~0.95 max
|
|
262
|
+
confidence = Math.min(0.95, topScore / 10);
|
|
263
|
+
} else if (matchStrength === 'folder' || matchStrength === 'district' ||
|
|
264
|
+
matchStrength === 'edges' || matchStrength === 'chain') {
|
|
265
|
+
confidence = Math.min(0.75, topScore / 10);
|
|
266
|
+
} else {
|
|
267
|
+
// keyword — lower ceiling
|
|
268
|
+
confidence = Math.min(0.6, topScore / 20);
|
|
269
|
+
}
|
|
270
|
+
confidence = Math.max(0, Math.min(1, confidence));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Classify tests / docs from all candidates
|
|
274
|
+
const tests = [];
|
|
275
|
+
const docs = [];
|
|
276
|
+
for (const [, node] of candidateMap) {
|
|
277
|
+
const ref = safeRef(node);
|
|
278
|
+
if (!ref) continue;
|
|
279
|
+
if (isTest(node)) tests.push({ path: ref, id: node.id });
|
|
280
|
+
else if (isDoc(node)) docs.push({ path: ref, id: node.id });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const doc = makeRouteDoc({
|
|
284
|
+
destination,
|
|
285
|
+
route,
|
|
286
|
+
alternates,
|
|
287
|
+
caveats,
|
|
288
|
+
confidence,
|
|
289
|
+
source_rev,
|
|
290
|
+
ledger_seq,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Attach tests + docs (extra fields beyond the base envelope — schema.validateRouteDoc does not reject them)
|
|
294
|
+
doc.tests = tests;
|
|
295
|
+
doc.docs = docs;
|
|
296
|
+
|
|
297
|
+
// Invariant: doc MUST pass validateRouteDoc
|
|
298
|
+
const validation = validateRouteDoc(doc);
|
|
299
|
+
if (!validation.ok) {
|
|
300
|
+
throw new Error('kcRoute produced an invalid route doc: ' + validation.errors.join('; '));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return doc;
|
|
304
|
+
}
|