knosky 0.4.1 → 0.6.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 +35 -0
- package/README.md +25 -1
- package/SECURITY.md +14 -0
- package/action/post-comment.mjs +89 -0
- package/action.yml +62 -0
- package/bin/knosky.mjs +39 -0
- package/core/benchmark-results.mjs +225 -0
- package/core/bundle.mjs +178 -0
- package/core/churn.mjs +6 -2
- package/core/ci.mjs +268 -0
- package/core/comparison.mjs +189 -0
- package/core/config.mjs +189 -0
- package/core/constants.mjs +13 -0
- package/core/cross-repo.mjs +111 -0
- package/core/destination.mjs +161 -0
- package/core/escalate.mjs +68 -0
- package/core/freshness.mjs +194 -0
- package/core/fs-indexer.mjs +10 -1
- package/core/key-store.mjs +348 -0
- package/core/ledger.mjs +141 -0
- package/core/lod.mjs +155 -0
- package/core/multi-model-benchmark.mjs +405 -0
- package/core/onboarding.mjs +223 -0
- package/core/overlays.mjs +45 -0
- package/core/pr-comment.mjs +198 -0
- package/core/protocol-spec.mjs +460 -0
- package/core/route.mjs +304 -0
- package/core/schema.mjs +275 -0
- package/mcp/server.mjs +34 -0
- package/package.json +3 -1
- package/renderer/city.template.html +824 -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,13 @@
|
|
|
1
|
+
// Shared, dependency-free constants used by more than one core module.
|
|
2
|
+
// Kept in its own file (no imports, no side effects) so any core/*.mjs can
|
|
3
|
+
// import from it without creating a circular dependency between modules.
|
|
4
|
+
|
|
5
|
+
// Upper bound for a plausible ledger sequence (git commit count). 1 billion
|
|
6
|
+
// is far beyond any real repo's commit count and far below
|
|
7
|
+
// Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
|
|
8
|
+
// supplied values (e.g. 1e100) with no precision-loss risk of its own.
|
|
9
|
+
// Enforced independently at two layers — core/freshness.mjs's
|
|
10
|
+
// extractLedgerSeq (structural validation) and core/ledger.mjs's
|
|
11
|
+
// checkAndAdvance (the persisted guard itself) — both import this single
|
|
12
|
+
// source of truth so the two layers cannot silently diverge.
|
|
13
|
+
export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Cross-repo graph utilities (SAT-448): edge detection, boundary extraction, and rendering metadata.
|
|
2
|
+
// Pure logic — no filesystem access, no new deps. Works on the in-memory city/byId structures
|
|
3
|
+
// produced by retrieve.load() or any equivalent.
|
|
4
|
+
//
|
|
5
|
+
// A node's *repo* identity is determined by provenance.repo when present, falling back to
|
|
6
|
+
// provenance.store (e.g. "fs", "gh:owner/repo") so existing city data stays fully compatible —
|
|
7
|
+
// single-repo graphs have no cross-repo edges because every node shares the same store value.
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// repoOf
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return the repository identity of a node.
|
|
15
|
+
* Preference order:
|
|
16
|
+
* 1. node.provenance.repo — explicit multi-repo label (new field, optional)
|
|
17
|
+
* 2. node.provenance.store — always present; used as the identity when repo is absent
|
|
18
|
+
* (works correctly for single-repo graphs: all nodes share one store → no cross edges)
|
|
19
|
+
*
|
|
20
|
+
* Returns null if provenance is absent or malformed.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} node
|
|
23
|
+
* @returns {string|null}
|
|
24
|
+
*/
|
|
25
|
+
export function repoOf(node) {
|
|
26
|
+
if (!node || typeof node !== 'object') return null;
|
|
27
|
+
const prov = node.provenance;
|
|
28
|
+
if (!prov || typeof prov !== 'object') return null;
|
|
29
|
+
if (typeof prov.repo === 'string' && prov.repo.length > 0) return prov.repo;
|
|
30
|
+
if (typeof prov.store === 'string' && prov.store.length > 0) return prov.store;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// isCrossRepoEdge
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Return true when the edge (fromId → toId) crosses a repo boundary.
|
|
40
|
+
* An edge is cross-repo when repoOf(fromNode) !== repoOf(toNode) AND both are non-null.
|
|
41
|
+
* Edges where either endpoint is missing or has no repo identity are treated as same-repo
|
|
42
|
+
* (conservative: no false positives).
|
|
43
|
+
*
|
|
44
|
+
* @param {Map<string, object>} byId — node map from retrieve.load()
|
|
45
|
+
* @param {string} fromId
|
|
46
|
+
* @param {string} toId
|
|
47
|
+
* @returns {boolean}
|
|
48
|
+
*/
|
|
49
|
+
export function isCrossRepoEdge(byId, fromId, toId) {
|
|
50
|
+
const src = byId.get(fromId);
|
|
51
|
+
const dst = byId.get(toId);
|
|
52
|
+
if (!src || !dst) return false;
|
|
53
|
+
const r1 = repoOf(src);
|
|
54
|
+
const r2 = repoOf(dst);
|
|
55
|
+
if (r1 === null || r2 === null) return false;
|
|
56
|
+
return r1 !== r2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// getCrossRepoEdges
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Walk every node's `links` array and return all edges that cross a repo boundary.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} ctx — { city: { nodes: [] }, byId: Map }
|
|
67
|
+
* @returns {Array<{ from: string, to: string, fromRepo: string, toRepo: string }>}
|
|
68
|
+
*/
|
|
69
|
+
export function getCrossRepoEdges(ctx) {
|
|
70
|
+
const edges = [];
|
|
71
|
+
for (const node of (ctx.city.nodes || [])) {
|
|
72
|
+
if (!Array.isArray(node.links)) continue;
|
|
73
|
+
const fromRepo = repoOf(node);
|
|
74
|
+
if (fromRepo === null) continue;
|
|
75
|
+
for (const toId of node.links) {
|
|
76
|
+
const dst = ctx.byId.get(toId);
|
|
77
|
+
if (!dst) continue;
|
|
78
|
+
const toRepo = repoOf(dst);
|
|
79
|
+
if (toRepo === null) continue;
|
|
80
|
+
if (fromRepo !== toRepo) {
|
|
81
|
+
edges.push({ from: node.id, to: toId, fromRepo, toRepo });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return edges;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// getRepoBoundaries
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return a summary of every distinct repo present in the graph and its node membership.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} ctx — { city: { nodes: [] }, byId: Map }
|
|
96
|
+
* @returns {Array<{ repo: string, nodeIds: string[], count: number }>}
|
|
97
|
+
* Sorted by count desc, then repo asc.
|
|
98
|
+
*/
|
|
99
|
+
export function getRepoBoundaries(ctx) {
|
|
100
|
+
/** @type {Map<string, string[]>} */
|
|
101
|
+
const byRepo = new Map();
|
|
102
|
+
for (const node of (ctx.city.nodes || [])) {
|
|
103
|
+
const r = repoOf(node);
|
|
104
|
+
if (r === null) continue;
|
|
105
|
+
if (!byRepo.has(r)) byRepo.set(r, []);
|
|
106
|
+
byRepo.get(r).push(node.id);
|
|
107
|
+
}
|
|
108
|
+
return [...byRepo.entries()]
|
|
109
|
+
.map(([repo, nodeIds]) => ({ repo, nodeIds, count: nodeIds.length }))
|
|
110
|
+
.sort((a, b) => b.count - a.count || String(a.repo).localeCompare(String(b.repo)));
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// KnoSky HITL escalation gate (D-166 / SAT-467) — any P0/critical or ambiguous
|
|
2
|
+
// review result must surface to Paul, never proceed silently.
|
|
3
|
+
// D-166 (SAT-466): zero-P0/critical + all reviewers succeeded → auto-proceed-to-publish.
|
|
4
|
+
// Pure function: no I/O, no external dependencies, safe to import anywhere.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decide whether a review result must be escalated to Paul, or may auto-publish.
|
|
8
|
+
*
|
|
9
|
+
* Rules (D-166 hitl gate, SAT-467 / SAT-466):
|
|
10
|
+
* - One or more CRITICAL findings → P0, escalate immediately.
|
|
11
|
+
* - All reviewers failed → ambiguous (no result at all), escalate.
|
|
12
|
+
* - Any reviewer failed (degraded) → ambiguous (result is incomplete), escalate.
|
|
13
|
+
* - Zero criticals + zero failures + at least one reviewer ran
|
|
14
|
+
* → canAutoPublish (D-166 pre-authorized path).
|
|
15
|
+
*
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {object[]} [opts.criticals] CRITICAL findings ({ sev, file, hint, role }).
|
|
18
|
+
* @param {object[]} [opts.failed] Failed reviewer records ({ role, err, ok: false }).
|
|
19
|
+
* @param {number} [opts.total] Total reviewer count dispatched this run.
|
|
20
|
+
* @returns {{ escalate: boolean, reasons: string[], paulMessage: string, canAutoPublish: boolean }}
|
|
21
|
+
* escalate — true iff the result must block and surface to Paul.
|
|
22
|
+
* reasons — human-readable list explaining why escalation was triggered
|
|
23
|
+
* (empty array when escalate === false).
|
|
24
|
+
* paulMessage — the block line to embed in the PR review body;
|
|
25
|
+
* non-empty iff escalate === true.
|
|
26
|
+
* canAutoPublish — DATA ONLY, not an authorization by itself (D-173): true iff a clean
|
|
27
|
+
* review (zero criticals, zero reviewer failures, ≥1 reviewer ran)
|
|
28
|
+
* would satisfy D-166's pre-authorized-publish condition. Callers must
|
|
29
|
+
* NOT treat this as license to take an autonomous action (e.g. approving
|
|
30
|
+
* a PR, publishing a release) on its own -- it exists so a separately
|
|
31
|
+
* gated, narrowly-scoped release workflow can consume it for the actual
|
|
32
|
+
* SAT-437/D-166 publish decision. The general per-PR review script
|
|
33
|
+
* deliberately does NOT act on it -- see that script's own comments.
|
|
34
|
+
*/
|
|
35
|
+
export function shouldEscalateToPaul({ criticals = [], failed = [], total = 0 } = {}) {
|
|
36
|
+
const reasons = [];
|
|
37
|
+
|
|
38
|
+
// P0 / critical findings — must never pass silently
|
|
39
|
+
if (criticals.length > 0) {
|
|
40
|
+
reasons.push(`${criticals.length} CRITICAL finding(s)`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const allFailed = total > 0 && failed.length === total;
|
|
44
|
+
|
|
45
|
+
if (allFailed) {
|
|
46
|
+
// No reviewer returned a result — outcome is unknowable (ambiguous)
|
|
47
|
+
reasons.push('all reviewers failed (no result — ambiguous)');
|
|
48
|
+
} else if (failed.length > 0) {
|
|
49
|
+
// At least one reviewer failed — remaining results are incomplete (ambiguous)
|
|
50
|
+
reasons.push(`${failed.length} of ${total} reviewer(s) failed (degraded — ambiguous)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const escalate = reasons.length > 0;
|
|
54
|
+
|
|
55
|
+
// D-166 (SAT-466): pre-authorized auto-publish path — zero criticals, all reviewers
|
|
56
|
+
// succeeded, and at least one reviewer actually ran. Do NOT auto-publish when total
|
|
57
|
+
// is 0 (no-reviewer synthetic run) — that would be a silent pass with no evidence.
|
|
58
|
+
const canAutoPublish = !escalate && total > 0 && failed.length === 0;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
escalate,
|
|
62
|
+
reasons,
|
|
63
|
+
paulMessage: escalate
|
|
64
|
+
? 'Merge blocked until CRITICAL items are resolved or dismissed by Paul.'
|
|
65
|
+
: '',
|
|
66
|
+
canAutoPublish,
|
|
67
|
+
};
|
|
68
|
+
}
|