create-pmos 0.31.0 → 0.32.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.
@@ -0,0 +1,223 @@
1
+ /* md-normalize — a newline in a PMOS document means what it says.
2
+ *
3
+ * ONE implementation, used on three sides, in the idiom `preview-kind.js` established for exactly
4
+ * this reason:
5
+ * - scripts/md-newlines.js requires it (the check, the --fix, the CI ratchet)
6
+ * - workspace/server.js requires it (the doc tab's `normalize` action)
7
+ * - the browser loads it as /lib/md-normalize.js (window.MdNormalize)
8
+ * so the command, the gate and the button can never disagree about what a normalized file is.
9
+ *
10
+ * ── WHAT IT IS FOR ─────────────────────────────────────────────────────────────────────────────
11
+ * PMOS hard-wraps prose at ~100 columns: 30,345 continuation lines across 1,013 of 1,022 tracked
12
+ * files. A single newline inside a paragraph is NOT a line break in markdown — CommonMark joins the
13
+ * lines, GitHub joins them, the workspace's own renderer joins them — so this looks right everywhere
14
+ * inside PMOS. It stops looking right the moment a file is published: Jira wiki markup treats a
15
+ * newline as a line break, a Google Doc keeps every newline, and several markdown→ADF converters
16
+ * emit a hard break per `\n`. The paragraph arrives as a stack of ragged short lines, and no
17
+ * converter can undo it, because an accidental wrap and a deliberate break are THE SAME BYTE in the
18
+ * source. The only place the distinction still exists is the file.
19
+ *
20
+ * ── WHAT IT REFUSES TO TOUCH ───────────────────────────────────────────────────────────────────
21
+ * Frontmatter, fenced code (including unterminated), indented code, table rows, headings, rules,
22
+ * link-reference definitions, HTML blocks, a line ending in two spaces or a backslash (a DELIBERATE
23
+ * hard break — an author's intent, not a wrap), and a line followed by a setext underline. Joining
24
+ * inside any of those changes what the document MEANS rather than how it wraps, and a repair tool
25
+ * that damages a file is worse than the defect it repairs.
26
+ *
27
+ * ── THE CORRECTNESS PROPERTY ───────────────────────────────────────────────────────────────────
28
+ * Idempotence: normalize(normalize(x)) === normalize(x), asserted over all 1,022 tracked files
29
+ * rather than over fixtures. The ratchet, the button and an adopter's repair run are all
30
+ * untrustworthy without it.
31
+ */
32
+ 'use strict';
33
+ (function (root, factory) {
34
+ const api = factory();
35
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
36
+ else root.MdNormalize = api;
37
+ })(typeof self !== 'undefined' ? self : this, function () {
38
+ const FENCE = /^(\s*)(`{3,}|~{3,})/;
39
+ const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
40
+ const RULE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
41
+ const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
42
+ const QUOTE = /^(\s{0,3}>\s?)(.*)$/;
43
+ const TABLE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
44
+ const SETEXT = /^\s{0,3}(=+|-+)\s*$/;
45
+ const REF_DEF = /^\s{0,3}\[[^\]]+\]:\s/;
46
+ const HTML_BLOCK = /^\s{0,3}<[a-zA-Z!/?]/;
47
+ const INDENTED_CODE = /^(?: {4,}|\t)\S/;
48
+ /* A break the AUTHOR asked for. Markdown spells it two trailing spaces or a trailing backslash,
49
+ * and it is the one newline in a paragraph that is not an accident — eating it would be removing
50
+ * intent, which is the failure this tool exists to avoid, not to commit. */
51
+ const HARD_BREAK = /(?: {2,}|\\)$/;
52
+
53
+ const isBlank = (l) => !l.trim();
54
+
55
+ /** A table's header row: this line has a pipe and the NEXT line is the delimiter. */
56
+ const isTableHeader = (lines, i) =>
57
+ lines[i].includes('|') && TABLE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
58
+
59
+ /** A row inside a table body — a pipe line in a run that began with a header. */
60
+ function tableRunEnd(lines, i) {
61
+ let j = i + 2;
62
+ while (j < lines.length && lines[j].includes('|') && lines[j].trim()) j++;
63
+ return j;
64
+ }
65
+
66
+ /**
67
+ * A line that can never be joined ONTO the previous one — it opens a block of its own. This is
68
+ * the list the blank-line rule is derived from too, so "what needs a blank line above it" and
69
+ * "what stops a paragraph" cannot drift apart.
70
+ */
71
+ function opensBlock(lines, i) {
72
+ const l = lines[i];
73
+ return HEADING.test(l) || RULE.test(l) || FENCE.test(l) || ITEM.test(l) || QUOTE.test(l)
74
+ || REF_DEF.test(l) || HTML_BLOCK.test(l) || INDENTED_CODE.test(l) || isTableHeader(lines, i)
75
+ || SETEXT.test(l);
76
+ }
77
+
78
+ /**
79
+ * Normalize `src`.
80
+ *
81
+ * Returns `{ text, changed, joins, blanks }` — the bytes, whether anything moved, how many lines
82
+ * were joined into a paragraph or an item, and how many blank lines were inserted above a block.
83
+ * The counts are what the CLI reports per file: a tool that says "changed" without saying what it
84
+ * changed is asking to be trusted rather than read.
85
+ */
86
+ function normalize(src) {
87
+ const input = String(src == null ? '' : src);
88
+ const nl = input.indexOf('\r\n') >= 0 ? '\r\n' : '\n';
89
+ const lines = input.replace(/\r\n?/g, '\n').split('\n');
90
+ const out = [];
91
+ let joins = 0;
92
+ let blanks = 0;
93
+ let i = 0;
94
+
95
+ // ── frontmatter: copied through, never parsed and never joined ──────────────────────────────
96
+ if (lines[0] === '---') {
97
+ let j = 1;
98
+ while (j < lines.length && lines[j] !== '---') j++;
99
+ if (j < lines.length) { for (let k = 0; k <= j; k++) out.push(lines[k]); i = j + 1; }
100
+ }
101
+
102
+ /* A continuation line, left-trimmed. Trailing whitespace is dropped ONLY when it is not a hard
103
+ * break: `.trim()` would eat the author's two trailing spaces mid-paragraph, which is the one
104
+ * newline in a paragraph that was asked for. */
105
+ const piece = (l) => {
106
+ const s2 = l.replace(/^\s+/, '');
107
+ return HARD_BREAK.test(s2) ? s2 : s2.replace(/\s+$/, '');
108
+ };
109
+
110
+ /** The blank line a block construct needs above it, when prose is what sits there. */
111
+ const blankAbove = () => {
112
+ const prev = out.length ? out[out.length - 1] : '';
113
+ if (!out.length || isBlank(prev)) return;
114
+ // Only prose gets a blank inserted after it. Two adjacent list items, a table row under its
115
+ // header, a fence closing — none of those want one, and inserting it would restructure the
116
+ // document rather than repair its wrapping.
117
+ if (ITEM.test(prev) || QUOTE.test(prev) || prev.includes('|') || HEADING.test(prev)) return;
118
+ out.push('');
119
+ blanks++;
120
+ };
121
+
122
+ while (i < lines.length) {
123
+ const line = lines[i];
124
+
125
+ if (isBlank(line)) { out.push(line); i++; continue; }
126
+
127
+ // fenced code — verbatim, to the closing fence or to EOF
128
+ const fence = FENCE.exec(line);
129
+ if (fence) {
130
+ blankAbove();
131
+ const close = new RegExp('^\\s*' + fence[2][0] + '{' + fence[2].length + ',}\\s*$');
132
+ out.push(line); i++;
133
+ while (i < lines.length && !close.test(lines[i])) { out.push(lines[i]); i++; }
134
+ if (i < lines.length) { out.push(lines[i]); i++; }
135
+ continue;
136
+ }
137
+
138
+ // indented code, headings, rules, reference definitions, HTML — one line, untouched
139
+ if (INDENTED_CODE.test(line) || HEADING.test(line) || RULE.test(line) || REF_DEF.test(line)
140
+ || HTML_BLOCK.test(line) || SETEXT.test(line)) {
141
+ /* A blank is NEVER inserted above a `---`/`===` line. Directly under prose that is a SETEXT
142
+ * HEADING, not a rule, and separating it would turn one heading into a paragraph and a
143
+ * horizontal rule — the untouchable rule wins over the blank-line rule wherever they meet. */
144
+ if (HEADING.test(line)) blankAbove();
145
+ out.push(line); i++;
146
+ continue;
147
+ }
148
+
149
+ // a table: its header, its delimiter and its rows are records — never joined, never reflowed
150
+ if (isTableHeader(lines, i)) {
151
+ blankAbove();
152
+ const end = tableRunEnd(lines, i);
153
+ for (let k = i; k < end; k++) out.push(lines[k]);
154
+ i = end;
155
+ continue;
156
+ }
157
+
158
+ // a list item, plus the continuation lines that belong to it
159
+ const item = ITEM.exec(line);
160
+ if (item) {
161
+ blankAbove();
162
+ /* INSIDE a list item, four spaces is not code — it is the item's own text column. The
163
+ * generic `opensBlock` test calls an indented continuation an indented code block and stops,
164
+ * which left every nested item's wrapped line un-joined. Code inside an item starts four
165
+ * columns further in than the item's CONTENT does, so that is the line this measures. */
166
+ const contentCol = item[1].length + item[2].length + item[3].length;
167
+ const isItemCode = (l) => (l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) >= contentCol + 4;
168
+ let body = line;
169
+ i++;
170
+ while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)
171
+ && /^\s+\S/.test(lines[i]) && !isItemCode(lines[i])
172
+ && !(HEADING.test(lines[i]) || RULE.test(lines[i]) || FENCE.test(lines[i])
173
+ || ITEM.test(lines[i]) || QUOTE.test(lines[i]) || isTableHeader(lines, i))) {
174
+ body += ' ' + piece(lines[i]);
175
+ joins++; i++;
176
+ }
177
+ out.push(body);
178
+ continue;
179
+ }
180
+
181
+ // a blockquote line, plus its lazy continuations
182
+ const quote = QUOTE.exec(line);
183
+ if (quote) {
184
+ /* A `>` with nothing after it is the blank line INSIDE a quote — it is what separates two
185
+ * quoted paragraphs. Joining the next paragraph onto it deletes the separator, silently
186
+ * turns two paragraphs into one, and (because the result then joins again on the next run)
187
+ * is how this tool would fail its own idempotence property. Found by running the proof over
188
+ * the real corpus rather than over fixtures: 50 files did exactly this. */
189
+ if (!quote[2].trim()) { out.push(line); i++; continue; }
190
+ let body = line;
191
+ i++;
192
+ while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)) {
193
+ const q = QUOTE.exec(lines[i]);
194
+ const inner = q ? q[2] : lines[i];
195
+ if (!inner.trim()) break; // a blank quote line ends it
196
+ if (q && (HEADING.test(inner) || ITEM.test(inner) || RULE.test(inner) || FENCE.test(inner))) break;
197
+ if (!q && opensBlock(lines, i)) break; // lazy continuation, but not a new block
198
+ body += ' ' + piece(inner);
199
+ joins++; i++;
200
+ }
201
+ out.push(body);
202
+ continue;
203
+ }
204
+
205
+ // prose — the case the whole tool is about
206
+ let para = line;
207
+ i++;
208
+ while (i < lines.length && !isBlank(lines[i]) && !opensBlock(lines, i) && !HARD_BREAK.test(para)) {
209
+ para += ' ' + piece(lines[i]);
210
+ joins++; i++;
211
+ }
212
+ out.push(para);
213
+ }
214
+
215
+ const text = out.join(nl);
216
+ return { text, changed: text !== input, joins, blanks };
217
+ }
218
+
219
+ /** True when the file is already normalized — the check half, with no allocation of a new string. */
220
+ const isNormalized = (src) => !normalize(src).changed;
221
+
222
+ return { normalize, isNormalized };
223
+ });
@@ -61,6 +61,7 @@
61
61
  * something does adopt it, it will not adopt seven bypasses with it.
62
62
  */
63
63
 
64
+ const fs = require('fs');
64
65
  const path = require('path');
65
66
 
66
67
  class ForbiddenWrite extends Error {
@@ -117,29 +118,69 @@ const ALLOWED_DIR_PREFIXES = Object.freeze([
117
118
  const ALLOWED_FILES = Object.freeze([
118
119
  'planning/runs/agent-runs.jsonl',
119
120
  'planning/runs/verify-queue.jsonl',
120
- 'planning/runs/work-items.jsonl',
121
+ /* `planning/work-items.jsonl`, NOT `planning/runs/work-items.jsonl`. The latter was allowlisted
122
+ * here for weeks and has never existed — scripts/work-item.js records that directly. An allowlist
123
+ * entry for a path that does not exist permits nothing and protects nothing; what it does is make
124
+ * the list look like it covers the factory's ledger when it does not. Found by an independent pass
125
+ * comparing this list against pmos/graph-membership.json, which is the whole argument for the
126
+ * derivation below: two enumerations of the same thing disagree, and nothing notices. */
127
+ 'planning/work-items.jsonl',
121
128
  'discoveries.md',
122
129
  ]);
123
130
 
124
131
  /** Kept for the refusal message only — never for deciding permission. */
125
132
  const ALLOWED_BASENAMES = Object.freeze(ALLOWED_FILES.map((f) => path.posix.basename(f)));
126
133
 
127
- /** Named explicitly so the refusal says WHY, rather than "not on the allowlist". Each of these
128
- * would otherwise be caught by the allowlist anyway — the value here is the message. */
129
- const HUMAN_DECISION_SINKS = Object.freeze({
130
- // The module argues that a DIRECTORY prefix "would admit the FOURTH ledger somebody adds next year
131
- // without anyone deciding to — exactly the class-failure this rewrite exists to close" — and then
132
- // allows `planning/evals/` as a prefix, which admits the PM's own override log and any future
133
- // planning/evals/*.jsonl. The argument is right; it just was not applied to its own second
134
- // directory. These two are the PM's calibration record, not the driver's scratch space.
135
- 'eval-calibration.md': 'the PM\'s log of every override of an evaluator verdict is the PM\'s (D40)',
136
- 'calibration-status.md': 'the declared calibration state is the PM\'s to assert, not the driver\'s',
137
- 'acceptances.jsonl': 'the Acceptance Gate is the PM\'s and non-delegable (D12)',
138
- 'evals.jsonl': 'a Review-Gate verdict on the driver\'s own work is self-grading — the driver may not record one (D40)',
139
- 'outcome_checks.jsonl': 'whether the bet paid off is the PM\'s judgement (D57)',
140
- 'DECISIONS.md': 'an architectural decision is the PM\'s (D45)',
141
- 'pm-decisions.md': 'the PM\'s decision journal is the PM\'s',
142
- });
134
+ /** ── DERIVED FROM THE REGISTRY, NOT RESTATED ────────────────────────────────────────────────────
135
+ *
136
+ * This was an inline map of seven basenames to reasons — a second enumeration of exactly the
137
+ * control-graph classification that `pmos/graph-membership.json` holds, with the D-numbers mirrored
138
+ * in both. Two copies of a rule are two places for it to be wrong, and these two ALREADY disagreed:
139
+ * the allowlist above named a ledger that has never existed while the registry named the real one.
140
+ *
141
+ * So the reasons come from the registry's `why` for every record it classifies `control`. Keyed by
142
+ * basename, because that is what the refusal path looks up.
143
+ *
144
+ * FAIL-OPEN IS NOT A RISK HERE, and it is worth saying why rather than trusting it: this map only
145
+ * supplies the REASON in a refusal message. Permission is decided by `onAllowlist` below, which
146
+ * refuses everything not sanctioned whether or not a reason was found. If the registry is missing —
147
+ * a vendored kit, a partial checkout — the write is still refused; the message is just the generic
148
+ * one. A guard whose enforcement depended on a file it might not have would be a different and much
149
+ * worse design. */
150
+ let REGISTRY_UNAVAILABLE = null;
151
+ function loadHumanDecisionSinks() {
152
+ const out = {};
153
+ try {
154
+ /* TWO LAYOUTS, the same two-path resolution scripts/md-newlines.js uses for its normalizer.
155
+ *
156
+ * In a checkout this file is `<repo>/workspace/lib/`, so the registry is two levels up. In an
157
+ * npm install the workspace app is bundled BESIDE the vendored kit, not inside it, so the
158
+ * registry — if the adopter has one — is `<install>/pmos/graph-membership.json`. Neither path
159
+ * is guaranteed, and that is fine: this map only supplies the REASON in a refusal message.
160
+ * The write is refused either way by `onAllowlist` below. */
161
+ const tried = [
162
+ path.join(__dirname, '..', '..', 'pmos', 'graph-membership.json'), // a checkout
163
+ path.join(__dirname, '..', '..', '..', 'pmos', 'graph-membership.json'), // beside a vendored kit
164
+ ];
165
+ const found = tried.find((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
166
+ if (!found) throw new Error(`no graph-membership.json in: ${tried.join(', ')}`);
167
+ const raw = fs.readFileSync(found, 'utf8');
168
+ for (const r of (JSON.parse(raw).records || [])) {
169
+ if (!r || r.graph !== 'control' || typeof r.path !== 'string' || r.path.endsWith('/')) continue;
170
+ const base = path.posix.basename(r.path);
171
+ if (ALLOWED_BASENAMES.includes(base)) continue; // the driver's own sanctioned ledgers
172
+ if (!out[base]) out[base] = String(r.why || 'a control-graph record — the PM\'s, not the driver\'s');
173
+ }
174
+ } catch (e) {
175
+ /* RECORDED, not swallowed. The first version caught silently, and a missing `require('fs')` in
176
+ * this very file turned into an empty map with no signal at all — every reasoned refusal quietly
177
+ * became the generic one and two suite assertions were the only thing that noticed. A catch that
178
+ * hides its own cause is how a degradation becomes permanent. */
179
+ REGISTRY_UNAVAILABLE = e && e.message ? e.message : String(e);
180
+ }
181
+ return out;
182
+ }
183
+ const HUMAN_DECISION_SINKS = Object.freeze(loadHumanDecisionSinks());
143
184
 
144
185
  /** Disposition tokens (D60), widened to the bare verbs bypass 4 slipped through, and anchored so a
145
186
  * status VALUE that begins with one is a disposition while prose mentioning one is not. */
@@ -530,5 +571,6 @@ function guardedWriter(write) {
530
571
 
531
572
  module.exports = {
532
573
  assertWritable, guardedWriter, carriesApproval, carriesDisposition, isDisposition, PROSE_LEAD, ForbiddenWrite,
533
- ALLOWED_DIR_PREFIXES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
574
+ ALLOWED_DIR_PREFIXES, ALLOWED_FILES, ALLOWED_BASENAMES, HUMAN_DECISION_SINKS, DISPOSITION_RE,
575
+ registryUnavailable: () => REGISTRY_UNAVAILABLE,
534
576
  };
@@ -196,8 +196,31 @@ function readBudgets(root) {
196
196
  * `index` comes from parseOkrIndex; `budgets` from readBudgets (optional — omit to skip the
197
197
  * registry check, which callers that only need the FORM can do).
198
198
  */
199
+ /** Strip ONE layer of matching surrounding quotes.
200
+ *
201
+ * `parent_kr` is a YAML scalar, and a value whose prose contains a colon-space MUST be quoted or
202
+ * the document does not parse. The flat frontmatter reader hands the value through with its quotes
203
+ * intact, so a legitimately-quoted anchor arrived here as `"health:security …` and matched no form.
204
+ *
205
+ * This defect occurred TWICE — `sink-discipline-backport` on 2026-08-04 and
206
+ * `separation-invariant-enforcement` on 2026-08-05 — and the first time it was fixed by unquoting
207
+ * the one file. That is the wave's own lesson landing on the person who wrote it down: the unit of
208
+ * attention was the file, the unit of the defect was the rule. "Do not quote it" is a rule that
209
+ * lives in prose and depends on an author remembering; tolerating the quoting is a rule that lives
210
+ * in code.
211
+ *
212
+ * Only a MATCHING outer pair, and only one layer: a stray leading quote is still malformed, and
213
+ * reporting it as such is the point. */
214
+ function unquote(s) {
215
+ const t = s.trim();
216
+ if (t.length >= 2 && ((t[0] === '"' && t[t.length - 1] === '"') || (t[0] === "'" && t[t.length - 1] === "'"))) {
217
+ return t.slice(1, -1).trim();
218
+ }
219
+ return t;
220
+ }
221
+
199
222
  function resolveAnchor(raw, index, budgets) {
200
- const original = String(raw == null ? '' : raw).trim();
223
+ const original = unquote(String(raw == null ? '' : raw).trim());
201
224
  const base = { raw: original, kind: 'empty', status: 'empty', objective: null, kr: null, budget: null, period: null, form: null, reason: null };
202
225
  if (!original) return Object.assign(base, { reason: 'parent_kr is empty — unanchored work is rejected (D59)' });
203
226
 
@@ -269,6 +292,7 @@ function canonical(resolved, fallbackPeriod) {
269
292
  }
270
293
 
271
294
  module.exports = {
295
+ unquote,
272
296
  parseOkrIndex, readBudgets, resolveAnchor, canonical,
273
297
  objectiveId, krId, stripCommentary, listOkrFiles,
274
298
  ACCEPTED_FORMS,
@@ -0,0 +1,223 @@
1
+ /* md-normalize — a newline in a PMOS document means what it says.
2
+ *
3
+ * ONE implementation, used on three sides, in the idiom `preview-kind.js` established for exactly
4
+ * this reason:
5
+ * - scripts/md-newlines.js requires it (the check, the --fix, the CI ratchet)
6
+ * - workspace/server.js requires it (the doc tab's `normalize` action)
7
+ * - the browser loads it as /lib/md-normalize.js (window.MdNormalize)
8
+ * so the command, the gate and the button can never disagree about what a normalized file is.
9
+ *
10
+ * ── WHAT IT IS FOR ─────────────────────────────────────────────────────────────────────────────
11
+ * PMOS hard-wraps prose at ~100 columns: 30,345 continuation lines across 1,013 of 1,022 tracked
12
+ * files. A single newline inside a paragraph is NOT a line break in markdown — CommonMark joins the
13
+ * lines, GitHub joins them, the workspace's own renderer joins them — so this looks right everywhere
14
+ * inside PMOS. It stops looking right the moment a file is published: Jira wiki markup treats a
15
+ * newline as a line break, a Google Doc keeps every newline, and several markdown→ADF converters
16
+ * emit a hard break per `\n`. The paragraph arrives as a stack of ragged short lines, and no
17
+ * converter can undo it, because an accidental wrap and a deliberate break are THE SAME BYTE in the
18
+ * source. The only place the distinction still exists is the file.
19
+ *
20
+ * ── WHAT IT REFUSES TO TOUCH ───────────────────────────────────────────────────────────────────
21
+ * Frontmatter, fenced code (including unterminated), indented code, table rows, headings, rules,
22
+ * link-reference definitions, HTML blocks, a line ending in two spaces or a backslash (a DELIBERATE
23
+ * hard break — an author's intent, not a wrap), and a line followed by a setext underline. Joining
24
+ * inside any of those changes what the document MEANS rather than how it wraps, and a repair tool
25
+ * that damages a file is worse than the defect it repairs.
26
+ *
27
+ * ── THE CORRECTNESS PROPERTY ───────────────────────────────────────────────────────────────────
28
+ * Idempotence: normalize(normalize(x)) === normalize(x), asserted over all 1,022 tracked files
29
+ * rather than over fixtures. The ratchet, the button and an adopter's repair run are all
30
+ * untrustworthy without it.
31
+ */
32
+ 'use strict';
33
+ (function (root, factory) {
34
+ const api = factory();
35
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
36
+ else root.MdNormalize = api;
37
+ })(typeof self !== 'undefined' ? self : this, function () {
38
+ const FENCE = /^(\s*)(`{3,}|~{3,})/;
39
+ const HEADING = /^\s{0,3}#{1,6}(\s|$)/;
40
+ const RULE = /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/;
41
+ const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])(\s+)(\S.*)$/;
42
+ const QUOTE = /^(\s{0,3}>\s?)(.*)$/;
43
+ const TABLE_DELIM = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
44
+ const SETEXT = /^\s{0,3}(=+|-+)\s*$/;
45
+ const REF_DEF = /^\s{0,3}\[[^\]]+\]:\s/;
46
+ const HTML_BLOCK = /^\s{0,3}<[a-zA-Z!/?]/;
47
+ const INDENTED_CODE = /^(?: {4,}|\t)\S/;
48
+ /* A break the AUTHOR asked for. Markdown spells it two trailing spaces or a trailing backslash,
49
+ * and it is the one newline in a paragraph that is not an accident — eating it would be removing
50
+ * intent, which is the failure this tool exists to avoid, not to commit. */
51
+ const HARD_BREAK = /(?: {2,}|\\)$/;
52
+
53
+ const isBlank = (l) => !l.trim();
54
+
55
+ /** A table's header row: this line has a pipe and the NEXT line is the delimiter. */
56
+ const isTableHeader = (lines, i) =>
57
+ lines[i].includes('|') && TABLE_DELIM.test(lines[i + 1] || '') && (lines[i + 1] || '').includes('-');
58
+
59
+ /** A row inside a table body — a pipe line in a run that began with a header. */
60
+ function tableRunEnd(lines, i) {
61
+ let j = i + 2;
62
+ while (j < lines.length && lines[j].includes('|') && lines[j].trim()) j++;
63
+ return j;
64
+ }
65
+
66
+ /**
67
+ * A line that can never be joined ONTO the previous one — it opens a block of its own. This is
68
+ * the list the blank-line rule is derived from too, so "what needs a blank line above it" and
69
+ * "what stops a paragraph" cannot drift apart.
70
+ */
71
+ function opensBlock(lines, i) {
72
+ const l = lines[i];
73
+ return HEADING.test(l) || RULE.test(l) || FENCE.test(l) || ITEM.test(l) || QUOTE.test(l)
74
+ || REF_DEF.test(l) || HTML_BLOCK.test(l) || INDENTED_CODE.test(l) || isTableHeader(lines, i)
75
+ || SETEXT.test(l);
76
+ }
77
+
78
+ /**
79
+ * Normalize `src`.
80
+ *
81
+ * Returns `{ text, changed, joins, blanks }` — the bytes, whether anything moved, how many lines
82
+ * were joined into a paragraph or an item, and how many blank lines were inserted above a block.
83
+ * The counts are what the CLI reports per file: a tool that says "changed" without saying what it
84
+ * changed is asking to be trusted rather than read.
85
+ */
86
+ function normalize(src) {
87
+ const input = String(src == null ? '' : src);
88
+ const nl = input.indexOf('\r\n') >= 0 ? '\r\n' : '\n';
89
+ const lines = input.replace(/\r\n?/g, '\n').split('\n');
90
+ const out = [];
91
+ let joins = 0;
92
+ let blanks = 0;
93
+ let i = 0;
94
+
95
+ // ── frontmatter: copied through, never parsed and never joined ──────────────────────────────
96
+ if (lines[0] === '---') {
97
+ let j = 1;
98
+ while (j < lines.length && lines[j] !== '---') j++;
99
+ if (j < lines.length) { for (let k = 0; k <= j; k++) out.push(lines[k]); i = j + 1; }
100
+ }
101
+
102
+ /* A continuation line, left-trimmed. Trailing whitespace is dropped ONLY when it is not a hard
103
+ * break: `.trim()` would eat the author's two trailing spaces mid-paragraph, which is the one
104
+ * newline in a paragraph that was asked for. */
105
+ const piece = (l) => {
106
+ const s2 = l.replace(/^\s+/, '');
107
+ return HARD_BREAK.test(s2) ? s2 : s2.replace(/\s+$/, '');
108
+ };
109
+
110
+ /** The blank line a block construct needs above it, when prose is what sits there. */
111
+ const blankAbove = () => {
112
+ const prev = out.length ? out[out.length - 1] : '';
113
+ if (!out.length || isBlank(prev)) return;
114
+ // Only prose gets a blank inserted after it. Two adjacent list items, a table row under its
115
+ // header, a fence closing — none of those want one, and inserting it would restructure the
116
+ // document rather than repair its wrapping.
117
+ if (ITEM.test(prev) || QUOTE.test(prev) || prev.includes('|') || HEADING.test(prev)) return;
118
+ out.push('');
119
+ blanks++;
120
+ };
121
+
122
+ while (i < lines.length) {
123
+ const line = lines[i];
124
+
125
+ if (isBlank(line)) { out.push(line); i++; continue; }
126
+
127
+ // fenced code — verbatim, to the closing fence or to EOF
128
+ const fence = FENCE.exec(line);
129
+ if (fence) {
130
+ blankAbove();
131
+ const close = new RegExp('^\\s*' + fence[2][0] + '{' + fence[2].length + ',}\\s*$');
132
+ out.push(line); i++;
133
+ while (i < lines.length && !close.test(lines[i])) { out.push(lines[i]); i++; }
134
+ if (i < lines.length) { out.push(lines[i]); i++; }
135
+ continue;
136
+ }
137
+
138
+ // indented code, headings, rules, reference definitions, HTML — one line, untouched
139
+ if (INDENTED_CODE.test(line) || HEADING.test(line) || RULE.test(line) || REF_DEF.test(line)
140
+ || HTML_BLOCK.test(line) || SETEXT.test(line)) {
141
+ /* A blank is NEVER inserted above a `---`/`===` line. Directly under prose that is a SETEXT
142
+ * HEADING, not a rule, and separating it would turn one heading into a paragraph and a
143
+ * horizontal rule — the untouchable rule wins over the blank-line rule wherever they meet. */
144
+ if (HEADING.test(line)) blankAbove();
145
+ out.push(line); i++;
146
+ continue;
147
+ }
148
+
149
+ // a table: its header, its delimiter and its rows are records — never joined, never reflowed
150
+ if (isTableHeader(lines, i)) {
151
+ blankAbove();
152
+ const end = tableRunEnd(lines, i);
153
+ for (let k = i; k < end; k++) out.push(lines[k]);
154
+ i = end;
155
+ continue;
156
+ }
157
+
158
+ // a list item, plus the continuation lines that belong to it
159
+ const item = ITEM.exec(line);
160
+ if (item) {
161
+ blankAbove();
162
+ /* INSIDE a list item, four spaces is not code — it is the item's own text column. The
163
+ * generic `opensBlock` test calls an indented continuation an indented code block and stops,
164
+ * which left every nested item's wrapped line un-joined. Code inside an item starts four
165
+ * columns further in than the item's CONTENT does, so that is the line this measures. */
166
+ const contentCol = item[1].length + item[2].length + item[3].length;
167
+ const isItemCode = (l) => (l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) >= contentCol + 4;
168
+ let body = line;
169
+ i++;
170
+ while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)
171
+ && /^\s+\S/.test(lines[i]) && !isItemCode(lines[i])
172
+ && !(HEADING.test(lines[i]) || RULE.test(lines[i]) || FENCE.test(lines[i])
173
+ || ITEM.test(lines[i]) || QUOTE.test(lines[i]) || isTableHeader(lines, i))) {
174
+ body += ' ' + piece(lines[i]);
175
+ joins++; i++;
176
+ }
177
+ out.push(body);
178
+ continue;
179
+ }
180
+
181
+ // a blockquote line, plus its lazy continuations
182
+ const quote = QUOTE.exec(line);
183
+ if (quote) {
184
+ /* A `>` with nothing after it is the blank line INSIDE a quote — it is what separates two
185
+ * quoted paragraphs. Joining the next paragraph onto it deletes the separator, silently
186
+ * turns two paragraphs into one, and (because the result then joins again on the next run)
187
+ * is how this tool would fail its own idempotence property. Found by running the proof over
188
+ * the real corpus rather than over fixtures: 50 files did exactly this. */
189
+ if (!quote[2].trim()) { out.push(line); i++; continue; }
190
+ let body = line;
191
+ i++;
192
+ while (i < lines.length && !isBlank(lines[i]) && !HARD_BREAK.test(body)) {
193
+ const q = QUOTE.exec(lines[i]);
194
+ const inner = q ? q[2] : lines[i];
195
+ if (!inner.trim()) break; // a blank quote line ends it
196
+ if (q && (HEADING.test(inner) || ITEM.test(inner) || RULE.test(inner) || FENCE.test(inner))) break;
197
+ if (!q && opensBlock(lines, i)) break; // lazy continuation, but not a new block
198
+ body += ' ' + piece(inner);
199
+ joins++; i++;
200
+ }
201
+ out.push(body);
202
+ continue;
203
+ }
204
+
205
+ // prose — the case the whole tool is about
206
+ let para = line;
207
+ i++;
208
+ while (i < lines.length && !isBlank(lines[i]) && !opensBlock(lines, i) && !HARD_BREAK.test(para)) {
209
+ para += ' ' + piece(lines[i]);
210
+ joins++; i++;
211
+ }
212
+ out.push(para);
213
+ }
214
+
215
+ const text = out.join(nl);
216
+ return { text, changed: text !== input, joins, blanks };
217
+ }
218
+
219
+ /** True when the file is already normalized — the check half, with no allocation of a new string. */
220
+ const isNormalized = (src) => !normalize(src).changed;
221
+
222
+ return { normalize, isNormalized };
223
+ });