codehere 0.8.0 → 1.0.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/README.md +37 -184
- package/dist/application/use-cases/check-claimed-tests.js +81 -0
- package/dist/application/use-cases/detect-test-weakening.js +237 -0
- package/dist/application/use-cases/rerun-tests.js +329 -0
- package/dist/index.js +74 -1004
- package/dist/infrastructure/orchestration/artifacts-extractor.js +2 -265
- package/dist/presentation/cli/commands/check-command.js +251 -0
- package/dist/presentation/cli/commands/help-command.js +22 -286
- package/dist/ui.js +19 -438
- package/dist/utils/version.js +0 -12
- package/package.json +18 -27
- package/dist/application/use-cases/domain-trust.js +0 -110
- package/dist/application/use-cases/generate-final-report.js +0 -134
- package/dist/application/use-cases/generate-narration.js +0 -128
- package/dist/application/use-cases/generate-thread-title.js +0 -178
- package/dist/application/use-cases/narrator-prompt-template.js +0 -158
- package/dist/application/use-cases/thread-export.js +0 -155
- package/dist/application/use-cases/verify-claim.js +0 -274
- package/dist/application/use-cases/write-pass.js +0 -277
- package/dist/domain/entities/task.js +0 -61
- package/dist/domain/services/codebase-detector.js +0 -91
- package/dist/error-handler.js +0 -251
- package/dist/infrastructure/completion/bash-completion.js +0 -25
- package/dist/infrastructure/completion/fish-completion.js +0 -22
- package/dist/infrastructure/completion/zsh-completion.js +0 -30
- package/dist/infrastructure/config/config-manager.js +0 -173
- package/dist/infrastructure/config/product-context.js +0 -67
- package/dist/infrastructure/context/adapters/drawio-source.js +0 -157
- package/dist/infrastructure/context/adapters/figma-source.js +0 -163
- package/dist/infrastructure/context/adapters/github-issue-source.js +0 -159
- package/dist/infrastructure/context/adapters/linear-source.js +0 -190
- package/dist/infrastructure/context/context-resolver.js +0 -144
- package/dist/infrastructure/context/context-source.js +0 -114
- package/dist/infrastructure/context/oauth-flow.js +0 -207
- package/dist/infrastructure/context/oauth-store.js +0 -119
- package/dist/infrastructure/context/registry-bootstrap.js +0 -99
- package/dist/infrastructure/context/write-pass-resolver.js +0 -106
- package/dist/infrastructure/cost/budget-config.js +0 -113
- package/dist/infrastructure/cost/cost-router.js +0 -104
- package/dist/infrastructure/cost/spend-aggregator.js +0 -147
- package/dist/infrastructure/hooks/claude-sdk-hook.js +0 -888
- package/dist/infrastructure/hooks/hook-mode.js +0 -71
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +0 -43
- package/dist/infrastructure/hooks/pre-prompt-scan.js +0 -122
- package/dist/infrastructure/hooks/session-analyzer.js +0 -177
- package/dist/infrastructure/observability/execution-tracer.js +0 -227
- package/dist/infrastructure/orchestration/agent-adapter.js +0 -13
- package/dist/infrastructure/orchestration/agent-installer.js +0 -101
- package/dist/infrastructure/orchestration/agents-config-seed.js +0 -56
- package/dist/infrastructure/orchestration/aider-adapter.js +0 -447
- package/dist/infrastructure/orchestration/cassette-recorder.js +0 -120
- package/dist/infrastructure/orchestration/claude-code-adapter.js +0 -478
- package/dist/infrastructure/orchestration/codex-adapter.js +0 -538
- package/dist/infrastructure/orchestration/conductor-adapter.js +0 -377
- package/dist/infrastructure/orchestration/context-handoff.js +0 -144
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +0 -99
- package/dist/infrastructure/orchestration/control/control-surface.js +0 -15
- package/dist/infrastructure/orchestration/failover-classifier.js +0 -151
- package/dist/infrastructure/orchestration/failover-config.js +0 -77
- package/dist/infrastructure/orchestration/failover-policy.js +0 -106
- package/dist/infrastructure/orchestration/gemini-adapter.js +0 -583
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +0 -558
- package/dist/infrastructure/orchestration/graphify-context.js +0 -254
- package/dist/infrastructure/orchestration/memory-injector.js +0 -380
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +0 -233
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +0 -73
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +0 -83
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +0 -37
- package/dist/infrastructure/orchestration/opencode-adapter.js +0 -510
- package/dist/infrastructure/orchestration/progress-aggregator.js +0 -689
- package/dist/infrastructure/orchestration/readiness.js +0 -103
- package/dist/infrastructure/orchestration/session-manager.js +0 -1091
- package/dist/infrastructure/orchestration/session-persistence.js +0 -258
- package/dist/infrastructure/orchestration/thread-key-store.js +0 -127
- package/dist/infrastructure/orchestration/write-pass-store.js +0 -46
- package/dist/infrastructure/protocols/cloud-sync.js +0 -207
- package/dist/infrastructure/protocols/mcp-helpers.js +0 -25
- package/dist/infrastructure/protocols/mcp-server-impl.js +0 -2853
- package/dist/infrastructure/protocols/probe-port.js +0 -36
- package/dist/infrastructure/protocols/thread-phase.js +0 -105
- package/dist/infrastructure/protocols/ui-page-v2.js +0 -4418
- package/dist/infrastructure/protocols/ui-page.js +0 -2377
- package/dist/infrastructure/protocols/verify-page.js +0 -983
- package/dist/infrastructure/security/file-collection.js +0 -118
- package/dist/infrastructure/security/input-validator.js +0 -152
- package/dist/infrastructure/security/permissions-config.js +0 -234
- package/dist/infrastructure/security/trust-scorer.js +0 -657
- package/dist/infrastructure/storage/cross-tool-agents.js +0 -268
- package/dist/infrastructure/storage/embeddings-path.js +0 -37
- package/dist/infrastructure/storage/project-thread-migration.js +0 -237
- package/dist/infrastructure/storage/project-thread-mirror.js +0 -524
- package/dist/infrastructure/storage/project-thread-reader.js +0 -172
- package/dist/infrastructure/storage/project-thread-title-backfill.js +0 -214
- package/dist/infrastructure/storage/task-repository.js +0 -206
- package/dist/monitoring.js +0 -212
- package/dist/presentation/cli/commands/audit-command.js +0 -126
- package/dist/presentation/cli/commands/budget-command.js +0 -79
- package/dist/presentation/cli/commands/config-command.js +0 -125
- package/dist/presentation/cli/commands/context-source-command.js +0 -198
- package/dist/presentation/cli/commands/hook-command.js +0 -265
- package/dist/presentation/cli/commands/init-command.js +0 -578
- package/dist/presentation/cli/commands/memory-command.js +0 -1031
- package/dist/presentation/cli/commands/narrator-command.js +0 -101
- package/dist/presentation/cli/commands/permission-command.js +0 -116
- package/dist/presentation/cli/commands/protocols-builder.js +0 -254
- package/dist/presentation/cli/commands/protocols-command.js +0 -84
- package/dist/presentation/cli/commands/repo-map-builder.js +0 -247
- package/dist/presentation/cli/commands/report-command.js +0 -387
- package/dist/presentation/cli/commands/scan-tool-command.js +0 -94
- package/dist/presentation/cli/commands/setup-command.js +0 -307
- package/dist/presentation/cli/commands/status-command.js +0 -661
- package/dist/presentation/cli/commands/thread-command.js +0 -288
- package/dist/presentation/cli/commands/trust-command.js +0 -306
- package/dist/presentation/cli/commands/verify-command.js +0 -233
- package/dist/presentation/cli/error-display.js +0 -336
- package/dist/presentation/cli/repl.js +0 -797
- package/dist/presentation/cli/task-deliverable.js +0 -127
- package/dist/testing/cassette.js +0 -125
- package/dist/testing/replay-adapter.js +0 -166
|
@@ -1,183 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* When a task finishes, a product manager reports: what changed, was anything
|
|
5
|
-
* risky, did tests pass. We derive that from ground truth:
|
|
6
|
-
* - git diff (working tree + untracked) vs the HEAD snapshot taken at spawn
|
|
7
|
-
* - trust score per changed file via the existing SAST scorer (F0)
|
|
8
|
-
* - test pass/fail counts parsed out of collected stdout (best-effort)
|
|
9
|
-
*
|
|
10
|
-
* Agent-agnostic on purpose. Both Claude and Codex mutate the filesystem;
|
|
11
|
-
* git tells the truth regardless of which tool did the write.
|
|
12
|
-
*
|
|
13
|
-
* v0.6.x: dirty-baseline subtraction. Without it, files the user had
|
|
14
|
-
* uncommitted at spawn time would falsely appear in artifacts even though
|
|
15
|
-
* the agent never touched them — git diff vs spawn-HEAD includes EVERYTHING
|
|
16
|
-
* dirty in the working tree, not just what changed during the session.
|
|
17
|
-
* Now snapshotCwd captures `git status --porcelain` + per-file mtime/size at
|
|
18
|
-
* spawn time. extractArtifacts filters out files whose stat is unchanged
|
|
19
|
-
* since spawn — they were dirty before, not by this session. Files that were
|
|
20
|
-
* dirty at spawn AND further modified during the session still appear.
|
|
21
|
-
*
|
|
22
|
-
* Limitation: cannot distinguish concurrent human writes from agent writes
|
|
23
|
-
* during the session window. True PID-level attribution would require
|
|
24
|
-
* fanotify/audit subsystem. The dirty-baseline filter eliminates the most
|
|
25
|
-
* common false-positive (pre-existing uncommitted work) without that.
|
|
2
|
+
* Test summary parsing: "N passed", "M failed" and the runner-specific variants, out of collected stdout.
|
|
3
|
+
* Used by `codehere check` to read what a test run reported.
|
|
26
4
|
*/
|
|
27
|
-
import { execFileSync } from 'child_process';
|
|
28
|
-
import { existsSync, readFileSync, statSync } from 'fs';
|
|
29
|
-
import { join, isAbsolute } from 'path';
|
|
30
|
-
import { scoreFile } from '../security/trust-scorer.js';
|
|
31
|
-
/** Capture mtime + size for a single path. Returns null if stat fails. */
|
|
32
|
-
function statBaseline(fullPath) {
|
|
33
|
-
try {
|
|
34
|
-
const st = statSync(fullPath);
|
|
35
|
-
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/** Read `git status --porcelain` and stat every dirty path. Best-effort.
|
|
42
|
-
* Paths are git-relative (i.e. relative to repoRoot, not cwd). */
|
|
43
|
-
function captureDirtyBaseline(cwd, repoRoot) {
|
|
44
|
-
const baseline = new Map();
|
|
45
|
-
try {
|
|
46
|
-
const out = execFileSync('git', ['status', '--porcelain', '-z'], {
|
|
47
|
-
cwd,
|
|
48
|
-
encoding: 'utf-8',
|
|
49
|
-
timeout: 5000,
|
|
50
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
51
|
-
});
|
|
52
|
-
// Porcelain -z separates entries by NUL. First two chars are status, then space, then path.
|
|
53
|
-
// Renames have an extra NUL-separated old path which we don't need here.
|
|
54
|
-
for (const entry of out.split('\0')) {
|
|
55
|
-
if (!entry)
|
|
56
|
-
continue;
|
|
57
|
-
// Strip the 2-char status prefix + 1 separator (3 chars total)
|
|
58
|
-
const path = entry.slice(3);
|
|
59
|
-
if (!path)
|
|
60
|
-
continue;
|
|
61
|
-
const full = isAbsolute(path) ? path : join(repoRoot, path);
|
|
62
|
-
const b = statBaseline(full);
|
|
63
|
-
if (b)
|
|
64
|
-
baseline.set(path, b);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
/* best-effort — empty baseline means no filtering, status-quo behavior */
|
|
69
|
-
}
|
|
70
|
-
return baseline;
|
|
71
|
-
}
|
|
72
|
-
/** Get repo root via `git rev-parse --show-toplevel`. Returns null if not in a repo. */
|
|
73
|
-
function getRepoRoot(cwd) {
|
|
74
|
-
try {
|
|
75
|
-
const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
76
|
-
cwd,
|
|
77
|
-
encoding: 'utf-8',
|
|
78
|
-
timeout: 2000,
|
|
79
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
80
|
-
}).trim();
|
|
81
|
-
return out.length > 0 ? out : null;
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/** Take a baseline so Layer 3 can diff against it on exit. Cheap; ~5ms. */
|
|
88
|
-
export function snapshotCwd(cwd) {
|
|
89
|
-
const spawnedAtMs = Date.now();
|
|
90
|
-
try {
|
|
91
|
-
const out = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
92
|
-
cwd,
|
|
93
|
-
encoding: 'utf-8',
|
|
94
|
-
timeout: 2000,
|
|
95
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
96
|
-
}).trim();
|
|
97
|
-
const isGitRepo = out.length > 0;
|
|
98
|
-
const repoRoot = isGitRepo ? (getRepoRoot(cwd) ?? cwd) : cwd;
|
|
99
|
-
return {
|
|
100
|
-
cwd,
|
|
101
|
-
head: out,
|
|
102
|
-
isGitRepo,
|
|
103
|
-
spawnedAtMs,
|
|
104
|
-
repoRoot,
|
|
105
|
-
dirtyBaseline: isGitRepo ? captureDirtyBaseline(cwd, repoRoot) : new Map(),
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
return {
|
|
110
|
-
cwd,
|
|
111
|
-
head: null,
|
|
112
|
-
isGitRepo: false,
|
|
113
|
-
spawnedAtMs,
|
|
114
|
-
repoRoot: cwd,
|
|
115
|
-
dirtyBaseline: new Map(),
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
/** Collect {path, +, -} from git diff (numstat) + untracked files (wc -l). */
|
|
120
|
-
function changedFiles(snap) {
|
|
121
|
-
if (!snap.isGitRepo)
|
|
122
|
-
return [];
|
|
123
|
-
const out = [];
|
|
124
|
-
try {
|
|
125
|
-
// Staged + unstaged vs HEAD at spawn
|
|
126
|
-
const diff = execFileSync('git', ['diff', '--numstat', snap.head ?? 'HEAD'], {
|
|
127
|
-
cwd: snap.cwd,
|
|
128
|
-
encoding: 'utf-8',
|
|
129
|
-
timeout: 5000,
|
|
130
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
131
|
-
});
|
|
132
|
-
for (const line of diff.split('\n')) {
|
|
133
|
-
const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/);
|
|
134
|
-
if (!m)
|
|
135
|
-
continue;
|
|
136
|
-
const [, aStr, rStr, path] = m;
|
|
137
|
-
out.push({
|
|
138
|
-
path,
|
|
139
|
-
added: aStr === '-' ? 0 : parseInt(aStr, 10) || 0,
|
|
140
|
-
removed: rStr === '-' ? 0 : parseInt(rStr, 10) || 0,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
catch {
|
|
145
|
-
/* diff failed — skip */
|
|
146
|
-
}
|
|
147
|
-
try {
|
|
148
|
-
// Untracked files — not in diff output
|
|
149
|
-
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
|
|
150
|
-
cwd: snap.cwd,
|
|
151
|
-
encoding: 'utf-8',
|
|
152
|
-
timeout: 5000,
|
|
153
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
154
|
-
});
|
|
155
|
-
for (const rel of untracked.split('\n')) {
|
|
156
|
-
if (!rel.trim())
|
|
157
|
-
continue;
|
|
158
|
-
// Count lines in the new file. Untracked paths come from git relative
|
|
159
|
-
// to repoRoot — must join with repoRoot, not cwd, when cwd is a subdir.
|
|
160
|
-
try {
|
|
161
|
-
const full = isAbsolute(rel) ? rel : join(snap.repoRoot, rel);
|
|
162
|
-
if (!existsSync(full))
|
|
163
|
-
continue;
|
|
164
|
-
const size = statSync(full).size;
|
|
165
|
-
if (size > 2 * 1024 * 1024)
|
|
166
|
-
continue; // skip files >2MB, don't need their trust score
|
|
167
|
-
const content = readFileSync(full, 'utf-8');
|
|
168
|
-
const lines = content ? content.split('\n').length : 0;
|
|
169
|
-
out.push({ path: rel, added: lines, removed: 0 });
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
/* skip */
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
/* ls-files failed — skip */
|
|
178
|
-
}
|
|
179
|
-
return out;
|
|
180
|
-
}
|
|
181
5
|
/** Parse "N passed", "M failed", "K tests" out of accumulated stdout. Best-effort.
|
|
182
6
|
* Vitest + jest output both have TWO pass/fail lines ("Test Files 11 passed" then
|
|
183
7
|
* "Tests 47 passed"). We want the second/last. Take the last match in the buffer,
|
|
@@ -239,91 +63,4 @@ export function parseTestSummary(stdout) {
|
|
|
239
63
|
result.durationMs = Math.round(parseFloat(dur[1]) * 1000);
|
|
240
64
|
return result;
|
|
241
65
|
}
|
|
242
|
-
/** Score each changed file with the existing SAST trust scorer. Safe on binaries (skipped). */
|
|
243
|
-
function annotateTrust(snap, files) {
|
|
244
|
-
return files.map((f) => {
|
|
245
|
-
try {
|
|
246
|
-
// Git-relative path: join with repoRoot, not cwd (matters when cwd is
|
|
247
|
-
// a subdirectory of the repo).
|
|
248
|
-
const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
|
|
249
|
-
if (!existsSync(full))
|
|
250
|
-
return f;
|
|
251
|
-
const size = statSync(full).size;
|
|
252
|
-
if (size > 2 * 1024 * 1024)
|
|
253
|
-
return f;
|
|
254
|
-
// Only score text-like files — trust scorer regexes operate on UTF-8
|
|
255
|
-
if (!/\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|rs|java|kt|cs|php|sh|bash|zsh|yml|yaml|json|md|sql)$/i.test(f.path))
|
|
256
|
-
return f;
|
|
257
|
-
const content = readFileSync(full, 'utf-8');
|
|
258
|
-
const scored = scoreFile(f.path, content);
|
|
259
|
-
return { ...f, trust: scored.score };
|
|
260
|
-
}
|
|
261
|
-
catch {
|
|
262
|
-
return f;
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Keep only files whose mtime is at or after spawn time — the signal that this
|
|
268
|
-
* session touched them. Prevents Session B in the same cwd from reporting files
|
|
269
|
-
* Session A already edited before B started. Not bulletproof (both sessions
|
|
270
|
-
* hitting the same file both report it) but fixes the common concurrent-sessions
|
|
271
|
-
* pollution observed in beta.3.
|
|
272
|
-
*/
|
|
273
|
-
function filterByMtime(snap, files) {
|
|
274
|
-
const grace = 1000; // ms; allow clock skew between git's touch and our Date.now()
|
|
275
|
-
return files.filter((f) => {
|
|
276
|
-
try {
|
|
277
|
-
// Git-relative path: join with repoRoot, not cwd.
|
|
278
|
-
const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
|
|
279
|
-
if (!existsSync(full))
|
|
280
|
-
return true; // file was deleted — keep it
|
|
281
|
-
const { mtimeMs } = statSync(full);
|
|
282
|
-
return mtimeMs >= snap.spawnedAtMs - grace;
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
return true; // stat failed — be permissive
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Drop files that were dirty at spawn time and have NOT been further modified
|
|
291
|
-
* since. The git-diff-vs-spawn-HEAD path picks up the user's pre-existing
|
|
292
|
-
* uncommitted changes; without this filter, those files get mis-attributed to
|
|
293
|
-
* the agent's task. A file is considered "further modified" if its mtime OR
|
|
294
|
-
* size changed since the spawn snapshot — content-level tampering with same
|
|
295
|
-
* mtime/size is rare enough to ignore.
|
|
296
|
-
*/
|
|
297
|
-
function filterBySpawnBaseline(snap, files) {
|
|
298
|
-
if (snap.dirtyBaseline.size === 0)
|
|
299
|
-
return files;
|
|
300
|
-
return files.filter((f) => {
|
|
301
|
-
const baseline = snap.dirtyBaseline.get(f.path);
|
|
302
|
-
if (!baseline)
|
|
303
|
-
return true; // wasn't dirty at spawn — definitely a session change
|
|
304
|
-
try {
|
|
305
|
-
// Git-relative path: join with repoRoot, not cwd.
|
|
306
|
-
const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
|
|
307
|
-
if (!existsSync(full))
|
|
308
|
-
return true; // deleted during session — that's a change
|
|
309
|
-
const { mtimeMs, size } = statSync(full);
|
|
310
|
-
// Further-modified if either mtime advanced or size differs from baseline.
|
|
311
|
-
return mtimeMs > baseline.mtimeMs || size !== baseline.size;
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
return true; // stat failed — be permissive (don't drop files we can't verify)
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
export function extractArtifacts(snap, collectedStdout) {
|
|
319
|
-
const raw = changedFiles(snap);
|
|
320
|
-
const sessionScoped = filterByMtime(snap, raw);
|
|
321
|
-
const agentLikely = filterBySpawnBaseline(snap, sessionScoped);
|
|
322
|
-
const files = annotateTrust(snap, agentLikely);
|
|
323
|
-
const tests = parseTestSummary(collectedStdout);
|
|
324
|
-
// Non-git cwd: files is empty because we can't diff, not because nothing
|
|
325
|
-
// changed. Flag it so the UI + Final Report say "can't track here" instead
|
|
326
|
-
// of silently implying "no files changed".
|
|
327
|
-
return { files, tests, ...(snap.isGitRepo ? {} : { noGitTracking: true }) };
|
|
328
|
-
}
|
|
329
66
|
//# sourceMappingURL=artifacts-extractor.js.map
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: check — the simplest proof. codehere independently RE-RUNS your project's
|
|
3
|
+
* tests and reports the real result, so you trust what the AI actually did, not
|
|
4
|
+
* what it claimed. Zero setup, no daemon, no API. Exit codes for CI: 0 a verified pass, 1 a finding (tests
|
|
5
|
+
* failed, no test ran, a test weakened, a claimed test file missing), 2 nothing could be verified.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { statSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
import { colors, symbols } from '../../../ui.js';
|
|
11
|
+
import { rerunTests } from '../../../application/use-cases/rerun-tests.js';
|
|
12
|
+
import { scanTestWeakening, } from '../../../application/use-cases/detect-test-weakening.js';
|
|
13
|
+
import { findMissingClaimedTests } from '../../../application/use-cases/check-claimed-tests.js';
|
|
14
|
+
const WHY_TEXT = {
|
|
15
|
+
'trivial-assertion': 'assertion that checks nothing',
|
|
16
|
+
skipped: 'test skipped',
|
|
17
|
+
'commented-out': 'assertion commented out',
|
|
18
|
+
'unreachable-assertion': 'assertion after an early return',
|
|
19
|
+
};
|
|
20
|
+
// File names, diff lines and branch names come from whoever wrote the change, so they are treated as untrusted:
|
|
21
|
+
// control characters (terminal escapes among them) are replaced, and in the markdown receipt the text sits inside
|
|
22
|
+
// an inline code span, where no link, @mention or emphasis renders.
|
|
23
|
+
// Control characters, plus direction overrides, zero-width, soft-hyphen, line-separator and tag characters that can
|
|
24
|
+
// disguise a file name.
|
|
25
|
+
const plain = (s) => s.replace(/[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\u{e0000}-\u{e007f}]|\u034f/gu, '?');
|
|
26
|
+
const code = (s) => '`' + plain(s).replace(/`/g, "'") + '`';
|
|
27
|
+
/** Characters a branch name usually has; anything else is not repeated in the reproduce command. */
|
|
28
|
+
const SAFE_REF = /^[A-Za-z0-9._/@+-]+$/;
|
|
29
|
+
const weakenedLines = (w) => w.map((f) => `${plain(f.file)}:${f.line} · ${WHY_TEXT[f.why]} · ${plain(f.text)}`);
|
|
30
|
+
const weakenedMarkdown = (w) => w.map((f) => `${code(f.file)}:${f.line} · ${WHY_TEXT[f.why]} · ${code(f.text)}`);
|
|
31
|
+
export const RECEIPT_MARKER = '<!-- codehere-receipt -->';
|
|
32
|
+
/**
|
|
33
|
+
* One word per result, for CI. Green (`pass`) only when codehere re-ran the suite, it exited 0, no test
|
|
34
|
+
* was weakened, no named test file was missing and the weakened-test scan finished. Tampering is `fail` even if
|
|
35
|
+
* no suite could run; every other case where nothing was verified is `unverified`, never a pass.
|
|
36
|
+
*/
|
|
37
|
+
export function checkVerdict(r, weakened, missingClaimed, scanError) {
|
|
38
|
+
if (weakened.length || missingClaimed.length)
|
|
39
|
+
return 'fail';
|
|
40
|
+
if (r.kind === 'disabled')
|
|
41
|
+
return 'skipped';
|
|
42
|
+
if (r.kind === 'failed')
|
|
43
|
+
return 'fail';
|
|
44
|
+
if (scanError)
|
|
45
|
+
return 'unverified';
|
|
46
|
+
if (r.kind === 'passed' && r.ran && (r.exitCode ?? 1) === 0)
|
|
47
|
+
return 'pass';
|
|
48
|
+
return 'unverified';
|
|
49
|
+
}
|
|
50
|
+
/** The check command that gives the same verdict as this run: the options that changed it are repeated. */
|
|
51
|
+
function reproduceCommand({ base, claim }) {
|
|
52
|
+
let cmd = 'codehere check';
|
|
53
|
+
if (base)
|
|
54
|
+
cmd += SAFE_REF.test(base) ? ` --base ${base}` : ' --base <the same base>';
|
|
55
|
+
if (claim) {
|
|
56
|
+
const inlineable = claim.length <= 160 && !/[`'\n\r]/.test(claim);
|
|
57
|
+
cmd += inlineable ? ` --claim '${plain(claim)}'` : ' --claim <the same summary>';
|
|
58
|
+
}
|
|
59
|
+
return cmd;
|
|
60
|
+
}
|
|
61
|
+
/** Short commit SHA of `cwd`'s repo, or null when it isn't a git checkout. */
|
|
62
|
+
function gitShortSha(cwd) {
|
|
63
|
+
try {
|
|
64
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
65
|
+
.toString()
|
|
66
|
+
.trim();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A paste-ready "verified outcome receipt": the one thing a reviewer can trust
|
|
74
|
+
* without re-running anything — codehere independently re-ran the suite, here's the
|
|
75
|
+
* observed result, pinned to a commit + timestamp, WITH the exact command to
|
|
76
|
+
* reproduce it independently. Plain markdown so it drops into a PR description or
|
|
77
|
+
* review comment as-is.
|
|
78
|
+
*
|
|
79
|
+
* Why reproducibility is the trust mechanism (not a digest): a self-computed hash
|
|
80
|
+
* over self-reported facts proves nothing — a sender could edit the facts AND the
|
|
81
|
+
* hash. What a third party can actually trust is REPRODUCING it: check out the
|
|
82
|
+
* pinned commit, run `codehere check`, get the same exit code. So the receipt leads
|
|
83
|
+
* with the reproduce command and is honest about SCOPE (a test re-run, not a
|
|
84
|
+
* security/logic audit) — it never says more than it verified.
|
|
85
|
+
*
|
|
86
|
+
* ponytail: this is the standalone (no-daemon) receipt. Upgrade path to true
|
|
87
|
+
* tamper-EVIDENCE (authenticity, not just reproducibility): when a ~/.codehere audit
|
|
88
|
+
* chain covers this commit, fold in its verified hash (audit-chain.ts) so the receipt
|
|
89
|
+
* also carries a digest a third party can check against an append-only log.
|
|
90
|
+
*/
|
|
91
|
+
function buildProofBlock(cwd, r, weakened = [], missingClaimed = [], repro = {}, scan = { findings: [] }) {
|
|
92
|
+
const sha = gitShortSha(cwd);
|
|
93
|
+
const when = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
94
|
+
const at = sha ? `commit \`${sha}\`` : 'the working tree';
|
|
95
|
+
const weakenedNote = weakened.length
|
|
96
|
+
? `> Tests changed in this diff, so a pass may check nothing: ${weakenedMarkdown(weakened).join(' · ')}\n`
|
|
97
|
+
: '';
|
|
98
|
+
const missingNote = missingClaimed.length
|
|
99
|
+
? `> The AI named test files that are not in the repo: ${missingClaimed.map(code).join(' · ')}\n`
|
|
100
|
+
: '';
|
|
101
|
+
const scanNote = scan.error
|
|
102
|
+
? `> Weakened-test scan could not run: ${scan.error}\n`
|
|
103
|
+
: scan.skipped
|
|
104
|
+
? `> Weakened-test scan skipped: ${scan.skipped}\n`
|
|
105
|
+
: '';
|
|
106
|
+
// A finding is reported even when the suite could not run.
|
|
107
|
+
const notes = weakenedNote + missingNote + scanNote;
|
|
108
|
+
if (!r.ran) {
|
|
109
|
+
if (r.kind === 'command-not-found') {
|
|
110
|
+
const fix = r.remedy ? ` · fix: \`${r.remedy}\`` : '';
|
|
111
|
+
return `> **codehere · verified outcome receipt** — couldn't run ${code(r.command || 'the tests')} (runner/deps not installed) · unverified${fix} · ${when}\n${notes}`;
|
|
112
|
+
}
|
|
113
|
+
return `> **codehere · verified outcome receipt** — no suite re-run (${code(r.skippedReason || 'none detected')}) · nothing verified · ${when}\n${notes}`;
|
|
114
|
+
}
|
|
115
|
+
if (r.kind === 'runner-missing') {
|
|
116
|
+
const fix = r.remedy ? ` · fix: \`${r.remedy}\`` : '';
|
|
117
|
+
return `> **codehere · verified outcome receipt** — couldn't run ${code(r.command ?? '')} on ${at} (runner not installed, exit ${r.exitCode}) · unverified${fix} · ${when}\n${notes}`;
|
|
118
|
+
}
|
|
119
|
+
const passed = (r.exitCode ?? 1) === 0 && !r.noTests;
|
|
120
|
+
const counts = typeof r.passed === 'number' || typeof r.failed === 'number'
|
|
121
|
+
? ` (${r.passed ?? '?'} passed · ${r.failed ?? 0} failed)`
|
|
122
|
+
: '';
|
|
123
|
+
const verdict = r.noTests
|
|
124
|
+
? '✕ no tests ran'
|
|
125
|
+
: !passed
|
|
126
|
+
? '✕ tests FAIL'
|
|
127
|
+
: weakened.length
|
|
128
|
+
? `✕ tests pass · ${weakened.length} weakened`
|
|
129
|
+
: missingClaimed.length
|
|
130
|
+
? `✕ tests pass · ${missingClaimed.length} claimed test file not found`
|
|
131
|
+
: scan.error
|
|
132
|
+
? '✕ tests pass · weakened-test scan could not run'
|
|
133
|
+
: '✓ tests pass';
|
|
134
|
+
// Reproduce line: the real, verifiable trust — anyone can re-run and confirm.
|
|
135
|
+
const reproduce = sha ? `> Reproduce independently: \`git checkout ${sha} && ${reproduceCommand(repro)}\`\n` : '';
|
|
136
|
+
return (`> **codehere · verified outcome receipt** · ${verdict}${counts}\n` +
|
|
137
|
+
`> codehere independently re-ran ${code(r.command ?? '')} on ${at} — exit ${r.exitCode}. Observed, not the AI's claim. · ${when}\n` +
|
|
138
|
+
notes +
|
|
139
|
+
reproduce +
|
|
140
|
+
`> Scope: the project's test suite — not a security or logic audit.\n`);
|
|
141
|
+
}
|
|
142
|
+
export async function executeCheckCommand(target, options = {}) {
|
|
143
|
+
const cwd = resolve(target || '.');
|
|
144
|
+
let isFolder = false;
|
|
145
|
+
try {
|
|
146
|
+
isFolder = statSync(cwd).isDirectory();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* missing path: handled below */
|
|
150
|
+
}
|
|
151
|
+
if (!isFolder)
|
|
152
|
+
throw new Error(`No such folder: ${target}`);
|
|
153
|
+
const r = await rerunTests(cwd);
|
|
154
|
+
const scan = scanTestWeakening(cwd, options.base);
|
|
155
|
+
const weakened = scan.findings;
|
|
156
|
+
const missingClaimed = options.claim ? findMissingClaimedTests(cwd, options.claim) : [];
|
|
157
|
+
const verdict = checkVerdict(r, weakened, missingClaimed, scan.error);
|
|
158
|
+
const weakeningScan = scan.error ? `could not run: ${scan.error}` : scan.skipped ? `skipped: ${scan.skipped}` : 'ran';
|
|
159
|
+
const exitCode = verdict === 'pass' ? 0 : verdict === 'fail' ? 1 : 2;
|
|
160
|
+
const weakenedText = () => weakened.length
|
|
161
|
+
? weakenedLines(weakened)
|
|
162
|
+
.map((l) => colors.dim(` ${l}\n`))
|
|
163
|
+
.join('') + colors.dim(` › ${options.base ? `git diff ${plain(options.base)}...HEAD` : 'git diff HEAD'}\n`)
|
|
164
|
+
: '';
|
|
165
|
+
const scanText = () => scan.error
|
|
166
|
+
? colors.dim(` weakened-test scan could not run: ${scan.error}\n`)
|
|
167
|
+
: scan.skipped
|
|
168
|
+
? colors.dim(` weakened-test scan skipped: ${scan.skipped}\n`)
|
|
169
|
+
: '';
|
|
170
|
+
const missingLines = () => missingClaimed.length
|
|
171
|
+
? missingClaimed.map((f) => colors.dim(` ${plain(f)} · named by the AI, not in the repo\n`)).join('')
|
|
172
|
+
: '';
|
|
173
|
+
if (options.json) {
|
|
174
|
+
const receipt = RECEIPT_MARKER +
|
|
175
|
+
'\n' +
|
|
176
|
+
buildProofBlock(cwd, r, weakened, missingClaimed, { base: options.base, claim: options.claim }, scan);
|
|
177
|
+
process.stdout.write(JSON.stringify({ cwd, ...r, weakened, missingClaimed, weakeningScan, verdict, receipt }, null, 2) + '\n');
|
|
178
|
+
return exitCode;
|
|
179
|
+
}
|
|
180
|
+
if (options.proof) {
|
|
181
|
+
process.stdout.write(buildProofBlock(cwd, r, weakened, missingClaimed, { base: options.base, claim: options.claim }, scan));
|
|
182
|
+
return exitCode;
|
|
183
|
+
}
|
|
184
|
+
if (!r.ran) {
|
|
185
|
+
// The runner/deps aren't installed (e.g. `spawn pytest ENOENT`) — that's a
|
|
186
|
+
// fixable environment gap, not "no tests". Name the one command that fixes it.
|
|
187
|
+
if (r.kind === 'command-not-found') {
|
|
188
|
+
process.stdout.write('\n' +
|
|
189
|
+
colors.error(`${symbols.error} Couldn't run the tests`) +
|
|
190
|
+
colors.dim(` — the runner for '${r.command || 'your tests'}' isn't installed here.\n`) +
|
|
191
|
+
(r.remedy
|
|
192
|
+
? colors.dim(' Fix it, then re-check: ') + colors.success(`${r.remedy}\n\n`)
|
|
193
|
+
: colors.dim(' Install the test runner, then run check again.\n\n')));
|
|
194
|
+
process.stdout.write(weakenedText() + missingLines() + scanText());
|
|
195
|
+
return exitCode; // couldn't verify (2); a weakened test or a missing named test file still counts (1)
|
|
196
|
+
}
|
|
197
|
+
if (r.kind === 'timed-out' || r.kind === 'disabled') {
|
|
198
|
+
process.stdout.write(colors.error(`${symbols.error} Nothing verified`) +
|
|
199
|
+
colors.dim(` — ${r.kind === 'timed-out' ? `'${r.command}' ${r.skippedReason?.replace(/^test re-run /, '')}` : r.skippedReason}.\n`));
|
|
200
|
+
process.stdout.write(weakenedText() + missingLines() + scanText());
|
|
201
|
+
return exitCode;
|
|
202
|
+
}
|
|
203
|
+
process.stdout.write(colors.dim(`No test suite codehere can run here — ${r.skippedReason || 'none detected'}.\n`) +
|
|
204
|
+
colors.dim('codehere proves the AI by re-running your tests; add one (npm test / node --test / pytest / go test / cargo test) and run check again.\n'));
|
|
205
|
+
process.stdout.write(weakenedText() + missingLines() + scanText());
|
|
206
|
+
return exitCode; // nothing was verified (2); a weakened test or a missing named test file still counts (1)
|
|
207
|
+
}
|
|
208
|
+
// Honesty: runner-missing (exit 126/127) means the test command couldn't be RUN
|
|
209
|
+
// (deps not installed), not that the tests failed. Never report "FAIL" for that —
|
|
210
|
+
// name the one command that makes it runnable.
|
|
211
|
+
if (r.kind === 'runner-missing') {
|
|
212
|
+
process.stdout.write('\n' +
|
|
213
|
+
colors.error(`${symbols.error} Couldn't run the tests`) +
|
|
214
|
+
colors.dim(` — '${r.command}' exited ${r.exitCode}: the deps/runner aren't installed here.\n`) +
|
|
215
|
+
(r.remedy
|
|
216
|
+
? colors.dim(' Fix it, then re-check: ') + colors.success(`${r.remedy}\n\n`)
|
|
217
|
+
: colors.dim(' Install deps, then run check again.\n\n')));
|
|
218
|
+
process.stdout.write(weakenedText() + missingLines() + scanText());
|
|
219
|
+
return exitCode; // non-zero for CI (unverified), but NOT a false "tests failed"
|
|
220
|
+
}
|
|
221
|
+
const passed = (r.exitCode ?? 1) === 0 && !r.noTests;
|
|
222
|
+
const counts = typeof r.passed === 'number' || typeof r.failed === 'number'
|
|
223
|
+
? ` ${r.passed ?? '?'} passed · ${r.failed ?? 0} failed`
|
|
224
|
+
: '';
|
|
225
|
+
const headline = r.noTests
|
|
226
|
+
? colors.error(`${symbols.error} No tests ran`)
|
|
227
|
+
: !passed
|
|
228
|
+
? colors.error(`${symbols.error} Tests FAIL`)
|
|
229
|
+
: weakened.length
|
|
230
|
+
? colors.error(`${symbols.error} Tests pass · ${weakened.length} weakened in this change`)
|
|
231
|
+
: missingClaimed.length
|
|
232
|
+
? colors.error(`${symbols.error} Tests pass · ${missingClaimed.length} claimed test file not found`)
|
|
233
|
+
: scan.error
|
|
234
|
+
? colors.error(`${symbols.error} Tests pass · weakened-test scan could not run`)
|
|
235
|
+
: colors.success(`${symbols.success} Tests pass`);
|
|
236
|
+
process.stdout.write('\n' +
|
|
237
|
+
headline +
|
|
238
|
+
colors.dim(` — codehere re-ran them itself (${r.command}, exit ${r.exitCode})${counts}\n`) +
|
|
239
|
+
colors.dim("This is observed reality, not the agent's claim.\n") +
|
|
240
|
+
(r.noTests
|
|
241
|
+
? colors.dim(r.allSkipped
|
|
242
|
+
? ' The runner says every test was skipped, so nothing was tested.\n'
|
|
243
|
+
: ' The runner reported 0 tests, so nothing was tested. Check the test file names.\n')
|
|
244
|
+
: '') +
|
|
245
|
+
weakenedText() +
|
|
246
|
+
missingLines() +
|
|
247
|
+
scanText() +
|
|
248
|
+
'\n');
|
|
249
|
+
return exitCode;
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=check-command.js.map
|