gitnexus 1.6.5-rc.20 → 1.6.5-rc.22
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/dist/cli/setup.js +6 -0
- package/dist/core/wiki/generator.js +4 -3
- package/dist/core/wiki/html-viewer.js +2 -1
- package/dist/core/wiki/mermaid-sanitizer.d.ts +2 -0
- package/dist/core/wiki/mermaid-sanitizer.js +100 -0
- package/hooks/claude/gitnexus-hook.cjs +8 -1
- package/hooks/claude/hook-lock.cjs +119 -0
- package/package.json +1 -1
package/dist/cli/setup.js
CHANGED
|
@@ -308,6 +308,12 @@ async function installClaudeCodeHooks(result) {
|
|
|
308
308
|
catch {
|
|
309
309
|
// Script not found in source — skip
|
|
310
310
|
}
|
|
311
|
+
try {
|
|
312
|
+
await fs.copyFile(path.join(pluginHooksPath, 'hook-lock.cjs'), path.join(destHooksDir, 'hook-lock.cjs'));
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Helper not found in source — skip
|
|
316
|
+
}
|
|
311
317
|
const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
|
|
312
318
|
// Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
|
|
313
319
|
// The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
|
|
@@ -14,6 +14,7 @@ import path from 'path';
|
|
|
14
14
|
import { execSync, execFileSync } from 'child_process';
|
|
15
15
|
import { initWikiDb, closeWikiDb, touchWikiDb, getFilesWithExports, getAllFiles, getIntraModuleCallEdges, getInterModuleCallEdges, getProcessesForFiles, getAllProcesses, getInterModuleEdgesForOverview, } from './graph-queries.js';
|
|
16
16
|
import { generateHTMLViewer } from './html-viewer.js';
|
|
17
|
+
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
|
|
17
18
|
import { callLLM, estimateTokens, } from './llm-client.js';
|
|
18
19
|
import { callCursorLLM, resolveCursorConfig } from './cursor-client.js';
|
|
19
20
|
import { GROUPING_SYSTEM_PROMPT, GROUPING_USER_PROMPT, MODULE_SYSTEM_PROMPT, MODULE_USER_PROMPT, PARENT_SYSTEM_PROMPT, PARENT_USER_PROMPT, OVERVIEW_SYSTEM_PROMPT, OVERVIEW_USER_PROMPT, fillTemplate, formatFileListForGrouping, formatDirectoryTree, formatCallEdges, formatProcesses, } from './prompts.js';
|
|
@@ -445,7 +446,7 @@ export class WikiGenerator {
|
|
|
445
446
|
});
|
|
446
447
|
const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
|
|
447
448
|
// Write page with front matter
|
|
448
|
-
const pageContent = `# ${node.name}\n\n${response.content}
|
|
449
|
+
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
|
449
450
|
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
|
450
451
|
}
|
|
451
452
|
/**
|
|
@@ -480,7 +481,7 @@ export class WikiGenerator {
|
|
|
480
481
|
CROSS_PROCESSES: formatProcesses(processes),
|
|
481
482
|
});
|
|
482
483
|
const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
|
|
483
|
-
const pageContent = `# ${node.name}\n\n${response.content}
|
|
484
|
+
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
|
484
485
|
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
|
485
486
|
}
|
|
486
487
|
// ─── Phase 3: Generate Overview ─────────────────────────────────────
|
|
@@ -516,7 +517,7 @@ export class WikiGenerator {
|
|
|
516
517
|
TOP_PROCESSES: formatProcesses(topProcesses),
|
|
517
518
|
});
|
|
518
519
|
const response = await this.invokeLLM(prompt, OVERVIEW_SYSTEM_PROMPT, this.streamOpts('Generating overview', 88));
|
|
519
|
-
const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}
|
|
520
|
+
const pageContent = sanitizeMermaidMarkdown(`# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`);
|
|
520
521
|
await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
|
|
521
522
|
}
|
|
522
523
|
// ─── Incremental Updates ────────────────────────────────────────────
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from 'fs/promises';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
|
|
9
10
|
/**
|
|
10
11
|
* Generate the wiki HTML viewer (index.html) from existing markdown pages.
|
|
11
12
|
*/
|
|
@@ -33,7 +34,7 @@ export async function generateHTMLViewer(wikiDir, projectName) {
|
|
|
33
34
|
const dirEntries = await fs.readdir(wikiDir);
|
|
34
35
|
for (const f of dirEntries.filter((f) => f.endsWith('.md'))) {
|
|
35
36
|
const content = await fs.readFile(path.join(wikiDir, f), 'utf-8');
|
|
36
|
-
pages[f.replace(/\.md$/, '')] = content;
|
|
37
|
+
pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content);
|
|
37
38
|
}
|
|
38
39
|
const html = buildHTML(projectName, moduleTree, pages, meta);
|
|
39
40
|
const outputPath = path.join(wikiDir, 'index.html');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g;
|
|
2
|
+
const NODE_LABEL_RE = /(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g;
|
|
3
|
+
const EDGE_LABEL_RE = /\|([^|\n]+)\|/g;
|
|
4
|
+
const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/;
|
|
5
|
+
const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/;
|
|
6
|
+
const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/;
|
|
7
|
+
const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/;
|
|
8
|
+
const EDGE_RE = /(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g;
|
|
9
|
+
export function sanitizeMermaidMarkdown(markdown) {
|
|
10
|
+
return markdown.replace(MERMAID_FENCE_RE, (_match, diagram) => {
|
|
11
|
+
return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```';
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function sanitizeMermaidDiagram(diagram) {
|
|
15
|
+
const aliases = new Map();
|
|
16
|
+
let nextAlias = 1;
|
|
17
|
+
const aliasFor = (id) => {
|
|
18
|
+
const existing = aliases.get(id);
|
|
19
|
+
if (existing)
|
|
20
|
+
return existing;
|
|
21
|
+
const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node';
|
|
22
|
+
let alias = base;
|
|
23
|
+
while ([...aliases.values()].includes(alias)) {
|
|
24
|
+
nextAlias += 1;
|
|
25
|
+
alias = `${base}_${nextAlias}`;
|
|
26
|
+
}
|
|
27
|
+
aliases.set(id, alias);
|
|
28
|
+
return alias;
|
|
29
|
+
};
|
|
30
|
+
return diagram
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => sanitizeMermaidLine(line, aliasFor))
|
|
33
|
+
.join('\n');
|
|
34
|
+
}
|
|
35
|
+
function sanitizeMermaidLine(line, aliasFor) {
|
|
36
|
+
let sanitized = replaceLiteralLineBreaksInLabels(line);
|
|
37
|
+
sanitized = quoteUnsafeEdgeLabels(sanitized);
|
|
38
|
+
const prefixMatch = sanitized.match(LINE_PREFIX_RE);
|
|
39
|
+
if (!prefixMatch)
|
|
40
|
+
return sanitized;
|
|
41
|
+
const prefix = prefixMatch[1];
|
|
42
|
+
const body = prefixMatch[2];
|
|
43
|
+
if (isDirectiveLine(body))
|
|
44
|
+
return sanitized;
|
|
45
|
+
const parts = body.split(EDGE_RE);
|
|
46
|
+
if (parts.length === 1)
|
|
47
|
+
return sanitized;
|
|
48
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
49
|
+
parts[i] = sanitizeNodeReference(parts[i], aliasFor);
|
|
50
|
+
}
|
|
51
|
+
return prefix + parts.join('');
|
|
52
|
+
}
|
|
53
|
+
function replaceLiteralLineBreaksInLabels(line) {
|
|
54
|
+
return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '<br/>'));
|
|
55
|
+
}
|
|
56
|
+
function quoteUnsafeEdgeLabels(line) {
|
|
57
|
+
return line.replace(EDGE_LABEL_RE, (match, label) => {
|
|
58
|
+
const trimmed = label.trim();
|
|
59
|
+
if (!UNSAFE_EDGE_LABEL_RE.test(trimmed))
|
|
60
|
+
return match;
|
|
61
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
62
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
63
|
+
return match;
|
|
64
|
+
}
|
|
65
|
+
return `|"${escapeMermaidLabel(trimmed)}"|`;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function sanitizeNodeReference(segment, aliasFor) {
|
|
69
|
+
const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/);
|
|
70
|
+
if (!match)
|
|
71
|
+
return segment;
|
|
72
|
+
const [, leading, id, suffix, trailing] = match;
|
|
73
|
+
if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id))
|
|
74
|
+
return segment;
|
|
75
|
+
const hasInlineLabel = suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{');
|
|
76
|
+
if (hasInlineLabel)
|
|
77
|
+
return `${leading}${aliasFor(id)}${suffix}${trailing}`;
|
|
78
|
+
return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`;
|
|
79
|
+
}
|
|
80
|
+
function escapeMermaidLabel(label) {
|
|
81
|
+
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
82
|
+
}
|
|
83
|
+
function isDirectiveLine(line) {
|
|
84
|
+
const trimmed = line.trim();
|
|
85
|
+
return (trimmed === '' ||
|
|
86
|
+
trimmed.startsWith('%%') ||
|
|
87
|
+
trimmed.startsWith('graph ') ||
|
|
88
|
+
trimmed.startsWith('flowchart ') ||
|
|
89
|
+
trimmed.startsWith('sequenceDiagram') ||
|
|
90
|
+
trimmed.startsWith('classDiagram') ||
|
|
91
|
+
trimmed.startsWith('stateDiagram') ||
|
|
92
|
+
trimmed.startsWith('erDiagram') ||
|
|
93
|
+
trimmed.startsWith('journey') ||
|
|
94
|
+
trimmed.startsWith('gantt') ||
|
|
95
|
+
trimmed.startsWith('pie ') ||
|
|
96
|
+
trimmed.startsWith('mindmap') ||
|
|
97
|
+
trimmed.startsWith('timeline') ||
|
|
98
|
+
trimmed.startsWith('subgraph ') ||
|
|
99
|
+
trimmed === 'end');
|
|
100
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
const fs = require('fs');
|
|
15
15
|
const path = require('path');
|
|
16
16
|
const { spawnSync } = require('child_process');
|
|
17
|
+
const { acquireHookSlot } = require('./hook-lock.cjs');
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Read JSON input from stdin synchronously.
|
|
@@ -207,7 +208,8 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
|
|
207
208
|
function handlePreToolUse(input) {
|
|
208
209
|
const cwd = input.cwd || process.cwd();
|
|
209
210
|
if (!path.isAbsolute(cwd)) return;
|
|
210
|
-
|
|
211
|
+
const gitNexusDir = findGitNexusDir(cwd);
|
|
212
|
+
if (!gitNexusDir) return;
|
|
211
213
|
|
|
212
214
|
const toolName = input.tool_name || '';
|
|
213
215
|
const toolInput = input.tool_input || {};
|
|
@@ -217,6 +219,9 @@ function handlePreToolUse(input) {
|
|
|
217
219
|
const pattern = extractPattern(toolName, toolInput);
|
|
218
220
|
if (!pattern || pattern.length < 3) return;
|
|
219
221
|
|
|
222
|
+
const release = acquireHookSlot(gitNexusDir);
|
|
223
|
+
if (!release) return;
|
|
224
|
+
|
|
220
225
|
const cliPath = resolveCliPath();
|
|
221
226
|
let result = '';
|
|
222
227
|
try {
|
|
@@ -226,6 +231,8 @@ function handlePreToolUse(input) {
|
|
|
226
231
|
}
|
|
227
232
|
} catch {
|
|
228
233
|
/* graceful failure */
|
|
234
|
+
} finally {
|
|
235
|
+
release();
|
|
229
236
|
}
|
|
230
237
|
|
|
231
238
|
if (result && result.trim()) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const HOOK_LOCK_SUBDIR = '.hook-locks';
|
|
5
|
+
const HOOK_LOCK_MAX_INFLIGHT = 3;
|
|
6
|
+
const HOOK_LOCK_STALE_MS = 30000;
|
|
7
|
+
|
|
8
|
+
function acquireHookSlot(gitNexusDir) {
|
|
9
|
+
const lockDir = path.join(gitNexusDir, HOOK_LOCK_SUBDIR);
|
|
10
|
+
try {
|
|
11
|
+
fs.mkdirSync(lockDir, { recursive: true });
|
|
12
|
+
} catch {
|
|
13
|
+
// Cannot create lock dir (read-only fs, cross-user perm denial, out of
|
|
14
|
+
// inodes, etc.) — fail closed by returning null. Caller skips augment.
|
|
15
|
+
// Fail-open here would let N concurrent hooks all proceed unguarded and
|
|
16
|
+
// reintroduce the #1486 fan-out the guard exists to prevent.
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const myPidStr = String(process.pid);
|
|
21
|
+
|
|
22
|
+
for (let slot = 0; slot < HOOK_LOCK_MAX_INFLIGHT; slot++) {
|
|
23
|
+
const slotPath = path.join(lockDir, `slot-${slot}.lock`);
|
|
24
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
25
|
+
try {
|
|
26
|
+
fs.writeFileSync(slotPath, myPidStr, { flag: 'wx' });
|
|
27
|
+
let released = false;
|
|
28
|
+
const release = () => {
|
|
29
|
+
if (released) return;
|
|
30
|
+
released = true;
|
|
31
|
+
try {
|
|
32
|
+
// Only unlink if we still own the slot. If we appeared stale and
|
|
33
|
+
// another hook took over, the file now belongs to it — leave alone.
|
|
34
|
+
const content = fs.readFileSync(slotPath, 'utf-8').trim();
|
|
35
|
+
if (content === myPidStr) fs.unlinkSync(slotPath);
|
|
36
|
+
} catch {
|
|
37
|
+
/* already removed or unreadable */
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
process.on('exit', release);
|
|
41
|
+
return release;
|
|
42
|
+
} catch {
|
|
43
|
+
// Slot exists. Decide whether to take it over.
|
|
44
|
+
// Open once and inspect mtime + content via the same fd so there's
|
|
45
|
+
// no TOCTOU between the metadata check and the content read
|
|
46
|
+
// (codeql js/file-system-race).
|
|
47
|
+
let fd;
|
|
48
|
+
try {
|
|
49
|
+
fd = fs.openSync(slotPath, 'r');
|
|
50
|
+
} catch {
|
|
51
|
+
continue; // Vanished between EEXIST and open — retry this slot.
|
|
52
|
+
}
|
|
53
|
+
let isLive = false;
|
|
54
|
+
let mtimeMs = Date.now();
|
|
55
|
+
try {
|
|
56
|
+
mtimeMs = fs.fstatSync(fd).mtimeMs;
|
|
57
|
+
const buf = Buffer.alloc(32);
|
|
58
|
+
const n = fs.readSync(fd, buf, 0, 32, 0);
|
|
59
|
+
const ownerStr = buf.slice(0, n).toString('utf-8').trim();
|
|
60
|
+
if (ownerStr === '') {
|
|
61
|
+
// Owner created the file but hasn't written its PID yet. The
|
|
62
|
+
// wx open+write window is microseconds; give it the benefit
|
|
63
|
+
// of the doubt and treat as live.
|
|
64
|
+
isLive = true;
|
|
65
|
+
} else {
|
|
66
|
+
const owner = Number.parseInt(ownerStr, 10);
|
|
67
|
+
if (Number.isFinite(owner) && owner > 0) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(owner, 0);
|
|
70
|
+
isLive = true;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// ESRCH = process gone → treat as dead. EPERM = process exists
|
|
73
|
+
// but owned by another user (cross-user lock dir) → still alive,
|
|
74
|
+
// keep the slot. Anything else: be conservative, assume alive.
|
|
75
|
+
if (e && e.code === 'ESRCH') {
|
|
76
|
+
isLive = false;
|
|
77
|
+
} else {
|
|
78
|
+
isLive = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* unreadable — treat as dead */
|
|
85
|
+
} finally {
|
|
86
|
+
try {
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
} catch {
|
|
89
|
+
/* already closed */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// For slots younger than HOOK_LOCK_STALE_MS, PID-liveness wins —
|
|
93
|
+
// a slow-but-alive hook is never wrongly evicted. For older slots,
|
|
94
|
+
// age is the final arbiter as a defense against PID reuse on long-
|
|
95
|
+
// abandoned slots. 30s >> the 7s augment timeout, so a healthy run
|
|
96
|
+
// never crosses this threshold.
|
|
97
|
+
if (isLive && Date.now() - mtimeMs > HOOK_LOCK_STALE_MS) {
|
|
98
|
+
isLive = false;
|
|
99
|
+
}
|
|
100
|
+
if (isLive) break; // Try the next slot.
|
|
101
|
+
try {
|
|
102
|
+
fs.unlinkSync(slotPath);
|
|
103
|
+
} catch {
|
|
104
|
+
/* another hook beat us to it — retry will hit EEXIST */
|
|
105
|
+
}
|
|
106
|
+
// Loop and retry this slot.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
HOOK_LOCK_SUBDIR,
|
|
116
|
+
HOOK_LOCK_MAX_INFLIGHT,
|
|
117
|
+
HOOK_LOCK_STALE_MS,
|
|
118
|
+
acquireHookSlot,
|
|
119
|
+
};
|
package/package.json
CHANGED