@am_shork/attest 0.7.2 → 0.7.4
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 +608 -166
- package/README.md +10 -1
- package/bin/attest.js +0 -0
- package/dist/cli/json.d.ts +3 -2
- package/dist/cli/json.js +3 -2
- package/dist/core/compiler.d.ts +32 -0
- package/dist/core/compiler.js +78 -0
- 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 +22 -2
- package/dist/core/locate.js +29 -4
- package/dist/core/parser.d.ts +12 -0
- package/dist/core/parser.js +18 -2
- package/dist/core/paths.d.ts +16 -0
- package/dist/core/paths.js +20 -1
- package/dist/core/pipeline.js +72 -9
- package/dist/core/render.js +190 -21
- package/dist/core/skill.js +19 -0
- package/dist/core/static-registry.d.ts +34 -2
- package/dist/core/static-registry.js +133 -8
- package/dist/core/validator.d.ts +9 -4
- package/dist/core/validator.js +74 -21
- package/package.json +17 -29
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
|
//
|
|
@@ -108,7 +108,7 @@ export async function runCheck(root, options = {}) {
|
|
|
108
108
|
// command that reads both the registry and every change's delta, so it is the
|
|
109
109
|
// only one where that distinction is worth anything.
|
|
110
110
|
return withLoader(options, async (loader) => {
|
|
111
|
-
const { registry, issues,
|
|
111
|
+
const { registry, issues, unreadable } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
112
|
const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
|
|
113
113
|
return [
|
|
114
114
|
...issues,
|
|
@@ -121,7 +121,7 @@ export async function runCheck(root, options = {}) {
|
|
|
121
121
|
// that *did* load are all still true. What it must not do is advise work
|
|
122
122
|
// that the load failure makes wrong — see `orphan-test` in
|
|
123
123
|
// `validateStructure`.
|
|
124
|
-
...validateStructure(registry, plan,
|
|
124
|
+
...validateStructure(registry, plan, unreadable),
|
|
125
125
|
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
126
126
|
...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
|
|
127
127
|
...(await changeDirSpecIssues(root)),
|
|
@@ -291,13 +291,13 @@ export async function runVerify(root, options = {}) {
|
|
|
291
291
|
const loader = await createLoader();
|
|
292
292
|
let registry;
|
|
293
293
|
let plan;
|
|
294
|
-
let
|
|
294
|
+
let unreadable;
|
|
295
295
|
const issues = [];
|
|
296
296
|
try {
|
|
297
297
|
const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
298
298
|
registry = loaded.registry;
|
|
299
299
|
issues.push(...loaded.issues);
|
|
300
|
-
|
|
300
|
+
unreadable = loaded.unreadable;
|
|
301
301
|
const parsedSpecs = await parseSpecs(scan.specFiles, root);
|
|
302
302
|
plan = parsedSpecs.plan;
|
|
303
303
|
issues.push(...parsedSpecs.issues);
|
|
@@ -307,7 +307,7 @@ export async function runVerify(root, options = {}) {
|
|
|
307
307
|
}
|
|
308
308
|
// Same as `check`: everything the loaded half supports is still reported, and
|
|
309
309
|
// only the advice that a load failure would make wrong is withdrawn.
|
|
310
|
-
issues.push(...validateStructure(registry, plan,
|
|
310
|
+
issues.push(...validateStructure(registry, plan, unreadable));
|
|
311
311
|
issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
|
|
312
312
|
const attesting = attestingFiles(plan);
|
|
313
313
|
const counts = {
|
|
@@ -450,15 +450,78 @@ export async function runInit(root, names) {
|
|
|
450
450
|
const { targets, issues } = resolveTargets(names.length > 0 ? names : [DEFAULT_TARGET]);
|
|
451
451
|
if (issues.length > 0)
|
|
452
452
|
return { files: [], issues };
|
|
453
|
-
|
|
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 = [];
|
|
454
464
|
for (const target of targets) {
|
|
455
|
-
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) {
|
|
456
482
|
await mkdir(dirname(dest), { recursive: true });
|
|
457
483
|
await writeAtomic(dest, target.content());
|
|
458
484
|
files.push(target.file);
|
|
459
485
|
}
|
|
460
486
|
return { files, issues };
|
|
461
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
|
+
}
|
|
462
525
|
/**
|
|
463
526
|
* A change name must name one directory inside `changes/`, and nothing else.
|
|
464
527
|
*
|
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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RegistryDelta } from './registry.js';
|
|
2
2
|
import type { Registry } from './types.js';
|
|
3
3
|
/** Codes this reader can produce; each is also produced by the evaluating path. */
|
|
4
|
-
export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static';
|
|
4
|
+
export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static' | 'unreadable-file';
|
|
5
5
|
export type StaticReadResult = {
|
|
6
6
|
ok: true;
|
|
7
7
|
registry: Registry;
|
|
@@ -13,7 +13,7 @@ export type StaticReadResult = {
|
|
|
13
13
|
};
|
|
14
14
|
export declare function readRegistrySource(file: string, source: string): StaticReadResult;
|
|
15
15
|
/** Codes the delta reader can produce; both are codes `archive` already emits. */
|
|
16
|
-
export type DeltaReadCode = 'change-not-found' | 'registry-not-static';
|
|
16
|
+
export type DeltaReadCode = 'change-not-found' | 'registry-not-static' | 'unreadable-file';
|
|
17
17
|
export type DeltaReadResult = {
|
|
18
18
|
ok: true;
|
|
19
19
|
delta: RegistryDelta;
|
|
@@ -39,6 +39,38 @@ export type DeltaReadResult = {
|
|
|
39
39
|
* (write the value inline, or `--eval`) is the same sentence either way.
|
|
40
40
|
*/
|
|
41
41
|
export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
|
|
42
|
+
/**
|
|
43
|
+
* The requirement ids a source *declares*, read from a file that could not be
|
|
44
|
+
* read as a registry (design §5.3).
|
|
45
|
+
*
|
|
46
|
+
* This is not a second registry reader and cannot become one: it returns ids and
|
|
47
|
+
* nothing else, it validates none of them, and no command builds a `Registry`
|
|
48
|
+
* from what it finds. What it exists for is the question `orphan-test` cannot
|
|
49
|
+
* otherwise answer — is this scenario attesting an id that a *broken* file
|
|
50
|
+
* declares, or one that genuinely does not exist — and the answer decides
|
|
51
|
+
* whether a finding is a fact or fallout.
|
|
52
|
+
*
|
|
53
|
+
* It reads the ids rather than the file's id *prefix*, which is what this was
|
|
54
|
+
* first framed as needing. The prefix cannot be recovered from a path: this
|
|
55
|
+
* repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
|
|
56
|
+
* evidence that killed the prefix-matches-filename rule (see CHANGELOG,
|
|
57
|
+
* `Considered and rejected`). The ids are in the source, so nothing has to be
|
|
58
|
+
* inferred from a naming convention that nothing enforces.
|
|
59
|
+
*
|
|
60
|
+
* Recall is partial by construction, and the direction of the miss is the point:
|
|
61
|
+
* an id it does not find keeps its `orphan-test`, so the report stays noisy —
|
|
62
|
+
* never wrong. Measured over the failure modes that produce an unreadable
|
|
63
|
+
* registry: a truncated file, a missing default export, a non-literal *value*, a
|
|
64
|
+
* schema-invalid entry, a module that throws at import, and a spread of ids from
|
|
65
|
+
* another module all yield the ids written in this file; only a registry built
|
|
66
|
+
* by a call — `export default buildReqs()` — yields none, and that file contains
|
|
67
|
+
* no id to find.
|
|
68
|
+
*
|
|
69
|
+
* `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
|
|
70
|
+
* first of those cases work at all: the parser recovers an object literal from a
|
|
71
|
+
* file that does not compile.
|
|
72
|
+
*/
|
|
73
|
+
export declare function declaredIdsFromSource(file: string, source: string): string[];
|
|
42
74
|
/**
|
|
43
75
|
* Where a new entry may be written into a registry file's literal, as an offset
|
|
44
76
|
* into its source (design §7).
|