@lumoai/cli 1.26.0 → 1.27.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/assets/skill/SKILL.md +2 -0
- package/assets/skill/references/docs.md +38 -5
- package/assets/skill/references/memory.md +18 -6
- package/dist/cli/src/commands/doc-diff.js +82 -0
- package/dist/cli/src/commands/doc-show.js +19 -2
- package/dist/cli/src/index.js +9 -2
- package/dist/cli/src/lib/unified-diff.js +154 -0
- package/package.json +1 -1
package/assets/skill/SKILL.md
CHANGED
|
@@ -98,6 +98,8 @@ The command catalog below is a **map**: it lists every command grouped by domain
|
|
|
98
98
|
**Documents** — see [docs.md](references/docs.md)
|
|
99
99
|
|
|
100
100
|
- `lumo doc create/update/show/list/delete` — document CRUD
|
|
101
|
+
- `lumo doc show <doc> --raw` — print the byte-identical markdown source of the last markdown upload (the only legal edit base; errors when no source is stored — never falls back to the lossy HTML→md render)
|
|
102
|
+
- `lumo doc diff <doc> --file <local.md>` — compare the server-side markdown source against a local file (exit 0 identical, 1 with unified diff)
|
|
101
103
|
- `lumo doc move` — reparent under a parent / to root
|
|
102
104
|
- `lumo doc bind/unbind <doc> <task>` — task linkage
|
|
103
105
|
- `lumo doc share/unshare/share-list` — member sharing
|
|
@@ -85,17 +85,50 @@ lumo doc update RFC --tag final --add-tag oops
|
|
|
85
85
|
- User wants to revise an existing doc.
|
|
86
86
|
- After running `lumo doc list` or `doc show`, if the user wants to change a doc's scope, title, or content.
|
|
87
87
|
|
|
88
|
-
### `lumo doc show <doc
|
|
88
|
+
### `lumo doc show <doc> [--raw]` — print one document's detail
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Default mode prints a key:value header (id, title, scope, project, created/updated timestamps, mentioned tasks) and the content rendered back to markdown.
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
93
|
lumo doc show "RFC: doc CLI"
|
|
94
|
+
lumo doc show cmd_xxx --raw > base.md # byte-identical edit base
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
**`--raw` (LUM-408)** prints the byte-identical markdown source of the last markdown upload — no header, no trailing newline added. The server stores the raw markdown (`sourceMarkdown`) alongside the rendered HTML on every markdown write (`doc create/update --content/--file/stdin`, gdoc import/sync), so `--raw` output IS a legal edit base: `doc show --raw > base.md`, edit, `doc update --file base.md` round-trips losslessly.
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
- A web-editor (HTML-direct) edit or revision restore **invalidates** the stored source — the doc's markdown source is gone until the next markdown upload.
|
|
100
|
+
- When no source is stored (legacy doc or after an HTML edit), `--raw` **fails with exit 1 and a rebuild hint** — it never silently falls back to the lossy HTML→markdown reverse render (that fallback flattened tables: LUM-349). Rebuild flow: `doc show` (rendered) → reconstruct the markdown faithfully → `doc update --file rebuilt.md` → `--raw` works from then on.
|
|
101
|
+
- Raw output is verbatim (unsanitized) by design — redirect it to a file rather than reading it in a terminal when the doc's provenance is uncertain.
|
|
102
|
+
|
|
103
|
+
Note: the markdown rendered by **default-mode** `doc show` is still best-effort (tables flatten). Round-trip via `doc show > tmp.md && doc update --file tmp.md` is NOT a no-op — use `--raw` as the edit base instead.
|
|
104
|
+
|
|
105
|
+
Use default mode when the user wants to read a doc; use `--raw` whenever the output will be edited and uploaded back.
|
|
106
|
+
|
|
107
|
+
### When to suggest `doc show --raw`
|
|
108
|
+
|
|
109
|
+
- Before any `doc update --file` that starts from existing remote content — fetch the base with `--raw`, never from rendered `doc show` output.
|
|
110
|
+
- User asks "what's the exact source of this doc", or an agent needs a faithful local copy of a live doc.
|
|
111
|
+
- If `--raw` errors (no stored source), walk the rebuild flow above instead of editing the rendered output.
|
|
112
|
+
|
|
113
|
+
### `lumo doc diff <doc> --file <local.md>` — compare remote markdown source vs a local file
|
|
114
|
+
|
|
115
|
+
Compares the server-side stored markdown source (the byte-identical last markdown upload) against a local file, making remote/local split-brain visible on demand.
|
|
116
|
+
|
|
117
|
+
| Flag | Type | Notes |
|
|
118
|
+
| --------------- | ------ | ---------------------------------------------------------------------------------- |
|
|
119
|
+
| `--file <path>` | string | Required. Local markdown file; same project-local sandbox as `doc update --file`. |
|
|
120
|
+
|
|
121
|
+
Exit codes: **0** = byte-identical (prints `Clean: …`), **1** = divergent (prints a unified diff, `--- remote/<id> (sourceMarkdown)` vs `+++ local/<file>`) or error. A doc without a stored markdown source errors explicitly (upload a markdown base once, then diff works).
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
lumo doc diff cmd_xxx --file docs/live-docs/research-intake-ledger.md
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### When to suggest `doc diff`
|
|
128
|
+
|
|
129
|
+
- Before uploading a locally edited file with `doc update --file` — check whether the remote source moved since the local copy was taken (prevents stale-upload clobbering, the #460 incident class).
|
|
130
|
+
- When a repo-tracked markdown source (e.g. `docs/live-docs/`) and the live doc may have drifted and the user asks which is current.
|
|
131
|
+
- After a suspected concurrent edit: a clean diff (exit 0) proves remote and local are in sync.
|
|
99
132
|
|
|
100
133
|
### `lumo doc list [flags]` — list documents
|
|
101
134
|
|
|
@@ -298,7 +331,7 @@ Synced cmd_xxx "Quarterly Plan" from Google
|
|
|
298
331
|
The CLI does **not** currently support:
|
|
299
332
|
|
|
300
333
|
- `--from-editor` (interactive $EDITOR).
|
|
301
|
-
- Lossless markdown round-trip.
|
|
334
|
+
- Lossless markdown round-trip from **rendered** `doc show` output (use `doc show --raw` — lossless whenever a markdown source is stored).
|
|
302
335
|
- Reordering siblings within the same parent (`--before` / `--after`); use the Web UI for that.
|
|
303
336
|
|
|
304
337
|
### When to suggest session binding for docs
|
|
@@ -45,17 +45,29 @@ the bound task; `lumo project memory add ...` records onto its project.
|
|
|
45
45
|
|
|
46
46
|
The command prints **one** of these outcome lines:
|
|
47
47
|
|
|
48
|
-
| Output line
|
|
49
|
-
|
|
50
|
-
| `Added <CATEGORY> <SCOPE> memory …`
|
|
51
|
-
| `Merged into existing memory <id> (near-duplicate) …`
|
|
52
|
-
| `Superseded an existing memory; new version <id> …`
|
|
53
|
-
| `Skipped — duplicate of existing memory <id>, nothing written …` | Exact or near-exact duplicate; no write performed (NOOP).
|
|
48
|
+
| Output line | Meaning |
|
|
49
|
+
| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
50
|
+
| `Added <CATEGORY> <SCOPE> memory …` | No near-duplicate found; stored as a new row. |
|
|
51
|
+
| `Merged into existing memory <id> (near-duplicate) …` | Near-duplicate found; the existing row was refined/updated in-place (UPDATE). |
|
|
52
|
+
| `Superseded an existing memory; new version <id> …` | New content contradicts an old memory; old row invalidated, new row created (SUPERSEDE). |
|
|
53
|
+
| `Skipped — duplicate of existing memory <id>, nothing written …` | Exact or near-exact duplicate; no write performed (NOOP). |
|
|
54
54
|
|
|
55
55
|
Content is **always normalized to English** before storing — the memory store has
|
|
56
56
|
a single canonical language. If you supply text in another language the CLI
|
|
57
57
|
translates it automatically; the stored memory will be in English.
|
|
58
58
|
|
|
59
|
+
### Lumo memory vs the harness memory tool
|
|
60
|
+
|
|
61
|
+
Claude Code / the Claude API may expose a file-based **memory tool** (a
|
|
62
|
+
`/memories` directory the model writes autonomously). That store is the agent's
|
|
63
|
+
private scratchpad — un-grounded, free-form, invisible to the team, and outside
|
|
64
|
+
Lumo's flywheel. **Project engineering lessons always go through `lumo task/project
|
|
65
|
+
memory add`** — never record them only in the harness memory tool, and never bulk-copy
|
|
66
|
+
harness memory files into Lumo memory (they are ungrounded drafts; Lumo's
|
|
67
|
+
extract→ground→reconcile write path is the only vetted entry). The two stores are
|
|
68
|
+
layered, not mirrored: transient working notes may live in the harness tool,
|
|
69
|
+
durable team knowledge lives in Lumo memory.
|
|
70
|
+
|
|
59
71
|
### When to record a memory (worthiness)
|
|
60
72
|
|
|
61
73
|
Record only knowledge that is **invisible in the codebase** — the _why_ behind a
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.docDiff = docDiff;
|
|
4
|
+
const config_1 = require("../lib/config");
|
|
5
|
+
const api_1 = require("../lib/api");
|
|
6
|
+
const resolve_doc_id_1 = require("../lib/resolve-doc-id");
|
|
7
|
+
const sanitize_1 = require("../lib/sanitize");
|
|
8
|
+
const doc_input_1 = require("../lib/doc-input");
|
|
9
|
+
const path_guard_1 = require("../lib/path-guard");
|
|
10
|
+
const unified_diff_1 = require("../lib/unified-diff");
|
|
11
|
+
/**
|
|
12
|
+
* `lumo doc diff <doc> --file <local.md>` (LUM-408).
|
|
13
|
+
*
|
|
14
|
+
* Compares the server-side markdown source (Document.sourceMarkdown — the
|
|
15
|
+
* byte-identical last markdown upload) against a local file, so a
|
|
16
|
+
* remote/local split-brain is visible on demand instead of discovered from
|
|
17
|
+
* memory after a stale upload clobbers someone's edit.
|
|
18
|
+
*
|
|
19
|
+
* Exit codes: 0 = sources byte-identical, 1 = divergent (or error).
|
|
20
|
+
*/
|
|
21
|
+
async function docDiff(reference, opts) {
|
|
22
|
+
if (!reference) {
|
|
23
|
+
console.error('Error: missing <doc>. Usage: lumo doc diff <doc> --file <local.md>');
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
if (!opts.file) {
|
|
27
|
+
console.error('Error: --file <local.md> is required for doc diff');
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
const creds = (0, config_1.readCredentials)();
|
|
31
|
+
if (!creds) {
|
|
32
|
+
console.error('Error: not logged in. Run `lumo auth login` first.');
|
|
33
|
+
return 1;
|
|
34
|
+
}
|
|
35
|
+
const apiUrl = (0, api_1.resolveAuthedApiUrl)(creds.apiUrl);
|
|
36
|
+
// Same sandbox as doc create/update --file: project-local, non-sensitive.
|
|
37
|
+
const check = (0, path_guard_1.checkArtifactFilePath)(opts.file);
|
|
38
|
+
if (!check.ok) {
|
|
39
|
+
console.error(check.reason === 'unreadable'
|
|
40
|
+
? `Error: ${(0, doc_input_1.unreadableFileMessage)(opts.file)}`
|
|
41
|
+
: `Error: refusing to read ${opts.file} — ${check.detail}. ` +
|
|
42
|
+
`--file must be a non-sensitive path inside the project directory.`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
const localText = await (0, doc_input_1.readFileUtf8)(check.resolved);
|
|
46
|
+
const id = await (0, resolve_doc_id_1.lookupDocId)(apiUrl, creds.token, reference);
|
|
47
|
+
if (!id) {
|
|
48
|
+
console.error(`Error: Document not found: ${reference}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}`, {
|
|
52
|
+
headers: { Authorization: `Bearer ${creds.token}` },
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
const data = (await res.json());
|
|
60
|
+
const d = data.document;
|
|
61
|
+
if (!d) {
|
|
62
|
+
console.error('Error: server returned an empty document response');
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
if (typeof d.sourceMarkdown !== 'string') {
|
|
66
|
+
console.error(`Error: ${d.id} has no stored markdown source to diff against (last edit ` +
|
|
67
|
+
`was HTML-direct or predates markdown source storage). Upload a markdown ` +
|
|
68
|
+
`base once (\`lumo doc update ${d.id} --file <base.md>\`) and diff from then on.`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (d.sourceMarkdown === localText) {
|
|
72
|
+
console.log(`Clean: remote markdown source of ${d.id} matches ${opts.file} (${Buffer.byteLength(localText, 'utf8')} bytes)`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const diff = (0, unified_diff_1.formatUnifiedDiff)(d.sourceMarkdown, localText, `remote/${d.id} (sourceMarkdown)`, `local/${opts.file}`);
|
|
76
|
+
// Byte-level divergence with identical line sequences (e.g. trailing
|
|
77
|
+
// newline only) still counts as divergent — say so explicitly.
|
|
78
|
+
console.log(diff !== ''
|
|
79
|
+
? (0, sanitize_1.sanitizeField)(diff)
|
|
80
|
+
: `Divergent: byte-level difference only (e.g. trailing newline) between remote ${d.id} and ${opts.file}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
@@ -27,9 +27,9 @@ function formatShowOutput(vm) {
|
|
|
27
27
|
const header = lines.join('\n');
|
|
28
28
|
return vm.bodyMarkdown ? `${header}\n\n${(0, sanitize_1.sanitizeField)(vm.bodyMarkdown)}` : header;
|
|
29
29
|
}
|
|
30
|
-
async function docShow(reference) {
|
|
30
|
+
async function docShow(reference, opts = {}) {
|
|
31
31
|
if (!reference) {
|
|
32
|
-
console.error('Error: missing <doc>. Usage: lumo doc show <doc>');
|
|
32
|
+
console.error('Error: missing <doc>. Usage: lumo doc show <doc> [--raw]');
|
|
33
33
|
return 1;
|
|
34
34
|
}
|
|
35
35
|
const creds = (0, config_1.readCredentials)();
|
|
@@ -58,6 +58,23 @@ async function docShow(reference) {
|
|
|
58
58
|
console.error('Error: server returned an empty document response');
|
|
59
59
|
return 1;
|
|
60
60
|
}
|
|
61
|
+
if (opts.raw) {
|
|
62
|
+
// Byte-identical markdown source of the last markdown upload (LUM-408).
|
|
63
|
+
// Written verbatim (no header, no sanitization, no added newline) so the
|
|
64
|
+
// output is a legal edit base: `doc show --raw > base.md` round-trips.
|
|
65
|
+
// No silent fallback to the lossy HTML→markdown reverse render — that
|
|
66
|
+
// fallback is exactly what flattened tables in LUM-349.
|
|
67
|
+
if (typeof d.sourceMarkdown !== 'string') {
|
|
68
|
+
console.error(`Error: ${d.id} has no stored markdown source (last edit was HTML-direct ` +
|
|
69
|
+
`or predates markdown source storage). --raw refuses to fall back to the ` +
|
|
70
|
+
`lossy HTML→markdown render. Rebuild a base instead: run \`lumo doc show ${d.id}\`, ` +
|
|
71
|
+
`reconstruct the markdown faithfully, then \`lumo doc update ${d.id} --file <rebuilt.md>\` ` +
|
|
72
|
+
`— from then on --raw works.`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write(d.sourceMarkdown);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
61
78
|
// Server returns `contentMarkdown` derived from the HTML body (LUM-83+).
|
|
62
79
|
// Fall back to parsing the raw content as legacy Tiptap JSON for docs
|
|
63
80
|
// written before the storage shape changed.
|
package/dist/cli/src/index.js
CHANGED
|
@@ -107,6 +107,7 @@ const doc_import_gdoc_1 = require("./commands/doc-import-gdoc");
|
|
|
107
107
|
const doc_sync_1 = require("./commands/doc-sync");
|
|
108
108
|
const doc_update_1 = require("./commands/doc-update");
|
|
109
109
|
const doc_show_1 = require("./commands/doc-show");
|
|
110
|
+
const doc_diff_1 = require("./commands/doc-diff");
|
|
110
111
|
const doc_list_1 = require("./commands/doc-list");
|
|
111
112
|
const doc_delete_1 = require("./commands/doc-delete");
|
|
112
113
|
const doc_bind_1 = require("./commands/doc-bind");
|
|
@@ -665,8 +666,14 @@ doc
|
|
|
665
666
|
.action(wrap((reference, opts) => (0, doc_update_1.docUpdate)(reference, opts)));
|
|
666
667
|
doc
|
|
667
668
|
.command('show <doc>')
|
|
668
|
-
.description('Show document header and body (doc = title or cuid)')
|
|
669
|
-
.
|
|
669
|
+
.description('Show document header and body (doc = title or cuid). --raw prints the byte-identical markdown source of the last markdown upload (a legal edit base); it errors when no markdown source is stored instead of falling back to the lossy HTML→markdown render.')
|
|
670
|
+
.option('--raw', 'Print the stored markdown source verbatim (no header); errors if absent')
|
|
671
|
+
.action(wrap((reference, opts) => (0, doc_show_1.docShow)(reference, opts)));
|
|
672
|
+
doc
|
|
673
|
+
.command('diff <doc>')
|
|
674
|
+
.description('Compare the server-side markdown source against a local file. Exit 0 when byte-identical, 1 with a unified diff when divergent. Requires the doc to have a stored markdown source.')
|
|
675
|
+
.requiredOption('--file <path>', 'Local markdown file to compare against')
|
|
676
|
+
.action(wrap((reference, opts) => (0, doc_diff_1.docDiff)(reference, opts)));
|
|
670
677
|
doc
|
|
671
678
|
.command('list')
|
|
672
679
|
.description('List documents visible to the current user')
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal line-based unified diff (LUM-408, `lumo doc diff`).
|
|
4
|
+
*
|
|
5
|
+
* Pure and dependency-free: the CLI ships only commander + markdown-it, and
|
|
6
|
+
* shelling out to system `diff` would not be portable. Equality (the exit
|
|
7
|
+
* code) is decided on raw bytes by the caller — this renderer only has to
|
|
8
|
+
* make the divergence readable, so a plain LCS with a size guard is enough.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.formatUnifiedDiff = formatUnifiedDiff;
|
|
12
|
+
const CONTEXT_LINES = 3;
|
|
13
|
+
/** Above this old×new line product the LCS table is too big — degrade to a single whole-block hunk. */
|
|
14
|
+
const MAX_LCS_CELLS = 4_000_000;
|
|
15
|
+
function splitLines(text) {
|
|
16
|
+
return text.split('\n');
|
|
17
|
+
}
|
|
18
|
+
/** LCS-based op list for the (already prefix/suffix-trimmed) middle section. */
|
|
19
|
+
function diffOps(oldLines, newLines) {
|
|
20
|
+
const n = oldLines.length;
|
|
21
|
+
const m = newLines.length;
|
|
22
|
+
if (n * m > MAX_LCS_CELLS) {
|
|
23
|
+
return [
|
|
24
|
+
...oldLines.map(text => ({ type: 'del', text })),
|
|
25
|
+
...newLines.map(text => ({ type: 'add', text })),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
// lcs[i][j] = LCS length of oldLines[i:] vs newLines[j:]
|
|
29
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
30
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
31
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
32
|
+
lcs[i][j] =
|
|
33
|
+
oldLines[i] === newLines[j]
|
|
34
|
+
? lcs[i + 1][j + 1] + 1
|
|
35
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const ops = [];
|
|
39
|
+
let i = 0;
|
|
40
|
+
let j = 0;
|
|
41
|
+
while (i < n && j < m) {
|
|
42
|
+
if (oldLines[i] === newLines[j]) {
|
|
43
|
+
ops.push({ type: 'ctx', text: oldLines[i] });
|
|
44
|
+
i++;
|
|
45
|
+
j++;
|
|
46
|
+
}
|
|
47
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
48
|
+
ops.push({ type: 'del', text: oldLines[i] });
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
ops.push({ type: 'add', text: newLines[j] });
|
|
53
|
+
j++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
while (i < n)
|
|
57
|
+
ops.push({ type: 'del', text: oldLines[i++] });
|
|
58
|
+
while (j < m)
|
|
59
|
+
ops.push({ type: 'add', text: newLines[j++] });
|
|
60
|
+
return ops;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Render a unified diff between two texts. Returns '' when the line
|
|
64
|
+
* sequences are identical (callers decide byte-equality separately —
|
|
65
|
+
* e.g. a trailing-newline-only difference still flips the exit code).
|
|
66
|
+
*/
|
|
67
|
+
function formatUnifiedDiff(oldText, newText, oldLabel, newLabel) {
|
|
68
|
+
const oldAll = splitLines(oldText);
|
|
69
|
+
const newAll = splitLines(newText);
|
|
70
|
+
// Trim the common prefix/suffix so the LCS only sees the changed middle.
|
|
71
|
+
let prefix = 0;
|
|
72
|
+
while (prefix < oldAll.length &&
|
|
73
|
+
prefix < newAll.length &&
|
|
74
|
+
oldAll[prefix] === newAll[prefix]) {
|
|
75
|
+
prefix++;
|
|
76
|
+
}
|
|
77
|
+
let suffix = 0;
|
|
78
|
+
while (suffix < oldAll.length - prefix &&
|
|
79
|
+
suffix < newAll.length - prefix &&
|
|
80
|
+
oldAll[oldAll.length - 1 - suffix] === newAll[newAll.length - 1 - suffix]) {
|
|
81
|
+
suffix++;
|
|
82
|
+
}
|
|
83
|
+
const middleOps = diffOps(oldAll.slice(prefix, oldAll.length - suffix), newAll.slice(prefix, newAll.length - suffix));
|
|
84
|
+
if (!middleOps.some(op => op.type !== 'ctx'))
|
|
85
|
+
return '';
|
|
86
|
+
// Re-attach trimmed context so hunks can carry CONTEXT_LINES around edits.
|
|
87
|
+
const ops = [
|
|
88
|
+
...oldAll.slice(0, prefix).map(text => ({ type: 'ctx', text })),
|
|
89
|
+
...middleOps,
|
|
90
|
+
...oldAll
|
|
91
|
+
.slice(oldAll.length - suffix)
|
|
92
|
+
.map(text => ({ type: 'ctx', text })),
|
|
93
|
+
];
|
|
94
|
+
// Group ops into hunks: a change plus up to CONTEXT_LINES of context on
|
|
95
|
+
// each side; nearby changes merge into one hunk.
|
|
96
|
+
const lines = [`--- ${oldLabel}`, `+++ ${newLabel}`];
|
|
97
|
+
let oldLineNo = 1;
|
|
98
|
+
let newLineNo = 1;
|
|
99
|
+
let idx = 0;
|
|
100
|
+
while (idx < ops.length) {
|
|
101
|
+
if (ops[idx].type === 'ctx') {
|
|
102
|
+
oldLineNo++;
|
|
103
|
+
newLineNo++;
|
|
104
|
+
idx++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Found a change — open a hunk starting CONTEXT_LINES back.
|
|
108
|
+
let hunkStart = idx;
|
|
109
|
+
let ctxBack = 0;
|
|
110
|
+
while (hunkStart > 0 && ops[hunkStart - 1].type === 'ctx' && ctxBack < CONTEXT_LINES) {
|
|
111
|
+
hunkStart--;
|
|
112
|
+
ctxBack++;
|
|
113
|
+
}
|
|
114
|
+
// Extend forward: include changes separated by ≤ 2×CONTEXT_LINES context.
|
|
115
|
+
let hunkEnd = idx;
|
|
116
|
+
let scan = idx;
|
|
117
|
+
while (scan < ops.length) {
|
|
118
|
+
if (ops[scan].type !== 'ctx') {
|
|
119
|
+
hunkEnd = scan;
|
|
120
|
+
scan++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let run = 0;
|
|
124
|
+
while (scan + run < ops.length && ops[scan + run].type === 'ctx')
|
|
125
|
+
run++;
|
|
126
|
+
if (scan + run < ops.length && run <= CONTEXT_LINES * 2) {
|
|
127
|
+
scan += run;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
const tail = Math.min(hunkEnd + CONTEXT_LINES, ops.length - 1);
|
|
133
|
+
const hunkOps = ops.slice(hunkStart, tail + 1);
|
|
134
|
+
const oldStart = oldLineNo - ctxBack;
|
|
135
|
+
const newStart = newLineNo - ctxBack;
|
|
136
|
+
const oldCount = hunkOps.filter(o => o.type !== 'add').length;
|
|
137
|
+
const newCount = hunkOps.filter(o => o.type !== 'del').length;
|
|
138
|
+
lines.push(`@@ -${oldStart}${oldCount === 1 ? '' : `,${oldCount}`} +${newStart}${newCount === 1 ? '' : `,${newCount}`} @@`);
|
|
139
|
+
for (const op of hunkOps) {
|
|
140
|
+
const marker = op.type === 'ctx' ? ' ' : op.type === 'del' ? '-' : '+';
|
|
141
|
+
lines.push(`${marker}${op.text}`);
|
|
142
|
+
}
|
|
143
|
+
// Advance the line counters across everything the hunk consumed.
|
|
144
|
+
for (let k = idx; k <= tail; k++) {
|
|
145
|
+
const t = ops[k].type;
|
|
146
|
+
if (t !== 'add')
|
|
147
|
+
oldLineNo++;
|
|
148
|
+
if (t !== 'del')
|
|
149
|
+
newLineNo++;
|
|
150
|
+
}
|
|
151
|
+
idx = tail + 1;
|
|
152
|
+
}
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|