bmad-method-test-architecture-enterprise 3.0.2 → 3.1.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/.claude-plugin/marketplace.json +1 -1
- package/cli/lib/evaluate/check.js +44 -5
- package/cli/lib/evaluate/corpus-index.js +10 -1
- package/cli/lib/evaluate/discovery/candidate.js +699 -0
- package/cli/lib/evaluate/discovery/candidate.schema.json +563 -0
- package/cli/lib/evaluate/discovery/generator.js +954 -0
- package/cli/lib/evaluate/discovery/report.js +239 -0
- package/cli/lib/evaluate/discovery/review.js +569 -0
- package/package.json +6 -2
- package/skills/bmod-tea/bmod.toml +1 -1
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The offline prompt candidate generator: three narrow families of one-line prompt edits, each previewed as an
|
|
3
|
+
* exploratory candidate whose edit is the native `replace-exact` shape.
|
|
4
|
+
*
|
|
5
|
+
* The generator is a function from the target file's bytes to a preview. It reads no file, starts no process, opens no
|
|
6
|
+
* socket and calls no model, so a preview costs nothing and runs anywhere. The caller supplies the bytes and the digest
|
|
7
|
+
* of the requirements statement and writes the records it keeps. The generator's test holds the module to that by its source and by running it with the
|
|
8
|
+
* network and process entry points broken.
|
|
9
|
+
*
|
|
10
|
+
* Families (the catalog, versioned by `GENERATOR_VERSION`):
|
|
11
|
+
*
|
|
12
|
+
* - `modal-strength`: `must` and `shall` become `should`, so a required step turns optional.
|
|
13
|
+
* - `negation`: `do not`, `must not`, `shall not`, `should not`, their contractions, `cannot` and `never` lose their
|
|
14
|
+
* negation, so a prohibition turns into an instruction.
|
|
15
|
+
* - `numeric-threshold`: the integer after a recognized bound phrase (`at least`, `at most`, `up to`, `below`, `>=` and
|
|
16
|
+
* the rest) loosens in the direction the bound constrains: a lower bound halves, an upper bound doubles. A number with
|
|
17
|
+
* no bound phrase before it is never touched.
|
|
18
|
+
*
|
|
19
|
+
* One candidate is one site: one word or number changes. Its `find` is the whole line holding the site and its `replace`
|
|
20
|
+
* is that line after the change, so a reviewer reads the edit in context and `find` holds the single-occurrence rule the
|
|
21
|
+
* native checker applies: a line that occurs twice in the file, or inside a longer line, is rejected as a repeated span.
|
|
22
|
+
* A site inside front matter, a code fence, inline code, a quotation, a heading, a block quote, an HTML comment, a link
|
|
23
|
+
* target, a URL or a placeholder is rejected as protected. A site whose edit would read as something else (a noun
|
|
24
|
+
* `must`, `never` in a statement of fact, a bound that is a date, a version, a decimal or a range, a percentage past
|
|
25
|
+
* 100) is rejected with the reason. Nothing is dropped silently: every rejection is in the preview with its code.
|
|
26
|
+
*
|
|
27
|
+
* The seed orders the sites of a family for the per-family cap. Each site ranks by a digest of the seed and the site, the
|
|
28
|
+
* lowest ranks are kept, and the rest are counted as omitted. The kept sites are numbered in catalog and document order.
|
|
29
|
+
* Every candidate records the seed in its generator field, which its identity covers.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const { digest } = require('../digest');
|
|
35
|
+
const { CANDIDATE_SCHEMA_VERSION, CandidateError, RECORD_KIND, bytesDigest, candidateIdentity, validateCandidate } = require('./candidate');
|
|
36
|
+
|
|
37
|
+
const GENERATOR_NAME = 'prompt-edit';
|
|
38
|
+
const GENERATOR_VERSION = '1.0.0';
|
|
39
|
+
const DEFAULT_PER_FAMILY_CAP = 5;
|
|
40
|
+
const REQUIREMENTS_PATH = 'requirements.md';
|
|
41
|
+
const MAX_SAFE_DIGITS = 15;
|
|
42
|
+
/**
|
|
43
|
+
* The largest target a preview reads. A prompt is kilobytes. The repeated-span count scans the target once for each
|
|
44
|
+
* line that holds a site, so the worst case is the number of such lines times the target size. The cap bounds it, and
|
|
45
|
+
* the many-line cost cases of the generator test pin the bound to seconds.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_TARGET_BYTES = 256 * 1024;
|
|
48
|
+
|
|
49
|
+
/** A generator input that cannot be used; the message names the option or the file property. */
|
|
50
|
+
class GeneratorError extends Error {
|
|
51
|
+
constructor(message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'GeneratorError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NEGATION_WORDS = new Set(['not', 'no', 'never', 'neither', 'nor', 'without', 'nothing', 'none', 'nobody', 'cannot']);
|
|
58
|
+
const NOUN_DETERMINERS = new Set(['a', 'an', 'the', 'this', 'that', 'no', 'any', 'every', 'each', 'its', 'their', 'our', 'your', 'my']);
|
|
59
|
+
const DESCRIPTIVE_SUBJECTS = new Set([
|
|
60
|
+
'we',
|
|
61
|
+
'they',
|
|
62
|
+
'i',
|
|
63
|
+
'he',
|
|
64
|
+
'she',
|
|
65
|
+
'it',
|
|
66
|
+
'which',
|
|
67
|
+
'who',
|
|
68
|
+
'that',
|
|
69
|
+
'has',
|
|
70
|
+
'have',
|
|
71
|
+
'had',
|
|
72
|
+
'was',
|
|
73
|
+
'were',
|
|
74
|
+
'is',
|
|
75
|
+
'are',
|
|
76
|
+
'am',
|
|
77
|
+
'been',
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
/** Words after a number that name no unit. */
|
|
81
|
+
const FILLER = new Set(['of', 'the', 'a', 'an', 'and', 'or', 'to', 'in', 'on', 'for', 'is', 'are', 'if', 'when']);
|
|
82
|
+
const LOWER_BOUNDS = [
|
|
83
|
+
'at least',
|
|
84
|
+
'no fewer than',
|
|
85
|
+
'no less than',
|
|
86
|
+
'minimum of',
|
|
87
|
+
'minimum',
|
|
88
|
+
'min',
|
|
89
|
+
'more than',
|
|
90
|
+
'greater than',
|
|
91
|
+
'above',
|
|
92
|
+
'over',
|
|
93
|
+
'exceeds',
|
|
94
|
+
'exceeding',
|
|
95
|
+
'>=',
|
|
96
|
+
'≥',
|
|
97
|
+
'>',
|
|
98
|
+
];
|
|
99
|
+
const UPPER_BOUNDS = [
|
|
100
|
+
'at most',
|
|
101
|
+
'no more than',
|
|
102
|
+
'up to',
|
|
103
|
+
'maximum of',
|
|
104
|
+
'maximum',
|
|
105
|
+
'max',
|
|
106
|
+
'fewer than',
|
|
107
|
+
'less than',
|
|
108
|
+
'under',
|
|
109
|
+
'below',
|
|
110
|
+
'within',
|
|
111
|
+
'<=',
|
|
112
|
+
'≤',
|
|
113
|
+
'<',
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/** `phrase` as a case-insensitive pattern source: spaces match any run of whitespace, symbols are escaped. */
|
|
117
|
+
function phraseSource(phrase) {
|
|
118
|
+
return phrase.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`).replaceAll(' ', String.raw`\s+`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Longest phrase first, so `no more than` is read whole before `more than` is tried. */
|
|
122
|
+
function boundPattern(phrases) {
|
|
123
|
+
const sorted = [...phrases].sort((a, b) => b.length - a.length);
|
|
124
|
+
return sorted.map(phraseSource).join('|');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A word phrase needs a space before its number (`over3` is no bound); a symbol phrase may touch it (`>=3`). */
|
|
128
|
+
const GAP = String.raw`\s+|(?<=[^\p{L}])\s*`;
|
|
129
|
+
const BOUND_SITE = new RegExp(
|
|
130
|
+
String.raw`(?<![\w=-])(?<lower>${boundPattern(LOWER_BOUNDS)})(?<gap>${GAP})(?<number>\d+)(?<tail>[A-Za-z%]*)|(?<![\w=-])(?<upper>${boundPattern(UPPER_BOUNDS)})(?<gap2>${GAP})(?<number2>\d+)(?<tail2>[A-Za-z%]*)`,
|
|
131
|
+
'giu',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
/** The letter case of `word` applied to `replacement`: all capitals, a leading capital or lower case. */
|
|
135
|
+
function matchCase(word, replacement) {
|
|
136
|
+
if (word.length > 1 && word === word.toUpperCase()) return replacement.toUpperCase();
|
|
137
|
+
if (word[0] !== word[0].toLowerCase()) return replacement[0].toUpperCase() + replacement.slice(1);
|
|
138
|
+
return replacement;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** How far a rule looks around a site, so the cost of a line stays linear in its length. */
|
|
142
|
+
const WINDOW = 64;
|
|
143
|
+
/** The words that negate what follows them. */
|
|
144
|
+
const NEGATED = /\b(?:not|no|never|nor|without|cannot)\b|n['’]t\b/i;
|
|
145
|
+
|
|
146
|
+
/** The word before `index` in `line`, lower-cased, or an empty string. */
|
|
147
|
+
function wordBefore(line, index) {
|
|
148
|
+
return /([\p{L}']+)\W*$/u.exec(line.slice(Math.max(0, index - WINDOW), index))?.[1].toLowerCase() ?? '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The word after `index` in `line`, lower-cased, or an empty string. */
|
|
152
|
+
function wordAfter(line, index) {
|
|
153
|
+
return /^\W*([\p{L}']+)/u.exec(line.slice(index, index + WINDOW))?.[1].toLowerCase() ?? '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SENTENCE_START = /[\s)\]"'”’]*(?:$|\p{Lu})/uy;
|
|
157
|
+
|
|
158
|
+
/** Whether the line ends at `at`, or a sentence starts there, past the closing marks of a quotation. */
|
|
159
|
+
function endsSentence(line, at) {
|
|
160
|
+
SENTENCE_START.lastIndex = at;
|
|
161
|
+
return SENTENCE_START.test(line);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `line` with every protected character blanked, so punctuation and words inside a quotation, inline code, a placeholder, a
|
|
166
|
+
* link target, a URL or a path read as part of that token and not as the sentence around it. The punctuation that closes a
|
|
167
|
+
* sentence after a URL or a path stays, and so does the text of a link only as blanks.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} line
|
|
170
|
+
* @param {Array<string|undefined>} reasons the reason each character of the line is protected, or `undefined`
|
|
171
|
+
* @param {{linkText?: boolean}} [options] `linkText: false` keeps the text of links, for a reader of the words and not the sentence ends
|
|
172
|
+
* @returns {string} a string of the same length
|
|
173
|
+
*/
|
|
174
|
+
/** The `[open, close]` positions of each link text of the line: the nearest `[` to a `]` that a `(` follows. */
|
|
175
|
+
function linkSpans(line) {
|
|
176
|
+
const spans = [];
|
|
177
|
+
let open = -1;
|
|
178
|
+
for (let at = 0; at < line.length; at += 1) {
|
|
179
|
+
if (line[at] === '[') open = at;
|
|
180
|
+
else if (line[at] === ']') {
|
|
181
|
+
if (open !== -1 && line[at + 1] === '(') spans.push([open, at]);
|
|
182
|
+
open = -1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return spans;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function maskProtected(line, reasons, { linkText = true } = {}) {
|
|
189
|
+
const masked = Array.from({ length: line.length }, (_, at) => line[at]);
|
|
190
|
+
for (let from = 0; from < masked.length; ) {
|
|
191
|
+
const reason = reasons[from];
|
|
192
|
+
let to = from + 1;
|
|
193
|
+
if (reason !== undefined) {
|
|
194
|
+
while (to < masked.length && reasons[to] === reason) to += 1;
|
|
195
|
+
if (reason === 'url' || reason === 'path') {
|
|
196
|
+
// The punctuation that closes a sentence after a URL or a path belongs to the sentence, except a `?` right after a `/`.
|
|
197
|
+
let kept = to;
|
|
198
|
+
while (kept > from && '.,;:!?'.includes(masked[kept - 1])) kept -= 1;
|
|
199
|
+
if (kept > from && masked[kept - 1] === '/' && masked[kept] === '?') kept += 1;
|
|
200
|
+
masked.fill(' ', from, kept);
|
|
201
|
+
} else if (
|
|
202
|
+
reason === 'quotation' &&
|
|
203
|
+
to - from > 2 &&
|
|
204
|
+
'.!?'.includes(masked[to - 2]) &&
|
|
205
|
+
/["”’']/.test(masked[to - 1]) &&
|
|
206
|
+
endsSentence(line, to)
|
|
207
|
+
) {
|
|
208
|
+
// A quotation that ends in a mark closes its sentence when the line ends after it or a new sentence starts there.
|
|
209
|
+
masked.fill(' ', from, to - 2);
|
|
210
|
+
masked[to - 1] = ' ';
|
|
211
|
+
} else {
|
|
212
|
+
masked.fill(' ', from, to);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
from = to;
|
|
216
|
+
}
|
|
217
|
+
const spans = linkSpans(line);
|
|
218
|
+
if (linkText) for (const [open, close] of spans) masked.fill(' ', open, close + 1);
|
|
219
|
+
return masked.join('');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The question test of one line: `(index) => boolean`, whether the sentence holding `index` ends in a question mark. A full
|
|
224
|
+
* stop between digits ends nothing, and a mark inside any protected span (a quotation, inline code, a placeholder, a link
|
|
225
|
+
* target, a URL or a path) ends nothing either; `reasons` says which characters of the line are protected. The terminators are
|
|
226
|
+
* found once per line, so each test is a search over them and a line of many sites costs time in proportion to its length.
|
|
227
|
+
*/
|
|
228
|
+
function questionTest(line, reasons) {
|
|
229
|
+
const endsOf = (text) => [...text.matchAll(/[!?]|\.(?!\d)/g)].map((match) => ({ at: match.index, question: match[0] === '?' }));
|
|
230
|
+
const outside = endsOf(maskProtected(line, reasons));
|
|
231
|
+
const inside = endsOf(maskProtected(line, reasons, { linkText: false }));
|
|
232
|
+
const spans = linkSpans(line);
|
|
233
|
+
const firstAfter = (ends, index) => {
|
|
234
|
+
let low = 0;
|
|
235
|
+
let high = ends.length;
|
|
236
|
+
while (low < high) {
|
|
237
|
+
const middle = (low + high) >> 1;
|
|
238
|
+
if (ends[middle].at < index) low = middle + 1;
|
|
239
|
+
else high = middle;
|
|
240
|
+
}
|
|
241
|
+
return ends[low];
|
|
242
|
+
};
|
|
243
|
+
return (index) => {
|
|
244
|
+
// A site in the text of a link reads that text's own sentence ends first.
|
|
245
|
+
let low = 0;
|
|
246
|
+
let high = spans.length;
|
|
247
|
+
while (low < high) {
|
|
248
|
+
const middle = (low + high) >> 1;
|
|
249
|
+
if (spans[middle][1] < index) low = middle + 1;
|
|
250
|
+
else high = middle;
|
|
251
|
+
}
|
|
252
|
+
const span = spans[low] !== undefined && index > spans[low][0] ? spans[low] : undefined;
|
|
253
|
+
if (span !== undefined) {
|
|
254
|
+
const own = firstAfter(inside, index);
|
|
255
|
+
if (own !== undefined && own.at < span[1]) return own.question;
|
|
256
|
+
}
|
|
257
|
+
return firstAfter(outside, index)?.question ?? false;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A site the family recognized but cannot edit safely. */
|
|
262
|
+
function refuse(start, end, code, message) {
|
|
263
|
+
return { start, end, code, message };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A site with its edit: `line.slice(start, end)` becomes `replacement`. */
|
|
267
|
+
function edit(start, end, replacement, hypothesis) {
|
|
268
|
+
return { start, end, replacement, hypothesis };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Participles that follow `have` without ending in -ed or -en. */
|
|
272
|
+
const IRREGULAR_PARTICIPLES = new Set(
|
|
273
|
+
'been had done gone made seen got gotten left kept held run come become given found said thought meant built sent set put read won lost paid sold told heard begun written chosen broken spoken fallen eaten hidden driven risen worn torn drawn grown thrown flown sung stolen frozen forgotten known shown taken bought brought caught felt fed sat spent stood understood slept led met lent lain hung dug dealt'.split(
|
|
274
|
+
' ',
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
/** Words that take -ing or -ed and still name a thing, so `be nothing but` states a requirement. */
|
|
278
|
+
|
|
279
|
+
/** Nouns, numbers and adjectives that look like a participle (`have ten items`, `be nothing but JSON`), so they state a requirement. */
|
|
280
|
+
const NOT_A_PARTICIPLE = new Set(
|
|
281
|
+
'ten eleven seven green children men women token tokens screen open even seed seeds need needs speed feed embed kitchen golden wooden hundred nothing something anything everything string strings thing things'.split(
|
|
282
|
+
' ',
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
/** Adverbs that may stand between the modal and the verb of an inference (`must already have used`). */
|
|
286
|
+
const ADVERBS = new Set([
|
|
287
|
+
'already',
|
|
288
|
+
'still',
|
|
289
|
+
'also',
|
|
290
|
+
'just',
|
|
291
|
+
'surely',
|
|
292
|
+
'always',
|
|
293
|
+
'currently',
|
|
294
|
+
'probably',
|
|
295
|
+
'definitely',
|
|
296
|
+
'clearly',
|
|
297
|
+
'simply',
|
|
298
|
+
'actually',
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Whether the modal ending at `end` reads as an inference about a past event or a state (`must have used`, `must have
|
|
303
|
+
* been`, `must be running`), which a requirement never states. A requirement such as `must have a test` is not one;
|
|
304
|
+
* a participle that a noun shares (`have read access`) is refused too, since the line cannot tell the two apart.
|
|
305
|
+
*/
|
|
306
|
+
function isEpistemic(line, end) {
|
|
307
|
+
const words = [...line.slice(end, end + WINDOW).matchAll(/[\p{L}\p{N}_'’]+/gu)].map((word) => word[0].toLowerCase());
|
|
308
|
+
while (ADVERBS.has(words[0])) words.shift();
|
|
309
|
+
const [next, following = ''] = words;
|
|
310
|
+
if (next === undefined) return false;
|
|
311
|
+
if (/^['’]ve$/.test(next)) return true;
|
|
312
|
+
if (next === 'have') {
|
|
313
|
+
const participle = ADVERBS.has(following) ? (words[2] ?? '') : following;
|
|
314
|
+
return !NOT_A_PARTICIPLE.has(participle) && (/(?:ed|en)$/.test(participle) || IRREGULAR_PARTICIPLES.has(participle));
|
|
315
|
+
}
|
|
316
|
+
return next === 'be' && following.endsWith('ing') && !NOT_A_PARTICIPLE.has(following);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The modal-strength sites of a line.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} line
|
|
323
|
+
* @param {Array<string|undefined>} reasons the reason each character of the line is protected, or `undefined`
|
|
324
|
+
* @returns {Array<object>}
|
|
325
|
+
*/
|
|
326
|
+
function modalSites(line, reasons) {
|
|
327
|
+
const sites = [];
|
|
328
|
+
const asks = questionTest(line, reasons);
|
|
329
|
+
for (const match of line.matchAll(/(?<![\w-])(must|shall)(?![\w-])/giu)) {
|
|
330
|
+
const [word] = match;
|
|
331
|
+
const start = match.index;
|
|
332
|
+
const end = start + word.length;
|
|
333
|
+
if (asks(end)) {
|
|
334
|
+
sites.push(refuse(start, end, 'question', `"${word}" sits in a question, which asks and does not instruct`));
|
|
335
|
+
} else if (NOUN_DETERMINERS.has(wordBefore(line, start))) {
|
|
336
|
+
sites.push(
|
|
337
|
+
refuse(
|
|
338
|
+
start,
|
|
339
|
+
end,
|
|
340
|
+
'not-a-directive',
|
|
341
|
+
`"${word}" follows "${wordBefore(line, start)}", which makes it a noun, and "should" would corrupt the grammar`,
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
} else if (isEpistemic(line, end)) {
|
|
345
|
+
sites.push(
|
|
346
|
+
refuse(
|
|
347
|
+
start,
|
|
348
|
+
end,
|
|
349
|
+
'epistemic',
|
|
350
|
+
`"${word}" is followed by "${line
|
|
351
|
+
.slice(end, end + WINDOW)
|
|
352
|
+
.trim()
|
|
353
|
+
.split(/\s+/)
|
|
354
|
+
.slice(0, 2)
|
|
355
|
+
.join(' ')}", which can infer a past event or a state and not require a step`,
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
const replacement = matchCase(word, 'should');
|
|
360
|
+
sites.push(
|
|
361
|
+
edit(start, end, replacement, `Softens "${word}" to "${replacement}", so a step the instruction requires becomes optional`),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return sites;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The negation sites of a line.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} line
|
|
372
|
+
* @param {Array<string|undefined>} reasons the reason each character of the line is protected, or `undefined`
|
|
373
|
+
* @returns {Array<object>}
|
|
374
|
+
*/
|
|
375
|
+
function negationSites(line, reasons) {
|
|
376
|
+
const sites = [];
|
|
377
|
+
const asks = questionTest(line, reasons);
|
|
378
|
+
const patterns = [
|
|
379
|
+
{ pattern: /(?<![\w-])(do|must|shall|should)(\s+)not(?![\w-])/giu, keep: (m) => m[1] },
|
|
380
|
+
{ pattern: /(?<![\w-])(do|must|should)n['’]t(?![\w-])/giu, keep: (m) => m[1] },
|
|
381
|
+
{ pattern: /(?<![\w-])can(?:not|\s+not|['’]t)(?![\w-])/giu, keep: (m) => matchCase(m[0], 'can') },
|
|
382
|
+
{ pattern: /(?<![\w-])never(?![\w-])/giu, keep: (m) => matchCase(m[0], 'always') },
|
|
383
|
+
];
|
|
384
|
+
for (const { pattern, keep } of patterns) {
|
|
385
|
+
for (const match of line.matchAll(pattern)) {
|
|
386
|
+
const start = match.index;
|
|
387
|
+
const end = start + match[0].length;
|
|
388
|
+
const before = wordBefore(line, start);
|
|
389
|
+
const after = wordAfter(line, end);
|
|
390
|
+
if (asks(end)) {
|
|
391
|
+
sites.push(refuse(start, end, 'question', `"${match[0]}" sits in a question, which asks and does not instruct`));
|
|
392
|
+
} else if (NEGATION_WORDS.has(after) || NEGATION_WORDS.has(before) || /^\W*\p{L}*n['’]t\b/iu.test(line.slice(end, end + WINDOW))) {
|
|
393
|
+
sites.push(
|
|
394
|
+
refuse(
|
|
395
|
+
start,
|
|
396
|
+
end,
|
|
397
|
+
'double-negation',
|
|
398
|
+
`"${match[0]}" sits beside another negation ("${NEGATION_WORDS.has(before) ? before : after}"), so removing it would leave a negation reading as the opposite of both`,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
} else if (match[0].toLowerCase() === 'never' && ['ever', 'again'].includes(after)) {
|
|
402
|
+
sites.push(refuse(start, end, 'grammar', `"${match[0]} ${after}" has no form with "always" that is English`));
|
|
403
|
+
} else if (DESCRIPTIVE_SUBJECTS.has(before)) {
|
|
404
|
+
sites.push(
|
|
405
|
+
refuse(start, end, 'descriptive', `"${match[0]}" follows "${before}", which reads as a statement of fact and not an instruction`),
|
|
406
|
+
);
|
|
407
|
+
} else {
|
|
408
|
+
const replacement = keep(match);
|
|
409
|
+
sites.push(
|
|
410
|
+
edit(
|
|
411
|
+
start,
|
|
412
|
+
end,
|
|
413
|
+
replacement,
|
|
414
|
+
`Removes the prohibition in "${match[0]}", so the instruction reads "${replacement}" and asks for the behavior it forbade`,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return sites;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The numeric-threshold sites of a line.
|
|
425
|
+
*
|
|
426
|
+
* @param {string} line
|
|
427
|
+
* @param {Array<string|undefined>} reasons the reason each character of the line is protected, or `undefined`
|
|
428
|
+
* @returns {Array<object>}
|
|
429
|
+
*/
|
|
430
|
+
function numericSites(line, reasons) {
|
|
431
|
+
const sites = [];
|
|
432
|
+
const asks = questionTest(line, reasons);
|
|
433
|
+
const masked = maskProtected(line, reasons, { linkText: false });
|
|
434
|
+
for (const match of line.matchAll(BOUND_SITE)) {
|
|
435
|
+
const { groups } = match;
|
|
436
|
+
const lower = groups.lower !== undefined;
|
|
437
|
+
const phrase = lower ? groups.lower : groups.upper;
|
|
438
|
+
const number = lower ? groups.number : groups.number2;
|
|
439
|
+
const gap = lower ? groups.gap : groups.gap2;
|
|
440
|
+
const tail = lower ? groups.tail : groups.tail2;
|
|
441
|
+
const start = match.index + phrase.length + gap.length;
|
|
442
|
+
const end = start + number.length;
|
|
443
|
+
const after = line.slice(end + tail.length, end + tail.length + WINDOW);
|
|
444
|
+
const bound = `${phrase}${gap}${number}${tail}`;
|
|
445
|
+
const percent = tail.startsWith('%') || tail.toLowerCase() === 'percent' || /^\s*(?:%|percent\b|per cent\b)/i.test(after);
|
|
446
|
+
// Attached letters are the unit (`500ms`); a spaced word is the unit too (`3 retries`).
|
|
447
|
+
const next = /^\s+(\p{L}+)/u.exec(after)?.[1];
|
|
448
|
+
const unit = percent ? '%' : tail === '' ? (next === undefined || FILLER.has(next.toLowerCase()) ? 'none' : next) : tail;
|
|
449
|
+
const clause = /[^.;:!?,]*$/.exec(masked.slice(Math.max(0, match.index - 200), match.index))[0];
|
|
450
|
+
if (asks(end)) {
|
|
451
|
+
sites.push(refuse(start, end, 'question', `"${bound}" sits in a question, which asks and does not instruct`));
|
|
452
|
+
} else if (NEGATED.test(clause)) {
|
|
453
|
+
sites.push(
|
|
454
|
+
refuse(start, end, 'negated-bound', `"${bound}" follows a negation, which turns the direction the bound constrains around`),
|
|
455
|
+
);
|
|
456
|
+
} else if (/^(?:[.,:/-]|\s*[–—-]\s*|\s+(?:to|or|and)\s+)[0-9]/i.test(after)) {
|
|
457
|
+
sites.push(
|
|
458
|
+
refuse(
|
|
459
|
+
start,
|
|
460
|
+
end,
|
|
461
|
+
'not-a-threshold',
|
|
462
|
+
`"${bound}" continues as a decimal, a version, a date, a time, a range or an alternative, and a part of it is not a bound`,
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
} else if (/^(?:st|nd|rd|th)$/i.test(tail)) {
|
|
466
|
+
sites.push(refuse(start, end, 'not-a-threshold', `"${bound}" is an ordinal, not a quantity`));
|
|
467
|
+
} else if (number.length > MAX_SAFE_DIGITS) {
|
|
468
|
+
sites.push(refuse(start, end, 'not-a-threshold', `"${bound}" is too large to loosen exactly`));
|
|
469
|
+
} else {
|
|
470
|
+
const value = Number(number);
|
|
471
|
+
const loosened = lower ? Math.floor(value / 2) : value * 2;
|
|
472
|
+
if (loosened === value) {
|
|
473
|
+
sites.push(refuse(start, end, 'no-change', `"${bound}" is already as loose as the rule loosens it`));
|
|
474
|
+
} else if (!percent && tail === '' && unit !== 'none' && (value === 1) !== (loosened === 1)) {
|
|
475
|
+
sites.push(
|
|
476
|
+
refuse(
|
|
477
|
+
start,
|
|
478
|
+
end,
|
|
479
|
+
'number-agreement',
|
|
480
|
+
`"${bound}" would become ${loosened} ${unit}, which breaks the singular or plural of "${unit}"`,
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
} else if (percent && loosened > 100) {
|
|
484
|
+
sites.push(refuse(start, end, 'beyond-range', `"${bound}" would become ${loosened}%, which no percentage can reach`));
|
|
485
|
+
} else {
|
|
486
|
+
const direction = lower ? 'lower' : 'upper';
|
|
487
|
+
sites.push(
|
|
488
|
+
edit(
|
|
489
|
+
start,
|
|
490
|
+
end,
|
|
491
|
+
String(loosened),
|
|
492
|
+
`Loosens the ${direction} bound "${bound}" (unit ${unit}) to "${phrase}${gap}${loosened}${tail}", so a ${lower ? 'smaller' : 'larger'} value than the requirement allows still meets the instruction`,
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return sites;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The catalog, in the order candidates are numbered. */
|
|
502
|
+
const FAMILIES = Object.freeze([
|
|
503
|
+
Object.freeze({ name: 'modal-strength', sites: modalSites }),
|
|
504
|
+
Object.freeze({ name: 'negation', sites: negationSites }),
|
|
505
|
+
Object.freeze({ name: 'numeric-threshold', sites: numericSites }),
|
|
506
|
+
]);
|
|
507
|
+
const FAMILY_NAMES = Object.freeze(FAMILIES.map((family) => family.name));
|
|
508
|
+
|
|
509
|
+
/** The width of the whitespace that opens `line`, a tab reaching the next multiple of four. */
|
|
510
|
+
function indentOf(line) {
|
|
511
|
+
let width = 0;
|
|
512
|
+
for (const character of line) {
|
|
513
|
+
if (character === ' ') width += 1;
|
|
514
|
+
else if (character === '\t') width += 4 - (width % 4);
|
|
515
|
+
else break;
|
|
516
|
+
}
|
|
517
|
+
return width;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The column where the content of a list item starts, given its match; a tab after the marker reaches the next tab stop. */
|
|
521
|
+
function contentColumn(item) {
|
|
522
|
+
const width = gapWidth(item);
|
|
523
|
+
return indentOf(item[1]) + item[2].length + (width >= 5 ? 1 : width);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The columns between a list marker and its text, a tab reaching the next tab stop; five or more make the text indented code. */
|
|
527
|
+
function gapWidth([, indentText, marker, gap]) {
|
|
528
|
+
const markerEnd = indentOf(indentText) + marker.length;
|
|
529
|
+
let column = markerEnd;
|
|
530
|
+
for (const character of gap) column += character === '\t' ? 4 - (column % 4) : 1;
|
|
531
|
+
return column - markerEnd;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Marks `[from, to)` of `reasons` as protected for `reason`, keeping a reason already set. */
|
|
535
|
+
function protect(reasons, from, to, reason) {
|
|
536
|
+
for (let at = from; at < Math.min(to, reasons.length); at += 1) reasons[at] ??= reason;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Marks the inline code spans of the paragraph `text.slice(from, to)`: a run of backticks opens a span that the next run of
|
|
541
|
+
* the same length closes, and a run with no such partner is literal. The scan visits each run once.
|
|
542
|
+
*/
|
|
543
|
+
function protectCodeSpans(reasons, text, from, to) {
|
|
544
|
+
const runs = [...text.slice(from, to).matchAll(/`+/g)];
|
|
545
|
+
const byLength = new Map();
|
|
546
|
+
for (const [position, run] of runs.entries()) {
|
|
547
|
+
if (!byLength.has(run[0].length)) byLength.set(run[0].length, []);
|
|
548
|
+
byLength.get(run[0].length).push(position);
|
|
549
|
+
}
|
|
550
|
+
const cursor = new Map();
|
|
551
|
+
for (let position = 0; position < runs.length; ) {
|
|
552
|
+
const length = runs[position][0].length;
|
|
553
|
+
const same = byLength.get(length);
|
|
554
|
+
let next = cursor.get(length) ?? 0;
|
|
555
|
+
while (same[next] <= position) next += 1;
|
|
556
|
+
cursor.set(length, next);
|
|
557
|
+
const closer = same[next];
|
|
558
|
+
if (closer === undefined) {
|
|
559
|
+
position += 1;
|
|
560
|
+
} else {
|
|
561
|
+
protect(reasons, from + runs[position].index, from + runs[closer].index + length, 'inline-code');
|
|
562
|
+
position = closer + 1;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The reason each character of `text` is protected from an edit, or `undefined`: front matter, fenced and indented
|
|
569
|
+
* code, headings, block quotes, HTML comments, inline code, quotations, link targets, URLs, paths and placeholders.
|
|
570
|
+
* A leading byte order mark is skipped, so the first line is read as a first line.
|
|
571
|
+
*
|
|
572
|
+
* @param {string} text
|
|
573
|
+
* @returns {Array<string|undefined>}
|
|
574
|
+
*/
|
|
575
|
+
function protectedSpans(text) {
|
|
576
|
+
if (text.startsWith('')) return [undefined, ...protectedSpans(text.slice(1))];
|
|
577
|
+
const reasons = Array.from({ length: text.length });
|
|
578
|
+
const front = /^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
|
579
|
+
if (front) protect(reasons, 0, front[0].length, 'front-matter');
|
|
580
|
+
for (const comment of text.matchAll(/<!--[\s\S]*?(?:-->|$)/g))
|
|
581
|
+
protect(reasons, comment.index, comment.index + comment[0].length, 'html-comment');
|
|
582
|
+
|
|
583
|
+
const lines = text.split('\n');
|
|
584
|
+
let fence = null;
|
|
585
|
+
let at = 0;
|
|
586
|
+
let blankBefore = true;
|
|
587
|
+
let indentedCode = false;
|
|
588
|
+
let quotedBefore = false;
|
|
589
|
+
// The content column of the list item the lines are in, so a fence inside an item is read against it.
|
|
590
|
+
let container = 0;
|
|
591
|
+
// The paragraph being read: an inline code span may run over its lines, and over nothing else.
|
|
592
|
+
let paragraph = null;
|
|
593
|
+
const endParagraph = () => {
|
|
594
|
+
if (paragraph !== null) protectCodeSpans(reasons, text, paragraph.from, paragraph.to);
|
|
595
|
+
paragraph = null;
|
|
596
|
+
};
|
|
597
|
+
for (const [index, rawLine] of lines.entries()) {
|
|
598
|
+
const lineEnd = at + rawLine.length;
|
|
599
|
+
if (reasons[at] === 'front-matter') {
|
|
600
|
+
endParagraph();
|
|
601
|
+
at = lineEnd + 1;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const blank = rawLine.trim() === '';
|
|
605
|
+
const indent = indentOf(rawLine);
|
|
606
|
+
// A fence ends with the list item that holds it, when a line falls back left of the item's content column.
|
|
607
|
+
if (fence !== null && !blank && indent < fence.container) fence = null;
|
|
608
|
+
if (fence !== null) {
|
|
609
|
+
protect(reasons, at, lineEnd, 'code-fence');
|
|
610
|
+
const closing = /^[ \t]*(`{3,}|~{3,})[ \t\r]*$/.exec(rawLine);
|
|
611
|
+
if (closing && closing[1][0] === fence.marker[0] && closing[1].length >= fence.marker.length && indent - fence.container <= 3) {
|
|
612
|
+
fence = null;
|
|
613
|
+
}
|
|
614
|
+
blankBefore = blank;
|
|
615
|
+
quotedBefore = false;
|
|
616
|
+
at = lineEnd + 1;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
// Only a bullet or `1.` interrupts a paragraph.
|
|
620
|
+
const item = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)\S/.exec(rawLine);
|
|
621
|
+
const listItem = item !== null && (paragraph === null || /^(?:[-*+]|1[.)])$/.test(item[2])) ? item : null;
|
|
622
|
+
if (!blank && listItem === null && indent < container) container = 0;
|
|
623
|
+
// A fence opens at most three columns past the container's content column, or on the line of a list marker.
|
|
624
|
+
const opening =
|
|
625
|
+
listItem === null
|
|
626
|
+
? indent >= container && indent - container <= 3
|
|
627
|
+
? /^[ \t]*(`{3,}|~{3,})([^\n]*)$/.exec(rawLine)
|
|
628
|
+
: null
|
|
629
|
+
: gapWidth(listItem) >= 5
|
|
630
|
+
? null
|
|
631
|
+
: /^(`{3,}|~{3,})([^\n]*)$/.exec(rawLine.slice(listItem[0].length - 1));
|
|
632
|
+
let quotedNow = false;
|
|
633
|
+
if (opening !== null && !(opening[1][0] === '`' && opening[2].includes('`'))) {
|
|
634
|
+
endParagraph();
|
|
635
|
+
if (listItem !== null) container = contentColumn(listItem);
|
|
636
|
+
fence = { marker: opening[1], container };
|
|
637
|
+
protect(reasons, at, lineEnd, 'code-fence');
|
|
638
|
+
} else {
|
|
639
|
+
// A line indented four columns or a tab after a blank line, or after such a line, is an indented code block.
|
|
640
|
+
indentedCode = !blank && indent - container >= 4 && (blankBefore || indentedCode);
|
|
641
|
+
const heading = /^ {0,3}#{1,6}(\s|$)/.test(rawLine) || (!blank && /^ {0,3}(?:=+|-+)[ \t]*\r?$/.test(lines[index + 1] ?? ''));
|
|
642
|
+
// A line after a quoted one that starts no other block continues the quote, as a lazy continuation.
|
|
643
|
+
const thematic = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t\r]*$/.test(rawLine);
|
|
644
|
+
const quoted = /^ {0,3}>/.test(rawLine) || (quotedBefore && !blank && !heading && !thematic && listItem === null);
|
|
645
|
+
quotedNow = quoted;
|
|
646
|
+
if (indentedCode) protect(reasons, at, lineEnd, 'indented-code');
|
|
647
|
+
if (heading) protect(reasons, at, lineEnd, 'heading');
|
|
648
|
+
if (quoted) protect(reasons, at, lineEnd, 'block-quote');
|
|
649
|
+
if (blank || indentedCode || heading || quoted || thematic || listItem !== null) endParagraph();
|
|
650
|
+
if (!blank && !indentedCode && !heading && !quoted && !thematic) {
|
|
651
|
+
paragraph ??= { from: at, to: lineEnd };
|
|
652
|
+
paragraph.to = lineEnd;
|
|
653
|
+
}
|
|
654
|
+
if (listItem !== null) container = contentColumn(listItem);
|
|
655
|
+
// Straight quotes pair up left to right; an unpaired last one quotes the rest of the line.
|
|
656
|
+
const quotes = [...rawLine.matchAll(/"/g)].map((quote) => quote.index);
|
|
657
|
+
for (let pair = 0; pair < quotes.length; pair += 2) {
|
|
658
|
+
protect(reasons, at + quotes[pair], quotes[pair + 1] === undefined ? lineEnd : at + quotes[pair + 1] + 1, 'quotation');
|
|
659
|
+
}
|
|
660
|
+
for (const quote of rawLine.matchAll(/“[^”]*(?:”|$)|‘[^’]*(?:’|$)|(?<![\p{L}\p{N}])'[^'\n]+'(?![\p{L}\p{N}])/gu))
|
|
661
|
+
protect(reasons, at + quote.index, at + quote.index + quote[0].length, 'quotation');
|
|
662
|
+
for (const link of rawLine.matchAll(/\]\([^)]*\)/g))
|
|
663
|
+
protect(reasons, at + link.index, at + link.index + link[0].length, 'link-target');
|
|
664
|
+
for (const token of rawLine.matchAll(/\S+/g)) {
|
|
665
|
+
const scheme = token[0].indexOf('://');
|
|
666
|
+
const start = scheme === -1 ? -1 : token[0].slice(0, scheme).search(/[a-z][a-z0-9+.-]*$/i);
|
|
667
|
+
if (start !== -1) protect(reasons, at + token.index + start, at + token.index + token[0].length, 'url');
|
|
668
|
+
else if (/(?:^|[^\p{L}\p{N}])(?:www\.|mailto:)/iu.test(token[0].slice(0, 12)))
|
|
669
|
+
protect(reasons, at + token.index, at + token.index + token[0].length, 'url');
|
|
670
|
+
else if (/[/\\]/.test(token[0])) protect(reasons, at + token.index, at + token.index + token[0].length, 'path');
|
|
671
|
+
}
|
|
672
|
+
const placeholder =
|
|
673
|
+
/\{\{[^}]*\}\}|\$\{[^}]*\}|\{[A-Za-z_][\w.-]*\}|<[A-Za-z_][\w.:-]*>|\[\[[^\]]*\]\]|%\([^)]*\)[sd]|\$[A-Za-z_]\w*/g;
|
|
674
|
+
for (const found of rawLine.matchAll(placeholder))
|
|
675
|
+
protect(reasons, at + found.index, at + found.index + found[0].length, 'placeholder');
|
|
676
|
+
}
|
|
677
|
+
blankBefore = blank;
|
|
678
|
+
quotedBefore = quotedNow;
|
|
679
|
+
at = lineEnd + 1;
|
|
680
|
+
}
|
|
681
|
+
endParagraph();
|
|
682
|
+
return reasons;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** How often `find` occurs in `text`, overlapping occurrences included, as the native `replace-exact` operator counts them. */
|
|
686
|
+
function countOccurrences(text, find) {
|
|
687
|
+
let count = 0;
|
|
688
|
+
for (let at = text.indexOf(find); at !== -1; at = text.indexOf(find, at + 1)) count += 1;
|
|
689
|
+
return count;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** The text of the bytes, which must be well-formed UTF-8 without a NUL. */
|
|
693
|
+
function decodeTarget(bytes) {
|
|
694
|
+
if (!ArrayBuffer.isView(bytes)) throw new GeneratorError('target.bytes must be a Buffer or Uint8Array holding the target file');
|
|
695
|
+
if (bytes.byteLength > MAX_TARGET_BYTES) {
|
|
696
|
+
throw new GeneratorError(`target.bytes is ${bytes.byteLength} bytes; a prompt past ${MAX_TARGET_BYTES} bytes is not a prompt`);
|
|
697
|
+
}
|
|
698
|
+
let text;
|
|
699
|
+
try {
|
|
700
|
+
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
701
|
+
} catch {
|
|
702
|
+
throw new GeneratorError('target.bytes is not UTF-8 text');
|
|
703
|
+
}
|
|
704
|
+
if (text.includes('\u0000')) throw new GeneratorError('target.bytes holds a NUL, so it is not a prompt');
|
|
705
|
+
return text;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Refuses a campaign ID, requirement digest or target path the candidate schema would refuse, before any scanning, so a
|
|
710
|
+
* bad input fails the same way whatever the prompt holds. The inputs are held to the schema by validating a stand-in
|
|
711
|
+
* record that carries them.
|
|
712
|
+
*/
|
|
713
|
+
function checkRecordInputs({ campaignId, requirementDigest, artifact, seed }) {
|
|
714
|
+
for (const [name, value] of [
|
|
715
|
+
['campaignId', campaignId],
|
|
716
|
+
['requirementDigest', requirementDigest],
|
|
717
|
+
['target.artifact', artifact],
|
|
718
|
+
]) {
|
|
719
|
+
if (typeof value !== 'string') throw new GeneratorError(`${name} must be a string`);
|
|
720
|
+
}
|
|
721
|
+
const standIn = {
|
|
722
|
+
schemaVersion: CANDIDATE_SCHEMA_VERSION,
|
|
723
|
+
recordKind: RECORD_KIND,
|
|
724
|
+
candidateId: 'C-001',
|
|
725
|
+
campaignId,
|
|
726
|
+
partition: 'development',
|
|
727
|
+
identity: '',
|
|
728
|
+
generator: { name: GENERATOR_NAME, version: GENERATOR_VERSION, seed },
|
|
729
|
+
intent: 'regression',
|
|
730
|
+
hypothesis: 'input check',
|
|
731
|
+
requirement: { path: REQUIREMENTS_PATH, digest: requirementDigest },
|
|
732
|
+
target: { artifact, artifactDigest: bytesDigest(new Uint8Array()) },
|
|
733
|
+
edit: { find: 'input check', replace: 'input check edited', occurrences: 1 },
|
|
734
|
+
state: 'generated',
|
|
735
|
+
disposition: null,
|
|
736
|
+
observations: [],
|
|
737
|
+
independentEvidence: [],
|
|
738
|
+
review: null,
|
|
739
|
+
acceptedRisk: null,
|
|
740
|
+
promotion: null,
|
|
741
|
+
};
|
|
742
|
+
standIn.identity = candidateIdentity(standIn);
|
|
743
|
+
try {
|
|
744
|
+
validateCandidate(standIn);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (error instanceof CandidateError)
|
|
747
|
+
throw new GeneratorError(`${error.message} (campaignId, requirementDigest, target.artifact and seed are held to it)`);
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** The families a run uses, in catalog order, from the caller's names. */
|
|
753
|
+
function chooseFamilies(names) {
|
|
754
|
+
if (names === undefined) return FAMILIES;
|
|
755
|
+
if (!Array.isArray(names) || names.length === 0)
|
|
756
|
+
throw new GeneratorError('families must name at least one of ' + FAMILY_NAMES.join(', '));
|
|
757
|
+
const unknown = names.filter((name) => !FAMILY_NAMES.includes(name));
|
|
758
|
+
if (unknown.length > 0)
|
|
759
|
+
throw new GeneratorError(
|
|
760
|
+
`unknown famil${unknown.length === 1 ? 'y' : 'ies'} ${unknown.map((name) => JSON.stringify(name)).join(', ')}; the catalog is ${FAMILY_NAMES.join(', ')}`,
|
|
761
|
+
);
|
|
762
|
+
return FAMILIES.filter((family) => names.includes(family.name));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** The position of the seed-ordered site, a digest that only the same seed and the same site reproduce. */
|
|
766
|
+
function rank(seed, site) {
|
|
767
|
+
return digest([
|
|
768
|
+
`tea-evaluate-generator/${GENERATOR_VERSION}`,
|
|
769
|
+
seed,
|
|
770
|
+
site.family,
|
|
771
|
+
String(site.line),
|
|
772
|
+
String(site.column),
|
|
773
|
+
site.original,
|
|
774
|
+
site.replacement,
|
|
775
|
+
]);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Every site of the text per family, each as an edit or a refusal, with where it sits and why. */
|
|
779
|
+
function scan(text, families) {
|
|
780
|
+
const reasons = protectedSpans(text);
|
|
781
|
+
const found = [];
|
|
782
|
+
const rejected = [];
|
|
783
|
+
let lineStart = 0;
|
|
784
|
+
for (const [index, raw] of text.split('\n').entries()) {
|
|
785
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
786
|
+
const lineReasons = reasons.slice(lineStart, lineStart + line.length);
|
|
787
|
+
let repeats;
|
|
788
|
+
for (const family of families) {
|
|
789
|
+
for (const site of family.sites(line, lineReasons)) {
|
|
790
|
+
const where = { family: family.name, line: index + 1, column: site.start + 1 };
|
|
791
|
+
const covered = Array.from({ length: site.end - site.start }, (_, offset) => reasons[lineStart + site.start + offset]).find(
|
|
792
|
+
Boolean,
|
|
793
|
+
);
|
|
794
|
+
const refusal =
|
|
795
|
+
site.code === undefined
|
|
796
|
+
? covered === undefined
|
|
797
|
+
? null
|
|
798
|
+
: { code: 'protected-span', message: `the site sits in ${covered.replace('-', ' ')}, which an edit leaves alone` }
|
|
799
|
+
: { code: site.code, message: site.message };
|
|
800
|
+
if (refusal === null) {
|
|
801
|
+
repeats ??= countOccurrences(text, line);
|
|
802
|
+
if (repeats === 1)
|
|
803
|
+
found.push({
|
|
804
|
+
...where,
|
|
805
|
+
find: line,
|
|
806
|
+
start: site.start,
|
|
807
|
+
end: site.end,
|
|
808
|
+
original: line.slice(site.start, site.end),
|
|
809
|
+
replacement: site.replacement,
|
|
810
|
+
hypothesis: site.hypothesis,
|
|
811
|
+
});
|
|
812
|
+
else
|
|
813
|
+
rejected.push({
|
|
814
|
+
...where,
|
|
815
|
+
text: line,
|
|
816
|
+
code: 'repeated-span',
|
|
817
|
+
message: `the line occurs ${repeats} times in the target, so an exact edit of it is ambiguous`,
|
|
818
|
+
});
|
|
819
|
+
} else {
|
|
820
|
+
rejected.push({ ...where, text: line, ...refusal });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
lineStart += raw.length + 1;
|
|
825
|
+
}
|
|
826
|
+
return { found, rejected };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Previews the candidates the catalog proposes for one prompt file. Nothing is read or written.
|
|
831
|
+
*
|
|
832
|
+
* @param {object} options
|
|
833
|
+
* @param {{artifact: string, bytes: Uint8Array}} options.target the file as a mutation's `targetArtifact` spells it, and its bytes
|
|
834
|
+
* @param {string} options.requirementDigest `requirements.digest` as `evaluation.json` records it
|
|
835
|
+
* @param {string} options.campaignId
|
|
836
|
+
* @param {string} options.seed any non-empty string; it orders the sites a cap has to choose among
|
|
837
|
+
* @param {string[]} [options.families] a subset of the catalog; all by default
|
|
838
|
+
* @param {number} [options.perFamilyCap] the most candidates one family contributes, 5 by default
|
|
839
|
+
* @returns {{generator: object, config: object, target: object, counts: object, candidates: object[], rejected: object[]}}
|
|
840
|
+
* @throws {GeneratorError} for an input the generator cannot use
|
|
841
|
+
* @throws {import('./candidate').CandidateError} when a built candidate is not a valid record
|
|
842
|
+
*/
|
|
843
|
+
function previewCandidates({ target, requirementDigest, campaignId, seed, families, perFamilyCap = DEFAULT_PER_FAMILY_CAP } = {}) {
|
|
844
|
+
if (typeof seed !== 'string' || seed.length === 0) throw new GeneratorError('seed must be a non-empty string');
|
|
845
|
+
if (!Number.isInteger(perFamilyCap) || perFamilyCap < 1) throw new GeneratorError('perFamilyCap must be a positive integer');
|
|
846
|
+
if (typeof target?.artifact !== 'string') throw new GeneratorError('target.artifact must name the file');
|
|
847
|
+
checkRecordInputs({ campaignId, requirementDigest, artifact: target.artifact, seed });
|
|
848
|
+
const chosen = chooseFamilies(families);
|
|
849
|
+
const text = decodeTarget(target.bytes);
|
|
850
|
+
const targetDigest = bytesDigest(target.bytes);
|
|
851
|
+
const { found, rejected } = scan(text, chosen);
|
|
852
|
+
|
|
853
|
+
const counts = {};
|
|
854
|
+
const selected = [];
|
|
855
|
+
for (const family of chosen) {
|
|
856
|
+
const sites = found.filter((site) => site.family === family.name);
|
|
857
|
+
const kept = sites
|
|
858
|
+
.map((site) => ({ site, rank: rank(seed, site) }))
|
|
859
|
+
.sort((a, b) => (a.rank < b.rank ? -1 : 1))
|
|
860
|
+
.slice(0, perFamilyCap)
|
|
861
|
+
.map(({ site }) => site);
|
|
862
|
+
selected.push(...kept);
|
|
863
|
+
counts[family.name] = {
|
|
864
|
+
found: sites.length,
|
|
865
|
+
selected: kept.length,
|
|
866
|
+
omitted: sites.length - kept.length,
|
|
867
|
+
rejected: rejected.filter((site) => site.family === family.name).length,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
selected.sort((a, b) => FAMILY_NAMES.indexOf(a.family) - FAMILY_NAMES.indexOf(b.family) || a.line - b.line || a.column - b.column);
|
|
871
|
+
|
|
872
|
+
const candidates = selected.map((site, index) => {
|
|
873
|
+
const record = {
|
|
874
|
+
schemaVersion: CANDIDATE_SCHEMA_VERSION,
|
|
875
|
+
recordKind: RECORD_KIND,
|
|
876
|
+
candidateId: `C-${String(index + 1).padStart(3, '0')}`,
|
|
877
|
+
campaignId,
|
|
878
|
+
partition: 'development',
|
|
879
|
+
identity: '',
|
|
880
|
+
generator: { name: GENERATOR_NAME, version: GENERATOR_VERSION, seed },
|
|
881
|
+
intent: 'regression',
|
|
882
|
+
hypothesis: `${site.family}, line ${site.line}: ${site.hypothesis}.`,
|
|
883
|
+
requirement: { path: REQUIREMENTS_PATH, digest: requirementDigest },
|
|
884
|
+
target: { artifact: target.artifact, artifactDigest: targetDigest },
|
|
885
|
+
edit: { find: site.find, replace: site.find.slice(0, site.start) + site.replacement + site.find.slice(site.end), occurrences: 1 },
|
|
886
|
+
state: 'generated',
|
|
887
|
+
disposition: null,
|
|
888
|
+
observations: [],
|
|
889
|
+
independentEvidence: [],
|
|
890
|
+
review: null,
|
|
891
|
+
acceptedRisk: null,
|
|
892
|
+
promotion: null,
|
|
893
|
+
};
|
|
894
|
+
record.identity = candidateIdentity(record);
|
|
895
|
+
return {
|
|
896
|
+
family: site.family,
|
|
897
|
+
line: site.line,
|
|
898
|
+
column: site.column,
|
|
899
|
+
original: site.original,
|
|
900
|
+
replacement: site.replacement,
|
|
901
|
+
record: validateCandidate(record),
|
|
902
|
+
};
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const total = (key) => Object.values(counts).reduce((sum, family) => sum + family[key], 0);
|
|
906
|
+
return {
|
|
907
|
+
generator: { name: GENERATOR_NAME, version: GENERATOR_VERSION, seed },
|
|
908
|
+
config: { families: chosen.map((family) => family.name), perFamilyCap },
|
|
909
|
+
target: { artifact: target.artifact, artifactDigest: targetDigest },
|
|
910
|
+
counts: { families: counts, found: total('found'), selected: total('selected'), omitted: total('omitted'), rejected: rejected.length },
|
|
911
|
+
candidates,
|
|
912
|
+
rejected: rejected.sort(
|
|
913
|
+
(a, b) => a.line - b.line || a.column - b.column || FAMILY_NAMES.indexOf(a.family) - FAMILY_NAMES.indexOf(b.family),
|
|
914
|
+
),
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The preview as text a person reads before keeping anything: the counts per family, each candidate as the line before
|
|
920
|
+
* and after, and each rejection with its reason.
|
|
921
|
+
*
|
|
922
|
+
* @param {ReturnType<typeof previewCandidates>} preview
|
|
923
|
+
* @returns {string}
|
|
924
|
+
*/
|
|
925
|
+
function renderPreview(preview) {
|
|
926
|
+
const lines = [
|
|
927
|
+
`${preview.generator.name} ${preview.generator.version}, seed ${preview.generator.seed}, per-family cap ${preview.config.perFamilyCap}`,
|
|
928
|
+
`target ${preview.target.artifact} ${preview.target.artifactDigest}`,
|
|
929
|
+
];
|
|
930
|
+
for (const [name, count] of Object.entries(preview.counts.families)) {
|
|
931
|
+
lines.push(`${name}: ${count.found} found, ${count.selected} selected, ${count.omitted} omitted, ${count.rejected} rejected`);
|
|
932
|
+
}
|
|
933
|
+
for (const { record, family, line, column } of preview.candidates) {
|
|
934
|
+
lines.push(
|
|
935
|
+
'',
|
|
936
|
+
`${record.candidateId} ${family} line ${line} column ${column}`,
|
|
937
|
+
` - ${record.edit.find}`,
|
|
938
|
+
` + ${record.edit.replace}`,
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
if (preview.rejected.length > 0) lines.push('', 'rejected:');
|
|
942
|
+
for (const site of preview.rejected) lines.push(` line ${site.line} column ${site.column} ${site.family} ${site.code}: ${site.message}`);
|
|
943
|
+
return `${lines.join('\n')}\n`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
module.exports = {
|
|
947
|
+
DEFAULT_PER_FAMILY_CAP,
|
|
948
|
+
FAMILY_NAMES,
|
|
949
|
+
GENERATOR_NAME,
|
|
950
|
+
GENERATOR_VERSION,
|
|
951
|
+
GeneratorError,
|
|
952
|
+
previewCandidates,
|
|
953
|
+
renderPreview,
|
|
954
|
+
};
|