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.
@@ -8,6 +8,8 @@ const { NubosPilotError, atomicWriteFileSync } = require('../../lib/core.cjs');
8
8
  const manifestMod = require('../../lib/install/manifest.cjs');
9
9
  const codexTomlMod = require('../../lib/install/codex-toml.cjs');
10
10
  const askuserMod = require('../../lib/askuser.cjs');
11
+ const codebaseManifest = require('../../lib/codebase-manifest.cjs');
12
+ const { scan: workspaceScan } = require('../../lib/workspace-scan.cjs');
11
13
 
12
14
  const PAYLOAD_SUBPATH = path.join('.claude', 'nubos-pilot');
13
15
  const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
@@ -147,6 +149,100 @@ function _checkAskUserBroken() {
147
149
  }
148
150
  }
149
151
 
152
+ function _checkCodebaseDocs(projectRoot) {
153
+ const issues = [];
154
+ const stateDir = path.join(projectRoot, '.nubos-pilot');
155
+ if (!fs.existsSync(stateDir)) return issues;
156
+ const codebaseDir = path.join(stateDir, 'codebase');
157
+ const indexPath = path.join(codebaseDir, 'INDEX.md');
158
+ const modulesDir = path.join(codebaseDir, 'modules');
159
+
160
+ if (!fs.existsSync(indexPath)) {
161
+ issues.push({
162
+ id: 'codebase-not-scanned',
163
+ severity: 'warn',
164
+ fixable: 'run-workflow',
165
+ details: { hint: 'run `np:scan-codebase`' },
166
+ });
167
+ return issues;
168
+ }
169
+
170
+ let prevManifest;
171
+ try {
172
+ prevManifest = codebaseManifest.readManifest(projectRoot);
173
+ } catch (err) {
174
+ issues.push({
175
+ id: 'codebase-manifest-unreadable',
176
+ severity: 'warn',
177
+ fixable: 'run-workflow',
178
+ details: { cause: err && err.code, hint: 'run `np:scan-codebase`' },
179
+ });
180
+ return issues;
181
+ }
182
+
183
+ let scanResult;
184
+ try {
185
+ scanResult = workspaceScan({ cwd: projectRoot, batchSize: 1000 });
186
+ } catch (err) {
187
+ issues.push({
188
+ id: 'codebase-scan-failed',
189
+ severity: 'warn',
190
+ fixable: 'run-workflow',
191
+ details: { cause: err && err.code, hint: 'inspect workspace and re-run `np:scan-codebase`' },
192
+ });
193
+ return issues;
194
+ }
195
+
196
+ const nextManifest = codebaseManifest.manifestFromScanFiles(scanResult.files);
197
+ const diff = codebaseManifest.diffManifest(prevManifest, nextManifest);
198
+ const touched = diff.summary.added + diff.summary.changed + diff.summary.removed;
199
+ if (touched > 0) {
200
+ issues.push({
201
+ id: 'codebase-manifest-stale',
202
+ severity: 'warn',
203
+ fixable: 'run-workflow',
204
+ details: {
205
+ added: diff.summary.added,
206
+ changed: diff.summary.changed,
207
+ removed: diff.summary.removed,
208
+ hint: 'run `np:update-docs`',
209
+ },
210
+ });
211
+ }
212
+
213
+ if (fs.existsSync(modulesDir)) {
214
+ let entries = [];
215
+ try {
216
+ entries = fs.readdirSync(modulesDir).filter((f) => f.endsWith('.md'));
217
+ } catch {}
218
+ const tbdDocs = [];
219
+ for (const f of entries) {
220
+ try {
221
+ const raw = fs.readFileSync(path.join(modulesDir, f), 'utf-8');
222
+ const purposeIdx = raw.indexOf('## Purpose');
223
+ if (purposeIdx >= 0) {
224
+ const chunk = raw.slice(purposeIdx, purposeIdx + 400);
225
+ if (chunk.includes('_TBD')) tbdDocs.push(f);
226
+ }
227
+ } catch {}
228
+ }
229
+ if (tbdDocs.length > 0) {
230
+ issues.push({
231
+ id: 'codebase-tbd-docs',
232
+ severity: 'info',
233
+ fixable: 'run-workflow',
234
+ details: {
235
+ count: tbdDocs.length,
236
+ sample: tbdDocs.slice(0, 5),
237
+ hint: 'run `np:scan-codebase` and dispatch the documenter agent for each module',
238
+ },
239
+ });
240
+ }
241
+ }
242
+
243
+ return issues;
244
+ }
245
+
150
246
  function _audit(projectRoot) {
151
247
  const payloadDir = _payloadDirFor(projectRoot);
152
248
  const issues = [];
@@ -157,6 +253,7 @@ function _audit(projectRoot) {
157
253
  const codex = _checkCodexTrappedFeatures();
158
254
  issues.push(...codex.issues);
159
255
  issues.push(..._checkAskUserBroken());
256
+ issues.push(..._checkCodebaseDocs(projectRoot));
160
257
  return { issues, _codexContent: codex.content };
161
258
  }
162
259
 
@@ -203,6 +300,12 @@ async function _applyFixes(issues, codexContent, askUser, stderr) {
203
300
  skipped.push({ id: issue.id, reason: 'requires-reinstall' });
204
301
  continue;
205
302
  }
303
+ if (issue.fixable === 'run-workflow') {
304
+ const hint = (issue.details && issue.details.hint) || 'run the suggested np workflow';
305
+ try { stderr.write(`[doctor] ${issue.id}: ${hint}.\n`); } catch {}
306
+ skipped.push({ id: issue.id, reason: 'requires-workflow' });
307
+ continue;
308
+ }
206
309
  skipped.push({ id: issue.id, reason: 'not-fixable' });
207
310
  }
208
311
  return { applied, skipped };
@@ -0,0 +1,112 @@
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 doctor = require('./doctor.cjs');
8
+ const scanCodebase = require('./scan-codebase.cjs');
9
+
10
+ const _sandboxes = [];
11
+
12
+ function makeSandbox() {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-doc-'));
14
+ fs.mkdirSync(path.join(dir, '.nubos-pilot'), { recursive: true });
15
+ _sandboxes.push(dir);
16
+ return dir;
17
+ }
18
+
19
+ function captureStdout() {
20
+ const chunks = [];
21
+ return {
22
+ stub: { write: (s) => chunks.push(String(s)), end: () => {} },
23
+ json: () => JSON.parse(chunks.join('')),
24
+ };
25
+ }
26
+
27
+ afterEach(() => {
28
+ while (_sandboxes.length) {
29
+ const dir = _sandboxes.pop();
30
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
31
+ }
32
+ });
33
+
34
+ test('DOC-1: flags codebase-not-scanned when INDEX.md missing', async () => {
35
+ const root = makeSandbox();
36
+ fs.writeFileSync(path.join(root, 'src.js'), 'export {};');
37
+ const cap = captureStdout();
38
+ await doctor.run([], { cwd: root, stdout: cap.stub, stderr: cap.stub, askUser: async () => ({ value: false }) });
39
+ const out = cap.json();
40
+ const ids = out.issues.map((i) => i.id);
41
+ assert.ok(ids.includes('codebase-not-scanned'));
42
+ });
43
+
44
+ test('DOC-2: no codebase issue when scanned and source unchanged', async () => {
45
+ const root = makeSandbox();
46
+ fs.writeFileSync(path.join(root, 'src.js'), 'export function a(){}');
47
+ scanCodebase.run([], { cwd: root, stdout: captureStdout().stub });
48
+
49
+ const cap = captureStdout();
50
+ await doctor.run([], { cwd: root, stdout: cap.stub, stderr: cap.stub, askUser: async () => ({ value: false }) });
51
+ const out = cap.json();
52
+ const ids = out.issues.map((i) => i.id);
53
+ assert.ok(!ids.includes('codebase-not-scanned'));
54
+ assert.ok(!ids.includes('codebase-manifest-stale'));
55
+ });
56
+
57
+ test('DOC-3: flags codebase-manifest-stale after source changes', async () => {
58
+ const root = makeSandbox();
59
+ fs.writeFileSync(path.join(root, 'src.js'), 'export function a(){}');
60
+ scanCodebase.run([], { cwd: root, stdout: captureStdout().stub });
61
+
62
+ fs.writeFileSync(path.join(root, 'src.js'), 'export function a(){ /* v2 */ }');
63
+ fs.writeFileSync(path.join(root, 'new.js'), 'export function b(){}');
64
+
65
+ const cap = captureStdout();
66
+ await doctor.run([], { cwd: root, stdout: cap.stub, stderr: cap.stub, askUser: async () => ({ value: false }) });
67
+ const out = cap.json();
68
+ const stale = out.issues.find((i) => i.id === 'codebase-manifest-stale');
69
+ assert.ok(stale, 'expected codebase-manifest-stale');
70
+ assert.ok(stale.details.changed >= 1);
71
+ assert.ok(stale.details.added >= 1);
72
+ });
73
+
74
+ test('DOC-4: flags codebase-tbd-docs for modules with _TBD Purpose', async () => {
75
+ const root = makeSandbox();
76
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
77
+ fs.writeFileSync(path.join(root, 'src', 'a.js'), 'export function a(){}');
78
+ scanCodebase.run([], { cwd: root, stdout: captureStdout().stub });
79
+
80
+ const cap = captureStdout();
81
+ await doctor.run([], { cwd: root, stdout: cap.stub, stderr: cap.stub, askUser: async () => ({ value: false }) });
82
+ const out = cap.json();
83
+ const tbd = out.issues.find((i) => i.id === 'codebase-tbd-docs');
84
+ assert.ok(tbd, 'expected codebase-tbd-docs');
85
+ assert.ok(tbd.details.count >= 1);
86
+ });
87
+
88
+ test('DOC-5: no tbd flag after prose applied', async () => {
89
+ const root = makeSandbox();
90
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
91
+ fs.writeFileSync(path.join(root, 'src', 'a.js'), 'export function a(){}');
92
+ scanCodebase.run([], { cwd: root, stdout: captureStdout().stub });
93
+
94
+ const proseFile = path.join(root, 'p.json');
95
+ fs.writeFileSync(proseFile, JSON.stringify({
96
+ description: 'A module',
97
+ purpose: 'Provides function a.',
98
+ key_concepts: ['just one thing'],
99
+ public_api: '`a()`',
100
+ invariants: [],
101
+ gotchas: [],
102
+ }));
103
+ scanCodebase.run(['--apply-prose', '--module', 'src', '--prose-file', proseFile], {
104
+ cwd: root, stdout: captureStdout().stub,
105
+ });
106
+
107
+ const cap = captureStdout();
108
+ await doctor.run([], { cwd: root, stdout: cap.stub, stderr: cap.stub, askUser: async () => ({ value: false }) });
109
+ const out = cap.json();
110
+ const tbd = out.issues.find((i) => i.id === 'codebase-tbd-docs');
111
+ assert.ok(!tbd, 'expected no codebase-tbd-docs');
112
+ });
@@ -153,6 +153,12 @@ function _apply(answersPath, cwd, stdout) {
153
153
  first_milestone_name: answers.first_milestone_name,
154
154
  first_phase_name: answers.first_phase_name,
155
155
  created_date: createdDate,
156
+ project_description: '_TBD — filled by `/np:discuss-project`._',
157
+ domain_text: '_TBD — filled by `/np:discuss-project`._',
158
+ target_users_text: '_TBD — filled by `/np:discuss-project`._',
159
+ non_goals_text: '_TBD — filled by `/np:discuss-project`._',
160
+ success_criteria_text: '_TBD — filled by `/np:discuss-project`._',
161
+ strategic_decisions_text: '_TBD — filled by `/np:discuss-project`._',
156
162
  };
157
163
  atomicWriteFileSync(projectMd, _render(_loadTemplate('PROJECT'), projectVars, 'PROJECT'));
158
164
 
@@ -0,0 +1,204 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const { NubosPilotError, atomicWriteFileSync } = require('../../lib/core.cjs');
5
+ const { scan } = require('../../lib/workspace-scan.cjs');
6
+ const { workspaceGitInfo } = require('../../lib/git.cjs');
7
+ const {
8
+ manifestFromScanFiles,
9
+ writeManifest,
10
+ readManifest,
11
+ } = require('../../lib/codebase-manifest.cjs');
12
+ const {
13
+ groupFilesIntoModules,
14
+ buildModuleFacts,
15
+ renderModuleDoc,
16
+ buildIndexDoc,
17
+ buildDocIndexMap,
18
+ moduleDocPath,
19
+ indexDocPath,
20
+ } = require('../../lib/codebase-docs.cjs');
21
+
22
+ function _parseArgs(args) {
23
+ const flags = {
24
+ cwd: null,
25
+ batchSize: 500,
26
+ maxFiles: 0,
27
+ applyProse: false,
28
+ moduleId: null,
29
+ proseFile: null,
30
+ emitPlan: true,
31
+ projectName: null,
32
+ };
33
+ for (let i = 0; i < (args || []).length; i++) {
34
+ const a = args[i];
35
+ if (a === '--cwd') flags.cwd = args[++i];
36
+ else if (a === '--batch-size') flags.batchSize = parseInt(args[++i], 10);
37
+ else if (a === '--max-files') flags.maxFiles = parseInt(args[++i], 10);
38
+ else if (a === '--apply-prose') { flags.applyProse = true; flags.emitPlan = false; }
39
+ else if (a === '--module') flags.moduleId = args[++i];
40
+ else if (a === '--prose-file') flags.proseFile = args[++i];
41
+ else if (a === '--project-name') flags.projectName = args[++i];
42
+ }
43
+ return flags;
44
+ }
45
+
46
+ function _hashesLookupFromManifest(manifest) {
47
+ const lookup = {};
48
+ for (const [p, meta] of Object.entries(manifest.files || {})) {
49
+ lookup[p] = meta.sha256;
50
+ }
51
+ return lookup;
52
+ }
53
+
54
+ function _emitPlan(projectRoot, flags, stdout) {
55
+ const modulesResult = _scanAndBuild(projectRoot, flags);
56
+ stdout.write(JSON.stringify({
57
+ mode: 'plan',
58
+ cwd: projectRoot,
59
+ stats: modulesResult.scan.stats,
60
+ git: modulesResult.scan.git,
61
+ language_distribution: modulesResult.scan.language_distribution,
62
+ manifests: Object.keys(modulesResult.scan.manifests).sort(),
63
+ docs: Object.keys(modulesResult.scan.docs).sort(),
64
+ modules: modulesResult.modules.map((m) => ({
65
+ id: m.id,
66
+ directory: m.directory,
67
+ primary_language: m.primary_language,
68
+ file_count: m.file_count,
69
+ facts: m.facts,
70
+ })),
71
+ index_path: path.relative(projectRoot, indexDocPath(projectRoot)),
72
+ manifest_path: path.relative(
73
+ projectRoot,
74
+ path.join(projectRoot, '.nubos-pilot', 'codebase', '.hashes.json'),
75
+ ),
76
+ }, null, 2));
77
+ }
78
+
79
+ function _scanAndBuild(projectRoot, flags) {
80
+ const scanResult = scan({
81
+ cwd: projectRoot,
82
+ batchSize: flags.batchSize,
83
+ maxFiles: flags.maxFiles > 0 ? flags.maxFiles : undefined,
84
+ gitInfo: workspaceGitInfo,
85
+ });
86
+
87
+ const groups = groupFilesIntoModules(scanResult.files);
88
+ const modules = groups.map((g) => {
89
+ const facts = buildModuleFacts(g, projectRoot);
90
+ return Object.assign({}, g, { facts });
91
+ });
92
+
93
+ const manifest = manifestFromScanFiles(scanResult.files);
94
+ writeManifest(projectRoot, manifest);
95
+
96
+ const indexMapPath = path.join(
97
+ projectRoot,
98
+ '.nubos-pilot',
99
+ 'codebase',
100
+ '.doc-index.json',
101
+ );
102
+ const docIndex = buildDocIndexMap(modules);
103
+ fs.mkdirSync(path.dirname(indexMapPath), { recursive: true });
104
+ atomicWriteFileSync(indexMapPath, JSON.stringify(docIndex, null, 2) + '\n');
105
+
106
+ const indexPath = indexDocPath(projectRoot);
107
+ fs.mkdirSync(path.dirname(indexPath), { recursive: true });
108
+ atomicWriteFileSync(indexPath, buildIndexDoc(modules, { project_name: flags.projectName || null }));
109
+
110
+ const hashLookup = _hashesLookupFromManifest(manifest);
111
+ for (const mod of modules) {
112
+ const docPath = moduleDocPath(projectRoot, mod.id);
113
+ if (fs.existsSync(docPath)) continue;
114
+ fs.mkdirSync(path.dirname(docPath), { recursive: true });
115
+ atomicWriteFileSync(docPath, renderModuleDoc(mod.facts, null, hashLookup));
116
+ }
117
+
118
+ return { scan: scanResult, modules, manifest, hashLookup };
119
+ }
120
+
121
+ function _applyProse(projectRoot, flags, stdout) {
122
+ if (!flags.moduleId) {
123
+ throw new NubosPilotError(
124
+ 'scan-codebase-missing-module',
125
+ '--apply-prose requires --module <id>',
126
+ {},
127
+ );
128
+ }
129
+ if (!flags.proseFile) {
130
+ throw new NubosPilotError(
131
+ 'scan-codebase-missing-prose',
132
+ '--apply-prose requires --prose-file <path>',
133
+ {},
134
+ );
135
+ }
136
+ let prose;
137
+ try {
138
+ prose = JSON.parse(fs.readFileSync(flags.proseFile, 'utf-8'));
139
+ } catch (err) {
140
+ throw new NubosPilotError(
141
+ 'scan-codebase-prose-unreadable',
142
+ 'prose file not readable or not valid JSON: ' + flags.proseFile,
143
+ { path: flags.proseFile, cause: err && err.message },
144
+ );
145
+ }
146
+
147
+ const scanResult = scan({
148
+ cwd: projectRoot,
149
+ batchSize: flags.batchSize,
150
+ maxFiles: flags.maxFiles > 0 ? flags.maxFiles : undefined,
151
+ gitInfo: workspaceGitInfo,
152
+ });
153
+ const groups = groupFilesIntoModules(scanResult.files);
154
+ const target = groups.find((g) => g.id === flags.moduleId);
155
+ if (!target) {
156
+ throw new NubosPilotError(
157
+ 'scan-codebase-module-not-found',
158
+ `module not found: ${flags.moduleId}`,
159
+ { moduleId: flags.moduleId },
160
+ );
161
+ }
162
+
163
+ const facts = buildModuleFacts(target, projectRoot);
164
+ const manifest = manifestFromScanFiles(scanResult.files);
165
+ const hashLookup = _hashesLookupFromManifest(manifest);
166
+
167
+ const docPath = moduleDocPath(projectRoot, target.id);
168
+ fs.mkdirSync(path.dirname(docPath), { recursive: true });
169
+ const rendered = renderModuleDoc(facts, prose, hashLookup);
170
+ atomicWriteFileSync(docPath, rendered);
171
+
172
+ writeManifest(projectRoot, manifest);
173
+
174
+ stdout.write(JSON.stringify({
175
+ mode: 'apply-prose',
176
+ module_id: target.id,
177
+ doc_path: path.relative(projectRoot, docPath),
178
+ symbol_count: facts.symbols.length,
179
+ }, null, 2));
180
+ }
181
+
182
+ function run(args, ctx) {
183
+ const context = ctx || {};
184
+ const stdout = context.stdout || process.stdout;
185
+ const flags = _parseArgs(args);
186
+ const projectRoot = path.resolve(flags.cwd || context.cwd || process.cwd());
187
+
188
+ const stateDir = path.join(projectRoot, '.nubos-pilot');
189
+ if (!fs.existsSync(stateDir)) {
190
+ throw new NubosPilotError(
191
+ 'scan-codebase-not-initialized',
192
+ '.nubos-pilot/ not found — run np:new-project first',
193
+ { cwd: projectRoot },
194
+ );
195
+ }
196
+
197
+ if (flags.applyProse) {
198
+ _applyProse(projectRoot, flags, stdout);
199
+ } else {
200
+ _emitPlan(projectRoot, flags, stdout);
201
+ }
202
+ }
203
+
204
+ module.exports = { run, _parseArgs };
@@ -0,0 +1,165 @@
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 subcmd = require('./scan-codebase.cjs');
8
+
9
+ const _sandboxes = [];
10
+
11
+ function makeSandbox() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sc-'));
13
+ fs.mkdirSync(path.join(dir, '.nubos-pilot'), { recursive: true });
14
+ _sandboxes.push(dir);
15
+ return dir;
16
+ }
17
+
18
+ function write(root, rel, content) {
19
+ const abs = path.join(root, rel);
20
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
21
+ fs.writeFileSync(abs, content);
22
+ }
23
+
24
+ function captureStdout() {
25
+ const chunks = [];
26
+ return {
27
+ stub: { write: (s) => chunks.push(String(s)) },
28
+ text: () => chunks.join(''),
29
+ json: () => JSON.parse(chunks.join('')),
30
+ };
31
+ }
32
+
33
+ afterEach(() => {
34
+ while (_sandboxes.length) {
35
+ const dir = _sandboxes.pop();
36
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
37
+ }
38
+ });
39
+
40
+ test('SC-1: throws when .nubos-pilot missing', () => {
41
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-sc-bare-'));
42
+ _sandboxes.push(dir);
43
+ assert.throws(
44
+ () => subcmd.run([], { cwd: dir, stdout: captureStdout().stub }),
45
+ (err) => err.code === 'scan-codebase-not-initialized',
46
+ );
47
+ });
48
+
49
+ test('SC-2: emits plan with modules, stats, and writes INDEX.md + stubs + manifest', () => {
50
+ const root = makeSandbox();
51
+ write(root, 'src/auth/login.js', 'export function login(){}');
52
+ write(root, 'src/auth/session.js', 'export class Session{}');
53
+ write(root, 'src/billing/invoice.js', 'export function invoice(){}');
54
+
55
+ const cap = captureStdout();
56
+ subcmd.run(['--project-name', 'Demo'], { cwd: root, stdout: cap.stub });
57
+ const out = cap.json();
58
+
59
+ assert.equal(out.mode, 'plan');
60
+ assert.ok(out.stats.file_count >= 3);
61
+ assert.ok(Array.isArray(out.modules));
62
+ const ids = out.modules.map((m) => m.id).sort();
63
+ assert.ok(ids.includes('src-auth'));
64
+ assert.ok(ids.includes('src-billing'));
65
+
66
+ assert.ok(fs.existsSync(path.join(root, '.nubos-pilot', 'codebase', 'INDEX.md')));
67
+ assert.ok(fs.existsSync(path.join(root, '.nubos-pilot', 'codebase', '.hashes.json')));
68
+ assert.ok(fs.existsSync(path.join(root, '.nubos-pilot', 'codebase', 'modules', 'src-auth.md')));
69
+
70
+ const indexMd = fs.readFileSync(path.join(root, '.nubos-pilot', 'codebase', 'INDEX.md'), 'utf-8');
71
+ assert.ok(indexMd.includes('Demo'));
72
+ });
73
+
74
+ test('SC-3: module stub contains facts in frontmatter even before prose', () => {
75
+ const root = makeSandbox();
76
+ write(root, 'src/auth/login.js', [
77
+ 'import bcrypt from "bcrypt";',
78
+ 'export function login(){}',
79
+ 'export class Session {}',
80
+ ].join('\n'));
81
+
82
+ const cap = captureStdout();
83
+ subcmd.run([], { cwd: root, stdout: cap.stub });
84
+ const stub = fs.readFileSync(
85
+ path.join(root, '.nubos-pilot', 'codebase', 'modules', 'src-auth.md'),
86
+ 'utf-8',
87
+ );
88
+ assert.ok(stub.startsWith('---\n'));
89
+ assert.ok(stub.includes('module_id: src-auth'));
90
+ assert.ok(stub.includes('symbols:'));
91
+ assert.ok(stub.includes('- login'));
92
+ assert.ok(stub.includes('- Session'));
93
+ assert.ok(stub.includes('external_deps:'));
94
+ assert.ok(stub.includes('- bcrypt'));
95
+ assert.ok(stub.includes('_TBD'));
96
+ });
97
+
98
+ test('SC-4: apply-prose merges prose sections into existing stub', () => {
99
+ const root = makeSandbox();
100
+ write(root, 'src/auth/login.js', 'export function login(){}');
101
+
102
+ const initialCap = captureStdout();
103
+ subcmd.run([], { cwd: root, stdout: initialCap.stub });
104
+
105
+ const proseFile = path.join(root, '.nubos-pilot', 'prose-auth.json');
106
+ fs.writeFileSync(proseFile, JSON.stringify({
107
+ description: 'Login flow',
108
+ purpose: 'Authenticates the user.',
109
+ key_concepts: ['Session token issued on success'],
110
+ public_api: '`login(user)` returns a Session',
111
+ invariants: ['No plaintext passwords'],
112
+ gotchas: ['bcrypt cost from env'],
113
+ }));
114
+
115
+ const cap = captureStdout();
116
+ subcmd.run(['--apply-prose', '--module', 'src-auth', '--prose-file', proseFile], {
117
+ cwd: root,
118
+ stdout: cap.stub,
119
+ });
120
+ const out = cap.json();
121
+ assert.equal(out.mode, 'apply-prose');
122
+ assert.equal(out.module_id, 'src-auth');
123
+
124
+ const doc = fs.readFileSync(
125
+ path.join(root, '.nubos-pilot', 'codebase', 'modules', 'src-auth.md'),
126
+ 'utf-8',
127
+ );
128
+ assert.ok(doc.includes('description: Login flow'));
129
+ assert.ok(doc.includes('Authenticates the user.'));
130
+ assert.ok(doc.includes('Session token issued on success'));
131
+ assert.ok(doc.includes('bcrypt cost from env'));
132
+ });
133
+
134
+ test('SC-5: apply-prose requires --module and --prose-file', () => {
135
+ const root = makeSandbox();
136
+ write(root, 'src/a.js', 'x');
137
+ subcmd.run([], { cwd: root, stdout: captureStdout().stub });
138
+
139
+ assert.throws(
140
+ () => subcmd.run(['--apply-prose'], { cwd: root, stdout: captureStdout().stub }),
141
+ (err) => err.code === 'scan-codebase-missing-module',
142
+ );
143
+ assert.throws(
144
+ () => subcmd.run(['--apply-prose', '--module', 'src'], {
145
+ cwd: root, stdout: captureStdout().stub,
146
+ }),
147
+ (err) => err.code === 'scan-codebase-missing-prose',
148
+ );
149
+ });
150
+
151
+ test('SC-6: apply-prose with unknown module throws', () => {
152
+ const root = makeSandbox();
153
+ write(root, 'src/a.js', 'x');
154
+ subcmd.run([], { cwd: root, stdout: captureStdout().stub });
155
+
156
+ const proseFile = path.join(root, 'p.json');
157
+ fs.writeFileSync(proseFile, JSON.stringify({ description: 'x' }));
158
+ assert.throws(
159
+ () => subcmd.run(
160
+ ['--apply-prose', '--module', 'does-not-exist', '--prose-file', proseFile],
161
+ { cwd: root, stdout: captureStdout().stub },
162
+ ),
163
+ (err) => err.code === 'scan-codebase-module-not-found',
164
+ );
165
+ });