knosky 0.4.0 → 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 +28 -0
- package/README.md +1 -1
- 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 +30 -9
- 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 +35 -1
- package/package.json +1 -1
- package/renderer/city.template.html +715 -710
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to KnoSky. Versions are git-tagged on this repo.
|
|
4
4
|
|
|
5
|
+
## [0.5.0] - 2026-07-01 -- Route engine + PR-GPS (Protocol v1)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`.knosky` protocol foundation:** versioned config (`.knosky/config.yml`), `route.json` and `intent-manifest` schemas (`knosky_protocol: "1.0"`).
|
|
9
|
+
- **Route engine (`kc_route`):** point at a destination -- a file, a folder, an import chain, a "district" (category) -- and get a ranked, advisory route through the repo with waypoints, alternates, confidence, and caveats. Structural only: file/folder/imports/dependency-chain, never semantic/code-meaning.
|
|
10
|
+
- **`kc_bundle`:** builds a shareable intent-manifest (paths + sha256 + edges) with a fail-closed secret scan, for agents that need to share a scoped, verifiable file set.
|
|
11
|
+
- **PR-GPS:** `knosky ci` generates an advisory navigation report for a pull request's changed files. A new GitHub Action posts/updates it as a single PR comment automatically -- advisory only, never blocks or gates a build.
|
|
12
|
+
|
|
13
|
+
### Security
|
|
14
|
+
- Independent adversarial pass ahead of this release: fixed a symlink-escape read and an unreadable-file fail-open in the bundle engine, a git-ref option-injection in the CI report generator, plus two additional issues found during the pass. See the repo's PR history for detail.
|
|
15
|
+
|
|
16
|
+
### Notes
|
|
17
|
+
- Everything above is **advisory-only, metadata-only, local, no telemetry** -- KnoSky reads structure, never uploads your code, and nothing here blocks or gates a build.
|
|
18
|
+
|
|
19
|
+
## [0.4.1] - 2026-06-29 — Security review fixes
|
|
20
|
+
|
|
21
|
+
### Security
|
|
22
|
+
- **The fail-closed secret scan now actually fails closed.** It previously ran *after* scrubbing, so a secret in a title/heading/summary was silently redacted instead of blocking the build. It now scans the **un-scrubbed** projections before serialization and blocks (the post-scrub residual is kept as a defense-in-depth gap detector). A secret in a heading now stops the build.
|
|
23
|
+
- **Embedded mode is fail-closed.** The postMessage bridge now requires a non-empty `window.__KC_ALLOWED_ORIGINS` allowlist (the bridge is disabled if it is empty), only accepts messages from the embedding parent frame, and never broadcasts readiness to `"*"`.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `test/security-fixtures.mjs` — pure-Node regression tests (secret block, ignore rules, XSS escaping, embed fail-closed). Run `node test/security-fixtures.mjs`.
|
|
27
|
+
- `.github/workflows/ci.yml` — runs the fixtures + `npm audit` on every PR.
|
|
28
|
+
- Root `package-lock.json` for reproducible `npx` / `npm ci` installs.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- README: removed a "zero data risk" line that contradicted PRIVACY.md.
|
|
32
|
+
|
|
5
33
|
## [0.4.0] - 2026-06-29 — File Connections + churn (code-intel)
|
|
6
34
|
|
|
7
35
|
### Added
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Your project grows faster than anyone can hold in their head. Hundreds of files,
|
|
|
21
21
|
- **See your whole project in one screen.** Instead of scrolling a file tree, you see the *shape* of everything — which areas are big, how they connect, where the gaps are. New collaborators get oriented in minutes, not weeks.
|
|
22
22
|
- **Find anything in seconds.** Search the city — or ask your assistant *"where does auth live / what did we decide about billing"* — and jump straight to the **live file**.
|
|
23
23
|
- **Your AI answers from YOUR source, with citations.** Connect it to Claude / Cursor / VS Code / Gemini and your assistant stops guessing about your codebase — it cites the real file, every time.
|
|
24
|
-
- **Zero setup tax,
|
|
24
|
+
- **Zero setup tax, local-first by default.** Point it at a folder → a city in under a minute. Deterministic, **$0 tokens** to keep fresh, **nothing ever leaves your machine**, and a fail-closed secret scan before you share. (KnoSky does not claim "zero data risk" — see [PRIVACY.md](./PRIVACY.md).)
|
|
25
25
|
|
|
26
26
|
**Net:** faster comprehension, reliable recall of your own knowledge, and a grounded AI — without giving up privacy or paying a cent.
|
|
27
27
|
|
package/bin/knosky.mjs
CHANGED
|
@@ -18,6 +18,45 @@ const flags = new Set(argv.filter(a => a.startsWith('--')));
|
|
|
18
18
|
const target = path.resolve(argv.find(a => !a.startsWith('--')) || '.');
|
|
19
19
|
const NODE = process.execPath;
|
|
20
20
|
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// ci subcommand: generate PR-GPS advisory artifacts (advisory, never breaks builds)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
if (argv.find(a => !a.startsWith('--')) === 'ci') {
|
|
25
|
+
const { knoskyCi } = await import('../core/ci.mjs');
|
|
26
|
+
|
|
27
|
+
// Resolve a named flag's value from argv. Supports --flag=value and --flag value.
|
|
28
|
+
const getArgVal = (name) => {
|
|
29
|
+
const prefix = name + '=';
|
|
30
|
+
const eq = argv.find(a => a.startsWith(prefix));
|
|
31
|
+
if (eq !== undefined) return eq.slice(prefix.length);
|
|
32
|
+
const idx = argv.indexOf(name);
|
|
33
|
+
if (idx !== -1 && idx + 1 < argv.length && !argv[idx + 1].startsWith('--')) {
|
|
34
|
+
return argv[idx + 1];
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ciBase = getArgVal('--base');
|
|
40
|
+
const ciHead = getArgVal('--head');
|
|
41
|
+
const ciCity = getArgVal('--city');
|
|
42
|
+
const ciFailOnSecret = flags.has('--fail-on-secret');
|
|
43
|
+
|
|
44
|
+
const { exitCode, summaryMd, routeJson, safetyJson } = await knoskyCi({
|
|
45
|
+
root: process.cwd(),
|
|
46
|
+
base: ciBase,
|
|
47
|
+
head: ciHead,
|
|
48
|
+
cityPath: ciCity,
|
|
49
|
+
failOnSecret: ciFailOnSecret,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
fs.writeFileSync('knosky-pr-summary.md', summaryMd, 'utf8');
|
|
53
|
+
fs.writeFileSync('knosky-pr-route.json', JSON.stringify(routeJson, null, 2) + '\n', 'utf8');
|
|
54
|
+
fs.writeFileSync('knosky-safety-report.json', JSON.stringify(safetyJson, null, 2) + '\n', 'utf8');
|
|
55
|
+
|
|
56
|
+
console.log(summaryMd);
|
|
57
|
+
process.exit(exitCode);
|
|
58
|
+
}
|
|
59
|
+
|
|
21
60
|
if (!fs.existsSync(target)) { console.error('KnoSky: path not found: ' + target); process.exit(1); }
|
|
22
61
|
|
|
23
62
|
const outDir = path.join(target, '.knosky');
|
package/core/bundle.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// KnoSky bundle engine (KSV2-R4) — intent-manifest builder with fail-closed secret scan.
|
|
2
|
+
// Pure Node stdlib + internal imports only. ESM. No new deps.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
5
|
+
import { join, relative, isAbsolute, sep } from 'node:path';
|
|
6
|
+
import { findSecrets } from './contract.mjs';
|
|
7
|
+
import { makeIntentManifest, validateIntentManifest } from './schema.mjs';
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Internal helpers
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Return true when `ref` is a safe, repo-relative path (not absolute, no "..").
|
|
15
|
+
* @param {string|undefined} ref
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function isSafeRef(ref) {
|
|
19
|
+
if (!ref || typeof ref !== 'string') return false;
|
|
20
|
+
if (ref.startsWith('/')) return false;
|
|
21
|
+
// Windows drive: C:\ or C:/
|
|
22
|
+
if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
|
|
23
|
+
if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve `ref` under `root` and read it — following symlinks via realpathSync,
|
|
29
|
+
* then verifying the REAL resolved path still lands inside `root` before the
|
|
30
|
+
* file is ever opened. `isSafeRef` above only validates the path STRING; it
|
|
31
|
+
* cannot see that a same-named file on disk is actually a symlink pointing
|
|
32
|
+
* outside root. This is the actual read boundary (belt-and-suspenders on top
|
|
33
|
+
* of fs-indexer's own symlink exclusion at index time — this is a separate
|
|
34
|
+
* read path with its own live filesystem access, so it re-checks independently).
|
|
35
|
+
*
|
|
36
|
+
* Never throws. Distinguishes "nothing to scan by design" (no root given —
|
|
37
|
+
* the caller explicitly opted out of content access) from "root given but the
|
|
38
|
+
* file could not be verified safe/readable" (fail-closed: caller must treat
|
|
39
|
+
* this as unscannable, NEVER as clean).
|
|
40
|
+
*
|
|
41
|
+
* @param {string|undefined} root
|
|
42
|
+
* @param {string} ref
|
|
43
|
+
* @returns {{ buf: Buffer } | { noRoot: true } | { unsafe: true }}
|
|
44
|
+
*/
|
|
45
|
+
function safeRead(root, ref) {
|
|
46
|
+
if (!root) return { noRoot: true };
|
|
47
|
+
const full = join(root, ref);
|
|
48
|
+
let real, rootReal;
|
|
49
|
+
try {
|
|
50
|
+
real = realpathSync(full);
|
|
51
|
+
rootReal = realpathSync(root);
|
|
52
|
+
} catch {
|
|
53
|
+
return { unsafe: true }; // missing / broken symlink / permission error — cannot verify
|
|
54
|
+
}
|
|
55
|
+
const rel = relative(rootReal, real);
|
|
56
|
+
if (rel === sep || rel.startsWith('..' + sep) || rel === '..' || isAbsolute(rel)) {
|
|
57
|
+
return { unsafe: true }; // real path escapes root (symlink pointed outside) — refuse
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
return { buf: readFileSync(real) };
|
|
61
|
+
} catch {
|
|
62
|
+
return { unsafe: true };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the hex SHA-256 of a file. Returns "" when unreadable/unsafe/no-root.
|
|
68
|
+
* @param {string|undefined} root Repo root directory, or undefined.
|
|
69
|
+
* @param {string} ref Repo-relative path.
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function sha256OfFile(root, ref) {
|
|
73
|
+
const r = safeRead(root, ref);
|
|
74
|
+
if (!r.buf) return '';
|
|
75
|
+
return createHash('sha256').update(r.buf).digest('hex');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// kcBundle — main export
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build an intent-manifest for the given node ids.
|
|
84
|
+
*
|
|
85
|
+
* @param {object} ctx — retrieve context {city:{nodes}, byId:Map}
|
|
86
|
+
* @param {string[]} ids — node ids to include
|
|
87
|
+
* @param {object} [opts]
|
|
88
|
+
* @param {string} [opts.root] — repo root; used to read files for sha256 + secret scan
|
|
89
|
+
* @param {string|null} [opts.expiry] — ISO-8601 expiry timestamp or null
|
|
90
|
+
* @returns {object} intent-manifest (passes validateIntentManifest)
|
|
91
|
+
*/
|
|
92
|
+
export function kcBundle(ctx, ids, { root, expiry = null } = {}) {
|
|
93
|
+
// Normalise ids to an array of strings
|
|
94
|
+
const idList = Array.isArray(ids) ? ids.map(String) : [];
|
|
95
|
+
|
|
96
|
+
// Build the set of included ids (only those with safe refs)
|
|
97
|
+
const includedSet = new Set();
|
|
98
|
+
const refByid = new Map(); // id -> safe ref string
|
|
99
|
+
|
|
100
|
+
for (const id of idList) {
|
|
101
|
+
const node = ctx.byId.get(id);
|
|
102
|
+
if (!node) continue; // missing node — skip
|
|
103
|
+
const ref = node.provenance && node.provenance.ref;
|
|
104
|
+
if (!isSafeRef(ref)) continue; // absolute or ".." — skip
|
|
105
|
+
includedSet.add(id);
|
|
106
|
+
refByid.set(id, ref);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Build paths[] with sha256
|
|
110
|
+
const paths = [];
|
|
111
|
+
for (const id of idList) {
|
|
112
|
+
if (!includedSet.has(id)) continue;
|
|
113
|
+
const ref = refByid.get(id);
|
|
114
|
+
const sha256 = sha256OfFile(root, ref);
|
|
115
|
+
paths.push({ path: ref, sha256 });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Build edges[] — out-edges within the bundled set
|
|
119
|
+
const edges = [];
|
|
120
|
+
for (const id of idList) {
|
|
121
|
+
if (!includedSet.has(id)) continue;
|
|
122
|
+
const node = ctx.byId.get(id);
|
|
123
|
+
const links = Array.isArray(node.links) ? node.links : [];
|
|
124
|
+
for (const target of links) {
|
|
125
|
+
if (includedSet.has(target)) {
|
|
126
|
+
edges.push({ from: id, to: target });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Secret scan — FAIL-CLOSED. Three outcomes per included file:
|
|
132
|
+
// noRoot → caller opted out of content access entirely; nothing to scan by
|
|
133
|
+
// design (sha256 is also "" in this mode — a deliberate no-read mode,
|
|
134
|
+
// not a failure).
|
|
135
|
+
// unsafe → root WAS given but the file could not be verified safe-and-readable
|
|
136
|
+
// (missing, permission error, or a symlink resolving outside root).
|
|
137
|
+
// We can no longer prove it's secret-free, so we must NOT call it
|
|
138
|
+
// clean — treat it as blocked (the previous behavior silently
|
|
139
|
+
// skipped these and returned "clean", a fail-OPEN bug).
|
|
140
|
+
// text → scanned normally.
|
|
141
|
+
let totalMatches = 0;
|
|
142
|
+
let blocked = false;
|
|
143
|
+
let noRootMode = false;
|
|
144
|
+
|
|
145
|
+
for (const id of idList) {
|
|
146
|
+
if (!includedSet.has(id)) continue;
|
|
147
|
+
const ref = refByid.get(id);
|
|
148
|
+
const r = safeRead(root, ref);
|
|
149
|
+
if (r.noRoot) { noRootMode = true; continue; }
|
|
150
|
+
if (r.unsafe) { blocked = true; continue; }
|
|
151
|
+
const hits = findSecrets(r.buf.toString('utf8'));
|
|
152
|
+
if (hits.length > 0) {
|
|
153
|
+
blocked = true;
|
|
154
|
+
for (const [, count] of hits) totalMatches += count;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
void noRootMode; // no-root is intentionally a no-op above; named for readability
|
|
158
|
+
|
|
159
|
+
const secret_scan = blocked
|
|
160
|
+
? { status: 'blocked', count: totalMatches }
|
|
161
|
+
: { status: 'clean', count: 0 };
|
|
162
|
+
|
|
163
|
+
const manifest = makeIntentManifest({ paths, edges, expiry, secret_scan });
|
|
164
|
+
|
|
165
|
+
// Invariant: manifest MUST pass validateIntentManifest
|
|
166
|
+
const validation = validateIntentManifest(manifest);
|
|
167
|
+
if (!validation.ok) {
|
|
168
|
+
throw new Error('kcBundle produced an invalid intent-manifest: ' + validation.errors.join('; '));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return manifest;
|
|
172
|
+
}
|
package/core/churn.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
|
|
2
2
|
// No commit messages, diffs, hunks, authors, or line-level churn retained.
|
|
3
|
-
|
|
3
|
+
// execFileSync + an args array (not execSync + a shell string) — no argument here is
|
|
4
|
+
// externally controlled today, but this matches the args-array-only discipline used by
|
|
5
|
+
// every other git invocation in this codebase (ci.mjs) and removes the shell entirely,
|
|
6
|
+
// so a future edit that adds a dynamic path/ref here can't reintroduce an injection class.
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
4
8
|
|
|
5
9
|
export function gitChurn(root) {
|
|
6
10
|
const counts = {}, last = {};
|
|
7
11
|
try {
|
|
8
|
-
const out =
|
|
12
|
+
const out = execFileSync('git', ['log', '--since=90.days.ago', '--name-only', '--pretty=format:%ct', '--', '.'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
9
13
|
let ts = 0;
|
|
10
14
|
for (const raw of out.split(/\r?\n/)) {
|
|
11
15
|
const line = raw.trim();
|
package/core/ci.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|