@lumoai/cli 1.29.1 → 1.30.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 -2
- package/assets/skill/references/docs.md +10 -2
- package/dist/cli/src/commands/doc-section-edit.js +11 -7
- package/dist/cli/src/commands/doc-update.js +16 -9
- package/dist/cli/src/index.js +4 -2
- package/dist/cli/src/lib/api.js +13 -0
- package/dist/shared/src/html-structure.js +79 -0
- package/package.json +1 -1
package/assets/skill/SKILL.md
CHANGED
|
@@ -97,10 +97,10 @@ The command catalog below is a **map**: it lists every command grouped by domain
|
|
|
97
97
|
|
|
98
98
|
**Documents** — see [docs.md](references/docs.md)
|
|
99
99
|
|
|
100
|
-
- `lumo doc create/update/show/list/delete` — document CRUD; `doc show` prints the body revision, `doc update` takes `--if-revision <n>` (mismatch → 409 conflict, re-read and retry — no silent overwrite)
|
|
100
|
+
- `lumo doc create/update/show/list/delete` — document CRUD; `doc show` prints the body revision, `doc update` takes `--if-revision <n>` (mismatch → 409 conflict, re-read and retry — no silent overwrite); a body update that drops tables/rows/headings vs the stored body is rejected 422 by the built-in structure guard unless `--allow-shrink` is passed (intentional deletions only — on 422 first suspect a stale edit base)
|
|
101
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
102
|
- `lumo doc show <doc> --section "<heading>"` — print just one heading-addressed section of the markdown source (byte-faithful slice, subsections included; revision on stderr; prefix `#…` pins depth)
|
|
103
|
-
- `lumo doc patch <doc> --section "<heading>" --content/--file/stdin [--if-revision N]` — replace ONLY that section (heading line included); every byte outside it is untouched; concurrent edits 409 instead of clobbering
|
|
103
|
+
- `lumo doc patch <doc> --section "<heading>" --content/--file/stdin [--if-revision N] [--allow-shrink]` — replace ONLY that section (heading line included); every byte outside it is untouched; concurrent edits 409 instead of clobbering; the structure guard 422s a replacement that drops the section's tables/rows/headings unless `--allow-shrink` (appends are never guarded)
|
|
104
104
|
- `lumo doc append <doc> [--section "<heading>"] --content/--file/stdin [--if-revision N]` — append at section end (or doc end without `--section`); pure insertion, no existing byte modified — the safest write for running logs/ledgers
|
|
105
105
|
- `lumo doc diff <doc> --file <local.md>` — compare the server-side markdown source against a local file (exit 0 identical, 1 with unified diff)
|
|
106
106
|
- `lumo doc move` — reparent under a parent / to root
|
|
@@ -63,8 +63,11 @@ The `Tags:` line is omitted when no tags were attached.
|
|
|
63
63
|
| `--add-tag-id <cuid>` | string (repeatable) | Attach tag by id. Max 20. |
|
|
64
64
|
| `--remove-tag <name>` | string (repeatable) | Detach tag by name. `--remove-tag <name>` resolves the name via find-or-create on the workspace. If the name was unknown, a new Tag row is created (orphan, no attachments) before the detach runs as a no-op. Use `--remove-tag-id <cuid>` to avoid orphans. Max 20. |
|
|
65
65
|
| `--remove-tag-id <cuid>` | string (repeatable) | Detach tag by id. Unknown ids are a no-op (no side effects). Max 20. |
|
|
66
|
+
| `--allow-shrink` | boolean | Let a body update through even when it drops tables/rows/headings versus the stored body (see structure guard below). |
|
|
66
67
|
|
|
67
|
-
Optimistic concurrency (LUM-409): `--if-revision <n>` only applies the update if the doc body is still at revision `n` (from `doc show`). Mismatch → 409 conflict, nothing written — re-read, rebase, retry. `--if-revision` alone is not an update (still errors "no fields to update")
|
|
68
|
+
Optimistic concurrency (LUM-409): `--if-revision <n>` only applies the update if the doc body is still at revision `n` (from `doc show`). Mismatch → 409 conflict, nothing written — re-read, rebase, retry. `--if-revision` alone is not an update (still errors "no fields to update"); same for `--allow-shrink`.
|
|
69
|
+
|
|
70
|
+
**Structure guard (LUM-410), built into the server:** a body update whose new render has **fewer `table` / `tr` / heading elements than the stored body** is rejected with **422** before anything is written — the error names each shrunk category with old→new counts (e.g. `table 1→0, tr 4→0`). This is the `verify-live-doc.ts` reconciliation moved into the write path, so table flattening (LUM-349) and stale-base section loss (#460) fail loudly even when nobody remembers to run the script. When the deletion is intentional (you really are removing a section/table), re-run with `--allow-shrink`. On a 422: don't reach for `--allow-shrink` reflexively — first check whether your edit base is stale (`doc show <doc> --raw`) and rebase. Only markdown-path writes are guarded; web-editor edits and `doc sync` (Google authority) are not.
|
|
68
71
|
|
|
69
72
|
`--tag` / `--tag-id` (bulk replace) are mutually exclusive with `--add-tag` / `--add-tag-id` / `--remove-tag` / `--remove-tag-id`. The CLI errors before any network call if both families are mixed.
|
|
70
73
|
|
|
@@ -88,6 +91,7 @@ lumo doc update RFC --tag final --add-tag oops
|
|
|
88
91
|
- After running `lumo doc list` or `doc show`, if the user wants to change a doc's scope, title, or content.
|
|
89
92
|
- For a small edit inside one section, prefer `doc patch` / `doc append` (below) — full `doc update` has the maximum clobber radius.
|
|
90
93
|
- When replacing the body from a previously fetched base, pass `--if-revision` so a concurrent edit fails loudly (409) instead of being overwritten.
|
|
94
|
+
- When the update intentionally deletes a section or table, pass `--allow-shrink` up front; otherwise expect (and want) the 422 structure guard on accidental structure loss.
|
|
91
95
|
|
|
92
96
|
### `lumo doc show <doc> [--raw | --section <heading>]` — print one document's detail
|
|
93
97
|
|
|
@@ -134,10 +138,13 @@ Replaces the **whole addressed section** (heading line included, subsections inc
|
|
|
134
138
|
| `--content <text>` | string | New section content (include the heading line — the whole section is replaced verbatim). |
|
|
135
139
|
| `--file <path>` | string | New section content from file (project-local sandbox). |
|
|
136
140
|
| `--if-revision <n>` | int | Only apply if the body is still at revision `n`. Recommended whenever the edit base was read earlier. |
|
|
141
|
+
| `--allow-shrink` | boolean | Let the patch through even when it drops tables/rows/headings within the addressed section (422 otherwise). |
|
|
137
142
|
|
|
138
143
|
Concurrency: the splice always commits **conditionally** on the revision the server read the source at — even without `--if-revision`, a concurrent body edit between read and write returns 409 instead of clobbering. On 409 the CLI prints the server reason plus a re-read-and-retry hint and exits 1.
|
|
139
144
|
|
|
140
|
-
|
|
145
|
+
Structure guard (LUM-410), **scoped to the addressed section**: a replacement whose render has fewer `table`/`tr`/heading elements than the old section's render is rejected with 422 naming each shrunk category (old→new counts); structure elsewhere in the document never factors in. Dropping the heading line itself trips the guard too (heading count shrinks). Pass `--allow-shrink` when the deletion is intentional. `doc append` is pure insertion and is never guarded.
|
|
146
|
+
|
|
147
|
+
Requires a stored markdown source (same rule as `--raw`); errors with the rebuild hint otherwise. Replacement is verbatim — if the new content omits the heading line, the heading is gone (an explicit choice the guard makes you confirm with `--allow-shrink`).
|
|
141
148
|
|
|
142
149
|
```bash
|
|
143
150
|
lumo doc show cmd_xxx --section "D 状态表" > sec.md # stderr: Revision: 6
|
|
@@ -151,6 +158,7 @@ lumo doc patch cmd_xxx --section "D 状态表" --file sec.md --if-revision 6
|
|
|
151
158
|
- User wants to change one section of a live doc ("update the status table", "rewrite section D") — patch beats full `doc update` on clobber radius and context size.
|
|
152
159
|
- Live-doc status updates that used to be read-whole/edit/upload-whole round-trips.
|
|
153
160
|
- If the patch 409s: re-read the section, rebase the edit, retry — never fall back to a full-body upload from the stale base.
|
|
161
|
+
- If the patch 422s (structure guard): the replacement drops tables/rows/headings the section has. Re-check the edit base first; add `--allow-shrink` only when the deletion is what the user wants.
|
|
154
162
|
|
|
155
163
|
### `lumo doc append <doc> [--section <heading>]` — append to a section (or the doc)
|
|
156
164
|
|
|
@@ -69,6 +69,8 @@ async function docSectionEdit(mode, reference, opts) {
|
|
|
69
69
|
}
|
|
70
70
|
if (ifRevision !== undefined)
|
|
71
71
|
body.ifRevision = ifRevision;
|
|
72
|
+
if (opts.allowShrink)
|
|
73
|
+
body.allowShrink = true;
|
|
72
74
|
const res = await fetch(`${(0, api_1.trimTrailingSlash)(apiUrl)}/api/documents/${id}/section`, {
|
|
73
75
|
method: 'POST',
|
|
74
76
|
headers: {
|
|
@@ -79,18 +81,20 @@ async function docSectionEdit(mode, reference, opts) {
|
|
|
79
81
|
});
|
|
80
82
|
if (!res.ok) {
|
|
81
83
|
const text = await res.text();
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
message = JSON.parse(text).error ?? text;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// non-JSON error body — print as-is
|
|
88
|
-
}
|
|
84
|
+
const message = (0, api_1.extractErrorMessage)(text);
|
|
89
85
|
if (res.status === 409) {
|
|
90
86
|
console.error(`Error: revision conflict: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
91
87
|
console.error(`Hint: re-read the section (lumo doc show ${reference}${opts.section ? ` --section "${opts.section}"` : ''}), rebase your edit on it, then retry with --if-revision <current>.`);
|
|
92
88
|
return 1;
|
|
93
89
|
}
|
|
90
|
+
if (res.status === 422) {
|
|
91
|
+
// Structure guard rejection (LUM-410): the replacement drops structure
|
|
92
|
+
// the addressed section has (the message names each category, old→new).
|
|
93
|
+
console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
94
|
+
console.error(`Hint: if the deletion is intentional, re-run with --allow-shrink. ` +
|
|
95
|
+
`Otherwise re-read the section (lumo doc show ${reference}${opts.section ? ` --section "${opts.section}"` : ''}) and rebase your edit on it.`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
94
98
|
console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
95
99
|
return 1;
|
|
96
100
|
}
|
|
@@ -82,6 +82,8 @@ async function docUpdate(reference, opts) {
|
|
|
82
82
|
}
|
|
83
83
|
body.ifRevision = Number(opts.ifRevision);
|
|
84
84
|
}
|
|
85
|
+
if (opts.allowShrink)
|
|
86
|
+
body.allowShrink = true;
|
|
85
87
|
// Resolve tag refs into ids
|
|
86
88
|
const deps = { apiUrl, token: creds.token };
|
|
87
89
|
let tagIds;
|
|
@@ -114,8 +116,10 @@ async function docUpdate(reference, opts) {
|
|
|
114
116
|
body.addTagIds = addTagIds;
|
|
115
117
|
if (removeTagIds !== undefined)
|
|
116
118
|
body.removeTagIds = removeTagIds;
|
|
117
|
-
// --if-revision
|
|
118
|
-
|
|
119
|
+
// --if-revision / --allow-shrink are modifiers, not fields — alone they
|
|
120
|
+
// are not an update.
|
|
121
|
+
if (Object.keys(body).filter(k => k !== 'ifRevision' && k !== 'allowShrink')
|
|
122
|
+
.length === 0) {
|
|
119
123
|
console.error('Error: no fields to update — provide at least one flag');
|
|
120
124
|
return 1;
|
|
121
125
|
}
|
|
@@ -130,19 +134,22 @@ async function docUpdate(reference, opts) {
|
|
|
130
134
|
});
|
|
131
135
|
if (!res.ok) {
|
|
132
136
|
const text = await res.text();
|
|
137
|
+
const message = (0, api_1.extractErrorMessage)(text);
|
|
133
138
|
if (res.status === 409 && opts.ifRevision !== undefined) {
|
|
134
|
-
let message = text;
|
|
135
|
-
try {
|
|
136
|
-
message = JSON.parse(text).error ?? text;
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
// non-JSON error body — print as-is
|
|
140
|
-
}
|
|
141
139
|
console.error(`Error: revision conflict: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
142
140
|
console.error(`Hint: re-read the doc (lumo doc show ${reference} --raw), rebase your edit ` +
|
|
143
141
|
`on the current source, then retry with --if-revision <current>.`);
|
|
144
142
|
return 1;
|
|
145
143
|
}
|
|
144
|
+
if (res.status === 422) {
|
|
145
|
+
// Structure guard rejection (LUM-410): the new body drops structure
|
|
146
|
+
// the stored body has (the message names each category, old→new).
|
|
147
|
+
console.error(`Error: ${(0, sanitize_1.sanitizeField)(message)}`);
|
|
148
|
+
console.error(`Hint: if the deletion is intentional, re-run with --allow-shrink. ` +
|
|
149
|
+
`Otherwise re-read the current source (lumo doc show ${reference} --raw) ` +
|
|
150
|
+
`and rebase your edit on it.`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
146
153
|
console.error(`Error: ${res.status} ${res.statusText}: ${(0, sanitize_1.sanitizeField)(text)}`);
|
|
147
154
|
return 1;
|
|
148
155
|
}
|
package/dist/cli/src/index.js
CHANGED
|
@@ -660,7 +660,7 @@ doc
|
|
|
660
660
|
.action(wrap(ref => (0, doc_sync_1.docSync)(ref)));
|
|
661
661
|
doc
|
|
662
662
|
.command('update <doc>')
|
|
663
|
-
.description('Update an existing document. <doc> accepts a cuid or a case-insensitive title (ambiguous titles fail with candidates). Replacement body comes from --content, --file, or piped stdin (pick one). --tag/--tag-id (bulk replace) cannot be combined with --add-tag/--add-tag-id/--remove-tag/--remove-tag-id.')
|
|
663
|
+
.description('Update an existing document. <doc> accepts a cuid or a case-insensitive title (ambiguous titles fail with candidates). Replacement body comes from --content, --file, or piped stdin (pick one). A body update that drops tables/rows/headings versus the stored body is rejected with 422 (structure guard) unless --allow-shrink is passed. --tag/--tag-id (bulk replace) cannot be combined with --add-tag/--add-tag-id/--remove-tag/--remove-tag-id.')
|
|
664
664
|
.option('--title <text>', 'New title')
|
|
665
665
|
.option('--content <text>', 'Replace content (inline)')
|
|
666
666
|
.option('--file <path>', 'Replace content from file')
|
|
@@ -673,6 +673,7 @@ doc
|
|
|
673
673
|
.option('--remove-tag <name>', 'Remove tag by name (repeatable). Unknown names are find-or-create, so prefer --remove-tag-id to avoid orphan rows.', collect, [])
|
|
674
674
|
.option('--remove-tag-id <cuid>', 'Remove tag by id (repeatable). Unknown ids are a no-op.', collect, [])
|
|
675
675
|
.option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show); mismatch fails with a 409 conflict')
|
|
676
|
+
.option('--allow-shrink', 'Let the update through even when it drops tables/rows/headings versus the stored body (default: rejected with 422)')
|
|
676
677
|
.action(wrap((reference, opts) => (0, doc_update_1.docUpdate)(reference, opts)));
|
|
677
678
|
doc
|
|
678
679
|
.command('show <doc>')
|
|
@@ -682,11 +683,12 @@ doc
|
|
|
682
683
|
.action(wrap((reference, opts) => (0, doc_show_1.docShow)(reference, opts)));
|
|
683
684
|
doc
|
|
684
685
|
.command('patch <doc>')
|
|
685
|
-
.description('Replace one heading-addressed section of the markdown source (heading line included), leaving every byte outside the section untouched. Requires the doc to have a stored markdown source. Commits conditionally on the body revision: a concurrent edit fails with 409 instead of being overwritten.')
|
|
686
|
+
.description('Replace one heading-addressed section of the markdown source (heading line included), leaving every byte outside the section untouched. Requires the doc to have a stored markdown source. Commits conditionally on the body revision: a concurrent edit fails with 409 instead of being overwritten. A replacement that drops tables/rows/headings within the addressed section is rejected with 422 (structure guard) unless --allow-shrink is passed.')
|
|
686
687
|
.requiredOption('--section <heading>', 'Heading of the section to replace (prefix with #… to pin depth)')
|
|
687
688
|
.option('--content <text>', 'New section content (inline markdown)')
|
|
688
689
|
.option('--file <path>', 'New section content from file')
|
|
689
690
|
.option('--if-revision <n>', 'Only apply if the doc body is still at this revision (from doc show)')
|
|
691
|
+
.option('--allow-shrink', 'Let the patch through even when it drops tables/rows/headings within the addressed section (default: rejected with 422)')
|
|
690
692
|
.action(wrap((reference, opts) => (0, doc_section_edit_1.docPatch)(reference, opts)));
|
|
691
693
|
doc
|
|
692
694
|
.command('append <doc>')
|
package/dist/cli/src/lib/api.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.hostMismatchWarning = hostMismatchWarning;
|
|
|
5
5
|
exports.resolveAuthedApiUrl = resolveAuthedApiUrl;
|
|
6
6
|
exports.resolveApiUrl = resolveApiUrl;
|
|
7
7
|
exports.trimTrailingSlash = trimTrailingSlash;
|
|
8
|
+
exports.extractErrorMessage = extractErrorMessage;
|
|
8
9
|
exports.verifyToken = verifyToken;
|
|
9
10
|
const DEFAULT_API_URL = 'https://www.uselumo.ai';
|
|
10
11
|
// Hostnames allowed to use plaintext http:// — local dev only. Everything
|
|
@@ -91,6 +92,18 @@ function resolveApiUrl() {
|
|
|
91
92
|
function trimTrailingSlash(url) {
|
|
92
93
|
return url.replace(/\/+$/, '');
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Pull the `error` field out of a JSON API error body; non-JSON bodies (or
|
|
97
|
+
* JSON without an `error` string) fall back to the raw text unchanged.
|
|
98
|
+
*/
|
|
99
|
+
function extractErrorMessage(text) {
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(text).error ?? text;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
94
107
|
async function verifyToken(apiUrl, token) {
|
|
95
108
|
let res;
|
|
96
109
|
try {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Structural tag counting over rendered document HTML.
|
|
4
|
+
*
|
|
5
|
+
* Single source of truth shared by the server-side structure guard on the
|
|
6
|
+
* document update path (LUM-410) and `scripts/verify-live-doc.ts`, so the
|
|
7
|
+
* guard and the script can never drift on what counts as "structure".
|
|
8
|
+
*
|
|
9
|
+
* Counting is regex-based over the rendered HTML. That is safe here because
|
|
10
|
+
* both inputs always come out of the same `markdownToHtml` renderer (or the
|
|
11
|
+
* Tiptap editor's serializer), which emits plain lowercase tags — this is
|
|
12
|
+
* not a general-purpose HTML parser.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.TAG_PATTERNS = void 0;
|
|
16
|
+
exports.countPattern = countPattern;
|
|
17
|
+
exports.compareStructure = compareStructure;
|
|
18
|
+
exports.hardMismatches = hardMismatches;
|
|
19
|
+
exports.softMismatches = softMismatches;
|
|
20
|
+
exports.structureShrinks = structureShrinks;
|
|
21
|
+
exports.formatStructureShrinks = formatStructureShrinks;
|
|
22
|
+
/** Categories `verify-live-doc.ts` reports on (hard = table structure). */
|
|
23
|
+
exports.TAG_PATTERNS = [
|
|
24
|
+
{ tag: 'table', pattern: /<table/g, hard: true },
|
|
25
|
+
{ tag: 'tr', pattern: /<tr/g, hard: true },
|
|
26
|
+
{ tag: 'h1', pattern: /<h1/g, hard: false },
|
|
27
|
+
{ tag: 'h2', pattern: /<h2/g, hard: false },
|
|
28
|
+
{ tag: 'h3', pattern: /<h3/g, hard: false },
|
|
29
|
+
{ tag: 'li', pattern: /<li/g, hard: false },
|
|
30
|
+
{ tag: 'a', pattern: /<a[\s>]/g, hard: false },
|
|
31
|
+
{ tag: 'strong', pattern: /<strong/g, hard: false },
|
|
32
|
+
{ tag: 'code', pattern: /<code/g, hard: false },
|
|
33
|
+
{ tag: 'blockquote', pattern: /<blockquote/g, hard: false },
|
|
34
|
+
];
|
|
35
|
+
function countPattern(html, pattern) {
|
|
36
|
+
return (html.match(pattern) ?? []).length;
|
|
37
|
+
}
|
|
38
|
+
function compareStructure(renderedHtml, storedHtml) {
|
|
39
|
+
return exports.TAG_PATTERNS.map(({ tag, pattern, hard }) => ({
|
|
40
|
+
tag,
|
|
41
|
+
rendered: countPattern(renderedHtml, pattern),
|
|
42
|
+
stored: countPattern(storedHtml, pattern),
|
|
43
|
+
hard,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
function hardMismatches(checks) {
|
|
47
|
+
return checks.filter(c => c.hard && c.rendered !== c.stored);
|
|
48
|
+
}
|
|
49
|
+
function softMismatches(checks) {
|
|
50
|
+
return checks.filter(c => !c.hard && c.rendered !== c.stored);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Categories the update guard protects. Headings are counted as one
|
|
54
|
+
* aggregate bucket (h1–h6) so promoting/demoting a heading level is not a
|
|
55
|
+
* loss — only dropping a section heading outright is.
|
|
56
|
+
*/
|
|
57
|
+
const GUARD_PATTERNS = [
|
|
58
|
+
{ tag: 'table', pattern: /<table/g },
|
|
59
|
+
{ tag: 'tr', pattern: /<tr/g },
|
|
60
|
+
{ tag: 'heading', pattern: /<h[1-6][\s>]/g },
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* Compare two rendered HTML bodies and report every guarded category whose
|
|
64
|
+
* count shrank. Empty result = the edit loses no guarded structure.
|
|
65
|
+
*/
|
|
66
|
+
function structureShrinks(oldHtml, newHtml) {
|
|
67
|
+
const shrinks = [];
|
|
68
|
+
for (const { tag, pattern } of GUARD_PATTERNS) {
|
|
69
|
+
const before = countPattern(oldHtml, pattern);
|
|
70
|
+
const after = countPattern(newHtml, pattern);
|
|
71
|
+
if (after < before)
|
|
72
|
+
shrinks.push({ tag, before, after });
|
|
73
|
+
}
|
|
74
|
+
return shrinks;
|
|
75
|
+
}
|
|
76
|
+
/** "table 3→0, tr 12→0" — the per-category loss detail for error messages. */
|
|
77
|
+
function formatStructureShrinks(shrinks) {
|
|
78
|
+
return shrinks.map(s => `${s.tag} ${s.before}→${s.after}`).join(', ');
|
|
79
|
+
}
|