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,266 @@
|
|
|
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
|
+
|
|
7
|
+
const d = require('./codebase-docs.cjs');
|
|
8
|
+
|
|
9
|
+
const _sandboxes = [];
|
|
10
|
+
|
|
11
|
+
function makeSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-cbd-'));
|
|
13
|
+
_sandboxes.push(dir);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function write(root, rel, content) {
|
|
18
|
+
const abs = path.join(root, rel);
|
|
19
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
20
|
+
fs.writeFileSync(abs, content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
while (_sandboxes.length) {
|
|
25
|
+
const dir = _sandboxes.pop();
|
|
26
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('CD-1: languageForExt maps known extensions', () => {
|
|
31
|
+
assert.equal(d.languageForExt('.js'), 'javascript');
|
|
32
|
+
assert.equal(d.languageForExt('.ts'), 'typescript');
|
|
33
|
+
assert.equal(d.languageForExt('.py'), 'python');
|
|
34
|
+
assert.equal(d.languageForExt('.rs'), 'rust');
|
|
35
|
+
assert.equal(d.languageForExt('.go'), 'go');
|
|
36
|
+
assert.equal(d.languageForExt('.xyz'), 'unknown');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('CD-2: isCodeExt excludes docs and data', () => {
|
|
40
|
+
assert.equal(d.isCodeExt('.js'), true);
|
|
41
|
+
assert.equal(d.isCodeExt('.py'), true);
|
|
42
|
+
assert.equal(d.isCodeExt('.md'), false);
|
|
43
|
+
assert.equal(d.isCodeExt('.json'), false);
|
|
44
|
+
assert.equal(d.isCodeExt('.css'), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('CD-3: groupFilesIntoModules clusters by directory', () => {
|
|
48
|
+
const files = [
|
|
49
|
+
{ path: 'src/auth/login.js', ext: '.js' },
|
|
50
|
+
{ path: 'src/auth/session.js', ext: '.js' },
|
|
51
|
+
{ path: 'src/billing/invoice.js', ext: '.js' },
|
|
52
|
+
{ path: 'README.md', ext: '.md' },
|
|
53
|
+
];
|
|
54
|
+
const modules = d.groupFilesIntoModules(files);
|
|
55
|
+
assert.equal(modules.length, 2);
|
|
56
|
+
const auth = modules.find((m) => m.directory === 'src/auth');
|
|
57
|
+
assert.equal(auth.file_count, 2);
|
|
58
|
+
assert.equal(auth.primary_language, 'javascript');
|
|
59
|
+
const billing = modules.find((m) => m.directory === 'src/billing');
|
|
60
|
+
assert.deepEqual(billing.source_paths, ['src/billing/invoice.js']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('CD-4: groupFilesIntoModules detects mixed-language module', () => {
|
|
64
|
+
const files = [
|
|
65
|
+
{ path: 'services/api/main.py', ext: '.py' },
|
|
66
|
+
{ path: 'services/api/helper.py', ext: '.py' },
|
|
67
|
+
{ path: 'services/api/tool.go', ext: '.go' },
|
|
68
|
+
];
|
|
69
|
+
const modules = d.groupFilesIntoModules(files);
|
|
70
|
+
assert.equal(modules.length, 1);
|
|
71
|
+
assert.equal(modules[0].primary_language, 'python');
|
|
72
|
+
assert.equal(modules[0].language_distribution.python, 2);
|
|
73
|
+
assert.equal(modules[0].language_distribution.go, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('CD-5: extractSymbols — javascript', () => {
|
|
77
|
+
const root = makeSandbox();
|
|
78
|
+
write(root, 'x.js', [
|
|
79
|
+
'export function login(user) {}',
|
|
80
|
+
'export class Session {}',
|
|
81
|
+
'export const TOKEN = 1;',
|
|
82
|
+
'module.exports.helper = () => {};',
|
|
83
|
+
'function internalOnly() {}',
|
|
84
|
+
].join('\n'));
|
|
85
|
+
const syms = d.extractSymbols(path.join(root, 'x.js'), 'javascript');
|
|
86
|
+
assert.ok(syms.includes('login'));
|
|
87
|
+
assert.ok(syms.includes('Session'));
|
|
88
|
+
assert.ok(syms.includes('TOKEN'));
|
|
89
|
+
assert.ok(syms.includes('helper'));
|
|
90
|
+
assert.ok(!syms.includes('internalOnly'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('CD-6: extractSymbols — python', () => {
|
|
94
|
+
const root = makeSandbox();
|
|
95
|
+
write(root, 'x.py', [
|
|
96
|
+
'import os',
|
|
97
|
+
'def greet(name):',
|
|
98
|
+
' pass',
|
|
99
|
+
'class Auth:',
|
|
100
|
+
' def _private(self): pass',
|
|
101
|
+
'async def fetch(): pass',
|
|
102
|
+
].join('\n'));
|
|
103
|
+
const syms = d.extractSymbols(path.join(root, 'x.py'), 'python');
|
|
104
|
+
assert.ok(syms.includes('greet'));
|
|
105
|
+
assert.ok(syms.includes('Auth'));
|
|
106
|
+
assert.ok(syms.includes('fetch'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('CD-7: extractSymbols — go exports only uppercase', () => {
|
|
110
|
+
const root = makeSandbox();
|
|
111
|
+
write(root, 'x.go', [
|
|
112
|
+
'package main',
|
|
113
|
+
'func Start() {}',
|
|
114
|
+
'func internal() {}',
|
|
115
|
+
'type User struct {}',
|
|
116
|
+
'type privateType int',
|
|
117
|
+
].join('\n'));
|
|
118
|
+
const syms = d.extractSymbols(path.join(root, 'x.go'), 'go');
|
|
119
|
+
assert.ok(syms.includes('Start'));
|
|
120
|
+
assert.ok(syms.includes('User'));
|
|
121
|
+
assert.ok(!syms.includes('internal'));
|
|
122
|
+
assert.ok(!syms.includes('privateType'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('CD-8: extractDeps — javascript imports and requires', () => {
|
|
126
|
+
const root = makeSandbox();
|
|
127
|
+
write(root, 'x.js', [
|
|
128
|
+
'import { readFile } from "node:fs";',
|
|
129
|
+
'import lodash from "lodash";',
|
|
130
|
+
'const path = require("node:path");',
|
|
131
|
+
'const local = require("./local");',
|
|
132
|
+
].join('\n'));
|
|
133
|
+
const deps = d.extractDeps(path.join(root, 'x.js'), 'javascript');
|
|
134
|
+
assert.ok(deps.includes('node:fs'));
|
|
135
|
+
assert.ok(deps.includes('lodash'));
|
|
136
|
+
assert.ok(deps.includes('node:path'));
|
|
137
|
+
assert.ok(deps.includes('./local'));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('CD-9: extractDeps — go import block', () => {
|
|
141
|
+
const root = makeSandbox();
|
|
142
|
+
write(root, 'x.go', [
|
|
143
|
+
'package main',
|
|
144
|
+
'import (',
|
|
145
|
+
' "fmt"',
|
|
146
|
+
' "net/http"',
|
|
147
|
+
')',
|
|
148
|
+
'import "os"',
|
|
149
|
+
].join('\n'));
|
|
150
|
+
const deps = d.extractDeps(path.join(root, 'x.go'), 'go');
|
|
151
|
+
assert.ok(deps.includes('fmt'));
|
|
152
|
+
assert.ok(deps.includes('net/http'));
|
|
153
|
+
assert.ok(deps.includes('os'));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('CD-10: buildModuleFacts aggregates symbols and splits internal vs external deps', () => {
|
|
157
|
+
const root = makeSandbox();
|
|
158
|
+
write(root, 'src/auth/login.js', [
|
|
159
|
+
'import { db } from "../db";',
|
|
160
|
+
'import bcrypt from "bcrypt";',
|
|
161
|
+
'export function login() {}',
|
|
162
|
+
].join('\n'));
|
|
163
|
+
write(root, 'src/auth/session.js', [
|
|
164
|
+
'import { cache } from "../cache";',
|
|
165
|
+
'export class Session {}',
|
|
166
|
+
].join('\n'));
|
|
167
|
+
const files = [
|
|
168
|
+
{ path: 'src/auth/login.js', ext: '.js' },
|
|
169
|
+
{ path: 'src/auth/session.js', ext: '.js' },
|
|
170
|
+
];
|
|
171
|
+
const modules = d.groupFilesIntoModules(files);
|
|
172
|
+
const facts = d.buildModuleFacts(modules[0], root);
|
|
173
|
+
assert.ok(facts.symbols.includes('login'));
|
|
174
|
+
assert.ok(facts.symbols.includes('Session'));
|
|
175
|
+
assert.ok(facts.internal_deps.includes('../db'));
|
|
176
|
+
assert.ok(facts.internal_deps.includes('../cache'));
|
|
177
|
+
assert.ok(facts.external_deps.includes('bcrypt'));
|
|
178
|
+
assert.equal(facts.file_count, 2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('CD-11: buildDocumenterPrompt includes facts JSON and output schema', () => {
|
|
182
|
+
const facts = {
|
|
183
|
+
id: 'src-auth',
|
|
184
|
+
name: 'src/auth',
|
|
185
|
+
directory: 'src/auth',
|
|
186
|
+
primary_language: 'javascript',
|
|
187
|
+
file_count: 2,
|
|
188
|
+
source_paths: ['src/auth/login.js'],
|
|
189
|
+
symbols: ['login'],
|
|
190
|
+
internal_deps: [],
|
|
191
|
+
external_deps: ['bcrypt'],
|
|
192
|
+
files: [],
|
|
193
|
+
};
|
|
194
|
+
const prompt = d.buildDocumenterPrompt(facts);
|
|
195
|
+
assert.ok(prompt.includes('np-codebase-documenter'));
|
|
196
|
+
assert.ok(prompt.includes('"key_concepts"'));
|
|
197
|
+
assert.ok(prompt.includes('"gotchas"'));
|
|
198
|
+
assert.ok(prompt.includes('"id": "src-auth"'));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('CD-12: renderModuleDoc produces frontmatter + body with prose', () => {
|
|
202
|
+
const facts = {
|
|
203
|
+
id: 'src-auth',
|
|
204
|
+
name: 'src/auth',
|
|
205
|
+
directory: 'src/auth',
|
|
206
|
+
primary_language: 'javascript',
|
|
207
|
+
file_count: 1,
|
|
208
|
+
source_paths: ['src/auth/login.js'],
|
|
209
|
+
symbols: ['login'],
|
|
210
|
+
internal_deps: [],
|
|
211
|
+
external_deps: ['bcrypt'],
|
|
212
|
+
files: [{ path: 'src/auth/login.js', language: 'javascript', symbols: ['login'], deps: ['bcrypt'] }],
|
|
213
|
+
};
|
|
214
|
+
const prose = {
|
|
215
|
+
description: 'Handles user login flow',
|
|
216
|
+
purpose: 'Authenticates users via bcrypt hashes.',
|
|
217
|
+
key_concepts: ['Passwords hashed before compare'],
|
|
218
|
+
public_api: '`login(credentials)` returns session token.',
|
|
219
|
+
invariants: ['No plaintext passwords stored'],
|
|
220
|
+
gotchas: ['bcrypt cost must match production'],
|
|
221
|
+
};
|
|
222
|
+
const md = d.renderModuleDoc(facts, prose, { 'src/auth/login.js': 'sha256:abc' });
|
|
223
|
+
assert.ok(md.startsWith('---\n'));
|
|
224
|
+
assert.ok(md.includes('name: src/auth'));
|
|
225
|
+
assert.ok(md.includes('kind: module'));
|
|
226
|
+
assert.ok(md.includes('description: Handles user login flow'));
|
|
227
|
+
assert.ok(md.includes('symbols:\n - login'));
|
|
228
|
+
assert.ok(md.includes('bcrypt'));
|
|
229
|
+
assert.ok(md.includes('## Purpose'));
|
|
230
|
+
assert.ok(md.includes('Authenticates users'));
|
|
231
|
+
assert.ok(md.includes('## Gotchas'));
|
|
232
|
+
assert.ok(md.includes('## Files'));
|
|
233
|
+
assert.ok(md.includes('src/auth/login.js: sha256:abc'));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('CD-13: renderModuleDoc gracefully handles missing prose', () => {
|
|
237
|
+
const facts = {
|
|
238
|
+
id: 'lib',
|
|
239
|
+
name: 'lib',
|
|
240
|
+
directory: 'lib',
|
|
241
|
+
primary_language: 'javascript',
|
|
242
|
+
file_count: 1,
|
|
243
|
+
source_paths: ['lib/x.js'],
|
|
244
|
+
symbols: [],
|
|
245
|
+
internal_deps: [],
|
|
246
|
+
external_deps: [],
|
|
247
|
+
files: [{ path: 'lib/x.js', language: 'javascript', symbols: [], deps: [] }],
|
|
248
|
+
};
|
|
249
|
+
const md = d.renderModuleDoc(facts, null, {});
|
|
250
|
+
assert.ok(md.includes('_TBD'));
|
|
251
|
+
assert.ok(md.includes('## Files'));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('CD-14: buildIndexDoc lists modules with counts', () => {
|
|
255
|
+
const modules = [
|
|
256
|
+
{ id: 'src-auth', directory: 'src/auth', primary_language: 'javascript', file_count: 2 },
|
|
257
|
+
{ id: 'src-billing', directory: 'src/billing', primary_language: 'javascript', file_count: 1 },
|
|
258
|
+
];
|
|
259
|
+
const md = d.buildIndexDoc(modules, { project_name: 'Demo' });
|
|
260
|
+
assert.ok(md.includes('# Codebase Index'));
|
|
261
|
+
assert.ok(md.includes('**Project:** Demo'));
|
|
262
|
+
assert.ok(md.includes('src-auth.md'));
|
|
263
|
+
assert.ok(md.includes('2 files'));
|
|
264
|
+
assert.ok(md.includes('1 file'));
|
|
265
|
+
assert.ok(md.includes('Dev-Agents MUST read'));
|
|
266
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { atomicWriteFileSync, NubosPilotError } = require('./core.cjs');
|
|
4
|
+
|
|
5
|
+
const SCHEMA_VERSION = 1;
|
|
6
|
+
const CODEBASE_DIR_NAME = 'codebase';
|
|
7
|
+
const MANIFEST_FILENAME = '.hashes.json';
|
|
8
|
+
|
|
9
|
+
function manifestPath(projectRoot) {
|
|
10
|
+
return path.join(
|
|
11
|
+
path.resolve(projectRoot),
|
|
12
|
+
'.nubos-pilot',
|
|
13
|
+
CODEBASE_DIR_NAME,
|
|
14
|
+
MANIFEST_FILENAME,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function emptyManifest() {
|
|
19
|
+
return {
|
|
20
|
+
schema_version: SCHEMA_VERSION,
|
|
21
|
+
generated_at: null,
|
|
22
|
+
files: {},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readManifest(projectRoot) {
|
|
27
|
+
const p = manifestPath(projectRoot);
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = fs.readFileSync(p, 'utf-8');
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err && err.code === 'ENOENT') return emptyManifest();
|
|
33
|
+
throw new NubosPilotError(
|
|
34
|
+
'manifest-read-error',
|
|
35
|
+
`cannot read codebase manifest at ${p}`,
|
|
36
|
+
{ path: p, cause: err && err.code },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(raw);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new NubosPilotError(
|
|
44
|
+
'manifest-parse-error',
|
|
45
|
+
`codebase manifest is not valid JSON at ${p}`,
|
|
46
|
+
{ path: p, cause: err && err.message },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
50
|
+
throw new NubosPilotError(
|
|
51
|
+
'manifest-invalid-shape',
|
|
52
|
+
`codebase manifest root is not an object at ${p}`,
|
|
53
|
+
{ path: p },
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (parsed.schema_version !== SCHEMA_VERSION) {
|
|
57
|
+
throw new NubosPilotError(
|
|
58
|
+
'manifest-schema-mismatch',
|
|
59
|
+
`codebase manifest schema_version ${parsed.schema_version} does not match ${SCHEMA_VERSION}`,
|
|
60
|
+
{ path: p, found: parsed.schema_version, expected: SCHEMA_VERSION },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!parsed.files || typeof parsed.files !== 'object') parsed.files = {};
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeManifest(projectRoot, manifest) {
|
|
68
|
+
const p = manifestPath(projectRoot);
|
|
69
|
+
const payload = {
|
|
70
|
+
schema_version: SCHEMA_VERSION,
|
|
71
|
+
generated_at: new Date().toISOString(),
|
|
72
|
+
files: manifest && manifest.files ? manifest.files : {},
|
|
73
|
+
};
|
|
74
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
75
|
+
atomicWriteFileSync(p, JSON.stringify(payload, null, 2) + '\n');
|
|
76
|
+
return payload;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function manifestFromScanFiles(scanFiles) {
|
|
80
|
+
const files = {};
|
|
81
|
+
for (const f of scanFiles || []) {
|
|
82
|
+
if (!f || !f.path || !f.sha256) continue;
|
|
83
|
+
files[f.path] = {
|
|
84
|
+
sha256: f.sha256,
|
|
85
|
+
size: typeof f.size === 'number' ? f.size : 0,
|
|
86
|
+
ext: typeof f.ext === 'string' ? f.ext : '',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { schema_version: SCHEMA_VERSION, generated_at: null, files };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function diffManifest(prev, next) {
|
|
93
|
+
const prevFiles = (prev && prev.files) || {};
|
|
94
|
+
const nextFiles = (next && next.files) || {};
|
|
95
|
+
const added = [];
|
|
96
|
+
const removed = [];
|
|
97
|
+
const changed = [];
|
|
98
|
+
const unchanged = [];
|
|
99
|
+
|
|
100
|
+
for (const [p, meta] of Object.entries(nextFiles)) {
|
|
101
|
+
const before = prevFiles[p];
|
|
102
|
+
if (!before) {
|
|
103
|
+
added.push({ path: p, sha256: meta.sha256, size: meta.size, ext: meta.ext });
|
|
104
|
+
} else if (before.sha256 !== meta.sha256) {
|
|
105
|
+
changed.push({
|
|
106
|
+
path: p,
|
|
107
|
+
sha256: meta.sha256,
|
|
108
|
+
prev_sha256: before.sha256,
|
|
109
|
+
size: meta.size,
|
|
110
|
+
ext: meta.ext,
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
unchanged.push({ path: p, sha256: meta.sha256, size: meta.size, ext: meta.ext });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [p, meta] of Object.entries(prevFiles)) {
|
|
117
|
+
if (!nextFiles[p]) {
|
|
118
|
+
removed.push({ path: p, prev_sha256: meta.sha256 });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
added.sort((a, b) => a.path.localeCompare(b.path));
|
|
123
|
+
removed.sort((a, b) => a.path.localeCompare(b.path));
|
|
124
|
+
changed.sort((a, b) => a.path.localeCompare(b.path));
|
|
125
|
+
unchanged.sort((a, b) => a.path.localeCompare(b.path));
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
added,
|
|
129
|
+
removed,
|
|
130
|
+
changed,
|
|
131
|
+
unchanged,
|
|
132
|
+
summary: {
|
|
133
|
+
added: added.length,
|
|
134
|
+
removed: removed.length,
|
|
135
|
+
changed: changed.length,
|
|
136
|
+
unchanged: unchanged.length,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function stalePathsForDocs(diff, docIndex) {
|
|
142
|
+
const touched = new Set();
|
|
143
|
+
for (const entry of [...(diff.added || []), ...(diff.changed || []), ...(diff.removed || [])]) {
|
|
144
|
+
touched.add(entry.path);
|
|
145
|
+
}
|
|
146
|
+
const staleDocs = new Set();
|
|
147
|
+
for (const [docId, sources] of Object.entries(docIndex || {})) {
|
|
148
|
+
if (!Array.isArray(sources)) continue;
|
|
149
|
+
for (const src of sources) {
|
|
150
|
+
if (touched.has(src)) {
|
|
151
|
+
staleDocs.add(docId);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
stale_docs: Array.from(staleDocs).sort(),
|
|
158
|
+
touched_paths: Array.from(touched).sort(),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
SCHEMA_VERSION,
|
|
164
|
+
manifestPath,
|
|
165
|
+
emptyManifest,
|
|
166
|
+
readManifest,
|
|
167
|
+
writeManifest,
|
|
168
|
+
manifestFromScanFiles,
|
|
169
|
+
diffManifest,
|
|
170
|
+
stalePathsForDocs,
|
|
171
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
|
|
7
|
+
const m = require('./codebase-manifest.cjs');
|
|
8
|
+
|
|
9
|
+
const _sandboxes = [];
|
|
10
|
+
|
|
11
|
+
function makeSandbox() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-cbm-'));
|
|
13
|
+
_sandboxes.push(dir);
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
while (_sandboxes.length) {
|
|
19
|
+
const dir = _sandboxes.pop();
|
|
20
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('CM-1: readManifest returns empty manifest when file missing', () => {
|
|
25
|
+
const root = makeSandbox();
|
|
26
|
+
const result = m.readManifest(root);
|
|
27
|
+
assert.equal(result.schema_version, m.SCHEMA_VERSION);
|
|
28
|
+
assert.deepEqual(result.files, {});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('CM-2: writeManifest then readManifest roundtrips', () => {
|
|
32
|
+
const root = makeSandbox();
|
|
33
|
+
const input = {
|
|
34
|
+
schema_version: m.SCHEMA_VERSION,
|
|
35
|
+
files: {
|
|
36
|
+
'src/a.js': { sha256: 'sha256:aa', size: 10, ext: '.js' },
|
|
37
|
+
'src/b.py': { sha256: 'sha256:bb', size: 20, ext: '.py' },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
m.writeManifest(root, input);
|
|
41
|
+
const read = m.readManifest(root);
|
|
42
|
+
assert.equal(read.schema_version, m.SCHEMA_VERSION);
|
|
43
|
+
assert.equal(read.files['src/a.js'].sha256, 'sha256:aa');
|
|
44
|
+
assert.equal(read.files['src/b.py'].size, 20);
|
|
45
|
+
assert.ok(read.generated_at);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('CM-3: manifestPath places file at .nubos-pilot/codebase/.hashes.json', () => {
|
|
49
|
+
const root = makeSandbox();
|
|
50
|
+
const p = m.manifestPath(root);
|
|
51
|
+
assert.equal(p, path.join(root, '.nubos-pilot', 'codebase', '.hashes.json'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('CM-4: manifestFromScanFiles converts scan files[] to manifest shape', () => {
|
|
55
|
+
const scanFiles = [
|
|
56
|
+
{ path: 'src/a.js', sha256: 'sha256:11', size: 5, ext: '.js' },
|
|
57
|
+
{ path: 'src/b.py', sha256: 'sha256:22', size: 7, ext: '.py' },
|
|
58
|
+
{ path: 'no-hash.bin' },
|
|
59
|
+
];
|
|
60
|
+
const manifest = m.manifestFromScanFiles(scanFiles);
|
|
61
|
+
assert.equal(manifest.schema_version, m.SCHEMA_VERSION);
|
|
62
|
+
assert.equal(Object.keys(manifest.files).length, 2);
|
|
63
|
+
assert.equal(manifest.files['src/a.js'].sha256, 'sha256:11');
|
|
64
|
+
assert.equal(manifest.files['src/b.py'].ext, '.py');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('CM-5: diffManifest detects added, removed, changed, unchanged', () => {
|
|
68
|
+
const prev = {
|
|
69
|
+
schema_version: 1,
|
|
70
|
+
files: {
|
|
71
|
+
'kept.js': { sha256: 'sha256:a', size: 1, ext: '.js' },
|
|
72
|
+
'changed.js': { sha256: 'sha256:old', size: 1, ext: '.js' },
|
|
73
|
+
'removed.js': { sha256: 'sha256:r', size: 1, ext: '.js' },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const next = {
|
|
77
|
+
schema_version: 1,
|
|
78
|
+
files: {
|
|
79
|
+
'kept.js': { sha256: 'sha256:a', size: 1, ext: '.js' },
|
|
80
|
+
'changed.js': { sha256: 'sha256:new', size: 2, ext: '.js' },
|
|
81
|
+
'added.js': { sha256: 'sha256:n', size: 1, ext: '.js' },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const diff = m.diffManifest(prev, next);
|
|
85
|
+
assert.deepEqual(diff.added.map((x) => x.path), ['added.js']);
|
|
86
|
+
assert.deepEqual(diff.removed.map((x) => x.path), ['removed.js']);
|
|
87
|
+
assert.deepEqual(diff.changed.map((x) => x.path), ['changed.js']);
|
|
88
|
+
assert.deepEqual(diff.unchanged.map((x) => x.path), ['kept.js']);
|
|
89
|
+
assert.equal(diff.changed[0].prev_sha256, 'sha256:old');
|
|
90
|
+
assert.equal(diff.changed[0].sha256, 'sha256:new');
|
|
91
|
+
assert.deepEqual(diff.summary, { added: 1, removed: 1, changed: 1, unchanged: 1 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('CM-6: diffManifest handles empty prev (bootstrap case)', () => {
|
|
95
|
+
const next = {
|
|
96
|
+
schema_version: 1,
|
|
97
|
+
files: {
|
|
98
|
+
'a.js': { sha256: 'sha256:a', size: 1, ext: '.js' },
|
|
99
|
+
'b.js': { sha256: 'sha256:b', size: 1, ext: '.js' },
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const diff = m.diffManifest(m.emptyManifest(), next);
|
|
103
|
+
assert.equal(diff.summary.added, 2);
|
|
104
|
+
assert.equal(diff.summary.removed, 0);
|
|
105
|
+
assert.equal(diff.summary.changed, 0);
|
|
106
|
+
assert.equal(diff.summary.unchanged, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('CM-7: readManifest throws on schema mismatch', () => {
|
|
110
|
+
const root = makeSandbox();
|
|
111
|
+
const p = m.manifestPath(root);
|
|
112
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
113
|
+
fs.writeFileSync(p, JSON.stringify({ schema_version: 99, files: {} }));
|
|
114
|
+
assert.throws(
|
|
115
|
+
() => m.readManifest(root),
|
|
116
|
+
(err) => err.code === 'manifest-schema-mismatch',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('CM-8: readManifest throws on invalid JSON', () => {
|
|
121
|
+
const root = makeSandbox();
|
|
122
|
+
const p = m.manifestPath(root);
|
|
123
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
124
|
+
fs.writeFileSync(p, '{not json');
|
|
125
|
+
assert.throws(
|
|
126
|
+
() => m.readManifest(root),
|
|
127
|
+
(err) => err.code === 'manifest-parse-error',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('CM-9: stalePathsForDocs flags docs whose sources changed', () => {
|
|
132
|
+
const diff = {
|
|
133
|
+
added: [{ path: 'new.js' }],
|
|
134
|
+
changed: [{ path: 'src/auth/login.js' }],
|
|
135
|
+
removed: [],
|
|
136
|
+
};
|
|
137
|
+
const docIndex = {
|
|
138
|
+
'modules/auth.md': ['src/auth/login.js', 'src/auth/session.js'],
|
|
139
|
+
'modules/billing.md': ['src/billing/invoice.js'],
|
|
140
|
+
'modules/shared.md': ['new.js'],
|
|
141
|
+
};
|
|
142
|
+
const result = m.stalePathsForDocs(diff, docIndex);
|
|
143
|
+
assert.deepEqual(result.stale_docs, ['modules/auth.md', 'modules/shared.md']);
|
|
144
|
+
assert.ok(result.touched_paths.includes('src/auth/login.js'));
|
|
145
|
+
assert.ok(result.touched_paths.includes('new.js'));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('CM-10: writeManifest is atomic and creates codebase dir', () => {
|
|
149
|
+
const root = makeSandbox();
|
|
150
|
+
const result = m.writeManifest(root, {
|
|
151
|
+
files: { 'x.js': { sha256: 'sha256:x', size: 1, ext: '.js' } },
|
|
152
|
+
});
|
|
153
|
+
const p = m.manifestPath(root);
|
|
154
|
+
assert.ok(fs.existsSync(p));
|
|
155
|
+
assert.equal(result.files['x.js'].sha256, 'sha256:x');
|
|
156
|
+
});
|
package/lib/git.cjs
CHANGED
|
@@ -193,6 +193,43 @@ function gitDiffNoColor(ref, filepath) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
function workspaceGitInfo(cwd) {
|
|
197
|
+
const exec = (args) => {
|
|
198
|
+
try {
|
|
199
|
+
return execFileSync('git', args, {
|
|
200
|
+
cwd,
|
|
201
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
202
|
+
timeout: 5000,
|
|
203
|
+
encoding: 'utf-8',
|
|
204
|
+
}).trim();
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const isRepoProbe = exec(['rev-parse', '--is-inside-work-tree']);
|
|
211
|
+
if (isRepoProbe !== 'true') return { is_repo: false };
|
|
212
|
+
|
|
213
|
+
const current_branch = exec(['rev-parse', '--abbrev-ref', 'HEAD']) || null;
|
|
214
|
+
const remote = exec(['config', '--get', 'remote.origin.url']) || null;
|
|
215
|
+
const branchesRaw = exec(['for-each-ref', '--format=%(refname:short)', 'refs/heads/']) || '';
|
|
216
|
+
const branches = branchesRaw.split('\n').filter(Boolean);
|
|
217
|
+
const commitsRaw = exec(['log', '--pretty=format:%h|%an|%ad|%s', '--date=short', '-n', '20']) || '';
|
|
218
|
+
const commits = commitsRaw.split('\n').filter(Boolean).map((line) => {
|
|
219
|
+
const idx1 = line.indexOf('|');
|
|
220
|
+
const idx2 = line.indexOf('|', idx1 + 1);
|
|
221
|
+
const idx3 = line.indexOf('|', idx2 + 1);
|
|
222
|
+
if (idx1 < 0 || idx2 < 0 || idx3 < 0) return { raw: line };
|
|
223
|
+
return {
|
|
224
|
+
sha: line.slice(0, idx1),
|
|
225
|
+
author: line.slice(idx1 + 1, idx2),
|
|
226
|
+
date: line.slice(idx2 + 1, idx3),
|
|
227
|
+
subject: line.slice(idx3 + 1),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
return { is_repo: true, current_branch, remote, branches, commits };
|
|
231
|
+
}
|
|
232
|
+
|
|
196
233
|
module.exports = {
|
|
197
234
|
commitTask,
|
|
198
235
|
assertCommittablePaths,
|
|
@@ -204,4 +241,5 @@ module.exports = {
|
|
|
204
241
|
listTaskCommits,
|
|
205
242
|
gitShowSafe,
|
|
206
243
|
gitDiffNoColor,
|
|
244
|
+
workspaceGitInfo,
|
|
207
245
|
};
|