@oh-my-pi/pi-coding-agent 14.5.9 → 14.5.10
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 +34 -0
- package/package.json +7 -15
- package/scripts/build-binary.ts +1 -1
- package/src/cli/update-cli.ts +25 -1
- package/src/config/model-registry.ts +21 -19
- package/src/config/settings-schema.ts +11 -16
- package/src/discovery/claude-plugins.ts +28 -3
- package/src/edit/modes/atom.ts +50 -19
- package/src/edit/modes/hashline.ts +171 -110
- package/src/extensibility/extensions/runner.ts +34 -1
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/lsp/client.ts +27 -35
- package/src/memories/index.ts +5 -0
- package/src/modes/components/settings-defs.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/interactive-mode.ts +27 -3
- package/src/modes/theme/theme.ts +10 -1
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +19 -6
- package/src/prompts/system/auto-continue.md +1 -0
- package/src/prompts/tools/github.md +3 -3
- package/src/sdk.ts +13 -2
- package/src/session/agent-session.ts +175 -79
- package/src/session/session-manager.ts +19 -2
- package/src/tools/bash.ts +9 -4
- package/src/tools/gh.ts +267 -119
- package/src/utils/git.ts +61 -2
- package/src/web/search/providers/searxng.ts +71 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [14.5.10] - 2026-04-30
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Removed the `worktree` parameter from `github` `pr_checkout`. Worktrees are now always written to `~/.omp/wt/<encoded-primary-repo>/pr-<number>/`, derived from the primary repository path
|
|
10
|
+
- Stopped reading the `branch` parameter for `github` `pr_checkout`. The local branch is now always `pr-<number>`; the `branch` schema field is still accepted by `pr_push`, `repo_view`, and `run_watch`
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Added `checkouts` summary entries to `pr_checkout` results, including each checkout's branch, worktree path, remote, and reuse status
|
|
15
|
+
- Added combined summaries for `pr_view` and `pr_diff` when `pr` is an array, so multi-request responses now include all requested pull requests in one return
|
|
16
|
+
- Added array support to the `pr` parameter on `github` `pr_view`, `pr_diff`, and `pr_checkout` so a single call can fetch, diff, or check out multiple pull requests in one batch
|
|
17
|
+
- Added a per-repo serialization lock (`withRepoLock`) so concurrent `pr_checkout` calls against the same repository no longer race on git's internal `.git/config.lock`, commit-graph, and worktree lock files
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Changed the diff preview shown after edits so changed lines are never collapsed: removed runs and the global preview budget no longer truncate, only unchanged context still collapses
|
|
22
|
+
- Changed adjacent `-`/`+` pairs in edit previews to fold into a single `*<line><hash>|<new-content>` modification line so 1:1 line replacements stay compact
|
|
23
|
+
- Changed `git.remote.add` to be idempotent when the remote already exists with the same URL (instead of failing with `remote ... already exists`), and to surface a clear error when the existing URL differs
|
|
24
|
+
- Changed `pr_checkout` to run `gh pr view` calls in parallel for batch invocations while serializing the in-repo git mutations to keep the operation race-free
|
|
25
|
+
- Changed `pr_checkout` to auto-derive the worktree location and local branch name (see Breaking Changes), removing the per-call overrides that previously let callers pin a worktree path or local branch
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
- Removed the `./hooks` and `./hooks/*` package export entries
|
|
30
|
+
- Removed the `Suspicious duplicate` warning emitted after edits — it produced too many false positives (e.g. legitimate adjacent `\t});\n\t});`); the auto-fix path that uses bracket balance to safely de-duplicate is unchanged
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Fixed bash interceptor rules to also check the original command before `cd` normalization, so leading `cd ... &&` wrappers no longer bypass interception
|
|
35
|
+
- Fixed LSP client shutdown to properly await the language server's exit instead of fire-and-forget, preventing premature process termination on SIGINT and SIGTERM
|
|
36
|
+
- Fixed concurrent bash commands being tracked independently so aborting one no longer silently drops tracking of others
|
|
37
|
+
|
|
5
38
|
## [14.5.9] - 2026-04-30
|
|
6
39
|
|
|
7
40
|
### Added
|
|
@@ -82,6 +115,7 @@
|
|
|
82
115
|
|
|
83
116
|
### Added
|
|
84
117
|
|
|
118
|
+
- Added the `after_provider_response` extension event for observing provider response status, headers, and request IDs.
|
|
85
119
|
- Added internal URL support to the `search` tool, allowing `artifact://`-style paths that resolve to local files to be searched directly
|
|
86
120
|
- Added IRC relay observation in the main agent UI so every IRC exchange between agents is rendered in the main transcript, even when the main agent is not a direct participant
|
|
87
121
|
- Added stateful `href`/`hrefr` prompt helpers that can reuse anchors remembered from prior `hline` helper calls
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "14.5.
|
|
4
|
+
"version": "14.5.10",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@agentclientprotocol/sdk": "0.20.0",
|
|
48
48
|
"@mozilla/readability": "^0.6.0",
|
|
49
|
-
"@oh-my-pi/omp-stats": "14.5.
|
|
50
|
-
"@oh-my-pi/pi-agent-core": "14.5.
|
|
51
|
-
"@oh-my-pi/pi-ai": "14.5.
|
|
52
|
-
"@oh-my-pi/pi-natives": "14.5.
|
|
53
|
-
"@oh-my-pi/pi-tui": "14.5.
|
|
54
|
-
"@oh-my-pi/pi-utils": "14.5.
|
|
49
|
+
"@oh-my-pi/omp-stats": "14.5.10",
|
|
50
|
+
"@oh-my-pi/pi-agent-core": "14.5.10",
|
|
51
|
+
"@oh-my-pi/pi-ai": "14.5.10",
|
|
52
|
+
"@oh-my-pi/pi-natives": "14.5.10",
|
|
53
|
+
"@oh-my-pi/pi-tui": "14.5.10",
|
|
54
|
+
"@oh-my-pi/pi-utils": "14.5.10",
|
|
55
55
|
"@puppeteer/browsers": "^2.13.0",
|
|
56
56
|
"@sinclair/typebox": "^0.34.49",
|
|
57
57
|
"@xterm/headless": "^6.0.0",
|
|
@@ -516,14 +516,6 @@
|
|
|
516
516
|
"types": "./src/web/search/providers/*.ts",
|
|
517
517
|
"import": "./src/web/search/providers/*.ts"
|
|
518
518
|
},
|
|
519
|
-
"./hooks": {
|
|
520
|
-
"types": "./src/extensibility/hooks/index.ts",
|
|
521
|
-
"import": "./src/extensibility/hooks/index.ts"
|
|
522
|
-
},
|
|
523
|
-
"./hooks/*": {
|
|
524
|
-
"types": "./src/extensibility/hooks/*.ts",
|
|
525
|
-
"import": "./src/extensibility/hooks/*.ts"
|
|
526
|
-
},
|
|
527
519
|
"./*.js": "./src/*.ts"
|
|
528
520
|
}
|
|
529
521
|
}
|
package/scripts/build-binary.ts
CHANGED
package/src/cli/update-cli.ts
CHANGED
|
@@ -53,13 +53,37 @@ function normalizePathForComparison(filePath: string): string {
|
|
|
53
53
|
return normalized;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
function
|
|
56
|
+
function tryRealpath(p: string): string | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return fs.realpathSync.native(p);
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPathInDirectoryLexical(filePath: string, directoryPath: string): boolean {
|
|
57
65
|
const normalizedPath = normalizePathForComparison(path.resolve(filePath));
|
|
58
66
|
const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath));
|
|
59
67
|
const relativePath = path.relative(normalizedDirectory, normalizedPath);
|
|
60
68
|
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
function isPathInDirectory(filePath: string, directoryPath: string): boolean {
|
|
72
|
+
if (isPathInDirectoryLexical(filePath, directoryPath)) return true;
|
|
73
|
+
// Layer realpath resolution on top of the lexical guard. On Windows, ~/.bun
|
|
74
|
+
// is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the
|
|
75
|
+
// PATH-resolved omp path can refer to the same directory through different
|
|
76
|
+
// strings. path.resolve does not traverse junctions/symlinks; realpath does.
|
|
77
|
+
// Resolve the file's parent directory to tolerate the file itself not yet
|
|
78
|
+
// existing (e.g. a fresh install path) while still catching link-traversed
|
|
79
|
+
// equality once the directory exists.
|
|
80
|
+
const fileDir = tryRealpath(path.dirname(path.resolve(filePath)));
|
|
81
|
+
const dirReal = tryRealpath(path.resolve(directoryPath));
|
|
82
|
+
if (!fileDir || !dirReal) return false;
|
|
83
|
+
const resolvedFile = path.join(fileDir, path.basename(filePath));
|
|
84
|
+
return isPathInDirectoryLexical(resolvedFile, dirReal);
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
|
|
64
88
|
|
|
65
89
|
function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
|
|
@@ -145,8 +145,16 @@ const OpenAICompatSchema = Type.Object({
|
|
|
145
145
|
maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
|
|
146
146
|
supportsUsageInStreaming: Type.Optional(Type.Boolean()),
|
|
147
147
|
requiresToolResultName: Type.Optional(Type.Boolean()),
|
|
148
|
+
requiresMistralToolIds: Type.Optional(Type.Boolean()),
|
|
148
149
|
requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
|
|
149
150
|
requiresThinkingAsText: Type.Optional(Type.Boolean()),
|
|
151
|
+
reasoningContentField: Type.Optional(
|
|
152
|
+
Type.Union([Type.Literal("reasoning_content"), Type.Literal("reasoning"), Type.Literal("reasoning_text")]),
|
|
153
|
+
),
|
|
154
|
+
requiresReasoningContentForToolCalls: Type.Optional(Type.Boolean()),
|
|
155
|
+
requiresAssistantContentForToolCalls: Type.Optional(Type.Boolean()),
|
|
156
|
+
supportsToolChoice: Type.Optional(Type.Boolean()),
|
|
157
|
+
disableReasoningOnForcedToolChoice: Type.Optional(Type.Boolean()),
|
|
150
158
|
thinkingFormat: Type.Optional(
|
|
151
159
|
Type.Union([
|
|
152
160
|
Type.Literal("openai"),
|
|
@@ -183,6 +191,7 @@ const ModelThinkingSchema = Type.Object({
|
|
|
183
191
|
minLevel: EffortSchema,
|
|
184
192
|
maxLevel: EffortSchema,
|
|
185
193
|
mode: ThinkingControlModeSchema,
|
|
194
|
+
defaultLevel: Type.Optional(EffortSchema),
|
|
186
195
|
});
|
|
187
196
|
|
|
188
197
|
// Schema for custom model definition
|
|
@@ -558,27 +567,20 @@ function resolveOAuthAccountIdForAccessToken(
|
|
|
558
567
|
return undefined;
|
|
559
568
|
}
|
|
560
569
|
|
|
561
|
-
function mergeCompat(
|
|
562
|
-
baseCompat:
|
|
563
|
-
overrideCompat:
|
|
564
|
-
):
|
|
570
|
+
function mergeCompat<TBase extends object, TOverride extends object>(
|
|
571
|
+
baseCompat: TBase | null | undefined,
|
|
572
|
+
overrideCompat: TOverride | null | undefined,
|
|
573
|
+
): (TBase & TOverride) | TBase | TOverride | undefined {
|
|
574
|
+
if (!baseCompat) return overrideCompat ?? undefined;
|
|
565
575
|
if (!overrideCompat) return baseCompat;
|
|
566
|
-
|
|
567
|
-
const
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
merged
|
|
571
|
-
|
|
572
|
-
if (baseCompat?.openRouterRouting || overrideCompat.openRouterRouting) {
|
|
573
|
-
merged.openRouterRouting = { ...baseCompat?.openRouterRouting, ...overrideCompat.openRouterRouting };
|
|
574
|
-
}
|
|
575
|
-
if (baseCompat?.vercelGatewayRouting || overrideCompat.vercelGatewayRouting) {
|
|
576
|
-
merged.vercelGatewayRouting = { ...baseCompat?.vercelGatewayRouting, ...overrideCompat.vercelGatewayRouting };
|
|
577
|
-
}
|
|
578
|
-
if (baseCompat?.extraBody || overrideCompat.extraBody) {
|
|
579
|
-
merged.extraBody = { ...baseCompat?.extraBody, ...overrideCompat.extraBody };
|
|
576
|
+
|
|
577
|
+
const merged: Record<string, unknown> = { ...(baseCompat as Record<string, unknown>) };
|
|
578
|
+
for (const [key, overrideValue] of Object.entries(overrideCompat)) {
|
|
579
|
+
const baseValue = (baseCompat as Record<string, unknown>)[key];
|
|
580
|
+
merged[key] =
|
|
581
|
+
isRecord(baseValue) && isRecord(overrideValue) ? mergeCompat(baseValue, overrideValue) : overrideValue;
|
|
580
582
|
}
|
|
581
|
-
return merged;
|
|
583
|
+
return merged as TBase & TOverride;
|
|
582
584
|
}
|
|
583
585
|
|
|
584
586
|
function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<Api> {
|
|
@@ -1793,38 +1793,33 @@ export const SETTINGS_SCHEMA = {
|
|
|
1793
1793
|
ui: {
|
|
1794
1794
|
tab: "providers",
|
|
1795
1795
|
label: "SearXNG Endpoint",
|
|
1796
|
-
description: "
|
|
1796
|
+
description: "Self-hosted search base URL",
|
|
1797
1797
|
},
|
|
1798
1798
|
},
|
|
1799
1799
|
|
|
1800
1800
|
"searxng.token": {
|
|
1801
1801
|
type: "string",
|
|
1802
1802
|
default: undefined,
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1803
|
+
},
|
|
1804
|
+
|
|
1805
|
+
"searxng.basicUsername": {
|
|
1806
|
+
type: "string",
|
|
1807
|
+
default: undefined,
|
|
1808
|
+
},
|
|
1809
|
+
|
|
1810
|
+
"searxng.basicPassword": {
|
|
1811
|
+
type: "string",
|
|
1812
|
+
default: undefined,
|
|
1808
1813
|
},
|
|
1809
1814
|
|
|
1810
1815
|
"searxng.categories": {
|
|
1811
1816
|
type: "string",
|
|
1812
1817
|
default: undefined,
|
|
1813
|
-
ui: {
|
|
1814
|
-
tab: "providers",
|
|
1815
|
-
label: "SearXNG Categories",
|
|
1816
|
-
description: "Comma-separated categories filter (e.g. general,news,science)",
|
|
1817
|
-
},
|
|
1818
1818
|
},
|
|
1819
1819
|
|
|
1820
1820
|
"searxng.language": {
|
|
1821
1821
|
type: "string",
|
|
1822
1822
|
default: undefined,
|
|
1823
|
-
ui: {
|
|
1824
|
-
tab: "providers",
|
|
1825
|
-
label: "SearXNG Language",
|
|
1826
|
-
description: "Language code for search results (e.g. en, zh-CN)",
|
|
1827
|
-
},
|
|
1828
1823
|
},
|
|
1829
1824
|
|
|
1830
1825
|
"commit.mapReduceEnabled": { type: "boolean", default: true },
|
|
@@ -265,10 +265,28 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
|
268
|
-
const
|
|
269
|
-
|
|
268
|
+
const obj = parsed as Record<string, unknown>;
|
|
269
|
+
|
|
270
|
+
// Two shapes are supported:
|
|
271
|
+
// nested: { "mcpServers": { name: cfg, ... } } (OMP/Claude Code project shape)
|
|
272
|
+
// flat: { name: cfg, ... } (Claude marketplace plugin shape)
|
|
273
|
+
// If "mcpServers" is present and an object, treat it as the canonical map.
|
|
274
|
+
// Otherwise, treat the whole object as the server map.
|
|
275
|
+
let servers: Record<string, unknown>;
|
|
276
|
+
if (
|
|
277
|
+
obj.mcpServers !== undefined &&
|
|
278
|
+
obj.mcpServers !== null &&
|
|
279
|
+
typeof obj.mcpServers === "object" &&
|
|
280
|
+
!Array.isArray(obj.mcpServers)
|
|
281
|
+
) {
|
|
282
|
+
servers = obj.mcpServers as Record<string, unknown>;
|
|
283
|
+
} else if (!("mcpServers" in obj)) {
|
|
284
|
+
servers = obj;
|
|
285
|
+
} else {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
270
288
|
|
|
271
|
-
for (const [serverName, serverCfg] of Object.entries(
|
|
289
|
+
for (const [serverName, serverCfg] of Object.entries(servers)) {
|
|
272
290
|
if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
|
|
273
291
|
const raw = serverCfg as {
|
|
274
292
|
enabled?: boolean;
|
|
@@ -283,6 +301,13 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
283
301
|
oauth?: MCPServer["oauth"];
|
|
284
302
|
type?: string;
|
|
285
303
|
};
|
|
304
|
+
// Require either command (stdio) or url (HTTP/SSE) — Claude marketplace plugins
|
|
305
|
+
// occasionally ship .mcp.json entries with neither, which would register a useless
|
|
306
|
+
// server and surface as a connection error at runtime.
|
|
307
|
+
if (typeof raw.command !== "string" && typeof raw.url !== "string") {
|
|
308
|
+
warnings.push(`[claude-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
286
311
|
const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
|
|
287
312
|
const server: MCPServer = {
|
|
288
313
|
name: namespacedName,
|
package/src/edit/modes/atom.ts
CHANGED
|
@@ -518,11 +518,26 @@ function isReplaceStart(line: string): boolean {
|
|
|
518
518
|
return /^[1-9]\d*[a-z]{2}[ \t]*[=|]/.test(stripped);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
// Lookahead used by the blank-line forgiveness rule below: returns true when
|
|
522
|
+
// the first non-blank line at or after `start` is a `\TEXT` continuation.
|
|
523
|
+
function nextNonBlankIsBackslash(lines: readonly string[], start: number): boolean {
|
|
524
|
+
for (let j = start; j < lines.length; j++) {
|
|
525
|
+
const peek = lines[j].endsWith("\r") ? lines[j].slice(0, -1) : lines[j];
|
|
526
|
+
if (peek.length === 0) continue;
|
|
527
|
+
return peek.startsWith("\\");
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
521
532
|
// Explicit continuation uses `\TEXT` after a replacement op (`Lid=FIRST` or
|
|
522
533
|
// `LidA..LidB=FIRST`). The leading backslash is the continuation marker; the
|
|
523
534
|
// rest of the line is inserted literally, so `\\TEXT` inserts a line starting
|
|
524
|
-
// with `\TEXT`.
|
|
525
|
-
//
|
|
535
|
+
// with `\TEXT`. As a forgiveness rule, a literal blank line inside an active
|
|
536
|
+
// replacement that is itself followed (possibly through more blanks) by another
|
|
537
|
+
// `\TEXT` continuation is treated as an implicit `\` blank insert — authors
|
|
538
|
+
// frequently drop a real blank between `\TEXT` lines instead of writing `\`.
|
|
539
|
+
// Raw unprefixed continuation remains an undocumented best-effort recovery for
|
|
540
|
+
// range replacements only, kept for old transcripts.
|
|
526
541
|
function preprocessRangeReplaceContinuation(diff: string): string {
|
|
527
542
|
const lines = diff.split("\n");
|
|
528
543
|
let inRangeReplace = false;
|
|
@@ -541,6 +556,14 @@ function preprocessRangeReplaceContinuation(diff: string): string {
|
|
|
541
556
|
continue;
|
|
542
557
|
}
|
|
543
558
|
|
|
559
|
+
// Forgiveness: a blank line inside an active replacement that is followed
|
|
560
|
+
// by another `\TEXT` continuation is treated as an implicit `\` blank
|
|
561
|
+
// insert. Keeps the replacement open across the blank.
|
|
562
|
+
if (inReplace && line.length === 0 && nextNonBlankIsBackslash(lines, i + 1)) {
|
|
563
|
+
lines[i] = `+${RANGE_CONTINUATION_SENTINEL}`;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
|
|
544
567
|
if (inRangeReplace) {
|
|
545
568
|
if (line.length === 0 || OP_LINE_HEAD_RE.test(line)) {
|
|
546
569
|
inRangeReplace = isRangeReplaceStart(line);
|
|
@@ -949,6 +972,9 @@ function getAnchorForAnchorEdit(edit: IndexedAnchorEdit["edit"]): Anchor {
|
|
|
949
972
|
// missed one delete on the front or back of the deletion range, leaving a
|
|
950
973
|
// stale copy of a line the agent already re-emitted (e.g. inserting a new
|
|
951
974
|
// closing `}` while the original `}` was never deleted, producing `}\n}`).
|
|
975
|
+
// A single edit may damage multiple unrelated segments (e.g. two block
|
|
976
|
+
// rewrites that each missed their trailing `}`), so detection and auto-fix
|
|
977
|
+
// operate on every new adjacent duplicate at once.
|
|
952
978
|
//
|
|
953
979
|
// Auto-fix is gated on bracket balance: we only remove the duplicate line if
|
|
954
980
|
// its removal restores the original file's `{}`/`()`/`[]` delta. That makes
|
|
@@ -1008,23 +1034,28 @@ function detectAndAutoFixDuplicates(
|
|
|
1008
1034
|
|
|
1009
1035
|
const formatPreview = (text: string): string => JSON.stringify(text.length > 60 ? `${text.slice(0, 60)}…` : text);
|
|
1010
1036
|
|
|
1011
|
-
// Auto-fix
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1037
|
+
// Auto-fix when removing one line from each new adjacent duplicate pair
|
|
1038
|
+
// collectively restores the original bracket balance. The balance check is
|
|
1039
|
+
// the safety gate: if we over- or under-correct (e.g. when 3+ adjacent
|
|
1040
|
+
// identical lines confuse the per-pair scan), the trial balance will not
|
|
1041
|
+
// match and we fall through to warnings.
|
|
1042
|
+
const origBalance = computeBalance(originalLines);
|
|
1043
|
+
const finalBalance = computeBalance(finalLines);
|
|
1044
|
+
if (!balancesEqual(origBalance, finalBalance)) {
|
|
1045
|
+
const trial = finalLines.slice();
|
|
1046
|
+
// Remove in reverse so earlier indices remain valid.
|
|
1047
|
+
for (let i = newDupPositions.length - 1; i >= 0; i--) {
|
|
1048
|
+
trial.splice(newDupPositions[i], 1);
|
|
1049
|
+
}
|
|
1050
|
+
if (balancesEqual(computeBalance(trial), origBalance)) {
|
|
1051
|
+
const previews = newDupPositions.map(pos => `${pos + 1} (${formatPreview(finalLines[pos])})`).join(", ");
|
|
1052
|
+
const noun = newDupPositions.length === 1 ? "duplicate line" : "duplicate lines";
|
|
1053
|
+
return {
|
|
1054
|
+
fixed: trial,
|
|
1055
|
+
warnings: [
|
|
1056
|
+
`Auto-fixed: removed ${noun} ${previews}; the edit left adjacent identical lines and bracket balance was off. Verify the result.`,
|
|
1057
|
+
],
|
|
1058
|
+
};
|
|
1028
1059
|
}
|
|
1029
1060
|
}
|
|
1030
1061
|
|