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
package/core/ci.mjs CHANGED
@@ -1,268 +1,268 @@
1
- // KnoSky CI artifact generator (KSV2-CI1) — PR-GPS advisory artifact.
2
- // Pure Node stdlib + node:child_process (git, args-array only) + internal imports.
3
- // HARD RULE: exit 0 by default. Only --fail-on-secret can cause exit 1, and only
4
- // when secrets are found in the emitted artifacts. Never throws, never breaks builds.
5
- import { execFileSync } from 'node:child_process';
6
- import { load } from './retrieve.mjs';
7
- import { kcRoute } from './route.mjs';
8
- import { findSecrets } from './contract.mjs';
9
-
10
- // ---------------------------------------------------------------------------
11
- // Path-safety guard (mirrors the one in route.mjs)
12
- // ---------------------------------------------------------------------------
13
-
14
- function isSafePath(p) {
15
- if (!p || typeof p !== 'string') return false;
16
- if (p.startsWith('/')) return false;
17
- if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
18
- if (p.split(/[/\\]/).some(seg => seg === '..')) return false;
19
- return true;
20
- }
21
-
22
- // ---------------------------------------------------------------------------
23
- // Git helper — returns changed file list or [] on any error (advisory, never throws)
24
- // ---------------------------------------------------------------------------
25
-
26
- // A conservative git-ref allowlist: SHAs, branch/tag names, and relative refs
27
- // (HEAD, HEAD~1, HEAD^). Must NOT start with '-' — that's the belt-and-suspenders
28
- // layer that stops a ref string looking like a CLI flag (e.g. "--output=...")
29
- // from ever being handed to git, even before --end-of-options runs.
30
- function isSafeGitRef(ref) {
31
- if (!ref || typeof ref !== 'string') return false;
32
- if (ref.length > 200) return false;
33
- return /^[A-Za-z0-9][A-Za-z0-9._/\-~^:]*$/.test(ref);
34
- }
35
-
36
- function gitChangedFiles(root, base, head) {
37
- // Refuse to shell out at all if either ref doesn't look like a ref (layer 1).
38
- if (!isSafeGitRef(base) || !isSafeGitRef(head)) return [];
39
- try {
40
- // --end-of-options (layer 2, git >= 2.24) tells git's option parser that
41
- // everything after is a revision, never an option — closes the residual
42
- // "ref value that happens to start with '-'" injection even though
43
- // execFileSync's args-array already rules out shell injection.
44
- const output = execFileSync(
45
- 'git',
46
- ['-C', root, 'diff', '--name-only', '--end-of-options', base + '...' + head],
47
- { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
48
- );
49
- return output.split('\n').map(l => l.trim()).filter(Boolean);
50
- } catch (_) {
51
- // Any git error → advisory empty set, never break the build
52
- return [];
53
- }
54
- }
55
-
56
- // ---------------------------------------------------------------------------
57
- // summaryMd builder
58
- // ---------------------------------------------------------------------------
59
-
60
- function buildSummaryMd(generatedAt, base, head, routes) {
61
- const lines = [];
62
- lines.push('## KnoSky PR-GPS — Advisory Navigation Report');
63
- lines.push('');
64
- lines.push('> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.');
65
- lines.push('');
66
- lines.push(`- Generated: ${generatedAt}`);
67
- if (base) lines.push(`- Base: \`${base}\``);
68
- if (head) lines.push(`- Head: \`${head}\``);
69
- lines.push('');
70
-
71
- if (routes.length === 0) {
72
- lines.push('_No changed files detected in this PR._');
73
- } else {
74
- for (const { file, route: routeDoc } of routes) {
75
- lines.push(`### \`${file}\``);
76
- lines.push('');
77
- const topWaypoints = (routeDoc.route || []).slice(0, 5);
78
- if (topWaypoints.length === 0) {
79
- lines.push('_No route waypoints found in the index for this file._');
80
- } else {
81
- lines.push('**Route waypoints:**');
82
- lines.push('');
83
- for (const wp of topWaypoints) {
84
- const path = typeof wp === 'string' ? wp : (wp && wp.path) || '';
85
- const reason = (wp && wp.reason) || '';
86
- lines.push(`- \`${path}\`${reason ? ' — ' + reason : ''}`);
87
- }
88
- }
89
- const confidence = typeof routeDoc.confidence === 'number'
90
- ? Math.round(routeDoc.confidence * 100) + '%'
91
- : 'n/a';
92
- lines.push('');
93
- lines.push(`**Confidence:** ${confidence}`);
94
- const caveats = (routeDoc.caveats || []).filter(c =>
95
- c.includes('recently changed') || c.includes('stale') || c.includes('coverage'),
96
- );
97
- if (caveats.length > 0) {
98
- lines.push('');
99
- lines.push('**Caveats:**');
100
- for (const c of caveats) lines.push(`- ${c}`);
101
- }
102
- lines.push('');
103
- }
104
- }
105
-
106
- lines.push('---');
107
- lines.push('_This report is advisory. KnoSky reads metadata only — it does not read code meaning._');
108
- return lines.join('\n');
109
- }
110
-
111
- // ---------------------------------------------------------------------------
112
- // knoskyCi — main export
113
- // ---------------------------------------------------------------------------
114
-
115
- /**
116
- * Generate the KnoSky PR-GPS advisory artifacts.
117
- *
118
- * @param {object} [opts]
119
- * @param {string} [opts.root='.'] Repo root for git commands.
120
- * @param {string} [opts.base] Base ref for git diff.
121
- * @param {string} [opts.head] Head ref for git diff.
122
- * @param {string} [opts.cityPath] Path to city-data.json (city index).
123
- * @param {boolean} [opts.failOnSecret=false] Exit 1 only if secrets found in artifacts.
124
- * @param {string[]} [opts.changedFiles] Inject changed files directly (test seam — skips git).
125
- * @returns {{ exitCode: number, summaryMd: string, routeJson: object, safetyJson: object }}
126
- */
127
- export async function knoskyCi({
128
- root = '.',
129
- base,
130
- head,
131
- cityPath,
132
- failOnSecret = false,
133
- changedFiles,
134
- } = {}) {
135
- const generatedAt = new Date().toISOString();
136
-
137
- // 1. Resolve changed files
138
- let files;
139
- if (Array.isArray(changedFiles)) {
140
- // Test seam — use injected list directly, no git needed
141
- files = changedFiles;
142
- } else {
143
- // Resolve base/head from args, then environment, then fallback
144
- const resolvedBase = base || process.env.GITHUB_BASE_REF || 'HEAD~1';
145
- const resolvedHead = head || process.env.GITHUB_SHA || 'HEAD';
146
- files = gitChangedFiles(root, resolvedBase, resolvedHead);
147
- }
148
-
149
- const resolvedBase = base || process.env.GITHUB_BASE_REF || null;
150
- const resolvedHead = head || process.env.GITHUB_SHA || null;
151
-
152
- // 2. Load the city index — if absent/unreadable, produce advisory summary and exit 0
153
- let ctx;
154
- if (!cityPath) {
155
- const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
156
- return {
157
- exitCode: 0,
158
- summaryMd,
159
- routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
160
- safetyJson: buildSafetyJson(generatedAt, 0),
161
- };
162
- }
163
-
164
- try {
165
- ctx = load(cityPath);
166
- } catch (_) {
167
- const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
168
- return {
169
- exitCode: 0,
170
- summaryMd,
171
- routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
172
- safetyJson: buildSafetyJson(generatedAt, 0),
173
- };
174
- }
175
-
176
- // 3. For each changed file, call kcRoute — metadata only, no file bodies
177
- const routes = [];
178
- for (const relpath of files) {
179
- // Safety: skip any path that escapes the repo
180
- if (!isSafePath(relpath)) continue;
181
- let routeDoc;
182
- try {
183
- routeDoc = kcRoute(ctx, 'file:' + relpath);
184
- } catch (_) {
185
- // Route errors are advisory — skip this file, never break
186
- continue;
187
- }
188
- // Drop any route entries with unsafe paths (defense in depth)
189
- if (routeDoc && Array.isArray(routeDoc.route)) {
190
- routeDoc.route = routeDoc.route.filter(wp => {
191
- const p = typeof wp === 'string' ? wp : (wp && wp.path);
192
- return !p || isSafePath(p);
193
- });
194
- }
195
- if (routeDoc && Array.isArray(routeDoc.alternates)) {
196
- routeDoc.alternates = routeDoc.alternates.filter(wp => {
197
- const p = typeof wp === 'string' ? wp : (wp && wp.path);
198
- return !p || isSafePath(p);
199
- });
200
- }
201
- routes.push({ file: relpath, route: routeDoc });
202
- }
203
-
204
- // 4. Build routeJson
205
- const routeJson = buildRouteJson(generatedAt, resolvedBase, resolvedHead, routes);
206
-
207
- // 5. Build summaryMd
208
- const summaryMd = buildSummaryMd(generatedAt, resolvedBase, resolvedHead, routes);
209
-
210
- // 6. Scan ONLY the emitted artifacts for secrets (defense in depth)
211
- const artifactText = summaryMd + '\n' + JSON.stringify(routeJson);
212
- const secretHits = findSecrets(artifactText);
213
- const secretsFound = secretHits.reduce((n, [, count]) => n + count, 0);
214
-
215
- // 7. Build safetyJson
216
- const safetyJson = buildSafetyJson(generatedAt, secretsFound);
217
-
218
- // 8. exitCode: only failOnSecret+secrets_found > 0 can be non-zero
219
- const exitCode = (failOnSecret && secretsFound > 0) ? 1 : 0;
220
-
221
- return { exitCode, summaryMd, routeJson, safetyJson };
222
- }
223
-
224
- // ---------------------------------------------------------------------------
225
- // Artifact builders
226
- // ---------------------------------------------------------------------------
227
-
228
- function buildNoIndexSummary(generatedAt, base, head) {
229
- const lines = [
230
- '## KnoSky PR-GPS — Advisory Navigation Report',
231
- '',
232
- '> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.',
233
- '',
234
- `- Generated: ${generatedAt}`,
235
- ];
236
- if (base) lines.push(`- Base: \`${base}\``);
237
- if (head) lines.push(`- Head: \`${head}\``);
238
- lines.push('');
239
- lines.push('_No index available — run `knosky <path>` to build the city index first._');
240
- lines.push('');
241
- lines.push('---');
242
- lines.push('_This report is advisory. KnoSky reads metadata only._');
243
- return lines.join('\n');
244
- }
245
-
246
- function buildRouteJson(generatedAt, base, head, routes) {
247
- return {
248
- knosky_protocol: '1.0',
249
- artifact_type: 'pr-route',
250
- advisory: true,
251
- generated_at: generatedAt,
252
- base: base || null,
253
- head: head || null,
254
- routes: routes.map(({ file, route }) => ({ file, route })),
255
- };
256
- }
257
-
258
- function buildSafetyJson(generatedAt, secretsFound) {
259
- return {
260
- knosky_protocol: '1.0',
261
- artifact_type: 'safety-report',
262
- advisory: true,
263
- generated_at: generatedAt,
264
- absolute_paths: false,
265
- secrets_found: secretsFound,
266
- redaction: 'metadata-only; no file bodies; no absolute paths',
267
- };
268
- }
1
+ // KnoSky CI artifact generator (KSV2-CI1) — PR-GPS advisory artifact.
2
+ // Pure Node stdlib + node:child_process (git, args-array only) + internal imports.
3
+ // HARD RULE: exit 0 by default. Only --fail-on-secret can cause exit 1, and only
4
+ // when secrets are found in the emitted artifacts. Never throws, never breaks builds.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { load } from './retrieve.mjs';
7
+ import { kcRoute } from './route.mjs';
8
+ import { findSecrets } from './contract.mjs';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Path-safety guard (mirrors the one in route.mjs)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function isSafePath(p) {
15
+ if (!p || typeof p !== 'string') return false;
16
+ if (p.startsWith('/')) return false;
17
+ if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
18
+ if (p.split(/[/\\]/).some(seg => seg === '..')) return false;
19
+ return true;
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Git helper — returns changed file list or [] on any error (advisory, never throws)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ // A conservative git-ref allowlist: SHAs, branch/tag names, and relative refs
27
+ // (HEAD, HEAD~1, HEAD^). Must NOT start with '-' — that's the belt-and-suspenders
28
+ // layer that stops a ref string looking like a CLI flag (e.g. "--output=...")
29
+ // from ever being handed to git, even before --end-of-options runs.
30
+ function isSafeGitRef(ref) {
31
+ if (!ref || typeof ref !== 'string') return false;
32
+ if (ref.length > 200) return false;
33
+ return /^[A-Za-z0-9][A-Za-z0-9._/\-~^:]*$/.test(ref);
34
+ }
35
+
36
+ function gitChangedFiles(root, base, head) {
37
+ // Refuse to shell out at all if either ref doesn't look like a ref (layer 1).
38
+ if (!isSafeGitRef(base) || !isSafeGitRef(head)) return [];
39
+ try {
40
+ // --end-of-options (layer 2, git >= 2.24) tells git's option parser that
41
+ // everything after is a revision, never an option — closes the residual
42
+ // "ref value that happens to start with '-'" injection even though
43
+ // execFileSync's args-array already rules out shell injection.
44
+ const output = execFileSync(
45
+ 'git',
46
+ ['-C', root, 'diff', '--name-only', '--end-of-options', base + '...' + head],
47
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
48
+ );
49
+ return output.split('\n').map(l => l.trim()).filter(Boolean);
50
+ } catch (_) {
51
+ // Any git error → advisory empty set, never break the build
52
+ return [];
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // summaryMd builder
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function buildSummaryMd(generatedAt, base, head, routes) {
61
+ const lines = [];
62
+ lines.push('## KnoSky PR-GPS — Advisory Navigation Report');
63
+ lines.push('');
64
+ lines.push('> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.');
65
+ lines.push('');
66
+ lines.push(`- Generated: ${generatedAt}`);
67
+ if (base) lines.push(`- Base: \`${base}\``);
68
+ if (head) lines.push(`- Head: \`${head}\``);
69
+ lines.push('');
70
+
71
+ if (routes.length === 0) {
72
+ lines.push('_No changed files detected in this PR._');
73
+ } else {
74
+ for (const { file, route: routeDoc } of routes) {
75
+ lines.push(`### \`${file}\``);
76
+ lines.push('');
77
+ const topWaypoints = (routeDoc.route || []).slice(0, 5);
78
+ if (topWaypoints.length === 0) {
79
+ lines.push('_No route waypoints found in the index for this file._');
80
+ } else {
81
+ lines.push('**Route waypoints:**');
82
+ lines.push('');
83
+ for (const wp of topWaypoints) {
84
+ const path = typeof wp === 'string' ? wp : (wp && wp.path) || '';
85
+ const reason = (wp && wp.reason) || '';
86
+ lines.push(`- \`${path}\`${reason ? ' — ' + reason : ''}`);
87
+ }
88
+ }
89
+ const confidence = typeof routeDoc.confidence === 'number'
90
+ ? Math.round(routeDoc.confidence * 100) + '%'
91
+ : 'n/a';
92
+ lines.push('');
93
+ lines.push(`**Confidence:** ${confidence}`);
94
+ const caveats = (routeDoc.caveats || []).filter(c =>
95
+ c.includes('recently changed') || c.includes('stale') || c.includes('coverage'),
96
+ );
97
+ if (caveats.length > 0) {
98
+ lines.push('');
99
+ lines.push('**Caveats:**');
100
+ for (const c of caveats) lines.push(`- ${c}`);
101
+ }
102
+ lines.push('');
103
+ }
104
+ }
105
+
106
+ lines.push('---');
107
+ lines.push('_This report is advisory. KnoSky reads metadata only — it does not read code meaning._');
108
+ return lines.join('\n');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // knoskyCi — main export
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Generate the KnoSky PR-GPS advisory artifacts.
117
+ *
118
+ * @param {object} [opts]
119
+ * @param {string} [opts.root='.'] Repo root for git commands.
120
+ * @param {string} [opts.base] Base ref for git diff.
121
+ * @param {string} [opts.head] Head ref for git diff.
122
+ * @param {string} [opts.cityPath] Path to city-data.json (city index).
123
+ * @param {boolean} [opts.failOnSecret=false] Exit 1 only if secrets found in artifacts.
124
+ * @param {string[]} [opts.changedFiles] Inject changed files directly (test seam — skips git).
125
+ * @returns {{ exitCode: number, summaryMd: string, routeJson: object, safetyJson: object }}
126
+ */
127
+ export async function knoskyCi({
128
+ root = '.',
129
+ base,
130
+ head,
131
+ cityPath,
132
+ failOnSecret = false,
133
+ changedFiles,
134
+ } = {}) {
135
+ const generatedAt = new Date().toISOString();
136
+
137
+ // 1. Resolve changed files
138
+ let files;
139
+ if (Array.isArray(changedFiles)) {
140
+ // Test seam — use injected list directly, no git needed
141
+ files = changedFiles;
142
+ } else {
143
+ // Resolve base/head from args, then environment, then fallback
144
+ const resolvedBase = base || process.env.GITHUB_BASE_REF || 'HEAD~1';
145
+ const resolvedHead = head || process.env.GITHUB_SHA || 'HEAD';
146
+ files = gitChangedFiles(root, resolvedBase, resolvedHead);
147
+ }
148
+
149
+ const resolvedBase = base || process.env.GITHUB_BASE_REF || null;
150
+ const resolvedHead = head || process.env.GITHUB_SHA || null;
151
+
152
+ // 2. Load the city index — if absent/unreadable, produce advisory summary and exit 0
153
+ let ctx;
154
+ if (!cityPath) {
155
+ const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
156
+ return {
157
+ exitCode: 0,
158
+ summaryMd,
159
+ routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
160
+ safetyJson: buildSafetyJson(generatedAt, 0),
161
+ };
162
+ }
163
+
164
+ try {
165
+ ctx = load(cityPath);
166
+ } catch (_) {
167
+ const summaryMd = buildNoIndexSummary(generatedAt, resolvedBase, resolvedHead);
168
+ return {
169
+ exitCode: 0,
170
+ summaryMd,
171
+ routeJson: buildRouteJson(generatedAt, resolvedBase, resolvedHead, []),
172
+ safetyJson: buildSafetyJson(generatedAt, 0),
173
+ };
174
+ }
175
+
176
+ // 3. For each changed file, call kcRoute — metadata only, no file bodies
177
+ const routes = [];
178
+ for (const relpath of files) {
179
+ // Safety: skip any path that escapes the repo
180
+ if (!isSafePath(relpath)) continue;
181
+ let routeDoc;
182
+ try {
183
+ routeDoc = kcRoute(ctx, 'file:' + relpath);
184
+ } catch (_) {
185
+ // Route errors are advisory — skip this file, never break
186
+ continue;
187
+ }
188
+ // Drop any route entries with unsafe paths (defense in depth)
189
+ if (routeDoc && Array.isArray(routeDoc.route)) {
190
+ routeDoc.route = routeDoc.route.filter(wp => {
191
+ const p = typeof wp === 'string' ? wp : (wp && wp.path);
192
+ return !p || isSafePath(p);
193
+ });
194
+ }
195
+ if (routeDoc && Array.isArray(routeDoc.alternates)) {
196
+ routeDoc.alternates = routeDoc.alternates.filter(wp => {
197
+ const p = typeof wp === 'string' ? wp : (wp && wp.path);
198
+ return !p || isSafePath(p);
199
+ });
200
+ }
201
+ routes.push({ file: relpath, route: routeDoc });
202
+ }
203
+
204
+ // 4. Build routeJson
205
+ const routeJson = buildRouteJson(generatedAt, resolvedBase, resolvedHead, routes);
206
+
207
+ // 5. Build summaryMd
208
+ const summaryMd = buildSummaryMd(generatedAt, resolvedBase, resolvedHead, routes);
209
+
210
+ // 6. Scan ONLY the emitted artifacts for secrets (defense in depth)
211
+ const artifactText = summaryMd + '\n' + JSON.stringify(routeJson);
212
+ const secretHits = findSecrets(artifactText);
213
+ const secretsFound = secretHits.reduce((n, [, count]) => n + count, 0);
214
+
215
+ // 7. Build safetyJson
216
+ const safetyJson = buildSafetyJson(generatedAt, secretsFound);
217
+
218
+ // 8. exitCode: only failOnSecret+secrets_found > 0 can be non-zero
219
+ const exitCode = (failOnSecret && secretsFound > 0) ? 1 : 0;
220
+
221
+ return { exitCode, summaryMd, routeJson, safetyJson };
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Artifact builders
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function buildNoIndexSummary(generatedAt, base, head) {
229
+ const lines = [
230
+ '## KnoSky PR-GPS — Advisory Navigation Report',
231
+ '',
232
+ '> **ADVISORY ONLY** — metadata pointers only, no file bodies, never blocks the build.',
233
+ '',
234
+ `- Generated: ${generatedAt}`,
235
+ ];
236
+ if (base) lines.push(`- Base: \`${base}\``);
237
+ if (head) lines.push(`- Head: \`${head}\``);
238
+ lines.push('');
239
+ lines.push('_No index available — run `knosky <path>` to build the city index first._');
240
+ lines.push('');
241
+ lines.push('---');
242
+ lines.push('_This report is advisory. KnoSky reads metadata only._');
243
+ return lines.join('\n');
244
+ }
245
+
246
+ function buildRouteJson(generatedAt, base, head, routes) {
247
+ return {
248
+ knosky_protocol: '1.0',
249
+ artifact_type: 'pr-route',
250
+ advisory: true,
251
+ generated_at: generatedAt,
252
+ base: base || null,
253
+ head: head || null,
254
+ routes: routes.map(({ file, route }) => ({ file, route })),
255
+ };
256
+ }
257
+
258
+ function buildSafetyJson(generatedAt, secretsFound) {
259
+ return {
260
+ knosky_protocol: '1.0',
261
+ artifact_type: 'safety-report',
262
+ advisory: true,
263
+ generated_at: generatedAt,
264
+ absolute_paths: false,
265
+ secrets_found: secretsFound,
266
+ redaction: 'metadata-only; no file bodies; no absolute paths',
267
+ };
268
+ }