@am_shork/attest 0.7.1 → 0.7.3
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/CHANGELOG.md +597 -129
- package/README.md +10 -1
- package/dist/cli/action.d.ts +48 -0
- package/dist/cli/action.js +100 -0
- package/dist/cli/index.js +11 -32
- package/dist/cli/json.d.ts +3 -2
- package/dist/cli/json.js +3 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/loader.js +63 -33
- package/dist/core/locate.d.ts +4 -3
- package/dist/core/locate.js +53 -8
- package/dist/core/paths.d.ts +16 -0
- package/dist/core/paths.js +20 -1
- package/dist/core/pipeline.js +100 -14
- package/dist/core/render.js +190 -21
- package/dist/core/skill.js +19 -0
- package/package.json +1 -1
package/dist/core/pipeline.js
CHANGED
|
@@ -13,9 +13,9 @@ import { DEFAULT_TARGET, resolveTargets } from './targets.js';
|
|
|
13
13
|
import { writeAtomic } from './write.js';
|
|
14
14
|
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
15
15
|
import { compilerIssue } from './compiler.js';
|
|
16
|
-
import { mkdir, readFile } from 'node:fs/promises';
|
|
16
|
+
import { mkdir, readFile, realpath } from 'node:fs/promises';
|
|
17
17
|
import { basename, dirname, join } from 'node:path';
|
|
18
|
-
import { relativePath } from './paths.js';
|
|
18
|
+
import { isInside, relativePath } from './paths.js';
|
|
19
19
|
import { hasError } from './types.js';
|
|
20
20
|
// The runner half of the engine, reached only when a command actually needs it.
|
|
21
21
|
//
|
|
@@ -109,9 +109,13 @@ export async function runCheck(root, options = {}) {
|
|
|
109
109
|
// only one where that distinction is worth anything.
|
|
110
110
|
return withLoader(options, async (loader) => {
|
|
111
111
|
const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
|
-
const plan = await parseSpecs(scan.specFiles, root);
|
|
112
|
+
const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
|
|
113
113
|
return [
|
|
114
114
|
...issues,
|
|
115
|
+
// A spec the parser could not read is reported here rather than dropped:
|
|
116
|
+
// `check`'s contract is breadth, and a file silently contributing no
|
|
117
|
+
// scenarios reads as a file with no scenarios (ATX-65).
|
|
118
|
+
...unreadableSpecs,
|
|
115
119
|
// `check` keeps reporting on a registry that only half-loaded,
|
|
116
120
|
// deliberately: its contract is breadth, and the findings from the files
|
|
117
121
|
// that *did* load are all still true. What it must not do is advise work
|
|
@@ -220,7 +224,14 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
|
220
224
|
for (const id of claimedIds(read.delta))
|
|
221
225
|
claimed.add(id);
|
|
222
226
|
}
|
|
223
|
-
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
227
|
+
const { plan: proposed, issues: unreadable } = await parseSpecs(scan.proposedSpecFiles, root);
|
|
228
|
+
issues.push(...unreadable);
|
|
229
|
+
// A file that would not parse declares no scenarios, so the loop below would
|
|
230
|
+
// find it claimed by nothing and call it unclaimed — a true sentence about a
|
|
231
|
+
// file whose real problem is that it could not be read, and a fix hint
|
|
232
|
+
// pointing at work that must not be done. Same shape as `orphan-test` after a
|
|
233
|
+
// registry fails to load (ATX-62), and refused here for the same reason.
|
|
234
|
+
const unreadableFiles = new Set(unreadable.map((i) => i.file));
|
|
224
235
|
const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
|
|
225
236
|
// Reported per file, not per scenario: the file is the unit a run includes,
|
|
226
237
|
// so it is the unit that did or did not execute, and one line per scenario
|
|
@@ -230,7 +241,7 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
|
230
241
|
// nothing for a second reason, and reading the plan alone cannot see it.
|
|
231
242
|
for (const abs of scan.proposedSpecFiles) {
|
|
232
243
|
const file = relativePath(root, abs);
|
|
233
|
-
if (claimedFiles.has(file))
|
|
244
|
+
if (claimedFiles.has(file) || unreadableFiles.has(file))
|
|
234
245
|
continue;
|
|
235
246
|
issues.push({
|
|
236
247
|
level: 'ERROR',
|
|
@@ -287,7 +298,9 @@ export async function runVerify(root, options = {}) {
|
|
|
287
298
|
registry = loaded.registry;
|
|
288
299
|
issues.push(...loaded.issues);
|
|
289
300
|
unreadableFiles = loaded.unreadableFiles;
|
|
290
|
-
|
|
301
|
+
const parsedSpecs = await parseSpecs(scan.specFiles, root);
|
|
302
|
+
plan = parsedSpecs.plan;
|
|
303
|
+
issues.push(...parsedSpecs.issues);
|
|
291
304
|
}
|
|
292
305
|
finally {
|
|
293
306
|
await loader.close();
|
|
@@ -365,7 +378,7 @@ export async function runCover(root, options = {}) {
|
|
|
365
378
|
const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
|
|
366
379
|
if (hasError(loadIssues))
|
|
367
380
|
return { rows: [], issues: loadIssues };
|
|
368
|
-
const plan = await parseSpecs(scan.specFiles, root);
|
|
381
|
+
const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
|
|
369
382
|
const counts = new Map();
|
|
370
383
|
for (const s of plan.scenarios) {
|
|
371
384
|
counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
|
|
@@ -381,7 +394,7 @@ export async function runCover(root, options = {}) {
|
|
|
381
394
|
covered: (counts.get(reqId) ?? 0) > 0,
|
|
382
395
|
scenarioCount: counts.get(reqId) ?? 0,
|
|
383
396
|
}));
|
|
384
|
-
return { rows, issues: [...loadIssues, ...uncoveredIssues(registry, plan)] };
|
|
397
|
+
return { rows, issues: [...loadIssues, ...unreadableSpecs, ...uncoveredIssues(registry, plan)] };
|
|
385
398
|
}
|
|
386
399
|
/**
|
|
387
400
|
* Markdown projection of the intent layer (design §9: `attest render`).
|
|
@@ -437,15 +450,78 @@ export async function runInit(root, names) {
|
|
|
437
450
|
const { targets, issues } = resolveTargets(names.length > 0 ? names : [DEFAULT_TARGET]);
|
|
438
451
|
if (issues.length > 0)
|
|
439
452
|
return { files: [], issues };
|
|
440
|
-
|
|
453
|
+
// Every destination is resolved before any of them is written, for the reason
|
|
454
|
+
// `resolveTargets` refuses the whole set on one unknown name: a run that stops
|
|
455
|
+
// partway leaves a repository carrying instructions for some agents and a
|
|
456
|
+
// failing command that does not say which.
|
|
457
|
+
// A root that does not exist yet is `init <dir>` on a directory this run
|
|
458
|
+
// creates, and nothing can have been planted inside a directory that is not
|
|
459
|
+
// there — so it stands for itself and every segment below it resolves
|
|
460
|
+
// lexically, which is what the old implementation did for all of them.
|
|
461
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
462
|
+
const writes = [];
|
|
463
|
+
const refusals = [];
|
|
441
464
|
for (const target of targets) {
|
|
442
|
-
const
|
|
465
|
+
const resolved = await resolveDest(realRoot, target.file);
|
|
466
|
+
if (resolved.ok) {
|
|
467
|
+
writes.push({ target, dest: resolved.dest });
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
refusals.push({
|
|
471
|
+
level: 'ERROR',
|
|
472
|
+
code: 'unsafe-target-path',
|
|
473
|
+
file: target.file,
|
|
474
|
+
message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (refusals.length > 0)
|
|
479
|
+
return { files: [], issues: refusals };
|
|
480
|
+
const files = [];
|
|
481
|
+
for (const { target, dest } of writes) {
|
|
443
482
|
await mkdir(dirname(dest), { recursive: true });
|
|
444
483
|
await writeAtomic(dest, target.content());
|
|
445
484
|
files.push(target.file);
|
|
446
485
|
}
|
|
447
486
|
return { files, issues };
|
|
448
487
|
}
|
|
488
|
+
/**
|
|
489
|
+
* Where a target's file really goes, or the path outside the project that
|
|
490
|
+
* asking put it at (design §9: ownership of the path is checked, not assumed).
|
|
491
|
+
*
|
|
492
|
+
* `join(root, file)` answers where the path *reads* as going, and that is not
|
|
493
|
+
* the same question: a segment that is a link makes a path lexically inside the
|
|
494
|
+
* root and physically outside it, which `mkdir -p` and the write both follow.
|
|
495
|
+
* So the resolution is the check, and a lexical one would not be — `join`
|
|
496
|
+
* normalises `..`, so it is already satisfied here. `writeAtomic`'s `wx` is not
|
|
497
|
+
* the defence either and was never scoped to be: it guards the *temporary* path
|
|
498
|
+
* against a planted link, in whichever directory the destination turns out to
|
|
499
|
+
* be.
|
|
500
|
+
*
|
|
501
|
+
* So every segment is resolved, not just the leaf. A link anywhere along the
|
|
502
|
+
* way is the escape, and the leaf is checked too — `rename` replaces a symlink
|
|
503
|
+
* rather than following it, so a link there would not leak the content, but it
|
|
504
|
+
* would silently destroy a file the user made deliberately.
|
|
505
|
+
*
|
|
506
|
+
* A segment that does not exist cannot be a link, and neither can the ones
|
|
507
|
+
* below it, so `realpath` failing means the rest of the path is Attest's own
|
|
508
|
+
* `mkdir` to create. What this cannot close is a link planted between this
|
|
509
|
+
* resolution and the write; the exposure it does close is one committed to the
|
|
510
|
+
* repository, which is the one a checkout hands you.
|
|
511
|
+
*/
|
|
512
|
+
async function resolveDest(realRoot, file) {
|
|
513
|
+
let current = realRoot;
|
|
514
|
+
// `AgentTarget.file` is spelled with `/` on every platform (ATX-28), so the
|
|
515
|
+
// segments are the same list here as they are in the report.
|
|
516
|
+
for (const segment of file.split('/')) {
|
|
517
|
+
const next = join(current, segment);
|
|
518
|
+
const real = await realpath(next).catch(() => next);
|
|
519
|
+
if (!isInside(realRoot, real))
|
|
520
|
+
return { ok: false, escape: relativePath(realRoot, real) };
|
|
521
|
+
current = real;
|
|
522
|
+
}
|
|
523
|
+
return { ok: true, dest: current };
|
|
524
|
+
}
|
|
449
525
|
/**
|
|
450
526
|
* A change name must name one directory inside `changes/`, and nothing else.
|
|
451
527
|
*
|
|
@@ -607,10 +683,15 @@ function claimedByDelta(proposed, delta) {
|
|
|
607
683
|
* walking the tree again, so a caller that already scanned does not repeat it.
|
|
608
684
|
*/
|
|
609
685
|
async function changeMergedPlan(root, delta, scan) {
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
const
|
|
686
|
+
const base = await parseSpecs(scan.specFiles, root);
|
|
687
|
+
const proposedSpecs = await parseSpecs(scan.proposedSpecFiles, root);
|
|
688
|
+
const basePlan = base.plan;
|
|
689
|
+
const claimed = claimedByDelta(proposedSpecs.plan, delta);
|
|
613
690
|
return {
|
|
691
|
+
// A spec neither parse could read blocks the gate rather than vanishing
|
|
692
|
+
// from it: an unreadable proposed spec is a scenario the gate would
|
|
693
|
+
// otherwise report as absent (ATX-65).
|
|
694
|
+
issues: [...base.issues, ...proposedSpecs.issues],
|
|
614
695
|
merged: {
|
|
615
696
|
scenarios: [...basePlan.scenarios, ...claimed.scenarios],
|
|
616
697
|
paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
|
|
@@ -722,7 +803,12 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
722
803
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
723
804
|
// same function `status` reports against — a progress report computed over a
|
|
724
805
|
// different spec set than the gate uses would be a report about nothing.
|
|
725
|
-
const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
|
|
806
|
+
const { merged: plan, claimed, issues: unreadableSpecs } = await changeMergedPlan(root, delta, scan);
|
|
807
|
+
// Before the run, not after: an unreadable spec means the gate cannot see
|
|
808
|
+
// what that file declared, so letting the suite start would grade the
|
|
809
|
+
// change against a plan known to be short (ATX-65).
|
|
810
|
+
if (unreadableSpecs.length > 0)
|
|
811
|
+
return { issues: unreadableSpecs };
|
|
726
812
|
// Other proposals need no exclude glob of their own: the include list below
|
|
727
813
|
// is the plan's own files, and the plan holds only the proposed specs this
|
|
728
814
|
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
package/dist/core/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// back, so the direction OpenSpec's pure-Markdown model went (Markdown as
|
|
7
7
|
// truth, and the free-form drift that comes with it) stays closed.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Five properties this file must keep:
|
|
10
10
|
// - **Intent only.** The document says what the system promises, never what is
|
|
11
11
|
// proven or green: coverage and results are verdicts, and verdicts belong to
|
|
12
12
|
// `cover` / `verify`, which recompute them on demand. Putting them here also
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
// and it is a *file* — committed, served, and read again long after the run
|
|
26
26
|
// that wrote it. See `sanitised` for why the defence sits here rather than
|
|
27
27
|
// at the terminal write.
|
|
28
|
+
// - **Prose stays prose.** A string the registry owns may be marked up and may
|
|
29
|
+
// not become a tag or a heading: the document promises one section per
|
|
30
|
+
// requirement, and a rationale that opens its own breaks the only structural
|
|
31
|
+
// claim the document makes. See `prose` (design §9.1).
|
|
28
32
|
import { byCodeUnit, sortDeep } from './order.js';
|
|
29
33
|
import { control } from './terminal.js';
|
|
30
34
|
/** Whether a value reads as words in a sentence — a scalar, or a list of them. */
|
|
@@ -199,7 +203,7 @@ function compareIds(a, b) {
|
|
|
199
203
|
function overviewTable(registry, ids) {
|
|
200
204
|
const rows = ids.map((id) => {
|
|
201
205
|
const req = registry[id];
|
|
202
|
-
return `| [${id}](${anchor(id)}) | ${cell(
|
|
206
|
+
return `| [${id}](${anchor(id)}) | ${cell(statementText(req))} |`;
|
|
203
207
|
});
|
|
204
208
|
return ['| ID | Requirement |', '| --- | --- |', ...rows];
|
|
205
209
|
}
|
|
@@ -207,9 +211,9 @@ function section(id, req) {
|
|
|
207
211
|
const out = [
|
|
208
212
|
`## ${id}`,
|
|
209
213
|
'',
|
|
210
|
-
|
|
214
|
+
statementText(req),
|
|
211
215
|
'',
|
|
212
|
-
`**Why:** ${req.rationale}`,
|
|
216
|
+
`**Why:** ${prose(req.rationale)}`,
|
|
213
217
|
];
|
|
214
218
|
const params = Object.entries(req.params);
|
|
215
219
|
if (params.length > 0) {
|
|
@@ -225,33 +229,200 @@ function section(id, req) {
|
|
|
225
229
|
}
|
|
226
230
|
}
|
|
227
231
|
if (req.outOfScope.length > 0) {
|
|
228
|
-
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
|
|
232
|
+
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
|
|
229
233
|
}
|
|
230
234
|
return out;
|
|
231
235
|
}
|
|
236
|
+
/** The statement as the document says it, with its params in place. */
|
|
237
|
+
function statementText(req) {
|
|
238
|
+
return prose(req.statement, req.params);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Author prose as Markdown that cannot become HTML and cannot open a section,
|
|
242
|
+
* with `{param}` placeholders substituted as it goes (design §9.1).
|
|
243
|
+
*
|
|
244
|
+
* One pass rather than two because the two jobs need the same fact — which
|
|
245
|
+
* regime each character is in. Escaping first and substituting after would run
|
|
246
|
+
* the value through a scanner reading backticks as structure; substituting
|
|
247
|
+
* first and escaping after would let a value's own backtick open a code span
|
|
248
|
+
* that was never in the statement, and carry the rest of the sentence out of
|
|
249
|
+
* the defence. Knowing the position is also what lets a value be escaped
|
|
250
|
+
* *for* it, which is the difference between `emphasised` and `literal` below.
|
|
251
|
+
*
|
|
252
|
+
* Which of the three things a registry string could do here is intended, and
|
|
253
|
+
* why this escapes rather than refuses, is §9.1. What is local to this function
|
|
254
|
+
* is where it stops: **a code span is left exactly as written**, because a
|
|
255
|
+
* span's contents are literal text in every renderer and an entity written
|
|
256
|
+
* there would be visible rather than decoded. The match is therefore
|
|
257
|
+
* deliberately conservative — a backtick run opens a span only if a run of
|
|
258
|
+
* *exactly* that length closes it (CommonMark), and an unmatched run is text
|
|
259
|
+
* with the defence still applied after it. Being wrong the other way, reading
|
|
260
|
+
* text as a span the renderer will not, is the only direction that leaks.
|
|
261
|
+
*/
|
|
262
|
+
function prose(text, params = {}) {
|
|
263
|
+
const spans = spanReader(text);
|
|
264
|
+
const out = [];
|
|
265
|
+
let i = 0;
|
|
266
|
+
let atLineStart = true;
|
|
267
|
+
while (i < text.length) {
|
|
268
|
+
const ch = text[i];
|
|
269
|
+
if (ch === '`') {
|
|
270
|
+
const end = spans.spanEndAt(i);
|
|
271
|
+
out.push(substitute(text.slice(i, end), params, literal));
|
|
272
|
+
atLineStart = false;
|
|
273
|
+
i = end;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (ch === '{') {
|
|
277
|
+
const placeholder = /^\{\w+\}/.exec(text.slice(i));
|
|
278
|
+
if (placeholder !== null) {
|
|
279
|
+
out.push(substitute(placeholder[0], params, emphasised));
|
|
280
|
+
atLineStart = false;
|
|
281
|
+
i += placeholder[0].length;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (ch === '\n') {
|
|
286
|
+
out.push(ch);
|
|
287
|
+
atLineStart = true;
|
|
288
|
+
i += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (atLineStart) {
|
|
292
|
+
// Leading whitespace does not end the line's opening position: ` # x` is
|
|
293
|
+
// a heading, and four spaces make an indented code block where the escape
|
|
294
|
+
// is visible but harmless. Counting the indent to tell those apart would
|
|
295
|
+
// be a second Markdown reader for one cosmetic case.
|
|
296
|
+
if (ch === ' ' || ch === '\t') {
|
|
297
|
+
out.push(ch);
|
|
298
|
+
i += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
atLineStart = false;
|
|
302
|
+
// A heading opens a section; a line of `=` or `-` alone makes a heading
|
|
303
|
+
// out of the line *above* it, which is the same forgery approaching from
|
|
304
|
+
// behind. A backslash before either renders the character and nothing else.
|
|
305
|
+
if (ch === '#' || SETEXT.test(lineAt(text, i))) {
|
|
306
|
+
out.push('\\');
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
out.push(ch === '<' ? '<' : ch);
|
|
311
|
+
i += 1;
|
|
312
|
+
}
|
|
313
|
+
return out.join('');
|
|
314
|
+
}
|
|
315
|
+
/** A line that would turn the one above it into a heading. */
|
|
316
|
+
const SETEXT = /^(?:=+|-+)[ \t]*$/;
|
|
317
|
+
function lineAt(text, from) {
|
|
318
|
+
const end = text.indexOf('\n', from);
|
|
319
|
+
return end === -1 ? text.slice(from) : text.slice(from, end);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Where each code span in `text` ends, answered in constant time per question.
|
|
323
|
+
*
|
|
324
|
+
* The obvious implementation searches forward from the opening run for a run of
|
|
325
|
+
* the same length, and it is the ATX-59 defect in a second costume: a run that
|
|
326
|
+
* closes nothing scans to the end of the string, so prose whose runs are all of
|
|
327
|
+
* *different* lengths pays that for every one of them. Measured, before this:
|
|
328
|
+
* 500 KB of such a rationale took 1.6 s and 2 MB took 15 s — from `attest
|
|
329
|
+
* render` with no flag, on text the registry chooses.
|
|
330
|
+
*
|
|
331
|
+
* So the runs are read once and indexed by length, and each length keeps a
|
|
332
|
+
* cursor into its own list. Openers are visited left to right, so a cursor only
|
|
333
|
+
* ever moves forward and the total work is bounded by the number of runs. A
|
|
334
|
+
* shorter or longer run is not a closer and is never a candidate here, which is
|
|
335
|
+
* the CommonMark rule and also what stops a scanner believing in a span the
|
|
336
|
+
* renderer does not.
|
|
337
|
+
*/
|
|
338
|
+
function spanReader(text) {
|
|
339
|
+
const runs = [];
|
|
340
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
341
|
+
if (text[i] !== '`')
|
|
342
|
+
continue;
|
|
343
|
+
const start = i;
|
|
344
|
+
while (text[i] === '`')
|
|
345
|
+
i += 1;
|
|
346
|
+
runs.push({ at: start, length: i - start });
|
|
347
|
+
i -= 1;
|
|
348
|
+
}
|
|
349
|
+
const byLength = new Map();
|
|
350
|
+
runs.forEach((run, index) => {
|
|
351
|
+
const list = byLength.get(run.length);
|
|
352
|
+
if (list === undefined)
|
|
353
|
+
byLength.set(run.length, [index]);
|
|
354
|
+
else
|
|
355
|
+
list.push(index);
|
|
356
|
+
});
|
|
357
|
+
const cursors = new Map();
|
|
358
|
+
let nextRun = 0;
|
|
359
|
+
return {
|
|
360
|
+
/** The end of the span opening at `at`, or the end of that run if none. */
|
|
361
|
+
spanEndAt: (at) => {
|
|
362
|
+
while (runs[nextRun].at < at)
|
|
363
|
+
nextRun += 1;
|
|
364
|
+
const opener = runs[nextRun];
|
|
365
|
+
const candidates = byLength.get(opener.length);
|
|
366
|
+
let cursor = cursors.get(opener.length) ?? 0;
|
|
367
|
+
while (cursor < candidates.length && candidates[cursor] <= nextRun)
|
|
368
|
+
cursor += 1;
|
|
369
|
+
cursors.set(opener.length, cursor);
|
|
370
|
+
const closer = cursor < candidates.length ? runs[candidates[cursor]] : undefined;
|
|
371
|
+
return closer === undefined ? at + opener.length : closer.at + closer.length;
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
232
375
|
/**
|
|
233
|
-
* Substitute `{param}` placeholders
|
|
234
|
-
*
|
|
376
|
+
* Substitute `{param}` placeholders in one region with `render`, the spelling
|
|
377
|
+
* that region's position calls for.
|
|
378
|
+
*
|
|
235
379
|
* An unbound placeholder is left verbatim — `check` already reports it as an
|
|
236
380
|
* `unbound-param` error, and silently swallowing it here would hide that.
|
|
237
381
|
*/
|
|
238
|
-
function
|
|
382
|
+
function substitute(region, params, render) {
|
|
239
383
|
// `Object.hasOwn`, never `in`: `'toString' in {}` is true, and an `in` probe
|
|
240
384
|
// would splice `function toString() { [native code] }` into a document that
|
|
241
385
|
// reviewers and audit read as the system's promise.
|
|
242
|
-
return
|
|
386
|
+
return region.replace(/\{(\w+)\}/g, (whole, name) => Object.hasOwn(params, name) ? render(params[name]) : whole);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* A param value in prose position: escaped, and emphasised so a reader can see
|
|
390
|
+
* which words in the sentence are pinned to a single source.
|
|
391
|
+
*
|
|
392
|
+
* Escaped unlike the statement around it, because those are prose an author may
|
|
393
|
+
* want to mark up and a value is data that must survive verbatim — an unescaped
|
|
394
|
+
* `*` would otherwise break out of the emphasis it is wrapped in. `<` and `#`
|
|
395
|
+
* are in the class for the same reason the rest of it is: they are structure
|
|
396
|
+
* *here*. A value is spliced into a sentence, so a tag in one is a tag in the
|
|
397
|
+
* document, and a value carrying a newline puts what follows at the start of a
|
|
398
|
+
* line, where `#` opens a section (design §9.1).
|
|
399
|
+
*/
|
|
400
|
+
function emphasised(value) {
|
|
401
|
+
const one = (v) => v.replace(/([*_`[\]\\#])/g, '\\$1').replaceAll('<', '<');
|
|
402
|
+
return `**${listed(value, (v) => one(inlineText(v)))}**`;
|
|
243
403
|
}
|
|
244
404
|
/**
|
|
245
|
-
* A param value
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
405
|
+
* A param value inside the author's code span: the value, and nothing added.
|
|
406
|
+
*
|
|
407
|
+
* A code span's contents are literal in every renderer, so escaping there is
|
|
408
|
+
* not merely unnecessary, it is *visible* — Markdown does not decode `<`
|
|
409
|
+
* inside one, and this repository's own `ATX-30` renders a path with `<name>`
|
|
410
|
+
* in it. Emphasis is dropped for the same reason: `**` in a code span is two
|
|
411
|
+
* asterisks, which is what the document said before this knew where it was.
|
|
412
|
+
*
|
|
413
|
+
* The exception is a value that would end the span early — a backtick, or the
|
|
414
|
+
* blank line a code span cannot contain. There the author has asked for two
|
|
415
|
+
* incompatible things, and the prose escape is the safe answer rather than the
|
|
416
|
+
* pretty one: what follows the broken span is author text this pass has already
|
|
417
|
+
* walked past.
|
|
249
418
|
*/
|
|
250
|
-
function
|
|
251
|
-
const
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
419
|
+
function literal(value) {
|
|
420
|
+
const text = listed(value, inlineText);
|
|
421
|
+
return text.includes('`') || /\n[ \t]*\n/.test(text) ? emphasised(value) : text;
|
|
422
|
+
}
|
|
423
|
+
/** A list param reads as its members; a scalar reads as itself. */
|
|
424
|
+
function listed(value, one) {
|
|
425
|
+
return Array.isArray(value) ? value.map(one).join(', ') : one(value);
|
|
255
426
|
}
|
|
256
427
|
/**
|
|
257
428
|
* One param value as a run of text.
|
|
@@ -273,9 +444,7 @@ function inlineText(value) {
|
|
|
273
444
|
function formatValue(value) {
|
|
274
445
|
if (!isFlat(value))
|
|
275
446
|
return '_see below_';
|
|
276
|
-
return
|
|
277
|
-
? value.map((v) => code(inlineText(v))).join(', ')
|
|
278
|
-
: code(inlineText(value));
|
|
447
|
+
return listed(value, (v) => code(inlineText(v)));
|
|
279
448
|
}
|
|
280
449
|
/**
|
|
281
450
|
* A structured param as a fenced JSON block.
|
package/dist/core/skill.js
CHANGED
|
@@ -93,6 +93,15 @@ promises is a two-stage workflow, and the stages are separate on purpose.
|
|
|
93
93
|
through any edit. Pin the expectation to something that does not move with the
|
|
94
94
|
param — a fixture, a literal in the test, or a second independently derived
|
|
95
95
|
value.
|
|
96
|
+
- **A param the scenario loops over is the *domain* of the run, and shortening it
|
|
97
|
+
is silent.** Drop a member and you drop a case; every assertion inside the loop
|
|
98
|
+
still passes over what is left, with the expectation as independent as it ever
|
|
99
|
+
was — so the rule above does not reach this. Pin the extent beside the loop:
|
|
100
|
+
the members against a literal when their identity is the promise, the count
|
|
101
|
+
when the size is. That literal is **not** the hardcoded value stage 2's third
|
|
102
|
+
rule forbids, and the distinction is the whole of it — that rule is about the
|
|
103
|
+
**expectation** the system is measured against; this pin asserts what the
|
|
104
|
+
**intent** is.
|
|
96
105
|
|
|
97
106
|
## Rules the engine enforces
|
|
98
107
|
|
|
@@ -313,6 +322,16 @@ failure this framework exists to make visible:
|
|
|
313
322
|
is the drift the single source exists to prevent, and the param is typed at
|
|
314
323
|
the value written in the registry, so a stale expectation stops compiling
|
|
315
324
|
rather than silently passing.
|
|
325
|
+
*This forbids one literal and not the other, and the difference decides
|
|
326
|
+
whether a domain can be shortened.* Forbidden is a literal standing in for the
|
|
327
|
+
param **as the value the system is measured against** — that is the second
|
|
328
|
+
copy. Required, when a scenario **iterates** a list it read from \`params\`, is
|
|
329
|
+
a literal pinning that list or its length beside the loop: the list is then
|
|
330
|
+
the set of cases the run covers rather than a value under test, and dropping a
|
|
331
|
+
member removes a case while every assertion inside still passes. A literal is
|
|
332
|
+
the only independent term available when what is at risk is the **size** of
|
|
333
|
+
the set, so the two never collide — one asserts what the system does, the
|
|
334
|
+
other what the intent promised.
|
|
316
335
|
*One thing to know before implementation code reads a param this change
|
|
317
336
|
ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
|
|
318
337
|
the delta in memory — so that read throws at import and the gate answers
|
package/package.json
CHANGED