@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
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nomos-arc/arc",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Architect — AI Orchestrator CLI for Verification Architecture",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20.0.0"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"arc": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "esbuild src/cli.ts --bundle --platform=node --target=node20 --outfile=dist/cli.js --format=esm --external:node-pty --external:simple-git --external:proper-lockfile --external:winston --external:gray-matter --external:commander --external:@google/generative-ai --external:open --external:google-auth-library --external:fast-glob --external:gitignore-parser --external:web-tree-sitter --external:tree-sitter-wasms --external:@lancedb/lancedb --external:apache-arrow --banner:js='#!/usr/bin/env node'",
|
|
17
|
+
"dev": "tsx src/cli.ts",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest",
|
|
20
|
+
"test:unit": "vitest run --dir src",
|
|
21
|
+
"test:e2e": "vitest run --dir test",
|
|
22
|
+
"lint": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/i-magdy/nomos-arc.git"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [],
|
|
29
|
+
"author": "Ibrahim Magdy",
|
|
30
|
+
"license": "Apache-2.0",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/i-magdy/nomos-arc/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/i-magdy/nomos-arc#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@google/generative-ai": "^0.24.1",
|
|
37
|
+
"@lancedb/lancedb": "0.27.2",
|
|
38
|
+
"apache-arrow": "18.1.0",
|
|
39
|
+
"commander": "^14.0.3",
|
|
40
|
+
"cytoscape": "^3.33.1",
|
|
41
|
+
"fast-glob": "^3.3.3",
|
|
42
|
+
"gitignore-parser": "^0.0.2",
|
|
43
|
+
"google-auth-library": "^10.6.2",
|
|
44
|
+
"gray-matter": "^4.0.3",
|
|
45
|
+
"node-pty": "^1.1.0",
|
|
46
|
+
"open": "^11.0.0",
|
|
47
|
+
"p-limit": "^7.3.0",
|
|
48
|
+
"proper-lockfile": "^4.1.2",
|
|
49
|
+
"simple-git": "^3.33.0",
|
|
50
|
+
"tree-sitter-wasms": "^0.1.13",
|
|
51
|
+
"web-tree-sitter": "^0.24.7",
|
|
52
|
+
"winston": "^3.19.0",
|
|
53
|
+
"zod": "^4.3.6"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^25.5.2",
|
|
57
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
58
|
+
"esbuild": "^0.27.7",
|
|
59
|
+
"tsx": "^4.21.0",
|
|
60
|
+
"typescript": "^6.0.2",
|
|
61
|
+
"vitest": "^4.1.2"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
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 { simpleGit } from 'simple-git';
|
|
6
|
+
import { GitAdapter } from '../git.js';
|
|
7
|
+
import { NomosError } from '../../core/errors.js';
|
|
8
|
+
import { getDefaultConfig } from '../../core/config.js';
|
|
9
|
+
import { createLogger } from '../../core/logger.js';
|
|
10
|
+
|
|
11
|
+
// Minimal logger for tests
|
|
12
|
+
const logger = createLogger('error');
|
|
13
|
+
|
|
14
|
+
function makeConfig(overrides: Partial<{ worktree_base: string }> = {}) {
|
|
15
|
+
const cfg = getDefaultConfig();
|
|
16
|
+
if (overrides.worktree_base) {
|
|
17
|
+
cfg.execution.worktree_base = overrides.worktree_base;
|
|
18
|
+
}
|
|
19
|
+
return cfg;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function initRepo(dir: string): Promise<void> {
|
|
23
|
+
const git = simpleGit(dir);
|
|
24
|
+
await git.init(['-b', 'main']);
|
|
25
|
+
await git.addConfig('user.email', 'test@test.com');
|
|
26
|
+
await git.addConfig('user.name', 'Test User');
|
|
27
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# test');
|
|
28
|
+
await git.add('README.md');
|
|
29
|
+
await git.commit('Initial commit');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function cleanupWorktrees(repoDir: string): Promise<void> {
|
|
33
|
+
try {
|
|
34
|
+
const git = simpleGit(repoDir);
|
|
35
|
+
const list = await git.raw(['worktree', 'list', '--porcelain']);
|
|
36
|
+
const worktrees = list
|
|
37
|
+
.split('\n\n')
|
|
38
|
+
.slice(1) // skip the main worktree
|
|
39
|
+
.map(block => {
|
|
40
|
+
const match = block.match(/^worktree (.+)/m);
|
|
41
|
+
return match ? match[1].trim() : null;
|
|
42
|
+
})
|
|
43
|
+
.filter(Boolean) as string[];
|
|
44
|
+
|
|
45
|
+
for (const wt of worktrees) {
|
|
46
|
+
await git.raw(['worktree', 'remove', wt, '--force']).catch(() => {});
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// best effort
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('GitAdapter', () => {
|
|
54
|
+
let tmpDir: string;
|
|
55
|
+
let worktreeBase: string;
|
|
56
|
+
let adapter: GitAdapter;
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomos-git-test-'));
|
|
60
|
+
worktreeBase = fs.mkdtempSync(path.join(os.tmpdir(), 'nomos-wt-base-'));
|
|
61
|
+
await initRepo(tmpDir);
|
|
62
|
+
const config = makeConfig({ worktree_base: worktreeBase });
|
|
63
|
+
adapter = new GitAdapter(tmpDir, config, logger);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await cleanupWorktrees(tmpDir);
|
|
68
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
69
|
+
fs.rmSync(worktreeBase, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── resolveWorktreePath ──────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
it('resolveWorktreePath produces correct path', () => {
|
|
75
|
+
const taskId = 'my-task';
|
|
76
|
+
const expected = path.join(worktreeBase, path.basename(tmpDir), taskId);
|
|
77
|
+
expect(adapter.resolveWorktreePath(taskId)).toBe(expected);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── createWorktree ───────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
it('createWorktree: branch exists, worktree path exists', async () => {
|
|
83
|
+
const { branch, worktreePath, baseCommit } = await adapter.createWorktree('task-create');
|
|
84
|
+
|
|
85
|
+
expect(branch).toContain('task-create');
|
|
86
|
+
expect(fs.existsSync(worktreePath)).toBe(true);
|
|
87
|
+
expect(baseCommit).toMatch(/^[a-f0-9]{40}$/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('createWorktree throws branch_exists when branch already exists', async () => {
|
|
91
|
+
await adapter.createWorktree('task-dup');
|
|
92
|
+
await expect(adapter.createWorktree('task-dup')).rejects.toMatchObject({
|
|
93
|
+
code: 'branch_exists',
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── removeWorktree ───────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
it('removeWorktree: path and branch cleaned up', async () => {
|
|
100
|
+
const { worktreePath } = await adapter.createWorktree('task-remove');
|
|
101
|
+
await adapter.removeWorktree('task-remove', true);
|
|
102
|
+
|
|
103
|
+
expect(fs.existsSync(worktreePath)).toBe(false);
|
|
104
|
+
|
|
105
|
+
const git = simpleGit(tmpDir);
|
|
106
|
+
const branches = await git.branch(['--list', adapter.resolveBranchName('task-remove')]);
|
|
107
|
+
expect(branches.all).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── getDiff ─────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
it('getDiff includes changes from multiple commits (not just HEAD~1)', async () => {
|
|
113
|
+
const { worktreePath, baseCommit } = await adapter.createWorktree('task-diff');
|
|
114
|
+
const wtGit = simpleGit(worktreePath);
|
|
115
|
+
|
|
116
|
+
// First commit in worktree
|
|
117
|
+
fs.writeFileSync(path.join(worktreePath, 'file1.ts'), 'const x = 1;');
|
|
118
|
+
await wtGit.add('file1.ts');
|
|
119
|
+
await wtGit.commit('Add file1');
|
|
120
|
+
|
|
121
|
+
// Second commit in worktree
|
|
122
|
+
fs.writeFileSync(path.join(worktreePath, 'file2.ts'), 'const y = 2;');
|
|
123
|
+
await wtGit.add('file2.ts');
|
|
124
|
+
await wtGit.commit('Add file2');
|
|
125
|
+
|
|
126
|
+
const diff = await adapter.getDiff('task-diff', baseCommit);
|
|
127
|
+
expect(diff).toContain('file1.ts');
|
|
128
|
+
expect(diff).toContain('file2.ts');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('getDiff throws base_commit_unreachable for a fabricated SHA', async () => {
|
|
132
|
+
await adapter.createWorktree('task-bad-sha');
|
|
133
|
+
const fakeSha = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
|
|
134
|
+
await expect(adapter.getDiff('task-bad-sha', fakeSha)).rejects.toMatchObject({
|
|
135
|
+
code: 'base_commit_unreachable',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── commitToShadowBranch ────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
it('commitToShadowBranch copies files and commits them', async () => {
|
|
142
|
+
// Pre-stage the plan file in the main repo so it's tracked (not untracked)
|
|
143
|
+
const planFile = path.join('tasks-management', 'plans', 'task-commit-v1.md');
|
|
144
|
+
fs.mkdirSync(path.join(tmpDir, 'tasks-management', 'plans'), { recursive: true });
|
|
145
|
+
fs.writeFileSync(path.join(tmpDir, planFile), '# Plan v1');
|
|
146
|
+
const mainGit = simpleGit(tmpDir);
|
|
147
|
+
await mainGit.add(planFile);
|
|
148
|
+
await mainGit.commit('track plan in main');
|
|
149
|
+
|
|
150
|
+
// Now create worktree (branched from HEAD which includes the plan file)
|
|
151
|
+
await adapter.createWorktree('task-commit');
|
|
152
|
+
|
|
153
|
+
// Update the plan file in project root (simulate planner output)
|
|
154
|
+
fs.writeFileSync(path.join(tmpDir, planFile), '# Plan v1 updated');
|
|
155
|
+
|
|
156
|
+
await adapter.commitToShadowBranch('task-commit', 'chore: add plan', [planFile]);
|
|
157
|
+
|
|
158
|
+
const worktreePath = adapter.resolveWorktreePath('task-commit');
|
|
159
|
+
const committedContent = fs.readFileSync(path.join(worktreePath, planFile), 'utf8');
|
|
160
|
+
expect(committedContent).toBe('# Plan v1 updated');
|
|
161
|
+
|
|
162
|
+
const wtGit = simpleGit(worktreePath);
|
|
163
|
+
const log = await wtGit.log();
|
|
164
|
+
expect(log.latest?.message).toBe('chore: add plan');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('commitToShadowBranch throws path_traversal for ../../etc/passwd', async () => {
|
|
168
|
+
await adapter.createWorktree('task-traversal');
|
|
169
|
+
await expect(
|
|
170
|
+
adapter.commitToShadowBranch('task-traversal', 'bad', ['../../etc/passwd']),
|
|
171
|
+
).rejects.toMatchObject({ code: 'path_traversal' });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── mergeToMain ─────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
it('mergeToMain performs a clean merge', async () => {
|
|
177
|
+
const { worktreePath } = await adapter.createWorktree('task-merge');
|
|
178
|
+
|
|
179
|
+
// Commit a new file directly in the worktree (no project root pollution)
|
|
180
|
+
fs.writeFileSync(path.join(worktreePath, 'plan.md'), '# Plan');
|
|
181
|
+
const wtGit = simpleGit(worktreePath);
|
|
182
|
+
await wtGit.add('plan.md');
|
|
183
|
+
await wtGit.commit('add plan');
|
|
184
|
+
|
|
185
|
+
const result = await adapter.mergeToMain('task-merge', 1, '[nomos]', 'main');
|
|
186
|
+
expect(result.success).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('mergeToMain with conflict returns success: false and conflict list', async () => {
|
|
190
|
+
// Create conflicting files in both branches
|
|
191
|
+
await adapter.createWorktree('task-conflict');
|
|
192
|
+
const worktreePath = adapter.resolveWorktreePath('task-conflict');
|
|
193
|
+
|
|
194
|
+
const conflictFile = 'conflict.txt';
|
|
195
|
+
|
|
196
|
+
// Write to main branch
|
|
197
|
+
fs.writeFileSync(path.join(tmpDir, conflictFile), 'main content');
|
|
198
|
+
const mainGit = simpleGit(tmpDir);
|
|
199
|
+
await mainGit.add(conflictFile);
|
|
200
|
+
await mainGit.commit('main adds conflict.txt');
|
|
201
|
+
|
|
202
|
+
// Write conflicting content in shadow branch
|
|
203
|
+
fs.writeFileSync(path.join(worktreePath, conflictFile), 'shadow content');
|
|
204
|
+
const wtGit = simpleGit(worktreePath);
|
|
205
|
+
await wtGit.add(conflictFile);
|
|
206
|
+
await wtGit.commit('shadow adds conflict.txt');
|
|
207
|
+
|
|
208
|
+
const result = await adapter.mergeToMain('task-conflict', 1, '[nomos]', 'main');
|
|
209
|
+
expect(result.success).toBe(false);
|
|
210
|
+
expect(result.conflicts).toBeDefined();
|
|
211
|
+
expect(result.conflicts!.length).toBeGreaterThan(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('mergeToMain throws wrong_branch when on a different branch', async () => {
|
|
215
|
+
await adapter.createWorktree('task-wrong-branch');
|
|
216
|
+
const mainGit = simpleGit(tmpDir);
|
|
217
|
+
// Checkout a different branch (not the shadow branch, which is checked out in a worktree)
|
|
218
|
+
await mainGit.checkoutLocalBranch('other-branch');
|
|
219
|
+
|
|
220
|
+
await expect(
|
|
221
|
+
adapter.mergeToMain('task-wrong-branch', 1, '[nomos]', 'main'),
|
|
222
|
+
).rejects.toMatchObject({ code: 'wrong_branch' });
|
|
223
|
+
|
|
224
|
+
// Restore main
|
|
225
|
+
await mainGit.checkout('main');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('mergeToMain throws dirty_working_tree when changes are uncommitted', async () => {
|
|
229
|
+
await adapter.createWorktree('task-dirty');
|
|
230
|
+
// Create an uncommitted change in project root
|
|
231
|
+
fs.writeFileSync(path.join(tmpDir, 'dirty.txt'), 'dirty');
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
adapter.mergeToMain('task-dirty', 1, '[nomos]', 'main'),
|
|
235
|
+
).rejects.toMatchObject({ code: 'dirty_working_tree' });
|
|
236
|
+
|
|
237
|
+
// Clean up
|
|
238
|
+
fs.unlinkSync(path.join(tmpDir, 'dirty.txt'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── recoverWorktree ─────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
it('recoverWorktree recreates worktree from existing branch', async () => {
|
|
244
|
+
const { branch, worktreePath } = await adapter.createWorktree('task-recover');
|
|
245
|
+
|
|
246
|
+
// Remove the worktree dir but keep the branch
|
|
247
|
+
const git = simpleGit(tmpDir);
|
|
248
|
+
await git.raw(['worktree', 'remove', worktreePath, '--force']);
|
|
249
|
+
expect(fs.existsSync(worktreePath)).toBe(false);
|
|
250
|
+
|
|
251
|
+
// Recover
|
|
252
|
+
const recovered = await adapter.recoverWorktree('task-recover', branch);
|
|
253
|
+
expect(fs.existsSync(recovered)).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('recoverWorktree throws worktree_unrecoverable when branch is also gone', async () => {
|
|
257
|
+
await expect(
|
|
258
|
+
adapter.recoverWorktree('task-gone', 'nomos/task-gone'),
|
|
259
|
+
).rejects.toMatchObject({ code: 'worktree_unrecoverable' });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── grep ────────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
it('grep returns relative paths matching a pattern', async () => {
|
|
265
|
+
// Create a file in the repo containing a searchable pattern
|
|
266
|
+
fs.writeFileSync(path.join(tmpDir, 'search-me.ts'), 'export const SECRET_PATTERN = true;');
|
|
267
|
+
const git = simpleGit(tmpDir);
|
|
268
|
+
await git.add('search-me.ts');
|
|
269
|
+
await git.commit('Add searchable file');
|
|
270
|
+
|
|
271
|
+
const results = await adapter.grep('SECRET_PATTERN', tmpDir);
|
|
272
|
+
expect(results).toContain('search-me.ts');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('grep returns empty array when no matches found', async () => {
|
|
276
|
+
const results = await adapter.grep('NONEXISTENT_XYZ_12345', tmpDir);
|
|
277
|
+
expect(results).toEqual([]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('grep returns empty array on timeout', async () => {
|
|
281
|
+
// Use 1ms timeout — will almost certainly time out
|
|
282
|
+
const results = await adapter.grep('.*', tmpDir, 1);
|
|
283
|
+
expect(Array.isArray(results)).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── isGitRepo / getCurrentCommit ────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
it('isGitRepo returns true for a git repo', async () => {
|
|
289
|
+
expect(await adapter.isGitRepo()).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('getCurrentCommit returns a full SHA', async () => {
|
|
293
|
+
const sha = await adapter.getCurrentCommit();
|
|
294
|
+
expect(sha).toMatch(/^[a-f0-9]{40}$/);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as process from 'process';
|
|
3
|
+
import { StdioAdapter } from '../stdio.js';
|
|
4
|
+
import { createLogger } from '../../core/logger.js';
|
|
5
|
+
|
|
6
|
+
const logger = createLogger('error');
|
|
7
|
+
const adapter = new StdioAdapter(logger);
|
|
8
|
+
|
|
9
|
+
const BASE_OPTIONS = {
|
|
10
|
+
cwd: process.cwd(),
|
|
11
|
+
env: process.env as Record<string, string>,
|
|
12
|
+
heartbeat_timeout_ms: 10000,
|
|
13
|
+
total_timeout_ms: 10000,
|
|
14
|
+
max_output_bytes: 1048576,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const NODE = process.execPath;
|
|
18
|
+
|
|
19
|
+
describe('StdioAdapter', () => {
|
|
20
|
+
it('captures stdout output from a subprocess', async () => {
|
|
21
|
+
const result = await adapter.execute({
|
|
22
|
+
...BASE_OPTIONS,
|
|
23
|
+
cmd: NODE,
|
|
24
|
+
args: ['-e', "process.stdout.write('hello world')"],
|
|
25
|
+
stdinData: '',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.exitCode).toBe(0);
|
|
29
|
+
expect(result.rawOutput).toContain('hello world');
|
|
30
|
+
expect(result.killed).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('pipes stdinData to the subprocess and captures output', async () => {
|
|
34
|
+
const result = await adapter.execute({
|
|
35
|
+
...BASE_OPTIONS,
|
|
36
|
+
cmd: NODE,
|
|
37
|
+
args: ['-e', 'process.stdin.pipe(process.stdout)'],
|
|
38
|
+
stdinData: 'piped input content',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.exitCode).toBe(0);
|
|
42
|
+
expect(result.rawOutput).toContain('piped input content');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('kills a long-running process on total_timeout_ms', async () => {
|
|
46
|
+
const result = await adapter.execute({
|
|
47
|
+
...BASE_OPTIONS,
|
|
48
|
+
cmd: NODE,
|
|
49
|
+
args: ['-e', 'setTimeout(() => {}, 60000)'],
|
|
50
|
+
stdinData: '',
|
|
51
|
+
total_timeout_ms: 500,
|
|
52
|
+
heartbeat_timeout_ms: 10000,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(result.killed).toBe(true);
|
|
56
|
+
expect(result.killReason).toBe('total_timeout');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('propagates non-zero exit codes', async () => {
|
|
60
|
+
const result = await adapter.execute({
|
|
61
|
+
...BASE_OPTIONS,
|
|
62
|
+
cmd: NODE,
|
|
63
|
+
args: ['-e', 'process.exit(42)'],
|
|
64
|
+
stdinData: '',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(result.exitCode).toBe(42);
|
|
68
|
+
expect(result.killed).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { simpleGit, type SimpleGit } from 'simple-git';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import type { Logger } from 'winston';
|
|
6
|
+
import { NomosError } from '../core/errors.js';
|
|
7
|
+
import type { NomosConfig } from '../types/index.js';
|
|
8
|
+
|
|
9
|
+
export class GitAdapter {
|
|
10
|
+
private readonly git: SimpleGit;
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly projectRoot: string,
|
|
14
|
+
private readonly config: NomosConfig,
|
|
15
|
+
private readonly logger: Logger,
|
|
16
|
+
) {
|
|
17
|
+
this.git = simpleGit(projectRoot);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
resolveWorktreePath(taskId: string): string {
|
|
21
|
+
const projectName = path.basename(this.projectRoot);
|
|
22
|
+
const worktreeBase =
|
|
23
|
+
process.platform === 'win32'
|
|
24
|
+
? path.join(process.env.LOCALAPPDATA ?? os.tmpdir(), 'nomos-worktrees')
|
|
25
|
+
: this.config.execution.worktree_base;
|
|
26
|
+
return path.join(worktreeBase, projectName, taskId);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
resolveBranchName(taskId: string): string {
|
|
30
|
+
return `${this.config.execution.shadow_branch_prefix}${taskId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async createWorktree(
|
|
34
|
+
taskId: string,
|
|
35
|
+
): Promise<{ branch: string; worktreePath: string; baseCommit: string }> {
|
|
36
|
+
const branchName = this.resolveBranchName(taskId);
|
|
37
|
+
const worktreePath = this.resolveWorktreePath(taskId);
|
|
38
|
+
|
|
39
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
40
|
+
|
|
41
|
+
const existing = await this.git.branch(['--list', branchName]);
|
|
42
|
+
if (existing.all.length > 0) {
|
|
43
|
+
throw new NomosError(
|
|
44
|
+
'branch_exists',
|
|
45
|
+
`Branch "${branchName}" already exists. Run: arc discard ${taskId}`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await this.git.raw(['worktree', 'add', worktreePath, '-b', branchName]);
|
|
50
|
+
|
|
51
|
+
const baseCommit = (await this.git.revparse(['HEAD'])).trim();
|
|
52
|
+
|
|
53
|
+
this.logger.debug(`Created worktree at ${worktreePath} on branch ${branchName}`);
|
|
54
|
+
|
|
55
|
+
return { branch: branchName, worktreePath, baseCommit };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async recoverWorktree(taskId: string, existingBranch: string): Promise<string> {
|
|
59
|
+
const worktreePath = this.resolveWorktreePath(taskId);
|
|
60
|
+
|
|
61
|
+
const existing = await this.git.branch(['--list', existingBranch]);
|
|
62
|
+
if (existing.all.length === 0) {
|
|
63
|
+
throw new NomosError(
|
|
64
|
+
'worktree_unrecoverable',
|
|
65
|
+
`Cannot recover worktree for "${taskId}": branch "${existingBranch}" no longer exists.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await this.git.raw(['worktree', 'add', worktreePath, existingBranch]);
|
|
70
|
+
|
|
71
|
+
this.logger.debug(`Recovered worktree at ${worktreePath} from branch ${existingBranch}`);
|
|
72
|
+
|
|
73
|
+
return worktreePath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async removeWorktree(taskId: string, force: boolean = false): Promise<void> {
|
|
77
|
+
const worktreePath = this.resolveWorktreePath(taskId);
|
|
78
|
+
const branchName = this.resolveBranchName(taskId);
|
|
79
|
+
|
|
80
|
+
await this.git.raw(['worktree', 'remove', worktreePath, '--force']);
|
|
81
|
+
await this.git.branch([force ? '-D' : '-d', branchName]);
|
|
82
|
+
|
|
83
|
+
this.logger.debug(`Removed worktree at ${worktreePath} and branch ${branchName}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
worktreeExists(taskId: string): boolean {
|
|
87
|
+
return fs.existsSync(this.resolveWorktreePath(taskId));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async grep(pattern: string, cwd: string, timeoutMs: number = 5000): Promise<string[]> {
|
|
91
|
+
const searchGit = simpleGit(cwd);
|
|
92
|
+
try {
|
|
93
|
+
const result = await Promise.race([
|
|
94
|
+
searchGit.raw(['grep', '-l', '-E', pattern]),
|
|
95
|
+
new Promise<never>((_, reject) =>
|
|
96
|
+
setTimeout(() => reject(new Error('grep timeout')), timeoutMs),
|
|
97
|
+
),
|
|
98
|
+
]);
|
|
99
|
+
return result.trim().split('\n').filter(Boolean);
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async getDiff(taskId: string, baseCommit: string): Promise<string> {
|
|
106
|
+
const worktreePath = this.resolveWorktreePath(taskId);
|
|
107
|
+
const worktreeGit = simpleGit(worktreePath);
|
|
108
|
+
|
|
109
|
+
// RT2-2.1 fix: verify baseCommit is reachable before diff
|
|
110
|
+
try {
|
|
111
|
+
await worktreeGit.raw(['cat-file', '-t', baseCommit]);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new NomosError(
|
|
114
|
+
'base_commit_unreachable',
|
|
115
|
+
`Base commit ${baseCommit.slice(0, 8)} is no longer reachable (likely due to rebase or force-push). ` +
|
|
116
|
+
`Run: arc discard ${taskId} && arc init ${taskId} to reinitialize.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const diff = await worktreeGit.diff([`${baseCommit}..HEAD`, '--', '.']);
|
|
121
|
+
return diff;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async commitToShadowBranch(
|
|
125
|
+
taskId: string,
|
|
126
|
+
message: string,
|
|
127
|
+
files: string[],
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const worktreePath = this.resolveWorktreePath(taskId);
|
|
130
|
+
const worktreeGit = simpleGit(worktreePath);
|
|
131
|
+
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const src = path.resolve(this.projectRoot, file);
|
|
134
|
+
const dst = path.resolve(worktreePath, file);
|
|
135
|
+
|
|
136
|
+
// W6: Path traversal defense
|
|
137
|
+
if (
|
|
138
|
+
!dst.startsWith(path.resolve(worktreePath) + path.sep) &&
|
|
139
|
+
dst !== path.resolve(worktreePath)
|
|
140
|
+
) {
|
|
141
|
+
throw new NomosError(
|
|
142
|
+
'path_traversal',
|
|
143
|
+
`File path "${file}" resolves outside worktree`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (
|
|
147
|
+
!src.startsWith(path.resolve(this.projectRoot) + path.sep) &&
|
|
148
|
+
src !== path.resolve(this.projectRoot)
|
|
149
|
+
) {
|
|
150
|
+
throw new NomosError(
|
|
151
|
+
'path_traversal',
|
|
152
|
+
`File path "${file}" resolves outside project root`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
157
|
+
fs.copyFileSync(src, dst);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await worktreeGit.add(files);
|
|
161
|
+
|
|
162
|
+
// M-3 fix: Check git identity before commit to avoid cryptic "Author identity unknown"
|
|
163
|
+
const email = await worktreeGit.raw(['config', '--get', 'user.email']).catch(() => '');
|
|
164
|
+
if (!email.trim()) {
|
|
165
|
+
throw new NomosError(
|
|
166
|
+
'config_invalid',
|
|
167
|
+
'Git user identity not configured in the worktree or globally. Run:\n' +
|
|
168
|
+
' git config --global user.email "you@example.com"\n' +
|
|
169
|
+
' git config --global user.name "Your Name"',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await worktreeGit.commit(message);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async mergeToMain(
|
|
177
|
+
taskId: string,
|
|
178
|
+
version: number,
|
|
179
|
+
commitPrefix: string,
|
|
180
|
+
targetBranch: string = 'main',
|
|
181
|
+
): Promise<{ success: boolean; conflicts?: string[] }> {
|
|
182
|
+
// W3 fix: Target branch verification
|
|
183
|
+
const currentBranch = (await this.git.branch()).current;
|
|
184
|
+
if (currentBranch !== targetBranch) {
|
|
185
|
+
throw new NomosError(
|
|
186
|
+
'wrong_branch',
|
|
187
|
+
`Cannot apply: expected to be on "${targetBranch}" but currently on "${currentBranch}". ` +
|
|
188
|
+
`Switch first: git checkout ${targetBranch}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// W7 fix: Dirty working tree check
|
|
193
|
+
const status = await this.git.status();
|
|
194
|
+
if (!status.isClean()) {
|
|
195
|
+
throw new NomosError(
|
|
196
|
+
'dirty_working_tree',
|
|
197
|
+
'Cannot apply: working tree has uncommitted changes. Commit or stash them first.',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const branchName = this.resolveBranchName(taskId);
|
|
202
|
+
const mergeMessage = `${commitPrefix} apply(${taskId}): merge approved plan v${version}`;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await this.git.merge([branchName, '--no-ff', '-m', mergeMessage]);
|
|
206
|
+
return { success: true };
|
|
207
|
+
} catch {
|
|
208
|
+
const mergeStatus = await this.git.status();
|
|
209
|
+
const conflicts = mergeStatus.conflicted;
|
|
210
|
+
await this.git.merge(['--abort']);
|
|
211
|
+
return { success: false, conflicts };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async isGitRepo(): Promise<boolean> {
|
|
216
|
+
return this.git.checkIsRepo();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async getCurrentCommit(): Promise<string> {
|
|
220
|
+
return (await this.git.revparse(['HEAD'])).trim();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async raw(args: string[]): Promise<string> {
|
|
224
|
+
return this.git.raw(args);
|
|
225
|
+
}
|
|
226
|
+
}
|