@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.
Files changed (120) hide show
  1. package/README.md +206 -209
  2. package/SECURITY.md +16 -19
  3. package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
  4. package/dist/neal/adjudicator/execute.js +33 -33
  5. package/dist/neal/adjudicator/final-completion.js +2 -31
  6. package/dist/neal/adjudicator/planning.js +264 -24
  7. package/dist/neal/agents/prompts.js +7 -15
  8. package/dist/neal/agents/rounds.js +51 -73
  9. package/dist/neal/agents/schemas.js +615 -502
  10. package/dist/neal/agents/structured-json.js +36 -0
  11. package/dist/neal/blocked-guidance.js +16 -9
  12. package/dist/neal/cli.js +2 -2
  13. package/dist/neal/commands/check.js +2 -2
  14. package/dist/neal/commands/compat.js +100 -36
  15. package/dist/neal/commands/interactive-activity.js +2 -2
  16. package/dist/neal/commands/recovery-guidance.js +20 -8
  17. package/dist/neal/commands/runtime.js +4 -2
  18. package/dist/neal/config.js +40 -7
  19. package/dist/neal/context/inline-review-context.js +13 -28
  20. package/dist/neal/context/reviewer-context.js +19 -14
  21. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  22. package/dist/neal/eval/reviewer-eval.js +155 -0
  23. package/dist/neal/git.js +9 -3
  24. package/dist/neal/interactive-controls.js +5 -0
  25. package/dist/neal/orchestrator/completion.js +215 -113
  26. package/dist/neal/orchestrator/failures.js +7 -0
  27. package/dist/neal/orchestrator/notifications.js +9 -9
  28. package/dist/neal/orchestrator/phases/planning.js +124 -112
  29. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  30. package/dist/neal/orchestrator/phases/review.js +28 -1
  31. package/dist/neal/orchestrator/split-plan.js +12 -11
  32. package/dist/neal/orchestrator/transitions.js +36 -71
  33. package/dist/neal/orchestrator.js +3 -0
  34. package/dist/neal/plan-doc.js +24 -1
  35. package/dist/neal/plan-queue.js +29 -1
  36. package/dist/neal/prompts/assert-builder.js +8 -1
  37. package/dist/neal/prompts/execute.js +11 -19
  38. package/dist/neal/prompts/planning.js +64 -55
  39. package/dist/neal/prompts/review-doctrine.js +13 -50
  40. package/dist/neal/prompts/specialized.js +28 -22
  41. package/dist/neal/prompts/specs.js +223 -17
  42. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  43. package/dist/neal/providers/anthropic-claude.js +309 -248
  44. package/dist/neal/providers/detection.js +3 -7
  45. package/dist/neal/providers/git-config-isolation.js +83 -0
  46. package/dist/neal/providers/openai-codex.js +102 -204
  47. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  48. package/dist/neal/providers/openai-compatible.js +1079 -403
  49. package/dist/neal/providers/openrouter-routing.js +68 -0
  50. package/dist/neal/providers/pricing.js +124 -0
  51. package/dist/neal/providers/rate-card.js +2275 -0
  52. package/dist/neal/providers/registry.js +6 -17
  53. package/dist/neal/providers/telemetry.js +4 -0
  54. package/dist/neal/providers/types.js +12 -0
  55. package/dist/neal/resume-decision.js +7 -6
  56. package/dist/neal/resume-planner.js +9 -0
  57. package/dist/neal/retrospective.js +36 -7
  58. package/dist/neal/review-debt.js +30 -0
  59. package/dist/neal/review-findings/provider.js +17 -29
  60. package/dist/neal/review.js +17 -1
  61. package/dist/neal/run-metrics.js +74 -9
  62. package/dist/neal/state-invariants.js +11 -3
  63. package/dist/neal/state-views.js +27 -5
  64. package/dist/neal/state.js +59 -15
  65. package/dist/neal/status.js +28 -1
  66. package/dist/neal/support.js +3 -3
  67. package/docs/README.md +22 -18
  68. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  69. package/docs/architecture.md +79 -83
  70. package/docs/automation.md +11 -11
  71. package/docs/compat.md +91 -61
  72. package/docs/compatible-models.md +145 -110
  73. package/docs/demo.md +15 -5
  74. package/docs/maintenance.md +74 -32
  75. package/docs/plan-format.md +61 -33
  76. package/docs/prompt-evals.md +96 -0
  77. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  78. package/docs/providers.md +330 -283
  79. package/docs/release.md +43 -40
  80. package/docs/review-convergence.md +246 -0
  81. package/docs/state-machine.md +61 -59
  82. package/docs/storage.md +40 -31
  83. package/docs/troubleshooting.md +32 -29
  84. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  85. package/examples/compat/add-edit-verify/broken.diff +2 -2
  86. package/examples/compat/add-edit-verify/good.diff +2 -2
  87. package/examples/compat/add-edit-verify/src/add.js +1 -1
  88. package/examples/compat/manifest.json +25 -29
  89. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  90. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  91. package/examples/compat/sum-grep-edit/good.diff +12 -0
  92. package/examples/compat/sum-grep-edit/package.json +5 -0
  93. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  94. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  95. package/examples/issue-triage-js/README.md +0 -15
  96. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  97. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  98. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  99. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  100. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  101. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  102. package/examples/reviewer-eval/manifest.json +45 -0
  103. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  104. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  105. package/{config.yml → neal.yml} +1 -1
  106. package/package.json +20 -15
  107. package/dist/neal/providers/generic-agentic.js +0 -1238
  108. package/docs/comparison.md +0 -105
  109. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  110. package/examples/compat/is-even-add-test/broken.diff +0 -11
  111. package/examples/compat/is-even-add-test/good.diff +0 -11
  112. package/examples/compat/is-even-add-test/package.json +0 -5
  113. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  114. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  115. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  116. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  117. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  118. package/examples/compat/reverse-grep-edit/package.json +0 -5
  119. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  120. 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 reviewer prompts. A no-read
8
- // reviewer prompt must contain no instruction that requires repository, file,
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
- // True when the configured reviewer provider's structured-advisor role has no
26
- // repository read access, so Neal must inline the review context (diff, plan,
27
- // artifacts) into reviewer prompts.
28
- export function reviewerNeedsInlineContext(reviewer) {
29
- if (!isRegisteredProviderId(reviewer.provider)) {
30
- return false;
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. generic-agentic)
59
- // inspect the range with that tool and do not receive an inlined diff; no-read
60
- // reviewers get the full inlineContext instead, and tool-access reviewers run
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, options = {}) {
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
- mode === 'inline'
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
- // Citation paths are reading instructions; a no-read reviewer cannot
145
- // follow them, so the inline rendering omits the section entirely.
146
- ...(mode === 'inline'
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;