@maci0/dsh-caveman 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +192 -0
  3. package/cordis.patch.yml +13 -0
  4. package/icon.svg +6 -0
  5. package/lib/client.js +488 -0
  6. package/lib/compress-detect.js +98 -0
  7. package/lib/compress-files.js +155 -0
  8. package/lib/compress-pipeline.js +109 -0
  9. package/lib/compress-rules.js +308 -0
  10. package/lib/compress-validate.js +227 -0
  11. package/lib/frontmatter.js +347 -0
  12. package/lib/host.js +15 -0
  13. package/lib/index.js +616 -0
  14. package/lib/modes.js +126 -0
  15. package/lib/skills.js +177 -0
  16. package/lib/types/compress-detect.d.ts +18 -0
  17. package/lib/types/compress-files.d.ts +76 -0
  18. package/lib/types/compress-pipeline.d.ts +32 -0
  19. package/lib/types/compress-rules.d.ts +65 -0
  20. package/lib/types/compress-validate.d.ts +36 -0
  21. package/lib/types/frontmatter.d.ts +57 -0
  22. package/lib/types/host.d.ts +201 -0
  23. package/lib/types/index.d.ts +87 -0
  24. package/lib/types/modes.d.ts +102 -0
  25. package/lib/types/skills.d.ts +56 -0
  26. package/locale/en.json +6 -0
  27. package/locale/zh.json +6 -0
  28. package/package.json +112 -0
  29. package/scripts/sync-upstream.mjs +158 -0
  30. package/skills/cavecrew/SKILL.md +91 -0
  31. package/skills/cavecrew/cavecrew-builder.md +46 -0
  32. package/skills/cavecrew/cavecrew-investigator.md +56 -0
  33. package/skills/cavecrew/cavecrew-reviewer.md +47 -0
  34. package/skills/caveman/SKILL.md +103 -0
  35. package/skills/caveman-commit/SKILL.md +63 -0
  36. package/skills/caveman-compress/SKILL.md +105 -0
  37. package/skills/caveman-explore/SKILL.md +42 -0
  38. package/skills/caveman-help/SKILL.md +68 -0
  39. package/skills/caveman-review/SKILL.md +53 -0
  40. package/skills/caveman-stats/SKILL.md +30 -0
  41. package/skills/investigate-first/SKILL.md +16 -0
  42. package/skills/lean-build/SKILL.md +18 -0
  43. package/skills/migration/SKILL.md +17 -0
  44. package/skills/safe-refactor/SKILL.md +16 -0
  45. package/skills/surgical-patch/SKILL.md +16 -0
  46. package/skills/verify-and-stop/SKILL.md +16 -0
  47. package/sync.manifest.json +25 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * File handling for the compress pipeline: sensitive path refusal, atomic
3
+ * writes, backups, and source reading.
4
+ *
5
+ * TypeScript port of the non-model parts of
6
+ * `skills/caveman-compress/scripts/compress.py` (MIT, © JuliusBrussee).
7
+ * The `callClaude` half is deliberately not ported: this plugin compresses
8
+ * with deterministic local rules instead (see `compress-rules.ts`). The
9
+ * Python original is dropped.
10
+ *
11
+ * @module dsh-caveman/compress-files
12
+ */
13
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { homedir } from 'node:os';
16
+ import { basename, dirname, join } from 'node:path';
17
+ /**
18
+ * Packaged default for the `maxFileSize` cap (500000 bytes, ~500 KB), used when
19
+ * the plugin row does not override it. `apply` validates the configured value
20
+ * and threads it through the pipeline; this is only the default.
21
+ */
22
+ export const MAX_FILE_SIZE = 500_000;
23
+ const SENSITIVE_BASENAME_REGEX = /^(\.env(\..+)?|\.netrc|credentials(\..+)?|secrets?(\..+)?|passwords?(\..+)?|id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|authorized_keys|known_hosts|.*\.(pem|key|p12|pfx|crt|cer|jks|keystore|asc|gpg))$/i;
24
+ const SENSITIVE_PATH_COMPONENTS = new Set([
25
+ '.ssh', '.aws', '.gnupg', '.kube', '.docker',
26
+ 'credential', 'credentials', 'secret', 'secrets',
27
+ ]);
28
+ const SENSITIVE_NAME_TOKENS = [
29
+ 'secret', 'credential', 'password', 'passwd',
30
+ 'apikey', 'accesskey', 'token', 'privatekey',
31
+ ];
32
+ /**
33
+ * Heuristic denylist for files that must never be rewritten by a tool that
34
+ * ships bytes to a model. Fail loudly rather than exfiltrate.
35
+ * @param filePath - absolute file path.
36
+ * @returns true when the path looks sensitive.
37
+ */
38
+ export function isSensitivePath(filePath) {
39
+ if (SENSITIVE_BASENAME_REGEX.test(basename(filePath)))
40
+ return true;
41
+ // Match the literal component first: the dot-prefixed denylist entries
42
+ // (`.ssh`, `.gnupg`, …) only exist with their dot, so the stripped form
43
+ // below can never reach them.
44
+ const parts = filePath.split(/[/\\]/).map((part) => part.toLowerCase());
45
+ if (parts.some((part) => SENSITIVE_PATH_COMPONENTS.has(part)))
46
+ return true;
47
+ // Token matching keeps the stripped form: `api-key` must read as `apikey`.
48
+ const flattened = parts.map((part) => part.replace(/[_\-.\s]/g, ''));
49
+ return flattened.some((part) => SENSITIVE_NAME_TOKENS.some((token) => part.includes(token)));
50
+ }
51
+ /** Platform data dir holding out-of-tree backups. */
52
+ function backupsBaseDir() {
53
+ const base = process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share');
54
+ return join(base, 'caveman-compress', 'backups');
55
+ }
56
+ /**
57
+ * Out-of-tree backup dir for a file, keyed by its parent dir name, kept
58
+ * outside the source tree so skill auto-loaders don't re-ingest backups.
59
+ * @param filePath - absolute source path.
60
+ * @returns the backup directory.
61
+ */
62
+ export function backupDirFor(filePath) {
63
+ return join(backupsBaseDir(), basename(dirname(filePath)));
64
+ }
65
+ /**
66
+ * Backup file path for a source file.
67
+ * @param filePath - absolute source path.
68
+ * @returns the `.original.md` backup path.
69
+ */
70
+ export function backupPathFor(filePath) {
71
+ const stem = basename(filePath).replace(/\.[^.]*$/, '');
72
+ return join(backupDirFor(filePath), `${stem}.original.md`);
73
+ }
74
+ /**
75
+ * Write bytes atomically: sibling temp file, fsync, rename. Loops over short
76
+ * writes so the temp file is never a truncated prefix of `data`. Preserves the
77
+ * destination's permission bits across the swap.
78
+ * @param filePath - destination path.
79
+ * @param data - bytes to write.
80
+ * @param write - write syscall seam; defaults to `fs.writeSync`.
81
+ * @param exclusive - publish a backup only if its destination does not exist.
82
+ */
83
+ export function writeBytesAtomic(filePath, data, write = writeSync, exclusive = false) {
84
+ const tmp = join(dirname(filePath), `${basename(filePath)}.${randomBytes(8).toString('hex')}.tmp`);
85
+ const fd = openSync(tmp, 'wx', 0o600);
86
+ try {
87
+ try {
88
+ let written = 0;
89
+ while (written < data.length) {
90
+ const count = write(fd, data, written, data.length - written);
91
+ if (count <= 0)
92
+ throw new Error(`Short write on ${filePath}: ${written} of ${data.length} bytes`);
93
+ written += count;
94
+ }
95
+ fsyncSync(fd);
96
+ }
97
+ finally {
98
+ closeSync(fd);
99
+ }
100
+ if (exclusive)
101
+ linkSync(tmp, filePath);
102
+ else {
103
+ if (existsSync(filePath))
104
+ chmodSync(tmp, statSync(filePath).mode & 0o777);
105
+ renameSync(tmp, filePath);
106
+ }
107
+ }
108
+ finally {
109
+ try {
110
+ unlinkSync(tmp);
111
+ }
112
+ catch { /* Original failure, if any, remains authoritative. */ }
113
+ }
114
+ }
115
+ /**
116
+ * Write text atomically as UTF-8, preserving the document's line terminator.
117
+ * @param filePath - destination path.
118
+ * @param text - text with `\n` line endings.
119
+ * @param newline - line terminator to emit.
120
+ */
121
+ export function writeTextAtomic(filePath, text, newline = '\n') {
122
+ const normalized = newline === '\n' ? text : text.replace(/\r\n/g, '\n').replace(/\n/g, newline);
123
+ writeBytesAtomic(filePath, Buffer.from(normalized, 'utf8'));
124
+ }
125
+ /**
126
+ * Read a source file strictly as UTF-8. A file that cannot be decoded
127
+ * exactly is refused: the round trip would destroy bytes.
128
+ *
129
+ * A leading BOM is detected from the raw bytes and reported separately: the
130
+ * decoder drops it, and the caller re-attaches it when writing so the byte
131
+ * survives the round trip.
132
+ * @param filePath - absolute source path.
133
+ * @returns text (LF-normalized), terminator, raw bytes for the backup, BOM flag.
134
+ */
135
+ export function readSource(filePath) {
136
+ const raw = readFileSync(filePath);
137
+ let text;
138
+ try {
139
+ const decoded = new TextDecoder('utf-8', { fatal: true }).decode(raw);
140
+ text = decoded;
141
+ }
142
+ catch {
143
+ throw new Error(`Refusing to compress ${filePath}: not valid UTF-8. `
144
+ + 'Compression rewrites the file in place, and any byte this tool '
145
+ + 'cannot decode would be destroyed by the round trip. '
146
+ + 'Convert the file to UTF-8 first.');
147
+ }
148
+ // The WHATWG UTF-8 decoder already swallows a leading BOM, so the flag has
149
+ // to come from the raw bytes; `text` never holds the U+FEFF.
150
+ const bom = raw.length >= 3 && raw[0] === 0xef && raw[1] === 0xbb && raw[2] === 0xbf;
151
+ const crlf = (text.match(/\r\n/g) ?? []).length;
152
+ const lf = (text.match(/\n/g) ?? []).length;
153
+ const newline = crlf * 2 > lf ? '\r\n' : '\n';
154
+ return { text: text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), newline, raw, bom };
155
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The compress pipeline: detect, guard, rewrite locally, validate, back up,
3
+ * and write. TypeScript port of the orchestration in
4
+ * `skills/caveman-compress/scripts/compress.py` (MIT, © JuliusBrussee) with
5
+ * one deliberate difference: the rewrite step uses deterministic local rules
6
+ * (`compress-rules.ts`) instead of a model call, so no file bytes ever leave
7
+ * the machine and no API key or CLI is needed.
8
+ *
9
+ * Fail-closed throughout: sensitive paths refuse, oversized files refuse,
10
+ * non-UTF-8 refuses, an existing backup aborts, a candidate that is not
11
+ * smaller aborts, and a candidate that fails validation aborts. The live
12
+ * file is written only after a passing validation.
13
+ *
14
+ * @module dsh-caveman/compress-pipeline
15
+ */
16
+ import { writeSync, existsSync, mkdirSync, realpathSync, statSync } from 'node:fs';
17
+ import { basename, resolve } from 'node:path';
18
+ import { detectFileType } from './compress-detect.js';
19
+ import { backupPathFor, backupDirFor, isSensitivePath, MAX_FILE_SIZE, readSource, writeBytesAtomic, writeTextAtomic } from './compress-files.js';
20
+ import { parseFrontmatter } from './frontmatter.js';
21
+ import { compressBody, isSmaller } from './compress-rules.js';
22
+ import { validate } from './compress-validate.js';
23
+ /**
24
+ * Compress one file in place, keeping an out-of-tree backup.
25
+ * @param inputPath - file to compress.
26
+ * @param maxFileSize - configured size cap in bytes; defaults to the packaged 500000.
27
+ * @returns the outcome; the file is untouched unless `ok` is true.
28
+ */
29
+ export function compressFile(inputPath, maxFileSize = MAX_FILE_SIZE) {
30
+ const requested = resolve(inputPath);
31
+ // Canonicalize before anything else: `rename` onto a symlink path swaps the
32
+ // link node for a regular file and leaves the real target untouched, and the
33
+ // sensitive-path denylist would otherwise be reading the link name.
34
+ let filePath;
35
+ try {
36
+ filePath = realpathSync(requested);
37
+ }
38
+ catch {
39
+ return { ok: false, reason: `File not found: ${requested}` };
40
+ }
41
+ let stat;
42
+ try {
43
+ stat = statSync(filePath);
44
+ }
45
+ catch {
46
+ return { ok: false, reason: `File not found: ${filePath}` };
47
+ }
48
+ if (!stat.isFile())
49
+ return { ok: false, reason: `Not a file: ${filePath}` };
50
+ if (stat.size > maxFileSize) {
51
+ return { ok: false, reason: `File too large to compress safely (max ${maxFileSize} bytes): ${filePath}` };
52
+ }
53
+ if (isSensitivePath(filePath)) {
54
+ return {
55
+ ok: false,
56
+ reason: `Refusing to compress ${filePath}: filename looks sensitive. Rename the file if this is a false positive.`,
57
+ };
58
+ }
59
+ const name = basename(filePath);
60
+ let source;
61
+ try {
62
+ source = readSource(filePath);
63
+ }
64
+ catch (error) {
65
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
66
+ }
67
+ // One classification per run; a backup file is never recompressed.
68
+ if (detectFileType(name, () => source.text) !== 'natural_language' || name.endsWith('.original.md')) {
69
+ return { ok: false, reason: `Skipping: ${name} is not natural language` };
70
+ }
71
+ if (source.text.trim() === '') {
72
+ return { ok: false, reason: 'Refusing to compress: file is empty or whitespace-only.' };
73
+ }
74
+ const backupPath = backupPathFor(filePath);
75
+ if (existsSync(backupPath)) {
76
+ return {
77
+ ok: false,
78
+ reason: `Backup already exists: ${backupPath}. Remove or rename it to proceed.`,
79
+ };
80
+ }
81
+ const { raw, body } = parseFrontmatter(source.text);
82
+ if (body.trim() === '') {
83
+ return { ok: false, reason: 'Refusing to compress: body is empty after frontmatter removal.' };
84
+ }
85
+ const compressedBody = compressBody(body);
86
+ if (compressedBody.trim() === '' || compressedBody.trim() === body.trim()) {
87
+ return { ok: false, reason: 'Compression produced no change; original left untouched.' };
88
+ }
89
+ if (!isSmaller(compressedBody, body)) {
90
+ return { ok: false, reason: 'Compressed output is not smaller than input; original left untouched.' };
91
+ }
92
+ const compressed = raw + compressedBody;
93
+ const result = validate(source.text, compressed);
94
+ if (!result.isValid) {
95
+ return {
96
+ ok: false,
97
+ reason: `Validation failed: ${result.errors.join('; ')}. Original left untouched.`,
98
+ };
99
+ }
100
+ mkdirSync(backupDirFor(filePath), { recursive: true });
101
+ writeBytesAtomic(backupPath, source.raw, writeSync, true);
102
+ writeTextAtomic(filePath, (source.bom ? '\uFEFF' : '') + compressed, source.newline);
103
+ return {
104
+ ok: true,
105
+ backupPath,
106
+ originalChars: body.trim().length,
107
+ compressedChars: compressedBody.trim().length,
108
+ };
109
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Deterministic local compression rules: the caveman style without a model.
3
+ *
4
+ * This replaces the `callClaude` half of
5
+ * `skills/caveman-compress/scripts/compress.py` (MIT, © JuliusBrussee),
6
+ * which this plugin deliberately does not port: shipping file bytes to a
7
+ * third-party model for a local rewrite is the "dumb feature" this port
8
+ * leaves out. Everything else (detect, validate, file handling) is ported
9
+ * faithfully; only the rewrite step is local and rule-based.
10
+ *
11
+ * Rules mirror the skill's Compression Rules section: drop articles, filler,
12
+ * pleasantries, hedging, and connective fluff; shorten redundant phrasing;
13
+ * keep code/URLs/paths/commands/technical terms/numbers byte-identical;
14
+ * never touch headings. Code spans (fenced, indented, inline) are masked
15
+ * before rewriting and restored after, so prose rules cannot reach them.
16
+ *
17
+ * @module dsh-caveman/compress-rules
18
+ */
19
+ const FENCE_OPEN_REGEX = /^[ ]{0,3}(`{3,}|~{3,})(?:[^\r\n]*)$/;
20
+ const CODE_MARKER_PREFIX = '@@CAVEMAN_PRESERVED_CODE_';
21
+ const LIST_ITEM_REGEX = /^\s*(?:[-*+]|\d+[.)])\s/;
22
+ /** Memoized closer per opening fence run; documents reuse one or two markers. */
23
+ const FENCE_CLOSE_CACHE = new Map();
24
+ /**
25
+ * Closing-fence matcher for an opening run: same character, at least as long,
26
+ * no info string. Cached because the pattern only depends on the run.
27
+ * @param run - the opening fence run, e.g. ` ``` ` or `~~~`.
28
+ * @returns the anchored closer regex.
29
+ */
30
+ function fenceCloseRegex(run) {
31
+ const cached = FENCE_CLOSE_CACHE.get(run);
32
+ if (cached !== undefined)
33
+ return cached;
34
+ const compiled = new RegExp(`^[ ]{0,3}${run[0]}{${run.length},}[ \\t]*$`);
35
+ FENCE_CLOSE_CACHE.set(run, compiled);
36
+ return compiled;
37
+ }
38
+ /**
39
+ * Phrase renames: the rewrite rules that emit a replacement word instead of
40
+ * nothing. These run *before* {@link DROP}, and that order is load-bearing: in
41
+ * the original single-pass alternation the rename branches sat at the end, so
42
+ * a rename phrase always won over the article inside it: "implement a
43
+ * solution for a thing" became "fix thing", not "implement solution for a
44
+ * thing". Renaming first preserves that.
45
+ *
46
+ * Each phrase carries the word the ruleset names for it
47
+ * (`skills/caveman-compress/SKILL.md`: `"big" not "extensive"`, `"fix" not
48
+ * "implement a solution for"`, `"use" not "utilize"`), plus `to` for `in order
49
+ * to` and `because` for `due to the fact that`.
50
+ *
51
+ * `the reason (?:is|why) because` was a branch of that alternation and is not
52
+ * one here: the article branch preceded it and any text it could match starts
53
+ * with `the\b`, so the article branch always consumed that text first and the
54
+ * branch could never fire (0 hits over the repo docs and the bench corpus).
55
+ * Groups, in order: 1 = `to`, 2 = `because`, 3 = `fix`, 4 = `use`, 5 = `big`.
56
+ */
57
+ const RENAME = new RegExp(String.raw `\b(?:${[
58
+ String.raw `(in order to\b)`,
59
+ String.raw `(due to the fact that\b)`,
60
+ String.raw `(implement a solution for\b)`,
61
+ String.raw `(utilize\b)`,
62
+ String.raw `(extensive\b)`,
63
+ ].join('|')})`, 'gi');
64
+ /**
65
+ * Drop-only rules: everything the rewrite deletes. One pass with a plain
66
+ * string replacement, so the frequent matches (articles, filler words) do not
67
+ * pay for a replacer call; the rare renames above keep the function replacer.
68
+ *
69
+ * The leading `\b` is written once around the alternation instead of once per
70
+ * branch: every branch starts with a word character, so the assertion is the
71
+ * same at a given position either way, and one boundary check in front of the
72
+ * branch dispatch retires roughly half the instructions the engine spent
73
+ * re-checking it before each branch.
74
+ */
75
+ const DROP = new RegExp(String.raw `\b(?:${[
76
+ String.raw `it might be worth\b\s*`,
77
+ String.raw `you could consider\b\s*`,
78
+ String.raw `it would be good to\b\s*`,
79
+ String.raw `you might want to\b\s*`,
80
+ String.raw `it seems (?:like |that )?`,
81
+ String.raw `(?:perhaps|maybe)\b\s*`,
82
+ String.raw `make sure to\b\s*`,
83
+ String.raw `remember to\b\s*`,
84
+ String.raw `you should (?:always )?\b\s*`,
85
+ String.raw `(?:it is|it's|this is) important (?:to|because|that)\b\s*`,
86
+ String.raw `sure\b[,.!]?\s*`,
87
+ String.raw `certainly\b[,.!]?\s*`,
88
+ String.raw `of course\b[,.!]?\s*`,
89
+ String.raw `happy to\b[,.!]?\s*`,
90
+ String.raw `i'd recommend\b[,.!]?\s*`,
91
+ String.raw `we'd recommend\b[,.!]?\s*`,
92
+ String.raw `please note that\b\s*`,
93
+ String.raw `please\b[,.!]?\s*`,
94
+ String.raw `(?:just|really|basically|actually|simply|essentially|generally|very|quite|rather)\b\s*`,
95
+ String.raw `(?:a|an|the)\b\s*`,
96
+ ].join('|')})`, 'gi');
97
+ function rewriteReplacer(_match, to, because, fix, use, big) {
98
+ if (to !== undefined)
99
+ return 'to';
100
+ if (because !== undefined)
101
+ return 'because';
102
+ if (fix !== undefined)
103
+ return 'fix';
104
+ if (use !== undefined)
105
+ return 'use';
106
+ if (big !== undefined)
107
+ return 'big';
108
+ return '';
109
+ }
110
+ /** Connective fluff dropped at sentence starts outside code. */
111
+ const CONNECTIVES = /(^|[.!?]\s+)(however|furthermore|additionally|moreover|in addition|also)\b[,.]?\s*/gi;
112
+ /** Trailing connective residue (`, however.`) dropped at line end, keeping the stop. */
113
+ const TRAILING_CONNECTIVE = /,\s*(however|furthermore|additionally|moreover|in addition|also)\s*([.!]?)\s*$/i;
114
+ /** Inline code spans, masked to a marker before the prose rules run. */
115
+ const INLINE_SPAN = /`[^`]+`/g;
116
+ /** The mask an inline span is replaced with; index is the span's slot. */
117
+ const INLINE_SPAN_MARKER = /@@caveman-inline-(\d+)@@/g;
118
+ /** Runs of spaces/tabs collapsed to one space. */
119
+ const SPACE_RUN = /[ \t]{2,}/g;
120
+ /** Whitespace before punctuation, dropped. */
121
+ const SPACE_BEFORE_PUNCT = /\s+([,.!?;:])/g;
122
+ /** An ATX heading line; those pass through byte-identical. */
123
+ const HEADING_LINE = /^#{1,6}\s/;
124
+ /**
125
+ * Mask fenced and indented code blocks with opaque markers.
126
+ *
127
+ * The output is assembled from the *gaps between* blocks instead of one string
128
+ * per line: a document with no code returns the input string itself, and a
129
+ * document with three blocks pushes a handful of segments rather than tens of
130
+ * thousands of line references and then joins them all. The result is
131
+ * identical: each segment is a run of whole lines and the join puts the same
132
+ * `\n` separators back.
133
+ * @param text - markdown body.
134
+ * @returns masked text plus the blocks for restoration.
135
+ */
136
+ export function maskCodeBlocks(text) {
137
+ if (text.includes(CODE_MARKER_PREFIX)) {
138
+ throw new Error('Input contains reserved Caveman code-preservation marker');
139
+ }
140
+ const lines = text.split('\n');
141
+ const blocks = [];
142
+ /** Output segments, or null while no block has been found. */
143
+ let out = null;
144
+ /** First line not yet emitted into `out`. */
145
+ let cursor = 0;
146
+ let i = 0;
147
+ while (i < lines.length) {
148
+ const line = lines[i] ?? '';
149
+ const fence = FENCE_OPEN_REGEX.exec(line);
150
+ const indented = line !== '' && (line.startsWith(' ') || line.startsWith('\t'));
151
+ if (fence === null && !indented) {
152
+ i += 1;
153
+ continue;
154
+ }
155
+ const start = i;
156
+ if (fence?.[1] !== undefined) {
157
+ const run = fence[1];
158
+ const close = fenceCloseRegex(run);
159
+ i += 1;
160
+ while (i < lines.length) {
161
+ if (close.test(lines[i] ?? '')) {
162
+ i += 1;
163
+ break;
164
+ }
165
+ i += 1;
166
+ }
167
+ }
168
+ else {
169
+ i += 1;
170
+ while (i < lines.length) {
171
+ const candidate = lines[i] ?? '';
172
+ if (candidate.trim() === '' || candidate.startsWith(' ') || candidate.startsWith('\t')) {
173
+ i += 1;
174
+ continue;
175
+ }
176
+ break;
177
+ }
178
+ }
179
+ const block = lines.slice(start, i).join('\n');
180
+ if (out === null)
181
+ out = [];
182
+ // Skip an empty gap: two blocks can be adjacent, and an empty segment
183
+ // would add a separator the original one-line-per-entry layout did not.
184
+ if (start > cursor)
185
+ out.push(lines.slice(cursor, start).join('\n'));
186
+ const marker = `${CODE_MARKER_PREFIX}${blocks.length}@@`;
187
+ blocks.push(block);
188
+ out.push(marker);
189
+ cursor = i;
190
+ }
191
+ // No code: hand back the input string, no join and no per-line segment.
192
+ if (out === null)
193
+ return { masked: text, blocks };
194
+ if (cursor < lines.length)
195
+ out.push(lines.slice(cursor).join('\n'));
196
+ return { masked: out.join('\n'), blocks };
197
+ }
198
+ /**
199
+ * Restore masked code blocks exactly; fail closed on tampering.
200
+ * @param text - rewritten text with markers.
201
+ * @param blocks - blocks from {@link maskCodeBlocks}.
202
+ * @returns text with code restored.
203
+ */
204
+ export function restoreCodeBlocks(text, blocks) {
205
+ if (blocks.length === 0) {
206
+ if (text.includes(CODE_MARKER_PREFIX)) {
207
+ throw new Error('Unknown Caveman code-preservation marker in output');
208
+ }
209
+ return text;
210
+ }
211
+ // Single pass: one combined pattern replaces every marker via lookup,
212
+ // instead of two full scans per block. A marker appearing anything but
213
+ // exactly once fails closed below.
214
+ const pattern = new RegExp(`${CODE_MARKER_PREFIX}(\\d+)@@`, 'g');
215
+ const seen = new Array(blocks.length).fill(0);
216
+ const restored = text.replace(pattern, (marker, index) => {
217
+ const slot = Number(index);
218
+ if (!Number.isInteger(slot) || slot < 0 || slot >= blocks.length)
219
+ return marker;
220
+ seen[slot] = (seen[slot] ?? 0) + 1;
221
+ return blocks[slot] ?? marker;
222
+ });
223
+ for (let index = 0; index < blocks.length; index += 1) {
224
+ if (seen[index] !== 1) {
225
+ throw new Error(`Preserved code marker ${CODE_MARKER_PREFIX}${index}@@ altered; refusing to write`);
226
+ }
227
+ }
228
+ if (CODE_MARKER_PREFIX.length > 0 && restored.includes(CODE_MARKER_PREFIX)) {
229
+ throw new Error('Unknown Caveman code-preservation marker in output');
230
+ }
231
+ return restored;
232
+ }
233
+ /**
234
+ * Compress one prose line: drop fluff, tighten phrasing, collapse space.
235
+ * Inline code spans (`` `...` ``) are masked first so rules skip them.
236
+ * @param line - a single non-heading, non-code line.
237
+ * @returns the compressed line.
238
+ */
239
+ export function compressLine(line) {
240
+ // Two guards over work most lines cannot need: a line with no backtick has
241
+ // no span to mask or restore, and `, however.` is the only shape the
242
+ // trailing-connective rule can match. Both keep the output byte-identical.
243
+ const spans = [];
244
+ let masked = line;
245
+ if (line.includes('`')) {
246
+ masked = line.replace(INLINE_SPAN, (span) => {
247
+ spans.push(span);
248
+ return `@@caveman-inline-${spans.length - 1}@@`;
249
+ });
250
+ }
251
+ let out = masked.replace(RENAME, rewriteReplacer);
252
+ out = out.replace(DROP, '');
253
+ out = out.replace(CONNECTIVES, '$1');
254
+ if (out.includes(','))
255
+ out = out.replace(TRAILING_CONNECTIVE, '$2');
256
+ out = out.replace(SPACE_RUN, ' ').trim();
257
+ out = out.replace(SPACE_BEFORE_PUNCT, '$1');
258
+ if (spans.length === 0)
259
+ return out;
260
+ return out.replace(INLINE_SPAN_MARKER, (_, index) => spans[Number(index)] ?? '');
261
+ }
262
+ /**
263
+ * Compress a markdown body with local rules. Headings pass through
264
+ * byte-identical; list markers and table pipes are preserved; everything
265
+ * else goes through {@link compressLine}. Empty results fall back to the
266
+ * original line so structure never collapses.
267
+ * @param body - markdown body (frontmatter already removed).
268
+ * @returns the compressed body.
269
+ */
270
+ export function compressBody(body) {
271
+ const { masked, blocks } = maskCodeBlocks(body);
272
+ // In place over the one array `split` already produced: no second array and
273
+ // no per-line callback frame. Skipped lines keep their own string.
274
+ const lines = masked.split('\n');
275
+ for (let index = 0; index < lines.length; index += 1) {
276
+ const line = lines[index] ?? '';
277
+ const trimmed = line.trim();
278
+ if (trimmed === '')
279
+ continue;
280
+ if (HEADING_LINE.test(line))
281
+ continue;
282
+ if (line.includes(CODE_MARKER_PREFIX))
283
+ continue;
284
+ const item = LIST_ITEM_REGEX.exec(line);
285
+ if (item !== null) {
286
+ const compressed = compressLine(line.slice(item[0].length));
287
+ if (compressed !== '')
288
+ lines[index] = item[0] + compressed;
289
+ continue;
290
+ }
291
+ if (trimmed.startsWith('|'))
292
+ continue;
293
+ const compressed = compressLine(line);
294
+ if (compressed !== '')
295
+ lines[index] = compressed;
296
+ }
297
+ return restoreCodeBlocks(lines.join('\n'), blocks);
298
+ }
299
+ /**
300
+ * Whether the candidate actually compresses the body (strictly smaller).
301
+ * Guard against a rewrite that validates structurally but saves nothing.
302
+ * @param candidate - compressed body.
303
+ * @param body - original body.
304
+ * @returns true when strictly smaller.
305
+ */
306
+ export function isSmaller(candidate, body) {
307
+ return candidate.trim().length < body.trim().length;
308
+ }