nubos-pilot 1.2.1 → 1.2.2

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/CHANGELOG.md CHANGED
@@ -4,7 +4,39 @@ All notable changes to nubos-pilot are documented in this file. Format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning
5
5
  follows [SemVer](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [1.1.4] — 2026-05-25
7
+ ## [1.2.2] — 2026-06-05
8
+
9
+ A dependency graph for the codebase you work in, plus stricter checks on nubos-pilot's own data.
10
+
11
+ - `np:scan-codebase` now builds a module dependency graph and writes it to `.nubos-pilot/codebase/.graph.json`. The new `np:graph-impact` command shows what a change touches before you make it. It reports which modules depend on a file, what that file depends on, and any dependency cycle it sits in. The graph reads relative imports only. It builds no AST and adds no dependencies.
12
+ - Persisted state files are now validated on read against versioned schemas. A corrupt single-document store fails with a clear error code. A bad line in an append-only log is skipped, not fatal.
13
+ - The reference docs now list every error code. That list is generated from source and checked on each build, so it cannot drift from the code.
14
+ - Internal logging goes through one structured logger. A test keeps `console.*` out of `lib/` and `bin/np-tools/`.
15
+ - Added `ATTRIBUTIONS.md`. It names the third-party packages nubos-pilot uses and their licenses.
16
+
17
+ Full documentation at <https://pilot.nubos.cloud>.
18
+
19
+ ## [1.2.1] — 2026-06-02
20
+
21
+ Two always-on quality layers that act while the agent writes code.
22
+
23
+ - In-session security review: nubos-pilot reviews the code it writes for
24
+ vulnerabilities while it works and fixes findings in the same session,
25
+ before they reach a pull request. Three non-blocking depths — an instant
26
+ per-edit pattern scan with no model call, a background semantic review of
27
+ the turn's diff at end of turn, and a deeper review that reads surrounding
28
+ code on each commit or push the agent makes.
29
+ - The security reviewer runs independently with a fresh context, reports each
30
+ finding once, and never blocks a write or commit. Extend it with custom
31
+ pattern rules and a review guidance file; built-in checks stay on.
32
+ - Requirements-aware executor: `/np:execute-phase` injects the milestone
33
+ success criteria into the executor as its acceptance target, so it writes
34
+ against the requirements from the first round, not just the verify command.
35
+ - New configuration blocks `security.*` and `conformance.*`.
36
+
37
+ Full documentation at <https://pilot.nubos.cloud>.
38
+
39
+ ## [1.2.0] — 2026-05-25
8
40
 
9
41
  Public release.
10
42
 
@@ -36,6 +36,7 @@ const COMMANDS = [
36
36
  { name: 'doctor', category: 'Install', description: '12-check install-integrity scan (--fix for auto-safe fixes)', description_de: '12-Check-Install-Integritäts-Scan (--fix für auto-sichere Fixes)' },
37
37
  { name: 'scan-codebase', category: 'Install', description: 'Initial deep codebase inventory → .nubos-pilot/codebase/ skill docs', description_de: 'Initiale tiefe Codebase-Inventur → .nubos-pilot/codebase/ Skill-Docs' },
38
38
  { name: 'update-docs', category: 'Install', description: 'Refresh stale module docs after code changes', description_de: 'Aktualisiert veraltete Modul-Docs nach Code-Änderungen' },
39
+ { name: 'graph-impact', category: 'Utility', description: 'Query the module dependency graph (.graph.json from np:scan-codebase): impact (transitive dependents), dependencies, cluster, cycle membership. Flags: --module <id> | --path <relpath> | --cycles', description_de: 'Fragt den Modul-Dependency-Graphen ab (.graph.json aus np:scan-codebase): Impact (transitive Dependents), Dependencies, Cluster, Zyklus-Zugehörigkeit. Flags: --module <id> | --path <relpath> | --cycles' },
39
40
 
40
41
  { name: 'resolve-model', category: 'Utility', description: 'Resolve agent/tier to model alias or id (Tier×Profile matrix)', description_de: 'Löst Agent/Tier zu Model-Alias oder -ID auf (Tier×Profile-Matrix)' },
41
42
  { name: 'metrics', category: 'Utility', description: 'Record JSONL metrics entry (record | now | start-timestamp | end-timestamp)', description_de: 'Schreibt JSONL-Metrics-Eintrag (record | now | start-timestamp | end-timestamp)' },
@@ -399,14 +399,27 @@ function _checkNubosloopKnowledgeStore(projectRoot) {
399
399
  }
400
400
  try {
401
401
  const parsed = JSON.parse(fs.readFileSync(learningsPath, 'utf-8'));
402
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.learnings)) {
402
+ const { STORE_VERSION } = require('../../lib/learnings.cjs');
403
+ const { validate } = require('../../lib/validate.cjs');
404
+ const isObject = parsed && typeof parsed === 'object' && !Array.isArray(parsed);
405
+ let errors;
406
+ if (isObject && parsed.version === STORE_VERSION) {
407
+ errors = validate(parsed, 'learnings.v1');
408
+ } else if (!isObject || !Array.isArray(parsed.learnings)) {
409
+ errors = [{ message: 'expected JSON object with `version` and `learnings[]`' }];
410
+ } else {
411
+ errors = [];
412
+ }
413
+ if (errors.length) {
403
414
  issues.push({
404
415
  id: 'nubosloop-knowledge-store-corrupt',
405
416
  severity: 'warn',
406
417
  fixable: 'manual',
407
418
  details: {
408
419
  path: learningsPath,
409
- hint: 'expected JSON with `version` and `learnings[]`; remove or restore from a backup.',
420
+ violations: errors.length,
421
+ first: errors[0].message,
422
+ hint: 'store violates the learnings.v1 schema; remove or restore from a backup.',
410
423
  },
411
424
  });
412
425
  }
@@ -0,0 +1,111 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ const { NubosPilotError } = require('../../lib/core.cjs');
5
+ const g = require('../../lib/codebase-graph.cjs');
6
+
7
+ function _parseArgs(args) {
8
+ const flags = { cwd: null, module: null, filePath: null, cycles: false };
9
+ for (let i = 0; i < (args || []).length; i++) {
10
+ const a = args[i];
11
+ if (a === '--cwd') flags.cwd = args[++i];
12
+ else if (a === '--module') flags.module = args[++i];
13
+ else if (a === '--path') flags.filePath = args[++i];
14
+ else if (a === '--cycles') flags.cycles = true;
15
+ }
16
+ return flags;
17
+ }
18
+
19
+ function _graphPath(projectRoot) {
20
+ return path.join(projectRoot, '.nubos-pilot', 'codebase', '.graph.json');
21
+ }
22
+
23
+ function _load(projectRoot) {
24
+ const p = _graphPath(projectRoot);
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(p, 'utf-8');
28
+ } catch {
29
+ throw new NubosPilotError(
30
+ 'graph-not-found',
31
+ 'module graph not found — run np:scan-codebase first',
32
+ { path: '.nubos-pilot/codebase/.graph.json' },
33
+ );
34
+ }
35
+ try {
36
+ return JSON.parse(raw);
37
+ } catch (err) {
38
+ throw new NubosPilotError(
39
+ 'graph-unreadable',
40
+ 'module graph is not valid JSON — re-run np:scan-codebase',
41
+ { path: '.nubos-pilot/codebase/.graph.json', cause: err && err.message },
42
+ );
43
+ }
44
+ }
45
+
46
+ function _moduleForPath(graph, rel) {
47
+ const norm = rel.split(path.sep).join('/');
48
+ const dir = norm.includes('/') ? norm.slice(0, norm.lastIndexOf('/')) : '';
49
+ const node = (graph.nodes || []).find((n) => n.directory === dir);
50
+ return node ? node.id : null;
51
+ }
52
+
53
+ function run(args, ctx) {
54
+ const context = ctx || {};
55
+ const stdout = context.stdout || process.stdout;
56
+ const flags = _parseArgs(args);
57
+ const projectRoot = path.resolve(flags.cwd || context.cwd || process.cwd());
58
+ const graph = _load(projectRoot);
59
+
60
+ if (flags.cycles && !flags.module && !flags.filePath) {
61
+ stdout.write(JSON.stringify({
62
+ module_count: graph.module_count,
63
+ cycle_count: (graph.cycles || []).length,
64
+ cycles: graph.cycles || [],
65
+ }, null, 2));
66
+ return 0;
67
+ }
68
+
69
+ let moduleId = flags.module;
70
+ if (!moduleId && flags.filePath) {
71
+ moduleId = _moduleForPath(graph, flags.filePath);
72
+ if (!moduleId) {
73
+ throw new NubosPilotError(
74
+ 'graph-path-unmapped',
75
+ 'no module owns that path: ' + flags.filePath,
76
+ { path: flags.filePath },
77
+ );
78
+ }
79
+ }
80
+ if (!moduleId) {
81
+ throw new NubosPilotError(
82
+ 'graph-missing-target',
83
+ '--module <id> or --path <relpath> required',
84
+ {},
85
+ );
86
+ }
87
+ if (!(graph.nodes || []).some((n) => n.id === moduleId)) {
88
+ throw new NubosPilotError(
89
+ 'graph-unknown-module',
90
+ 'module not in graph: ' + moduleId,
91
+ { module: moduleId },
92
+ );
93
+ }
94
+
95
+ stdout.write(JSON.stringify({
96
+ module: moduleId,
97
+ direct_dependents: g.directDependents(graph, moduleId),
98
+ impact: g.transitiveDependents(graph, moduleId),
99
+ direct_dependencies: g.directDependencies(graph, moduleId),
100
+ transitive_dependencies: g.transitiveDependencies(graph, moduleId),
101
+ cluster: g.clusterOf(graph, moduleId),
102
+ in_cycle: g.cycleFor(graph, moduleId),
103
+ }, null, 2));
104
+ return 0;
105
+ }
106
+
107
+ module.exports = { run, _parseArgs };
108
+
109
+ if (require.main === module) {
110
+ process.exit(run(process.argv.slice(2)) || 0);
111
+ }
@@ -0,0 +1,119 @@
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 cli = require('./graph-impact.cjs');
8
+
9
+ const _sandboxes = [];
10
+
11
+ function makeSandbox() {
12
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'np-gi-'));
13
+ _sandboxes.push(dir);
14
+ return dir;
15
+ }
16
+
17
+ function writeGraph(root, graph) {
18
+ const dir = path.join(root, '.nubos-pilot', 'codebase');
19
+ fs.mkdirSync(dir, { recursive: true });
20
+ fs.writeFileSync(path.join(dir, '.graph.json'), JSON.stringify(graph));
21
+ }
22
+
23
+ function capture() {
24
+ let buf = '';
25
+ return { stream: { write: (s) => { buf += s; } }, read: () => buf };
26
+ }
27
+
28
+ const SAMPLE = {
29
+ schema_version: 1,
30
+ module_count: 3,
31
+ edge_count: 2,
32
+ nodes: [
33
+ { id: 'a', directory: 'a', primary_language: 'javascript', file_count: 1 },
34
+ { id: 'b', directory: 'b', primary_language: 'javascript', file_count: 1 },
35
+ { id: 'c', directory: 'c', primary_language: 'javascript', file_count: 1 },
36
+ ],
37
+ edges: [
38
+ { from: 'a', to: 'b', weight: 1 },
39
+ { from: 'b', to: 'c', weight: 1 },
40
+ ],
41
+ cycles: [],
42
+ clusters: [{ id: 0, members: ['a', 'b', 'c'] }],
43
+ metrics: { unresolved_internal_deps: 0, max_fan_in: 1, max_fan_out: 1, isolated_modules: 0 },
44
+ };
45
+
46
+ afterEach(() => {
47
+ while (_sandboxes.length) {
48
+ const dir = _sandboxes.pop();
49
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
50
+ }
51
+ });
52
+
53
+ test('GI-1: --module reports impact and dependencies', () => {
54
+ const root = makeSandbox();
55
+ writeGraph(root, SAMPLE);
56
+ const out = capture();
57
+ const rc = cli.run(['--module', 'c'], { cwd: root, stdout: out.stream });
58
+ assert.equal(rc, 0);
59
+ const res = JSON.parse(out.read());
60
+ assert.equal(res.module, 'c');
61
+ assert.deepEqual(res.direct_dependents, ['b']);
62
+ assert.deepEqual(res.impact, ['a', 'b']);
63
+ assert.deepEqual(res.transitive_dependencies, []);
64
+ });
65
+
66
+ test('GI-2: --path maps a file to its owning module', () => {
67
+ const root = makeSandbox();
68
+ writeGraph(root, SAMPLE);
69
+ const out = capture();
70
+ cli.run(['--path', 'a/login.js'], { cwd: root, stdout: out.stream });
71
+ const res = JSON.parse(out.read());
72
+ assert.equal(res.module, 'a');
73
+ assert.deepEqual(res.direct_dependencies, ['b']);
74
+ });
75
+
76
+ test('GI-3: missing graph throws graph-not-found', () => {
77
+ const root = makeSandbox();
78
+ assert.throws(
79
+ () => cli.run(['--module', 'a'], { cwd: root, stdout: capture().stream }),
80
+ (err) => err.code === 'graph-not-found',
81
+ );
82
+ });
83
+
84
+ test('GI-4: unknown module throws graph-unknown-module', () => {
85
+ const root = makeSandbox();
86
+ writeGraph(root, SAMPLE);
87
+ assert.throws(
88
+ () => cli.run(['--module', 'nope'], { cwd: root, stdout: capture().stream }),
89
+ (err) => err.code === 'graph-unknown-module',
90
+ );
91
+ });
92
+
93
+ test('GI-5: no target throws graph-missing-target', () => {
94
+ const root = makeSandbox();
95
+ writeGraph(root, SAMPLE);
96
+ assert.throws(
97
+ () => cli.run([], { cwd: root, stdout: capture().stream }),
98
+ (err) => err.code === 'graph-missing-target',
99
+ );
100
+ });
101
+
102
+ test('GI-6: --cycles dumps the cycle list', () => {
103
+ const root = makeSandbox();
104
+ writeGraph(root, Object.assign({}, SAMPLE, { cycles: [['a', 'b']] }));
105
+ const out = capture();
106
+ cli.run(['--cycles'], { cwd: root, stdout: out.stream });
107
+ const res = JSON.parse(out.read());
108
+ assert.equal(res.cycle_count, 1);
109
+ assert.deepEqual(res.cycles[0], ['a', 'b']);
110
+ });
111
+
112
+ test('GI-7: unmappable --path throws graph-path-unmapped', () => {
113
+ const root = makeSandbox();
114
+ writeGraph(root, SAMPLE);
115
+ assert.throws(
116
+ () => cli.run(['--path', 'ghost/x.js'], { cwd: root, stdout: capture().stream }),
117
+ (err) => err.code === 'graph-path-unmapped',
118
+ );
119
+ });
@@ -18,6 +18,7 @@ const {
18
18
  moduleDocPath,
19
19
  indexDocPath,
20
20
  } = require('../../lib/codebase-docs.cjs');
21
+ const { buildModuleGraph } = require('../../lib/codebase-graph.cjs');
21
22
 
22
23
  function _parseArgs(args) {
23
24
  const flags = {
@@ -73,6 +74,16 @@ function _emitPlan(projectRoot, flags, stdout) {
73
74
  projectRoot,
74
75
  path.join(projectRoot, '.nubos-pilot', 'codebase', '.hashes.json'),
75
76
  ),
77
+ graph_path: path.relative(
78
+ projectRoot,
79
+ path.join(projectRoot, '.nubos-pilot', 'codebase', '.graph.json'),
80
+ ),
81
+ graph: {
82
+ module_count: modulesResult.graph.module_count,
83
+ edge_count: modulesResult.graph.edge_count,
84
+ cycle_count: modulesResult.graph.cycles.length,
85
+ unresolved_internal_deps: modulesResult.graph.metrics.unresolved_internal_deps,
86
+ },
76
87
  }, null, 2));
77
88
  }
78
89
 
@@ -103,6 +114,15 @@ function _scanAndBuild(projectRoot, flags) {
103
114
  fs.mkdirSync(path.dirname(indexMapPath), { recursive: true });
104
115
  atomicWriteFileSync(indexMapPath, JSON.stringify(docIndex, null, 2) + '\n');
105
116
 
117
+ const graph = buildModuleGraph(modules.map((m) => m.facts));
118
+ const graphPath = path.join(
119
+ projectRoot,
120
+ '.nubos-pilot',
121
+ 'codebase',
122
+ '.graph.json',
123
+ );
124
+ atomicWriteFileSync(graphPath, JSON.stringify(graph, null, 2) + '\n');
125
+
106
126
  const indexPath = indexDocPath(projectRoot);
107
127
  fs.mkdirSync(path.dirname(indexPath), { recursive: true });
108
128
  atomicWriteFileSync(indexPath, buildIndexDoc(modules, { project_name: flags.projectName || null }));
@@ -115,7 +135,7 @@ function _scanAndBuild(projectRoot, flags) {
115
135
  atomicWriteFileSync(docPath, renderModuleDoc(mod.facts, null, hashLookup));
116
136
  }
117
137
 
118
- return { scan: scanResult, modules, manifest, hashLookup };
138
+ return { scan: scanResult, modules, manifest, hashLookup, graph };
119
139
  }
120
140
 
121
141
  function _applyProse(projectRoot, flags, stdout) {
@@ -10,8 +10,10 @@ const {
10
10
  } = require('./core.cjs');
11
11
  const { parseState, serializeState } = require('./state.cjs');
12
12
  const { TASK_ID_RE } = require('./ids.cjs');
13
+ const { assertValid } = require('./validate.cjs');
13
14
 
14
15
  const CHECKPOINT_SCHEMA_VERSION = 1;
16
+ const STORE_SCHEMA = 'checkpoint.v1';
15
17
 
16
18
  function _assertSafeTaskId(taskId) {
17
19
  if (typeof taskId !== 'string' || !TASK_ID_RE.test(taskId)) {
@@ -78,6 +80,7 @@ function _assertCompatibleSchema(existing, cpPath) {
78
80
  },
79
81
  );
80
82
  }
83
+ assertValid(existing, STORE_SCHEMA, 'checkpoint-corrupt', { path: cpPath });
81
84
  }
82
85
 
83
86
  function _sliceFromTaskId(taskId) {
Binary file
@@ -0,0 +1,174 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const g = require('./codebase-graph.cjs');
5
+
6
+ function fact(id, directory, files, extra) {
7
+ const source_paths = files.map((f) => f.path);
8
+ return Object.assign({
9
+ id,
10
+ name: directory || 'root',
11
+ directory: directory || '',
12
+ primary_language: 'javascript',
13
+ language_distribution: { javascript: files.length },
14
+ file_count: files.length,
15
+ source_paths,
16
+ symbols: [],
17
+ internal_deps: [],
18
+ external_deps: [],
19
+ files,
20
+ }, extra || {});
21
+ }
22
+
23
+ test('CG-1: buildModuleGraph creates a node per fact', () => {
24
+ const facts = [
25
+ fact('src-auth', 'src/auth', [{ path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: [] }]),
26
+ fact('src-db', 'src/db', [{ path: 'src/db/index.js', language: 'javascript', symbols: [], deps: [] }]),
27
+ ];
28
+ const graph = g.buildModuleGraph(facts);
29
+ assert.equal(graph.module_count, 2);
30
+ assert.deepEqual(graph.nodes.map((n) => n.id), ['src-auth', 'src-db']);
31
+ });
32
+
33
+ test('CG-2: relative import resolves to a cross-module edge', () => {
34
+ const facts = [
35
+ fact('src-auth', 'src/auth', [
36
+ { path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['../db', 'bcrypt'] },
37
+ ]),
38
+ fact('src-db', 'src/db', [
39
+ { path: 'src/db/index.js', language: 'javascript', symbols: [], deps: [] },
40
+ ]),
41
+ ];
42
+ const graph = g.buildModuleGraph(facts);
43
+ assert.equal(graph.edge_count, 1);
44
+ assert.deepEqual(graph.edges[0], { from: 'src-auth', to: 'src-db', weight: 1 });
45
+ });
46
+
47
+ test('CG-3: external (non-relative) deps never become edges', () => {
48
+ const facts = [
49
+ fact('src-auth', 'src/auth', [
50
+ { path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['bcrypt', 'node:fs'] },
51
+ ]),
52
+ ];
53
+ const graph = g.buildModuleGraph(facts);
54
+ assert.equal(graph.edge_count, 0);
55
+ });
56
+
57
+ test('CG-4: same-module relative import is not a self-edge', () => {
58
+ const facts = [
59
+ fact('src-auth', 'src/auth', [
60
+ { path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['./session'] },
61
+ { path: 'src/auth/session.js', language: 'javascript', symbols: [], deps: [] },
62
+ ]),
63
+ ];
64
+ const graph = g.buildModuleGraph(facts);
65
+ assert.equal(graph.edge_count, 0);
66
+ });
67
+
68
+ test('CG-5: repeated imports raise edge weight', () => {
69
+ const facts = [
70
+ fact('a', 'a', [
71
+ { path: 'a/one.js', language: 'javascript', symbols: [], deps: ['../b'] },
72
+ { path: 'a/two.js', language: 'javascript', symbols: [], deps: ['../b/helper'] },
73
+ ]),
74
+ fact('b', 'b', [
75
+ { path: 'b/index.js', language: 'javascript', symbols: [], deps: [] },
76
+ { path: 'b/helper.js', language: 'javascript', symbols: [], deps: [] },
77
+ ]),
78
+ ];
79
+ const graph = g.buildModuleGraph(facts);
80
+ assert.equal(graph.edges.length, 1);
81
+ assert.equal(graph.edges[0].weight, 2);
82
+ });
83
+
84
+ test('CG-6: unresolved internal-looking deps are counted, not edged', () => {
85
+ const facts = [
86
+ fact('a', 'a', [
87
+ { path: 'a/one.js', language: 'javascript', symbols: [], deps: ['../nonexistent'] },
88
+ ]),
89
+ ];
90
+ const graph = g.buildModuleGraph(facts);
91
+ assert.equal(graph.edge_count, 0);
92
+ assert.equal(graph.metrics.unresolved_internal_deps, 1);
93
+ });
94
+
95
+ test('CG-7: Tarjan detects a 2-module cycle', () => {
96
+ const facts = [
97
+ fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
98
+ fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../a'] }]),
99
+ ];
100
+ const graph = g.buildModuleGraph(facts);
101
+ assert.equal(graph.cycles.length, 1);
102
+ assert.deepEqual(graph.cycles[0], ['a', 'b']);
103
+ });
104
+
105
+ test('CG-8: acyclic graph reports no cycles', () => {
106
+ const facts = [
107
+ fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
108
+ fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../c'] }]),
109
+ fact('c', 'c', [{ path: 'c/index.js', language: 'javascript', symbols: [], deps: [] }]),
110
+ ];
111
+ const graph = g.buildModuleGraph(facts);
112
+ assert.equal(graph.cycles.length, 0);
113
+ });
114
+
115
+ test('CG-9: transitive dependents (impact) walk the reverse graph', () => {
116
+ const facts = [
117
+ fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
118
+ fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../c'] }]),
119
+ fact('c', 'c', [{ path: 'c/index.js', language: 'javascript', symbols: [], deps: [] }]),
120
+ ];
121
+ const graph = g.buildModuleGraph(facts);
122
+ assert.deepEqual(g.transitiveDependents(graph, 'c'), ['a', 'b']);
123
+ assert.deepEqual(g.directDependents(graph, 'c'), ['b']);
124
+ assert.deepEqual(g.transitiveDependencies(graph, 'a'), ['b', 'c']);
125
+ assert.deepEqual(g.directDependencies(graph, 'a'), ['b']);
126
+ });
127
+
128
+ test('CG-10: deterministic clustering groups a connected component together', () => {
129
+ const facts = [
130
+ fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
131
+ fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../a'] }]),
132
+ fact('lonely', 'lonely', [{ path: 'lonely/z.js', language: 'javascript', symbols: [], deps: [] }]),
133
+ ];
134
+ const graph = g.buildModuleGraph(facts);
135
+ const first = g.buildModuleGraph(facts);
136
+ assert.deepEqual(graph.clusters, first.clusters);
137
+ const clusterAB = graph.clusters.find((c) => c.members.includes('a'));
138
+ assert.ok(clusterAB.members.includes('b'));
139
+ assert.ok(!clusterAB.members.includes('lonely'));
140
+ });
141
+
142
+ test('CG-11: cycleFor and clusterOf locate a module', () => {
143
+ const facts = [
144
+ fact('a', 'a', [{ path: 'a/x.js', language: 'javascript', symbols: [], deps: ['../b'] }]),
145
+ fact('b', 'b', [{ path: 'b/index.js', language: 'javascript', symbols: [], deps: ['../a'] }]),
146
+ ];
147
+ const graph = g.buildModuleGraph(facts);
148
+ assert.deepEqual(g.cycleFor(graph, 'a'), ['a', 'b']);
149
+ assert.equal(typeof g.clusterOf(graph, 'a'), 'number');
150
+ assert.equal(g.cycleFor(graph, 'missing'), null);
151
+ assert.equal(g.clusterOf(graph, 'missing'), null);
152
+ });
153
+
154
+ test('CG-12: root-relative (/-prefixed) deps resolve from project root', () => {
155
+ const facts = [
156
+ fact('src-auth', 'src/auth', [
157
+ { path: 'src/auth/login.js', language: 'javascript', symbols: [], deps: ['/src/db'] },
158
+ ]),
159
+ fact('src-db', 'src/db', [
160
+ { path: 'src/db/index.js', language: 'javascript', symbols: [], deps: [] },
161
+ ]),
162
+ ];
163
+ const graph = g.buildModuleGraph(facts);
164
+ assert.equal(graph.edge_count, 1);
165
+ assert.deepEqual(graph.edges[0], { from: 'src-auth', to: 'src-db', weight: 1 });
166
+ });
167
+
168
+ test('CG-13: empty facts yield an empty graph', () => {
169
+ const graph = g.buildModuleGraph([]);
170
+ assert.equal(graph.module_count, 0);
171
+ assert.equal(graph.edge_count, 0);
172
+ assert.deepEqual(graph.cycles, []);
173
+ assert.deepEqual(graph.clusters, []);
174
+ });
@@ -3,8 +3,10 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { atomicWriteFileSync, NubosPilotError } = require('./core.cjs');
6
+ const { assertValid } = require('./validate.cjs');
6
7
 
7
8
  const SCHEMA_VERSION = 1;
9
+ const STORE_SCHEMA = 'codebase-manifest.v1';
8
10
  const CODEBASE_DIR_NAME = 'codebase';
9
11
  const MANIFEST_FILENAME = '.hashes.json';
10
12
 
@@ -63,6 +65,7 @@ function readManifest(projectRoot) {
63
65
  );
64
66
  }
65
67
  if (!parsed.files || typeof parsed.files !== 'object') parsed.files = {};
68
+ assertValid(parsed, STORE_SCHEMA, 'manifest-invalid-shape', { path: p });
66
69
  return parsed;
67
70
  }
68
71