acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ SHOW THE CHANGE BEFORE IT LANDS, NOT AFTER ──────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* This agent writes a file and then tells you what it wrote. Every terminal
|
|
5
|
+
* agent people compare us to shows the diff and asks first. That ordering is
|
|
6
|
+
* the single biggest felt difference on a first run, and it is not a cosmetic
|
|
7
|
+
* one: "I rewrote 200 lines of a 210-line file" reported afterwards is a
|
|
8
|
+
* post-mortem, and reported beforehand it is a veto.
|
|
9
|
+
*
|
|
10
|
+
* `report.mjs` already saw half of this and said so out loud — *"the CLI tells
|
|
11
|
+
* you WHICH files it touched and never WHAT it did to them"* — and deliberately
|
|
12
|
+
* stopped short of a unified diff because a report that runs off the screen is
|
|
13
|
+
* one nobody reads. That reasoning is right for a REPORT and wrong for a
|
|
14
|
+
* PROMPT: a person about to answer y/n will read forty lines, and the cap is a
|
|
15
|
+
* rendering problem, not a reason to withhold the diff.
|
|
16
|
+
*
|
|
17
|
+
* ── ⚠️⚠️ THIS IS NOT A SECURITY BOUNDARY, AND BUILDING IT AS ONE BREAKS IT ───
|
|
18
|
+
*
|
|
19
|
+
* The temptation is to make approval fail closed everywhere, the way
|
|
20
|
+
* `mcp-consent.mjs` does. It is the wrong instinct here, and the difference is
|
|
21
|
+
* worth stating precisely because getting it backwards is what kills the
|
|
22
|
+
* feature:
|
|
23
|
+
*
|
|
24
|
+
* `checkMcpConsent` guards a **foreign binary being executed**. There is no
|
|
25
|
+
* other control standing between a cloned repo and code running as you, so
|
|
26
|
+
* "nobody could be asked" has to mean "no".
|
|
27
|
+
*
|
|
28
|
+
* A write is already guarded four times over before it reaches here:
|
|
29
|
+
* `resolveInWorkspace` confines it to the root, `WRITE_FORBIDDEN_ROOTS`
|
|
30
|
+
* refuses `.git` and friends, `MAX_WRITE_BYTES` bounds it, and
|
|
31
|
+
* `policy.requireDryRun` is the org control that says "this machine may not
|
|
32
|
+
* be written to at all". Approval is a **review affordance on top of those**,
|
|
33
|
+
* not a fifth lock.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ SO WHAT HAPPENS WITH NO TERMINAL IS THE WHOLE DESIGN QUESTION. In CI, in
|
|
36
|
+
* `--parallel`, in the fleet, in a pipe, nobody can answer. `prompt.mjs` already
|
|
37
|
+
* pays for that lesson in its own header: a prompt with no terminal must fail
|
|
38
|
+
* with a message and NEVER block, because a hang is the least legible failure
|
|
39
|
+
* there is. Blocking is off the table. That leaves two options and both are
|
|
40
|
+
* wrong in isolation:
|
|
41
|
+
*
|
|
42
|
+
* · **Refuse the write.** Then the day this merges, every unattended run
|
|
43
|
+
* stops working. The workaround people reach for is `ACUVO_APPROVE=never`
|
|
44
|
+
* in their shell profile — which switches review off on their laptop too.
|
|
45
|
+
* ⭐ A gate that breaks unattended runs gets globally disabled, and then it
|
|
46
|
+
* protects nobody. That is strictly worse than not shipping it.
|
|
47
|
+
* · **Write silently.** Then an operator who explicitly asked for a review
|
|
48
|
+
* gate gets no gate and no warning, which is the fail-open-on-a-typo shape
|
|
49
|
+
* `policy.mjs` refuses by name.
|
|
50
|
+
*
|
|
51
|
+
* ⭐ THE SPLIT IS ON WHETHER ANYBODY ASKED. Default (`auto`) with no terminal:
|
|
52
|
+
* the write proceeds, is marked `reviewed: false`, and the reason says so, so
|
|
53
|
+
* the run summary can count "4 files written without review". Explicit
|
|
54
|
+
* (`--approve` / `ACUVO_APPROVE=always`) with no terminal: that is a stated
|
|
55
|
+
* intent we cannot honour, so it refuses and names the way out — exactly the
|
|
56
|
+
* `checkMcpConsent` shape, applied only where somebody actually stated the
|
|
57
|
+
* intent.
|
|
58
|
+
*
|
|
59
|
+
* ── ⚠️⚠️ AND ASKING ABOUT EVERY WRITE IS THE SAME AS ASKING ABOUT NONE ──────
|
|
60
|
+
*
|
|
61
|
+
* `mcp-consent.mjs` says it twice in its own comments: *a nag people learn to
|
|
62
|
+
* click through is worse than no prompt.* A 40-file build that asks 40 times
|
|
63
|
+
* trains the exact reflex the feature exists to prevent, and the 41st question
|
|
64
|
+
* — the one that mattered — gets the same reflex `y`.
|
|
65
|
+
*
|
|
66
|
+
* ⭐ THE GRANULARITY IS NOT "PER FILE" AND NOT "PER RUN". IT IS **PER
|
|
67
|
+
* IRREVERSIBLE ACT**. Creating a file destroys nothing; you can delete it
|
|
68
|
+
* afterwards and be exactly where you started. Overwriting or deleting content
|
|
69
|
+
* that existed BEFORE this run destroys the only copy — that content exists
|
|
70
|
+
* nowhere else on the machine. So:
|
|
71
|
+
*
|
|
72
|
+
* create a new file → never asked (nothing is lost)
|
|
73
|
+
* rewrite a file THIS RUN created → never asked (the agent revising
|
|
74
|
+
* its own draft)
|
|
75
|
+
* write identical bytes → never asked (a no-op is not a
|
|
76
|
+
* decision)
|
|
77
|
+
* scratch / build output → never asked (see the caveat below)
|
|
78
|
+
* overwrite pre-existing content → ASK
|
|
79
|
+
* delete a pre-existing file → ASK, highest risk
|
|
80
|
+
* a credential path, or `.acuvo/` → ASK, whatever else is true
|
|
81
|
+
*
|
|
82
|
+
* On a realistic build that is a handful of questions, not forty, and every one
|
|
83
|
+
* of them is a question a person would actually want.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ THE SCRATCH LIST IS A HEURISTIC AND IS LABELLED AS ONE. `dist/` being
|
|
86
|
+
* regenerable is a convention, not a fact we can verify; someone's `dist/` is
|
|
87
|
+
* hand-maintained. It is overridable for that reason, and it only ever
|
|
88
|
+
* downgrades an overwrite to "not asked" — it never upgrades anything, so the
|
|
89
|
+
* worst case is a question that should have been asked, in a directory whose
|
|
90
|
+
* whole purpose is to be rebuilt.
|
|
91
|
+
*
|
|
92
|
+
* ⭐ AND `[a]ll remaining` IS A DELIBERATE ANSWER, NOT A CONVENIENCE. People
|
|
93
|
+
* disengage from prompts; that is a fact about people, not a bug to design out.
|
|
94
|
+
* The choice is whether disengagement is EXPLICIT and recorded once, or
|
|
95
|
+
* simulated by forty reflex keystrokes. Offering "stop asking me" makes it the
|
|
96
|
+
* former, and the run summary can then say "you approved the rest at file 3".
|
|
97
|
+
*
|
|
98
|
+
* ── ⚠️ THE DIFF ITSELF HAS TO BE HONEST ABOUT INVISIBLE CHANGES ─────────────
|
|
99
|
+
*
|
|
100
|
+
* A preview that renders a change as two identical-looking lines is worse than
|
|
101
|
+
* no preview, because it invites a yes on a false reading. Three real cases,
|
|
102
|
+
* all handled here rather than left to the eye:
|
|
103
|
+
*
|
|
104
|
+
* · **CRLF ↔ LF.** Diffing lines with their terminators attached marks every
|
|
105
|
+
* line in the file changed and prints hundreds of `-foo` / `+foo` pairs.
|
|
106
|
+
* So lines are compared WITHOUT terminators and the ending change is
|
|
107
|
+
* reported in words instead: "line endings changed CRLF → LF".
|
|
108
|
+
* · **A trailing newline appearing or vanishing.** Zero visible line changes.
|
|
109
|
+
* Forced into a real `-`/`+` pair carrying git's
|
|
110
|
+
* `` marker, which is the whole point of that
|
|
111
|
+
* marker existing.
|
|
112
|
+
* · **Trailing whitespace.** `-a` and `+a` where one has a space. Annotated
|
|
113
|
+
* on the line, because nobody has ever spotted that unaided.
|
|
114
|
+
*
|
|
115
|
+
* Pure throughout. No filesystem, no spawn, no network, no clock — the caller
|
|
116
|
+
* supplies the before and after text, and every branch below is reachable from
|
|
117
|
+
* a test with none of those things.
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* ⭐ IMPORTED, NEVER RE-IMPLEMENTED — `secret-paths.mjs` exists precisely
|
|
122
|
+
* because two copies of "is this a credential?" drifted apart and which of a
|
|
123
|
+
* user's secrets were protected depended on which verb the model picked.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ AND IT IS THE ONLY IMPORT, WHICH IS A MEASURED CONSTRAINT RATHER THAN A
|
|
126
|
+
* PREFERENCE. `isPolicyProtectedPath` in `policy.mjs` answers the `.acuvo/`
|
|
127
|
+
* question and would be the natural second import — but `policy.mjs` imports
|
|
128
|
+
* `tools.mjs` → `command.mjs` → `workspace.mjs`, and `workspace.mjs` is where
|
|
129
|
+
* this module gets called from. That is a cycle, and `secret-paths.mjs`'s own
|
|
130
|
+
* header records what a cycle costs here: Node runs it happily, every test
|
|
131
|
+
* passes, and `scripts/bundle.mjs` cannot topologically order it, so the break
|
|
132
|
+
* lands at ship time. The `.acuvo/` predicate below is therefore a deliberate
|
|
133
|
+
* two-line duplicate, and it is injectable so the lead can pass the real one in
|
|
134
|
+
* from a layer that already has both.
|
|
135
|
+
*/
|
|
136
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
137
|
+
|
|
138
|
+
// ── caps ───────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Bigger than this and we do not attempt a diff.
|
|
142
|
+
*
|
|
143
|
+
* ⭐ Matched to `workspace.mjs`'s `MAX_READ_BYTES` (200_000) on purpose rather
|
|
144
|
+
* than to `MAX_WRITE_BYTES` (400_000): a file this agent cannot READ as text is
|
|
145
|
+
* one it cannot meaningfully show you either, so the two limits agreeing means a
|
|
146
|
+
* user never meets a file that is diffable but not readable, or the reverse.
|
|
147
|
+
*/
|
|
148
|
+
export const MAX_DIFF_BYTES = 200_000;
|
|
149
|
+
|
|
150
|
+
/** A file taller than this is a data dump, not something a person reviews. */
|
|
151
|
+
export const MAX_DIFF_LINES = 20_000;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ⚠️ ONE LONG LINE IS THE MINIFIED-BUNDLE CASE, and it defeats a line diff
|
|
155
|
+
* completely: `dist/app.js` is a single 300KB line, so the "diff" is one `-`
|
|
156
|
+
* and one `+` containing the entire file. Refusing is honest; printing that is
|
|
157
|
+
* not a preview.
|
|
158
|
+
*/
|
|
159
|
+
export const MAX_DIFF_LINE_CHARS = 2_000;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Myers is O((N+M)·D). D is small for real edits (the common prefix and suffix
|
|
163
|
+
* are trimmed first), but two unrelated 5,000-line files have D ≈ 10,000 and
|
|
164
|
+
* would take a noticeable second to compare for no benefit. Past this we say
|
|
165
|
+
* "this file was replaced wholesale" — which is both true and the thing the
|
|
166
|
+
* reviewer needed to know anyway.
|
|
167
|
+
*/
|
|
168
|
+
export const MAX_EDIT_DISTANCE = 3_000;
|
|
169
|
+
|
|
170
|
+
/** Unified-diff context lines either side of a change. Three is the universal default. */
|
|
171
|
+
export const DEFAULT_CONTEXT = 3;
|
|
172
|
+
|
|
173
|
+
/** How many diff lines a prompt prints before it truncates and says so. */
|
|
174
|
+
export const DEFAULT_PREVIEW_LINES = 60;
|
|
175
|
+
|
|
176
|
+
/** The way out of the truncation, named in the truncation message itself. */
|
|
177
|
+
export const DIFF_LINES_ENV = 'ACUVO_DIFF_LINES';
|
|
178
|
+
|
|
179
|
+
/** The env var that chooses the review mode, and the modes it accepts. */
|
|
180
|
+
export const APPROVE_ENV = 'ACUVO_APPROVE';
|
|
181
|
+
export const APPROVE_MODES = Object.freeze(['auto', 'always', 'never']);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Directories whose contents are regenerated rather than authored. See the
|
|
185
|
+
* header: a heuristic, only ever downgrades, and overridable.
|
|
186
|
+
*/
|
|
187
|
+
export const SCRATCH_PREFIXES = Object.freeze([
|
|
188
|
+
'dist/', 'build/', 'out/', '.next/', '.turbo/', '.cache/',
|
|
189
|
+
'coverage/', 'tmp/', '.tmp/', 'node_modules/',
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
/** Risk, ordered. `auto` asks at `medium` and above. */
|
|
193
|
+
export const RISK_ORDER = Object.freeze({ none: 0, low: 1, medium: 2, high: 3 });
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Removing this fraction or more of a pre-existing file is the "it quietly
|
|
197
|
+
* deleted something" case `report.mjs` was written to surface. It does not
|
|
198
|
+
* change WHETHER we ask — an overwrite is asked about regardless — it changes
|
|
199
|
+
* the risk label and the wording, so the dangerous one does not read like the
|
|
200
|
+
* routine one.
|
|
201
|
+
*/
|
|
202
|
+
export const REWRITE_FRACTION = 0.5;
|
|
203
|
+
|
|
204
|
+
// ── line splitting ─────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Split into lines, keeping the terminators SEPARATELY.
|
|
208
|
+
*
|
|
209
|
+
* ⚠️ NOT `text.split('\n')`. That leaves a `\r` glued to the end of every line
|
|
210
|
+
* of a CRLF file, which then compares unequal to the same line in an LF file and
|
|
211
|
+
* marks the whole file changed. The terminators are kept because they are real
|
|
212
|
+
* (a CRLF→LF flip is a genuine change) but they are compared separately, so the
|
|
213
|
+
* change is reported once in words instead of once per line.
|
|
214
|
+
*
|
|
215
|
+
* A lone `\r` is a terminator too — old Mac line endings still turn up in
|
|
216
|
+
* fixtures, and treating one as ordinary text would make a whole file into one
|
|
217
|
+
* enormous line and then trip `MAX_DIFF_LINE_CHARS`.
|
|
218
|
+
*/
|
|
219
|
+
export function splitLines(text) {
|
|
220
|
+
const s = String(text ?? '');
|
|
221
|
+
const lines = [];
|
|
222
|
+
const eols = [];
|
|
223
|
+
let start = 0;
|
|
224
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
225
|
+
const c = s[i];
|
|
226
|
+
if (c === '\n') {
|
|
227
|
+
lines.push(s.slice(start, i)); eols.push('\n'); start = i + 1;
|
|
228
|
+
} else if (c === '\r') {
|
|
229
|
+
if (s[i + 1] === '\n') { lines.push(s.slice(start, i)); eols.push('\r\n'); i += 1; } else { lines.push(s.slice(start, i)); eols.push('\r'); }
|
|
230
|
+
start = i + 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Text after the last terminator is a line with no terminator — the case git
|
|
234
|
+
// marks with ``.
|
|
235
|
+
if (start < s.length) {
|
|
236
|
+
lines.push(s.slice(start));
|
|
237
|
+
eols.push('');
|
|
238
|
+
return { lines, eols, finalNewline: false };
|
|
239
|
+
}
|
|
240
|
+
return { lines, eols, finalNewline: true };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 'lf' | 'crlf' | 'cr' | 'mixed' | 'none' */
|
|
244
|
+
export function eolStyle(eols) {
|
|
245
|
+
let lf = 0; let crlf = 0; let cr = 0;
|
|
246
|
+
for (const e of eols) {
|
|
247
|
+
if (e === '\n') lf += 1;
|
|
248
|
+
else if (e === '\r\n') crlf += 1;
|
|
249
|
+
else if (e === '\r') cr += 1;
|
|
250
|
+
}
|
|
251
|
+
const kinds = [lf > 0 ? 'lf' : null, crlf > 0 ? 'crlf' : null, cr > 0 ? 'cr' : null].filter(Boolean);
|
|
252
|
+
if (kinds.length === 0) return 'none';
|
|
253
|
+
if (kinds.length > 1) return 'mixed';
|
|
254
|
+
return kinds[0];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── refusing to render ─────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Can this pair be shown at all?
|
|
261
|
+
*
|
|
262
|
+
* ⭐ RETURNS THE REASON, AND THE REASON NAMES THE WAY OUT. "cannot render" on
|
|
263
|
+
* its own is an obstacle; a reviewer who is told the file is binary and that
|
|
264
|
+
* they can inspect it with `git diff` after the run has been given a decision.
|
|
265
|
+
*
|
|
266
|
+
* ⚠️ A REFUSAL TO *RENDER* IS NOT A REFUSAL TO *ASK*. The policy below still
|
|
267
|
+
* runs, and the prompt still appears — it just carries sizes and a reason
|
|
268
|
+
* instead of hunks. Silently skipping the question because we could not draw a
|
|
269
|
+
* picture would mean the largest and least legible writes are the ones that
|
|
270
|
+
* land unreviewed, which is precisely backwards.
|
|
271
|
+
*
|
|
272
|
+
* @returns {string|null}
|
|
273
|
+
*/
|
|
274
|
+
export function refuseToRender(before, after, { maxBytes = MAX_DIFF_BYTES, maxLines = MAX_DIFF_LINES, maxLineChars = MAX_DIFF_LINE_CHARS } = {}) {
|
|
275
|
+
for (const [label, text] of [['the previous contents', before], ['the new contents', after]]) {
|
|
276
|
+
if (text === null || text === undefined) continue;
|
|
277
|
+
const s = String(text);
|
|
278
|
+
if (s.includes('\u0000')) {
|
|
279
|
+
return `${label} contain a NUL byte, so this is binary and a line diff of it would be noise. `
|
|
280
|
+
+ 'Inspect it with your own tools after the run (`git diff -- <path>` shows it as "Binary files differ").';
|
|
281
|
+
}
|
|
282
|
+
const bytes = Buffer.byteLength(s, 'utf8');
|
|
283
|
+
if (bytes > maxBytes) {
|
|
284
|
+
return `${label} are ${bytes} bytes, over the ${maxBytes}-byte preview limit. `
|
|
285
|
+
+ 'The change is still described by its counts below; review the file itself if the counts look wrong.';
|
|
286
|
+
}
|
|
287
|
+
const { lines } = splitLines(s);
|
|
288
|
+
if (lines.length > maxLines) {
|
|
289
|
+
return `${label} are ${lines.length} lines, over the ${maxLines}-line preview limit. `
|
|
290
|
+
+ 'The change is still described by its counts below; review the file itself if the counts look wrong.';
|
|
291
|
+
}
|
|
292
|
+
const longest = lines.reduce((n, l) => Math.max(n, l.length), 0);
|
|
293
|
+
if (longest > maxLineChars) {
|
|
294
|
+
return `${label} contain a ${longest}-character line, over the ${maxLineChars}-character limit — `
|
|
295
|
+
+ 'this is minified or generated, and a line diff of it shows the whole file as one changed line. '
|
|
296
|
+
+ 'Review the source it was generated from instead.';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── the diff engine ────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Myers' greedy shortest-edit-script, returning ops or `null` when the two
|
|
306
|
+
* files are too different to be worth aligning.
|
|
307
|
+
*
|
|
308
|
+
* ⚠️ `null` IS A REAL ANSWER AND MUST NOT BE TREATED AS "NO CHANGES". The
|
|
309
|
+
* caller turns it into a wholesale replace, which is honest; a bug that read it
|
|
310
|
+
* as "identical" would be a preview showing an empty diff for a total rewrite —
|
|
311
|
+
* the single worst thing this module could do.
|
|
312
|
+
*/
|
|
313
|
+
function shortestEdit(a, b, maxEdits) {
|
|
314
|
+
const n = a.length;
|
|
315
|
+
const m = b.length;
|
|
316
|
+
const max = Math.min(maxEdits, n + m);
|
|
317
|
+
const offset = max + 1;
|
|
318
|
+
const v = new Int32Array(2 * max + 3);
|
|
319
|
+
const trace = [];
|
|
320
|
+
|
|
321
|
+
for (let d = 0; d <= max; d += 1) {
|
|
322
|
+
// ⚠️ Recorded BEFORE this round runs. The backtrack re-derives round d's
|
|
323
|
+
// move from the state that preceded it, so storing the post-state here
|
|
324
|
+
// would make every step off by one and silently mislabel insertions.
|
|
325
|
+
trace.push(v.slice());
|
|
326
|
+
for (let k = -d; k <= d; k += 2) {
|
|
327
|
+
let x;
|
|
328
|
+
if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) x = v[offset + k + 1];
|
|
329
|
+
else x = v[offset + k - 1] + 1;
|
|
330
|
+
let y = x - k;
|
|
331
|
+
while (x < n && y < m && a[x] === b[y]) { x += 1; y += 1; }
|
|
332
|
+
v[offset + k] = x;
|
|
333
|
+
if (x >= n && y >= m) return backtrack(trace, offset, n, m);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function backtrack(trace, offset, n, m) {
|
|
340
|
+
const ops = [];
|
|
341
|
+
let x = n;
|
|
342
|
+
let y = m;
|
|
343
|
+
for (let d = trace.length - 1; d >= 0; d -= 1) {
|
|
344
|
+
const v = trace[d];
|
|
345
|
+
const k = x - y;
|
|
346
|
+
let prevK;
|
|
347
|
+
if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) prevK = k + 1;
|
|
348
|
+
else prevK = k - 1;
|
|
349
|
+
const prevX = v[offset + prevK];
|
|
350
|
+
const prevY = prevX - prevK;
|
|
351
|
+
while (x > prevX && y > prevY) {
|
|
352
|
+
ops.push({ sign: '=', oldIndex: x - 1, newIndex: y - 1 });
|
|
353
|
+
x -= 1; y -= 1;
|
|
354
|
+
}
|
|
355
|
+
if (d > 0) {
|
|
356
|
+
if (x === prevX) ops.push({ sign: '+', oldIndex: -1, newIndex: y - 1 });
|
|
357
|
+
else ops.push({ sign: '-', oldIndex: x - 1, newIndex: -1 });
|
|
358
|
+
}
|
|
359
|
+
x = prevX; y = prevY;
|
|
360
|
+
}
|
|
361
|
+
ops.reverse();
|
|
362
|
+
return ops;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Trim the shared head and tail before running Myers.
|
|
367
|
+
*
|
|
368
|
+
* Not an optimisation for its own sake: a one-line change in a 3,000-line file
|
|
369
|
+
* has D = 2 after trimming and D = 2 before it only by luck of the algorithm's
|
|
370
|
+
* shape. Trimming makes the `MAX_EDIT_DISTANCE` ceiling apply to how DIFFERENT
|
|
371
|
+
* the files are rather than to how BIG they are, which is the property that
|
|
372
|
+
* keeps ordinary edits to large files inside the fast path.
|
|
373
|
+
*/
|
|
374
|
+
function diffLines(a, b, maxEdits) {
|
|
375
|
+
let head = 0;
|
|
376
|
+
while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
|
|
377
|
+
let tail = 0;
|
|
378
|
+
while (tail < a.length - head && tail < b.length - head
|
|
379
|
+
&& a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1;
|
|
380
|
+
|
|
381
|
+
const midA = a.slice(head, a.length - tail);
|
|
382
|
+
const midB = b.slice(head, b.length - tail);
|
|
383
|
+
const mid = shortestEdit(midA, midB, maxEdits);
|
|
384
|
+
if (mid === null) return null;
|
|
385
|
+
|
|
386
|
+
const ops = [];
|
|
387
|
+
for (let i = 0; i < head; i += 1) ops.push({ sign: '=', oldIndex: i, newIndex: i });
|
|
388
|
+
for (const op of mid) {
|
|
389
|
+
ops.push({
|
|
390
|
+
sign: op.sign,
|
|
391
|
+
oldIndex: op.oldIndex >= 0 ? op.oldIndex + head : -1,
|
|
392
|
+
newIndex: op.newIndex >= 0 ? op.newIndex + head : -1,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
for (let i = 0; i < tail; i += 1) {
|
|
396
|
+
ops.push({ sign: '=', oldIndex: a.length - tail + i, newIndex: b.length - tail + i });
|
|
397
|
+
}
|
|
398
|
+
return ops;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Every old line removed, then every new line added. The honest fallback. */
|
|
402
|
+
function wholesale(a, b) {
|
|
403
|
+
const ops = [];
|
|
404
|
+
for (let i = 0; i < a.length; i += 1) ops.push({ sign: '-', oldIndex: i, newIndex: -1 });
|
|
405
|
+
for (let i = 0; i < b.length; i += 1) ops.push({ sign: '+', oldIndex: -1, newIndex: i });
|
|
406
|
+
return ops;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function groupHunks(ops, context) {
|
|
410
|
+
const changedAt = [];
|
|
411
|
+
for (let i = 0; i < ops.length; i += 1) if (ops[i].sign !== '=') changedAt.push(i);
|
|
412
|
+
if (changedAt.length === 0) return [];
|
|
413
|
+
|
|
414
|
+
const groups = [];
|
|
415
|
+
let start = changedAt[0];
|
|
416
|
+
let end = changedAt[0];
|
|
417
|
+
for (let i = 1; i < changedAt.length; i += 1) {
|
|
418
|
+
// Two changes closer than 2×context share a hunk, otherwise their context
|
|
419
|
+
// blocks would overlap and print the same lines twice.
|
|
420
|
+
if (changedAt[i] - end <= context * 2 + 1) end = changedAt[i];
|
|
421
|
+
else { groups.push([start, end]); start = changedAt[i]; end = changedAt[i]; }
|
|
422
|
+
}
|
|
423
|
+
groups.push([start, end]);
|
|
424
|
+
|
|
425
|
+
// Running counts of how many old/new lines precede each op, so a hunk header
|
|
426
|
+
// can be produced for a pure insertion (which has no old line to read a
|
|
427
|
+
// number off).
|
|
428
|
+
const oldBefore = new Array(ops.length);
|
|
429
|
+
const newBefore = new Array(ops.length);
|
|
430
|
+
let o = 0; let nw = 0;
|
|
431
|
+
for (let i = 0; i < ops.length; i += 1) {
|
|
432
|
+
oldBefore[i] = o; newBefore[i] = nw;
|
|
433
|
+
if (ops[i].sign !== '+') o += 1;
|
|
434
|
+
if (ops[i].sign !== '-') nw += 1;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return groups.map(([g0, g1]) => {
|
|
438
|
+
const from = Math.max(0, g0 - context);
|
|
439
|
+
const to = Math.min(ops.length - 1, g1 + context);
|
|
440
|
+
const slice = ops.slice(from, to + 1);
|
|
441
|
+
const oldLines = slice.filter((op) => op.sign !== '+').length;
|
|
442
|
+
const newLines = slice.filter((op) => op.sign !== '-').length;
|
|
443
|
+
return {
|
|
444
|
+
oldStart: oldLines > 0 ? oldBefore[from] + 1 : oldBefore[from],
|
|
445
|
+
oldLines,
|
|
446
|
+
newStart: newLines > 0 ? newBefore[from] + 1 : newBefore[from],
|
|
447
|
+
newLines,
|
|
448
|
+
ops: slice,
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function rangeText(start, count) {
|
|
454
|
+
// git omits the count when it is 1; matching it means our output pastes into
|
|
455
|
+
// `git apply` and into every diff viewer people already have.
|
|
456
|
+
return count === 1 ? String(start) : `${start},${count}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The unified diff for one file. PURE.
|
|
461
|
+
*
|
|
462
|
+
* `before === null` (or undefined) means the file did not exist — a creation.
|
|
463
|
+
* `after === null` means it is being deleted. An EMPTY STRING is a real,
|
|
464
|
+
* existing, empty file and is not the same thing as either; conflating them
|
|
465
|
+
* would report "created" for a write that blanked a file, which is the exact
|
|
466
|
+
* wording `tools.mjs` records as having frightened a reader once already.
|
|
467
|
+
*
|
|
468
|
+
* @param {string|null|undefined} before
|
|
469
|
+
* @param {string|null|undefined} after
|
|
470
|
+
* @param {string} path
|
|
471
|
+
* @returns {{ok: true, ...} | {ok: false, ...}}
|
|
472
|
+
*/
|
|
473
|
+
export function diffUnified(before, after, path, {
|
|
474
|
+
context = DEFAULT_CONTEXT,
|
|
475
|
+
maxBytes = MAX_DIFF_BYTES,
|
|
476
|
+
maxLines = MAX_DIFF_LINES,
|
|
477
|
+
maxLineChars = MAX_DIFF_LINE_CHARS,
|
|
478
|
+
maxEdits = MAX_EDIT_DISTANCE,
|
|
479
|
+
} = {}) {
|
|
480
|
+
const rel = String(path ?? '');
|
|
481
|
+
const existedBefore = before !== null && before !== undefined;
|
|
482
|
+
const existsAfter = after !== null && after !== undefined;
|
|
483
|
+
|
|
484
|
+
const kind = !existedBefore && existsAfter ? 'created'
|
|
485
|
+
: existedBefore && !existsAfter ? 'deleted'
|
|
486
|
+
: !existedBefore && !existsAfter ? 'absent'
|
|
487
|
+
: String(before) === String(after) ? 'unchanged' : 'replaced';
|
|
488
|
+
|
|
489
|
+
const base = {
|
|
490
|
+
path: rel,
|
|
491
|
+
kind,
|
|
492
|
+
added: 0,
|
|
493
|
+
removed: 0,
|
|
494
|
+
beforeBytes: existedBefore ? Buffer.byteLength(String(before), 'utf8') : 0,
|
|
495
|
+
afterBytes: existsAfter ? Buffer.byteLength(String(after), 'utf8') : 0,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
if (kind === 'absent') {
|
|
499
|
+
return { ok: false, ...base, reason: 'there is nothing to compare: the file neither existed before nor exists after' };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const refusal = refuseToRender(before, after, { maxBytes, maxLines, maxLineChars });
|
|
503
|
+
if (refusal) {
|
|
504
|
+
/**
|
|
505
|
+
* ⚠️ `ok: false` HERE MEANS "NOT SHOWABLE", NOT "NOT HAPPENING". The counts
|
|
506
|
+
* we can still compute are returned alongside, because a reviewer staring at
|
|
507
|
+
* a refusal with no numbers has strictly less than they started with.
|
|
508
|
+
*/
|
|
509
|
+
return { ok: false, ...base, reason: refusal, renderable: false };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const a = splitLines(existedBefore ? String(before) : '');
|
|
513
|
+
const b = splitLines(existsAfter ? String(after) : '');
|
|
514
|
+
|
|
515
|
+
const eol = {
|
|
516
|
+
before: existedBefore ? eolStyle(a.eols) : 'none',
|
|
517
|
+
after: existsAfter ? eolStyle(b.eols) : 'none',
|
|
518
|
+
changed: false,
|
|
519
|
+
};
|
|
520
|
+
eol.changed = kind === 'replaced' && eol.before !== 'none' && eol.after !== 'none' && eol.before !== eol.after;
|
|
521
|
+
|
|
522
|
+
if (kind === 'unchanged') {
|
|
523
|
+
return {
|
|
524
|
+
ok: true, ...base, eol, hunks: [], text: '', truncatedByDistance: false,
|
|
525
|
+
noNewlineBefore: !a.finalNewline, noNewlineAfter: !b.finalNewline, invisible: false,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let ops = diffLines(a.lines, b.lines, maxEdits);
|
|
530
|
+
const truncatedByDistance = ops === null;
|
|
531
|
+
if (truncatedByDistance) ops = wholesale(a.lines, b.lines);
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* ⚠️⚠️ A TRAILING NEWLINE THAT APPEARS OR VANISHES CHANGES ZERO LINES.
|
|
535
|
+
* `"a\n"` → `"a"` is a real, byte-level change that every line-based diff
|
|
536
|
+
* reports as nothing at all. Forcing the last line into a `-`/`+` pair is
|
|
537
|
+
* what git does, and it is the only way the ``
|
|
538
|
+
* marker below ever gets somewhere to attach.
|
|
539
|
+
*/
|
|
540
|
+
if (a.finalNewline !== b.finalNewline && existedBefore && existsAfter) {
|
|
541
|
+
const last = ops[ops.length - 1];
|
|
542
|
+
if (last && last.sign === '=') {
|
|
543
|
+
ops = ops.slice(0, -1);
|
|
544
|
+
ops.push({ sign: '-', oldIndex: last.oldIndex, newIndex: -1 });
|
|
545
|
+
ops.push({ sign: '+', oldIndex: -1, newIndex: last.newIndex });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const added = ops.filter((op) => op.sign === '+').length;
|
|
550
|
+
const removed = ops.filter((op) => op.sign === '-').length;
|
|
551
|
+
const hunks = groupHunks(ops, context).map(({ ops: hunkOps, ...h }) => ({
|
|
552
|
+
...h,
|
|
553
|
+
lines: hunkOps.map((op) => ({
|
|
554
|
+
/**
|
|
555
|
+
* ⚠️ `=` IS AN INTERNAL TOKEN AND A UNIFIED DIFF USES A SPACE. Emitting
|
|
556
|
+
* `=a` for a context line produced something that LOOKS like a diff in a
|
|
557
|
+
* terminal and is rejected by `git apply`, by every review tool, and by
|
|
558
|
+
* every editor's diff viewer. Caught by a test asserting `^ b$`; nothing
|
|
559
|
+
* about the rendered output looked wrong to the eye.
|
|
560
|
+
*/
|
|
561
|
+
sign: op.sign === '=' ? ' ' : op.sign,
|
|
562
|
+
text: op.sign === '+' ? b.lines[op.newIndex] : a.lines[op.oldIndex],
|
|
563
|
+
oldIndex: op.oldIndex,
|
|
564
|
+
newIndex: op.newIndex,
|
|
565
|
+
})),
|
|
566
|
+
}));
|
|
567
|
+
|
|
568
|
+
const result = {
|
|
569
|
+
ok: true,
|
|
570
|
+
...base,
|
|
571
|
+
added,
|
|
572
|
+
removed,
|
|
573
|
+
eol,
|
|
574
|
+
hunks,
|
|
575
|
+
truncatedByDistance,
|
|
576
|
+
noNewlineBefore: existedBefore && !a.finalNewline,
|
|
577
|
+
noNewlineAfter: existsAfter && !b.finalNewline,
|
|
578
|
+
oldLineCount: a.lines.length,
|
|
579
|
+
newLineCount: b.lines.length,
|
|
580
|
+
/**
|
|
581
|
+
* ⭐ THE FLAG THAT STOPS THE PREVIEW LYING. The bytes differ and the lines
|
|
582
|
+
* do not, which today only happens for a pure line-ending flip. Without
|
|
583
|
+
* this the renderer prints a header, no hunks, and reads as "nothing
|
|
584
|
+
* changed" over a write that touches every byte in the file.
|
|
585
|
+
*/
|
|
586
|
+
invisible: added === 0 && removed === 0 && kind === 'replaced',
|
|
587
|
+
};
|
|
588
|
+
result.text = renderUnified(result);
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** `--- a/x` / `+++ b/x` / `@@` … — the pasteable form. */
|
|
593
|
+
export function renderUnified(diff) {
|
|
594
|
+
if (!diff?.ok) return '';
|
|
595
|
+
const lines = [];
|
|
596
|
+
lines.push(`--- ${diff.kind === 'created' ? '/dev/null' : `a/${diff.path}`}`);
|
|
597
|
+
lines.push(`+++ ${diff.kind === 'deleted' ? '/dev/null' : `b/${diff.path}`}`);
|
|
598
|
+
for (const h of diff.hunks) {
|
|
599
|
+
lines.push(`@@ -${rangeText(h.oldStart, h.oldLines)} +${rangeText(h.newStart, h.newLines)} @@`);
|
|
600
|
+
for (const l of h.lines) {
|
|
601
|
+
lines.push(`${l.sign}${l.text}`);
|
|
602
|
+
if (l.sign !== '+' && diff.noNewlineBefore && l.oldIndex === diff.oldLineCount - 1) {
|
|
603
|
+
lines.push('\');
|
|
604
|
+
} else if (l.sign === '+' && diff.noNewlineAfter && l.newIndex === diff.newLineCount - 1) {
|
|
605
|
+
lines.push('\');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return lines.join('\n');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ── the policy ─────────────────────────────────────────────────────────────
|
|
613
|
+
|
|
614
|
+
/** `.acuvo/` — the directory holding `policy.json` and `mcp.json`. See the
|
|
615
|
+
* import note for why this is a deliberate duplicate rather than an import. */
|
|
616
|
+
export function isProtectedConfigPath(relPath) {
|
|
617
|
+
if (typeof relPath !== 'string') return false;
|
|
618
|
+
return relPath === '.acuvo' || relPath.startsWith('.acuvo/');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export function isScratchPath(relPath, prefixes = SCRATCH_PREFIXES) {
|
|
622
|
+
const p = String(relPath ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
623
|
+
return prefixes.some((prefix) => p === prefix.replace(/\/$/, '') || p.startsWith(prefix));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Read the review mode.
|
|
628
|
+
*
|
|
629
|
+
* ⚠️ AN UNKNOWN VALUE IS AN ERROR, NOT A FALLBACK TO THE DEFAULT. `policy.mjs`
|
|
630
|
+
* names this exact failure: `ACUVO_APPROVE=nver` is a person asking for a gate
|
|
631
|
+
* and silently getting none, and the symptom is *everything works fine*.
|
|
632
|
+
*/
|
|
633
|
+
export function approvalMode({ env = process.env, flag = null } = {}) {
|
|
634
|
+
if (flag !== null && flag !== undefined) {
|
|
635
|
+
if (!APPROVE_MODES.includes(flag)) {
|
|
636
|
+
return { ok: false, error: `review mode "${flag}" is not one of ${APPROVE_MODES.join(', ')}` };
|
|
637
|
+
}
|
|
638
|
+
return { ok: true, mode: flag, from: 'flag' };
|
|
639
|
+
}
|
|
640
|
+
const raw = String(env?.[APPROVE_ENV] ?? '').trim().toLowerCase();
|
|
641
|
+
if (!raw) return { ok: true, mode: 'auto', from: 'default' };
|
|
642
|
+
if (!APPROVE_MODES.includes(raw)) {
|
|
643
|
+
return {
|
|
644
|
+
ok: false,
|
|
645
|
+
error: `${APPROVE_ENV}="${raw}" is not a review mode. Use one of ${APPROVE_MODES.join(', ')} — `
|
|
646
|
+
+ '"auto" asks only about writes that destroy something, "always" asks about every write, "never" asks about none.',
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
return { ok: true, mode: raw, from: APPROVE_ENV };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* ⭐⭐ THE POLICY. Is a human's approval REQUIRED before this write lands, and
|
|
654
|
+
* can it actually be obtained here?
|
|
655
|
+
*
|
|
656
|
+
* PURE. Everything it needs about the outside world — is there a terminal, did
|
|
657
|
+
* this run create this file, what did the user already say — arrives as an
|
|
658
|
+
* argument.
|
|
659
|
+
*
|
|
660
|
+
* @param {{path: string, before?: string|null, after?: string|null, exists?: boolean}} write
|
|
661
|
+
* @returns {{required: boolean, satisfiable: boolean, risk: string, reason: string, blocked: boolean, reviewed: boolean, scope: string}}
|
|
662
|
+
*/
|
|
663
|
+
export function approvalDecision(write, {
|
|
664
|
+
mode = 'auto',
|
|
665
|
+
interactive = false,
|
|
666
|
+
createdThisRun = null,
|
|
667
|
+
sessionApproved = false,
|
|
668
|
+
scratchPrefixes = SCRATCH_PREFIXES,
|
|
669
|
+
isSecretPath = refusedCommitPath,
|
|
670
|
+
isProtectedPath = isProtectedConfigPath,
|
|
671
|
+
rewriteFraction = REWRITE_FRACTION,
|
|
672
|
+
} = {}) {
|
|
673
|
+
const path = String(write?.path ?? '');
|
|
674
|
+
const before = write?.before;
|
|
675
|
+
const after = write?.after;
|
|
676
|
+
const existedBefore = write?.exists ?? (before !== null && before !== undefined);
|
|
677
|
+
const existsAfter = after !== null && after !== undefined;
|
|
678
|
+
|
|
679
|
+
const madeByThisRun = typeof createdThisRun === 'function'
|
|
680
|
+
? Boolean(createdThisRun(path))
|
|
681
|
+
: Boolean(createdThisRun && typeof createdThisRun.has === 'function' && createdThisRun.has(path));
|
|
682
|
+
|
|
683
|
+
const risk = writeRisk({
|
|
684
|
+
path, before, after, existedBefore, existsAfter, madeByThisRun,
|
|
685
|
+
scratchPrefixes, isSecretPath, isProtectedPath, rewriteFraction,
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const settled = (over) => ({
|
|
689
|
+
required: false, satisfiable: true, blocked: false, reviewed: false, scope: 'write', risk: risk.level, ...over,
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
if (mode === 'never') {
|
|
693
|
+
return settled({ reason: `review is switched off (${APPROVE_ENV}=never / --yes), so ${path} was written without asking` });
|
|
694
|
+
}
|
|
695
|
+
if (risk.level === 'none') {
|
|
696
|
+
return settled({ reason: risk.why });
|
|
697
|
+
}
|
|
698
|
+
if (sessionApproved) {
|
|
699
|
+
return settled({ reason: `you answered "all remaining" earlier in this run, so ${path} was not queried again` });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const needed = mode === 'always' || RISK_ORDER[risk.level] >= RISK_ORDER.medium;
|
|
703
|
+
if (!needed) {
|
|
704
|
+
return settled({ reason: risk.why });
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (!interactive) {
|
|
708
|
+
if (mode === 'always') {
|
|
709
|
+
/**
|
|
710
|
+
* ⚠️ THE ONE FAIL-CLOSED BRANCH, AND THE ONLY ONE THAT EARNS IT. Somebody
|
|
711
|
+
* stated an intent — "ask me about every write" — that cannot be honoured
|
|
712
|
+
* here. Honouring it silently is impossible and ignoring it silently is
|
|
713
|
+
* the fail-open shape `policy.mjs` refuses by name. So it stops, and it
|
|
714
|
+
* names three ways forward rather than one.
|
|
715
|
+
*/
|
|
716
|
+
return {
|
|
717
|
+
required: true,
|
|
718
|
+
satisfiable: false,
|
|
719
|
+
blocked: true,
|
|
720
|
+
reviewed: false,
|
|
721
|
+
scope: 'write',
|
|
722
|
+
risk: risk.level,
|
|
723
|
+
reason: `${path}: you asked for every write to be approved, but stdin and stdout are not both terminals, so there is nobody here to ask `
|
|
724
|
+
+ '— and waiting for an answer that cannot arrive would hang this run instead of failing it.\n'
|
|
725
|
+
+ `Run it from a terminal to review the change, pass --yes (${APPROVE_ENV}=never) to write without review, `
|
|
726
|
+
+ `or ${APPROVE_ENV}=auto to review only the writes that destroy something.`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* ⭐ THE DEFAULT FAILS OPEN, DELIBERATELY, AND SAYS SO. See the header: a
|
|
731
|
+
* review gate that breaks CI gets globally disabled and then protects
|
|
732
|
+
* nobody. `reviewed: false` is what lets the run summary count these
|
|
733
|
+
* honestly instead of implying they were seen.
|
|
734
|
+
*/
|
|
735
|
+
return {
|
|
736
|
+
required: false,
|
|
737
|
+
satisfiable: false,
|
|
738
|
+
blocked: false,
|
|
739
|
+
reviewed: false,
|
|
740
|
+
scope: 'write',
|
|
741
|
+
risk: risk.level,
|
|
742
|
+
reason: `${path}: ${risk.why}, but there is no terminal here to ask, so it was written WITHOUT review. `
|
|
743
|
+
+ `Run interactively to review it, or set ${APPROVE_ENV}=always to make an unattended run refuse rather than write.`,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
required: true,
|
|
749
|
+
satisfiable: true,
|
|
750
|
+
blocked: false,
|
|
751
|
+
reviewed: false,
|
|
752
|
+
scope: 'write',
|
|
753
|
+
risk: risk.level,
|
|
754
|
+
reason: risk.why,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* How much of this write cannot be undone?
|
|
760
|
+
*
|
|
761
|
+
* ⭐ THE AXIS IS DESTRUCTION, NOT MODIFICATION. See the header. Everything here
|
|
762
|
+
* is a statement about what stops existing if the write proceeds.
|
|
763
|
+
*/
|
|
764
|
+
export function writeRisk({
|
|
765
|
+
path, before, after, existedBefore, existsAfter, madeByThisRun,
|
|
766
|
+
scratchPrefixes = SCRATCH_PREFIXES,
|
|
767
|
+
isSecretPath = refusedCommitPath,
|
|
768
|
+
isProtectedPath = isProtectedConfigPath,
|
|
769
|
+
rewriteFraction = REWRITE_FRACTION,
|
|
770
|
+
}) {
|
|
771
|
+
/**
|
|
772
|
+
* ⚠️ THE PATH CHECKS COME FIRST AND ARE NOT DOWNGRADEABLE. `.acuvo/mcp.json`
|
|
773
|
+
* names a binary spawned on the NEXT run (`mcp-consent.mjs` exists entirely
|
|
774
|
+
* because of that file), and a credential path is the one write whose damage
|
|
775
|
+
* survives being reverted. Neither becomes safe by being small, new, or
|
|
776
|
+
* inside `dist/`.
|
|
777
|
+
*/
|
|
778
|
+
if (typeof isSecretPath === 'function' && isSecretPath(path)) {
|
|
779
|
+
return { level: 'high', why: `${path} looks like a credential file, and this write ${existsAfter ? 'changes' : 'deletes'} it` };
|
|
780
|
+
}
|
|
781
|
+
if (typeof isProtectedPath === 'function' && isProtectedPath(path)) {
|
|
782
|
+
return { level: 'high', why: `${path} is agent configuration — it decides what the NEXT run is allowed to do and which binaries it may spawn` };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (existedBefore && !existsAfter) {
|
|
786
|
+
return madeByThisRun
|
|
787
|
+
? { level: 'low', why: `${path} is being deleted, and this run created it — nothing that predates the run is lost` }
|
|
788
|
+
: { level: 'high', why: `${path} is being DELETED, and its contents existed before this run started` };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (!existedBefore) {
|
|
792
|
+
return { level: 'low', why: `${path} is a new file — nothing is overwritten, and deleting it puts you back exactly where you were` };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* ⚠️ "IT EXISTS BUT WE COULD NOT READ IT" IS THE RISKIEST CASE, NOT A MISSING
|
|
797
|
+
* ONE. A caller passes `exists: true` with no `before` when the file was too
|
|
798
|
+
* big, binary, or unreadable. Falling through would compare the string
|
|
799
|
+
* "undefined" against the new contents and produce a confidently wrong
|
|
800
|
+
* "1 of its 1 pre-existing lines" — a number that reads like a measurement.
|
|
801
|
+
*/
|
|
802
|
+
if (before === undefined || before === null) {
|
|
803
|
+
return { level: 'high', why: `${path} exists but its current contents could not be read, so what this write overwrites cannot be shown to you` };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (String(before) === String(after)) {
|
|
807
|
+
// ⭐ A no-op write is not a decision, and asking about one teaches the
|
|
808
|
+
// reflex that makes the real questions worthless.
|
|
809
|
+
return { level: 'none', why: `${path} is written with identical contents — nothing changes` };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (madeByThisRun) {
|
|
813
|
+
return { level: 'low', why: `${path} was created by this run, so this is the agent revising its own draft` };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (isScratchPath(path, scratchPrefixes)) {
|
|
817
|
+
return { level: 'low', why: `${path} is under a build/scratch directory whose contents are regenerated rather than authored` };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const oldLines = splitLines(String(before)).lines.length;
|
|
821
|
+
const newLines = splitLines(String(after)).lines.length;
|
|
822
|
+
if (oldLines > 0) {
|
|
823
|
+
const survives = commonLineCount(String(before), String(after));
|
|
824
|
+
const lost = (oldLines - survives) / oldLines;
|
|
825
|
+
if (lost >= rewriteFraction) {
|
|
826
|
+
return {
|
|
827
|
+
level: 'high',
|
|
828
|
+
why: `${path} is being rewritten: ${oldLines - survives} of its ${oldLines} pre-existing lines do not appear in the new contents`
|
|
829
|
+
+ `${newLines < oldLines ? `, and the file shrinks to ${newLines} lines` : ''}`,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return { level: 'medium', why: `${path} already existed before this run, and this write replaces its contents` };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* How many of the old lines survive into the new text.
|
|
838
|
+
*
|
|
839
|
+
* ⚠️ DELIBERATELY A MULTISET COUNT, NOT THE LCS. It is used only to LABEL risk,
|
|
840
|
+
* and it is O(n) rather than O(n·d) — running the full diff here would double
|
|
841
|
+
* the work on every write for a heuristic, and `approvalDecision` is called for
|
|
842
|
+
* writes that are never even shown.
|
|
843
|
+
*/
|
|
844
|
+
function commonLineCount(before, after) {
|
|
845
|
+
const counts = new Map();
|
|
846
|
+
for (const l of splitLines(after).lines) counts.set(l, (counts.get(l) ?? 0) + 1);
|
|
847
|
+
let survives = 0;
|
|
848
|
+
for (const l of splitLines(before).lines) {
|
|
849
|
+
const n = counts.get(l) ?? 0;
|
|
850
|
+
if (n > 0) { counts.set(l, n - 1); survives += 1; }
|
|
851
|
+
}
|
|
852
|
+
return survives;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Plan a whole batch in one pass — the granularity answer, made concrete.
|
|
857
|
+
*
|
|
858
|
+
* ⭐ The point is the SPLIT, not the loop: `auto` is the list a person is
|
|
859
|
+
* actually asked about, and it is the short one. A caller that prints
|
|
860
|
+
* `summariseApprovals` before the first question tells the user up front "6
|
|
861
|
+
* files, 2 need you" — which is what stops the third question feeling like an
|
|
862
|
+
* ambush and getting a reflex `y`.
|
|
863
|
+
*/
|
|
864
|
+
export function planApprovals(writes, options = {}) {
|
|
865
|
+
const decisions = [];
|
|
866
|
+
let sessionApproved = Boolean(options.sessionApproved);
|
|
867
|
+
for (const w of writes ?? []) {
|
|
868
|
+
const d = approvalDecision(w, { ...options, sessionApproved });
|
|
869
|
+
decisions.push({ path: String(w?.path ?? ''), ...d });
|
|
870
|
+
// ⚠️ Not mutated by this function: `sessionApproved` only ever arrives from
|
|
871
|
+
// a real answer. Planning must not invent one.
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
decisions,
|
|
875
|
+
ask: decisions.filter((d) => d.required && d.satisfiable),
|
|
876
|
+
blocked: decisions.filter((d) => d.blocked),
|
|
877
|
+
unreviewed: decisions.filter((d) => !d.required && !d.satisfiable),
|
|
878
|
+
auto: decisions.filter((d) => !d.required && d.satisfiable),
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** One line the caller can print before the first question. */
|
|
883
|
+
export function summariseApprovals(plan) {
|
|
884
|
+
const total = plan?.decisions?.length ?? 0;
|
|
885
|
+
const parts = [`${total} file${total === 1 ? '' : 's'} to write`];
|
|
886
|
+
if (plan.ask.length) parts.push(`${plan.ask.length} need${plan.ask.length === 1 ? 's' : ''} your approval`);
|
|
887
|
+
if (plan.blocked.length) parts.push(`${plan.blocked.length} cannot be approved here`);
|
|
888
|
+
if (plan.unreviewed.length) parts.push(`${plan.unreviewed.length} will land without review`);
|
|
889
|
+
if (plan.auto.length) parts.push(`${plan.auto.length} need no review`);
|
|
890
|
+
return parts.join(' · ');
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── the answer ─────────────────────────────────────────────────────────────
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Read what the human typed.
|
|
897
|
+
*
|
|
898
|
+
* ⚠️⚠️ EVERYTHING THAT IS NOT A YES IS NOT A YES. `null` (the stream ended
|
|
899
|
+
* mid-question — `askOnce` returns exactly that) is an ABORT, empty is a
|
|
900
|
+
* refusal, and an unrecognised word is a refusal that says which letters work.
|
|
901
|
+
* A parser that fell through to "approve" on anything would turn a dying pipe
|
|
902
|
+
* into consent, which is the failure `prompt.mjs`'s header spends a paragraph
|
|
903
|
+
* on.
|
|
904
|
+
*/
|
|
905
|
+
export function interpretAnswer(raw) {
|
|
906
|
+
if (raw === null || raw === undefined) {
|
|
907
|
+
return { decision: 'abort', reason: 'the input ended before an answer arrived, and silence is not consent — nothing was written' };
|
|
908
|
+
}
|
|
909
|
+
const a = String(raw).trim().toLowerCase();
|
|
910
|
+
if (a === 'y' || a === 'yes') return { decision: 'approve', reason: 'approved at the prompt' };
|
|
911
|
+
if (a === 'a' || a === 'all') return { decision: 'approve-all', reason: 'approved, and the rest of this run will not be queried' };
|
|
912
|
+
if (a === 'n' || a === 'no' || a === '') return { decision: 'reject', reason: 'declined at the prompt — the file was left as it was' };
|
|
913
|
+
if (a === 'q' || a === 'quit') return { decision: 'abort', reason: 'stopped at the prompt — this write and everything after it was abandoned' };
|
|
914
|
+
return {
|
|
915
|
+
decision: 'reject',
|
|
916
|
+
reason: `"${String(raw).trim()}" is not one of y / n / a / q, and an answer that is not a yes is treated as a no. `
|
|
917
|
+
+ 'The file was left as it was; answer y to apply it.',
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ── the terminal view ──────────────────────────────────────────────────────
|
|
922
|
+
|
|
923
|
+
const NO_PAINT = { dim: (s) => s, bold: (s) => s, gold: (s) => s, green: (s) => s, red: (s) => s, cyan: (s) => s };
|
|
924
|
+
|
|
925
|
+
/** Honour the cap override, and refuse a nonsense one rather than silently ignoring it. */
|
|
926
|
+
export function previewLineCap({ env = process.env, fallback = DEFAULT_PREVIEW_LINES } = {}) {
|
|
927
|
+
const raw = String(env?.[DIFF_LINES_ENV] ?? '').trim();
|
|
928
|
+
if (!raw) return fallback;
|
|
929
|
+
const n = Number(raw);
|
|
930
|
+
if (!Number.isInteger(n) || n < 1) return fallback;
|
|
931
|
+
return n;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The hunks, capped, with the cap STATED.
|
|
936
|
+
*
|
|
937
|
+
* ⚠️ "…and more" IS NOT A STATED CAP. A reader who is told 214 lines were
|
|
938
|
+
* hidden but not why, or how to see them, has been told the preview is
|
|
939
|
+
* incomplete and given no way to complete it — which is the same as being told
|
|
940
|
+
* nothing, with extra anxiety.
|
|
941
|
+
*/
|
|
942
|
+
export function renderDiff(diff, { cap = DEFAULT_PREVIEW_LINES, paint = NO_PAINT, indent = ' ' } = {}) {
|
|
943
|
+
if (!diff?.ok) return [];
|
|
944
|
+
const out = [];
|
|
945
|
+
const all = [];
|
|
946
|
+
for (const h of diff.hunks) {
|
|
947
|
+
all.push({ sign: '@', text: `@@ -${rangeText(h.oldStart, h.oldLines)} +${rangeText(h.newStart, h.newLines)} @@` });
|
|
948
|
+
for (let i = 0; i < h.lines.length; i += 1) {
|
|
949
|
+
const l = h.lines[i];
|
|
950
|
+
let note = '';
|
|
951
|
+
/**
|
|
952
|
+
* ⭐ THE ANNOTATION THAT MAKES AN INVISIBLE EDIT VISIBLE. `-const a = 1;`
|
|
953
|
+
* over `+const a = 1; ` are the same seven pixels wide in every terminal
|
|
954
|
+
* ever made. Nobody catches this by looking, so the renderer says it.
|
|
955
|
+
*/
|
|
956
|
+
const prev = h.lines[i - 1];
|
|
957
|
+
if (l.sign === '+' && prev?.sign === '-' && prev.text !== l.text && prev.text.trimEnd() === l.text.trimEnd()) {
|
|
958
|
+
note = paint.dim(' ⟵ trailing whitespace only');
|
|
959
|
+
}
|
|
960
|
+
all.push({ sign: l.sign, text: `${l.sign}${l.text}${note}` });
|
|
961
|
+
if (l.sign !== '+' && diff.noNewlineBefore && l.oldIndex === diff.oldLineCount - 1) {
|
|
962
|
+
all.push({ sign: '\\', text: '\' });
|
|
963
|
+
} else if (l.sign === '+' && diff.noNewlineAfter && l.newIndex === diff.newLineCount - 1) {
|
|
964
|
+
all.push({ sign: '\\', text: '\' });
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const shown = all.slice(0, cap);
|
|
970
|
+
for (const l of shown) {
|
|
971
|
+
const colour = l.sign === '+' ? paint.green : l.sign === '-' ? paint.red : paint.dim;
|
|
972
|
+
out.push(indent + colour(l.text));
|
|
973
|
+
}
|
|
974
|
+
if (all.length > shown.length) {
|
|
975
|
+
out.push(indent + paint.dim(
|
|
976
|
+
`… ${all.length - shown.length} more diff line${all.length - shown.length === 1 ? '' : 's'} hidden `
|
|
977
|
+
+ `(showing ${shown.length} of ${all.length}; the cap is ${cap}). `
|
|
978
|
+
+ `Set ${DIFF_LINES_ENV}=${all.length} to see all of it.`,
|
|
979
|
+
));
|
|
980
|
+
}
|
|
981
|
+
return out;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The whole terminal view: what is changing, by how much, a capped look at it,
|
|
986
|
+
* and the exact question.
|
|
987
|
+
*
|
|
988
|
+
* Returns the body and the prompt SEPARATELY, because the caller writes the
|
|
989
|
+
* body to stderr and hands the prompt to `askOnce` — a single blob would print
|
|
990
|
+
* the question twice or lose the cursor position.
|
|
991
|
+
*/
|
|
992
|
+
export function renderApproval(diff, decision, { cap = DEFAULT_PREVIEW_LINES, paint = NO_PAINT, path = null } = {}) {
|
|
993
|
+
const rel = path ?? diff?.path ?? decision?.path ?? '';
|
|
994
|
+
const lines = [];
|
|
995
|
+
|
|
996
|
+
const kind = diff?.kind ?? 'replaced';
|
|
997
|
+
const headline = kind === 'created' ? 'creates' : kind === 'deleted' ? 'DELETES' : 'changes';
|
|
998
|
+
lines.push(` ${paint.gold('✎')} ${paint.bold(rel)} — this ${headline} the file`);
|
|
999
|
+
|
|
1000
|
+
if (diff?.ok) {
|
|
1001
|
+
lines.push(` ${paint.green(`+${diff.added}`)} ${paint.red(`−${diff.removed}`)}`
|
|
1002
|
+
+ (kind === 'replaced' ? paint.dim(` (${diff.oldLineCount} lines → ${diff.newLineCount})`) : ''));
|
|
1003
|
+
if (diff.eol?.changed) {
|
|
1004
|
+
// ⭐ Reported in words rather than as N identical-looking ± pairs. See the header.
|
|
1005
|
+
lines.push(` ${paint.dim(`line endings change ${diff.eol.before.toUpperCase()} → ${diff.eol.after.toUpperCase()} across the whole file`)}`);
|
|
1006
|
+
}
|
|
1007
|
+
if (diff.invisible) {
|
|
1008
|
+
lines.push(` ${paint.dim('no line content changes — the bytes differ only in the line endings above')}`);
|
|
1009
|
+
}
|
|
1010
|
+
if (diff.noNewlineBefore !== diff.noNewlineAfter) {
|
|
1011
|
+
lines.push(` ${paint.dim(diff.noNewlineAfter ? 'the trailing newline is REMOVED' : 'a trailing newline is added')}`);
|
|
1012
|
+
}
|
|
1013
|
+
if (diff.truncatedByDistance) {
|
|
1014
|
+
lines.push(` ${paint.dim(`the two versions share too little to align (over ${MAX_EDIT_DISTANCE} edits), so this is shown as a wholesale replacement`)}`);
|
|
1015
|
+
}
|
|
1016
|
+
lines.push(...renderDiff(diff, { cap, paint }));
|
|
1017
|
+
} else {
|
|
1018
|
+
lines.push(` ${paint.dim(`${diff?.beforeBytes ?? 0} bytes → ${diff?.afterBytes ?? 0} bytes`)}`);
|
|
1019
|
+
lines.push(` ${paint.dim(`the change cannot be shown: ${diff?.reason ?? 'unknown'}`)}`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (decision?.risk === 'high') lines.push(` ${paint.red('⚠')} ${decision.reason}`);
|
|
1023
|
+
else if (decision?.reason) lines.push(` ${paint.dim(decision.reason)}`);
|
|
1024
|
+
|
|
1025
|
+
return {
|
|
1026
|
+
body: lines.join('\n'),
|
|
1027
|
+
prompt: approvalPrompt(rel, kind),
|
|
1028
|
+
lines,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* The exact question.
|
|
1034
|
+
*
|
|
1035
|
+
* ⚠️ `[y]es` IS LISTED FIRST AND IS STILL NOT THE DEFAULT — `interpretAnswer`
|
|
1036
|
+
* maps an empty answer to a refusal. The order reflects what a reviewer usually
|
|
1037
|
+
* wants; the default reflects what is safe when they hit return by accident.
|
|
1038
|
+
* `mcp-consent.mjs` uses `[y/N]` for the same reason, and this prompt has four
|
|
1039
|
+
* answers rather than two so the letters are spelled out.
|
|
1040
|
+
*/
|
|
1041
|
+
export function approvalPrompt(path, kind = 'replaced') {
|
|
1042
|
+
const verb = kind === 'deleted' ? 'Delete' : kind === 'created' ? 'Create' : 'Apply this change to';
|
|
1043
|
+
return ` ${verb} ${path}? [y]es / [n]o / [a]ll remaining / [q]uit `;
|
|
1044
|
+
}
|