knosky 0.4.1 → 0.5.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 +14 -0
- package/bin/knosky.mjs +39 -0
- package/core/bundle.mjs +172 -0
- package/core/churn.mjs +6 -2
- package/core/ci.mjs +268 -0
- package/core/config.mjs +189 -0
- package/core/destination.mjs +161 -0
- package/core/fs-indexer.mjs +4 -1
- package/core/overlays.mjs +45 -0
- package/core/pr-comment.mjs +198 -0
- package/core/route.mjs +299 -0
- package/core/schema.mjs +250 -0
- package/mcp/server.mjs +34 -0
- package/package.json +1 -1
- package/renderer/city.template.html +715 -715
package/core/config.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// KnoSky protocol config schema, loader, and validator (SAT-426 / KSV2-P1).
|
|
2
|
+
// Pure Node stdlib, ESM — no third-party dependencies.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// DEFAULTS — the canonical safe config for every knosky project.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** @type {Readonly<KnoskyConfig>} */
|
|
11
|
+
export const DEFAULTS = Object.freeze({
|
|
12
|
+
knosky_protocol: '1.0',
|
|
13
|
+
telemetry: false,
|
|
14
|
+
absolute_paths: false,
|
|
15
|
+
fail_on_secret: true,
|
|
16
|
+
allow_excerpts: false,
|
|
17
|
+
max_excerpt_chars: 0,
|
|
18
|
+
categories: [],
|
|
19
|
+
ignore: [],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// parseConfigYaml — minimal YAML-subset parser, no deps.
|
|
24
|
+
// Supports: `key: value` lines, booleans, integers, bare/quoted strings,
|
|
25
|
+
// block lists (`- item` lines following a `key:` line with no inline value).
|
|
26
|
+
// Ignores blank lines and # comments.
|
|
27
|
+
// Throws on any line it cannot interpret for a known key.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse a minimal YAML subset used by `.knosky/config.yml`.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text Raw file contents.
|
|
34
|
+
* @returns {Record<string, unknown>} Parsed key/value map.
|
|
35
|
+
*/
|
|
36
|
+
export function parseConfigYaml(text) {
|
|
37
|
+
const result = {};
|
|
38
|
+
const lines = text.split(/\r?\n/);
|
|
39
|
+
let i = 0;
|
|
40
|
+
|
|
41
|
+
while (i < lines.length) {
|
|
42
|
+
const raw = lines[i];
|
|
43
|
+
// Strip inline comment and trim
|
|
44
|
+
const line = raw.replace(/#.*$/, '').trimEnd();
|
|
45
|
+
i++;
|
|
46
|
+
|
|
47
|
+
if (!line.trim()) continue; // blank / comment-only
|
|
48
|
+
|
|
49
|
+
// Must be a `key: [value]` line at the top level (no leading spaces)
|
|
50
|
+
const kvMatch = line.match(/^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)/);
|
|
51
|
+
if (!kvMatch) {
|
|
52
|
+
throw new Error(`parseConfigYaml: unexpected line: ${JSON.stringify(raw)}`);
|
|
53
|
+
}
|
|
54
|
+
const key = kvMatch[1];
|
|
55
|
+
const rest = kvMatch[2].trim();
|
|
56
|
+
|
|
57
|
+
if (rest === '') {
|
|
58
|
+
// Possibly a block list — collect ` - item` lines that follow
|
|
59
|
+
const items = [];
|
|
60
|
+
while (i < lines.length) {
|
|
61
|
+
const next = lines[i].replace(/#.*$/, '');
|
|
62
|
+
if (/^\s*-\s/.test(next)) {
|
|
63
|
+
items.push(next.replace(/^\s*-\s*/, '').trim());
|
|
64
|
+
i++;
|
|
65
|
+
} else {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
result[key] = items;
|
|
70
|
+
} else {
|
|
71
|
+
result[key] = parseScalar(rest, key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse a scalar YAML value: boolean, integer, or string (bare or quoted).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} raw The raw value string (already trimmed).
|
|
82
|
+
* @param {string} key The key name, used for error messages.
|
|
83
|
+
* @returns {boolean|number|string}
|
|
84
|
+
*/
|
|
85
|
+
function parseScalar(raw, key) {
|
|
86
|
+
if (raw === 'true') return true;
|
|
87
|
+
if (raw === 'false') return false;
|
|
88
|
+
|
|
89
|
+
// Integer
|
|
90
|
+
if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
|
|
91
|
+
|
|
92
|
+
// Double-quoted string
|
|
93
|
+
if (raw.startsWith('"') && raw.endsWith('"')) return raw.slice(1, -1);
|
|
94
|
+
|
|
95
|
+
// Single-quoted string
|
|
96
|
+
if (raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
97
|
+
|
|
98
|
+
// Bare string — must not contain characters that signal unhandled YAML
|
|
99
|
+
if (/^[A-Za-z0-9._\-/ ]+$/.test(raw)) return raw;
|
|
100
|
+
|
|
101
|
+
throw new Error(`parseConfigYaml: cannot parse value for key "${key}": ${JSON.stringify(raw)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// loadConfig — read + parse `.knosky/config.yml`, merge over DEFAULTS.
|
|
106
|
+
// Fail-closed: throws on parse error; returns DEFAULTS copy if file absent.
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load `.knosky/config.yml` from `root` and merge it over {@link DEFAULTS}.
|
|
111
|
+
* Returns a plain object (not frozen). Throws if the file exists but is
|
|
112
|
+
* malformed.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} root Absolute (or relative) project root.
|
|
115
|
+
* @returns {Record<string, unknown>} Merged config object.
|
|
116
|
+
*/
|
|
117
|
+
export function loadConfig(root) {
|
|
118
|
+
const cfgPath = path.join(root, '.knosky', 'config.yml');
|
|
119
|
+
|
|
120
|
+
let text;
|
|
121
|
+
try {
|
|
122
|
+
text = fs.readFileSync(cfgPath, 'utf8');
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (err.code === 'ENOENT') {
|
|
125
|
+
// File absent — return a safe copy of defaults
|
|
126
|
+
return { ...DEFAULTS, categories: [], ignore: [] };
|
|
127
|
+
}
|
|
128
|
+
throw err; // permission error, etc — still fail-closed
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// File exists: parse (throws on malformed — never silently fall back)
|
|
132
|
+
const parsed = parseConfigYaml(text);
|
|
133
|
+
|
|
134
|
+
// Deep-merge: parsed wins per-key; arrays are replaced, not concatenated.
|
|
135
|
+
return { ...DEFAULTS, categories: [], ignore: [], ...parsed };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// validateConfig — collect all constraint violations, return { ok, errors }.
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const BOOLEAN_KEYS = ['telemetry', 'absolute_paths', 'fail_on_secret', 'allow_excerpts'];
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Validate a config object against the KnoSky protocol schema.
|
|
146
|
+
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
147
|
+
*
|
|
148
|
+
* @param {Record<string, unknown>} cfg Config object (e.g. from {@link loadConfig}).
|
|
149
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
150
|
+
*/
|
|
151
|
+
export function validateConfig(cfg) {
|
|
152
|
+
const errors = [];
|
|
153
|
+
const knownKeys = new Set(Object.keys(DEFAULTS));
|
|
154
|
+
|
|
155
|
+
// knosky_protocol must be the string "1.0"
|
|
156
|
+
if (cfg.knosky_protocol !== '1.0') {
|
|
157
|
+
errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(cfg.knosky_protocol)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Boolean fields
|
|
161
|
+
for (const k of BOOLEAN_KEYS) {
|
|
162
|
+
if (typeof cfg[k] !== 'boolean') {
|
|
163
|
+
errors.push(`${k} must be a boolean, got: ${JSON.stringify(cfg[k])}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// max_excerpt_chars must be integer >= 0
|
|
168
|
+
const mec = cfg.max_excerpt_chars;
|
|
169
|
+
if (!Number.isInteger(mec) || mec < 0) {
|
|
170
|
+
errors.push(`max_excerpt_chars must be an integer >= 0, got: ${JSON.stringify(mec)}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// categories and ignore must be arrays of strings
|
|
174
|
+
for (const k of ['categories', 'ignore']) {
|
|
175
|
+
const val = cfg[k];
|
|
176
|
+
if (!Array.isArray(val) || val.some(v => typeof v !== 'string')) {
|
|
177
|
+
errors.push(`${k} must be an array of strings, got: ${JSON.stringify(val)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Unknown keys
|
|
182
|
+
for (const k of Object.keys(cfg)) {
|
|
183
|
+
if (!knownKeys.has(k)) {
|
|
184
|
+
errors.push(`unknown config key: ${k}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { ok: errors.length === 0, errors };
|
|
189
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// KnoSky destination parser (KSV2-R2) — extended prefix set for kc_route.
|
|
2
|
+
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
+
import { search } from './retrieve.mjs';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Internal helpers
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a single node by id "fs:<rest>" OR by provenance.ref === <rest>.
|
|
11
|
+
* Also accepts a fully-qualified id (already has "fs:" prefix).
|
|
12
|
+
* @param {object} ctx
|
|
13
|
+
* @param {string} idOrRef
|
|
14
|
+
* @returns {object|null}
|
|
15
|
+
*/
|
|
16
|
+
function resolveOne(ctx, idOrRef) {
|
|
17
|
+
// Try constructing the canonical node id
|
|
18
|
+
const byConstructed = ctx.byId.get('fs:' + idOrRef);
|
|
19
|
+
if (byConstructed) return byConstructed;
|
|
20
|
+
// Try the raw string as-is (already fully qualified, e.g. "fs:src/a.js")
|
|
21
|
+
const byRaw = ctx.byId.get(idOrRef);
|
|
22
|
+
if (byRaw) return byRaw;
|
|
23
|
+
// Fallback: linear scan for provenance.ref match
|
|
24
|
+
return ctx.city.nodes.find(n => n.provenance && n.provenance.ref === idOrRef) || null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// parseDestination — main export
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the destination string to an initial set of matching nodes.
|
|
33
|
+
* Returns { matched: Node[], matchStrength: 'direct'|'folder'|'district'|'edges'|'chain'|'keyword' }
|
|
34
|
+
*
|
|
35
|
+
* Supported prefixes (ALL metadata-only, no filesystem access):
|
|
36
|
+
* file:<idOrRef> — node by id "fs:<rest>" OR by provenance.ref === rest; 'direct'
|
|
37
|
+
* folder:<prefix> — nodes whose provenance.ref starts with prefix; 'folder'
|
|
38
|
+
* district:<name> — nodes whose category equals name (case-insensitive) or
|
|
39
|
+
* whose category label matches; 'district'
|
|
40
|
+
* importsOf:<idOrRef> — out-edge targets of that node (node.links through ctx.byId); 'edges'
|
|
41
|
+
* depChainTo:<idOrRef> — nodes that TRANSITIVELY import/reach the target via
|
|
42
|
+
* reverse-edge BFS; bounded (max depth 6, max 500 visited,
|
|
43
|
+
* visited-set prevents cycles); 'chain'
|
|
44
|
+
* <anything else> — keyword fallback via search(); 'keyword'
|
|
45
|
+
*
|
|
46
|
+
* @param {object} ctx — retrieve context {city:{nodes,categories}, byId:Map}
|
|
47
|
+
* @param {string} destination
|
|
48
|
+
* @param {number} limit — passed through to keyword search
|
|
49
|
+
* @returns {{ matched: object[], matchStrength: string }}
|
|
50
|
+
*/
|
|
51
|
+
export function parseDestination(ctx, destination, limit) {
|
|
52
|
+
const dest = String(destination || '').trim();
|
|
53
|
+
|
|
54
|
+
// ------------------------------------------------------------------
|
|
55
|
+
// file:<idOrRef>
|
|
56
|
+
// ------------------------------------------------------------------
|
|
57
|
+
if (dest.startsWith('file:')) {
|
|
58
|
+
const rest = dest.slice('file:'.length);
|
|
59
|
+
const node = resolveOne(ctx, rest);
|
|
60
|
+
return { matched: node ? [node] : [], matchStrength: 'direct' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ------------------------------------------------------------------
|
|
64
|
+
// folder:<prefix>
|
|
65
|
+
// ------------------------------------------------------------------
|
|
66
|
+
if (dest.startsWith('folder:')) {
|
|
67
|
+
const rest = dest.slice('folder:'.length);
|
|
68
|
+
// Normalise: ensure trailing slash for prefix match (unless rest already ends with /)
|
|
69
|
+
const prefix = rest.endsWith('/') ? rest : rest + '/';
|
|
70
|
+
const matched = ctx.city.nodes.filter(n => {
|
|
71
|
+
const ref = n.provenance && n.provenance.ref;
|
|
72
|
+
return typeof ref === 'string' && (ref.startsWith(prefix) || ref === rest);
|
|
73
|
+
});
|
|
74
|
+
return { matched, matchStrength: 'folder' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ------------------------------------------------------------------
|
|
78
|
+
// district:<name>
|
|
79
|
+
// ------------------------------------------------------------------
|
|
80
|
+
if (dest.startsWith('district:')) {
|
|
81
|
+
const name = dest.slice('district:'.length).toLowerCase();
|
|
82
|
+
// Build label->id map from city.categories
|
|
83
|
+
const labelToId = new Map();
|
|
84
|
+
for (const cat of (ctx.city.categories || [])) {
|
|
85
|
+
if (cat.label) labelToId.set(String(cat.label).toLowerCase(), String(cat.id).toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
const resolvedCatId = labelToId.get(name) || null;
|
|
88
|
+
const matched = ctx.city.nodes.filter(n => {
|
|
89
|
+
const cat = String(n.category || '').toLowerCase();
|
|
90
|
+
return cat === name || (resolvedCatId !== null && cat === resolvedCatId);
|
|
91
|
+
});
|
|
92
|
+
return { matched, matchStrength: 'district' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ------------------------------------------------------------------
|
|
96
|
+
// importsOf:<idOrRef>
|
|
97
|
+
// ------------------------------------------------------------------
|
|
98
|
+
if (dest.startsWith('importsOf:')) {
|
|
99
|
+
const rest = dest.slice('importsOf:'.length);
|
|
100
|
+
const node = resolveOne(ctx, rest);
|
|
101
|
+
if (!node) return { matched: [], matchStrength: 'edges' };
|
|
102
|
+
const matched = (node.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean);
|
|
103
|
+
return { matched, matchStrength: 'edges' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------------
|
|
107
|
+
// depChainTo:<idOrRef>
|
|
108
|
+
// Bounded reverse-edge BFS: finds all nodes that transitively import the target.
|
|
109
|
+
// Safety invariants:
|
|
110
|
+
// - visited Set prevents revisiting nodes (handles cycles A→B→A)
|
|
111
|
+
// - max depth 6 per BFS level
|
|
112
|
+
// - hard cap of 500 nodes visited (includes the target seed)
|
|
113
|
+
// ------------------------------------------------------------------
|
|
114
|
+
if (dest.startsWith('depChainTo:')) {
|
|
115
|
+
const rest = dest.slice('depChainTo:'.length);
|
|
116
|
+
const target = resolveOne(ctx, rest);
|
|
117
|
+
if (!target) return { matched: [], matchStrength: 'chain' };
|
|
118
|
+
const targetId = target.id;
|
|
119
|
+
|
|
120
|
+
// Build reverse-edge index: nodeId -> Set<callerId>
|
|
121
|
+
// (which nodes have this id in their links)
|
|
122
|
+
const importedBy = new Map();
|
|
123
|
+
for (const n of ctx.city.nodes) {
|
|
124
|
+
for (const link of (n.links || [])) {
|
|
125
|
+
if (!importedBy.has(link)) importedBy.set(link, new Set());
|
|
126
|
+
importedBy.get(link).add(n.id);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// BFS — each queue entry carries { id, depth }
|
|
131
|
+
const visited = new Set();
|
|
132
|
+
visited.add(targetId);
|
|
133
|
+
const queue = [{ id: targetId, depth: 0 }];
|
|
134
|
+
|
|
135
|
+
while (queue.length > 0 && visited.size < 500) {
|
|
136
|
+
const { id, depth } = queue.shift();
|
|
137
|
+
if (depth >= 6) continue;
|
|
138
|
+
const parents = importedBy.get(id);
|
|
139
|
+
if (!parents) continue;
|
|
140
|
+
for (const pid of parents) {
|
|
141
|
+
if (!visited.has(pid)) {
|
|
142
|
+
visited.add(pid);
|
|
143
|
+
queue.push({ id: pid, depth: depth + 1 });
|
|
144
|
+
if (visited.size >= 500) break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Return callers of target, not the target itself
|
|
150
|
+
visited.delete(targetId);
|
|
151
|
+
const matched = [...visited].map(id => ctx.byId.get(id)).filter(Boolean);
|
|
152
|
+
return { matched, matchStrength: 'chain' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ------------------------------------------------------------------
|
|
156
|
+
// no recognized prefix — keyword fallback
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
const hits = search(ctx, dest, { limit });
|
|
159
|
+
const matched = hits.map(h => ctx.byId.get(h.id)).filter(Boolean);
|
|
160
|
+
return { matched, matchStrength: 'keyword' };
|
|
161
|
+
}
|
package/core/fs-indexer.mjs
CHANGED
|
@@ -11,7 +11,10 @@ import { gitChurn } from './churn.mjs';
|
|
|
11
11
|
const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
|
|
12
12
|
const ROOT = args.root && path.resolve(args.root);
|
|
13
13
|
const OUT = args.out || null;
|
|
14
|
-
|
|
14
|
+
// Validate --max: a non-numeric/zero/negative value must NOT silently disable the
|
|
15
|
+
// cap (NaN comparisons are always false, so `scanned > MAX` would never trip) —
|
|
16
|
+
// that's a resource-exhaustion opening on a hostile/malformed CI invocation.
|
|
17
|
+
const MAX = (() => { const n = parseInt(args.max || '6000', 10); return (Number.isFinite(n) && n > 0) ? n : 6000; })();
|
|
15
18
|
const SHARE_SAFE = process.argv.includes('--share-safe');
|
|
16
19
|
const INCLUDE_ABS = process.argv.includes('--include-absolute-root');
|
|
17
20
|
const ALLOW_LEAKS = process.argv.includes('--allow-leaks');
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Operational-overlay metadata ingest (D-155): file-level ONLY.
|
|
2
|
+
// Reads existing local test/coverage artifacts under `root` — never executes tests.
|
|
3
|
+
// Returns a map { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }.
|
|
4
|
+
// Key: relpath is always forward-slash, relative to root, no leading './'.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
// Parse Istanbul coverage-summary.json. Returns partial overlay map.
|
|
9
|
+
function readIstanbul(root) {
|
|
10
|
+
const fp = path.join(root, 'coverage', 'coverage-summary.json');
|
|
11
|
+
let raw;
|
|
12
|
+
try { raw = fs.readFileSync(fp, 'utf8'); } catch { return {}; }
|
|
13
|
+
let json;
|
|
14
|
+
try { json = JSON.parse(raw); } catch { return {}; }
|
|
15
|
+
const out = {};
|
|
16
|
+
for (const [key, val] of Object.entries(json)) {
|
|
17
|
+
if (key === 'total') continue; // skip the aggregate row
|
|
18
|
+
if (!val || typeof val !== 'object') continue;
|
|
19
|
+
const pct = val.lines?.pct;
|
|
20
|
+
if (typeof pct !== 'number') continue;
|
|
21
|
+
const rel = key.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
22
|
+
out[rel] = { coverage: Math.min(100, Math.max(0, pct)) };
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Merge source into dest (dest wins on conflict).
|
|
28
|
+
function merge(dest, src) {
|
|
29
|
+
for (const [k, v] of Object.entries(src)) {
|
|
30
|
+
dest[k] = dest[k] ? { ...v, ...dest[k] } : v;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read all recognised local test/coverage artifacts under `root` and return
|
|
36
|
+
* a file-level overlay map. Never executes tests or modifies files.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} root Absolute or relative path to the project root.
|
|
39
|
+
* @returns {{ [relpath: string]: { coverage?: number, test?: 'pass'|'fail' } }}
|
|
40
|
+
*/
|
|
41
|
+
export function readOverlays(root) {
|
|
42
|
+
const out = {};
|
|
43
|
+
merge(out, readIstanbul(root));
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// KnoSky PR-comment renderer (KSV2-CI2) — claims-disciplined public Markdown.
|
|
2
|
+
// Consumes CI1 artifacts (routeJson + safetyJson) and renders the polished
|
|
3
|
+
// PR-GPS comment that knosky ci posts on a pull request.
|
|
4
|
+
//
|
|
5
|
+
// CLAIMS DISCIPLINE — BINDING (D-162 §8):
|
|
6
|
+
// USE: "advisory PR-GPS report" / "advisory route you can ignore"
|
|
7
|
+
// USE: "route cache / freshness"
|
|
8
|
+
// USE: "reads metadata only / never uploads your code"
|
|
9
|
+
// USE: "network-silent (verify with --verify-airgap)"
|
|
10
|
+
// USE: "open protocol / reference implementation"
|
|
11
|
+
// NEVER: CI gate / blocks the build / gates the build
|
|
12
|
+
// NEVER: learns / learns your repo
|
|
13
|
+
// NEVER: understands your code
|
|
14
|
+
// NEVER: zero data risk
|
|
15
|
+
// NEVER: official standard
|
|
16
|
+
// NEVER: air-gap guarantee
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Internal helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format a confidence fraction [0..1] as a percent string, or 'n/a'.
|
|
24
|
+
* @param {unknown} val
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function fmtConfidence(val) {
|
|
28
|
+
if (typeof val === 'number' && isFinite(val)) {
|
|
29
|
+
return Math.round(val * 100) + '%';
|
|
30
|
+
}
|
|
31
|
+
return 'n/a';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Extract path + reason from a waypoint entry (string | { path, reason }).
|
|
36
|
+
* @param {unknown} wp
|
|
37
|
+
* @returns {{ path: string, reason: string }}
|
|
38
|
+
*/
|
|
39
|
+
function parsWaypoint(wp) {
|
|
40
|
+
if (typeof wp === 'string') return { path: wp, reason: '' };
|
|
41
|
+
const path = (wp && typeof wp.path === 'string') ? wp.path : '';
|
|
42
|
+
const reason = (wp && typeof wp.reason === 'string') ? wp.reason : '';
|
|
43
|
+
return { path, reason };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// renderPrComment — main export
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render the KnoSky PR-GPS comment as a Markdown string.
|
|
52
|
+
*
|
|
53
|
+
* This is a DRAFT — the final public wording is Paul's critical gate.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {object} opts.routeJson CI1 route artifact ({ routes, base, head, advisory, … })
|
|
57
|
+
* @param {object} opts.safetyJson CI1 safety artifact ({ secrets_found, absolute_paths, … })
|
|
58
|
+
* @returns {string} Markdown comment body.
|
|
59
|
+
*/
|
|
60
|
+
export function renderPrComment({ routeJson = {}, safetyJson = {} } = {}) {
|
|
61
|
+
const lines = [];
|
|
62
|
+
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
// Header
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
lines.push('## 🧭 KnoSky PR-GPS');
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(
|
|
69
|
+
'> **DRAFT — advisory PR-GPS report.** ' +
|
|
70
|
+
'This is an advisory route you can ignore — it never gates or enforces a build outcome.',
|
|
71
|
+
);
|
|
72
|
+
lines.push('');
|
|
73
|
+
|
|
74
|
+
// -------------------------------------------------------------------------
|
|
75
|
+
// Per-file route sections
|
|
76
|
+
// -------------------------------------------------------------------------
|
|
77
|
+
const routes = Array.isArray(routeJson.routes) ? routeJson.routes : [];
|
|
78
|
+
|
|
79
|
+
if (routes.length === 0) {
|
|
80
|
+
lines.push('_No changed files detected in this PR — nothing to navigate._');
|
|
81
|
+
lines.push('');
|
|
82
|
+
} else {
|
|
83
|
+
for (const { file, route: routeDoc } of routes) {
|
|
84
|
+
lines.push(`### \`${file}\``);
|
|
85
|
+
lines.push('');
|
|
86
|
+
|
|
87
|
+
// Top ~5 waypoints
|
|
88
|
+
const waypoints = (Array.isArray(routeDoc && routeDoc.route) ? routeDoc.route : []).slice(0, 5);
|
|
89
|
+
if (waypoints.length === 0) {
|
|
90
|
+
lines.push('_No route waypoints found in the route cache for this file._');
|
|
91
|
+
} else {
|
|
92
|
+
lines.push('**Advisory route waypoints** _(top ' + waypoints.length + ', from route cache):_');
|
|
93
|
+
lines.push('');
|
|
94
|
+
for (const wp of waypoints) {
|
|
95
|
+
const { path, reason } = parsWaypoint(wp);
|
|
96
|
+
lines.push(`- \`${path}\`${reason ? ' — ' + reason : ''}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push('');
|
|
100
|
+
|
|
101
|
+
// Confidence
|
|
102
|
+
const conf = fmtConfidence(routeDoc && routeDoc.confidence);
|
|
103
|
+
lines.push(`**Confidence:** ${conf}`);
|
|
104
|
+
lines.push('');
|
|
105
|
+
|
|
106
|
+
// Freshness / coverage caveats
|
|
107
|
+
const caveats = Array.isArray(routeDoc && routeDoc.caveats) ? routeDoc.caveats : [];
|
|
108
|
+
const freshnessOrCoverage = caveats.filter(c =>
|
|
109
|
+
/recently changed|stale|freshness|coverage/i.test(c),
|
|
110
|
+
);
|
|
111
|
+
if (freshnessOrCoverage.length > 0) {
|
|
112
|
+
lines.push('**Route freshness / coverage caveats:**');
|
|
113
|
+
lines.push('');
|
|
114
|
+
for (const c of freshnessOrCoverage) lines.push(`- ${c}`);
|
|
115
|
+
lines.push('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Related tests
|
|
119
|
+
const tests = Array.isArray(routeDoc && routeDoc.tests) ? routeDoc.tests : [];
|
|
120
|
+
if (tests.length > 0) {
|
|
121
|
+
lines.push('**Related tests:**');
|
|
122
|
+
lines.push('');
|
|
123
|
+
for (const t of tests) {
|
|
124
|
+
const p = typeof t === 'string' ? t : (t && t.path);
|
|
125
|
+
if (p) lines.push(`- \`${p}\``);
|
|
126
|
+
}
|
|
127
|
+
lines.push('');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Related docs
|
|
131
|
+
const docs = Array.isArray(routeDoc && routeDoc.docs) ? routeDoc.docs : [];
|
|
132
|
+
if (docs.length > 0) {
|
|
133
|
+
lines.push('**Related docs:**');
|
|
134
|
+
lines.push('');
|
|
135
|
+
for (const d of docs) {
|
|
136
|
+
const p = typeof d === 'string' ? d : (d && d.path);
|
|
137
|
+
if (p) lines.push(`- \`${p}\``);
|
|
138
|
+
}
|
|
139
|
+
lines.push('');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// Suggested reviewer / agent prompt block
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
lines.push('---');
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('### 💬 Suggested reviewer / agent prompt');
|
|
150
|
+
lines.push('');
|
|
151
|
+
|
|
152
|
+
if (routes.length > 0) {
|
|
153
|
+
// Collect top waypoints across all changed files (unique, capped at 5)
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const topPaths = [];
|
|
156
|
+
for (const { route: routeDoc } of routes) {
|
|
157
|
+
const wps = Array.isArray(routeDoc && routeDoc.route) ? routeDoc.route : [];
|
|
158
|
+
for (const wp of wps.slice(0, 3)) {
|
|
159
|
+
const { path } = parsWaypoint(wp);
|
|
160
|
+
if (path && !seen.has(path)) {
|
|
161
|
+
seen.add(path);
|
|
162
|
+
topPaths.push(path);
|
|
163
|
+
if (topPaths.length >= 5) break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (topPaths.length >= 5) break;
|
|
167
|
+
}
|
|
168
|
+
const waypointList = topPaths.map(p => `\`${p}\``).join(', ');
|
|
169
|
+
lines.push(
|
|
170
|
+
`> Start your review at: ${waypointList || '_see routes above_'}. ` +
|
|
171
|
+
'Advisory only — verify before acting.',
|
|
172
|
+
);
|
|
173
|
+
} else {
|
|
174
|
+
lines.push(
|
|
175
|
+
'> No route waypoints available for this PR. ' +
|
|
176
|
+
'Advisory only — verify before acting.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
lines.push('');
|
|
181
|
+
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
// Footer — safety / privacy
|
|
184
|
+
// -------------------------------------------------------------------------
|
|
185
|
+
lines.push('---');
|
|
186
|
+
lines.push('');
|
|
187
|
+
const secretsFound = (safetyJson && typeof safetyJson.secrets_found === 'number')
|
|
188
|
+
? safetyJson.secrets_found
|
|
189
|
+
: 0;
|
|
190
|
+
lines.push(
|
|
191
|
+
'_KnoSky reads metadata only — it never uploads your code. ' +
|
|
192
|
+
`Secrets scan: **${secretsFound}** potential secret(s) found in emitted artifacts. ` +
|
|
193
|
+
'network-silent (verify with --verify-airgap). ' +
|
|
194
|
+
'open protocol / reference implementation._',
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
return lines.join('\n');
|
|
198
|
+
}
|