klypix-mcp 1.59.0 → 1.61.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/README.md +5 -3
- package/bin/klypix-install.mjs +1 -1
- package/bin/klypix-project-map.mjs +17 -1
- package/bin/klypix-worker.mjs +34 -1
- package/package.json +1 -1
- package/src/project-graph.mjs +465 -5
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ npx klypix-mcp conformance
|
|
|
79
79
|
|
|
80
80
|
It runs in a temporary fixture and touches nothing else. It checks tool discovery, task memory,
|
|
81
81
|
truthful peer reporting, overlap surfacing, proactive logging, and in-band delivery of a peer note.
|
|
82
|
-
It verifies 12 required coordination behaviours — not the
|
|
82
|
+
It verifies 12 required coordination behaviours — not the 21 tools, and not the retrieval engine.
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
@@ -437,7 +437,7 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
437
437
|
|
|
438
438
|
---
|
|
439
439
|
|
|
440
|
-
## The
|
|
440
|
+
## The 21 verbs
|
|
441
441
|
|
|
442
442
|
| Tool | What it does |
|
|
443
443
|
|---|---|
|
|
@@ -452,7 +452,9 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
452
452
|
| `brain_message` | Session-to-session coordination notes (24h TTL, never written into the brain) |
|
|
453
453
|
| `brain_sync` | Context Gateway: task capsule, active-task peers, exact-file overlap, one-time alerts, timing |
|
|
454
454
|
| `brain_connect` | Find and draw related-but-unlinked cards |
|
|
455
|
-
| `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context, with exact-path review proposals;
|
|
455
|
+
| `project_map_context` | Read-only, bounded code-graph evidence beside correction-aware brain context, with exact-path review proposals; external artifacts (e.g. Graphify) are supported but never installed or run locally |
|
|
456
|
+
| `project_map_scan` | KLYPIX's own zero-install scanner: gitignore-aware file inventory + file-level import edges (relative, tsconfig-alias, and monorepo-workspace imports resolved) written to `klypix-map/graph.json` — which then serves `project_map_context` automatically |
|
|
457
|
+
| `project_map_drift` | Read-only drift report: brain cards whose referenced files are gone or moved (with rename candidates), plus a headline when the checkout itself is behind its origin default branch |
|
|
456
458
|
| `canvas_view` | Returns the board as a structured render spec plus a text summary, and declares an MCP Apps (SEP-1865) UI resource |
|
|
457
459
|
| `read_canvas` | A canvas as markdown (cards, connection graph, `[[links]]`, `#tags`) |
|
|
458
460
|
| `search_canvases` | Search across canvases by name and content |
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -293,7 +293,7 @@ try {
|
|
|
293
293
|
// canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
|
|
294
294
|
// file must never get a JS-comment banner) beside the flat server, which
|
|
295
295
|
// resolves it via its ./canvas-view-app.html candidate path.
|
|
296
|
-
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
296
|
+
for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
|
|
297
297
|
const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
|
|
298
298
|
}
|
|
299
299
|
for (const [src, dst] of [
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { discoverProjectGraph } from '../src/project-graph.mjs';
|
|
5
|
+
import { discoverProjectGraph, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
|
|
6
6
|
|
|
7
7
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const template = path.resolve(here, '..', 'examples', 'github', 'klypix-project-map.yml');
|
|
@@ -22,8 +22,11 @@ function usage() {
|
|
|
22
22
|
'KLYPIX Project Map',
|
|
23
23
|
'',
|
|
24
24
|
' klypix-project-map status [project]',
|
|
25
|
+
' klypix-project-map scan [project] native no-install map: file inventory + import edges -> klypix-map/graph.json',
|
|
26
|
+
' klypix-project-map drift [project] check brain cards\' file references against the repo\'s real files (read-only)',
|
|
25
27
|
' klypix-project-map setup-github [project] [--force]',
|
|
26
28
|
'',
|
|
29
|
+
'scan writes only its own artifact (klypix-map/graph.json) inside the project; drift writes nothing.',
|
|
27
30
|
'setup-github installs a read-only, pinned GitHub Actions workflow. It never installs or runs a provider on your computer.',
|
|
28
31
|
].join('\n'));
|
|
29
32
|
}
|
|
@@ -42,6 +45,19 @@ try {
|
|
|
42
45
|
console.log(`GitHub workflow: ${fs.existsSync(workflow) ? 'installed' : 'not installed'}`);
|
|
43
46
|
process.exit(0);
|
|
44
47
|
}
|
|
48
|
+
if (command === 'scan') {
|
|
49
|
+
const result = scanNativeProjectMap({ project: root });
|
|
50
|
+
console.log(`Project: ${result.projectRoot}`);
|
|
51
|
+
console.log(`Artifact: ${result.artifactRelative} (${result.viaGit ? 'gitignore-aware' : 'walk fallback'}${result.truncated ? ', TRUNCATED at file cap' : ''})`);
|
|
52
|
+
console.log(`Files: ${result.counts.files.toLocaleString()} | parsed code files: ${result.counts.parsedCodeFiles.toLocaleString()} | import edges: ${result.counts.importEdges.toLocaleString()}`);
|
|
53
|
+
console.log(`Workspace packages: ${result.counts.workspacePackages} | external packages: ${result.counts.externalPackages} | minified skipped: ${result.counts.skippedMinified}`);
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
if (command === 'drift') {
|
|
57
|
+
const result = await checkBrainDrift({ project: root });
|
|
58
|
+
console.log(brainDriftMarkdown(result));
|
|
59
|
+
process.exit(result.status === 'ready' ? 0 : 1);
|
|
60
|
+
}
|
|
45
61
|
if (command !== 'setup-github') {
|
|
46
62
|
usage();
|
|
47
63
|
process.exit(1);
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
opBrainInsights, opBrainConnect, opBrainReconcile, opBrainGarden, opCreateCanvas, opAddToCanvas, opBrainNote, opBrainMessage, opBrainAsk, opBrainChallenge, opCanvasView, opBrainLens,
|
|
31
31
|
opBrainTaskContext,
|
|
32
32
|
} from '../src/klypix-core.mjs';
|
|
33
|
-
import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks } from '../src/project-graph.mjs';
|
|
33
|
+
import { compareProjectGraphResults, projectGraphContextMarkdown, queryProjectGraph, suggestProjectGraphBrainLinks, scanNativeProjectMap, checkBrainDrift, brainDriftMarkdown } from '../src/project-graph.mjs';
|
|
34
34
|
import { auditProject, compactAgentsBrief, linkProject, mcpServerEntry } from '../src/agent-rules.mjs';
|
|
35
35
|
import { createMcpPresence, KLYPIX_MCP_INSTRUCTIONS } from '../src/mcp-presence.mjs';
|
|
36
36
|
import {
|
|
@@ -348,6 +348,39 @@ server.registerTool('project_map_context', {
|
|
|
348
348
|
});
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
+
server.registerTool('project_map_scan', {
|
|
352
|
+
title: 'Scan the project natively - no-install file inventory + import map',
|
|
353
|
+
description: 'KLYPIX\'s own zero-install scanner: walks the repo (gitignore-aware, junk dirs like browser profiles and build output excluded), extracts FILE-LEVEL import edges for the JS/TS family (relative, tsconfig-alias, and monorepo-workspace imports resolved), and writes klypix-map/graph.json inside the project — the only thing it ever writes. The artifact then serves project_map_context automatically when no Graphify artifact exists. It deliberately does not build a per-symbol AST graph; deeper external artifacts remain importable through the same door.',
|
|
354
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
355
|
+
inputSchema: {
|
|
356
|
+
project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
|
|
357
|
+
},
|
|
358
|
+
}, async ({ project }) => {
|
|
359
|
+
const root = typeof project === 'string' && project.trim() ? path.resolve(project.trim()) : path.resolve(mcpPresence.vault);
|
|
360
|
+
const result = scanNativeProjectMap({ project: root });
|
|
361
|
+
return toContent({
|
|
362
|
+
blocks: [{ kind: 'text', text: `Native project map written to \`${result.artifactRelative}\` (${result.viaGit ? 'gitignore-aware' : 'walk fallback'}${result.truncated ? '; TRUNCATED at the file cap' : ''}).\nFiles: ${result.counts.files.toLocaleString()}; parsed code files: ${result.counts.parsedCodeFiles.toLocaleString()}; import edges: ${result.counts.importEdges.toLocaleString()}; workspace packages: ${result.counts.workspacePackages}; minified skipped: ${result.counts.skippedMinified}.` }],
|
|
363
|
+
structured: result,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
server.registerTool('project_map_drift', {
|
|
368
|
+
title: 'Check brain cards against the repo\'s real files (drift report)',
|
|
369
|
+
description: 'Read-only drift check: every brain card that references THIS project\'s files is verified against the working tree. Reports cards whose referenced files are gone or moved (with rename candidates by unique basename), and headlines when the checkout itself is behind its origin default branch — in that state a "missing" file may simply not be in this checkout. Slash-joined name enumerations and other-project paths are recognized and skipped, not reported as drift. Nothing is written; fix cards with a CORRECTION marker or by editing them in KLYPIX.',
|
|
370
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
371
|
+
inputSchema: {
|
|
372
|
+
project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
|
|
373
|
+
brain: z.string().optional().describe('Optional brain filename/path inside the project. Defaults to brain.klypix / brain.any.'),
|
|
374
|
+
},
|
|
375
|
+
}, async ({ project, brain }) => {
|
|
376
|
+
const root = typeof project === 'string' && project.trim() ? path.resolve(project.trim()) : path.resolve(mcpPresence.vault);
|
|
377
|
+
const result = await checkBrainDrift({ project: root, brain });
|
|
378
|
+
return toContent({
|
|
379
|
+
blocks: [{ kind: 'text', text: brainDriftMarkdown(result) }],
|
|
380
|
+
structured: result,
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
351
384
|
server.registerTool('brain_challenge', {
|
|
352
385
|
title: 'Challenge a decision against the brain (argue back with receipts)',
|
|
353
386
|
description: 'BEFORE committing to a significant decision, ask the brain to ARGUE BACK: prior decisions that deterministically contradict the claim (correction-cue / opposite-polarity evidence — never mere topical similarity), 🛠 standing rules that dispute it, approaches tried before and REVERSED (with the correction/successor as the receipt), and open questions it collides with. Candidates, not verdicts — silence means "no deterministic contradiction signal", not verified consistency. Cards captured by a DIFFERENT agent are flagged so you coordinate instead of overriding. Dismiss a confirmed-false pair (after capturing the claim) with brain_connect pairs + relationship:"not_contradiction". Defaults to the project brain ("brain").',
|
package/package.json
CHANGED
package/src/project-graph.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
// Provider-neutral
|
|
1
|
+
// Provider-neutral project-graph adapter, plus KLYPIX's own native scanner.
|
|
2
2
|
//
|
|
3
|
-
// Graphify is the first supported producer, but callers only see the
|
|
4
|
-
// KLYPIX shape below.
|
|
5
|
-
//
|
|
3
|
+
// Graphify is the first supported external producer, but callers only see the
|
|
4
|
+
// stable KLYPIX shape below. Since 1.61.0 the module can also PRODUCE a map
|
|
5
|
+
// itself — scanNativeProjectMap walks the repo and extracts file-level import
|
|
6
|
+
// edges with zero third-party installs, writing only into its own artifact
|
|
7
|
+
// dir (klypix-map/). Nothing here ever runs Graphify, edits source files, or
|
|
8
|
+
// writes into brain.klypix.
|
|
6
9
|
|
|
7
10
|
import fs from 'fs';
|
|
8
11
|
import path from 'path';
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import { parseKlypix } from './klypix-format.mjs';
|
|
9
14
|
|
|
10
15
|
export const PROJECT_GRAPH_SCHEMA_VERSION = 2;
|
|
11
16
|
export const DEFAULT_PROJECT_GRAPH = 'graphify-out/graph.json';
|
|
@@ -184,7 +189,16 @@ function cacheLimitBytes() {
|
|
|
184
189
|
|
|
185
190
|
export function discoverProjectGraph({ project, graphPath } = {}) {
|
|
186
191
|
const projectRoot = realRoot(project);
|
|
187
|
-
|
|
192
|
+
// No explicit path: prefer the Graphify artifact for continuity, but fall
|
|
193
|
+
// back to KLYPIX's own native artifact so a no-install scan serves
|
|
194
|
+
// project_map_context with zero configuration.
|
|
195
|
+
let effectivePath = graphPath;
|
|
196
|
+
if (!graphPath) {
|
|
197
|
+
const graphifyDefault = path.resolve(projectRoot, DEFAULT_PROJECT_GRAPH);
|
|
198
|
+
const nativeDefault = path.resolve(projectRoot, NATIVE_MAP_ARTIFACT);
|
|
199
|
+
if (!fs.existsSync(graphifyDefault) && fs.existsSync(nativeDefault)) effectivePath = NATIVE_MAP_ARTIFACT;
|
|
200
|
+
}
|
|
201
|
+
const filePath = resolveInsideProject(projectRoot, effectivePath, DEFAULT_PROJECT_GRAPH);
|
|
188
202
|
const artifactDir = path.dirname(filePath);
|
|
189
203
|
const htmlPath = resolveInsideProject(projectRoot, path.join(artifactDir, 'graph.html'), DEFAULT_PROJECT_GRAPH_HTML);
|
|
190
204
|
const reportPath = resolveInsideProject(projectRoot, path.join(artifactDir, 'GRAPH_REPORT.md'), DEFAULT_PROJECT_GRAPH_REPORT);
|
|
@@ -437,3 +451,449 @@ export function projectGraphContextMarkdown(result) {
|
|
|
437
451
|
export function clearProjectGraphCache() {
|
|
438
452
|
cache = null;
|
|
439
453
|
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Native project map — the no-install scanner.
|
|
457
|
+
//
|
|
458
|
+
// Extracts exactly what the 2026-08-06 value evaluation proved useful: the
|
|
459
|
+
// full file inventory plus FILE-LEVEL import edges (who imports whom). It
|
|
460
|
+
// deliberately does NOT rebuild a per-symbol AST graph — that depth belongs to
|
|
461
|
+
// external providers like Graphify, importable through the same artifact door.
|
|
462
|
+
|
|
463
|
+
export const NATIVE_MAP_ARTIFACT = 'klypix-map/graph.json';
|
|
464
|
+
|
|
465
|
+
const SCAN_MAX_FILES = 20_000;
|
|
466
|
+
const SCAN_MAX_PARSE_BYTES = 2 * 1024 * 1024;
|
|
467
|
+
const CODE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts']);
|
|
468
|
+
const INVENTORY_EXTENSIONS = new Set([
|
|
469
|
+
...CODE_EXTENSIONS,
|
|
470
|
+
'.json', '.md', '.mdx', '.yml', '.yaml', '.toml', '.css', '.scss', '.html', '.sql',
|
|
471
|
+
'.py', '.rs', '.go', '.swift', '.kt', '.java', '.rb', '.sh', '.ps1', '.vue', '.svelte',
|
|
472
|
+
]);
|
|
473
|
+
// Junk that polluted the 2026-08-06 field graph (browser profiles, worktrees,
|
|
474
|
+
// build output) — excluded even when a repo forgets to gitignore it.
|
|
475
|
+
const SCAN_EXCLUDED_DIRS = new Set([
|
|
476
|
+
'node_modules', '.git', 'dist', 'dist-electron', 'build', 'release', 'out', 'coverage',
|
|
477
|
+
'.next', '.turbo', '.cache', 'tmp', '.worktrees', '.codex-artifacts', '.codex-tmp',
|
|
478
|
+
'graphify-out', 'klypix-map', '__pycache__', '.venv', 'venv',
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
function runGit(root, args) {
|
|
482
|
+
try {
|
|
483
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 });
|
|
484
|
+
if (result.status !== 0) return null;
|
|
485
|
+
return result.stdout;
|
|
486
|
+
} catch { return null; }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function excludedByDir(rel) {
|
|
490
|
+
return rel.split('/').some(segment => SCAN_EXCLUDED_DIRS.has(segment.toLocaleLowerCase('en-US')));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function walkInventoryFallback(root) {
|
|
494
|
+
const out = [];
|
|
495
|
+
const queue = ['.'];
|
|
496
|
+
while (queue.length && out.length < SCAN_MAX_FILES) {
|
|
497
|
+
const relDir = queue.shift();
|
|
498
|
+
let entries;
|
|
499
|
+
try { entries = fs.readdirSync(path.join(root, relDir), { withFileTypes: true }); } catch { continue; }
|
|
500
|
+
for (const entry of entries) {
|
|
501
|
+
const rel = relDir === '.' ? entry.name : `${relDir}/${entry.name}`;
|
|
502
|
+
if (entry.isSymbolicLink()) continue;
|
|
503
|
+
if (entry.isDirectory()) {
|
|
504
|
+
if (!SCAN_EXCLUDED_DIRS.has(entry.name.toLocaleLowerCase('en-US'))) queue.push(rel);
|
|
505
|
+
} else if (entry.isFile()) {
|
|
506
|
+
out.push(rel);
|
|
507
|
+
if (out.length >= SCAN_MAX_FILES) break;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function listScanInventory(root) {
|
|
515
|
+
// git ls-files (tracked + untracked-not-ignored) honors .gitignore and is the
|
|
516
|
+
// authoritative "what belongs to this project" answer; the manual walk is the
|
|
517
|
+
// no-git fallback. Both still pass the junk-dir filter — field evidence
|
|
518
|
+
// showed repos forget to ignore browser profiles and build drops.
|
|
519
|
+
const gitOut = runGit(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
|
|
520
|
+
const rawList = gitOut != null
|
|
521
|
+
? gitOut.split('\0').filter(Boolean).map(entry => entry.replace(/\\/g, '/'))
|
|
522
|
+
: walkInventoryFallback(root);
|
|
523
|
+
const files = [];
|
|
524
|
+
let truncated = false;
|
|
525
|
+
for (const rel of rawList) {
|
|
526
|
+
if (excludedByDir(rel)) continue;
|
|
527
|
+
const extension = path.extname(rel).toLocaleLowerCase('en-US');
|
|
528
|
+
if (!INVENTORY_EXTENSIONS.has(extension)) continue;
|
|
529
|
+
if (files.length >= SCAN_MAX_FILES) { truncated = true; break; }
|
|
530
|
+
files.push(rel);
|
|
531
|
+
}
|
|
532
|
+
files.sort();
|
|
533
|
+
return { files, truncated, viaGit: gitOut != null };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function looksMinified(root, rel, sizeBytes) {
|
|
537
|
+
const base = path.basename(rel).toLocaleLowerCase('en-US');
|
|
538
|
+
if (base.includes('.min.')) return true;
|
|
539
|
+
if (sizeBytes < 50 * 1024) return false;
|
|
540
|
+
try {
|
|
541
|
+
const fd = fs.openSync(path.join(root, rel), 'r');
|
|
542
|
+
const buf = Buffer.alloc(4096);
|
|
543
|
+
const read = fs.readSync(fd, buf, 0, 4096, 0);
|
|
544
|
+
fs.closeSync(fd);
|
|
545
|
+
const sample = buf.toString('utf8', 0, read);
|
|
546
|
+
const firstNewline = sample.indexOf('\n');
|
|
547
|
+
return firstNewline === -1 || firstNewline > 2000;
|
|
548
|
+
} catch { return false; }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const IMPORT_PATTERNS = [
|
|
552
|
+
/(?:^|[^\w$.])import\s+(?:[\w$*{},\s]+?from\s+)?['"]([^'"\n]+)['"]/gm,
|
|
553
|
+
/(?:^|[^\w$.])export\s+[\w$*{},\s]*?from\s+['"]([^'"\n]+)['"]/gm,
|
|
554
|
+
/(?:^|[^\w$.])require\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
555
|
+
/(?:^|[^\w$.])import\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
function extractImportSpecifiers(source) {
|
|
559
|
+
const specifiers = new Set();
|
|
560
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
561
|
+
pattern.lastIndex = 0;
|
|
562
|
+
for (const match of source.matchAll(pattern)) specifiers.add(match[1]);
|
|
563
|
+
}
|
|
564
|
+
return [...specifiers];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const RESOLVE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json'];
|
|
568
|
+
|
|
569
|
+
function tryResolveFile(candidate, fileSet) {
|
|
570
|
+
const normal = candidate.replace(/\\/g, '/').replace(/\/{2,}/g, '/').replace(/^\.\//, '');
|
|
571
|
+
if (fileSet.has(normal)) return normal;
|
|
572
|
+
for (const extension of RESOLVE_EXTENSIONS) {
|
|
573
|
+
if (fileSet.has(normal + extension)) return normal + extension;
|
|
574
|
+
}
|
|
575
|
+
for (const extension of RESOLVE_EXTENSIONS) {
|
|
576
|
+
if (fileSet.has(`${normal}/index${extension}`)) return `${normal}/index${extension}`;
|
|
577
|
+
}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function collectWorkspacePackages(root, files) {
|
|
582
|
+
const packages = new Map();
|
|
583
|
+
for (const rel of files) {
|
|
584
|
+
if (path.basename(rel) !== 'package.json') continue;
|
|
585
|
+
try {
|
|
586
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
587
|
+
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
|
|
588
|
+
if (name && !packages.has(name)) packages.set(name, path.dirname(rel).replace(/^\.$/, ''));
|
|
589
|
+
} catch { /* unparseable package.json is not an error for the scan */ }
|
|
590
|
+
}
|
|
591
|
+
return packages;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Per-package alias scopes: in a monorepo every package can carry its own
|
|
595
|
+
// tsconfig `paths` (typically `@/*` -> `./src/*`), scoped to files under that
|
|
596
|
+
// package's directory. A root-only reading resolved 0 of a field repo's 56
|
|
597
|
+
// `@/lib/utils` importers; nearest-tsconfig scoping resolves them all.
|
|
598
|
+
function collectTsconfigAliasScopes(root, files) {
|
|
599
|
+
const scopes = [];
|
|
600
|
+
for (const rel of files) {
|
|
601
|
+
if (path.basename(rel) !== 'tsconfig.json') continue;
|
|
602
|
+
const dir = path.dirname(rel).replace(/^\.$/, '');
|
|
603
|
+
try {
|
|
604
|
+
const raw = fs.readFileSync(path.join(root, rel), 'utf8')
|
|
605
|
+
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
606
|
+
.replace(/,\s*([}\]])/g, '$1');
|
|
607
|
+
const parsed = JSON.parse(raw);
|
|
608
|
+
const paths = parsed?.compilerOptions?.paths || {};
|
|
609
|
+
const baseUrl = String(parsed?.compilerOptions?.baseUrl || '.').replace(/^\.\//, '').replace(/^\.$/, '');
|
|
610
|
+
const aliases = [];
|
|
611
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
612
|
+
if (!pattern.endsWith('/*') || !Array.isArray(targets) || !targets.length) continue;
|
|
613
|
+
const target = String(targets[0]);
|
|
614
|
+
if (!target.endsWith('/*')) continue;
|
|
615
|
+
const prefix = pattern.slice(0, -1);
|
|
616
|
+
const replacement = [dir, baseUrl, target.slice(0, -1).replace(/^\.\//, '')]
|
|
617
|
+
.filter(Boolean).join('/').replace(/\/{2,}/g, '/');
|
|
618
|
+
aliases.push({ prefix, replacement });
|
|
619
|
+
}
|
|
620
|
+
if (aliases.length) scopes.push({ dir, aliases });
|
|
621
|
+
} catch { /* unparseable tsconfig — that scope stays alias-free */ }
|
|
622
|
+
}
|
|
623
|
+
// Longest directory first so the nearest enclosing tsconfig wins.
|
|
624
|
+
scopes.sort((a, b) => b.dir.length - a.dir.length);
|
|
625
|
+
return scopes;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function aliasesForFile(fromRel, scopes) {
|
|
629
|
+
const normal = fromRel.replace(/\\/g, '/');
|
|
630
|
+
for (const scope of scopes) {
|
|
631
|
+
if (!scope.dir || normal.startsWith(`${scope.dir}/`)) return scope.aliases;
|
|
632
|
+
}
|
|
633
|
+
return [];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function resolveImport(fromRel, specifier, fileSet, workspaces, aliasScopes) {
|
|
637
|
+
if (!specifier || specifier.startsWith('node:') || specifier.startsWith('data:')) return null;
|
|
638
|
+
if (specifier.startsWith('.')) {
|
|
639
|
+
const joined = path.posix.join(path.posix.dirname(fromRel.replace(/\\/g, '/')), specifier);
|
|
640
|
+
if (joined.startsWith('..')) return null;
|
|
641
|
+
return tryResolveFile(joined, fileSet);
|
|
642
|
+
}
|
|
643
|
+
for (const alias of aliasesForFile(fromRel, aliasScopes)) {
|
|
644
|
+
if (specifier.startsWith(alias.prefix)) {
|
|
645
|
+
return tryResolveFile(`${alias.replacement}${specifier.slice(alias.prefix.length)}`, fileSet);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
for (const [name, dir] of workspaces) {
|
|
649
|
+
if (specifier === name) {
|
|
650
|
+
return tryResolveFile(`${dir}/src/index`, fileSet)
|
|
651
|
+
|| tryResolveFile(`${dir}/index`, fileSet)
|
|
652
|
+
|| (fileSet.has(`${dir}/package.json`) ? `${dir}/package.json` : null);
|
|
653
|
+
}
|
|
654
|
+
if (specifier.startsWith(`${name}/`)) {
|
|
655
|
+
const sub = `${dir}/${specifier.slice(name.length + 1)}`;
|
|
656
|
+
return tryResolveFile(sub, fileSet) || tryResolveFile(`${dir}/src/${specifier.slice(name.length + 1)}`, fileSet);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Scan the project natively: full file inventory + file-level import edges.
|
|
664
|
+
* Writes klypix-map/graph.json (NetworkX node-link, the same shape external
|
|
665
|
+
* providers use) unless write:false. The artifact is disposable local cache.
|
|
666
|
+
*/
|
|
667
|
+
export function scanNativeProjectMap({ project, write = true } = {}) {
|
|
668
|
+
const projectRoot = realRoot(project);
|
|
669
|
+
const inventory = listScanInventory(projectRoot);
|
|
670
|
+
const fileSet = new Set(inventory.files);
|
|
671
|
+
const workspaces = collectWorkspacePackages(projectRoot, inventory.files);
|
|
672
|
+
const aliasScopes = collectTsconfigAliasScopes(projectRoot, inventory.files);
|
|
673
|
+
|
|
674
|
+
const links = [];
|
|
675
|
+
const externals = new Map();
|
|
676
|
+
let parsedFiles = 0;
|
|
677
|
+
let skippedMinified = 0;
|
|
678
|
+
for (const rel of inventory.files) {
|
|
679
|
+
if (!CODE_EXTENSIONS.has(path.extname(rel).toLocaleLowerCase('en-US'))) continue;
|
|
680
|
+
let stat;
|
|
681
|
+
try { stat = fs.statSync(path.join(projectRoot, rel)); } catch { continue; }
|
|
682
|
+
if (stat.size > SCAN_MAX_PARSE_BYTES) continue;
|
|
683
|
+
if (looksMinified(projectRoot, rel, stat.size)) { skippedMinified++; continue; }
|
|
684
|
+
let source;
|
|
685
|
+
try { source = fs.readFileSync(path.join(projectRoot, rel), 'utf8'); } catch { continue; }
|
|
686
|
+
parsedFiles++;
|
|
687
|
+
for (const specifier of extractImportSpecifiers(source)) {
|
|
688
|
+
const resolved = resolveImport(rel, specifier, fileSet, workspaces, aliasScopes);
|
|
689
|
+
if (resolved && resolved !== rel) {
|
|
690
|
+
links.push({ source: rel, target: resolved, relation: 'imports', confidence: 'EXTRACTED' });
|
|
691
|
+
} else if (!resolved && !specifier.startsWith('.')) {
|
|
692
|
+
const packageName = specifier.startsWith('@')
|
|
693
|
+
? specifier.split('/').slice(0, 2).join('/')
|
|
694
|
+
: specifier.split('/')[0];
|
|
695
|
+
externals.set(packageName, (externals.get(packageName) || 0) + 1);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const nodes = inventory.files.map(rel => ({
|
|
701
|
+
id: rel,
|
|
702
|
+
label: path.basename(rel),
|
|
703
|
+
file_type: 'code',
|
|
704
|
+
source_file: rel,
|
|
705
|
+
}));
|
|
706
|
+
const artifact = {
|
|
707
|
+
directed: true,
|
|
708
|
+
multigraph: false,
|
|
709
|
+
graph: {
|
|
710
|
+
generator: 'klypix-native-map',
|
|
711
|
+
schema_version: 1,
|
|
712
|
+
generated_at: new Date().toISOString(),
|
|
713
|
+
via_git: inventory.viaGit,
|
|
714
|
+
truncated: inventory.truncated,
|
|
715
|
+
external_packages: [...externals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40)
|
|
716
|
+
.map(([name, count]) => ({ name, imports: count })),
|
|
717
|
+
},
|
|
718
|
+
nodes,
|
|
719
|
+
links,
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
let artifactPath = null;
|
|
723
|
+
if (write) {
|
|
724
|
+
const dir = path.join(projectRoot, path.dirname(NATIVE_MAP_ARTIFACT));
|
|
725
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
726
|
+
artifactPath = path.join(projectRoot, ...NATIVE_MAP_ARTIFACT.split('/'));
|
|
727
|
+
const temporary = `${artifactPath}.tmp`;
|
|
728
|
+
fs.writeFileSync(temporary, JSON.stringify(artifact));
|
|
729
|
+
fs.renameSync(temporary, artifactPath);
|
|
730
|
+
clearProjectGraphCache();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
status: 'ready',
|
|
735
|
+
projectRoot,
|
|
736
|
+
artifactPath,
|
|
737
|
+
artifactRelative: write ? NATIVE_MAP_ARTIFACT : null,
|
|
738
|
+
counts: {
|
|
739
|
+
files: inventory.files.length,
|
|
740
|
+
parsedCodeFiles: parsedFiles,
|
|
741
|
+
importEdges: links.length,
|
|
742
|
+
workspacePackages: workspaces.size,
|
|
743
|
+
externalPackages: externals.size,
|
|
744
|
+
skippedMinified,
|
|
745
|
+
},
|
|
746
|
+
truncated: inventory.truncated,
|
|
747
|
+
viaGit: inventory.viaGit,
|
|
748
|
+
graph: write ? null : artifact,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// Brain drift — check brain cards' file references against repo reality.
|
|
754
|
+
//
|
|
755
|
+
// Field-derived false-positive taxonomy (2026-08-06 adversarial run) is baked
|
|
756
|
+
// in: slash-joined enumerations are rejected, refs that resolve nowhere near
|
|
757
|
+
// this project are treated as another repo's mentions and skipped, and a
|
|
758
|
+
// checkout that is behind its origin default branch is reported as its own
|
|
759
|
+
// headline instead of blaming the cards.
|
|
760
|
+
|
|
761
|
+
const DRIFT_REF_PATTERN = /[A-Za-z0-9_@](?:[A-Za-z0-9_.@-]|[\\/])*\.[A-Za-z0-9]{1,8}/g;
|
|
762
|
+
const DRIFT_TAG_PATTERN = /#file-([a-z0-9-]+)/g;
|
|
763
|
+
|
|
764
|
+
function slugifyStem(basename) {
|
|
765
|
+
return basename.replace(/\.[^.]+$/, '').toLocaleLowerCase('en-US')
|
|
766
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function isEnumerationToken(token) {
|
|
770
|
+
// "AGENTS.md/.cursor/.windsurf" — a NON-final segment carrying a file
|
|
771
|
+
// extension means this is a prose list of names, not one path.
|
|
772
|
+
const segments = token.split('/');
|
|
773
|
+
return segments.slice(0, -1).some(segment => /\.[A-Za-z0-9]{1,8}$/.test(segment) && segment !== '.' && segment !== '..');
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function driftRefsFromText(text) {
|
|
777
|
+
const refs = new Set();
|
|
778
|
+
for (const match of String(text || '').matchAll(DRIFT_REF_PATTERN)) {
|
|
779
|
+
const token = match[0].replace(/\\/g, '/').replace(/^\.\//, '');
|
|
780
|
+
if (!token.includes('/')) continue;
|
|
781
|
+
if (/^[A-Za-z]:\//.test(token) || token.startsWith('http') || token.startsWith('//')) continue;
|
|
782
|
+
if (isEnumerationToken(token)) continue;
|
|
783
|
+
refs.add(token);
|
|
784
|
+
}
|
|
785
|
+
return [...refs];
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function checkoutStaleness(root) {
|
|
789
|
+
const head = runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
790
|
+
const defaultRef = runGit(root, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
791
|
+
const target = (defaultRef || '').trim() || ['origin/main', 'origin/master']
|
|
792
|
+
.find(ref => runGit(root, ['rev-parse', '--verify', '--quiet', ref]) != null);
|
|
793
|
+
if (!target) return null;
|
|
794
|
+
const behindRaw = runGit(root, ['rev-list', '--count', `HEAD..${target}`]);
|
|
795
|
+
const behind = Number((behindRaw || '').trim());
|
|
796
|
+
if (!Number.isFinite(behind)) return null;
|
|
797
|
+
return { branch: (head || '').trim() || null, comparedTo: target, behind };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Check every text card in the project brain against the repo's real files.
|
|
802
|
+
* Read-only: never writes to the brain or the repo.
|
|
803
|
+
*/
|
|
804
|
+
export async function checkBrainDrift({ project, brain } = {}) {
|
|
805
|
+
const projectRoot = realRoot(project);
|
|
806
|
+
const brainPath = brain
|
|
807
|
+
? path.resolve(projectRoot, brain)
|
|
808
|
+
: ['brain.klypix', 'brain.any'].map(name => path.join(projectRoot, name)).find(file => fs.existsSync(file));
|
|
809
|
+
if (!brainPath || !fs.existsSync(brainPath)) {
|
|
810
|
+
return { status: 'no-brain', projectRoot, brainPath: brainPath || null, cards: [], summary: null, staleCheckout: null };
|
|
811
|
+
}
|
|
812
|
+
const parsed = await parseKlypix(fs.readFileSync(brainPath));
|
|
813
|
+
const cards = Array.isArray(parsed?.struct?.cards) ? parsed.struct.cards : [];
|
|
814
|
+
|
|
815
|
+
const inventory = listScanInventory(projectRoot);
|
|
816
|
+
const fileSet = new Set(inventory.files);
|
|
817
|
+
const byBasename = new Map();
|
|
818
|
+
const bySlug = new Map();
|
|
819
|
+
const rootEntries = new Set(fs.readdirSync(projectRoot).map(entry => entry.toLocaleLowerCase('en-US')));
|
|
820
|
+
for (const file of inventory.files) {
|
|
821
|
+
const basename = path.basename(file).toLocaleLowerCase('en-US');
|
|
822
|
+
byBasename.set(basename, [...(byBasename.get(basename) || []), file]);
|
|
823
|
+
const slug = slugifyStem(basename);
|
|
824
|
+
if (slug) bySlug.set(slug, [...(bySlug.get(slug) || []), file]);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const drifted = [];
|
|
828
|
+
let cardsWithRefs = 0;
|
|
829
|
+
let refsOk = 0;
|
|
830
|
+
let refsChecked = 0;
|
|
831
|
+
for (const card of cards) {
|
|
832
|
+
const text = card?.text || '';
|
|
833
|
+
if (!text) continue;
|
|
834
|
+
const findings = [];
|
|
835
|
+
const refs = driftRefsFromText(text);
|
|
836
|
+
let sawCheckable = false;
|
|
837
|
+
for (const ref of refs) {
|
|
838
|
+
const lower = ref.toLocaleLowerCase('en-US');
|
|
839
|
+
const firstSegment = lower.split('/')[0];
|
|
840
|
+
const inRepoShape = fileSet.has(ref) || fileSet.has(lower) || rootEntries.has(firstSegment);
|
|
841
|
+
if (!inRepoShape) continue; // likely another project's path — not our claim to judge
|
|
842
|
+
sawCheckable = true;
|
|
843
|
+
refsChecked++;
|
|
844
|
+
if (fileSet.has(ref) || fileSet.has(lower) || fs.existsSync(path.join(projectRoot, ref))) { refsOk++; continue; }
|
|
845
|
+
const movedTo = (byBasename.get(path.basename(lower)) || []).slice(0, 3);
|
|
846
|
+
findings.push({ ref, status: movedTo.length ? 'moved' : 'missing', movedTo });
|
|
847
|
+
}
|
|
848
|
+
for (const match of text.matchAll(DRIFT_TAG_PATTERN)) {
|
|
849
|
+
const candidates = bySlug.get(match[1]);
|
|
850
|
+
if (candidates?.length === 1) { sawCheckable = true; refsChecked++; refsOk++; }
|
|
851
|
+
}
|
|
852
|
+
if (sawCheckable) cardsWithRefs++;
|
|
853
|
+
if (findings.length) {
|
|
854
|
+
drifted.push({
|
|
855
|
+
id: card.id || null,
|
|
856
|
+
area: card.area || null,
|
|
857
|
+
preview: shortString(text, 120),
|
|
858
|
+
findings,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return {
|
|
864
|
+
status: 'ready',
|
|
865
|
+
projectRoot,
|
|
866
|
+
brainPath,
|
|
867
|
+
staleCheckout: checkoutStaleness(projectRoot),
|
|
868
|
+
summary: {
|
|
869
|
+
cards: cards.length,
|
|
870
|
+
cardsWithCheckableRefs: cardsWithRefs,
|
|
871
|
+
refsChecked,
|
|
872
|
+
refsOk,
|
|
873
|
+
driftedCards: drifted.length,
|
|
874
|
+
},
|
|
875
|
+
cards: drifted,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export function brainDriftMarkdown(result) {
|
|
880
|
+
if (result.status === 'no-brain') {
|
|
881
|
+
return '# Brain drift\n\nNo brain.klypix found in this project — nothing to check.';
|
|
882
|
+
}
|
|
883
|
+
const lines = ['# Brain drift — cards vs the repo\'s real files'];
|
|
884
|
+
const stale = result.staleCheckout;
|
|
885
|
+
if (stale && stale.behind > 0) {
|
|
886
|
+
lines.push(`⚠️ **This checkout is ${stale.behind} commit(s) behind \`${stale.comparedTo}\`** (branch \`${stale.branch || '?'}\`, as of the last fetch). Files reported missing below may simply not be in THIS checkout — pull before trusting the card verdicts.`);
|
|
887
|
+
}
|
|
888
|
+
const s = result.summary;
|
|
889
|
+
lines.push(`Checked ${s.cards} card(s); ${s.cardsWithCheckableRefs} referenced this repo's files (${s.refsChecked} reference(s), ${s.refsOk} still valid). **${s.driftedCards} card(s) reference files that are gone or moved.**`);
|
|
890
|
+
for (const card of result.cards.slice(0, 40)) {
|
|
891
|
+
lines.push(`- ${card.area ? `[${card.area}] ` : ''}${card.preview}`);
|
|
892
|
+
for (const finding of card.findings.slice(0, 4)) {
|
|
893
|
+
lines.push(` - \`${finding.ref}\` — ${finding.status}${finding.movedTo.length ? `; probably now: ${finding.movedTo.map(f => `\`${f}\``).join(', ')}` : ''}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (result.cards.length > 40) lines.push(`…and ${result.cards.length - 40} more drifted card(s).`);
|
|
897
|
+
lines.push('_Read-only report. Fix cards with a CORRECTION marker or edit them in KLYPIX; nothing was changed automatically._');
|
|
898
|
+
return lines.join('\n');
|
|
899
|
+
}
|