@navels/neal 0.1.0 → 0.3.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/README.md +206 -209
- package/SECURITY.md +16 -19
- package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
- package/dist/neal/adjudicator/execute.js +33 -33
- package/dist/neal/adjudicator/final-completion.js +2 -31
- package/dist/neal/adjudicator/planning.js +264 -24
- package/dist/neal/agents/prompts.js +7 -15
- package/dist/neal/agents/rounds.js +51 -73
- package/dist/neal/agents/schemas.js +615 -502
- package/dist/neal/agents/structured-json.js +36 -0
- package/dist/neal/blocked-guidance.js +16 -9
- package/dist/neal/cli.js +2 -2
- package/dist/neal/commands/check.js +2 -2
- package/dist/neal/commands/compat.js +100 -36
- package/dist/neal/commands/interactive-activity.js +2 -2
- package/dist/neal/commands/recovery-guidance.js +20 -8
- package/dist/neal/commands/runtime.js +4 -2
- package/dist/neal/config.js +40 -7
- package/dist/neal/context/inline-review-context.js +13 -28
- package/dist/neal/context/reviewer-context.js +19 -14
- package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
- package/dist/neal/eval/reviewer-eval.js +155 -0
- package/dist/neal/git.js +9 -3
- package/dist/neal/interactive-controls.js +5 -0
- package/dist/neal/orchestrator/completion.js +215 -113
- package/dist/neal/orchestrator/failures.js +7 -0
- package/dist/neal/orchestrator/notifications.js +9 -9
- package/dist/neal/orchestrator/phases/planning.js +124 -112
- package/dist/neal/orchestrator/phases/recovery.js +151 -115
- package/dist/neal/orchestrator/phases/review.js +28 -1
- package/dist/neal/orchestrator/split-plan.js +12 -11
- package/dist/neal/orchestrator/transitions.js +36 -71
- package/dist/neal/orchestrator.js +3 -0
- package/dist/neal/plan-doc.js +24 -1
- package/dist/neal/plan-queue.js +29 -1
- package/dist/neal/prompts/assert-builder.js +8 -1
- package/dist/neal/prompts/execute.js +11 -19
- package/dist/neal/prompts/planning.js +64 -55
- package/dist/neal/prompts/review-doctrine.js +13 -50
- package/dist/neal/prompts/specialized.js +28 -22
- package/dist/neal/prompts/specs.js +223 -17
- package/dist/neal/providers/agent-settings-isolation.js +35 -0
- package/dist/neal/providers/anthropic-claude.js +309 -248
- package/dist/neal/providers/detection.js +3 -7
- package/dist/neal/providers/git-config-isolation.js +83 -0
- package/dist/neal/providers/openai-codex.js +102 -204
- package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
- package/dist/neal/providers/openai-compatible.js +1079 -403
- package/dist/neal/providers/openrouter-routing.js +68 -0
- package/dist/neal/providers/pricing.js +124 -0
- package/dist/neal/providers/rate-card.js +2275 -0
- package/dist/neal/providers/registry.js +6 -17
- package/dist/neal/providers/telemetry.js +4 -0
- package/dist/neal/providers/types.js +12 -0
- package/dist/neal/resume-decision.js +7 -6
- package/dist/neal/resume-planner.js +9 -0
- package/dist/neal/retrospective.js +36 -7
- package/dist/neal/review-debt.js +30 -0
- package/dist/neal/review-findings/provider.js +17 -29
- package/dist/neal/review.js +17 -1
- package/dist/neal/run-metrics.js +74 -9
- package/dist/neal/state-invariants.js +11 -3
- package/dist/neal/state-views.js +27 -5
- package/dist/neal/state.js +59 -15
- package/dist/neal/status.js +28 -1
- package/dist/neal/support.js +3 -3
- package/docs/README.md +22 -18
- package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
- package/docs/architecture.md +79 -83
- package/docs/automation.md +11 -11
- package/docs/compat.md +91 -61
- package/docs/compatible-models.md +145 -110
- package/docs/demo.md +15 -5
- package/docs/maintenance.md +74 -32
- package/docs/plan-format.md +61 -33
- package/docs/prompt-evals.md +96 -0
- package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
- package/docs/providers.md +330 -283
- package/docs/release.md +43 -40
- package/docs/review-convergence.md +246 -0
- package/docs/state-machine.md +61 -59
- package/docs/storage.md +40 -31
- package/docs/troubleshooting.md +32 -29
- package/examples/compat/add-edit-verify/PLAN.md +2 -0
- package/examples/compat/add-edit-verify/broken.diff +2 -2
- package/examples/compat/add-edit-verify/good.diff +2 -2
- package/examples/compat/add-edit-verify/src/add.js +1 -1
- package/examples/compat/manifest.json +25 -29
- package/examples/compat/sum-grep-edit/PLAN.md +33 -0
- package/examples/compat/sum-grep-edit/broken.diff +12 -0
- package/examples/compat/sum-grep-edit/good.diff +12 -0
- package/examples/compat/sum-grep-edit/package.json +5 -0
- package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
- package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
- package/examples/issue-triage-js/README.md +0 -15
- package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
- package/examples/reviewer-eval/clean-doc/change.diff +9 -0
- package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
- package/examples/reviewer-eval/clean-extract/change.diff +13 -0
- package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
- package/examples/reviewer-eval/dropped-error/change.diff +15 -0
- package/examples/reviewer-eval/manifest.json +45 -0
- package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
- package/examples/reviewer-eval/off-by-one/change.diff +11 -0
- package/{config.yml → neal.yml} +1 -1
- package/package.json +20 -15
- package/dist/neal/providers/generic-agentic.js +0 -1238
- package/docs/comparison.md +0 -105
- package/examples/compat/is-even-add-test/PLAN.md +0 -30
- package/examples/compat/is-even-add-test/broken.diff +0 -11
- package/examples/compat/is-even-add-test/good.diff +0 -11
- package/examples/compat/is-even-add-test/package.json +0 -5
- package/examples/compat/is-even-add-test/src/is-even.js +0 -3
- package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
- package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
- package/examples/compat/reverse-grep-edit/broken.diff +0 -12
- package/examples/compat/reverse-grep-edit/good.diff +0 -12
- package/examples/compat/reverse-grep-edit/package.json +0 -5
- package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
- package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
|
@@ -4,8 +4,9 @@ import { getProviderDefinition, isRegisteredProviderId } from '../providers/regi
|
|
|
4
4
|
// truncation posture of truncateForPrompt in src/neal/agents/structured-json.ts:
|
|
5
5
|
// truncate with an explicit marker instead of silently dropping content.
|
|
6
6
|
export const INLINE_SECTION_MAX_CHARS = 200_000;
|
|
7
|
-
// Canonical forbidden-phrase list for no-read
|
|
8
|
-
//
|
|
7
|
+
// Canonical forbidden-phrase list for no-read prompts (today: the blocked-run
|
|
8
|
+
// consultant, which always judges from Neal-inlined in-memory context). A
|
|
9
|
+
// no-read prompt must contain no instruction that requires repository, file,
|
|
9
10
|
// tool, or shell access of any kind. Both the prompt-builder implementations
|
|
10
11
|
// and the runtime capture tests assert against this shared list, so a newly
|
|
11
12
|
// added repo-access phrase fails the shared assertion rather than silently
|
|
@@ -22,31 +23,18 @@ export const NO_READ_PROMPT_FORBIDDEN_MARKERS = [
|
|
|
22
23
|
'is available at',
|
|
23
24
|
'Inspect cited artifacts',
|
|
24
25
|
];
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return getProviderDefinition(reviewer.provider).capabilities['structured-advisor'].toolAccess.read === false;
|
|
33
|
-
}
|
|
34
|
-
// Three-way review-doctrine access mode for the configured reviewer provider's
|
|
35
|
-
// structured-advisor role. Unregistered provider ids default to 'tool-access',
|
|
36
|
-
// mirroring reviewerNeedsInlineContext's false. No repository read access
|
|
37
|
-
// selects 'no-read' (Neal inlines the review context); read access without
|
|
38
|
-
// shell access selects 'read-only' (the reviewer inspects the repository with
|
|
39
|
-
// read tools but must not be instructed to run commands); read plus shell
|
|
40
|
-
// access selects 'tool-access'. Write access does not affect review doctrine:
|
|
41
|
-
// reviews never instruct repository mutation.
|
|
26
|
+
// Two-way review-doctrine access mode for the configured reviewer provider's
|
|
27
|
+
// structured-advisor role. Unregistered provider ids default to 'tool-access'.
|
|
28
|
+
// Every registered reviewer provider has repository read access; read access
|
|
29
|
+
// without shell access selects 'read-only' (the reviewer inspects the
|
|
30
|
+
// repository with read tools but must not be instructed to run commands);
|
|
31
|
+
// read plus shell access selects 'tool-access'. Write access does not affect
|
|
32
|
+
// review doctrine: reviews never instruct repository mutation.
|
|
42
33
|
export function getReviewerDoctrineAccessMode(reviewer) {
|
|
43
34
|
if (!isRegisteredProviderId(reviewer.provider)) {
|
|
44
35
|
return 'tool-access';
|
|
45
36
|
}
|
|
46
37
|
const toolAccess = getProviderDefinition(reviewer.provider).capabilities['structured-advisor'].toolAccess;
|
|
47
|
-
if (toolAccess.read === false) {
|
|
48
|
-
return 'no-read';
|
|
49
|
-
}
|
|
50
38
|
if (toolAccess.shell === false) {
|
|
51
39
|
return 'read-only';
|
|
52
40
|
}
|
|
@@ -55,12 +43,9 @@ export function getReviewerDoctrineAccessMode(reviewer) {
|
|
|
55
43
|
// True when the configured reviewer is a read-only reviewer (read tools, no
|
|
56
44
|
// shell) that has no commit-range diff tool of its own, so Neal must inline the
|
|
57
45
|
// commit-range diff into its reviewer prompt. Read-only reviewers that expose a
|
|
58
|
-
// commit-range diff tool (providesRangeDiffTool === true, e.g.
|
|
59
|
-
// inspect the range with that tool and do not receive an inlined diff
|
|
60
|
-
//
|
|
61
|
-
// shell git themselves. The inlined commit-range diff rides a dedicated prompt
|
|
62
|
-
// channel separate from the no-read inlineContext channel, so it never trips
|
|
63
|
-
// resolveReviewerPromptAccessMode's no-read-only inlineContext guard.
|
|
46
|
+
// commit-range diff tool (providesRangeDiffTool === true, e.g. openai-compatible)
|
|
47
|
+
// inspect the range with that tool and do not receive an inlined diff, and
|
|
48
|
+
// tool-access reviewers run shell git themselves.
|
|
64
49
|
export function readOnlyReviewerNeedsInlinedDiff(reviewer) {
|
|
65
50
|
if (!isRegisteredProviderId(reviewer.provider)) {
|
|
66
51
|
return false;
|
|
@@ -37,6 +37,13 @@ export function buildReviewerContextPacket(args) {
|
|
|
37
37
|
claim: finding.claim,
|
|
38
38
|
requiredAction: finding.requiredAction,
|
|
39
39
|
}));
|
|
40
|
+
const inheritedPlanReviewDebt = state.inheritedPlanReviewDebt.map((item) => ({
|
|
41
|
+
canonicalId: item.canonicalId,
|
|
42
|
+
findingClass: item.findingClass ?? null,
|
|
43
|
+
originRound: item.originRound ?? null,
|
|
44
|
+
claim: item.claim,
|
|
45
|
+
requiredAction: item.requiredAction,
|
|
46
|
+
}));
|
|
40
47
|
const finalCompletion = summarizeFinalCompletion(state);
|
|
41
48
|
const packetWithoutMarkdown = {
|
|
42
49
|
version: 1,
|
|
@@ -55,6 +62,7 @@ export function buildReviewerContextPacket(args) {
|
|
|
55
62
|
},
|
|
56
63
|
completedScopes,
|
|
57
64
|
findings,
|
|
65
|
+
inheritedPlanReviewDebt,
|
|
58
66
|
finalCompletion,
|
|
59
67
|
citations: buildReviewerContextCitations(state),
|
|
60
68
|
limits: {
|
|
@@ -100,21 +108,21 @@ function buildReviewerContextCitations(state) {
|
|
|
100
108
|
{ label: REVIEWER_CONTEXT_MARKDOWN, path: toDisplayPath(state.cwd, join(state.runDir, REVIEWER_CONTEXT_MARKDOWN)) },
|
|
101
109
|
];
|
|
102
110
|
}
|
|
103
|
-
export function renderReviewerContextMarkdown(packet
|
|
104
|
-
const mode = options.mode ?? 'tool-access';
|
|
111
|
+
export function renderReviewerContextMarkdown(packet) {
|
|
105
112
|
const scopeLines = packet.completedScopes.length
|
|
106
113
|
? packet.completedScopes.map((scope) => `- Scope ${scope.number}: ${scope.result}; marker=${scope.marker}; finalCommit=${scope.finalCommit ?? 'none'}; reviewRounds=${scope.reviewRounds}; findings=${scope.findings}; residualDebt=${scope.residualReviewDebt}; summary=${scope.summary ?? 'none'}`)
|
|
107
114
|
: ['- none'];
|
|
108
115
|
const findingLines = packet.findings.length
|
|
109
116
|
? packet.findings.map((finding) => `- ${finding.canonicalId} (${finding.id}): ${finding.severity}/${finding.status}; source=${finding.source}; files=${finding.files.join(', ') || 'none'}; claim=${finding.claim}; requiredAction=${finding.requiredAction}`)
|
|
110
117
|
: ['- none'];
|
|
118
|
+
const inheritedDebtLines = packet.inheritedPlanReviewDebt.length
|
|
119
|
+
? packet.inheritedPlanReviewDebt.map((item) => `- ${item.canonicalId}: findingClass=${item.findingClass ?? 'n/a'}; originRound=${item.originRound ?? 'n/a'}; claim=${item.claim}; requiredAction=${item.requiredAction}`)
|
|
120
|
+
: ['- none'];
|
|
111
121
|
const finalCompletion = packet.finalCompletion;
|
|
112
122
|
return [
|
|
113
123
|
'# Reviewer Continuity Context',
|
|
114
124
|
'',
|
|
115
|
-
|
|
116
|
-
? 'Use this bounded Neal-owned context for continuity only. All required review context is inlined into this prompt; you have no access to the cited run artifacts or the checkout.'
|
|
117
|
-
: 'Use this bounded Neal-owned context for continuity only. Inspect cited artifacts or repository state directly before making findings.',
|
|
125
|
+
'Use this bounded Neal-owned context for continuity only. Inspect cited artifacts or repository state directly before making findings.',
|
|
118
126
|
'',
|
|
119
127
|
'## Run',
|
|
120
128
|
`- id: ${packet.run.id}`,
|
|
@@ -137,19 +145,16 @@ export function renderReviewerContextMarkdown(packet, options = {}) {
|
|
|
137
145
|
? `- truncated: showing latest ${packet.findings.length} of ${packet.limits.findingCount}`
|
|
138
146
|
: '- truncated: no',
|
|
139
147
|
'',
|
|
148
|
+
'## Inherited Plan-Review Debt',
|
|
149
|
+
...inheritedDebtLines,
|
|
150
|
+
'',
|
|
140
151
|
'## Final Completion',
|
|
141
152
|
finalCompletion
|
|
142
153
|
? `- state=${finalCompletion.state}; effectiveAction=${finalCompletion.effectiveAction ?? 'none'}; hasSummary=${finalCompletion.hasSummary}; hasReviewVerdict=${finalCompletion.hasReviewVerdict}; continueExecutionCount=${finalCompletion.continueExecutionCount}; capReached=${finalCompletion.continueExecutionCapReached}`
|
|
143
154
|
: '- not applicable',
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
...(
|
|
147
|
-
? []
|
|
148
|
-
: [
|
|
149
|
-
'',
|
|
150
|
-
'## Citations',
|
|
151
|
-
...packet.citations.map((citation) => `- ${citation.label}: ${citation.path}`),
|
|
152
|
-
]),
|
|
155
|
+
'',
|
|
156
|
+
'## Citations',
|
|
157
|
+
...packet.citations.map((citation) => `- ${citation.label}: ${citation.path}`),
|
|
153
158
|
].join('\n');
|
|
154
159
|
}
|
|
155
160
|
function runIdFromDir(runDir) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
function fail(context, message) {
|
|
4
|
+
throw new Error(`reviewer-eval manifest: ${context} ${message}`);
|
|
5
|
+
}
|
|
6
|
+
function asString(value, context, field) {
|
|
7
|
+
const candidate = value[field];
|
|
8
|
+
if (typeof candidate !== 'string' || candidate.trim() === '') {
|
|
9
|
+
fail(context, `must define a non-empty string ${field}`);
|
|
10
|
+
}
|
|
11
|
+
return candidate;
|
|
12
|
+
}
|
|
13
|
+
function parseExpectedFinding(raw, context) {
|
|
14
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
15
|
+
fail(context, 'each expectedFindings entry must be an object');
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
file: asString(raw, context, 'file'),
|
|
19
|
+
defectClass: asString(raw, context, 'defectClass'),
|
|
20
|
+
description: asString(raw, context, 'description'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Every path the diff adds or modifies, read from its `+++ b/<path>` headers.
|
|
24
|
+
function diffTargetFiles(diffText) {
|
|
25
|
+
const files = new Set();
|
|
26
|
+
for (const line of diffText.split('\n')) {
|
|
27
|
+
const match = /^\+\+\+ b\/(.+)$/.exec(line);
|
|
28
|
+
if (match) {
|
|
29
|
+
files.add(match[1].trim());
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
export function loadReviewerEvalManifest(manifestDir) {
|
|
35
|
+
const dir = isAbsolute(manifestDir) ? manifestDir : resolve(manifestDir);
|
|
36
|
+
const manifestPath = join(dir, 'manifest.json');
|
|
37
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
38
|
+
const rawFixtures = parsed.fixtures;
|
|
39
|
+
if (!Array.isArray(rawFixtures) || rawFixtures.length === 0) {
|
|
40
|
+
fail('manifest.json', 'must define a non-empty fixtures array');
|
|
41
|
+
}
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
return rawFixtures.map((raw, index) => {
|
|
44
|
+
const context = `fixtures[${index}]`;
|
|
45
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
46
|
+
fail(context, 'must be an object');
|
|
47
|
+
}
|
|
48
|
+
const id = asString(raw, context, 'id');
|
|
49
|
+
if (seen.has(id)) {
|
|
50
|
+
fail(context, `duplicate fixture id ${id}`);
|
|
51
|
+
}
|
|
52
|
+
seen.add(id);
|
|
53
|
+
const kind = raw.kind;
|
|
54
|
+
if (kind !== 'defective' && kind !== 'clean') {
|
|
55
|
+
fail(id, "kind must be 'defective' or 'clean'");
|
|
56
|
+
}
|
|
57
|
+
const diff = asString(raw, id, 'diff');
|
|
58
|
+
const baseDirRelative = asString(raw, id, 'baseDir');
|
|
59
|
+
const rawExpected = raw.expectedFindings;
|
|
60
|
+
if (!Array.isArray(rawExpected)) {
|
|
61
|
+
fail(id, 'expectedFindings must be an array');
|
|
62
|
+
}
|
|
63
|
+
const expectedFindings = rawExpected.map((entry) => parseExpectedFinding(entry, id));
|
|
64
|
+
if (kind === 'defective' && expectedFindings.length === 0) {
|
|
65
|
+
fail(id, 'a defective fixture must label at least one expected finding');
|
|
66
|
+
}
|
|
67
|
+
if (kind === 'clean' && expectedFindings.length > 0) {
|
|
68
|
+
fail(id, 'a clean fixture must not label any expected findings');
|
|
69
|
+
}
|
|
70
|
+
const diffPath = join(dir, diff);
|
|
71
|
+
const diffText = readFileSync(diffPath, 'utf8');
|
|
72
|
+
const targetFiles = diffTargetFiles(diffText);
|
|
73
|
+
for (const expected of expectedFindings) {
|
|
74
|
+
if (!targetFiles.has(expected.file)) {
|
|
75
|
+
fail(id, `expected finding file ${expected.file} is not modified by ${diff}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
kind,
|
|
81
|
+
expectedFindings,
|
|
82
|
+
diffPath,
|
|
83
|
+
baseDir: join(dir, baseDirRelative),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Reviewer-recall eval scoring (issue #22 Phase B).
|
|
2
|
+
//
|
|
3
|
+
// Given a labeled fixture set — diffs with known defects, plus clean diffs —
|
|
4
|
+
// and the findings a reviewer produced for each, this computes recall,
|
|
5
|
+
// precision, and blocking-finding rate. It is the measurement instrument for
|
|
6
|
+
// the eval-gated reviewer-doctrine rewrite (Phase C): run it against a
|
|
7
|
+
// reviewer prompt before and after a change and compare the numbers.
|
|
8
|
+
//
|
|
9
|
+
// The scoring here is pure and deterministic; the live reviewer invocation
|
|
10
|
+
// lives in scripts/eval-reviewer.mjs (operator-run, subscription-billed, like
|
|
11
|
+
// scripts/qualify-sdk.sh). Matching is intentionally coarse — a blocking
|
|
12
|
+
// finding matches an expected label when they name the same file — because a
|
|
13
|
+
// finer match (defect-class, line range) would encode judgment the labels
|
|
14
|
+
// cannot reliably carry. The coarseness is documented so a reader does not
|
|
15
|
+
// over-trust the numbers: recall counts "did the reviewer flag the right
|
|
16
|
+
// file", not "did it describe the exact defect".
|
|
17
|
+
/**
|
|
18
|
+
* Projects a reviewer round's raw findings (as produced by the review-findings
|
|
19
|
+
* loop: objects carrying at least a severity and a files list) into a scored
|
|
20
|
+
* observation. Unknown severities are treated as non_blocking so a malformed
|
|
21
|
+
* finding cannot inflate the blocking count, and a missing files list becomes
|
|
22
|
+
* empty. Keeping this here lets the operator runner stay a thin shell over the
|
|
23
|
+
* tested scoring core.
|
|
24
|
+
*/
|
|
25
|
+
export function toReviewerEvalObservation(fixtureId, rawFindings) {
|
|
26
|
+
const findings = rawFindings.map((finding) => ({
|
|
27
|
+
severity: finding.severity === 'blocking' ? 'blocking' : 'non_blocking',
|
|
28
|
+
files: Array.isArray(finding.files) ? finding.files.filter((file) => typeof file === 'string') : [],
|
|
29
|
+
}));
|
|
30
|
+
return { fixtureId, findings };
|
|
31
|
+
}
|
|
32
|
+
function normalizeFilePath(path) {
|
|
33
|
+
return path.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
34
|
+
}
|
|
35
|
+
function findingFileSet(observation) {
|
|
36
|
+
const files = new Set();
|
|
37
|
+
for (const finding of observation.findings) {
|
|
38
|
+
if (finding.severity !== 'blocking') {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const file of finding.files) {
|
|
42
|
+
files.add(normalizeFilePath(file));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Scores reviewer observations against labeled fixtures. Every fixture must
|
|
49
|
+
* have exactly one observation (missing observations throw — a fixture the
|
|
50
|
+
* runner failed to evaluate must not silently count as perfect recall).
|
|
51
|
+
*/
|
|
52
|
+
export function scoreReviewerEval(fixtures, observations) {
|
|
53
|
+
const observationById = new Map(observations.map((observation) => [observation.fixtureId, observation]));
|
|
54
|
+
const fixtureResults = [];
|
|
55
|
+
let totalExpectedLabels = 0;
|
|
56
|
+
let matchedLabels = 0;
|
|
57
|
+
let totalBlockingFindings = 0;
|
|
58
|
+
let truePositiveFindings = 0;
|
|
59
|
+
let falsePositiveFindings = 0;
|
|
60
|
+
let cleanFixturesFlagged = 0;
|
|
61
|
+
for (const fixture of fixtures) {
|
|
62
|
+
const observation = observationById.get(fixture.id);
|
|
63
|
+
if (!observation) {
|
|
64
|
+
throw new Error(`scoreReviewerEval: no observation for fixture ${fixture.id}`);
|
|
65
|
+
}
|
|
66
|
+
const blockingFindings = observation.findings.filter((finding) => finding.severity === 'blocking');
|
|
67
|
+
const blockingFindingCount = blockingFindings.length;
|
|
68
|
+
const findingFiles = findingFileSet(observation);
|
|
69
|
+
const matched = [];
|
|
70
|
+
const missed = [];
|
|
71
|
+
const labeledFiles = new Set();
|
|
72
|
+
for (const expected of fixture.expectedFindings) {
|
|
73
|
+
const file = normalizeFilePath(expected.file);
|
|
74
|
+
labeledFiles.add(file);
|
|
75
|
+
if (findingFiles.has(file)) {
|
|
76
|
+
matched.push(expected);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
missed.push(expected);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// A blocking finding is a true positive when it touches at least one
|
|
83
|
+
// labeled file; every other blocking finding (all of them on a clean
|
|
84
|
+
// fixture) is a false positive.
|
|
85
|
+
let truePositiveFindingCount = 0;
|
|
86
|
+
let falsePositiveFindingCount = 0;
|
|
87
|
+
for (const finding of blockingFindings) {
|
|
88
|
+
const touchesLabel = finding.files.some((file) => labeledFiles.has(normalizeFilePath(file)));
|
|
89
|
+
if (touchesLabel) {
|
|
90
|
+
truePositiveFindingCount += 1;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
falsePositiveFindingCount += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
totalExpectedLabels += fixture.expectedFindings.length;
|
|
97
|
+
matchedLabels += matched.length;
|
|
98
|
+
totalBlockingFindings += blockingFindingCount;
|
|
99
|
+
truePositiveFindings += truePositiveFindingCount;
|
|
100
|
+
falsePositiveFindings += falsePositiveFindingCount;
|
|
101
|
+
if (fixture.kind === 'clean' && blockingFindingCount > 0) {
|
|
102
|
+
cleanFixturesFlagged += 1;
|
|
103
|
+
}
|
|
104
|
+
fixtureResults.push({
|
|
105
|
+
fixtureId: fixture.id,
|
|
106
|
+
kind: fixture.kind,
|
|
107
|
+
matchedLabels: matched,
|
|
108
|
+
missedLabels: missed,
|
|
109
|
+
truePositiveFindingCount,
|
|
110
|
+
falsePositiveFindingCount,
|
|
111
|
+
blockingFindingCount,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const defectiveFixtures = fixtures.filter((fixture) => fixture.kind === 'defective').length;
|
|
115
|
+
const cleanFixtures = fixtures.filter((fixture) => fixture.kind === 'clean').length;
|
|
116
|
+
return {
|
|
117
|
+
fixtures: fixtureResults,
|
|
118
|
+
totalFixtures: fixtures.length,
|
|
119
|
+
defectiveFixtures,
|
|
120
|
+
cleanFixtures,
|
|
121
|
+
recall: totalExpectedLabels === 0 ? null : matchedLabels / totalExpectedLabels,
|
|
122
|
+
precision: totalBlockingFindings === 0 ? null : truePositiveFindings / totalBlockingFindings,
|
|
123
|
+
totalExpectedLabels,
|
|
124
|
+
matchedLabels,
|
|
125
|
+
totalBlockingFindings,
|
|
126
|
+
truePositiveFindings,
|
|
127
|
+
falsePositiveFindings,
|
|
128
|
+
cleanFalsePositiveRate: cleanFixtures === 0 ? null : cleanFixturesFlagged / cleanFixtures,
|
|
129
|
+
cleanFixturesFlagged,
|
|
130
|
+
blockingFindingRate: fixtures.length === 0 ? 0 : totalBlockingFindings / fixtures.length,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Renders a report as a compact human-readable table plus a summary line.
|
|
135
|
+
* The machine-readable form is the ReviewerEvalReport itself (write it as
|
|
136
|
+
* JSON); this is for terminal output.
|
|
137
|
+
*/
|
|
138
|
+
export function renderReviewerEvalReport(report) {
|
|
139
|
+
const pct = (value) => (value === null ? 'n/a' : `${(value * 100).toFixed(1)}%`);
|
|
140
|
+
const lines = [];
|
|
141
|
+
lines.push('| Fixture | Kind | Labels hit | Blocking | False+ |');
|
|
142
|
+
lines.push('|---|---|---:|---:|---:|');
|
|
143
|
+
for (const fixture of report.fixtures) {
|
|
144
|
+
const labels = fixture.kind === 'defective'
|
|
145
|
+
? `${fixture.matchedLabels.length}/${fixture.matchedLabels.length + fixture.missedLabels.length}`
|
|
146
|
+
: '—';
|
|
147
|
+
lines.push(`| ${fixture.fixtureId} | ${fixture.kind} | ${labels} | ${fixture.blockingFindingCount} | ${fixture.falsePositiveFindingCount} |`);
|
|
148
|
+
}
|
|
149
|
+
lines.push('');
|
|
150
|
+
lines.push(`Recall ${pct(report.recall)} (${report.matchedLabels}/${report.totalExpectedLabels} labels) · ` +
|
|
151
|
+
`Precision ${pct(report.precision)} (${report.truePositiveFindings}/${report.totalBlockingFindings} findings) · ` +
|
|
152
|
+
`Clean false-positive ${pct(report.cleanFalsePositiveRate)} (${report.cleanFixturesFlagged}/${report.cleanFixtures}) · ` +
|
|
153
|
+
`Blocking rate ${report.blockingFindingRate.toFixed(2)}/fixture`);
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
package/dist/neal/git.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
// Git output (diffs, status) scales with scope artifacts — a scope that
|
|
4
|
+
// vendors or generates a large file produces a multi-megabyte diff, and
|
|
5
|
+
// Node's default execFile maxBuffer (1 MiB) kills the run with
|
|
6
|
+
// "stdout maxBuffer length exceeded". 64 MiB clears any realistic scope
|
|
7
|
+
// diff while still bounding a runaway.
|
|
8
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
3
9
|
function runGit(args, cwd) {
|
|
4
10
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
5
|
-
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
|
11
|
+
execFile('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER }, (error, stdout, stderr) => {
|
|
6
12
|
if (error) {
|
|
7
13
|
rejectPromise(new Error(stderr.trim() || error.message));
|
|
8
14
|
return;
|
|
@@ -13,7 +19,7 @@ function runGit(args, cwd) {
|
|
|
13
19
|
}
|
|
14
20
|
function runGitOptionalConfig(args, cwd) {
|
|
15
21
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
16
|
-
execFile('git', args, { cwd }, (error, stdout, stderr) => {
|
|
22
|
+
execFile('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER }, (error, stdout, stderr) => {
|
|
17
23
|
if (!error) {
|
|
18
24
|
resolvePromise(stdout.trim());
|
|
19
25
|
return;
|
|
@@ -158,7 +164,7 @@ export async function resolveCommitRef(cwd, ref) {
|
|
|
158
164
|
}
|
|
159
165
|
export async function isAncestorCommit(cwd, ancestor, descendant) {
|
|
160
166
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
161
|
-
execFile('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd }, (error, _stdout, stderr) => {
|
|
167
|
+
execFile('git', ['merge-base', '--is-ancestor', ancestor, descendant], { cwd, maxBuffer: GIT_MAX_BUFFER }, (error, _stdout, stderr) => {
|
|
162
168
|
if (!error) {
|
|
163
169
|
resolvePromise(true);
|
|
164
170
|
return;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import readline from 'node:readline';
|
|
3
3
|
import { clearDiagnosticFooter, getBufferedDetailSnapshot, isDiagnosticDetailVisible, showDiagnosticDetailView, showDiagnosticNarrativeView, writeNarrative, } from './diagnostic.js';
|
|
4
|
+
export function renderInteractiveKeyHint(allowStopRequest) {
|
|
5
|
+
return allowStopRequest
|
|
6
|
+
? '[neal] keys: q stop after current scope, v show/hide details\n'
|
|
7
|
+
: '[neal] keys: v show/hide details\n';
|
|
8
|
+
}
|
|
4
9
|
export function createInteractiveKeyController(options = {}) {
|
|
5
10
|
let stopRequested = false;
|
|
6
11
|
let cleanedUp = false;
|