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/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
|
+
}
|