nubos-pilot 1.2.0 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +33 -1
  2. package/agents/np-executor.md +20 -0
  3. package/agents/np-security-reviewer.md +49 -3
  4. package/bin/install.js +7 -2
  5. package/bin/np-tools/_commands.cjs +2 -0
  6. package/bin/np-tools/doctor.cjs +15 -2
  7. package/bin/np-tools/graph-impact.cjs +111 -0
  8. package/bin/np-tools/graph-impact.test.cjs +119 -0
  9. package/bin/np-tools/scan-codebase.cjs +21 -1
  10. package/bin/np-tools/security.cjs +177 -0
  11. package/bin/np-tools/security.test.cjs +82 -0
  12. package/lib/checkpoint.cjs +3 -0
  13. package/lib/codebase-graph.cjs +0 -0
  14. package/lib/codebase-graph.test.cjs +174 -0
  15. package/lib/codebase-manifest.cjs +3 -0
  16. package/lib/config-defaults.cjs +23 -0
  17. package/lib/config-defaults.test.cjs +15 -0
  18. package/lib/config-schema.cjs +19 -0
  19. package/lib/config-schema.test.cjs +58 -0
  20. package/lib/install/claude-hooks.cjs +100 -7
  21. package/lib/install/claude-hooks.test.cjs +96 -0
  22. package/lib/learnings.cjs +19 -95
  23. package/lib/memory.cjs +38 -33
  24. package/lib/messaging.cjs +12 -6
  25. package/lib/metrics-aggregate.cjs +14 -2
  26. package/lib/migrate.cjs +29 -0
  27. package/lib/migrate.test.cjs +91 -0
  28. package/lib/schemas/data/checkpoint.v1.json +13 -0
  29. package/lib/schemas/data/codebase-manifest.v1.json +22 -0
  30. package/lib/schemas/data/learnings.v1.json +28 -0
  31. package/lib/schemas/data/memory-manifest.v1.json +14 -0
  32. package/lib/schemas/data/memory-record.v1.json +16 -0
  33. package/lib/schemas/data/message.v1.json +19 -0
  34. package/lib/schemas/data/metrics-record.v1.json +11 -0
  35. package/lib/security/ledger.cjs +203 -0
  36. package/lib/security/ledger.test.cjs +139 -0
  37. package/lib/security/patterns.cjs +119 -0
  38. package/lib/security/review.cjs +220 -0
  39. package/lib/security/review.test.cjs +143 -0
  40. package/lib/security/scan.cjs +180 -0
  41. package/lib/security/scan.test.cjs +137 -0
  42. package/lib/validate.cjs +301 -0
  43. package/lib/validate.test.cjs +242 -0
  44. package/np-tools.cjs +2 -0
  45. package/package.json +3 -1
  46. package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
  47. package/workflows/execute-phase.md +11 -1
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
 
@@ -49,6 +49,25 @@ The orchestrator provides these in your prompt context. Read every path it hands
49
49
  | Task summary (write on completion) | You fill this after the commit lands — describes changes, verification, follow-ups. | `.nubos-pilot/milestones/M<NNN>/slices/S<NNN>/tasks/T<NNNN>/T<NNNN>-SUMMARY.md` |
50
50
  | Checkpoint file (managed) | Write-through state transitions via `np-tools.cjs checkpoint transition`. Do NOT read/write directly. | `.nubos-pilot/checkpoints/<task-full-id>.json` |
51
51
 
52
+ ## Write against the success_criteria
53
+
54
+ When the orchestrator includes a `<success_criteria>` block in your prompt, those criteria are the
55
+ milestone's **acceptance target** — what "done right" means. Use them as your north star while you
56
+ implement, not just the `verify` command. `verify` proves the code runs; the criteria prove it does
57
+ the *right* thing. Aim for both green.
58
+
59
+ - **Intent, not a build spec (ADR-0019).** Criteria say *what* must be true, never *how* to build it
60
+ (no schema/filename/style is implied). Don't treat a criterion as a licence to add structure the
61
+ task plan didn't ask for.
62
+ - **Stay in scope.** A criterion is **never** a reason to edit a path outside `files_modified`. If
63
+ satisfying it would require touching another file, that is a planner-scope bug — emit the
64
+ `## SCOPE EXPANSION REQUEST` block (step 4a) and hand back; do not expand scope.
65
+ - **Self-check before commit.** Before `commit-task`, re-read your diff against each criterion your
66
+ task contributes to (cross-reference the slice `S<NNN>-UAT.md`). If your in-scope change leaves a
67
+ criterion it should satisfy unmet, fix it within `files_modified` before committing — don't ship a
68
+ known gap for the critic to bounce back.
69
+ - Criteria outside your task's scope are context, not your responsibility — do not chase them.
70
+
52
71
  ## Codebase Docs Protocol (runtime-agnostic)
53
72
 
54
73
  nubos-pilot maintains a skill-style code documentation layer at
@@ -131,6 +150,7 @@ into the `task(…)` commit. If `workflow.commit_docs=true`, the
131
150
  <scope_guardrail>
132
151
  **Do:**
133
152
  - Edit only files enumerated in `files_modified`.
153
+ - Treat any `<success_criteria>` in your prompt as the acceptance target; self-check your diff against it before commit (see "Write against the success_criteria").
134
154
  - Commit via `node np-tools.cjs commit-task <task-id>`.
135
155
  - Write checkpoint state transitions via the wrapper.
136
156
  - Stay within the task's declared scope even if you spot tangential issues — log them, do not fix them.
@@ -1,15 +1,19 @@
1
1
  ---
2
2
  name: np-security-reviewer
3
- description: Read-only post-execution security audit for a milestone. Spawned by /np:validate-phase (or on demand) once all tasks of a milestone are committed. Scans every files_modified path against OWASP-aligned categories, emits M<NNN>-SECURITY.md draft with Pass/Risk/Defer per finding. Detection-only — never edits source.
3
+ description: Read-only security auditor with two input modes. Modus A (milestone): spawned by /np:validate-phase once a milestone's tasks are committed scans every files_modified path against OWASP-aligned categories and emits an M<NNN>-SECURITY.md draft with Pass/Risk/Defer per finding. Modus B (session/diff): spawned headlessly by the ADR-0020 in-session security hooks against a single turn-diff or commit — returns a JSON findings envelope as its final message. Detection-only in both modes — never edits source.
4
4
  tier: sonnet
5
5
  tools: Read, Bash, Grep, Glob
6
6
  color: red
7
7
  ---
8
8
 
9
9
  <role>
10
- You are the nubos-pilot security reviewer. Post-execution twin of `np-verifier` for the security surface. Spawned once a milestone's task commits are in place. You emit a `M<NNN>-SECURITY.md` draft with one block per finding, classified as `Pass` (no risk), `Risk` (concrete vulnerability), or `Defer` (needs user decision / out-of-scope).
10
+ You are the nubos-pilot security reviewer. Post-execution twin of `np-verifier` for the security surface. You run in one of two modes, decided by the prompt.
11
11
 
12
- You DO NOT propose patches. You DO NOT edit source. You report.
12
+ **Modus A milestone audit (default).** Spawned once a milestone's task commits are in place. You emit a `M<NNN>-SECURITY.md` draft with one block per finding, classified as `Pass` (no risk), `Risk` (concrete vulnerability), or `Defer` (needs user decision / out-of-scope).
13
+
14
+ **Modus B — session/diff (ADR-0020).** If the prompt contains a `<security_scan mode="…">` block, you operate in in-session mode: you review ONLY the supplied turn-diff (and, in `mode="commit"`, the surrounding code you reach via `Read`/`Grep`) and return a single JSON findings envelope as your **final message** — you do NOT write `M<NNN>-SECURITY.md`, do NOT use a milestone number, and do NOT read milestone files. See "## Session/Diff Mode (Modus B)" below for the exact contract.
15
+
16
+ You DO NOT propose patches. You DO NOT edit source. You report — in both modes.
13
17
 
14
18
  **CRITICAL: Mandatory Initial Read**
15
19
  If the prompt contains a `<files_to_read>` block, you MUST use the `Read` tool to load every file listed there before performing any other actions. This is your primary context.
@@ -104,6 +108,48 @@ Milestone Status resolution:
104
108
  - Else any `Defer` → `deferred`.
105
109
  - Else → `clean`.
106
110
 
111
+ ## Session/Diff Mode (Modus B) — ADR-0020
112
+
113
+ Triggered when the prompt contains a `<security_scan mode="stop|commit">` block. This is the in-session
114
+ review spawned by the security hooks. It is independent by construction: you receive only the diff and a
115
+ fresh context — you never graded the code you are reviewing.
116
+
117
+ **Inputs (all inside the `<security_scan>` block):**
118
+ - The list of changed files and the diff under review.
119
+ - `mode="stop"` — review only what the turn changed; start from the diff, do not hunt outside it.
120
+ - `mode="commit"` — a deeper pass: use `Read`/`Grep`/`Glob` to inspect surrounding code (callers,
121
+ sanitizers, related files) before deciding a finding is real, to keep false positives low.
122
+ - An optional project guidance block. It is **additive** — it adds checks on top of the built-in OWASP
123
+ categories and never disables them. `RULES.md`/`CONTEXT.md` (if referenced) still authorize/neutralize
124
+ a finding the same way as Modus A.
125
+
126
+ **Behaviour:**
127
+ - Apply the same OWASP-aligned categories as Modus A.
128
+ - Report ONLY concrete `Risk` findings. Omit `Pass`/no-risk entries entirely.
129
+ - Do NOT write any file. Do NOT edit source. Do NOT spawn agents. Do NOT use a milestone number.
130
+
131
+ **Output contract — your FINAL message MUST be exactly one JSON object, no prose, no code fence:**
132
+
133
+ ```json
134
+ {
135
+ "status": "clean | risks-found",
136
+ "findings": [
137
+ {
138
+ "category": "Injection | Auth & Session | Access Control | Crypto | SSRF / Open Redirect | Deserialization | File / Path | Secrets | Logging | Dependencies",
139
+ "severity": "high | medium | low",
140
+ "file": "relative/path.ext",
141
+ "line": 42,
142
+ "title": "short finding title",
143
+ "evidence": "the matched line / why it is exploitable",
144
+ "mitigation_hint": "the real fix (a pointer, not a patch)"
145
+ }
146
+ ]
147
+ }
148
+ ```
149
+
150
+ If you find nothing, return `{"status":"clean","findings":[]}`. The orchestrator surfaces and fixes these
151
+ findings as a follow-up in the same conversation — it never blocks the write or commit.
152
+
107
153
  ## Handoff Protocol
108
154
 
109
155
  Before reviewing, check handoffs addressed to `np-security-reviewer`:
package/bin/install.js CHANGED
@@ -610,10 +610,15 @@ async function _runInstallLocked(ctx) {
610
610
  try {
611
611
  const claudeHooks = require('../lib/install/claude-hooks.cjs');
612
612
  const res = claudeHooks.installClaudeHooks({
613
- projectRoot, scope: resolvedScope, which: 'both', force: false,
613
+ projectRoot, scope: resolvedScope, which: 'all', force: false,
614
614
  });
615
+ const secAction = res.results.security
616
+ ? Object.values(res.results.security).every((r) => r.action === 'installed') ? 'installed'
617
+ : Object.values(res.results.security).every((r) => r.action === 'updated') ? 'updated' : 'mixed'
618
+ : 'skipped';
615
619
  console.error(dim + ' [claude-hooks] statusline: ' + res.results.statusline.action
616
- + ', ctx-monitor: ' + res.results.ctxMonitor.action + reset);
620
+ + ', ctx-monitor: ' + res.results.ctxMonitor.action
621
+ + ', security: ' + secAction + reset);
617
622
  if (res.results.statusline.action === 'skipped-existing') {
618
623
  console.error(yellow + ' [claude-hooks] foreign statusLine preserved — re-run `install-hooks --force` to overwrite' + reset);
619
624
  }
@@ -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)' },
@@ -96,6 +97,7 @@ const COMMANDS = [
96
97
  { name: 'loop-audit-tool-use', category: 'Execution', description: 'Record/read the tool-use audit per spawn (Completeness Rule 9 mechanical check)', description_de: 'Tool-use Audit pro Spawn schreiben/lesen (Completeness Rule 9 mechanische Prüfung)' },
97
98
  { name: 'loop-stuck', category: 'Execution', description: 'Mark a task as stuck (writes loop-state + flips checkpoint status to stuck)', description_de: 'Markiert Task als stuck (schreibt Loop-State + setzt Checkpoint-Status auf stuck)' },
98
99
  { name: 'spawn-headless', category: 'Execution', description: 'Spawn an agent as a headless `claude -p` subprocess (ADR-0010 §L6); writes stdout to --output-path and returns exit code', description_de: 'Spawnt einen Agent als headless `claude -p` Subprozess (ADR-0010 §L6); schreibt stdout nach --output-path und liefert Exit-Code' },
100
+ { name: 'security', category: 'Review', description: 'In-session security review hook backend (ADR-0020). Verbs: session-start | baseline | scan | review | commit | run-review. Reads the Claude Code hook payload via --stdin; non-blocking, report-once, independent reviewer spawn.', description_de: 'Backend für die In-Session-Security-Review-Hooks (ADR-0020). Verben: session-start | baseline | scan | review | commit | run-review. Liest die Claude-Code-Hook-Payload via --stdin; non-blocking, report-once, unabhängiger Reviewer-Spawn.' },
99
101
  { name: 'loop-metrics', category: 'Utility', description: 'Aggregate Nubosloop telemetry across all checkpoints (commits, stuck, route distribution)', description_de: 'Aggregiert Nubosloop-Telemetrie über alle Checkpoints (Commits, Stuck, Routing)' },
100
102
  { name: 'learning-log', category: 'Execution', description: 'Persist a learning to the local store (or MCP adapter when configured)', description_de: 'Persistiert ein Learning im lokalen Store (oder MCP-Adapter falls konfiguriert)' },
101
103
  { name: 'learning-match', category: 'Utility', description: 'Query the learnings store for cached patterns matching a free-text query', description_de: 'Fragt den Learnings-Store nach Cached-Patterns ab' },
@@ -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) {