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.
- package/CHANGELOG.md +33 -1
- package/agents/np-executor.md +20 -0
- package/agents/np-security-reviewer.md +49 -3
- package/bin/install.js +7 -2
- package/bin/np-tools/_commands.cjs +2 -0
- package/bin/np-tools/doctor.cjs +15 -2
- package/bin/np-tools/graph-impact.cjs +111 -0
- package/bin/np-tools/graph-impact.test.cjs +119 -0
- package/bin/np-tools/scan-codebase.cjs +21 -1
- package/bin/np-tools/security.cjs +177 -0
- package/bin/np-tools/security.test.cjs +82 -0
- package/lib/checkpoint.cjs +3 -0
- package/lib/codebase-graph.cjs +0 -0
- package/lib/codebase-graph.test.cjs +174 -0
- package/lib/codebase-manifest.cjs +3 -0
- package/lib/config-defaults.cjs +23 -0
- package/lib/config-defaults.test.cjs +15 -0
- package/lib/config-schema.cjs +19 -0
- package/lib/config-schema.test.cjs +58 -0
- package/lib/install/claude-hooks.cjs +100 -7
- package/lib/install/claude-hooks.test.cjs +96 -0
- package/lib/learnings.cjs +19 -95
- package/lib/memory.cjs +38 -33
- package/lib/messaging.cjs +12 -6
- package/lib/metrics-aggregate.cjs +14 -2
- package/lib/migrate.cjs +29 -0
- package/lib/migrate.test.cjs +91 -0
- package/lib/schemas/data/checkpoint.v1.json +13 -0
- package/lib/schemas/data/codebase-manifest.v1.json +22 -0
- package/lib/schemas/data/learnings.v1.json +28 -0
- package/lib/schemas/data/memory-manifest.v1.json +14 -0
- package/lib/schemas/data/memory-record.v1.json +16 -0
- package/lib/schemas/data/message.v1.json +19 -0
- package/lib/schemas/data/metrics-record.v1.json +11 -0
- package/lib/security/ledger.cjs +203 -0
- package/lib/security/ledger.test.cjs +139 -0
- package/lib/security/patterns.cjs +119 -0
- package/lib/security/review.cjs +220 -0
- package/lib/security/review.test.cjs +143 -0
- package/lib/security/scan.cjs +180 -0
- package/lib/security/scan.test.cjs +137 -0
- package/lib/validate.cjs +301 -0
- package/lib/validate.test.cjs +242 -0
- package/np-tools.cjs +2 -0
- package/package.json +3 -1
- package/templates/claude/payload/hooks/np-security-hook.cjs +50 -0
- 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.
|
|
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
|
|
package/agents/np-executor.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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' },
|
package/bin/np-tools/doctor.cjs
CHANGED
|
@@ -399,14 +399,27 @@ function _checkNubosloopKnowledgeStore(projectRoot) {
|
|
|
399
399
|
}
|
|
400
400
|
try {
|
|
401
401
|
const parsed = JSON.parse(fs.readFileSync(learningsPath, 'utf-8'));
|
|
402
|
-
|
|
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
|
-
|
|
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) {
|