knosky 0.6.2 → 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 -824
  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
@@ -0,0 +1,320 @@
1
+ // KnoSky F0.3 — Build provenance: in-toto/DSSE attestation (SAT-547).
2
+ //
3
+ // Produces a signed DSSE (Dead Simple Signing Envelope) wrapping an
4
+ // in-toto provenance statement that binds:
5
+ // - the published npm artifact (name + version + file digest)
6
+ // - the source commit that produced it
7
+ // - the package-lock.json digest (lockfile integrity)
8
+ //
9
+ // This extends the GitHub Actions OIDC → npm Trusted Publishing pipeline
10
+ // (D-162/EPIC Q) with a repo-local attestation: the attestation is signed
11
+ // by the project's own release key (the KnoSky key-store, core/key-store.mjs),
12
+ // NOT by Sigstore/Fulcio/Rekor — consistent with the project's strict
13
+ // no-egress design principle (see SECURITY.md and test/no-egress-v21.test.mjs).
14
+ //
15
+ // DSSE envelope shape (https://github.com/secure-systems-lab/dsse):
16
+ // {
17
+ // payloadType: "application/vnd.in-toto+json",
18
+ // payload: <base64url(utf8(JSON(statementV1)))>,
19
+ // signatures: [{ keyid: <key_id>, sig: <base64url(hex-hmac)> }],
20
+ // }
21
+ //
22
+ // in-toto Statement v1 (https://in-toto.io/Statement/v1):
23
+ // {
24
+ // _type: "https://in-toto.io/Statement/v1",
25
+ // subject: [{ name: "<pkg>@<ver>", digest: { sha256: "<hex>" } }],
26
+ // predicateType: "https://slsa.dev/provenance/v1",
27
+ // predicate: {
28
+ // buildDefinition: {
29
+ // buildType: "https://knosky.com/provenance/build/v1",
30
+ // externalParameters: {
31
+ // source_commit: <git-commit-sha>,
32
+ // lockfile_sha256: <hex-sha256-of-package-lock.json>,
33
+ // },
34
+ // },
35
+ // runDetails: {
36
+ // builder: { id: "https://knosky.com/builders/release-key/v1" },
37
+ // metadata: { invocationId: <key_id>, finishedOn: <ISO-8601> },
38
+ // },
39
+ // },
40
+ // }
41
+ //
42
+ // Signature PAE (Pre-Authentication Encoding) exactly follows DSSE § 4.2:
43
+ // DSSEv1 + SP + len(payloadType) + SP + payloadType + SP + len(payload) + SP + payload
44
+ // where "len" is the decimal byte-length of the UTF-8 encoding.
45
+ //
46
+ // All inputs are pure in-process data: no file-system access, no network.
47
+ // Callers are responsible for reading the source commit and lockfile digest
48
+ // from the environment or the filesystem and passing them as plain strings.
49
+ //
50
+ // Pure Node stdlib, ESM — no third-party dependencies.
51
+
52
+ import { createHmac } from 'node:crypto';
53
+ import { signManifest, verifyManifest } from './key-store.mjs';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** in-toto Statement type URI (v1). */
60
+ export const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
61
+
62
+ /** SLSA provenance predicate type URI (v1). */
63
+ export const PREDICATE_TYPE = 'https://slsa.dev/provenance/v1';
64
+
65
+ /** KnoSky-specific build type URI (repo-local, no-egress signer). */
66
+ export const BUILD_TYPE = 'https://knosky.com/provenance/build/v1';
67
+
68
+ /** DSSE payload type for in-toto statements. */
69
+ export const DSSE_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // makeProvenanceStatement
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Construct an in-toto Statement v1 / SLSA Provenance v1 predicate object
77
+ * (plain JS object — not yet encoded or signed).
78
+ *
79
+ * @param {object} opts
80
+ * @param {string} opts.packageName npm package name (e.g. "knosky").
81
+ * @param {string} opts.packageVersion npm package version (e.g. "0.6.3").
82
+ * @param {string} opts.artifactDigest hex-encoded SHA-256 of the published tarball.
83
+ * @param {string} opts.sourceCommit git commit SHA that produced this release.
84
+ * @param {string} opts.lockfileDigest hex-encoded SHA-256 of package-lock.json.
85
+ * @param {string} opts.keyId `key_id` of the signing key (recorded in runDetails).
86
+ * @param {string} opts.finishedOn ISO-8601 timestamp for the attestation (caller-supplied).
87
+ * @returns {object} A plain JS in-toto Statement v1 object.
88
+ */
89
+ export function makeProvenanceStatement({
90
+ packageName,
91
+ packageVersion,
92
+ artifactDigest,
93
+ sourceCommit,
94
+ lockfileDigest,
95
+ keyId,
96
+ finishedOn,
97
+ }) {
98
+ if (!packageName || typeof packageName !== 'string') {
99
+ throw new TypeError('makeProvenanceStatement: packageName must be a non-empty string');
100
+ }
101
+ if (!packageVersion || typeof packageVersion !== 'string') {
102
+ throw new TypeError('makeProvenanceStatement: packageVersion must be a non-empty string');
103
+ }
104
+ if (!artifactDigest || !/^[0-9a-f]{64}$/i.test(artifactDigest)) {
105
+ throw new TypeError('makeProvenanceStatement: artifactDigest must be a 64-char hex SHA-256');
106
+ }
107
+ if (!sourceCommit || !/^[0-9a-f]{7,64}$/i.test(sourceCommit)) {
108
+ throw new TypeError('makeProvenanceStatement: sourceCommit must be a hex git SHA (7-64 chars)');
109
+ }
110
+ if (!lockfileDigest || !/^[0-9a-f]{64}$/i.test(lockfileDigest)) {
111
+ throw new TypeError('makeProvenanceStatement: lockfileDigest must be a 64-char hex SHA-256');
112
+ }
113
+ if (!keyId || typeof keyId !== 'string') {
114
+ throw new TypeError('makeProvenanceStatement: keyId must be a non-empty string');
115
+ }
116
+ if (!finishedOn || typeof finishedOn !== 'string') {
117
+ throw new TypeError('makeProvenanceStatement: finishedOn must be a non-empty ISO-8601 string');
118
+ }
119
+
120
+ return {
121
+ _type: STATEMENT_TYPE,
122
+ subject: [
123
+ {
124
+ name: `${packageName}@${packageVersion}`,
125
+ digest: { sha256: artifactDigest.toLowerCase() },
126
+ },
127
+ ],
128
+ predicateType: PREDICATE_TYPE,
129
+ predicate: {
130
+ buildDefinition: {
131
+ buildType: BUILD_TYPE,
132
+ externalParameters: {
133
+ source_commit: sourceCommit.toLowerCase(),
134
+ lockfile_sha256: lockfileDigest.toLowerCase(),
135
+ },
136
+ },
137
+ runDetails: {
138
+ builder: { id: 'https://knosky.com/builders/release-key/v1' },
139
+ metadata: {
140
+ invocationId: keyId,
141
+ finishedOn,
142
+ },
143
+ },
144
+ },
145
+ };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // dssePreAuthEncoding
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Compute the DSSE Pre-Authentication Encoding (PAE) for the given
154
+ * payload type and payload bytes (DSSE spec § 4.2):
155
+ *
156
+ * "DSSEv1" SP len(payloadType) SP payloadType SP len(payload) SP payload
157
+ *
158
+ * where `len` is the decimal UTF-8 byte-length of the corresponding value.
159
+ *
160
+ * @param {string} payloadType e.g. "application/vnd.in-toto+json"
161
+ * @param {Buffer|string} payload The raw payload bytes (or UTF-8 string).
162
+ * @returns {Buffer}
163
+ */
164
+ export function dssePreAuthEncoding(payloadType, payload) {
165
+ const ptBuf = Buffer.from(payloadType, 'utf8');
166
+ const payBuf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
167
+ // "DSSEv1" SP len(pt) SP pt SP len(pay) SP pay
168
+ const prefix = Buffer.from(`DSSEv1 ${ptBuf.length} `, 'utf8');
169
+ const middle = Buffer.from(` ${payBuf.length} `, 'utf8');
170
+ return Buffer.concat([prefix, ptBuf, middle, payBuf]);
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // buildDsseEnvelope
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Build a signed DSSE envelope wrapping a KnoSky build-provenance statement.
179
+ *
180
+ * The statement is signed using the project's own HMAC-SHA256 release key
181
+ * (via `signManifest` from `core/key-store.mjs`). No Sigstore, no Fulcio,
182
+ * no Rekor — consistent with KnoSky's no-egress design principle.
183
+ *
184
+ * Signing is done over the DSSE PAE of the JSON-serialised statement:
185
+ * PAE(DSSE_PAYLOAD_TYPE, JSON.stringify(statement))
186
+ * The resulting HMAC hex digest is base64url-encoded as the `sig` field
187
+ * inside `signatures[0]`.
188
+ *
189
+ * The envelope also carries the raw `_ks_sig` object (output of
190
+ * `signManifest`) so that `verifyDsseEnvelope` can verify it using only
191
+ * the in-process key store — no external verifier required.
192
+ *
193
+ * Envelope shape:
194
+ * ```json
195
+ * {
196
+ * "payloadType": "application/vnd.in-toto+json",
197
+ * "payload": "<base64url(utf8(JSON(statement)))>",
198
+ * "signatures": [{ "keyid": "<key_id>", "sig": "<base64url(hmac-hex)>" }],
199
+ * "_ks_sig": { "key_id": "...", "sig": "...", "_type": "...", ... }
200
+ * }
201
+ * ```
202
+ *
203
+ * `_ks_sig` is a KnoSky-internal field (prefix `_`) carrying the raw
204
+ * `signManifest` output so the envelope is self-verifying against the
205
+ * key store without any additional state.
206
+ *
207
+ * @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
208
+ * The KnoSky key store with an active signing key.
209
+ * @param {object} statement
210
+ * The in-toto Statement object from {@link makeProvenanceStatement}.
211
+ * @returns {object} The signed DSSE envelope.
212
+ */
213
+ export function buildDsseEnvelope(ks, statement) {
214
+ const payloadJson = JSON.stringify(statement);
215
+ const payloadBase64 = Buffer.from(payloadJson, 'utf8').toString('base64url');
216
+
217
+ // Build the PAE over which the HMAC is computed.
218
+ const pae = dssePreAuthEncoding(DSSE_PAYLOAD_TYPE, payloadJson);
219
+
220
+ // signManifest expects a plain object; wrap the PAE hash as a signing payload.
221
+ // We sign a minimal manifest that carries the PAE digest so all the
222
+ // key-store machinery (key lookup, sig format, revocation checks) is reused
223
+ // unchanged. The PAE digest is included as `pae_sha256` in the manifest.
224
+ const paeDigest = createHmac('sha256', Buffer.alloc(0))
225
+ .update(pae)
226
+ .digest('hex');
227
+
228
+ // Use signManifest with a synthetic manifest that includes the PAE digest.
229
+ // The manifest's sig covers: artifact_type, pae_sha256 (sorted keys →
230
+ // canonical JSON). This ties the DSSE signature check back to the
231
+ // key-store verification path used everywhere else.
232
+ const signingManifest = {
233
+ artifact_type: 'dsse-provenance',
234
+ pae_sha256: paeDigest,
235
+ };
236
+ const ksSigned = signManifest(ks, signingManifest);
237
+
238
+ // DSSE `sig` field: base64url of the HMAC hex bytes (per DSSE § 4.2 —
239
+ // "base64-encoded bytes of the signature"). Node's HMAC outputs 32
240
+ // raw bytes (hex = 64 chars); we encode the raw bytes directly.
241
+ const sigBytes = Buffer.from(ksSigned.sig, 'hex');
242
+ const sigBase64 = sigBytes.toString('base64url');
243
+
244
+ return {
245
+ payloadType: DSSE_PAYLOAD_TYPE,
246
+ payload: payloadBase64,
247
+ signatures: [
248
+ { keyid: ksSigned.key_id, sig: sigBase64 },
249
+ ],
250
+ // Internal field: raw signManifest output for self-verifying with the key store.
251
+ _ks_sig: ksSigned,
252
+ };
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // verifyDsseEnvelope
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Verify a KnoSky DSSE provenance envelope against the key store.
261
+ *
262
+ * Checks:
263
+ * 1. The envelope is structurally valid (required fields present).
264
+ * 2. The payload decodes to valid JSON containing an in-toto Statement v1.
265
+ * 3. The `_ks_sig` internal token is valid (key present, not revoked, HMAC correct).
266
+ * 4. The PAE digest recorded in `_ks_sig.pae_sha256` matches the PAE of the
267
+ * current payload — ensuring the payload has not been swapped after signing.
268
+ *
269
+ * Returns `{ ok: true, statement }` on success, or
270
+ * `{ ok: false, reason: string }` on any failure. Never throws on malformed input.
271
+ *
272
+ * @param {{ keys: Map<string,object>, activeKeyId: string|null }} ks
273
+ * @param {object} envelope A signed DSSE envelope from {@link buildDsseEnvelope}.
274
+ * @returns {{ ok: boolean, statement?: object, reason?: string }}
275
+ */
276
+ export function verifyDsseEnvelope(ks, envelope) {
277
+ // (1) Structural check.
278
+ if (!envelope || typeof envelope !== 'object') {
279
+ return { ok: false, reason: 'envelope_not_object' };
280
+ }
281
+ const { payloadType, payload, _ks_sig } = envelope;
282
+ if (payloadType !== DSSE_PAYLOAD_TYPE) {
283
+ return { ok: false, reason: 'wrong_payload_type' };
284
+ }
285
+ if (typeof payload !== 'string' || payload.length === 0) {
286
+ return { ok: false, reason: 'missing_payload' };
287
+ }
288
+ if (!_ks_sig || typeof _ks_sig !== 'object') {
289
+ return { ok: false, reason: 'missing_ks_sig' };
290
+ }
291
+
292
+ // (2) Decode and parse the statement.
293
+ let payloadJson;
294
+ let statement;
295
+ try {
296
+ payloadJson = Buffer.from(payload, 'base64url').toString('utf8');
297
+ statement = JSON.parse(payloadJson);
298
+ } catch {
299
+ return { ok: false, reason: 'payload_not_valid_json' };
300
+ }
301
+ if (statement._type !== STATEMENT_TYPE) {
302
+ return { ok: false, reason: 'wrong_statement_type' };
303
+ }
304
+
305
+ // (3) Verify the _ks_sig token against the key store.
306
+ const ksResult = verifyManifest(ks, _ks_sig);
307
+ if (!ksResult.ok) {
308
+ return { ok: false, reason: `ks_sig_invalid:${ksResult.reason}` };
309
+ }
310
+
311
+ // (4) Check the PAE digest. Re-derive it from the current payload and
312
+ // compare against what was recorded when the envelope was built.
313
+ const pae = dssePreAuthEncoding(DSSE_PAYLOAD_TYPE, payloadJson);
314
+ const paeDigest = createHmac('sha256', Buffer.alloc(0)).update(pae).digest('hex');
315
+ if (_ks_sig.pae_sha256 !== paeDigest) {
316
+ return { ok: false, reason: 'pae_digest_mismatch' };
317
+ }
318
+
319
+ return { ok: true, statement };
320
+ }
package/core/retrieve.mjs CHANGED
@@ -1,63 +1,63 @@
1
- // KC retrieval lib (Phase 2a) — the shared seam both the MCP and the City read.
2
- // Navigation-scoped (where/what/how-connected + citations), NOT code-RAG. Pure, no deps.
3
- import fs from 'node:fs';
4
-
5
- export function load(cityPath) {
6
- const city = JSON.parse(fs.readFileSync(cityPath, 'utf8'));
7
- const byId = new Map((city.nodes || []).map(n => [n.id, n]));
8
- return { city, byId };
9
- }
10
- const tok = (s) => (String(s || '').toLowerCase().match(/[a-z0-9]+/g) || []);
11
-
12
- function fmt(n, score) {
13
- return { id: n.id, title: n.title, summary: n.summary, category: n.category, kind: n.kind, score, provenance: n.provenance };
14
- }
15
-
16
- // search: token scoring over title/headings/summary/tags/category. Returns ranked hits w/ provenance (citations).
17
- export function search(ctx, q, { limit = 10, category = null } = {}) {
18
- const qt = [...new Set(tok(q))];
19
- if (!qt.length) return [];
20
- const out = [];
21
- for (const n of ctx.city.nodes) {
22
- if (category && n.category !== category) continue;
23
- const title = new Set(tok(n.title)), heads = new Set(tok((n.headings || []).join(' ')));
24
- const summ = new Set(tok(n.summary)), tags = new Set((n.tags || []).map(x => String(x).toLowerCase()));
25
- const cat = new Set(tok(n.category));
26
- let score = 0;
27
- for (const t of qt) {
28
- if (title.has(t)) score += 5;
29
- if (heads.has(t)) score += 3;
30
- if (summ.has(t)) score += 2;
31
- if (tags.has(t)) score += 2;
32
- if (cat.has(t)) score += 1;
33
- }
34
- if (score > 0) out.push({ n, score });
35
- }
36
- out.sort((a, b) => b.score - a.score || String(a.n.id).localeCompare(String(b.n.id)));
37
- return out.slice(0, limit).map(r => fmt(r.n, r.score));
38
- }
39
-
40
- export function getNode(ctx, id) { const n = ctx.byId.get(id); return n ? fmt(n, null) : null; }
41
-
42
- export function listCategories(ctx) {
43
- const counts = {};
44
- for (const n of ctx.city.nodes) counts[n.category] = (counts[n.category] || 0) + 1;
45
- return (ctx.city.categories || []).map(c => ({ ...c, count: counts[c.id] || 0 }));
46
- }
47
-
48
- // provenance = the citation: where the live source is + what it links to.
49
- export function getProvenance(ctx, id) {
50
- const n = ctx.byId.get(id);
51
- if (!n) return null;
52
- return { id, title: n.title, provenance: n.provenance, links: n.links || [] };
53
- }
54
-
55
- // related = file connections (D-155): out-edges (imports) + in-edges (imported-by) + churn signal. File-level only.
56
- export function getRelated(ctx, id) {
57
- const n = ctx.byId.get(id);
58
- if (!n) return null;
59
- const ref = (m) => ({ id: m.id, title: m.title, source: m.provenance && m.provenance.ref });
60
- const imports = (n.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean).map(ref);
61
- const importedBy = ctx.city.nodes.filter(m => (m.links || []).includes(id)).map(ref);
62
- return { id, title: n.title, churn: n.churn || null, imports, importedBy };
63
- }
1
+ // KC retrieval lib (Phase 2a) — the shared seam both the MCP and the City read.
2
+ // Navigation-scoped (where/what/how-connected + citations), NOT code-RAG. Pure, no deps.
3
+ import fs from 'node:fs';
4
+
5
+ export function load(cityPath) {
6
+ const city = JSON.parse(fs.readFileSync(cityPath, 'utf8'));
7
+ const byId = new Map((city.nodes || []).map(n => [n.id, n]));
8
+ return { city, byId };
9
+ }
10
+ const tok = (s) => (String(s || '').toLowerCase().match(/[a-z0-9]+/g) || []);
11
+
12
+ function fmt(n, score) {
13
+ return { id: n.id, title: n.title, summary: n.summary, category: n.category, kind: n.kind, score, provenance: n.provenance };
14
+ }
15
+
16
+ // search: token scoring over title/headings/summary/tags/category. Returns ranked hits w/ provenance (citations).
17
+ export function search(ctx, q, { limit = 10, category = null } = {}) {
18
+ const qt = [...new Set(tok(q))];
19
+ if (!qt.length) return [];
20
+ const out = [];
21
+ for (const n of ctx.city.nodes) {
22
+ if (category && n.category !== category) continue;
23
+ const title = new Set(tok(n.title)), heads = new Set(tok((n.headings || []).join(' ')));
24
+ const summ = new Set(tok(n.summary)), tags = new Set((n.tags || []).map(x => String(x).toLowerCase()));
25
+ const cat = new Set(tok(n.category));
26
+ let score = 0;
27
+ for (const t of qt) {
28
+ if (title.has(t)) score += 5;
29
+ if (heads.has(t)) score += 3;
30
+ if (summ.has(t)) score += 2;
31
+ if (tags.has(t)) score += 2;
32
+ if (cat.has(t)) score += 1;
33
+ }
34
+ if (score > 0) out.push({ n, score });
35
+ }
36
+ out.sort((a, b) => b.score - a.score || String(a.n.id).localeCompare(String(b.n.id)));
37
+ return out.slice(0, limit).map(r => fmt(r.n, r.score));
38
+ }
39
+
40
+ export function getNode(ctx, id) { const n = ctx.byId.get(id); return n ? fmt(n, null) : null; }
41
+
42
+ export function listCategories(ctx) {
43
+ const counts = {};
44
+ for (const n of ctx.city.nodes) counts[n.category] = (counts[n.category] || 0) + 1;
45
+ return (ctx.city.categories || []).map(c => ({ ...c, count: counts[c.id] || 0 }));
46
+ }
47
+
48
+ // provenance = the citation: where the live source is + what it links to.
49
+ export function getProvenance(ctx, id) {
50
+ const n = ctx.byId.get(id);
51
+ if (!n) return null;
52
+ return { id, title: n.title, provenance: n.provenance, links: n.links || [] };
53
+ }
54
+
55
+ // related = file connections (D-155): out-edges (imports) + in-edges (imported-by) + churn signal. File-level only.
56
+ export function getRelated(ctx, id) {
57
+ const n = ctx.byId.get(id);
58
+ if (!n) return null;
59
+ const ref = (m) => ({ id: m.id, title: m.title, source: m.provenance && m.provenance.ref });
60
+ const imports = (n.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean).map(ref);
61
+ const importedBy = ctx.city.nodes.filter(m => (m.links || []).includes(id)).map(ref);
62
+ return { id, title: n.title, churn: n.churn || null, imports, importedBy };
63
+ }