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,218 +1,218 @@
1
- // KC fs indexer (Phase 1b): a real local folder -> contract v2. Deterministic, $0-token.
2
- // Pointers + projections ONLY (no file bodies). Dir-name categorizer (always-works fallback).
3
- // Honors IGNORE_DEFAULTS + .gitignore + .kcignore. Council fixes: allowlist+scrub via contract.serializeNode.
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { execSync } from 'node:child_process';
7
- import { SCHEMA_VERSION, IGNORE_DEFAULTS, deriveCategories, serializeNode, validateCity, setRedactTerms, findSecrets } from './contract.mjs';
8
- import { extractImportSpecifiers, resolveSpec } from './edges.mjs';
9
- import { gitChurn } from './churn.mjs';
10
- import { computeLedgerSeq } from './freshness.mjs';
11
-
12
- const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
13
- const ROOT = args.root && path.resolve(args.root);
14
- const OUT = args.out || null;
15
- // Validate --max: a non-numeric/zero/negative value must NOT silently disable the
16
- // cap (NaN comparisons are always false, so `scanned > MAX` would never trip) —
17
- // that's a resource-exhaustion opening on a hostile/malformed CI invocation.
18
- const MAX = (() => { const n = parseInt(args.max || '6000', 10); return (Number.isFinite(n) && n > 0) ? n : 6000; })();
19
- const SHARE_SAFE = process.argv.includes('--share-safe');
20
- const INCLUDE_ABS = process.argv.includes('--include-absolute-root');
21
- const ALLOW_LEAKS = process.argv.includes('--allow-leaks');
22
- const NO_GRAPH = process.argv.includes('--no-graph');
23
- const NO_CHURN = process.argv.includes('--no-churn');
24
- if (!ROOT || !fs.existsSync(ROOT)) { console.log('usage: node fs-indexer.mjs --root <dir> [--out <file>] [--max N] [--redact a,b]'); process.exit(1); }
25
- const REDACT = (args.redact || '').split(',').map(s => s.trim()).filter(Boolean);
26
- if (REDACT.length) setRedactTerms(REDACT);
27
- const REDACT_RX = REDACT.map(t => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i'));
28
-
29
- const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|swift|kt|sh|sql)$/i;
30
- const DOC = /\.(md|markdown|mdx|txt|rst|adoc)$/i;
31
- const TEXT_READABLE = /\.(md|markdown|mdx|txt|rst|adoc|ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|swift|kt|sh|sql|json|ya?ml|toml|ini|cfg)$/i;
32
-
33
- function gitRev(root) {
34
- try { return 'git:' + execSync('git rev-parse --short HEAD', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
35
- catch { return 'fs:' + new Date().toISOString().slice(0, 10); }
36
- }
37
- // Parse .gitignore/.kcignore into conservative matchers (dir names + basenames + *.ext).
38
- function loadIgnore(root) {
39
- const pats = [];
40
- for (const f of ['.gitignore', '.kcignore']) {
41
- try {
42
- for (let ln of fs.readFileSync(path.join(root, f), 'utf8').split(/\r?\n/)) {
43
- ln = ln.trim(); if (!ln || ln.startsWith('#') || ln.startsWith('!')) continue;
44
- ln = ln.replace(/^\/+/, '').replace(/\/+$/, '');
45
- if (!ln) continue;
46
- const rx = '(^|/)' + ln.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '(/|$)';
47
- pats.push(new RegExp(rx, 'i'));
48
- }
49
- } catch { /* no file */ }
50
- }
51
- return pats;
52
- }
53
- const REV = gitRev(ROOT);
54
- const IGN = [...IGNORE_DEFAULTS, ...loadIgnore(ROOT)];
55
- const ignored = (rel) => IGN.some(re => re.test(rel));
56
-
57
- const flags = [];
58
- let scanned = 0, capped = false, redactedPaths = 0;
59
- const raw = []; // pre-serialize nodes
60
-
61
- function excerptAndHeadings(fp, isDoc) {
62
- let txt = '';
63
- try { const fd = fs.openSync(fp, 'r'); const buf = Buffer.alloc(4096); const n = fs.readSync(fd, buf, 0, 4096, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
64
- catch { return { title: null, summary: '', headings: [] }; }
65
- let title = null; const headings = [];
66
- if (isDoc) {
67
- const h1 = txt.match(/^#\s+(.+)$/m); if (h1) title = h1[1].trim();
68
- for (const m of txt.matchAll(/^#{2,3}\s+(.+)$/gm)) { headings.push(m[1].trim()); if (headings.length >= 8) break; }
69
- }
70
- // first prose line: skip frontmatter + headings/code fences
71
- const prose = txt.replace(/^---[\s\S]*?---/, '').split(/\r?\n/).map(s => s.trim())
72
- .find(s => s && !s.startsWith('#') && !s.startsWith('```') && !s.startsWith('|') && !s.startsWith('<')) || '';
73
- return { title, summary: prose, headings };
74
- }
75
-
76
- function walk(dir, depth) {
77
- if (capped) return;
78
- let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
79
- for (const e of ents) {
80
- if (capped) return;
81
- const fp = path.join(dir, e.name);
82
- const rel = path.relative(ROOT, fp).split(path.sep).join('/');
83
- if (ignored(rel)) continue;
84
- if (e.isDirectory()) { if (depth < 12) walk(fp, depth + 1); continue; }
85
- if (!e.isFile()) continue;
86
- if (REDACT_RX.some(re => re.test(rel))) { redactedPaths++; continue; }
87
- scanned++;
88
- if (scanned > MAX) { capped = true; flags.push(`capped at ${MAX} files`); return; }
89
- const parts = rel.split('/');
90
- const category = parts.length > 1 ? parts[0] : '(root)';
91
- const ext = path.extname(rel);
92
- const kind = DOC.test(rel) ? 'doc' : CODE.test(rel) ? 'code' : 'file';
93
- const readable = TEXT_READABLE.test(rel);
94
- const { title, summary, headings } = readable ? excerptAndHeadings(fp, DOC.test(rel)) : { title: null, summary: '', headings: [] };
95
- raw.push({
96
- id: 'fs:' + rel,
97
- kind,
98
- title: title || parts[parts.length - 1],
99
- summary,
100
- category,
101
- status: 'present',
102
- tags: ext ? [ext.slice(1)] : [],
103
- headings,
104
- links: [],
105
- importSpecs: (!NO_GRAPH && readable) ? extractImportSpecifiers(fp) : [],
106
- provenance: { store: 'fs', ref: rel, source_rev: REV, fetched_at: new Date().toISOString() },
107
- visibility: 'internal',
108
- });
109
- }
110
- }
111
-
112
- walk(ROOT, 0);
113
-
114
- // Accurate ignore inside a git repo: let git decide (catches .gitignore patterns the conservative parser misses).
115
- try {
116
- const NL = String.fromCharCode(10);
117
- const rels = raw.map(n => n.provenance.ref);
118
- if (rels.length) {
119
- let ignoredOut = '';
120
- try { ignoredOut = execSync('git check-ignore --stdin', { cwd: ROOT, input: rels.join(NL), encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); }
121
- catch (e) { ignoredOut = (e && e.stdout) ? String(e.stdout) : ''; } // exit 1 (none ignored) / 128 (not a repo) -> empty
122
- const gi = new Set(ignoredOut.split(NL).map(s => s.trim()).filter(Boolean));
123
- if (gi.size) for (let i = raw.length - 1; i >= 0; i--) if (gi.has(raw[i].provenance.ref)) raw.splice(i, 1);
124
- }
125
- } catch (e) { /* git unavailable: keep the conservative-parser result */ }
126
-
127
- // File Connections (D-155): resolve import specifiers to indexed files; store ONLY file-to-file edges.
128
- const relSet = new Set(raw.map(n => n.provenance.ref));
129
- if (!NO_GRAPH) {
130
- for (const n of raw) {
131
- const targets = new Set();
132
- for (const spec of (n.importSpecs || [])) {
133
- const t = resolveSpec(n.provenance.ref, spec, relSet);
134
- if (t) { const id = 'fs:' + t; if (id !== n.id) targets.add(id); }
135
- }
136
- n.links = [...targets];
137
- }
138
- }
139
- for (const n of raw) delete n.importSpecs; // discard specifiers; only the resolved file-to-file edge survives
140
-
141
- // Churn overlay (D-155): per-file commit count + last-commit ts only (no messages/diffs/authors/lines).
142
- if (!NO_CHURN) {
143
- const ch = gitChurn(ROOT);
144
- for (const n of raw) {
145
- const c = ch.counts[n.provenance.ref] || 0;
146
- if (c > 0) n.churn = { c, t: ch.last[n.provenance.ref] || 0, b: c >= 6 ? 3 : c >= 3 ? 2 : 1 };
147
- }
148
- }
149
-
150
- // --- fail-closed secret scan on RAW projections, BEFORE scrubbing (the decisive boundary).
151
- // Scans exactly the text that will be emitted (title/summary/headings/tags + relative path) in
152
- // its un-scrubbed form, so detection cannot be defeated by serializeNode()'s scrub. ---
153
- const _NL = String.fromCharCode(10);
154
- const rawText = raw.map(n => [
155
- n.title, n.summary,
156
- (n.headings || []).join(_NL),
157
- (n.tags || []).join(' '),
158
- n.provenance && n.provenance.ref,
159
- ].filter(Boolean).join(_NL)).join(_NL);
160
- const preHits = findSecrets(rawText);
161
- const preTotal = preHits.reduce((a, h) => a + h[1], 0);
162
- const preList = preHits.map(h => h[0] + ':' + h[1]).join(', ');
163
-
164
- const nodes = raw.map(serializeNode);
165
- const catIds = [...new Set(nodes.map(n => n.category))].sort();
166
- const categories = deriveCategories(catIds);
167
- // Ledger-anchored freshness (SAT-444): monotone commit count, used as a
168
- // replay-resistant sequence number. Consumers compare against the last
169
- // accepted ledger_seq (V13 high-water-mark guard) to detect rollbacks.
170
- const ledger_seq = computeLedgerSeq(ROOT);
171
- const city = {
172
- schema_version: SCHEMA_VERSION,
173
- generated_at: new Date().toISOString(),
174
- source: { kind: 'fs', ref: INCLUDE_ABS ? ROOT : path.basename(ROOT), rev: REV },
175
- ledger_seq,
176
- categories, node_count: nodes.length, nodes,
177
- };
178
-
179
- const res = validateCity(city);
180
- const dist = {}; for (const n of nodes) dist[n.category] = (dist[n.category] || 0) + 1;
181
-
182
- // leakage audit: scan emitted projections for secret-ish patterns + employer strings
183
- const blob = JSON.stringify(nodes);
184
- const leakHits = (blob.match(/\b(AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)\b/g) || []).length
185
- + (blob.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) || []).length;
186
-
187
- console.log('--- fs index:', INCLUDE_ABS ? ROOT : path.basename(ROOT), '---');
188
- console.log('rev', REV, '| scanned files:', scanned, '| nodes:', nodes.length, '| categories:', catIds.length);
189
- console.log('byKind:', JSON.stringify(nodes.reduce((a, n) => (a[n.kind] = (a[n.kind] || 0) + 1, a), {})));
190
- console.log('top categories:', Object.entries(dist).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `${k}:${v}`).join(', '));
191
- console.log('VALID:', res.ok, res.ok ? '' : '\n - ' + res.errors.slice(0, 8).join('\n - '));
192
- console.log('post-scrub leak hits (emails/keys, should be ~0):', leakHits);
193
- console.log('redacted-path files skipped:', redactedPaths);
194
- if (flags.length) console.log('flags:', flags.join('; '));
195
-
196
- // --- fail-closed safe-share audit.
197
- // PRIMARY = pre-scrub detection (preTotal, computed above) -> the real boundary.
198
- // SECONDARY = post-scrub residual on the emitted artifact -> flags scrub gaps (defense in depth). ---
199
- const residualHits = findSecrets(blob);
200
- const residualTotal = residualHits.reduce((a, h) => a + h[1], 0);
201
- const residualList = residualHits.map(h => h[0] + ':' + h[1]).join(', ');
202
- if (SHARE_SAFE) {
203
- console.log('');
204
- console.log('KnoSky safety report');
205
- console.log('- Files scanned:', scanned);
206
- console.log('- Files skipped (redact-path):', redactedPaths);
207
- console.log('- Secrets found (pre-scrub):', preTotal, preTotal ? '[' + preList + ']' : '');
208
- console.log('- Residual after scrub (should be 0):', residualTotal, residualTotal ? '[' + residualList + ']' : '');
209
- console.log('- Absolute path in output:', INCLUDE_ABS ? 'INCLUDED' : 'removed (basename only)');
210
- console.log('- Risk level:', (preTotal || residualTotal) ? 'HIGH' : 'Low');
211
- }
212
- if ((preTotal || residualTotal) && !ALLOW_LEAKS) {
213
- const why = preTotal ? preList : residualList;
214
- console.error('BLOCKED: ' + (preTotal || residualTotal) + ' secret-like value(s) detected [' + why + ']. Nothing written.');
215
- console.error('Add the offending paths to .kcignore or pass --redact <term>, then rebuild. Override with --allow-leaks (not recommended).');
216
- process.exit(1);
217
- }
218
- if (OUT) { fs.writeFileSync(OUT, JSON.stringify(city, null, 2) + '\n'); console.log('wrote', OUT); }
1
+ // KC fs indexer (Phase 1b): a real local folder -> contract v2. Deterministic, $0-token.
2
+ // Pointers + projections ONLY (no file bodies). Dir-name categorizer (always-works fallback).
3
+ // Honors IGNORE_DEFAULTS + .gitignore + .kcignore. Council fixes: allowlist+scrub via contract.serializeNode.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { execSync } from 'node:child_process';
7
+ import { SCHEMA_VERSION, IGNORE_DEFAULTS, deriveCategories, serializeNode, validateCity, setRedactTerms, findSecrets } from './contract.mjs';
8
+ import { extractImportSpecifiers, resolveSpec } from './edges.mjs';
9
+ import { gitChurn } from './churn.mjs';
10
+ import { computeLedgerSeq } from './freshness.mjs';
11
+
12
+ const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
13
+ const ROOT = args.root && path.resolve(args.root);
14
+ const OUT = args.out || null;
15
+ // Validate --max: a non-numeric/zero/negative value must NOT silently disable the
16
+ // cap (NaN comparisons are always false, so `scanned > MAX` would never trip) —
17
+ // that's a resource-exhaustion opening on a hostile/malformed CI invocation.
18
+ const MAX = (() => { const n = parseInt(args.max || '6000', 10); return (Number.isFinite(n) && n > 0) ? n : 6000; })();
19
+ const SHARE_SAFE = process.argv.includes('--share-safe');
20
+ const INCLUDE_ABS = process.argv.includes('--include-absolute-root');
21
+ const ALLOW_LEAKS = process.argv.includes('--allow-leaks');
22
+ const NO_GRAPH = process.argv.includes('--no-graph');
23
+ const NO_CHURN = process.argv.includes('--no-churn');
24
+ if (!ROOT || !fs.existsSync(ROOT)) { console.log('usage: node fs-indexer.mjs --root <dir> [--out <file>] [--max N] [--redact a,b]'); process.exit(1); }
25
+ const REDACT = (args.redact || '').split(',').map(s => s.trim()).filter(Boolean);
26
+ if (REDACT.length) setRedactTerms(REDACT);
27
+ const REDACT_RX = REDACT.map(t => new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i'));
28
+
29
+ const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|swift|kt|sh|sql)$/i;
30
+ const DOC = /\.(md|markdown|mdx|txt|rst|adoc)$/i;
31
+ const TEXT_READABLE = /\.(md|markdown|mdx|txt|rst|adoc|ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|php|c|cc|cpp|h|hpp|cs|swift|kt|sh|sql|json|ya?ml|toml|ini|cfg)$/i;
32
+
33
+ function gitRev(root) {
34
+ try { return 'git:' + execSync('git rev-parse --short HEAD', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
35
+ catch { return 'fs:' + new Date().toISOString().slice(0, 10); }
36
+ }
37
+ // Parse .gitignore/.kcignore into conservative matchers (dir names + basenames + *.ext).
38
+ function loadIgnore(root) {
39
+ const pats = [];
40
+ for (const f of ['.gitignore', '.kcignore']) {
41
+ try {
42
+ for (let ln of fs.readFileSync(path.join(root, f), 'utf8').split(/\r?\n/)) {
43
+ ln = ln.trim(); if (!ln || ln.startsWith('#') || ln.startsWith('!')) continue;
44
+ ln = ln.replace(/^\/+/, '').replace(/\/+$/, '');
45
+ if (!ln) continue;
46
+ const rx = '(^|/)' + ln.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '(/|$)';
47
+ pats.push(new RegExp(rx, 'i'));
48
+ }
49
+ } catch { /* no file */ }
50
+ }
51
+ return pats;
52
+ }
53
+ const REV = gitRev(ROOT);
54
+ const IGN = [...IGNORE_DEFAULTS, ...loadIgnore(ROOT)];
55
+ const ignored = (rel) => IGN.some(re => re.test(rel));
56
+
57
+ const flags = [];
58
+ let scanned = 0, capped = false, redactedPaths = 0;
59
+ const raw = []; // pre-serialize nodes
60
+
61
+ function excerptAndHeadings(fp, isDoc) {
62
+ let txt = '';
63
+ try { const fd = fs.openSync(fp, 'r'); const buf = Buffer.alloc(4096); const n = fs.readSync(fd, buf, 0, 4096, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
64
+ catch { return { title: null, summary: '', headings: [] }; }
65
+ let title = null; const headings = [];
66
+ if (isDoc) {
67
+ const h1 = txt.match(/^#\s+(.+)$/m); if (h1) title = h1[1].trim();
68
+ for (const m of txt.matchAll(/^#{2,3}\s+(.+)$/gm)) { headings.push(m[1].trim()); if (headings.length >= 8) break; }
69
+ }
70
+ // first prose line: skip frontmatter + headings/code fences
71
+ const prose = txt.replace(/^---[\s\S]*?---/, '').split(/\r?\n/).map(s => s.trim())
72
+ .find(s => s && !s.startsWith('#') && !s.startsWith('```') && !s.startsWith('|') && !s.startsWith('<')) || '';
73
+ return { title, summary: prose, headings };
74
+ }
75
+
76
+ function walk(dir, depth) {
77
+ if (capped) return;
78
+ let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
79
+ for (const e of ents) {
80
+ if (capped) return;
81
+ const fp = path.join(dir, e.name);
82
+ const rel = path.relative(ROOT, fp).split(path.sep).join('/');
83
+ if (ignored(rel)) continue;
84
+ if (e.isDirectory()) { if (depth < 12) walk(fp, depth + 1); continue; }
85
+ if (!e.isFile()) continue;
86
+ if (REDACT_RX.some(re => re.test(rel))) { redactedPaths++; continue; }
87
+ scanned++;
88
+ if (scanned > MAX) { capped = true; flags.push(`capped at ${MAX} files`); return; }
89
+ const parts = rel.split('/');
90
+ const category = parts.length > 1 ? parts[0] : '(root)';
91
+ const ext = path.extname(rel);
92
+ const kind = DOC.test(rel) ? 'doc' : CODE.test(rel) ? 'code' : 'file';
93
+ const readable = TEXT_READABLE.test(rel);
94
+ const { title, summary, headings } = readable ? excerptAndHeadings(fp, DOC.test(rel)) : { title: null, summary: '', headings: [] };
95
+ raw.push({
96
+ id: 'fs:' + rel,
97
+ kind,
98
+ title: title || parts[parts.length - 1],
99
+ summary,
100
+ category,
101
+ status: 'present',
102
+ tags: ext ? [ext.slice(1)] : [],
103
+ headings,
104
+ links: [],
105
+ importSpecs: (!NO_GRAPH && readable) ? extractImportSpecifiers(fp) : [],
106
+ provenance: { store: 'fs', ref: rel, source_rev: REV, fetched_at: new Date().toISOString() },
107
+ visibility: 'internal',
108
+ });
109
+ }
110
+ }
111
+
112
+ walk(ROOT, 0);
113
+
114
+ // Accurate ignore inside a git repo: let git decide (catches .gitignore patterns the conservative parser misses).
115
+ try {
116
+ const NL = String.fromCharCode(10);
117
+ const rels = raw.map(n => n.provenance.ref);
118
+ if (rels.length) {
119
+ let ignoredOut = '';
120
+ try { ignoredOut = execSync('git check-ignore --stdin', { cwd: ROOT, input: rels.join(NL), encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }); }
121
+ catch (e) { ignoredOut = (e && e.stdout) ? String(e.stdout) : ''; } // exit 1 (none ignored) / 128 (not a repo) -> empty
122
+ const gi = new Set(ignoredOut.split(NL).map(s => s.trim()).filter(Boolean));
123
+ if (gi.size) for (let i = raw.length - 1; i >= 0; i--) if (gi.has(raw[i].provenance.ref)) raw.splice(i, 1);
124
+ }
125
+ } catch (e) { /* git unavailable: keep the conservative-parser result */ }
126
+
127
+ // File Connections (D-155): resolve import specifiers to indexed files; store ONLY file-to-file edges.
128
+ const relSet = new Set(raw.map(n => n.provenance.ref));
129
+ if (!NO_GRAPH) {
130
+ for (const n of raw) {
131
+ const targets = new Set();
132
+ for (const spec of (n.importSpecs || [])) {
133
+ const t = resolveSpec(n.provenance.ref, spec, relSet);
134
+ if (t) { const id = 'fs:' + t; if (id !== n.id) targets.add(id); }
135
+ }
136
+ n.links = [...targets];
137
+ }
138
+ }
139
+ for (const n of raw) delete n.importSpecs; // discard specifiers; only the resolved file-to-file edge survives
140
+
141
+ // Churn overlay (D-155): per-file commit count + last-commit ts only (no messages/diffs/authors/lines).
142
+ if (!NO_CHURN) {
143
+ const ch = gitChurn(ROOT);
144
+ for (const n of raw) {
145
+ const c = ch.counts[n.provenance.ref] || 0;
146
+ if (c > 0) n.churn = { c, t: ch.last[n.provenance.ref] || 0, b: c >= 6 ? 3 : c >= 3 ? 2 : 1 };
147
+ }
148
+ }
149
+
150
+ // --- fail-closed secret scan on RAW projections, BEFORE scrubbing (the decisive boundary).
151
+ // Scans exactly the text that will be emitted (title/summary/headings/tags + relative path) in
152
+ // its un-scrubbed form, so detection cannot be defeated by serializeNode()'s scrub. ---
153
+ const _NL = String.fromCharCode(10);
154
+ const rawText = raw.map(n => [
155
+ n.title, n.summary,
156
+ (n.headings || []).join(_NL),
157
+ (n.tags || []).join(' '),
158
+ n.provenance && n.provenance.ref,
159
+ ].filter(Boolean).join(_NL)).join(_NL);
160
+ const preHits = findSecrets(rawText);
161
+ const preTotal = preHits.reduce((a, h) => a + h[1], 0);
162
+ const preList = preHits.map(h => h[0] + ':' + h[1]).join(', ');
163
+
164
+ const nodes = raw.map(serializeNode);
165
+ const catIds = [...new Set(nodes.map(n => n.category))].sort();
166
+ const categories = deriveCategories(catIds);
167
+ // Ledger-anchored freshness (SAT-444): monotone commit count, used as a
168
+ // replay-resistant sequence number. Consumers compare against the last
169
+ // accepted ledger_seq (V13 high-water-mark guard) to detect rollbacks.
170
+ const ledger_seq = computeLedgerSeq(ROOT);
171
+ const city = {
172
+ schema_version: SCHEMA_VERSION,
173
+ generated_at: new Date().toISOString(),
174
+ source: { kind: 'fs', ref: INCLUDE_ABS ? ROOT : path.basename(ROOT), rev: REV },
175
+ ledger_seq,
176
+ categories, node_count: nodes.length, nodes,
177
+ };
178
+
179
+ const res = validateCity(city);
180
+ const dist = {}; for (const n of nodes) dist[n.category] = (dist[n.category] || 0) + 1;
181
+
182
+ // leakage audit: scan emitted projections for secret-ish patterns + employer strings
183
+ const blob = JSON.stringify(nodes);
184
+ const leakHits = (blob.match(/\b(AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)\b/g) || []).length
185
+ + (blob.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g) || []).length;
186
+
187
+ console.log('--- fs index:', INCLUDE_ABS ? ROOT : path.basename(ROOT), '---');
188
+ console.log('rev', REV, '| scanned files:', scanned, '| nodes:', nodes.length, '| categories:', catIds.length);
189
+ console.log('byKind:', JSON.stringify(nodes.reduce((a, n) => (a[n.kind] = (a[n.kind] || 0) + 1, a), {})));
190
+ console.log('top categories:', Object.entries(dist).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `${k}:${v}`).join(', '));
191
+ console.log('VALID:', res.ok, res.ok ? '' : '\n - ' + res.errors.slice(0, 8).join('\n - '));
192
+ console.log('post-scrub leak hits (emails/keys, should be ~0):', leakHits);
193
+ console.log('redacted-path files skipped:', redactedPaths);
194
+ if (flags.length) console.log('flags:', flags.join('; '));
195
+
196
+ // --- fail-closed safe-share audit.
197
+ // PRIMARY = pre-scrub detection (preTotal, computed above) -> the real boundary.
198
+ // SECONDARY = post-scrub residual on the emitted artifact -> flags scrub gaps (defense in depth). ---
199
+ const residualHits = findSecrets(blob);
200
+ const residualTotal = residualHits.reduce((a, h) => a + h[1], 0);
201
+ const residualList = residualHits.map(h => h[0] + ':' + h[1]).join(', ');
202
+ if (SHARE_SAFE) {
203
+ console.log('');
204
+ console.log('KnoSky safety report');
205
+ console.log('- Files scanned:', scanned);
206
+ console.log('- Files skipped (redact-path):', redactedPaths);
207
+ console.log('- Secrets found (pre-scrub):', preTotal, preTotal ? '[' + preList + ']' : '');
208
+ console.log('- Residual after scrub (should be 0):', residualTotal, residualTotal ? '[' + residualList + ']' : '');
209
+ console.log('- Absolute path in output:', INCLUDE_ABS ? 'INCLUDED' : 'removed (basename only)');
210
+ console.log('- Risk level:', (preTotal || residualTotal) ? 'HIGH' : 'Low');
211
+ }
212
+ if ((preTotal || residualTotal) && !ALLOW_LEAKS) {
213
+ const why = preTotal ? preList : residualList;
214
+ console.error('BLOCKED: ' + (preTotal || residualTotal) + ' secret-like value(s) detected [' + why + ']. Nothing written.');
215
+ console.error('Add the offending paths to .kcignore or pass --redact <term>, then rebuild. Override with --allow-leaks (not recommended).');
216
+ process.exit(1);
217
+ }
218
+ if (OUT) { fs.writeFileSync(OUT, JSON.stringify(city, null, 2) + '\n'); console.log('wrote', OUT); }