@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.
- package/README.md +35 -9
- package/dist/ary/desk.js +112 -0
- package/dist/ary/jobs.js +132 -0
- package/dist/ary/page-job.js +23 -0
- package/dist/ary/picker.js +8 -0
- package/dist/ary/server.js +41 -107
- package/dist/batch.js +184 -0
- package/dist/cli.js +2 -1
- package/dist/commands/add.js +28 -2
- package/dist/commands/ary.js +3 -0
- package/dist/commands/carry.js +3 -1
- package/dist/commands/lint.js +99 -0
- package/dist/commands/review.js +10 -5
- package/dist/commands/reviews.js +64 -16
- package/dist/commands/show.js +11 -5
- package/dist/commands/sync.js +35 -12
- package/dist/commands/versions.js +52 -8
- package/dist/commands/why.js +8 -3
- package/dist/config.js +22 -11
- package/dist/findings.js +349 -0
- package/dist/floor.js +37 -10
- package/dist/git.js +151 -27
- package/dist/html/client.js +82 -11
- package/dist/html/data.js +38 -11
- package/dist/html/style.css +16 -0
- package/dist/loader.js +22 -3
- package/dist/plain.js +2 -1
- package/dist/reviews.js +35 -13
- package/dist/schema.js +2 -0
- package/dist/validate.js +4 -2
- package/package.json +2 -2
- package/skills/gloss/SKILL.md +15 -6
package/dist/findings.js
ADDED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|