@nomos-arc/arc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +10 -0
- package/.nomos-config.json +5 -0
- package/CLAUDE.md +108 -0
- package/LICENSE +190 -0
- package/README.md +569 -0
- package/dist/cli.js +21120 -0
- package/docs/auth/googel_plan.yaml +1093 -0
- package/docs/auth/google_task.md +235 -0
- package/docs/auth/hardened_blueprint.yaml +1658 -0
- package/docs/auth/red_team_report.yaml +336 -0
- package/docs/auth/session_state.yaml +162 -0
- package/docs/certificate/cer_enhance_plan.md +605 -0
- package/docs/certificate/certificate_report.md +338 -0
- package/docs/dev_overview.md +419 -0
- package/docs/feature_assessment.md +156 -0
- package/docs/how_it_works.md +78 -0
- package/docs/infrastructure/map.md +867 -0
- package/docs/init/master_plan.md +3581 -0
- package/docs/init/red_team_report.md +215 -0
- package/docs/init/report_phase_1a.md +304 -0
- package/docs/integrity-gate/enhance_drift.md +703 -0
- package/docs/integrity-gate/overview.md +108 -0
- package/docs/management/manger-task.md +99 -0
- package/docs/management/scafffold.md +76 -0
- package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
- package/docs/map/RED_TEAM_REPORT.md +159 -0
- package/docs/map/map_task.md +147 -0
- package/docs/map/semantic_graph_task.md +792 -0
- package/docs/map/semantic_master_plan.md +705 -0
- package/docs/phase7/TEAM_RED.md +249 -0
- package/docs/phase7/plan.md +1682 -0
- package/docs/phase7/task.md +275 -0
- package/docs/prompts/USAGE.md +312 -0
- package/docs/prompts/architect.md +165 -0
- package/docs/prompts/executer.md +190 -0
- package/docs/prompts/hardener.md +190 -0
- package/docs/prompts/red_team.md +146 -0
- package/docs/verification/goveranance-overview.md +396 -0
- package/docs/verification/governance-overview.md +245 -0
- package/docs/verification/verification-arc-ar.md +560 -0
- package/docs/verification/verification-architecture.md +560 -0
- package/docs/very_next.md +52 -0
- package/docs/whitepaper.md +89 -0
- package/overview.md +1469 -0
- package/package.json +63 -0
- package/src/adapters/__tests__/git.test.ts +296 -0
- package/src/adapters/__tests__/stdio.test.ts +70 -0
- package/src/adapters/git.ts +226 -0
- package/src/adapters/pty.ts +159 -0
- package/src/adapters/stdio.ts +113 -0
- package/src/cli.ts +83 -0
- package/src/commands/apply.ts +47 -0
- package/src/commands/auth.ts +301 -0
- package/src/commands/certificate.ts +89 -0
- package/src/commands/discard.ts +24 -0
- package/src/commands/drift.ts +116 -0
- package/src/commands/index.ts +78 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/list.ts +75 -0
- package/src/commands/map.ts +55 -0
- package/src/commands/plan.ts +30 -0
- package/src/commands/review.ts +58 -0
- package/src/commands/run.ts +63 -0
- package/src/commands/search.ts +147 -0
- package/src/commands/show.ts +63 -0
- package/src/commands/status.ts +59 -0
- package/src/core/__tests__/budget.test.ts +213 -0
- package/src/core/__tests__/certificate.test.ts +385 -0
- package/src/core/__tests__/config.test.ts +191 -0
- package/src/core/__tests__/preflight.test.ts +24 -0
- package/src/core/__tests__/prompt.test.ts +358 -0
- package/src/core/__tests__/review.test.ts +161 -0
- package/src/core/__tests__/state.test.ts +362 -0
- package/src/core/auth/__tests__/manager.test.ts +166 -0
- package/src/core/auth/__tests__/server.test.ts +220 -0
- package/src/core/auth/gcp-projects.ts +160 -0
- package/src/core/auth/manager.ts +114 -0
- package/src/core/auth/server.ts +141 -0
- package/src/core/budget.ts +119 -0
- package/src/core/certificate.ts +502 -0
- package/src/core/config.ts +212 -0
- package/src/core/errors.ts +54 -0
- package/src/core/factory.ts +49 -0
- package/src/core/graph/__tests__/builder.test.ts +272 -0
- package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
- package/src/core/graph/__tests__/enricher.test.ts +299 -0
- package/src/core/graph/__tests__/parser.test.ts +200 -0
- package/src/core/graph/__tests__/pipeline.test.ts +202 -0
- package/src/core/graph/__tests__/renderer.test.ts +128 -0
- package/src/core/graph/__tests__/resolver.test.ts +185 -0
- package/src/core/graph/__tests__/scanner.test.ts +231 -0
- package/src/core/graph/__tests__/show.test.ts +134 -0
- package/src/core/graph/builder.ts +303 -0
- package/src/core/graph/constraints.ts +94 -0
- package/src/core/graph/contract-writer.ts +93 -0
- package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
- package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
- package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
- package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
- package/src/core/graph/drift/classifier.ts +165 -0
- package/src/core/graph/drift/comparator.ts +205 -0
- package/src/core/graph/drift/reporter.ts +77 -0
- package/src/core/graph/enricher.ts +251 -0
- package/src/core/graph/grammar-paths.ts +30 -0
- package/src/core/graph/html-template.ts +493 -0
- package/src/core/graph/map-schema.ts +137 -0
- package/src/core/graph/parser.ts +336 -0
- package/src/core/graph/pipeline.ts +209 -0
- package/src/core/graph/renderer.ts +92 -0
- package/src/core/graph/resolver.ts +195 -0
- package/src/core/graph/scanner.ts +145 -0
- package/src/core/logger.ts +46 -0
- package/src/core/orchestrator.ts +792 -0
- package/src/core/plan-file-manager.ts +66 -0
- package/src/core/preflight.ts +64 -0
- package/src/core/prompt.ts +173 -0
- package/src/core/review.ts +95 -0
- package/src/core/state.ts +294 -0
- package/src/core/worktree-coordinator.ts +77 -0
- package/src/search/__tests__/chunk-extractor.test.ts +339 -0
- package/src/search/__tests__/embedder-auth.test.ts +124 -0
- package/src/search/__tests__/embedder.test.ts +267 -0
- package/src/search/__tests__/graph-enricher.test.ts +178 -0
- package/src/search/__tests__/indexer.test.ts +518 -0
- package/src/search/__tests__/integration.test.ts +649 -0
- package/src/search/__tests__/query-engine.test.ts +334 -0
- package/src/search/__tests__/similarity.test.ts +78 -0
- package/src/search/__tests__/vector-store.test.ts +281 -0
- package/src/search/chunk-extractor.ts +167 -0
- package/src/search/embedder.ts +209 -0
- package/src/search/graph-enricher.ts +95 -0
- package/src/search/indexer.ts +483 -0
- package/src/search/lexical-searcher.ts +190 -0
- package/src/search/query-engine.ts +225 -0
- package/src/search/vector-store.ts +311 -0
- package/src/types/index.ts +572 -0
- package/src/utils/__tests__/ansi.test.ts +54 -0
- package/src/utils/__tests__/frontmatter.test.ts +79 -0
- package/src/utils/__tests__/sanitize.test.ts +229 -0
- package/src/utils/ansi.ts +19 -0
- package/src/utils/context.ts +44 -0
- package/src/utils/frontmatter.ts +27 -0
- package/src/utils/sanitize.ts +78 -0
- package/test/e2e/lifecycle.test.ts +330 -0
- package/test/fixtures/mock-planner-hang.ts +5 -0
- package/test/fixtures/mock-planner.ts +26 -0
- package/test/fixtures/mock-reviewer-bad.ts +8 -0
- package/test/fixtures/mock-reviewer-retry.ts +34 -0
- package/test/fixtures/mock-reviewer.ts +18 -0
- package/test/fixtures/sample-project/src/circular-a.ts +6 -0
- package/test/fixtures/sample-project/src/circular-b.ts +6 -0
- package/test/fixtures/sample-project/src/config.ts +15 -0
- package/test/fixtures/sample-project/src/main.ts +19 -0
- package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
- package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
- package/test/fixtures/sample-project/src/types.ts +14 -0
- package/test/fixtures/sample-project/src/utils/index.ts +14 -0
- package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractJson, validateReviewSchema, semanticValidation, parseReviewOutput } from '../review.js';
|
|
3
|
+
import type { ReviewResult } from '../../types/index.js';
|
|
4
|
+
|
|
5
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const VALID_REVIEW_OBJ = {
|
|
8
|
+
score: 0.85,
|
|
9
|
+
summary: 'Good implementation with minor issues',
|
|
10
|
+
issues: [
|
|
11
|
+
{
|
|
12
|
+
severity: 'medium' as const,
|
|
13
|
+
category: 'performance' as const,
|
|
14
|
+
description: 'N+1 query pattern',
|
|
15
|
+
suggestion: 'Use batch loading',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const VALID_REVIEW_JSON = JSON.stringify(VALID_REVIEW_OBJ);
|
|
21
|
+
|
|
22
|
+
// ── extractJson ───────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('extractJson', () => {
|
|
25
|
+
it('parses clean JSON directly', () => {
|
|
26
|
+
const result = extractJson(VALID_REVIEW_JSON);
|
|
27
|
+
expect(result).toMatchObject(VALID_REVIEW_OBJ);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('extracts JSON wrapped in markdown fences', () => {
|
|
31
|
+
const raw = `Here is my review:\n\`\`\`json\n${VALID_REVIEW_JSON}\n\`\`\``;
|
|
32
|
+
const result = extractJson(raw);
|
|
33
|
+
expect(result).toMatchObject(VALID_REVIEW_OBJ);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('extracts JSON with preamble text using brace-depth extraction', () => {
|
|
37
|
+
const raw = `Here is my review:\n${VALID_REVIEW_JSON}`;
|
|
38
|
+
const result = extractJson(raw);
|
|
39
|
+
expect(result).toMatchObject(VALID_REVIEW_OBJ);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null for completely invalid output', () => {
|
|
43
|
+
const result = extractJson('This is not JSON at all.');
|
|
44
|
+
expect(result).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── validateReviewSchema ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('validateReviewSchema', () => {
|
|
51
|
+
it('returns a valid ReviewResult when object matches schema', () => {
|
|
52
|
+
const result = validateReviewSchema(VALID_REVIEW_OBJ, 'supervised');
|
|
53
|
+
expect(result).not.toBeNull();
|
|
54
|
+
expect(result!.score).toBe(0.85);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// RT2-4.2 fix: mode must be threaded through, never hardcoded
|
|
58
|
+
it('sets mode to the provided value (dry-run), not a hardcoded value', () => {
|
|
59
|
+
const result = validateReviewSchema(VALID_REVIEW_OBJ, 'dry-run');
|
|
60
|
+
expect(result!.mode).toBe('dry-run');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null when summary is too short', () => {
|
|
64
|
+
const result = validateReviewSchema({ ...VALID_REVIEW_OBJ, summary: 'Short' }, 'supervised');
|
|
65
|
+
expect(result).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns null when score exceeds 2.0 (schema max)', () => {
|
|
69
|
+
const result = validateReviewSchema({ ...VALID_REVIEW_OBJ, score: 3.0 }, 'supervised');
|
|
70
|
+
expect(result).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── semanticValidation ────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('semanticValidation', () => {
|
|
77
|
+
it('clamps score > 1.0 to 1.0', () => {
|
|
78
|
+
const review: ReviewResult = { ...VALID_REVIEW_OBJ, score: 1.5, mode: 'supervised' };
|
|
79
|
+
const { valid } = semanticValidation(review);
|
|
80
|
+
expect(valid).toBe(true);
|
|
81
|
+
expect(review.score).toBe(1.0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects low score (< 0.5) with no supporting issues', () => {
|
|
85
|
+
const review: ReviewResult = {
|
|
86
|
+
score: 0.3,
|
|
87
|
+
summary: 'Terrible implementation overall',
|
|
88
|
+
issues: [],
|
|
89
|
+
mode: 'supervised',
|
|
90
|
+
};
|
|
91
|
+
const { valid, reason } = semanticValidation(review);
|
|
92
|
+
expect(valid).toBe(false);
|
|
93
|
+
expect(reason).toContain('Low score');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects score >= 0.9 when a high-severity issue exists', () => {
|
|
97
|
+
const review: ReviewResult = {
|
|
98
|
+
score: 0.95,
|
|
99
|
+
summary: 'Mostly excellent but has a critical flaw',
|
|
100
|
+
issues: [
|
|
101
|
+
{
|
|
102
|
+
severity: 'high' as const,
|
|
103
|
+
category: 'security' as const,
|
|
104
|
+
description: 'SQL injection risk',
|
|
105
|
+
suggestion: 'Use parameterized queries',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
mode: 'supervised',
|
|
109
|
+
};
|
|
110
|
+
const { valid, reason } = semanticValidation(review);
|
|
111
|
+
expect(valid).toBe(false);
|
|
112
|
+
expect(reason).toContain('High severity');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('accepts a valid review without clamping', () => {
|
|
116
|
+
const review: ReviewResult = { ...VALID_REVIEW_OBJ, mode: 'supervised' };
|
|
117
|
+
const { valid } = semanticValidation(review);
|
|
118
|
+
expect(valid).toBe(true);
|
|
119
|
+
expect(review.score).toBe(0.85);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── parseReviewOutput ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('parseReviewOutput', () => {
|
|
126
|
+
it('returns a parsed ReviewResult for valid JSON', () => {
|
|
127
|
+
const { result, error } = parseReviewOutput(VALID_REVIEW_JSON, 'supervised');
|
|
128
|
+
expect(result).not.toBeNull();
|
|
129
|
+
expect(error).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// RT2-4.2 fix: mode is threaded through — not hardcoded to 'auto'
|
|
133
|
+
it('sets result.mode to the provided mode (supervised)', () => {
|
|
134
|
+
const { result } = parseReviewOutput(VALID_REVIEW_JSON, 'supervised');
|
|
135
|
+
expect(result!.mode).toBe('supervised');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns null with stage-1 error for completely unparseable output', () => {
|
|
139
|
+
const { result, error } = parseReviewOutput('not json at all', 'supervised');
|
|
140
|
+
expect(result).toBeNull();
|
|
141
|
+
expect(error).toContain('Stage 1');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns null with stage-2 error when JSON fails schema validation', () => {
|
|
145
|
+
const bad = JSON.stringify({ score: 0.8, summary: 'Too short', issues: [] });
|
|
146
|
+
const { result, error } = parseReviewOutput(bad, 'supervised');
|
|
147
|
+
expect(result).toBeNull();
|
|
148
|
+
expect(error).toContain('Stage 2');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null with stage-3 error when semantic validation fails', () => {
|
|
152
|
+
const semanticFail = JSON.stringify({
|
|
153
|
+
score: 0.3,
|
|
154
|
+
summary: 'Very poor implementation here',
|
|
155
|
+
issues: [],
|
|
156
|
+
});
|
|
157
|
+
const { result, error } = parseReviewOutput(semanticFail, 'supervised');
|
|
158
|
+
expect(result).toBeNull();
|
|
159
|
+
expect(error).toContain('Stage 3');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { createLogger } from '../logger.js';
|
|
6
|
+
import { StateManager } from '../state.js';
|
|
7
|
+
import { NomosError } from '../errors.js';
|
|
8
|
+
import type { TaskState, HistoryEntry, ReviewResult } from '../../types/index.js';
|
|
9
|
+
|
|
10
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const logger = createLogger('silent'); // suppressed console output in tests
|
|
13
|
+
|
|
14
|
+
function makeTmpDir(): string {
|
|
15
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'nomos-state-test-'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeInitialState(taskId: string): TaskState {
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
return {
|
|
21
|
+
schema_version: 1,
|
|
22
|
+
task_id: taskId,
|
|
23
|
+
current_version: 1,
|
|
24
|
+
meta: {
|
|
25
|
+
status: 'init',
|
|
26
|
+
created_at: now,
|
|
27
|
+
updated_at: now,
|
|
28
|
+
},
|
|
29
|
+
orchestration: {
|
|
30
|
+
planner_bin: 'claude',
|
|
31
|
+
reviewer_bin: 'codex',
|
|
32
|
+
},
|
|
33
|
+
shadow_branch: {
|
|
34
|
+
branch: `nomos/${taskId}`,
|
|
35
|
+
worktree: `/tmp/nomos-worktrees/${taskId}`,
|
|
36
|
+
base_commit: 'abc123',
|
|
37
|
+
status: 'active',
|
|
38
|
+
},
|
|
39
|
+
context: {
|
|
40
|
+
files: [],
|
|
41
|
+
rules: [],
|
|
42
|
+
rules_hash: 'sha256:0000',
|
|
43
|
+
},
|
|
44
|
+
budget: {
|
|
45
|
+
tokens_used: 0,
|
|
46
|
+
estimated_cost_usd: 0,
|
|
47
|
+
},
|
|
48
|
+
history: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeHistoryEntry(version: number = 1): HistoryEntry {
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
return {
|
|
55
|
+
version,
|
|
56
|
+
step: 'planning',
|
|
57
|
+
mode: 'supervised',
|
|
58
|
+
binary: 'claude',
|
|
59
|
+
started_at: now,
|
|
60
|
+
completed_at: now,
|
|
61
|
+
raw_output: 'some output',
|
|
62
|
+
output_hash: 'sha256:abcdef',
|
|
63
|
+
input_tokens: 100,
|
|
64
|
+
output_tokens: 200,
|
|
65
|
+
tokens_used: 300,
|
|
66
|
+
tokens_source: 'metered',
|
|
67
|
+
rules_snapshot: [],
|
|
68
|
+
review: null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
describe('StateManager — create & read', () => {
|
|
75
|
+
let stateDir: string;
|
|
76
|
+
let sm: StateManager;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
stateDir = makeTmpDir();
|
|
80
|
+
sm = new StateManager(stateDir, logger);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('creates and reads a task state round-trip', async () => {
|
|
88
|
+
const state = makeInitialState('task-001');
|
|
89
|
+
await sm.create('task-001', state);
|
|
90
|
+
const loaded = await sm.read('task-001');
|
|
91
|
+
expect(loaded.task_id).toBe('task-001');
|
|
92
|
+
expect(loaded.meta.status).toBe('init');
|
|
93
|
+
expect(loaded.schema_version).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws task_exists when creating a duplicate task', async () => {
|
|
97
|
+
const state = makeInitialState('dup-task');
|
|
98
|
+
await sm.create('dup-task', state);
|
|
99
|
+
await expect(sm.create('dup-task', state)).rejects.toMatchObject({
|
|
100
|
+
code: 'task_exists',
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('throws task_not_found when reading a non-existent task', async () => {
|
|
105
|
+
await expect(sm.read('ghost-task')).rejects.toMatchObject({
|
|
106
|
+
code: 'task_not_found',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('StateManager — atomic write', () => {
|
|
112
|
+
let stateDir: string;
|
|
113
|
+
let sm: StateManager;
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
stateDir = makeTmpDir();
|
|
117
|
+
sm = new StateManager(stateDir, logger);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('atomic write: original file is intact if tmp exists but rename never happened', async () => {
|
|
125
|
+
const state = makeInitialState('atomic-task');
|
|
126
|
+
await sm.create('atomic-task', state);
|
|
127
|
+
|
|
128
|
+
// Simulate a crash: leave a .tmp file behind without renaming
|
|
129
|
+
const filePath = path.join(stateDir, 'atomic-task.json');
|
|
130
|
+
const tmpPath = `${filePath}.tmp`;
|
|
131
|
+
fs.writeFileSync(tmpPath, '{"broken":true}', 'utf-8');
|
|
132
|
+
|
|
133
|
+
// Original must still be intact
|
|
134
|
+
const loaded = await sm.read('atomic-task');
|
|
135
|
+
expect(loaded.task_id).toBe('atomic-task');
|
|
136
|
+
expect(fs.existsSync(tmpPath)).toBe(true); // tmp still there (we didn't clean it)
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('StateManager — stale lock', () => {
|
|
141
|
+
let stateDir: string;
|
|
142
|
+
let sm: StateManager;
|
|
143
|
+
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
stateDir = makeTmpDir();
|
|
146
|
+
sm = new StateManager(stateDir, logger);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('write succeeds when a stale lock file exists (mtime backdated 60s)', async () => {
|
|
154
|
+
const state = makeInitialState('stale-task');
|
|
155
|
+
await sm.create('stale-task', state);
|
|
156
|
+
|
|
157
|
+
const filePath = path.join(stateDir, 'stale-task.json');
|
|
158
|
+
const lockPath = `${filePath}.lock`;
|
|
159
|
+
|
|
160
|
+
// Manually create a lock directory and backdate its mtime by 60s
|
|
161
|
+
fs.mkdirSync(lockPath, { recursive: true });
|
|
162
|
+
const sixtySecondsAgo = new Date(Date.now() - 60_000);
|
|
163
|
+
fs.utimesSync(lockPath, sixtySecondsAgo, sixtySecondsAgo);
|
|
164
|
+
|
|
165
|
+
// Write should succeed — proper-lockfile sees the lock as stale and breaks it
|
|
166
|
+
state.meta.status = 'planning';
|
|
167
|
+
await expect(sm.write('stale-task', state)).resolves.toBeUndefined();
|
|
168
|
+
const loaded = await sm.read('stale-task');
|
|
169
|
+
expect(loaded.meta.status).toBe('planning');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('StateManager — transitions', () => {
|
|
174
|
+
let stateDir: string;
|
|
175
|
+
let sm: StateManager;
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
stateDir = makeTmpDir();
|
|
179
|
+
sm = new StateManager(stateDir, logger);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('valid transition: init → planning → pending_review', async () => {
|
|
187
|
+
await sm.create('fsm-task', makeInitialState('fsm-task'));
|
|
188
|
+
|
|
189
|
+
let state = await sm.transition('fsm-task', 'planning');
|
|
190
|
+
expect(state.meta.status).toBe('planning');
|
|
191
|
+
|
|
192
|
+
state = await sm.transition('fsm-task', 'pending_review');
|
|
193
|
+
expect(state.meta.status).toBe('pending_review');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('invalid transition: merged → planning throws invalid_transition', async () => {
|
|
197
|
+
const initial = makeInitialState('merged-task');
|
|
198
|
+
initial.meta.status = 'merged';
|
|
199
|
+
await sm.create('merged-task', initial);
|
|
200
|
+
|
|
201
|
+
await expect(sm.transition('merged-task', 'planning')).rejects.toMatchObject({
|
|
202
|
+
code: 'invalid_transition',
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('transition with version_increment bumps current_version', async () => {
|
|
207
|
+
await sm.create('ver-task', makeInitialState('ver-task'));
|
|
208
|
+
const state = await sm.transition('ver-task', 'planning', { version_increment: true });
|
|
209
|
+
expect(state.current_version).toBe(2);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('transition with history_entry appends to history array', async () => {
|
|
213
|
+
await sm.create('hist-task', makeInitialState('hist-task'));
|
|
214
|
+
const entry = makeHistoryEntry();
|
|
215
|
+
const state = await sm.transition('hist-task', 'planning', { history_entry: entry });
|
|
216
|
+
expect(state.history).toHaveLength(1);
|
|
217
|
+
expect(state.history[0]!.step).toBe('planning');
|
|
218
|
+
expect(state.history[0]!.tokens_source).toBe('metered');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('transition with review_result attaches to last history entry', async () => {
|
|
222
|
+
await sm.create('rev-task', makeInitialState('rev-task'));
|
|
223
|
+
// First add a history entry
|
|
224
|
+
await sm.transition('rev-task', 'planning', { history_entry: makeHistoryEntry() });
|
|
225
|
+
// Now add review result (simulate reviewing → refinement)
|
|
226
|
+
await sm.transition('rev-task', 'pending_review');
|
|
227
|
+
await sm.transition('rev-task', 'reviewing');
|
|
228
|
+
const review: ReviewResult = {
|
|
229
|
+
score: 0.85,
|
|
230
|
+
mode: 'supervised',
|
|
231
|
+
issues: [],
|
|
232
|
+
summary: 'Looks good overall, minor issues found.',
|
|
233
|
+
};
|
|
234
|
+
const state = await sm.transition('rev-task', 'refinement', { review_result: review });
|
|
235
|
+
expect(state.history[0]!.review).toMatchObject({ score: 0.85 });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('transition with approval_reason sets meta.approval_reason', async () => {
|
|
239
|
+
await sm.create('appr-task', makeInitialState('appr-task'));
|
|
240
|
+
await sm.transition('appr-task', 'planning');
|
|
241
|
+
await sm.transition('appr-task', 'pending_review');
|
|
242
|
+
await sm.transition('appr-task', 'reviewing');
|
|
243
|
+
const state = await sm.transition('appr-task', 'approved', {
|
|
244
|
+
approval_reason: 'score_threshold',
|
|
245
|
+
});
|
|
246
|
+
expect(state.meta.approval_reason).toBe('score_threshold');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('valid recovery transitions: stalled → planning, failed → planning, merge_conflict → approved', async () => {
|
|
250
|
+
// stalled → planning
|
|
251
|
+
const s1 = makeInitialState('stalled-task');
|
|
252
|
+
s1.meta.status = 'stalled';
|
|
253
|
+
await sm.create('stalled-task', s1);
|
|
254
|
+
const r1 = await sm.transition('stalled-task', 'planning');
|
|
255
|
+
expect(r1.meta.status).toBe('planning');
|
|
256
|
+
|
|
257
|
+
// failed → planning
|
|
258
|
+
const s2 = makeInitialState('failed-task');
|
|
259
|
+
s2.meta.status = 'failed';
|
|
260
|
+
await sm.create('failed-task', s2);
|
|
261
|
+
const r2 = await sm.transition('failed-task', 'planning');
|
|
262
|
+
expect(r2.meta.status).toBe('planning');
|
|
263
|
+
|
|
264
|
+
// merge_conflict → approved
|
|
265
|
+
const s3 = makeInitialState('conflict-task');
|
|
266
|
+
s3.meta.status = 'merge_conflict';
|
|
267
|
+
await sm.create('conflict-task', s3);
|
|
268
|
+
const r3 = await sm.transition('conflict-task', 'approved');
|
|
269
|
+
expect(r3.meta.status).toBe('approved');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('StateManager — cleanupTempFiles', () => {
|
|
274
|
+
let stateDir: string;
|
|
275
|
+
let sm: StateManager;
|
|
276
|
+
|
|
277
|
+
beforeEach(() => {
|
|
278
|
+
stateDir = makeTmpDir();
|
|
279
|
+
sm = new StateManager(stateDir, logger);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('removes .json.tmp files', () => {
|
|
287
|
+
const tmpFile = path.join(stateDir, 'orphan.json.tmp');
|
|
288
|
+
fs.writeFileSync(tmpFile, '{}', 'utf-8');
|
|
289
|
+
expect(fs.existsSync(tmpFile)).toBe(true);
|
|
290
|
+
sm.cleanupTempFiles(stateDir);
|
|
291
|
+
expect(fs.existsSync(tmpFile)).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('does NOT remove .lock files (proper-lockfile owns those)', () => {
|
|
295
|
+
const lockDir = path.join(stateDir, 'some-task.json.lock');
|
|
296
|
+
fs.mkdirSync(lockDir);
|
|
297
|
+
sm.cleanupTempFiles(stateDir);
|
|
298
|
+
expect(fs.existsSync(lockDir)).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('StateManager — schema migration', () => {
|
|
303
|
+
let stateDir: string;
|
|
304
|
+
let sm: StateManager;
|
|
305
|
+
|
|
306
|
+
beforeEach(() => {
|
|
307
|
+
stateDir = makeTmpDir();
|
|
308
|
+
sm = new StateManager(stateDir, logger);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
afterEach(() => {
|
|
312
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('reads state file missing schema_version and sets it to 1', async () => {
|
|
316
|
+
// Write raw JSON without schema_version (simulates pre-v1 state)
|
|
317
|
+
const now = new Date().toISOString();
|
|
318
|
+
const rawState = {
|
|
319
|
+
// schema_version deliberately omitted
|
|
320
|
+
task_id: 'legacy-task',
|
|
321
|
+
current_version: 1,
|
|
322
|
+
meta: { status: 'init', created_at: now, updated_at: now },
|
|
323
|
+
orchestration: { planner_bin: 'claude', reviewer_bin: 'codex' },
|
|
324
|
+
shadow_branch: { branch: 'nomos/legacy-task', worktree: '/tmp/x', base_commit: 'abc', status: 'active' },
|
|
325
|
+
context: { files: [], rules: [], rules_hash: 'sha256:0' },
|
|
326
|
+
budget: { tokens_used: 0, estimated_cost_usd: 0 },
|
|
327
|
+
history: [],
|
|
328
|
+
};
|
|
329
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
330
|
+
fs.writeFileSync(path.join(stateDir, 'legacy-task.json'), JSON.stringify(rawState), 'utf-8');
|
|
331
|
+
|
|
332
|
+
const state = await sm.read('legacy-task');
|
|
333
|
+
expect(state.schema_version).toBe(1);
|
|
334
|
+
expect(state.task_id).toBe('legacy-task');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('StateManager — listTasks', () => {
|
|
339
|
+
let stateDir: string;
|
|
340
|
+
let sm: StateManager;
|
|
341
|
+
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
stateDir = makeTmpDir();
|
|
344
|
+
sm = new StateManager(stateDir, logger);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
afterEach(() => {
|
|
348
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('lists all valid task states and skips .tmp files', async () => {
|
|
352
|
+
await sm.create('list-task-a', makeInitialState('list-task-a'));
|
|
353
|
+
await sm.create('list-task-b', makeInitialState('list-task-b'));
|
|
354
|
+
// Orphan tmp should be ignored
|
|
355
|
+
fs.writeFileSync(path.join(stateDir, 'orphan.json.tmp'), '{}', 'utf-8');
|
|
356
|
+
|
|
357
|
+
const tasks = await sm.listTasks();
|
|
358
|
+
expect(tasks).toHaveLength(2);
|
|
359
|
+
const ids = tasks.map((t) => t.task_id).sort();
|
|
360
|
+
expect(ids).toEqual(['list-task-a', 'list-task-b']);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as os from 'node:os';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import type { Logger } from 'winston';
|
|
6
|
+
import { AuthManager } from '../manager.js';
|
|
7
|
+
import { NomosError } from '../../errors.js';
|
|
8
|
+
import type { AuthCredentials, NomosConfig } from '../../../types/index.js';
|
|
9
|
+
|
|
10
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeLogger(): Logger {
|
|
13
|
+
return {
|
|
14
|
+
info: vi.fn(),
|
|
15
|
+
warn: vi.fn(),
|
|
16
|
+
error: vi.fn(),
|
|
17
|
+
debug: vi.fn(),
|
|
18
|
+
} as unknown as Logger;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeConfig(credentialsPath: string): NomosConfig['auth'] {
|
|
22
|
+
return {
|
|
23
|
+
client_id: 'test-client-id',
|
|
24
|
+
credentials_path: credentialsPath,
|
|
25
|
+
redirect_port: 3000,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeCredentials(overrides: Partial<AuthCredentials> = {}): AuthCredentials {
|
|
30
|
+
return {
|
|
31
|
+
access_token: 'test-access-token',
|
|
32
|
+
refresh_token: 'test-refresh-token',
|
|
33
|
+
expiry_date: Date.now() + 3600_000,
|
|
34
|
+
token_type: 'Bearer',
|
|
35
|
+
scope: 'https://www.googleapis.com/auth/generative-language.retriever',
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe('AuthManager', () => {
|
|
43
|
+
let tmpDir: string;
|
|
44
|
+
let credentialsPath: string;
|
|
45
|
+
let manager: AuthManager;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nomos-auth-test-'));
|
|
49
|
+
credentialsPath = path.join(tmpDir, '.nomos', 'credentials.json');
|
|
50
|
+
manager = new AuthManager(makeConfig(credentialsPath), makeLogger());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─── saveCredentials ───────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('saveCredentials', () => {
|
|
60
|
+
it('writes JSON to the credentials path', async () => {
|
|
61
|
+
const creds = makeCredentials();
|
|
62
|
+
await manager.saveCredentials(creds);
|
|
63
|
+
|
|
64
|
+
const raw = await fs.readFile(credentialsPath, 'utf8');
|
|
65
|
+
const parsed = JSON.parse(raw) as AuthCredentials;
|
|
66
|
+
expect(parsed.access_token).toBe(creds.access_token);
|
|
67
|
+
expect(parsed.refresh_token).toBe(creds.refresh_token);
|
|
68
|
+
expect(parsed.expiry_date).toBe(creds.expiry_date);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('creates parent directories if they do not exist', async () => {
|
|
72
|
+
const nestedPath = path.join(tmpDir, 'a', 'b', 'c', 'credentials.json');
|
|
73
|
+
const nestedManager = new AuthManager(makeConfig(nestedPath), makeLogger());
|
|
74
|
+
await nestedManager.saveCredentials(makeCredentials());
|
|
75
|
+
|
|
76
|
+
const stat = await fs.stat(nestedPath);
|
|
77
|
+
expect(stat.isFile()).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('sets file permissions to 0600', async () => {
|
|
81
|
+
await manager.saveCredentials(makeCredentials());
|
|
82
|
+
const stat = await fs.stat(credentialsPath);
|
|
83
|
+
// eslint-disable-next-line no-bitwise
|
|
84
|
+
expect(stat.mode & 0o777).toBe(0o600);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── loadCredentials ───────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('loadCredentials', () => {
|
|
91
|
+
it('returns parsed credentials when file exists', async () => {
|
|
92
|
+
const creds = makeCredentials();
|
|
93
|
+
await manager.saveCredentials(creds);
|
|
94
|
+
|
|
95
|
+
const loaded = manager.loadCredentials();
|
|
96
|
+
expect(loaded).not.toBeNull();
|
|
97
|
+
expect(loaded!.access_token).toBe(creds.access_token);
|
|
98
|
+
expect(loaded!.refresh_token).toBe(creds.refresh_token);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns null when the file does not exist', () => {
|
|
102
|
+
const result = manager.loadCredentials();
|
|
103
|
+
expect(result).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns null on invalid JSON', async () => {
|
|
107
|
+
await fs.mkdir(path.dirname(credentialsPath), { recursive: true });
|
|
108
|
+
await fs.writeFile(credentialsPath, 'not valid json', 'utf8');
|
|
109
|
+
|
|
110
|
+
const result = manager.loadCredentials();
|
|
111
|
+
expect(result).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns null when JSON is missing required fields', async () => {
|
|
115
|
+
await fs.mkdir(path.dirname(credentialsPath), { recursive: true });
|
|
116
|
+
await fs.writeFile(credentialsPath, JSON.stringify({ access_token: 'only-one-field' }), 'utf8');
|
|
117
|
+
|
|
118
|
+
const result = manager.loadCredentials();
|
|
119
|
+
expect(result).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── isLoggedIn ────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('isLoggedIn', () => {
|
|
126
|
+
it('returns true when credentials file has refresh_token', async () => {
|
|
127
|
+
await manager.saveCredentials(makeCredentials({ refresh_token: 'valid-refresh-token' }));
|
|
128
|
+
expect(manager.isLoggedIn()).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns false when credentials file is missing', () => {
|
|
132
|
+
expect(manager.isLoggedIn()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('returns false when refresh_token is empty', async () => {
|
|
136
|
+
await manager.saveCredentials(makeCredentials({ refresh_token: '' }));
|
|
137
|
+
expect(manager.isLoggedIn()).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─── clearCredentials ──────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
describe('clearCredentials', () => {
|
|
144
|
+
it('deletes the credentials file', async () => {
|
|
145
|
+
await manager.saveCredentials(makeCredentials());
|
|
146
|
+
await manager.clearCredentials();
|
|
147
|
+
|
|
148
|
+
await expect(fs.stat(credentialsPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not throw if the credentials file is missing', async () => {
|
|
152
|
+
await expect(manager.clearCredentials()).resolves.toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── getAccessToken ────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe('getAccessToken', () => {
|
|
159
|
+
it('throws auth_not_logged_in when no credentials exist', async () => {
|
|
160
|
+
await expect(manager.getAccessToken()).rejects.toSatisfy(
|
|
161
|
+
(err: unknown) =>
|
|
162
|
+
err instanceof NomosError && err.code === 'auth_not_logged_in',
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|