@ok-ggwp/git-gloss 0.5.0 → 0.7.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,349 @@
1
+ // Stable references for the findings of a review report. Reports are free
2
+ // markdown and every reviewer formats them differently, so for DISPLAY each
3
+ // top-level list item is rewritten as `- **[S.N]** text`, S the section number
4
+ // and N the item's position in that section. Nothing stored is changed: the
5
+ // report stays raw in the notes, and verdicts and review keys use the raw text.
6
+ //
7
+ // The input is untrusted and up to 256 KiB, so this is one pass over the lines
8
+ // with no backtracking patterns: every line is looked at a bounded number of
9
+ // characters from its start (a 200 KB line costs no more than a short one,
10
+ // apart from the copy into the output).
11
+ //
12
+ // Section numbers:
13
+ // - a heading (# to ######) that starts with a number (`1.`, `1)` or `1`) sets
14
+ // the section to that number;
15
+ // - any other heading is the previous section plus one;
16
+ // - EXCEPT titles: unnumbered headings before the first list item or numbered
17
+ // heading take no number (the first item then falls in section 1). So the
18
+ // headings `honesty`, `Review of X`, `1. Mismatches`, `Misgraded necessity`,
19
+ // `3. Removal` give sections 1, 2, 3, and `## A`, `## B` with items under
20
+ // both give 1 and 2.
21
+ // - a heading is a `#` line, or a line that is ONLY bold text (`**1. Title**`,
22
+ // `__Title__`, optional trailing colon), or, conservatively, a plain
23
+ // `1. Title` / `1) Title` line that is short, has no sentence punctuation
24
+ // at its end, follows a blank line (or the start), and whose next non-empty
25
+ // line (within two blank lines) is a bullet or another heading: an ordinary
26
+ // numbered sentence, or a numbered list, is an item, not a heading.
27
+ // - list items before any heading are labelled `[N]` (no section).
28
+ // Items are indent-0 `-`, `*`, `+`, `1.` or `1)` lines; indented sub-items and
29
+ // wrapped lines are left alone, as are fenced code blocks, rules and empty
30
+ // items. A numbered heading with no list under it keeps its own number as its
31
+ // ref (`1`, or `1/2` when `1` is taken) and its line is not rewritten. A section whose
32
+ // first ref is already taken (a second `1.` heading with items) is labelled `S/N`, see
33
+ // below. Running it on its own output changes nothing.
34
+ // A section label is the section number, `1`. A section is labelled `S/N` (`1/2`, items
35
+ // `1/2.1`, N from 2 to 999) only when a ref it would emit equals one already emitted: the
36
+ // heading ref `1` of a list-less heading, or the first item ref `1.1` of a section with
37
+ // items. Within a section a heading ref and an item ref never collide, so an empty
38
+ // numbered heading, an unnumbered heading or a bold line uses no label up, and no ref the
39
+ // old labeller produced changes. The one new ref: a list-less heading whose bare number
40
+ // equals a loose item's (`- x` then `## 1.`) was unreachable before and now gets `1/2`. Past 999 repeats the label stays `S/999`
41
+ // and the items keep counting on, so item refs stay distinct; only the heading row of such
42
+ // a section without items has no row of its own (its ref is already taken).
43
+ const MAX_REPEAT = 999;
44
+ const repeatLabel = (num, n) => (n === 1 ? String(num) : `${num}/${n}`);
45
+ const isDigit = (c) => c >= '0' && c <= '9';
46
+ const MAXDIG = 6;
47
+ // The section number at the start of a heading's text: up to 3 digits, then `.` or
48
+ // `)`, then a space or the end. `2024 results` and `10 files changed` are text.
49
+ function leadingNumber(s) {
50
+ let i = 0;
51
+ while (i < s.length && i < 4 && isDigit(s[i]))
52
+ i++;
53
+ if (i === 0 || i > 3)
54
+ return null;
55
+ if (s[i] !== '.' && s[i] !== ')')
56
+ return null;
57
+ i++;
58
+ if (i < s.length && s[i] !== ' ' && s[i] !== '\t')
59
+ return null;
60
+ return Number(s.slice(0, i - 1));
61
+ }
62
+ function headingText(line) {
63
+ let i = 0;
64
+ while (i < 3 && line[i] === ' ')
65
+ i++;
66
+ let h = 0;
67
+ while (line[i + h] === '#' && h < 7)
68
+ h++;
69
+ if (h < 1 || h > 6)
70
+ return null;
71
+ const c = line[i + h];
72
+ if (c !== undefined && c !== ' ' && c !== '\t')
73
+ return null;
74
+ let j = i + h;
75
+ while (line[j] === ' ' || line[j] === '\t')
76
+ j++;
77
+ return line.slice(j, j + 16);
78
+ }
79
+ // The text of a line made only of bold (`**x**`, `__x__`, optional colon), else null.
80
+ function boldOnly(line) {
81
+ const mark = line.startsWith('**') ? '**' : line.startsWith('__') ? '__' : null;
82
+ if (mark === null)
83
+ return null;
84
+ let end = line.length;
85
+ while (end > 0 && (line[end - 1] === ' ' || line[end - 1] === '\t' || line[end - 1] === '\r'))
86
+ end--;
87
+ if (line[end - 1] === ':')
88
+ end--;
89
+ if (end < 2 * mark.length + 1 || !line.startsWith(mark, end - mark.length))
90
+ return null;
91
+ const inner = line.slice(mark.length, end - mark.length);
92
+ if (inner.indexOf(mark) !== -1 || inner.trim() === '')
93
+ return null;
94
+ return inner.trimStart().slice(0, 16);
95
+ }
96
+ const isBullet = (l) => (l[0] === '-' || l[0] === '*' || l[0] === '+') && (l[1] === ' ' || l[1] === '\t');
97
+ // A plain `1. Title` line that is a section title rather than a numbered finding.
98
+ function plainTitle(lines, n) {
99
+ const line = lines[n];
100
+ if (leadingNumber(line.slice(0, 12)) === null || itemText(line) === null)
101
+ return false;
102
+ if (line.length > 70 || (n > 0 && lines[n - 1].trim() !== ''))
103
+ return false;
104
+ const last = line.trimEnd().slice(-1);
105
+ if ('.!?,;:'.includes(last) && last !== ':')
106
+ return false;
107
+ // A real title has a blank line after it too: `1. Race` straight above bullets is an item with sub-items.
108
+ if (n + 1 >= lines.length || lines[n + 1].trim() !== '')
109
+ return false;
110
+ let k = n + 1;
111
+ while (k < lines.length && k <= n + 3 && lines[k].trim() === '')
112
+ k++;
113
+ if (k >= lines.length || k > n + 3)
114
+ return false;
115
+ const next = lines[k];
116
+ return isBullet(next) || headingText(next) !== null || boldOnly(next) !== null;
117
+ }
118
+ // The text after a top-level list marker, or null when the line is not an item.
119
+ function itemText(line) {
120
+ const c = line[0];
121
+ let i = 0;
122
+ if (c === '-' || c === '*' || c === '+')
123
+ i = 1;
124
+ else if (c !== undefined && isDigit(c)) {
125
+ while (i < MAXDIG + 1 && isDigit(line[i] ?? ''))
126
+ i++;
127
+ if (i > MAXDIG || (line[i] !== '.' && line[i] !== ')'))
128
+ return null;
129
+ i++;
130
+ }
131
+ else
132
+ return null;
133
+ if (line[i] !== ' ' && line[i] !== '\t')
134
+ return null;
135
+ // A rule (`* * *`, `- - -`, `___`): at most 64 characters, so the pattern is bounded.
136
+ if (line.length <= 64 && /^([-*_])( *\1){2,} *$/.test(line))
137
+ return null;
138
+ let j = i;
139
+ while (line[j] === ' ' || line[j] === '\t')
140
+ j++;
141
+ if (j >= line.length)
142
+ return null;
143
+ return line.slice(j);
144
+ }
145
+ // Drop a label this function wrote before: `**[1.2]** ` or `**[1/2.2]** `.
146
+ function stripLabel(t) {
147
+ if (!t.startsWith('**['))
148
+ return t;
149
+ const m = /^\*\*\[\d{1,3}\/\d{1,3}\.\d{1,6}\]\*\* */.exec(t.slice(0, 40));
150
+ if (m)
151
+ return t.slice(m[0].length);
152
+ let i = 3;
153
+ while (i < 20 && (isDigit(t[i] ?? '') || t[i] === '.'))
154
+ i++;
155
+ if (i > 3 && t.startsWith(']**', i)) {
156
+ let j = i + 3;
157
+ while (t[j] === ' ')
158
+ j++;
159
+ return t.slice(j);
160
+ }
161
+ return t;
162
+ }
163
+ function scan(text) {
164
+ const lines = text.split('\n');
165
+ // Each ref with its text (an item's, or a numbered heading's line); the first wins.
166
+ const found = new Map();
167
+ // The rows of a decision template: one per ref that was added, marked heading or item.
168
+ const rows = [];
169
+ // Refs a heading row could not take because another row has them (a template row lost, or a
170
+ // heading whose ref a decision would give to the other finding).
171
+ const clashes = new Set();
172
+ // `lost` marks a heading row that would have been listed: only then is a lost ref a clash.
173
+ const add = (ref, t, heading, lost = false) => {
174
+ if (found.has(ref)) {
175
+ if (lost)
176
+ clashes.add(ref);
177
+ return null;
178
+ }
179
+ found.set(ref, t);
180
+ const row = { ref, text: t, heading, hasItems: false };
181
+ rows.push(row);
182
+ return row;
183
+ };
184
+ // How many items each label has.
185
+ const counts = new Map();
186
+ // The first label of `num` whose first ref is not taken: the heading's own ref, or `.1`.
187
+ // Refs only get added, so a label found taken stays taken: each (number, kind) resumes
188
+ // from where it stopped, which keeps this linear in the number of sections.
189
+ const resume = new Map();
190
+ const pick = (num, forHeading) => {
191
+ const key = `${num}:${forHeading}`;
192
+ let k = resume.get(key) ?? 1;
193
+ while (k < MAX_REPEAT && found.has(forHeading ? repeatLabel(num, k) : `${repeatLabel(num, k)}.1`))
194
+ k++;
195
+ resume.set(key, k);
196
+ return repeatLabel(num, k);
197
+ };
198
+ // A numbered heading waits for its first item: with items it only names the section
199
+ // (the items are the findings), without it is a row of its own.
200
+ let pending = null;
201
+ const flush = (withItems = false) => {
202
+ if (!pending)
203
+ return;
204
+ const lab = pick(pending.num, true);
205
+ const row = add(lab, pending.text, true, !withItems);
206
+ if (row && withItems)
207
+ row.hasItems = true;
208
+ pending = null;
209
+ };
210
+ let section = 0;
211
+ let label = null;
212
+ let sawHeading = false, sawNumbered = false, sawItem = false;
213
+ let fence = null;
214
+ for (let n = 0; n < lines.length; n++) {
215
+ const line = lines[n];
216
+ const t = line.trimStart();
217
+ if (t.startsWith('```') || t.startsWith('~~~')) {
218
+ if (fence === null)
219
+ fence = t.slice(0, 3);
220
+ else if (t.startsWith(fence))
221
+ fence = null;
222
+ continue;
223
+ }
224
+ if (fence !== null)
225
+ continue;
226
+ const h = headingText(line) ?? boldOnly(line) ?? (plainTitle(lines, n) ? line : null);
227
+ if (h !== null) {
228
+ flush();
229
+ const num = leadingNumber(h);
230
+ if (num !== null) {
231
+ section = num;
232
+ sawNumbered = true;
233
+ pending = { num, text: line.replace(/^[\s#*_]+/, '').replace(/^\d{1,3}[.)]?\s*/, '').replace(/[\s*_:]+$/, '') };
234
+ }
235
+ else if (!sawItem && !sawNumbered)
236
+ section = 0;
237
+ else
238
+ section += 1;
239
+ label = null;
240
+ sawHeading = true;
241
+ continue;
242
+ }
243
+ const item = itemText(line);
244
+ if (item === null)
245
+ continue;
246
+ if (section === 0 && sawHeading)
247
+ section = 1;
248
+ // A heading numbered 0 keeps its own ref: it is not the section its items fall in.
249
+ if (pending && pending.num !== section)
250
+ flush(true);
251
+ sawItem = true;
252
+ if (sawHeading && label === null) {
253
+ label = pick(section, false);
254
+ if (pending) {
255
+ const row = add(label, pending.text, true);
256
+ if (row)
257
+ row.hasItems = true;
258
+ pending = null;
259
+ }
260
+ }
261
+ const key = label ?? '';
262
+ const k = (counts.get(key) ?? 0) + 1;
263
+ counts.set(key, k);
264
+ const ref = sawHeading ? `${label}.${k}` : String(k);
265
+ add(ref, stripLabel(item), false);
266
+ lines[n] = `- **[${ref}]** ${stripLabel(item)}`;
267
+ }
268
+ flush();
269
+ return { out: lines.join('\n'), found, rows, clashes: [...clashes] };
270
+ }
271
+ export function labelFindings(text) {
272
+ return scan(text).out;
273
+ }
274
+ // The refs a report's findings carry (and its numbered headings'), as a set.
275
+ export function findingRefs(text) {
276
+ return new Set(scan(text).found.keys());
277
+ }
278
+ // What a decision template lists: each finding's ref once, with its text (raw, for the
279
+ // caller to clean). A numbered heading is listed only when no items sit under it; its
280
+ // ref stays valid for a decision (findingRefs has it) but is not suggested.
281
+ // Refs that more than one finding of a report claims, so a decision naming one is ambiguous.
282
+ export function findingClashes(text) {
283
+ return scan(text).clashes;
284
+ }
285
+ export function findingTexts(text) {
286
+ return scan(text).rows.filter((r) => !(r.heading && r.hasItems)).map((r) => [r.ref, r.text]);
287
+ }
288
+ // A decision's finding names a finding by starting with its ref: `1.2 wording nit`,
289
+ // `1.2: ...`, or a bare section number only with a delimiter (`3: ...`, `3) ...`,
290
+ // `3. ...`). Numbers are up to 3 digits, so `2024 migration` and `3 files` are text.
291
+ export function refOf(finding) {
292
+ // The end of the digit run at `from` (1 to `max` digits), or null if there is none or it is longer.
293
+ const digits = (from, max) => {
294
+ let e = from;
295
+ while (e < from + max && isDigit(finding[e] ?? ''))
296
+ e++;
297
+ return e === from || isDigit(finding[e] ?? '') ? null : e;
298
+ };
299
+ let s = digits(0, 3);
300
+ if (s === null)
301
+ return null;
302
+ // The Nth occurrence of a section number: `1/2`, N from 2 to 999.
303
+ if (finding[s] === '/') {
304
+ const e = digits(s + 1, 3);
305
+ if (e === null || Number(finding.slice(s + 1, e)) < 2)
306
+ return null;
307
+ s = e;
308
+ }
309
+ if (finding[s] === '.') {
310
+ let j = s + 1;
311
+ while (j < s + 5 && isDigit(finding[j] ?? ''))
312
+ j++;
313
+ if (j > s + 1 && j <= s + 4) {
314
+ const c = finding[j];
315
+ if (c === undefined || c === ' ' || c === '\t' || c === ':')
316
+ return finding.slice(0, j);
317
+ }
318
+ }
319
+ const c = finding[s];
320
+ if (c === ':' || c === ')')
321
+ return finding.slice(0, s);
322
+ if (c === '.' && (finding[s + 1] === undefined || finding[s + 1] === ' '))
323
+ return finding.slice(0, s);
324
+ return null;
325
+ }
326
+ // `1` < `1.1` < `1.10` < `1/2` < `1/2.1` < `2`: section number, then repeat, then item.
327
+ // Only refs from refOf reach this.
328
+ const refKey = (r) => {
329
+ const [sec, item = '-1'] = r.split('.');
330
+ const [num, rep = '1'] = sec.split('/');
331
+ return { num: Number(num), rep: Number(rep), item: Number(item) };
332
+ };
333
+ const compareKeys = (x, y) => x.num - y.num || x.rep - y.rep || x.item - y.item;
334
+ // Finding order: decisions whose finding starts with a ref, by ref (1.2 before
335
+ // 1.10, ties by time); the rest after them in time order.
336
+ export function orderDecisions(input) {
337
+ const ds = [...input].sort((a, b) => a.at.localeCompare(b.at));
338
+ const withRef = [];
339
+ const rest = [];
340
+ for (const d of ds) {
341
+ const r = refOf(d.finding ?? '');
342
+ if (r === null)
343
+ rest.push(d);
344
+ else
345
+ withRef.push([refKey(r), d]);
346
+ }
347
+ withRef.sort((a, b) => compareKeys(a[0], b[0]));
348
+ return [...withRef.map((x) => x[1]), ...rest];
349
+ }
package/dist/floor.js CHANGED
@@ -104,24 +104,51 @@ export function analyzeGroups(hunks, groups, cfg) {
104
104
  return { signals, groups: floors, floor: signals.floor.attention, derived: { level, reasons }, timedOut: timedOutNotes(signals.hunks) };
105
105
  }
106
106
  export const analyzeGloss = (gloss, cfg) => analyzeGroups(gloss.hunks, groupInputs(gloss), cfg);
107
- export function ratingProblems(a, an) {
108
- const warnings = [];
109
- if (an.signals.floor.reasons.includes(CONFIG_EDIT_NOTE))
110
- warnings.push(CONFIG_EDIT_NOTE);
107
+ // Where a floor comes from, for messages.
108
+ export const whyText = (why) => why.join('; ') || 'computed from the diff';
109
+ export function ratingIssues(a, an) {
110
+ const belowFloor = [];
111
+ const unrated = [];
111
112
  for (const g of a.groups) {
112
113
  const f = an.groups.get(g.id);
113
- // A floor of plain "module" is the baseline for source files, not a signal
114
- // to warn about: a private helper may honestly be `local`.
115
- if (!g.impact || !f || impactRank(f.floor) <= impactRank('module') || impactRank(g.impact) >= impactRank(f.floor))
114
+ if (g.impact) {
115
+ // A floor of plain "module" is the baseline for source files, not a signal
116
+ // to warn about: a private helper may honestly be `local`.
117
+ if (f && impactRank(f.floor) > impactRank('module') && impactRank(g.impact) < impactRank(f.floor))
118
+ belowFloor.push({ id: g.id, impact: g.impact, floor: f.floor, why: f.why });
116
119
  continue;
117
- warnings.push(`group "${g.id}" is rated impact "${g.impact}" but its floor is "${f.floor}" (${f.why.join('; ') || 'computed from the diff'}); raise it, or say why in impact_reason`);
120
+ }
121
+ // A mechanical group on a floor signal (lockfile, config, CI, sensitive path)
122
+ // is not pure; plain module is the baseline for source files.
123
+ const signal = !!f && impactRank(f.floor) > impactRank('module');
124
+ if (g.necessity === 'mechanical' && !signal)
125
+ continue;
126
+ unrated.push({ id: g.id, floor: f?.floor ?? 'local', why: f?.why ?? [], signal });
118
127
  }
119
- let underrated = null;
128
+ let attention = null;
120
129
  const att = a.attention;
121
130
  if (att && attentionRank(att.level) < attentionRank(an.floor) && !att.override_reason) {
122
131
  const why = an.signals.floor.reasons.length ? an.signals.floor.reasons.join('; ') : `${an.signals.floor.impact}: ordinary source changes`;
123
- underrated = `attention.level "${att.level}" is below the computed floor "${an.floor}" (${why}); raise it, or explain with attention.override_reason`;
132
+ attention = { level: att.level, floor: an.floor, why };
133
+ }
134
+ return { configEdit: an.signals.floor.reasons.includes(CONFIG_EDIT_NOTE), belowFloor, unrated, attention };
135
+ }
136
+ export function ratingProblems(a, an) {
137
+ const issues = ratingIssues(a, an);
138
+ const warnings = [];
139
+ if (issues.configEdit)
140
+ warnings.push(CONFIG_EDIT_NOTE);
141
+ for (const b of issues.belowFloor) {
142
+ warnings.push(`group "${b.id}" is rated impact "${b.impact}" but its floor is "${b.floor}" (${whyText(b.why)}); raise it, or say why in impact_reason`);
143
+ }
144
+ const unrated = issues.unrated.map((u) => `"${u.id}"`);
145
+ const floored = issues.unrated.filter((u) => u.signal).map((u) => `${u.id}: floor ${u.floor}: ${whyText(u.why)}`);
146
+ if (unrated.length) {
147
+ const names = unrated.slice(0, 10).join(', ') + (unrated.length > 10 ? ` and ${unrated.length - 10} more` : '');
148
+ warnings.push(`no impact rated on group ${names}${floored.length ? ` (${floored.join('; ')})` : ''}; rate impact on every group except a mechanical one on plain source (floor module or below), see the gloss skill`);
124
149
  }
150
+ const att = issues.attention;
151
+ const underrated = att && `attention.level "${att.level}" is below the computed floor "${att.floor}" (${att.why}); raise it, or explain with attention.override_reason`;
125
152
  return { warnings, underrated };
126
153
  }
127
154
  let warned = false;