create-pmos 0.30.1 → 0.31.1

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
+ });
@@ -333,6 +333,7 @@ function firstAppearance(root, range, rel) {
333
333
  function buildCommits(root, range, lane, opts) {
334
334
  if (!range.ok) return { unknown: true };
335
335
  const id = opts && opts.id;
336
+ const recordIdx = opts && opts.recordIdx;
336
337
  const whole = !!range.wholeHistory;
337
338
 
338
339
  // Over the trunk, the id must be attributable before any commit counts. The index is built once
@@ -373,6 +374,19 @@ function buildCommits(root, range, lane, opts) {
373
374
  // answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
374
375
  // initiative is counted from its merge subject below rather than from this per-file loop.
375
376
  if (whole && id && !commitNamesId(range, i, id)) continue;
377
+ /* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
378
+ * carry one initiative and everything in it to be that initiative's build. A branch carrying
379
+ * several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
380
+ * supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
381
+ * not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
382
+ * demonstrably preceded its build by three commits.
383
+ *
384
+ * A commit made BEFORE an initiative's record first appeared cannot be that initiative's
385
+ * implementation — the initiative did not exist to be built. That is the whole rule, and it is
386
+ * narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
387
+ * fails, which is the case D13 actually exists to catch. This removes a false violation without
388
+ * removing a true one. */
389
+ if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
376
390
  count++;
377
391
  if (first === null) { first = i; sample = hit; }
378
392
  }
@@ -574,6 +588,9 @@ function computeWorkflow(id, opts) {
574
588
  const prdIdx = prd ? firstAppearance(root, range, prd) : null;
575
589
  const build = buildCommits(root, range, effLane, {
576
590
  id,
591
+ // Where this initiative's own record first appears in the range — the earliest commit that could
592
+ // possibly be its build. See the note in buildCommits' attribution loop.
593
+ recordIdx: firstAppearance(root, range, recRel),
577
594
  deliveryIndex: opts && opts.deliveryIndex,
578
595
  declaredDelivery: fm.delivered_by,
579
596
  });
@@ -945,6 +962,50 @@ function computeWorkflow(id, opts) {
945
962
  }
946
963
  }
947
964
 
965
+ /* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
966
+ *
967
+ * This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
968
+ * in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
969
+ * prompt and no surface is a field that does not exist.
970
+ *
971
+ * `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
972
+ * renders red for every initiative without a metric becomes noise and gets ignored — which is how
973
+ * you end up with a board nobody reads. */
974
+ {
975
+ const metric = String(fm.outcome_metric || '').trim();
976
+ const horizon = String(fm.outcome_horizon || '').trim();
977
+ const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
978
+ .filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
979
+ const latest = checks.length ? checks[checks.length - 1] : null;
980
+ const today = new Date().toISOString().slice(0, 10);
981
+
982
+ if (!metric) {
983
+ steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
984
+ } else if (!horizon) {
985
+ /* A metric with no horizon can never come due, so it reads as diligence for ever while
986
+ * committing to nothing. That is worse than declaring nothing at all. */
987
+ steps.push(step('outcome', 'outcome', 'blocked',
988
+ `outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
989
+ 'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
990
+ } else if (latest) {
991
+ /* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
992
+ * it as a plain green tick is the same watermelon the review rung was fixed for. */
993
+ const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
994
+ steps.push(step('outcome', 'outcome', done,
995
+ `${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
996
+ latest.disposition === 'refuted'
997
+ ? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
998
+ : undefined));
999
+ } else if (horizon <= today) {
1000
+ steps.push(step('outcome', 'outcome', 'blocked',
1001
+ `outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
1002
+ 'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
1003
+ { rule: 'D57' }));
1004
+ } else {
1005
+ steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
1006
+ }
1007
+ }
1008
+
948
1009
  // ── stage, derived from what the steps found ─────────────────────────────────────────────────
949
1010
  const by = Object.fromEntries(steps.map((s) => [s.key, s]));
950
1011
  let stage = 'intake';
@@ -333,6 +333,7 @@ function firstAppearance(root, range, rel) {
333
333
  function buildCommits(root, range, lane, opts) {
334
334
  if (!range.ok) return { unknown: true };
335
335
  const id = opts && opts.id;
336
+ const recordIdx = opts && opts.recordIdx;
336
337
  const whole = !!range.wholeHistory;
337
338
 
338
339
  // Over the trunk, the id must be attributable before any commit counts. The index is built once
@@ -373,6 +374,19 @@ function buildCommits(root, range, lane, opts) {
373
374
  // answerable at all. `--name-only` lists no files for a true merge commit, so a branch-attributed
374
375
  // initiative is counted from its merge subject below rather than from this per-file loop.
375
376
  if (whole && id && !commitNamesId(range, i, id)) continue;
377
+ /* Inside a PR RANGE the id filter above does not apply, because a pull request was assumed to
378
+ * carry one initiative and everything in it to be that initiative's build. A branch carrying
379
+ * several breaks that: PR #287 held five, and the FIRST commit on it — a D01 propagation touching
380
+ * supabase/functions/mcp/index.ts — was attributed to `sink-discipline-backport`, whose record did
381
+ * not yet exist. The gate reported ORDER VIOLATION, spec-after-build, for an initiative whose spec
382
+ * demonstrably preceded its build by three commits.
383
+ *
384
+ * A commit made BEFORE an initiative's record first appeared cannot be that initiative's
385
+ * implementation — the initiative did not exist to be built. That is the whole rule, and it is
386
+ * narrow on purpose: an implementation commit landing AFTER the record but BEFORE the PRD still
387
+ * fails, which is the case D13 actually exists to catch. This removes a false violation without
388
+ * removing a true one. */
389
+ if (!whole && recordIdx != null && recordIdx !== undefined && recordIdx >= 0 && i < recordIdx) continue;
376
390
  count++;
377
391
  if (first === null) { first = i; sample = hit; }
378
392
  }
@@ -574,6 +588,9 @@ function computeWorkflow(id, opts) {
574
588
  const prdIdx = prd ? firstAppearance(root, range, prd) : null;
575
589
  const build = buildCommits(root, range, effLane, {
576
590
  id,
591
+ // Where this initiative's own record first appears in the range — the earliest commit that could
592
+ // possibly be its build. See the note in buildCommits' attribution loop.
593
+ recordIdx: firstAppearance(root, range, recRel),
577
594
  deliveryIndex: opts && opts.deliveryIndex,
578
595
  declaredDelivery: fm.delivered_by,
579
596
  });
@@ -945,6 +962,50 @@ function computeWorkflow(id, opts) {
945
962
  }
946
963
  }
947
964
 
965
+ /* ── outcome ─ D57: did the bet pay off? A SECOND verdict, at a second time ───────────────────
966
+ *
967
+ * This rung did not exist, and its absence is why the loop never ran: `outcome_metric` is optional
968
+ * in D57, nothing ever asked for it, and 0 of ~170 records declared one. An optional field with no
969
+ * prompt and no surface is a field that does not exist.
970
+ *
971
+ * `na` is a first-class, non-failing state on purpose. Most work is not a bet, and a rung that
972
+ * renders red for every initiative without a metric becomes noise and gets ignored — which is how
973
+ * you end up with a board nobody reads. */
974
+ {
975
+ const metric = String(fm.outcome_metric || '').trim();
976
+ const horizon = String(fm.outcome_horizon || '').trim();
977
+ const checks = readJsonl(path.join(L.runsDir, 'outcome_checks.jsonl'))
978
+ .filter((c) => c && c.initiative_id === id && ['validated', 'refuted', 'inconclusive'].includes(c.disposition));
979
+ const latest = checks.length ? checks[checks.length - 1] : null;
980
+ const today = new Date().toISOString().slice(0, 10);
981
+
982
+ if (!metric) {
983
+ steps.push(step('outcome', 'outcome', 'na', 'no outcome declared — most work is not a bet (D57)'));
984
+ } else if (!horizon) {
985
+ /* A metric with no horizon can never come due, so it reads as diligence for ever while
986
+ * committing to nothing. That is worse than declaring nothing at all. */
987
+ steps.push(step('outcome', 'outcome', 'blocked',
988
+ `outcome_metric declared with NO outcome_horizon — a bet that can never come due`,
989
+ 'add outcome_horizon: YYYY-MM-DD to the initiative record, or remove the metric', { rule: 'D57' }));
990
+ } else if (latest) {
991
+ /* A refuted bet is a COMPLETED check and an UNSUCCESSFUL bet. The rung says both — rendering
992
+ * it as a plain green tick is the same watermelon the review rung was fixed for. */
993
+ const done = latest.disposition === 'refuted' ? 'blocked' : 'done';
994
+ steps.push(step('outcome', 'outcome', done,
995
+ `${latest.disposition}${latest.evidence ? ` — ${String(latest.evidence).slice(0, 60)}` : ''}`,
996
+ latest.disposition === 'refuted'
997
+ ? 'the check is complete and the bet did not pay off — that is a result, not a failure to record'
998
+ : undefined));
999
+ } else if (horizon <= today) {
1000
+ steps.push(step('outcome', 'outcome', 'blocked',
1001
+ `outcome check DUE — horizon ${horizon} has passed and no disposition is recorded`,
1002
+ 'dispose it: node scripts/record-outcome.js (validated | refuted | inconclusive, evidence required)',
1003
+ { rule: 'D57' }));
1004
+ } else {
1005
+ steps.push(step('outcome', 'outcome', 'pending', `outcome pending — horizon ${horizon}`));
1006
+ }
1007
+ }
1008
+
948
1009
  // ── stage, derived from what the steps found ─────────────────────────────────────────────────
949
1010
  const by = Object.fromEntries(steps.map((s) => [s.key, s]));
950
1011
  let stage = 'intake';
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -53,6 +53,10 @@
53
53
  opinion about what an .html file is would mean the endpoint and the pane disagreeing about the
54
54
  same document. Classic script (a UMD), like the three above. -->
55
55
  <script src="/lib/preview-kind.js"></script>
56
+ <!-- The newline normalizer behind the doc tab's `normalize` action (markdown-newlines). The same
57
+ file scripts/md-newlines.js requires, loaded here as window.MdNormalize, so the one-click
58
+ repair and the CI ratchet can never disagree about what a normalized document is. -->
59
+ <script src="/lib/md-normalize.js"></script>
56
60
  <link rel="preload" href="/vendor/ibm-plex-mono-500.woff2" as="font" type="font/woff2" crossorigin />
57
61
  </head>
58
62
  <body>