nubos-pilot 0.3.0 → 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/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/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,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'),
|
package/package.json
CHANGED
package/templates/PROJECT.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- Placeholders: core_value, created_date, first_milestone_name, first_phase_name, primary_constraints, project_name -->
|
|
1
|
+
<!-- Placeholders: core_value, created_date, domain_text, first_milestone_name, first_phase_name, non_goals_text, primary_constraints, project_description, project_name, strategic_decisions_text, success_criteria_text, target_users_text -->
|
|
2
2
|
# {{project_name}}
|
|
3
3
|
|
|
4
4
|
## Project
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
## What This Is
|
|
9
9
|
|
|
10
|
-
{{
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
{{project_description}}
|
|
11
|
+
|
|
12
|
+
## Domain
|
|
13
|
+
|
|
14
|
+
{{domain_text}}
|
|
15
|
+
|
|
16
|
+
## Target Users
|
|
17
|
+
|
|
18
|
+
{{target_users_text}}
|
|
13
19
|
|
|
14
20
|
## Core Value
|
|
15
21
|
|
|
@@ -18,6 +24,18 @@ and who it serves. Use the user's language and framing.
|
|
|
18
24
|
If everything else fails, this one sentence must remain true. It drives
|
|
19
25
|
prioritization when tradeoffs arise.
|
|
20
26
|
|
|
27
|
+
## Non-Goals
|
|
28
|
+
|
|
29
|
+
{{non_goals_text}}
|
|
30
|
+
|
|
31
|
+
## Success Criteria
|
|
32
|
+
|
|
33
|
+
{{success_criteria_text}}
|
|
34
|
+
|
|
35
|
+
## Strategic Decisions
|
|
36
|
+
|
|
37
|
+
{{strategic_decisions_text}}
|
|
38
|
+
|
|
21
39
|
## Constraints
|
|
22
40
|
|
|
23
41
|
{{primary_constraints}}
|
|
@@ -55,6 +73,10 @@ PROJECT.md evolves throughout the project lifecycle.
|
|
|
55
73
|
2. Core Value check — still the right priority?
|
|
56
74
|
3. Update Current Focus with next milestone/phase
|
|
57
75
|
|
|
76
|
+
**When scope or positioning shifts:**
|
|
77
|
+
- Run `np:discuss-project` to refresh Domain, Target Users, Non-Goals,
|
|
78
|
+
Success Criteria, and Strategic Decisions without starting over.
|
|
79
|
+
|
|
58
80
|
---
|
|
59
81
|
*Created: {{created_date}}*
|
|
60
82
|
*Last updated: {{created_date}} after np:new-project*
|