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.
- package/README.md +340 -1000
- package/package.json +2 -2
- package/payload/AGENTS.md +51 -152
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/anchor-resolve.js +25 -1
- package/payload/scripts/md-newlines.js +204 -0
- package/payload/scripts/md-normalize.js +223 -0
- package/workspace/lib/drive-guard.js +60 -18
- package/workspace/lib/engine/anchor-resolve.js +25 -1
- package/workspace/lib/md-normalize.js +223 -0
- package/workspace/lib/sink.js +321 -0
- package/workspace/package.json +3 -3
- package/workspace/public/index.html +4 -0
- package/workspace/public/pm/tab-doc.js +34 -3
- package/workspace/server.js +22 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
});
|