@gitset-dev/cli 2.3.3 → 2.4.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/index.js +19 -0
- package/lib/generate-local.js +1 -0
- package/lib/knowledge/budget.js +85 -0
- package/lib/knowledge/ci.js +99 -0
- package/lib/knowledge/discover.js +212 -0
- package/lib/knowledge/index.js +89 -0
- package/lib/knowledge/map.js +239 -0
- package/lib/knowledge/secrets.js +99 -0
- package/lib/knowledge/state.js +81 -0
- package/lib/knowledge/validate.js +180 -0
- package/lib/knowledge/write.js +266 -0
- package/lib/prompts/defaults.js +69 -1
- package/package.json +1 -1
- package/src/commands/knowledge.js +582 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const JS_IMPORT_RES = [
|
|
6
|
+
/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
7
|
+
/\bfrom\s+['"]([^'"]+)['"]/g,
|
|
8
|
+
/\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
9
|
+
/^\s*import\s+['"]([^'"]+)['"]/gm,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const PY_IMPORT_RES = [
|
|
13
|
+
/^\s*from\s+([\w.]+)\s+import\b/gm,
|
|
14
|
+
/^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const JS_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'];
|
|
18
|
+
const CONTAINER_DIRS = new Set(['src', 'lib', 'app', 'packages', 'pkg', 'internal', 'cmd', 'api', 'server', 'client']);
|
|
19
|
+
|
|
20
|
+
function extractImportSpecs(filePath, content) {
|
|
21
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
+
const specs = [];
|
|
23
|
+
if (JS_EXTS.includes(ext) || ['.astro', '.vue', '.svelte'].includes(ext)) {
|
|
24
|
+
for (const re of JS_IMPORT_RES) {
|
|
25
|
+
re.lastIndex = 0;
|
|
26
|
+
let m;
|
|
27
|
+
while ((m = re.exec(content)) !== null) specs.push(m[1]);
|
|
28
|
+
}
|
|
29
|
+
} else if (ext === '.py') {
|
|
30
|
+
for (const re of PY_IMPORT_RES) {
|
|
31
|
+
re.lastIndex = 0;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(content)) !== null) {
|
|
34
|
+
for (const part of m[1].split(',')) specs.push(part.trim());
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return specs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildFileIndex(files) {
|
|
42
|
+
const index = new Set(files.map((f) => f.path));
|
|
43
|
+
return index;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveJsImport(fromFile, spec, fileIndex) {
|
|
47
|
+
if (!spec.startsWith('./') && !spec.startsWith('../')) return null;
|
|
48
|
+
const baseDir = path.posix.dirname(fromFile);
|
|
49
|
+
const joined = path.posix.normalize(path.posix.join(baseDir, spec));
|
|
50
|
+
const candidates = [joined];
|
|
51
|
+
for (const ext of JS_EXTS) candidates.push(joined + ext);
|
|
52
|
+
for (const ext of JS_EXTS) candidates.push(`${joined}/index${ext}`);
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
if (fileIndex.has(candidate)) return candidate;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function resolvePyImport(spec, fileIndex) {
|
|
60
|
+
const rel = spec.replace(/\./g, '/');
|
|
61
|
+
const candidates = [`${rel}.py`, `${rel}/__init__.py`, `src/${rel}.py`, `src/${rel}/__init__.py`];
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
if (fileIndex.has(candidate)) return candidate;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function moduleKeyFor(filePath) {
|
|
69
|
+
const segments = filePath.split('/');
|
|
70
|
+
if (segments.length === 1) return '(root)';
|
|
71
|
+
if (CONTAINER_DIRS.has(segments[0]) && segments.length > 2) {
|
|
72
|
+
return `${segments[0]}/${segments[1]}`;
|
|
73
|
+
}
|
|
74
|
+
return segments[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parsePackageJson(content) {
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(content);
|
|
80
|
+
return {
|
|
81
|
+
name: pkg.name || null,
|
|
82
|
+
version: pkg.version || null,
|
|
83
|
+
description: pkg.description || null,
|
|
84
|
+
bin: pkg.bin || null,
|
|
85
|
+
main: pkg.main || null,
|
|
86
|
+
type: pkg.type || null,
|
|
87
|
+
scripts: pkg.scripts || {},
|
|
88
|
+
dependencies: Object.keys(pkg.dependencies || {}),
|
|
89
|
+
devDependencies: Object.keys(pkg.devDependencies || {}),
|
|
90
|
+
engines: pkg.engines || null,
|
|
91
|
+
packageManager: pkg.packageManager || null,
|
|
92
|
+
};
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const COMMAND_REGISTRATION_RES = [
|
|
99
|
+
/\bcase\s+'([\w:-]+)'/g,
|
|
100
|
+
/\bcase\s+"([\w:-]+)"/g,
|
|
101
|
+
/\.command\(\s*['"]([\w:-]+)/g,
|
|
102
|
+
/add_parser\(\s*['"]([\w-]+)/g,
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
function extractRegisteredCommands(content) {
|
|
106
|
+
const names = new Set();
|
|
107
|
+
for (const re of COMMAND_REGISTRATION_RES) {
|
|
108
|
+
re.lastIndex = 0;
|
|
109
|
+
let m;
|
|
110
|
+
while ((m = re.exec(content)) !== null) {
|
|
111
|
+
if (!m[1].startsWith('-')) names.add(m[1]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...names].sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function detectEntryPoints(files, manifest) {
|
|
118
|
+
const fileIndex = new Set(files.map((f) => f.path));
|
|
119
|
+
const entries = new Set();
|
|
120
|
+
if (manifest) {
|
|
121
|
+
if (typeof manifest.main === 'string' && fileIndex.has(manifest.main.replace(/^\.\//, ''))) {
|
|
122
|
+
entries.add(manifest.main.replace(/^\.\//, ''));
|
|
123
|
+
}
|
|
124
|
+
if (manifest.bin) {
|
|
125
|
+
const bins = typeof manifest.bin === 'string' ? [manifest.bin] : Object.values(manifest.bin);
|
|
126
|
+
for (const bin of bins) {
|
|
127
|
+
const norm = String(bin).replace(/^\.\//, '');
|
|
128
|
+
if (fileIndex.has(norm)) entries.add(norm);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const candidate of ['index.js', 'index.ts', 'src/index.js', 'src/index.ts', 'server.js', 'main.py', 'app.py', 'main.go', 'src/main.rs']) {
|
|
133
|
+
if (fileIndex.has(candidate)) entries.add(candidate);
|
|
134
|
+
}
|
|
135
|
+
return [...entries].sort();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildMap(files, readContentFn) {
|
|
139
|
+
const fileIndex = buildFileIndex(files);
|
|
140
|
+
const importsByFile = new Map();
|
|
141
|
+
const importedBy = new Map();
|
|
142
|
+
const externalDeps = new Map();
|
|
143
|
+
|
|
144
|
+
const contentEligible = files.filter((f) => f.kind === 'source' || f.kind === 'config' || f.kind === 'manifest');
|
|
145
|
+
|
|
146
|
+
for (const file of contentEligible) {
|
|
147
|
+
if (file.kind !== 'source') continue;
|
|
148
|
+
const content = readContentFn(file);
|
|
149
|
+
if (!content) continue;
|
|
150
|
+
const specs = extractImportSpecs(file.path, content);
|
|
151
|
+
const resolved = [];
|
|
152
|
+
for (const spec of specs) {
|
|
153
|
+
const ext = path.extname(file.path).toLowerCase();
|
|
154
|
+
const target = ext === '.py'
|
|
155
|
+
? resolvePyImport(spec, fileIndex)
|
|
156
|
+
: resolveJsImport(file.path, spec, fileIndex);
|
|
157
|
+
if (target && target !== file.path) {
|
|
158
|
+
resolved.push(target);
|
|
159
|
+
if (!importedBy.has(target)) importedBy.set(target, new Set());
|
|
160
|
+
importedBy.get(target).add(file.path);
|
|
161
|
+
} else if (!spec.startsWith('.') && !spec.startsWith('/')) {
|
|
162
|
+
const root = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0];
|
|
163
|
+
externalDeps.set(root, (externalDeps.get(root) || 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
importsByFile.set(file.path, [...new Set(resolved)].sort());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let manifest = null;
|
|
170
|
+
const manifestFile = files.find((f) => f.path === 'package.json');
|
|
171
|
+
if (manifestFile) {
|
|
172
|
+
const content = readContentFn(manifestFile);
|
|
173
|
+
if (content) manifest = parsePackageJson(content);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const modules = new Map();
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
if (file.kind === 'asset' || file.kind === 'sensitive') continue;
|
|
179
|
+
const key = moduleKeyFor(file.path);
|
|
180
|
+
if (!modules.has(key)) {
|
|
181
|
+
modules.set(key, { name: key, files: [], sourceCount: 0, testCount: 0, docCount: 0 });
|
|
182
|
+
}
|
|
183
|
+
const mod = modules.get(key);
|
|
184
|
+
mod.files.push(file);
|
|
185
|
+
if (file.kind === 'source') mod.sourceCount += 1;
|
|
186
|
+
if (file.kind === 'test') mod.testCount += 1;
|
|
187
|
+
if (file.kind === 'doc') mod.docCount += 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const centrality = new Map();
|
|
191
|
+
for (const [target, importers] of importedBy) {
|
|
192
|
+
centrality.set(target, importers.size);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const moduleEdges = new Map();
|
|
196
|
+
for (const [from, targets] of importsByFile) {
|
|
197
|
+
const fromMod = moduleKeyFor(from);
|
|
198
|
+
for (const target of targets) {
|
|
199
|
+
const toMod = moduleKeyFor(target);
|
|
200
|
+
if (fromMod === toMod) continue;
|
|
201
|
+
const edgeKey = `${fromMod} -> ${toMod}`;
|
|
202
|
+
moduleEdges.set(edgeKey, (moduleEdges.get(edgeKey) || 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const entryPoints = detectEntryPoints(files, manifest);
|
|
207
|
+
|
|
208
|
+
const registeredCommands = new Set();
|
|
209
|
+
for (const entry of entryPoints) {
|
|
210
|
+
const file = files.find((f) => f.path === entry);
|
|
211
|
+
if (!file) continue;
|
|
212
|
+
const content = readContentFn(file);
|
|
213
|
+
if (!content) continue;
|
|
214
|
+
for (const name of extractRegisteredCommands(content)) registeredCommands.add(name);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
manifest,
|
|
219
|
+
entryPoints,
|
|
220
|
+
registeredCommands: [...registeredCommands].sort(),
|
|
221
|
+
modules: [...modules.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
222
|
+
importsByFile,
|
|
223
|
+
importedBy,
|
|
224
|
+
centrality,
|
|
225
|
+
moduleEdges: [...moduleEdges.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([edge, count]) => ({ edge, count })),
|
|
226
|
+
externalDeps: [...externalDeps.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([name, count]) => ({ name, count })),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
buildMap,
|
|
232
|
+
extractImportSpecs,
|
|
233
|
+
extractRegisteredCommands,
|
|
234
|
+
resolveJsImport,
|
|
235
|
+
resolvePyImport,
|
|
236
|
+
moduleKeyFor,
|
|
237
|
+
parsePackageJson,
|
|
238
|
+
detectEntryPoints,
|
|
239
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const KEY_RULES = [
|
|
4
|
+
{ rule: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
5
|
+
{ rule: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,255}\b/g },
|
|
6
|
+
{ rule: 'github-pat', re: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g },
|
|
7
|
+
{ rule: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{30,}\b/g },
|
|
8
|
+
{ rule: 'gcp-oauth-key', re: /\bAQ\.[A-Za-z0-9_-]{30,}\b/g },
|
|
9
|
+
{ rule: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
10
|
+
{ rule: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
11
|
+
{ rule: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
12
|
+
{ rule: 'stripe-key', re: /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
|
|
13
|
+
{ rule: 'npm-token', re: /\bnpm_[A-Za-z0-9]{36,}\b/g },
|
|
14
|
+
{ rule: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g },
|
|
15
|
+
{ rule: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const ASSIGNMENT_RE = /(\b(?:api[_-]?key|apikey|secret|token|password|passwd|pwd|auth|credential|private[_-]?key)[\w-]*\s*[=:]\s*["']?)([A-Za-z0-9+/_.-]{16,})(["']?)/gi;
|
|
19
|
+
|
|
20
|
+
const ASSIGNMENT_SAFE_VALUES = /^(?:process\.env|import\.meta|os\.environ|env\(|\$\{|\$[A-Z_]|<[^>]+>|your[_-]|xxx|placeholder|example|changeme|dummy|test)/i;
|
|
21
|
+
|
|
22
|
+
const PROPERTY_CHAIN_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/;
|
|
23
|
+
|
|
24
|
+
const ENTROPY_TOKEN_RE = /\b[A-Za-z0-9+/_-]{24,}\b/g;
|
|
25
|
+
const ENTROPY_THRESHOLD = 4.2;
|
|
26
|
+
const MAX_FINDINGS_PER_FILE = 5;
|
|
27
|
+
|
|
28
|
+
function shannonEntropy(str) {
|
|
29
|
+
const freq = new Map();
|
|
30
|
+
for (const ch of str) freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
31
|
+
let entropy = 0;
|
|
32
|
+
for (const count of freq.values()) {
|
|
33
|
+
const p = count / str.length;
|
|
34
|
+
entropy -= p * Math.log2(p);
|
|
35
|
+
}
|
|
36
|
+
return entropy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function looksLikeIdentifier(token) {
|
|
40
|
+
if (/^[a-z]+(?:[A-Z][a-z0-9]*)+$/.test(token)) return true;
|
|
41
|
+
if (/^[A-Z0-9_]+$/.test(token)) return true;
|
|
42
|
+
if (/^[a-z0-9]+(?:[-_][a-z0-9]+)+$/.test(token)) return true;
|
|
43
|
+
if (/^(?:[0-9a-f]{2}[-:])+[0-9a-f]{2}$/i.test(token)) return true;
|
|
44
|
+
if (!/[0-9]/.test(token) || !/[a-zA-Z]/.test(token)) return false;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function redactSecrets(content) {
|
|
49
|
+
if (typeof content !== 'string' || !content) {
|
|
50
|
+
return { content, findings: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const findings = [];
|
|
54
|
+
let out = content;
|
|
55
|
+
|
|
56
|
+
for (const { rule, re } of KEY_RULES) {
|
|
57
|
+
re.lastIndex = 0;
|
|
58
|
+
out = out.replace(re, () => {
|
|
59
|
+
findings.push(rule);
|
|
60
|
+
return `[REDACTED:${rule}]`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ASSIGNMENT_RE.lastIndex = 0;
|
|
65
|
+
out = out.replace(ASSIGNMENT_RE, (full, prefix, value, suffix) => {
|
|
66
|
+
if (ASSIGNMENT_SAFE_VALUES.test(value)) return full;
|
|
67
|
+
if (PROPERTY_CHAIN_RE.test(value)) return full;
|
|
68
|
+
if (value.includes('[REDACTED:')) return full;
|
|
69
|
+
if (looksLikeIdentifier(value) && shannonEntropy(value) < ENTROPY_THRESHOLD) return full;
|
|
70
|
+
findings.push('secret-assignment');
|
|
71
|
+
return `${prefix}[REDACTED:secret-assignment]${suffix}`;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
ENTROPY_TOKEN_RE.lastIndex = 0;
|
|
75
|
+
out = out.replace(ENTROPY_TOKEN_RE, (token) => {
|
|
76
|
+
if (token.includes('REDACTED')) return token;
|
|
77
|
+
if (looksLikeIdentifier(token)) return token;
|
|
78
|
+
if (shannonEntropy(token) < ENTROPY_THRESHOLD) return token;
|
|
79
|
+
findings.push('high-entropy-token');
|
|
80
|
+
return '[REDACTED:high-entropy-token]';
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { content: out, findings };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sanitizeForPrompt(content) {
|
|
87
|
+
const { content: redacted, findings } = redactSecrets(content);
|
|
88
|
+
if (findings.length > MAX_FINDINGS_PER_FILE) {
|
|
89
|
+
return { content: null, findings, dropped: true };
|
|
90
|
+
}
|
|
91
|
+
return { content: redacted, findings, dropped: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
redactSecrets,
|
|
96
|
+
sanitizeForPrompt,
|
|
97
|
+
shannonEntropy,
|
|
98
|
+
MAX_FINDINGS_PER_FILE,
|
|
99
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const STATE_VERSION = 1;
|
|
6
|
+
const STATE_FILENAME = '.state.json';
|
|
7
|
+
|
|
8
|
+
function hashContent(content) {
|
|
9
|
+
return crypto.createHash('sha256').update(content || '').digest('hex').slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function buildState({ files, moduleSummaries, provider, model, tag, commit }) {
|
|
13
|
+
const fileHashes = {};
|
|
14
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
15
|
+
if (file.hash) fileHashes[file.path] = file.hash;
|
|
16
|
+
}
|
|
17
|
+
const summaries = {};
|
|
18
|
+
for (const key of Object.keys(moduleSummaries || {}).sort()) {
|
|
19
|
+
summaries[key] = moduleSummaries[key];
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
version: STATE_VERSION,
|
|
23
|
+
generatedAt: new Date().toISOString(),
|
|
24
|
+
provider: provider || null,
|
|
25
|
+
model: model || null,
|
|
26
|
+
tag: tag || null,
|
|
27
|
+
commit: commit || null,
|
|
28
|
+
fileHashes,
|
|
29
|
+
moduleSummaries: summaries,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseState(raw) {
|
|
34
|
+
try {
|
|
35
|
+
const state = JSON.parse(raw);
|
|
36
|
+
if (!state || state.version !== STATE_VERSION || typeof state.fileHashes !== 'object') return null;
|
|
37
|
+
return state;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function diffAgainstState(state, currentFiles) {
|
|
44
|
+
const previous = state && state.fileHashes ? state.fileHashes : {};
|
|
45
|
+
const changed = [];
|
|
46
|
+
const added = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const file of currentFiles) {
|
|
49
|
+
if (!file.hash) continue;
|
|
50
|
+
seen.add(file.path);
|
|
51
|
+
if (!(file.path in previous)) added.push(file.path);
|
|
52
|
+
else if (previous[file.path] !== file.hash) changed.push(file.path);
|
|
53
|
+
}
|
|
54
|
+
const removed = Object.keys(previous).filter((p) => !seen.has(p));
|
|
55
|
+
return { changed: changed.sort(), added: added.sort(), removed: removed.sort() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function affectedModules(diff, moduleKeyFor, importedBy) {
|
|
59
|
+
const direct = new Set();
|
|
60
|
+
for (const p of [...diff.changed, ...diff.added, ...diff.removed]) {
|
|
61
|
+
direct.add(moduleKeyFor(p));
|
|
62
|
+
}
|
|
63
|
+
const withImporters = new Set(direct);
|
|
64
|
+
for (const p of [...diff.changed, ...diff.removed]) {
|
|
65
|
+
const importers = importedBy && importedBy.get ? importedBy.get(p) : null;
|
|
66
|
+
if (importers) {
|
|
67
|
+
for (const importer of importers) withImporters.add(moduleKeyFor(importer));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...withImporters].sort();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
STATE_VERSION,
|
|
75
|
+
STATE_FILENAME,
|
|
76
|
+
hashContent,
|
|
77
|
+
buildState,
|
|
78
|
+
parseState,
|
|
79
|
+
diffAgainstState,
|
|
80
|
+
affectedModules,
|
|
81
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const LINK_RE = /\[[^\]]*\]\(([^)#\s]+)(?:#[^)\s]*)?\)/g;
|
|
4
|
+
const FENCE_RE = /```(?:bash|sh|shell|console|zsh)?\n([\s\S]*?)```/g;
|
|
5
|
+
const CMD_RE = /^\s*\$?\s*(npm|pnpm|yarn|node|git|gitset|npx)\s+(\S+)(?:\s+(\S+))?/;
|
|
6
|
+
|
|
7
|
+
function extractInternalLinks(markdown) {
|
|
8
|
+
const links = [];
|
|
9
|
+
LINK_RE.lastIndex = 0;
|
|
10
|
+
let m;
|
|
11
|
+
while ((m = LINK_RE.exec(markdown)) !== null) {
|
|
12
|
+
const target = m[1];
|
|
13
|
+
if (/^[a-z]+:\/\//i.test(target)) continue;
|
|
14
|
+
if (target.startsWith('mailto:')) continue;
|
|
15
|
+
links.push(target);
|
|
16
|
+
}
|
|
17
|
+
return links;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractCitedCommands(markdown) {
|
|
21
|
+
const commands = [];
|
|
22
|
+
FENCE_RE.lastIndex = 0;
|
|
23
|
+
let block;
|
|
24
|
+
while ((block = FENCE_RE.exec(markdown)) !== null) {
|
|
25
|
+
for (const line of block[1].split('\n')) {
|
|
26
|
+
const m = CMD_RE.exec(line);
|
|
27
|
+
if (m) commands.push({ tool: m[1], arg1: m[2] || '', arg2: m[3] || '', line: line.trim() });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return commands;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeDocLink(docDir, target) {
|
|
34
|
+
const clean = target.replace(/^\.\//, '');
|
|
35
|
+
if (clean.startsWith('../') || clean.startsWith('/')) {
|
|
36
|
+
return clean.replace(/^(\.\.\/)+/, '').replace(/^\//, '');
|
|
37
|
+
}
|
|
38
|
+
return `${docDir}/${clean}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function linkResolves(target, { docDir, fileSet, docSet, dirSet }) {
|
|
42
|
+
const asDocRelative = normalizeDocLink(docDir, target);
|
|
43
|
+
const asRepoRelative = target.replace(/^\.\//, '');
|
|
44
|
+
return fileSet.has(asDocRelative)
|
|
45
|
+
|| docSet.has(asDocRelative)
|
|
46
|
+
|| fileSet.has(asRepoRelative)
|
|
47
|
+
|| docSet.has(asRepoRelative)
|
|
48
|
+
|| (dirSet ? dirSet.has(asDocRelative.replace(/\/$/, '')) || dirSet.has(asRepoRelative.replace(/\/$/, '')) : false);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildDirSet(repoFiles) {
|
|
52
|
+
const dirs = new Set();
|
|
53
|
+
for (const p of repoFiles) {
|
|
54
|
+
const segments = p.split('/');
|
|
55
|
+
for (let i = 1; i < segments.length; i += 1) {
|
|
56
|
+
dirs.add(segments.slice(0, i).join('/'));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return dirs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function repairInternalLinks(docs, { docDir, repoFiles }) {
|
|
63
|
+
const fileSet = new Set(repoFiles);
|
|
64
|
+
const docSet = new Set(docs.map((d) => `${docDir}/${d.filename}`));
|
|
65
|
+
const dirSet = buildDirSet(repoFiles);
|
|
66
|
+
const byBasename = new Map();
|
|
67
|
+
for (const p of repoFiles) {
|
|
68
|
+
const base = p.split('/').pop();
|
|
69
|
+
if (!byBasename.has(base)) byBasename.set(base, []);
|
|
70
|
+
byBasename.get(base).push(p);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const upPrefix = '../'.repeat(docDir.split('/').length);
|
|
74
|
+
const repairs = [];
|
|
75
|
+
|
|
76
|
+
for (const doc of docs) {
|
|
77
|
+
doc.content = (doc.content || '').replace(
|
|
78
|
+
/(\]\()([^)#\s]+)((?:#[^)\s]*)?\))/g,
|
|
79
|
+
(full, open, target, close) => {
|
|
80
|
+
if (/^[a-z]+:\/\//i.test(target) || target.startsWith('mailto:')) return full;
|
|
81
|
+
if (linkResolves(target, { docDir, fileSet, docSet, dirSet })) return full;
|
|
82
|
+
|
|
83
|
+
const basename = target.split('/').pop();
|
|
84
|
+
const candidates = new Set([basename]);
|
|
85
|
+
const deduped = basename.replace(/^(.+?)\.(?=\1\.)/, '');
|
|
86
|
+
candidates.add(deduped);
|
|
87
|
+
|
|
88
|
+
let match = null;
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
const hits = byBasename.get(candidate) || [];
|
|
91
|
+
if (hits.length === 1) {
|
|
92
|
+
match = hits[0];
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!match) {
|
|
97
|
+
for (const candidate of candidates) {
|
|
98
|
+
const suffixHits = [];
|
|
99
|
+
for (const [base, paths] of byBasename) {
|
|
100
|
+
if (base === candidate || !base.endsWith(candidate)) continue;
|
|
101
|
+
const before = base[base.length - candidate.length - 1];
|
|
102
|
+
if (before === '-' || before === '_' || before === '.') suffixHits.push(...paths);
|
|
103
|
+
}
|
|
104
|
+
if (suffixHits.length === 1) {
|
|
105
|
+
match = suffixHits[0];
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!match) return full;
|
|
111
|
+
|
|
112
|
+
const fixed = `${upPrefix}${match}`;
|
|
113
|
+
repairs.push({ doc: doc.filename, from: target, to: fixed });
|
|
114
|
+
return `${open}${fixed}${close}`;
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return repairs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function missingSections(content, sections) {
|
|
123
|
+
const markdown = content || '';
|
|
124
|
+
const missing = [];
|
|
125
|
+
for (const section of sections || []) {
|
|
126
|
+
const re = new RegExp(`^##\\s+${section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
|
|
127
|
+
if (!re.test(markdown)) missing.push(section);
|
|
128
|
+
}
|
|
129
|
+
return missing;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateDocs({ docs, docDir, repoFiles, packageScripts = {}, cliVerbs = [] }) {
|
|
133
|
+
const fileSet = new Set(repoFiles);
|
|
134
|
+
const docSet = new Set(docs.map((d) => `${docDir}/${d.filename}`));
|
|
135
|
+
const dirSet = buildDirSet(repoFiles);
|
|
136
|
+
const issues = [];
|
|
137
|
+
|
|
138
|
+
for (const doc of docs) {
|
|
139
|
+
const markdown = doc.content || '';
|
|
140
|
+
|
|
141
|
+
if (Array.isArray(doc.sections)) {
|
|
142
|
+
for (const section of missingSections(markdown, doc.sections)) {
|
|
143
|
+
issues.push({ doc: doc.filename, type: 'missing-section', detail: section });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const target of extractInternalLinks(markdown)) {
|
|
148
|
+
if (!linkResolves(target, { docDir, fileSet, docSet, dirSet })) {
|
|
149
|
+
issues.push({ doc: doc.filename, type: 'dead-link', detail: target });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const cmd of extractCitedCommands(markdown)) {
|
|
154
|
+
if ((cmd.tool === 'npm' || cmd.tool === 'pnpm' || cmd.tool === 'yarn')) {
|
|
155
|
+
const scriptName = cmd.arg1 === 'run' ? cmd.arg2 : cmd.arg1;
|
|
156
|
+
const builtins = new Set(['install', 'ci', 'test', 'start', 'publish', 'pack', 'link', 'init', 'exec', 'dlx', 'add', 'remove', 'i', 'it', 'update', 'audit', 'sync:ai']);
|
|
157
|
+
if (scriptName && !builtins.has(scriptName) && !(scriptName in packageScripts)) {
|
|
158
|
+
if (cmd.arg1 === 'run' || (!builtins.has(cmd.arg1) && cmd.tool === 'pnpm')) {
|
|
159
|
+
issues.push({ doc: doc.filename, type: 'unknown-script', detail: cmd.line });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (cmd.tool === 'gitset' && cliVerbs.length && cmd.arg1 && !cmd.arg1.startsWith('-')) {
|
|
164
|
+
if (!cliVerbs.includes(cmd.arg1)) {
|
|
165
|
+
issues.push({ doc: doc.filename, type: 'unknown-cli-command', detail: cmd.line });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return issues;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
validateDocs,
|
|
176
|
+
missingSections,
|
|
177
|
+
repairInternalLinks,
|
|
178
|
+
extractInternalLinks,
|
|
179
|
+
extractCitedCommands,
|
|
180
|
+
};
|