nubos-pilot 0.2.2 → 0.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/agents/np-code-fixer.md +22 -4
- package/agents/np-code-reviewer.md +6 -5
- package/agents/np-codebase-documenter.md +176 -0
- package/agents/np-executor.md +54 -7
- package/agents/np-planner.md +1 -0
- package/agents/np-researcher.md +7 -0
- package/bin/install.js +81 -8
- package/bin/np-tools/discuss-project.cjs +377 -0
- package/bin/np-tools/discuss-project.test.cjs +238 -0
- package/bin/np-tools/doctor.cjs +103 -0
- package/bin/np-tools/doctor.test.cjs +112 -0
- package/bin/np-tools/new-project.cjs +6 -0
- package/bin/np-tools/scan-codebase.cjs +204 -0
- package/bin/np-tools/scan-codebase.test.cjs +165 -0
- package/bin/np-tools/update-docs.cjs +216 -0
- package/bin/np-tools/update-docs.test.cjs +130 -0
- package/docs/adr/0007-codebase-docs-layer.md +273 -0
- package/docs/adr/README.md +2 -0
- package/lib/codebase-docs.cjs +450 -0
- package/lib/codebase-docs.test.cjs +266 -0
- package/lib/codebase-manifest.cjs +171 -0
- package/lib/codebase-manifest.test.cjs +156 -0
- package/lib/git.cjs +38 -0
- package/lib/install/runtime-assets.cjs +145 -0
- package/lib/install/runtimes-registry.cjs +4 -0
- package/lib/workspace-scan.cjs +290 -0
- package/lib/workspace-scan.test.cjs +212 -0
- package/np-tools.cjs +3 -0
- package/package.json +1 -1
- package/templates/PROJECT.md +26 -4
- package/workflows/discuss-project.md +177 -0
- package/workflows/new-project.md +141 -94
- package/workflows/scan-codebase.md +155 -0
- package/workflows/update-docs.md +132 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const registryMod = require('./runtimes-registry.cjs');
|
|
8
|
+
|
|
9
|
+
function _hashFile(file) {
|
|
10
|
+
const h = crypto.createHash('sha256');
|
|
11
|
+
h.update(fs.readFileSync(file));
|
|
12
|
+
return h.digest('hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function _listMarkdown(dir) {
|
|
16
|
+
if (!fs.existsSync(dir)) return [];
|
|
17
|
+
return fs.readdirSync(dir)
|
|
18
|
+
.filter((n) => n.endsWith('.md'))
|
|
19
|
+
.sort();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function _payloadBase(scope, projectRoot) {
|
|
23
|
+
return scope === 'global' ? os.homedir() : projectRoot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _toPosix(p) {
|
|
27
|
+
return p.split(path.sep).join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function planRuntimeAssets({ selectedRuntimes, scope, projectRoot, workflowsDir, agentsDir }) {
|
|
31
|
+
const base = _payloadBase(scope, projectRoot);
|
|
32
|
+
const workflows = _listMarkdown(workflowsDir);
|
|
33
|
+
const agents = _listMarkdown(agentsDir);
|
|
34
|
+
const plans = [];
|
|
35
|
+
for (const id of selectedRuntimes || []) {
|
|
36
|
+
const meta = registryMod.getRuntimeMeta(id);
|
|
37
|
+
if (!meta) continue;
|
|
38
|
+
const configDir = registryMod.runtimeConfigDir(meta, scope, projectRoot);
|
|
39
|
+
if (meta.commandsSubdir) {
|
|
40
|
+
for (const file of workflows) {
|
|
41
|
+
const targetFile = path.join(configDir, meta.commandsSubdir, file);
|
|
42
|
+
plans.push({
|
|
43
|
+
runtime: id,
|
|
44
|
+
kind: 'command',
|
|
45
|
+
sourceFile: path.join(workflowsDir, file),
|
|
46
|
+
targetFile,
|
|
47
|
+
manifestKey: _toPosix(path.relative(base, targetFile)),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (meta.agentsSubdir) {
|
|
52
|
+
for (const file of agents) {
|
|
53
|
+
const targetFile = path.join(configDir, meta.agentsSubdir, file);
|
|
54
|
+
plans.push({
|
|
55
|
+
runtime: id,
|
|
56
|
+
kind: 'agent',
|
|
57
|
+
sourceFile: path.join(agentsDir, file),
|
|
58
|
+
targetFile,
|
|
59
|
+
manifestKey: _toPosix(path.relative(base, targetFile)),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return plans;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function manifestEntriesForPlans(plans) {
|
|
68
|
+
const entries = Object.create(null);
|
|
69
|
+
for (const plan of plans) {
|
|
70
|
+
entries[plan.manifestKey] = _hashFile(plan.sourceFile);
|
|
71
|
+
}
|
|
72
|
+
return entries;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeRuntimeAssets(plans) {
|
|
76
|
+
const written = [];
|
|
77
|
+
for (const plan of plans) {
|
|
78
|
+
fs.mkdirSync(path.dirname(plan.targetFile), { recursive: true });
|
|
79
|
+
fs.copyFileSync(plan.sourceFile, plan.targetFile);
|
|
80
|
+
written.push(plan.targetFile);
|
|
81
|
+
}
|
|
82
|
+
return written;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function removeStaleAssets(staleKeys, scope, projectRoot) {
|
|
86
|
+
const base = _payloadBase(scope, projectRoot);
|
|
87
|
+
const removed = [];
|
|
88
|
+
const dirs = new Set();
|
|
89
|
+
for (const key of staleKeys || []) {
|
|
90
|
+
if (!_isAssetKey(key)) continue;
|
|
91
|
+
const abs = path.join(base, key);
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(abs);
|
|
94
|
+
removed.push(abs);
|
|
95
|
+
dirs.add(path.dirname(abs));
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
_pruneEmptyDirs(dirs, base);
|
|
99
|
+
return removed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _isAssetKey(key) {
|
|
103
|
+
if (typeof key !== 'string') return false;
|
|
104
|
+
if (key.startsWith('~/')) return true;
|
|
105
|
+
if (key.startsWith('.')) {
|
|
106
|
+
if (key.startsWith('.claude/commands/')) return true;
|
|
107
|
+
if (key.startsWith('.claude/agents/')) return true;
|
|
108
|
+
for (const meta of registryMod.RUNTIMES) {
|
|
109
|
+
if (meta.commandsSubdir) {
|
|
110
|
+
if (key.startsWith(meta.localDir + '/' + meta.commandsSubdir + '/')) return true;
|
|
111
|
+
}
|
|
112
|
+
if (meta.agentsSubdir) {
|
|
113
|
+
if (key.startsWith(meta.localDir + '/' + meta.agentsSubdir + '/')) return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _pruneEmptyDirs(dirSet, base) {
|
|
121
|
+
const sorted = Array.from(dirSet).sort((a, b) => b.length - a.length);
|
|
122
|
+
for (const dir of sorted) {
|
|
123
|
+
let cur = dir;
|
|
124
|
+
while (cur && cur.startsWith(base) && cur !== base) {
|
|
125
|
+
try {
|
|
126
|
+
const entries = fs.readdirSync(cur);
|
|
127
|
+
if (entries.length > 0) break;
|
|
128
|
+
fs.rmdirSync(cur);
|
|
129
|
+
} catch { break; }
|
|
130
|
+
cur = path.dirname(cur);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isAssetManifestKey(key) {
|
|
136
|
+
return _isAssetKey(key);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
planRuntimeAssets,
|
|
141
|
+
manifestEntriesForPlans,
|
|
142
|
+
writeRuntimeAssets,
|
|
143
|
+
removeStaleAssets,
|
|
144
|
+
isAssetManifestKey,
|
|
145
|
+
};
|
|
@@ -13,6 +13,8 @@ const RUNTIMES = [
|
|
|
13
13
|
agentsMd: 'CLAUDE.md',
|
|
14
14
|
agentsMdScope: 'project',
|
|
15
15
|
payloadSubdir: 'nubos-pilot',
|
|
16
|
+
commandsSubdir: 'commands/np',
|
|
17
|
+
agentsSubdir: 'agents',
|
|
16
18
|
},
|
|
17
19
|
{
|
|
18
20
|
id: 'antigravity',
|
|
@@ -113,6 +115,8 @@ const RUNTIMES = [
|
|
|
113
115
|
agentsMd: 'AGENTS.md',
|
|
114
116
|
agentsMdScope: 'dir',
|
|
115
117
|
payloadSubdir: 'nubos-pilot',
|
|
118
|
+
commandsSubdir: 'command/np',
|
|
119
|
+
agentsSubdir: 'agent',
|
|
116
120
|
},
|
|
117
121
|
{
|
|
118
122
|
id: 'qwen',
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { NubosPilotError } = require('./core.cjs');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_IGNORES = Object.freeze(new Set([
|
|
7
|
+
'node_modules', '.git', '.nubos-pilot', '.planning', '.claude',
|
|
8
|
+
'vendor', 'target', 'build', 'dist', 'out',
|
|
9
|
+
'.next', '.nuxt', '.svelte-kit', '.astro',
|
|
10
|
+
'.venv', 'venv', 'env', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache',
|
|
11
|
+
'coverage', '.coverage', '.nyc_output', '.tox',
|
|
12
|
+
'.idea', '.vscode', '.vs',
|
|
13
|
+
'.cache', '.turbo', '.parcel-cache', '.gradle',
|
|
14
|
+
'Pods', 'DerivedData',
|
|
15
|
+
'tmp', 'temp', '.tmp',
|
|
16
|
+
]));
|
|
17
|
+
|
|
18
|
+
const MANIFEST_FILES = Object.freeze(new Set([
|
|
19
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
|
|
20
|
+
'tsconfig.json', 'jsconfig.json',
|
|
21
|
+
'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile', 'Pipfile.lock', 'poetry.lock',
|
|
22
|
+
'Cargo.toml', 'Cargo.lock',
|
|
23
|
+
'go.mod', 'go.sum',
|
|
24
|
+
'composer.json', 'composer.lock',
|
|
25
|
+
'Gemfile', 'Gemfile.lock', 'Rakefile',
|
|
26
|
+
'mix.exs', 'rebar.config',
|
|
27
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts',
|
|
28
|
+
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
|
|
29
|
+
'Makefile', 'CMakeLists.txt',
|
|
30
|
+
'.nvmrc', '.tool-versions', '.node-version', '.python-version', '.ruby-version',
|
|
31
|
+
'.env.example', '.env.sample', '.editorconfig',
|
|
32
|
+
]));
|
|
33
|
+
|
|
34
|
+
const DOC_FILE_PREFIXES = Object.freeze([
|
|
35
|
+
'README', 'CHANGELOG', 'LICENSE', 'CONTRIBUTING',
|
|
36
|
+
'ARCHITECTURE', 'ROADMAP', 'SECURITY', 'CODE_OF_CONDUCT',
|
|
37
|
+
'AUTHORS', 'NOTICE', 'DESIGN',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const BINARY_EXTS = Object.freeze(new Set([
|
|
41
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.avif',
|
|
42
|
+
'.pdf', '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.xz',
|
|
43
|
+
'.mp3', '.mp4', '.mov', '.avi', '.mkv', '.wav', '.flac', '.ogg', '.webm',
|
|
44
|
+
'.ttf', '.otf', '.woff', '.woff2', '.eot',
|
|
45
|
+
'.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a',
|
|
46
|
+
'.class', '.jar', '.war', '.pyc', '.pyo',
|
|
47
|
+
'.db', '.sqlite', '.sqlite3',
|
|
48
|
+
'.node', '.wasm',
|
|
49
|
+
'.psd', '.ai', '.sketch', '.fig',
|
|
50
|
+
]));
|
|
51
|
+
|
|
52
|
+
const MAX_FILE_HASH_BYTES = 512 * 1024;
|
|
53
|
+
const MAX_FILE_CAPTURE_BYTES = 200 * 1024;
|
|
54
|
+
const MAX_FILES_WALKED = 100000;
|
|
55
|
+
const MAX_DEPTH = 12;
|
|
56
|
+
|
|
57
|
+
function _isDocFile(basename) {
|
|
58
|
+
const up = basename.toUpperCase();
|
|
59
|
+
for (const prefix of DOC_FILE_PREFIXES) {
|
|
60
|
+
if (up === prefix) return true;
|
|
61
|
+
if (up.startsWith(prefix + '.')) return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _isDotfileAllowed(name) {
|
|
67
|
+
if (name === '.nvmrc' || name === '.tool-versions' || name === '.node-version') return true;
|
|
68
|
+
if (name === '.python-version' || name === '.ruby-version') return true;
|
|
69
|
+
if (name === '.env.example' || name === '.env.sample') return true;
|
|
70
|
+
if (name === '.editorconfig' || name === '.gitignore' || name === '.dockerignore') return true;
|
|
71
|
+
if (name === '.gitattributes') return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _sha256(buffer) {
|
|
76
|
+
return 'sha256:' + crypto.createHash('sha256').update(buffer).digest('hex');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _readCapture(absPath) {
|
|
80
|
+
try {
|
|
81
|
+
const fd = fs.openSync(absPath, 'r');
|
|
82
|
+
try {
|
|
83
|
+
const stat = fs.fstatSync(fd);
|
|
84
|
+
const toRead = Math.min(stat.size, MAX_FILE_CAPTURE_BYTES);
|
|
85
|
+
const buf = Buffer.alloc(toRead);
|
|
86
|
+
if (toRead > 0) fs.readSync(fd, buf, 0, toRead, 0);
|
|
87
|
+
return {
|
|
88
|
+
content: buf.toString('utf-8'),
|
|
89
|
+
size: stat.size,
|
|
90
|
+
truncated: stat.size > toRead,
|
|
91
|
+
};
|
|
92
|
+
} finally {
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { error: err && err.code ? err.code : String(err) };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function _walk(root, ignores, opts) {
|
|
101
|
+
const files = [];
|
|
102
|
+
const skipped = [];
|
|
103
|
+
let walked = 0;
|
|
104
|
+
|
|
105
|
+
function visit(abs, rel, depth) {
|
|
106
|
+
if (walked >= opts.maxFiles) return;
|
|
107
|
+
if (depth > opts.maxDepth) return;
|
|
108
|
+
let entries;
|
|
109
|
+
try {
|
|
110
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
skipped.push({ path: rel || '.', reason: 'readdir-error', detail: err && err.code });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (walked >= opts.maxFiles) {
|
|
117
|
+
skipped.push({ path: rel, reason: 'max-files-reached' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const name = entry.name;
|
|
121
|
+
if (ignores.has(name)) continue;
|
|
122
|
+
if (name.startsWith('.') && entry.isDirectory()) continue;
|
|
123
|
+
if (name.startsWith('.') && entry.isFile() && !_isDotfileAllowed(name)) continue;
|
|
124
|
+
|
|
125
|
+
const childAbs = path.join(abs, name);
|
|
126
|
+
const childRel = rel === '' ? name : rel + '/' + name;
|
|
127
|
+
if (entry.isSymbolicLink()) continue;
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
visit(childAbs, childRel, depth + 1);
|
|
130
|
+
} else if (entry.isFile()) {
|
|
131
|
+
walked++;
|
|
132
|
+
let stat;
|
|
133
|
+
try {
|
|
134
|
+
stat = fs.statSync(childAbs);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
skipped.push({ path: childRel, reason: 'stat-error', detail: err && err.code });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
files.push({
|
|
140
|
+
path: childRel,
|
|
141
|
+
absPath: childAbs,
|
|
142
|
+
size: stat.size,
|
|
143
|
+
ext: path.extname(name).toLowerCase(),
|
|
144
|
+
basename: name,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
visit(root, '', 0);
|
|
151
|
+
return { files, skipped };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _hashFile(file) {
|
|
155
|
+
if (BINARY_EXTS.has(file.ext)) return null;
|
|
156
|
+
if (file.size > MAX_FILE_HASH_BYTES) return null;
|
|
157
|
+
try {
|
|
158
|
+
return _sha256(fs.readFileSync(file.absPath));
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scan(opts) {
|
|
165
|
+
const options = opts || {};
|
|
166
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
167
|
+
const ignores = new Set([...DEFAULT_IGNORES, ...(options.additionalIgnores || [])]);
|
|
168
|
+
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
|
|
169
|
+
const batchSize = options.batchSize > 0 ? options.batchSize : 500;
|
|
170
|
+
const maxFiles = options.maxFiles > 0 ? options.maxFiles : MAX_FILES_WALKED;
|
|
171
|
+
const maxDepth = options.maxDepth > 0 ? options.maxDepth : MAX_DEPTH;
|
|
172
|
+
|
|
173
|
+
let rootStat;
|
|
174
|
+
try {
|
|
175
|
+
rootStat = fs.statSync(cwd);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
throw new NubosPilotError(
|
|
178
|
+
'scan-cwd-unreadable',
|
|
179
|
+
`cannot stat cwd: ${cwd}`,
|
|
180
|
+
{ cwd, cause: err && err.code },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (!rootStat.isDirectory()) {
|
|
184
|
+
throw new NubosPilotError(
|
|
185
|
+
'scan-not-a-directory',
|
|
186
|
+
`cwd is not a directory: ${cwd}`,
|
|
187
|
+
{ cwd },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
onProgress({ phase: 'walk-start', cwd });
|
|
192
|
+
const { files, skipped } = _walk(cwd, ignores, { maxFiles, maxDepth });
|
|
193
|
+
onProgress({ phase: 'walk-complete', file_count: files.length, skipped: skipped.length });
|
|
194
|
+
|
|
195
|
+
const language_distribution = {};
|
|
196
|
+
const manifests = {};
|
|
197
|
+
const docs = {};
|
|
198
|
+
const fileHashes = [];
|
|
199
|
+
let totalBytes = 0;
|
|
200
|
+
|
|
201
|
+
const totalBatches = Math.ceil(files.length / batchSize);
|
|
202
|
+
for (let i = 0; i < files.length; i += batchSize) {
|
|
203
|
+
const batchIndex = Math.floor(i / batchSize);
|
|
204
|
+
const batch = files.slice(i, i + batchSize);
|
|
205
|
+
onProgress({
|
|
206
|
+
phase: 'batch-start',
|
|
207
|
+
index: batchIndex,
|
|
208
|
+
total: totalBatches,
|
|
209
|
+
size: batch.length,
|
|
210
|
+
files_processed: i,
|
|
211
|
+
files_total: files.length,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
for (const f of batch) {
|
|
215
|
+
totalBytes += f.size;
|
|
216
|
+
const extKey = f.ext || '<no-ext>';
|
|
217
|
+
language_distribution[extKey] = (language_distribution[extKey] || 0) + 1;
|
|
218
|
+
|
|
219
|
+
const isManifest = MANIFEST_FILES.has(f.basename);
|
|
220
|
+
const isDoc = _isDocFile(f.basename);
|
|
221
|
+
if (isManifest || isDoc) {
|
|
222
|
+
const captured = _readCapture(f.absPath);
|
|
223
|
+
const entry = { path: f.path, size: f.size, ...captured };
|
|
224
|
+
if (isManifest) manifests[f.path] = entry;
|
|
225
|
+
else docs[f.path] = entry;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const hash = _hashFile(f);
|
|
229
|
+
if (hash) {
|
|
230
|
+
fileHashes.push({
|
|
231
|
+
path: f.path,
|
|
232
|
+
size: f.size,
|
|
233
|
+
sha256: hash,
|
|
234
|
+
ext: f.ext,
|
|
235
|
+
});
|
|
236
|
+
} else {
|
|
237
|
+
skipped.push({
|
|
238
|
+
path: f.path,
|
|
239
|
+
reason: BINARY_EXTS.has(f.ext) ? 'binary' : f.size > MAX_FILE_HASH_BYTES ? 'too-large' : 'hash-error',
|
|
240
|
+
size: f.size,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
onProgress({
|
|
246
|
+
phase: 'batch-done',
|
|
247
|
+
index: batchIndex,
|
|
248
|
+
total: totalBatches,
|
|
249
|
+
files_processed: Math.min(i + batch.length, files.length),
|
|
250
|
+
files_total: files.length,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let git = { is_repo: false };
|
|
255
|
+
if (typeof options.gitInfo === 'function') {
|
|
256
|
+
try { git = options.gitInfo(cwd) || { is_repo: false }; }
|
|
257
|
+
catch { git = { is_repo: false }; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = {
|
|
261
|
+
cwd,
|
|
262
|
+
scanned_at: new Date().toISOString(),
|
|
263
|
+
stats: {
|
|
264
|
+
file_count: files.length,
|
|
265
|
+
hashed_count: fileHashes.length,
|
|
266
|
+
manifest_count: Object.keys(manifests).length,
|
|
267
|
+
doc_count: Object.keys(docs).length,
|
|
268
|
+
skipped_count: skipped.length,
|
|
269
|
+
total_bytes: totalBytes,
|
|
270
|
+
},
|
|
271
|
+
files: fileHashes,
|
|
272
|
+
manifests,
|
|
273
|
+
docs,
|
|
274
|
+
git,
|
|
275
|
+
language_distribution,
|
|
276
|
+
skipped,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
onProgress({ phase: 'complete', stats: result.stats });
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
scan,
|
|
285
|
+
DEFAULT_IGNORES,
|
|
286
|
+
MANIFEST_FILES,
|
|
287
|
+
BINARY_EXTS,
|
|
288
|
+
MAX_FILE_HASH_BYTES,
|
|
289
|
+
MAX_FILE_CAPTURE_BYTES,
|
|
290
|
+
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
const { test, afterEach } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { execFileSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const { scan, DEFAULT_IGNORES, MANIFEST_FILES } = require('./workspace-scan.cjs');
|
|
9
|
+
const { workspaceGitInfo } = require('./git.cjs');
|
|
10
|
+
|
|
11
|
+
const _sandboxes = [];
|
|
12
|
+
|
|
13
|
+
function makeSandbox() {
|
|
14
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-ws-scan-'));
|
|
15
|
+
_sandboxes.push(dir);
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function write(root, rel, content) {
|
|
20
|
+
const abs = path.join(root, rel);
|
|
21
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
22
|
+
fs.writeFileSync(abs, content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
while (_sandboxes.length) {
|
|
27
|
+
const dir = _sandboxes.pop();
|
|
28
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('WS-1: empty directory returns zero files', () => {
|
|
33
|
+
const root = makeSandbox();
|
|
34
|
+
const result = scan({ cwd: root });
|
|
35
|
+
assert.equal(result.stats.file_count, 0);
|
|
36
|
+
assert.equal(result.stats.hashed_count, 0);
|
|
37
|
+
assert.deepEqual(result.files, []);
|
|
38
|
+
assert.equal(result.git.is_repo, false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('WS-2: walks regular source files and hashes them', () => {
|
|
42
|
+
const root = makeSandbox();
|
|
43
|
+
write(root, 'src/index.js', 'console.log("hi");\n');
|
|
44
|
+
write(root, 'src/util.js', 'module.exports = {};\n');
|
|
45
|
+
write(root, 'lib/helper.py', 'def f(): pass\n');
|
|
46
|
+
|
|
47
|
+
const result = scan({ cwd: root });
|
|
48
|
+
assert.equal(result.stats.file_count, 3);
|
|
49
|
+
assert.equal(result.stats.hashed_count, 3);
|
|
50
|
+
const paths = result.files.map((f) => f.path).sort();
|
|
51
|
+
assert.deepEqual(paths, ['lib/helper.py', 'src/index.js', 'src/util.js']);
|
|
52
|
+
for (const f of result.files) {
|
|
53
|
+
assert.match(f.sha256, /^sha256:[a-f0-9]{64}$/);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('WS-3: ignores node_modules / .git / vendor / .nubos-pilot', () => {
|
|
58
|
+
const root = makeSandbox();
|
|
59
|
+
write(root, 'src/a.js', 'a');
|
|
60
|
+
write(root, 'node_modules/foo/index.js', 'foo');
|
|
61
|
+
write(root, 'vendor/libx/x.php', 'x');
|
|
62
|
+
write(root, '.git/HEAD', 'ref: refs/heads/main');
|
|
63
|
+
write(root, '.nubos-pilot/PROJECT.md', '# project');
|
|
64
|
+
|
|
65
|
+
const result = scan({ cwd: root });
|
|
66
|
+
const paths = result.files.map((f) => f.path);
|
|
67
|
+
assert.deepEqual(paths, ['src/a.js']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('WS-4: captures manifest content (package.json) verbatim', () => {
|
|
71
|
+
const root = makeSandbox();
|
|
72
|
+
const pkg = { name: 'demo', version: '1.0.0', dependencies: { express: '^4' } };
|
|
73
|
+
write(root, 'package.json', JSON.stringify(pkg, null, 2));
|
|
74
|
+
|
|
75
|
+
const result = scan({ cwd: root });
|
|
76
|
+
assert.ok(result.manifests['package.json']);
|
|
77
|
+
const parsed = JSON.parse(result.manifests['package.json'].content);
|
|
78
|
+
assert.equal(parsed.name, 'demo');
|
|
79
|
+
assert.equal(parsed.dependencies.express, '^4');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('WS-5: captures README as a doc', () => {
|
|
83
|
+
const root = makeSandbox();
|
|
84
|
+
write(root, 'README.md', '# Demo\n\nHello world.\n');
|
|
85
|
+
|
|
86
|
+
const result = scan({ cwd: root });
|
|
87
|
+
assert.ok(result.docs['README.md']);
|
|
88
|
+
assert.ok(result.docs['README.md'].content.includes('Demo'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('WS-6: language_distribution counts extensions', () => {
|
|
92
|
+
const root = makeSandbox();
|
|
93
|
+
write(root, 'a.js', 'a');
|
|
94
|
+
write(root, 'b.js', 'b');
|
|
95
|
+
write(root, 'c.py', 'c');
|
|
96
|
+
write(root, 'Makefile', 'all:\n');
|
|
97
|
+
|
|
98
|
+
const result = scan({ cwd: root });
|
|
99
|
+
assert.equal(result.language_distribution['.js'], 2);
|
|
100
|
+
assert.equal(result.language_distribution['.py'], 1);
|
|
101
|
+
assert.equal(result.language_distribution['<no-ext>'], 1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('WS-7: onProgress emits batch events', () => {
|
|
105
|
+
const root = makeSandbox();
|
|
106
|
+
for (let i = 0; i < 5; i++) write(root, `f${i}.txt`, 'x');
|
|
107
|
+
|
|
108
|
+
const events = [];
|
|
109
|
+
scan({ cwd: root, batchSize: 2, onProgress: (e) => events.push(e.phase) });
|
|
110
|
+
assert.ok(events.includes('walk-start'));
|
|
111
|
+
assert.ok(events.includes('walk-complete'));
|
|
112
|
+
assert.ok(events.includes('batch-start'));
|
|
113
|
+
assert.ok(events.includes('batch-done'));
|
|
114
|
+
assert.equal(events[events.length - 1], 'complete');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('WS-8: binary extensions skip hashing', () => {
|
|
118
|
+
const root = makeSandbox();
|
|
119
|
+
write(root, 'logo.png', Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
120
|
+
write(root, 'code.js', 'x');
|
|
121
|
+
|
|
122
|
+
const result = scan({ cwd: root });
|
|
123
|
+
const hashed = result.files.map((f) => f.path);
|
|
124
|
+
assert.deepEqual(hashed, ['code.js']);
|
|
125
|
+
const skippedPaths = result.skipped.map((s) => s.path);
|
|
126
|
+
assert.ok(skippedPaths.includes('logo.png'));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('WS-9: additionalIgnores extend the default ignore set', () => {
|
|
130
|
+
const root = makeSandbox();
|
|
131
|
+
write(root, 'src/a.js', 'a');
|
|
132
|
+
write(root, 'generated/big.js', 'g');
|
|
133
|
+
|
|
134
|
+
const result = scan({ cwd: root, additionalIgnores: ['generated'] });
|
|
135
|
+
const paths = result.files.map((f) => f.path);
|
|
136
|
+
assert.deepEqual(paths, ['src/a.js']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('WS-10: detects git repo and captures commits', () => {
|
|
140
|
+
const root = makeSandbox();
|
|
141
|
+
write(root, 'a.txt', 'a');
|
|
142
|
+
try {
|
|
143
|
+
execFileSync('git', ['init', '-q'], { cwd: root, stdio: 'ignore' });
|
|
144
|
+
execFileSync('git', ['config', 'user.email', 't@t.t'], { cwd: root, stdio: 'ignore' });
|
|
145
|
+
execFileSync('git', ['config', 'user.name', 't'], { cwd: root, stdio: 'ignore' });
|
|
146
|
+
execFileSync('git', ['add', 'a.txt'], { cwd: root, stdio: 'ignore' });
|
|
147
|
+
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: root, stdio: 'ignore' });
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = scan({ cwd: root, gitInfo: workspaceGitInfo });
|
|
153
|
+
assert.equal(result.git.is_repo, true);
|
|
154
|
+
assert.ok(Array.isArray(result.git.commits));
|
|
155
|
+
assert.ok(result.git.commits.length >= 1);
|
|
156
|
+
assert.equal(result.git.commits[0].subject, 'init');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('WS-11: throws scan-not-a-directory for file path', () => {
|
|
160
|
+
const root = makeSandbox();
|
|
161
|
+
const filePath = path.join(root, 'not-a-dir.txt');
|
|
162
|
+
fs.writeFileSync(filePath, 'x');
|
|
163
|
+
assert.throws(
|
|
164
|
+
() => scan({ cwd: filePath }),
|
|
165
|
+
(err) => err.code === 'scan-not-a-directory',
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('WS-12: manifest file triggers capture even in nested dir', () => {
|
|
170
|
+
const root = makeSandbox();
|
|
171
|
+
write(root, 'services/api/package.json', JSON.stringify({ name: 'api' }));
|
|
172
|
+
write(root, 'services/api/src/main.ts', 'export {};');
|
|
173
|
+
|
|
174
|
+
const result = scan({ cwd: root });
|
|
175
|
+
assert.ok(result.manifests['services/api/package.json']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('WS-13: stats counts match produced arrays', () => {
|
|
179
|
+
const root = makeSandbox();
|
|
180
|
+
write(root, 'a.js', 'a');
|
|
181
|
+
write(root, 'b.js', 'b');
|
|
182
|
+
write(root, 'README.md', '# x');
|
|
183
|
+
write(root, 'package.json', '{}');
|
|
184
|
+
|
|
185
|
+
const result = scan({ cwd: root });
|
|
186
|
+
assert.equal(result.stats.file_count, 4);
|
|
187
|
+
assert.equal(result.stats.manifest_count, Object.keys(result.manifests).length);
|
|
188
|
+
assert.equal(result.stats.doc_count, Object.keys(result.docs).length);
|
|
189
|
+
assert.equal(result.stats.hashed_count, result.files.length);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('WS-14: dotfiles like .nvmrc are walked but .idea directory is ignored', () => {
|
|
193
|
+
const root = makeSandbox();
|
|
194
|
+
write(root, '.nvmrc', '22');
|
|
195
|
+
write(root, '.idea/workspace.xml', '<x/>');
|
|
196
|
+
write(root, 'src/a.js', 'a');
|
|
197
|
+
|
|
198
|
+
const result = scan({ cwd: root });
|
|
199
|
+
const paths = result.files.map((f) => f.path).sort();
|
|
200
|
+
assert.ok(paths.includes('.nvmrc'));
|
|
201
|
+
assert.ok(paths.includes('src/a.js'));
|
|
202
|
+
assert.ok(!paths.some((p) => p.startsWith('.idea')));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('WS-15: MANIFEST_FILES set is frozen and contains known ones', () => {
|
|
206
|
+
assert.ok(MANIFEST_FILES.has('package.json'));
|
|
207
|
+
assert.ok(MANIFEST_FILES.has('Cargo.toml'));
|
|
208
|
+
assert.ok(MANIFEST_FILES.has('go.mod'));
|
|
209
|
+
assert.ok(MANIFEST_FILES.has('composer.json'));
|
|
210
|
+
assert.ok(DEFAULT_IGNORES.has('node_modules'));
|
|
211
|
+
assert.ok(DEFAULT_IGNORES.has('.git'));
|
|
212
|
+
});
|
package/np-tools.cjs
CHANGED
|
@@ -15,6 +15,7 @@ const initWorkflows = {
|
|
|
15
15
|
'discuss-phase-power': require('./bin/np-tools/discuss-phase-power.cjs'),
|
|
16
16
|
'research-phase': require('./bin/np-tools/research-phase.cjs'),
|
|
17
17
|
'new-project': require('./bin/np-tools/new-project.cjs'),
|
|
18
|
+
'discuss-project': require('./bin/np-tools/discuss-project.cjs'),
|
|
18
19
|
'new-milestone': require('./bin/np-tools/new-milestone.cjs'),
|
|
19
20
|
'plan-milestone-gaps': require('./bin/np-tools/plan-milestone-gaps.cjs'),
|
|
20
21
|
|
|
@@ -52,6 +53,8 @@ const topLevelCommands = {
|
|
|
52
53
|
'commit': require('./bin/np-tools/commit.cjs'),
|
|
53
54
|
'config-get': require('./bin/np-tools/config.cjs'),
|
|
54
55
|
'dispatch': require('./bin/np-tools/dispatch.cjs'),
|
|
56
|
+
'scan-codebase': require('./bin/np-tools/scan-codebase.cjs'),
|
|
57
|
+
'update-docs': require('./bin/np-tools/update-docs.cjs'),
|
|
55
58
|
'doctor': require('./bin/np-tools/doctor.cjs'),
|
|
56
59
|
'generate-slug': require('./bin/np-tools/slug.cjs'),
|
|
57
60
|
'metrics': require('./bin/np-tools/metrics.cjs'),
|