@am_shork/attest 0.8.0 → 0.9.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/CHANGELOG.md +715 -53
- package/README.md +3 -2
- package/dist/cli/report.js +9 -1
- package/dist/core/apply.d.ts +18 -1
- package/dist/core/apply.js +19 -2
- package/dist/core/locate.d.ts +19 -4
- package/dist/core/locate.js +86 -36
- package/dist/core/merge.js +218 -72
- package/dist/core/pipeline.js +45 -23
- package/dist/core/skill.js +88 -27
- package/dist/core/splice.d.ts +62 -3
- package/dist/core/splice.js +252 -13
- package/dist/core/static-registry.d.ts +42 -0
- package/dist/core/static-registry.js +131 -17
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +1 -1
package/dist/core/splice.js
CHANGED
|
@@ -14,6 +14,19 @@
|
|
|
14
14
|
// and the only edit made to the file is text inserted at it. Every other byte is
|
|
15
15
|
// the byte that was already there, which is a property a test can state.
|
|
16
16
|
//
|
|
17
|
+
// **MODIFIED is the second shape, and it is not an insertion — so it is bounded
|
|
18
|
+
// instead.** `spliceModifications` writes a changed value over the value that was
|
|
19
|
+
// there, and the clause above does not cover it: a re-read verifies the values
|
|
20
|
+
// and is blind to what was *lost*, so a replacement that ate a comment would pass
|
|
21
|
+
// one cleanly. What stands in for the pure-insertion property is the **size of
|
|
22
|
+
// the span**. Nothing here replaces an entry; it replaces the source span of one
|
|
23
|
+
// value inside an entry, which is why `statement` and `rationale` are free —
|
|
24
|
+
// their span is a single string literal and a comment does not fit inside a
|
|
25
|
+
// token — and why a `params` key or an `outOfScope` list is checked for one
|
|
26
|
+
// before it is written over, and refused if it carries it. That is the whole of
|
|
27
|
+
// the difference from REMOVED, which is still refused: deleting an entry has no
|
|
28
|
+
// smaller span to fall back to, so its comments have nowhere to go.
|
|
29
|
+
//
|
|
17
30
|
// The generated text is therefore the one thing here that has to be right on its
|
|
18
31
|
// own, and it is generated conservatively: strings are escaped rather than
|
|
19
32
|
// interpolated, params are emitted in code-unit key order so the same delta
|
|
@@ -21,7 +34,7 @@
|
|
|
21
34
|
// reach this file because the gate validated the delta before `--apply` ran.
|
|
22
35
|
import ts from 'typescript';
|
|
23
36
|
import { dirname, relative, resolve } from 'node:path';
|
|
24
|
-
import { registryInsertionPoint } from './static-registry.js';
|
|
37
|
+
import { registryEntryLayouts, registryInsertionPoint } from './static-registry.js';
|
|
25
38
|
import { toPosixPath } from './paths.js';
|
|
26
39
|
import { byCodeUnit, sortDeep } from './order.js';
|
|
27
40
|
/**
|
|
@@ -155,15 +168,23 @@ export function requirementSource(id, req, indent) {
|
|
|
155
168
|
// are as much of the emitted text as the outer ones.
|
|
156
169
|
const params = Object.entries(sortDeep(req.params));
|
|
157
170
|
if (params.length > 0) {
|
|
158
|
-
const body = params.map(([k, v]) =>
|
|
171
|
+
const body = params.map(([k, v]) => paramEntrySource(k, v)).join(', ');
|
|
159
172
|
lines.push(`${inner}params: { ${body} },`);
|
|
160
173
|
}
|
|
161
174
|
if (req.outOfScope.length > 0) {
|
|
162
|
-
lines.push(`${inner}outOfScope:
|
|
175
|
+
lines.push(`${inner}outOfScope: ${outOfScopeSource(req.outOfScope)},`);
|
|
163
176
|
}
|
|
164
177
|
lines.push(`${indent}}`);
|
|
165
178
|
return lines.join('\n');
|
|
166
179
|
}
|
|
180
|
+
/** One `outOfScope` list as source. Shared with the modification writer below. */
|
|
181
|
+
function outOfScopeSource(entries) {
|
|
182
|
+
return `[${entries.map((s) => tsString(s)).join(', ')}]`;
|
|
183
|
+
}
|
|
184
|
+
/** One `params` key as source, at the key order the emitter writes everywhere. */
|
|
185
|
+
function paramEntrySource(key, value) {
|
|
186
|
+
return `${keySource(key)}: ${paramSource(sortDeep(value))}`;
|
|
187
|
+
}
|
|
167
188
|
/**
|
|
168
189
|
* `source` with `additions` inserted into its registry literal.
|
|
169
190
|
*
|
|
@@ -187,6 +208,215 @@ export function spliceRequirements(file, source, additions) {
|
|
|
187
208
|
const text = point.leadingComma ? `,\n${entries}` : `\n${entries},\n`;
|
|
188
209
|
return source.slice(0, point.offset) + text + source.slice(point.offset);
|
|
189
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* `source` with each modified requirement's **changed values** written over.
|
|
213
|
+
*
|
|
214
|
+
* The other half of `--apply`'s write-back, and the one the design refused for
|
|
215
|
+
* five releases. What makes it allowable is stated at the top of this file and
|
|
216
|
+
* is a property of *granularity*: this never replaces an entry, only the span of
|
|
217
|
+
* a value inside one. A `statement` is a single string literal, so a comment
|
|
218
|
+
* cannot be inside what it overwrites; a `params` key or an `outOfScope` list
|
|
219
|
+
* can be, and that span is the only place this has to ask. Everything else in
|
|
220
|
+
* the file — the keys, the commas, the layout, every comment attached to a field
|
|
221
|
+
* that did not move — is bytes this does not address.
|
|
222
|
+
*
|
|
223
|
+
* A refusal is per value and the caller's response is whole-merge: the list is
|
|
224
|
+
* returned complete rather than at the first one, so an author fixing them by
|
|
225
|
+
* hand sees the whole of what stopped it.
|
|
226
|
+
*
|
|
227
|
+
* `undefined` under the same condition `spliceRequirements` returns it, through
|
|
228
|
+
* the same reader.
|
|
229
|
+
*/
|
|
230
|
+
export function spliceModifications(file, source, changes) {
|
|
231
|
+
if (changes.length === 0)
|
|
232
|
+
return { ok: true, source };
|
|
233
|
+
const layouts = registryEntryLayouts(file, source);
|
|
234
|
+
if (!layouts)
|
|
235
|
+
return undefined;
|
|
236
|
+
const refusals = [];
|
|
237
|
+
const edits = [];
|
|
238
|
+
const wanted = new Map(changes.map((change) => [change.id, change]));
|
|
239
|
+
// Ids the file does not hold, first and in code-unit order: they have no
|
|
240
|
+
// position to be reported at, and the walk below is driven by positions.
|
|
241
|
+
for (const id of [...wanted.keys()].sort(byCodeUnit)) {
|
|
242
|
+
if (!layouts[id])
|
|
243
|
+
refusals.push({ reqId: id, reason: 'entry-not-found' });
|
|
244
|
+
}
|
|
245
|
+
// **The file front to back, not the delta.** Driving the walk from the layout
|
|
246
|
+
// rather than from `changes` is what makes every edit below land at a higher
|
|
247
|
+
// offset than the one before it — entries in the order the file writes them,
|
|
248
|
+
// fields in the order the entry writes them, params in the order the entry
|
|
249
|
+
// writes those. That ordering is not cosmetic: it is what lets the pass at the
|
|
250
|
+
// bottom be a `reverse()` rather than a numeric sort, which ATX-15 does not
|
|
251
|
+
// allow in `src/` and would be the wrong shape anyway — an ordering that holds
|
|
252
|
+
// by construction beats one restored after the fact.
|
|
253
|
+
for (const [id, layout] of Object.entries(layouts)) {
|
|
254
|
+
const change = wanted.get(id);
|
|
255
|
+
if (!change)
|
|
256
|
+
continue;
|
|
257
|
+
const { before, after } = change;
|
|
258
|
+
// Everything this entry writes goes through one of these three, which is
|
|
259
|
+
// what lets `editParams` be a function rather than a fourth copy of them.
|
|
260
|
+
const out = {
|
|
261
|
+
at: (field, span, text) => {
|
|
262
|
+
// The one question a replacement has that an insertion does not. Run over
|
|
263
|
+
// every span rather than only the two that can fail it, so the rule is
|
|
264
|
+
// "nothing is written over a comment" rather than a list of fields somebody
|
|
265
|
+
// has to keep in step with the schema — on `statement` and `rationale` it
|
|
266
|
+
// is a scan of one token and can never fire.
|
|
267
|
+
if (spanHasComment(source.slice(span.start, span.end))) {
|
|
268
|
+
refusals.push({ reqId: id, field, reason: 'comment' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
edits.push({ start: span.start, end: span.end, text });
|
|
272
|
+
},
|
|
273
|
+
insert: (offset, text) => edits.push({ start: offset, end: offset, text }),
|
|
274
|
+
refuse: (field, reason) => refusals.push({ reqId: id, field, reason }),
|
|
275
|
+
};
|
|
276
|
+
// Fields the entry does not write yet. Collected rather than emitted, since
|
|
277
|
+
// they share one offset — the end of the entry's body — and several edits at
|
|
278
|
+
// one position is the one thing the back-to-front pass cannot order.
|
|
279
|
+
const opened = [];
|
|
280
|
+
const openField = (name, value) => {
|
|
281
|
+
opened.push(`${layout.fieldInsertion.indent}${name}: ${value}`);
|
|
282
|
+
};
|
|
283
|
+
for (const [name, span] of Object.entries(layout.fields)) {
|
|
284
|
+
if (name === 'statement' || name === 'rationale') {
|
|
285
|
+
if (before[name] !== after[name])
|
|
286
|
+
out.at(name, span, tsString(after[name]));
|
|
287
|
+
}
|
|
288
|
+
else if (name === 'outOfScope') {
|
|
289
|
+
if (!sameValue(before.outOfScope, after.outOfScope)) {
|
|
290
|
+
out.at(name, span, outOfScopeSource(after.outOfScope));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else if (name === 'params') {
|
|
294
|
+
editParams(layout, before, after, out);
|
|
295
|
+
}
|
|
296
|
+
// Any other field is one the schema does not define, and not this
|
|
297
|
+
// module's to rewrite or to remove.
|
|
298
|
+
}
|
|
299
|
+
if (!layout.fields['statement'] && before.statement !== after.statement) {
|
|
300
|
+
openField('statement', tsString(after.statement));
|
|
301
|
+
}
|
|
302
|
+
if (!layout.fields['rationale'] && before.rationale !== after.rationale) {
|
|
303
|
+
openField('rationale', tsString(after.rationale));
|
|
304
|
+
}
|
|
305
|
+
if (!layout.fields['params']) {
|
|
306
|
+
const fresh = freshParams(before, after);
|
|
307
|
+
if (fresh.length > 0)
|
|
308
|
+
openField('params', `{ ${fresh.join(', ')} }`);
|
|
309
|
+
}
|
|
310
|
+
if (!layout.fields['outOfScope'] && !sameValue(before.outOfScope, after.outOfScope)) {
|
|
311
|
+
openField('outOfScope', outOfScopeSource(after.outOfScope));
|
|
312
|
+
}
|
|
313
|
+
if (opened.length > 0) {
|
|
314
|
+
const point = layout.fieldInsertion;
|
|
315
|
+
const body = opened.join(',\n');
|
|
316
|
+
// The comma leads rather than trails, for the reason the registry splice's
|
|
317
|
+
// does: the offset sits *before* whatever trailing comma the entry already
|
|
318
|
+
// carries, so writing one here would produce two.
|
|
319
|
+
edits.push({
|
|
320
|
+
start: point.offset,
|
|
321
|
+
end: point.offset,
|
|
322
|
+
text: point.leadingComma ? `,\n${body}` : `\n${body},\n`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (refusals.length > 0)
|
|
327
|
+
return { ok: false, refusals };
|
|
328
|
+
// Back to front, for the reason `repointImport` applies its edits that way: an
|
|
329
|
+
// edit cannot move an offset that lies before it, so every span stays valid as
|
|
330
|
+
// the string is rewritten under them. A `reverse()` and not a sort, because
|
|
331
|
+
// the walk above produced them in source order.
|
|
332
|
+
let out = source;
|
|
333
|
+
for (const edit of [...edits].reverse()) {
|
|
334
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
335
|
+
}
|
|
336
|
+
return { ok: true, source: out };
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* The `params` half of one entry: a value per changed key, and one insertion for
|
|
340
|
+
* the keys the entry does not have.
|
|
341
|
+
*
|
|
342
|
+
* Split out because it is the only field with structure of its own, and inlining
|
|
343
|
+
* it put three loops inside a loop inside a loop. Its edits are emitted in the
|
|
344
|
+
* order the file writes the keys, then the insertion at the end of the object —
|
|
345
|
+
* which is what keeps the caller's ascending-offset property true through it.
|
|
346
|
+
*/
|
|
347
|
+
function editParams(layout, before, after, out) {
|
|
348
|
+
// A key the end state has lost. Unreachable through the command — `applyDelta`
|
|
349
|
+
// merges `params` key by key and a patch has no spelling for a deletion — and
|
|
350
|
+
// refused rather than ignored, for the reason `keySource` refuses `__proto__`:
|
|
351
|
+
// this is the module that *writes*, and silently keeping a value the proved
|
|
352
|
+
// end state does not have is the one outcome worse than stopping.
|
|
353
|
+
for (const key of Object.keys(before.params).sort(byCodeUnit)) {
|
|
354
|
+
if (!Object.hasOwn(after.params, key)) {
|
|
355
|
+
out.refuse(`params.${key}`, 'param-dropped');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// A key the file holds and this cannot locate: its value is not written as a
|
|
359
|
+
// literal, which the registry reader refuses before a merge can reach here.
|
|
360
|
+
// Named rather than skipped, so the guard does not depend on something
|
|
361
|
+
// upstream holding.
|
|
362
|
+
for (const key of Object.keys(before.params).sort(byCodeUnit)) {
|
|
363
|
+
if (!layout.paramKeys[key] && !sameValue(before.params[key], after.params[key])) {
|
|
364
|
+
out.refuse(`params.${key}`, 'not-a-literal');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for (const [key, span] of Object.entries(layout.paramKeys)) {
|
|
368
|
+
if (!Object.hasOwn(after.params, key))
|
|
369
|
+
continue;
|
|
370
|
+
const value = after.params[key];
|
|
371
|
+
if (sameValue(before.params[key], value))
|
|
372
|
+
continue;
|
|
373
|
+
// The value alone: the span is the value's, and the key in front of it is a
|
|
374
|
+
// byte this does not address — the same rule that keeps the comma, the
|
|
375
|
+
// comment and the layout where they are.
|
|
376
|
+
out.at(`params.${key}`, span, paramSource(sortDeep(value)));
|
|
377
|
+
}
|
|
378
|
+
const fresh = freshParams(before, after);
|
|
379
|
+
if (fresh.length === 0)
|
|
380
|
+
return;
|
|
381
|
+
if (!layout.paramsInsertion) {
|
|
382
|
+
// A `params` that is not an object literal, so there is no inside to write
|
|
383
|
+
// into. Same guard as the keys above, one level out.
|
|
384
|
+
out.refuse('params', 'not-a-literal');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const point = layout.paramsInsertion;
|
|
388
|
+
out.insert(point.offset, point.leadingComma ? `, ${fresh.join(', ')}` : ` ${fresh.join(', ')} `);
|
|
389
|
+
}
|
|
390
|
+
/** The params the end state has and the file does not, as source, in key order. */
|
|
391
|
+
function freshParams(before, after) {
|
|
392
|
+
return Object.keys(after.params)
|
|
393
|
+
.sort(byCodeUnit)
|
|
394
|
+
.filter((key) => !Object.hasOwn(before.params, key))
|
|
395
|
+
.map((key) => paramEntrySource(key, after.params[key]));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Whether a span of source carries a comment.
|
|
399
|
+
*
|
|
400
|
+
* Scanned rather than matched, and that is the whole of why it is correct: `//`
|
|
401
|
+
* inside a string is not a comment, and a registry's statements are prose full
|
|
402
|
+
* of URLs. The scanner is the same one the reader's parser is built on, so the
|
|
403
|
+
* two agree about what a comment is by construction.
|
|
404
|
+
*/
|
|
405
|
+
function spanHasComment(text) {
|
|
406
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ false);
|
|
407
|
+
scanner.setText(text);
|
|
408
|
+
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
409
|
+
if (token === ts.SyntaxKind.SingleLineCommentTrivia ||
|
|
410
|
+
token === ts.SyntaxKind.MultiLineCommentTrivia) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
/** Content equality for one value, by the canonical form the engine compares on. */
|
|
417
|
+
function sameValue(a, b) {
|
|
418
|
+
return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b));
|
|
419
|
+
}
|
|
190
420
|
/**
|
|
191
421
|
* `source` with every import of `from` repointed at `to`.
|
|
192
422
|
*
|
|
@@ -196,9 +426,10 @@ export function spliceRequirements(file, source, additions) {
|
|
|
196
426
|
* discipline as the splice, for the same reason.
|
|
197
427
|
*
|
|
198
428
|
* The extension is taken from the specifier being replaced rather than chosen
|
|
199
|
-
* here. Whether a project writes `./x.reqs.js` or `./x.reqs
|
|
200
|
-
* its module resolution, uniform across the project, and already
|
|
201
|
-
* specifier sitting in front of us
|
|
429
|
+
* here. Whether a project writes `./x.reqs.js`, `./x.reqs.ts` or `./x.reqs` is a
|
|
430
|
+
* property of its module resolution, uniform across the project, and already
|
|
431
|
+
* answered by the specifier sitting in front of us — the third spelling
|
|
432
|
+
* included, where the answer is "none".
|
|
202
433
|
*/
|
|
203
434
|
export function repointImport(file, source, from, to) {
|
|
204
435
|
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
@@ -212,10 +443,13 @@ export function repointImport(file, source, from, to) {
|
|
|
212
443
|
continue;
|
|
213
444
|
if (!resolvesTo(dir, spec.text, from))
|
|
214
445
|
continue;
|
|
446
|
+
// Unconditional, because "no extension" is an answer and not a missing one:
|
|
447
|
+
// a bundler-resolution project writes every specifier that way, so the empty
|
|
448
|
+
// case has to strip what `to` carries rather than leave it. Guarding this on
|
|
449
|
+
// a non-empty `ext` is only safe while `resolvesTo` rejects an extensionless
|
|
450
|
+
// specifier — the two move together.
|
|
215
451
|
const ext = spec.text.endsWith('.js') ? '.js' : spec.text.endsWith('.ts') ? '.ts' : '';
|
|
216
|
-
let target = toPosixPath(relative(dir, to));
|
|
217
|
-
if (ext)
|
|
218
|
-
target = target.replace(/\.[^./]+$/, ext);
|
|
452
|
+
let target = toPosixPath(relative(dir, to)).replace(/\.[^./]+$/, ext);
|
|
219
453
|
// A bare `x.reqs.js` is a package specifier, not a sibling file.
|
|
220
454
|
if (!target.startsWith('.'))
|
|
221
455
|
target = `./${target}`;
|
|
@@ -248,14 +482,19 @@ export function repointImport(file, source, from, to) {
|
|
|
248
482
|
/**
|
|
249
483
|
* Whether `specifier`, written in a file under `dir`, names `target`.
|
|
250
484
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
485
|
+
* Three spellings of one import, because module resolution decides which a
|
|
486
|
+
* project writes and it writes that one everywhere — including here. The
|
|
487
|
+
* `.js`-for-`.ts` spelling is what NodeNext requires; the extensionless one is
|
|
488
|
+
* what bundler resolution produces, and matching it is the whole reason a
|
|
489
|
+
* specifier can be repointed at all in such a project.
|
|
254
490
|
*/
|
|
255
491
|
function resolvesTo(dir, specifier, target) {
|
|
256
492
|
if (!specifier.startsWith('.'))
|
|
257
493
|
return false;
|
|
258
494
|
const resolved = resolve(dir, specifier);
|
|
259
|
-
|
|
495
|
+
// Only `.ts` is appended, not `.js` as well: `target` is the delta, whose name
|
|
496
|
+
// is the constant `requirements.delta.ts`, so the symmetrical clause would be
|
|
497
|
+
// unreachable — a hypothetical seam rather than a real one.
|
|
498
|
+
return (resolved === target || resolved.replace(/\.js$/, '.ts') === target || `${resolved}.ts` === target);
|
|
260
499
|
}
|
|
261
500
|
//# sourceMappingURL=splice.js.map
|
|
@@ -101,4 +101,46 @@ export interface RegistryInsertion {
|
|
|
101
101
|
leadingComma: boolean;
|
|
102
102
|
}
|
|
103
103
|
export declare function registryInsertionPoint(file: string, source: string): RegistryInsertion | undefined;
|
|
104
|
+
/** The source span of a value, as offsets into the file it was read from. */
|
|
105
|
+
export interface ValueSpan {
|
|
106
|
+
start: number;
|
|
107
|
+
end: number;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Where each part of one requirement's entry sits in the file, for a merge that
|
|
111
|
+
* has to **replace** a value rather than add one (design §7).
|
|
112
|
+
*
|
|
113
|
+
* This is the reading half of writing MODIFIED back. A splice is a pure
|
|
114
|
+
* insertion and is safe by construction; a modification is not, and the property
|
|
115
|
+
* that replaces "every other byte is the byte that was there" is the *size of
|
|
116
|
+
* what it overwrites*. So nothing here describes an entry — it describes the
|
|
117
|
+
* spans of its individual values, and the writer replaces one value at a time.
|
|
118
|
+
* Note what that buys at the top of `splice.ts`: a `statement` is one string
|
|
119
|
+
* literal, and a comment does not fit inside a token.
|
|
120
|
+
*
|
|
121
|
+
* The spans are of the values, never of the properties that hold them: the key,
|
|
122
|
+
* the colon, the comma and every comment around them are outside every span
|
|
123
|
+
* this hands back, so they cannot be written over by anything using it.
|
|
124
|
+
*/
|
|
125
|
+
export interface RegistryEntryLayout {
|
|
126
|
+
/** Value span of each field the entry writes, by field name. */
|
|
127
|
+
fields: Record<string, ValueSpan>;
|
|
128
|
+
/** Value span of each key of `params`, when the entry writes one as a literal. */
|
|
129
|
+
paramKeys: Record<string, ValueSpan>;
|
|
130
|
+
/** Where a new `params` key goes — absent when the entry writes no `params`. */
|
|
131
|
+
paramsInsertion?: RegistryInsertion;
|
|
132
|
+
/** Where a new field goes, inside the entry's own body. */
|
|
133
|
+
fieldInsertion: RegistryInsertion;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The layout of every entry in a registry file, by requirement id.
|
|
137
|
+
*
|
|
138
|
+
* `undefined` under exactly the condition `registryInsertionPoint` returns it —
|
|
139
|
+
* one walker answers "where is this registry's literal" for both, so a file the
|
|
140
|
+
* splice refuses cannot be one the modification accepts. Entries whose *shape*
|
|
141
|
+
* this cannot describe (a computed key, a value built by a call) are absent from
|
|
142
|
+
* the result rather than partially described, which the caller reads as a
|
|
143
|
+
* refusal for that id: an entry nothing can locate is not one to edit.
|
|
144
|
+
*/
|
|
145
|
+
export declare function registryEntryLayouts(file: string, source: string): Record<string, RegistryEntryLayout> | undefined;
|
|
104
146
|
//# sourceMappingURL=static-registry.d.ts.map
|
|
@@ -425,12 +425,32 @@ function objectValue(expr) {
|
|
|
425
425
|
return out;
|
|
426
426
|
}
|
|
427
427
|
function propertyName(name) {
|
|
428
|
+
const key = staticName(name);
|
|
429
|
+
if (key !== undefined)
|
|
430
|
+
return key;
|
|
431
|
+
// The reason is re-derived here rather than carried out of `staticName`,
|
|
432
|
+
// which has no diagnostics to give: the extraction refuses a whole file over
|
|
433
|
+
// this, so the sentence has to name which of the two it met.
|
|
434
|
+
throw new NotStatic(name, ts.isNumericLiteral(name) ? 'a number this reader cannot spell out' : 'a computed key');
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* A property's key when the source text alone fixes it, `undefined` when it does
|
|
438
|
+
* not.
|
|
439
|
+
*
|
|
440
|
+
* The total half of `propertyName`, which is the form the layout reader needs:
|
|
441
|
+
* that one promises a result for a whole file and cannot throw over one entry it
|
|
442
|
+
* merely fails to describe. One function for both so a key the extractor reads
|
|
443
|
+
* and a key the writer locates can never be two different strings.
|
|
444
|
+
*/
|
|
445
|
+
function staticName(name) {
|
|
428
446
|
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
|
|
429
447
|
return name.text;
|
|
430
448
|
}
|
|
431
|
-
if (ts.isNumericLiteral(name))
|
|
432
|
-
|
|
433
|
-
|
|
449
|
+
if (ts.isNumericLiteral(name)) {
|
|
450
|
+
const value = numericText(name);
|
|
451
|
+
return value === undefined ? undefined : String(value);
|
|
452
|
+
}
|
|
453
|
+
return undefined;
|
|
434
454
|
}
|
|
435
455
|
/**
|
|
436
456
|
* The value of a numeric literal, read from its source spelling.
|
|
@@ -441,12 +461,117 @@ function propertyName(name) {
|
|
|
441
461
|
* first, since `Number('1_000')` is NaN.
|
|
442
462
|
*/
|
|
443
463
|
function numericValue(node) {
|
|
444
|
-
const value =
|
|
445
|
-
if (
|
|
464
|
+
const value = numericText(node);
|
|
465
|
+
if (value === undefined)
|
|
446
466
|
throw new NotStatic(node, 'a number this reader cannot spell out');
|
|
447
467
|
return value;
|
|
448
468
|
}
|
|
469
|
+
/** The same reading, without the refusal — see `staticName`. */
|
|
470
|
+
function numericText(node) {
|
|
471
|
+
const value = Number(node.getText().replace(/_/g, ''));
|
|
472
|
+
return Number.isNaN(value) ? undefined : value;
|
|
473
|
+
}
|
|
449
474
|
export function registryInsertionPoint(file, source) {
|
|
475
|
+
const found = registryLiteral(file, source);
|
|
476
|
+
return found && objectInsertion(found.sf, source, found.literal);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Where a new member goes in an object literal.
|
|
480
|
+
*
|
|
481
|
+
* Three sites ask, at three depths — the registry's own body, a requirement's
|
|
482
|
+
* body, and its `params` — and the answer is the same question each time. One
|
|
483
|
+
* function because the interesting half is a rule about *commas*, and a second
|
|
484
|
+
* copy of it is a second trailing-comma policy the moment one is edited.
|
|
485
|
+
*/
|
|
486
|
+
function objectInsertion(sf, source, obj) {
|
|
487
|
+
const last = obj.properties[obj.properties.length - 1];
|
|
488
|
+
if (!last) {
|
|
489
|
+
// An empty body: open it rather than continue it. What separates the new
|
|
490
|
+
// member from the braces is the caller's, since a registry opens a line for
|
|
491
|
+
// its entry and a `params` writes one on the line it is already on.
|
|
492
|
+
return {
|
|
493
|
+
offset: obj.getStart(sf) + 1,
|
|
494
|
+
indent: indentOf(sf, source, obj) + ' ',
|
|
495
|
+
leadingComma: false,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
// Deliberately the *end of the last property*, not the end of the literal:
|
|
499
|
+
// inserting here sits before any trailing comma the file already has, so one
|
|
500
|
+
// leading comma is correct whether or not that comma is present, and the
|
|
501
|
+
// file's own trailing-comma style is left exactly as it was.
|
|
502
|
+
return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The layout of every entry in a registry file, by requirement id.
|
|
506
|
+
*
|
|
507
|
+
* `undefined` under exactly the condition `registryInsertionPoint` returns it —
|
|
508
|
+
* one walker answers "where is this registry's literal" for both, so a file the
|
|
509
|
+
* splice refuses cannot be one the modification accepts. Entries whose *shape*
|
|
510
|
+
* this cannot describe (a computed key, a value built by a call) are absent from
|
|
511
|
+
* the result rather than partially described, which the caller reads as a
|
|
512
|
+
* refusal for that id: an entry nothing can locate is not one to edit.
|
|
513
|
+
*/
|
|
514
|
+
export function registryEntryLayouts(file, source) {
|
|
515
|
+
const found = registryLiteral(file, source);
|
|
516
|
+
if (!found)
|
|
517
|
+
return undefined;
|
|
518
|
+
const { sf, literal } = found;
|
|
519
|
+
const layouts = {};
|
|
520
|
+
for (const entry of literal.properties) {
|
|
521
|
+
if (!ts.isPropertyAssignment(entry))
|
|
522
|
+
continue;
|
|
523
|
+
const id = staticName(entry.name);
|
|
524
|
+
if (id === undefined || id === '__proto__')
|
|
525
|
+
continue;
|
|
526
|
+
const body = unwrap(entry.initializer);
|
|
527
|
+
if (!ts.isObjectLiteralExpression(body))
|
|
528
|
+
continue;
|
|
529
|
+
const fields = {};
|
|
530
|
+
const paramKeys = {};
|
|
531
|
+
let params;
|
|
532
|
+
for (const field of body.properties) {
|
|
533
|
+
if (!ts.isPropertyAssignment(field))
|
|
534
|
+
continue;
|
|
535
|
+
const name = staticName(field.name);
|
|
536
|
+
if (name === undefined || name === '__proto__')
|
|
537
|
+
continue;
|
|
538
|
+
// The unwrapped value, so an `as const` or a parenthesis stays outside the
|
|
539
|
+
// span and survives the replacement it wraps.
|
|
540
|
+
const value = unwrap(field.initializer);
|
|
541
|
+
fields[name] = { start: value.getStart(sf), end: value.getEnd() };
|
|
542
|
+
if (name === 'params' && ts.isObjectLiteralExpression(value))
|
|
543
|
+
params = value;
|
|
544
|
+
}
|
|
545
|
+
if (params) {
|
|
546
|
+
for (const param of params.properties) {
|
|
547
|
+
if (!ts.isPropertyAssignment(param))
|
|
548
|
+
continue;
|
|
549
|
+
const key = staticName(param.name);
|
|
550
|
+
if (key === undefined || key === '__proto__')
|
|
551
|
+
continue;
|
|
552
|
+
const value = unwrap(param.initializer);
|
|
553
|
+
paramKeys[key] = { start: value.getStart(sf), end: value.getEnd() };
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
layouts[id] = {
|
|
557
|
+
fields,
|
|
558
|
+
paramKeys,
|
|
559
|
+
...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
|
|
560
|
+
fieldInsertion: objectInsertion(sf, source, body),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
return layouts;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* The `defineRequirements({ … })` literal of a registry file, with the source
|
|
567
|
+
* file it was parsed from.
|
|
568
|
+
*
|
|
569
|
+
* Both write-side readers go through it, for the reason there is one reader:
|
|
570
|
+
* "which literal is the registry" must have one answer, and a second walk that
|
|
571
|
+
* accepted a file the first refuses would let a modification edit a file the
|
|
572
|
+
* splice would not touch.
|
|
573
|
+
*/
|
|
574
|
+
function registryLiteral(file, source) {
|
|
450
575
|
// The write site, and the one where a recovered AST does damage rather than
|
|
451
576
|
// merely misreports: an offset taken from a file that does not compile would
|
|
452
577
|
// splice a new requirement into it. `undefined` is this function's documented
|
|
@@ -462,18 +587,7 @@ export function registryInsertionPoint(file, source) {
|
|
|
462
587
|
const arg = authoringCall(exported, sf, 'defineRequirements');
|
|
463
588
|
if (!arg || !ts.isObjectLiteralExpression(arg))
|
|
464
589
|
return undefined;
|
|
465
|
-
|
|
466
|
-
if (!last) {
|
|
467
|
-
// An empty registry: open the body rather than continue it. The trailing
|
|
468
|
-
// newline is written by the caller, so `{` does not end up sharing a line
|
|
469
|
-
// with the entry and the closing `}`.
|
|
470
|
-
return { offset: arg.getStart(sf) + 1, indent: indentOf(sf, source, arg) + ' ', leadingComma: false };
|
|
471
|
-
}
|
|
472
|
-
// Deliberately the *end of the last property*, not the end of the literal:
|
|
473
|
-
// inserting here sits before any trailing comma the file already has, so one
|
|
474
|
-
// leading comma is correct whether or not that comma is present, and the
|
|
475
|
-
// file's own trailing-comma style is left exactly as it was.
|
|
476
|
-
return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
|
|
590
|
+
return { sf, literal: arg };
|
|
477
591
|
}
|
|
478
592
|
/**
|
|
479
593
|
* The whitespace a node's line opens with.
|
package/dist/core/validator.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { AttestPlan, Issue, ParamRef, Registry } from './types.js';
|
|
|
13
13
|
export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): Issue[];
|
|
14
14
|
/**
|
|
15
15
|
* Static structural validation (design §5.3). Emits ERROR-level issues for:
|
|
16
|
+
* - empty-spec: the root declares no requirements at all
|
|
16
17
|
* - orphan-test: a scenario covers an unknown requirement id
|
|
17
18
|
* - uncovered-requirement: a requirement has no scenario
|
|
18
19
|
* - unbound-param: a statement placeholder has no matching param
|
package/dist/core/validator.js
CHANGED
|
@@ -28,6 +28,7 @@ export function uncoveredIssues(registry, plan) {
|
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
30
|
* Static structural validation (design §5.3). Emits ERROR-level issues for:
|
|
31
|
+
* - empty-spec: the root declares no requirements at all
|
|
31
32
|
* - orphan-test: a scenario covers an unknown requirement id
|
|
32
33
|
* - uncovered-requirement: a requirement has no scenario
|
|
33
34
|
* - unbound-param: a statement placeholder has no matching param
|
|
@@ -47,6 +48,27 @@ export function uncoveredIssues(registry, plan) {
|
|
|
47
48
|
export function validateStructure(registry, plan, unreadable = []) {
|
|
48
49
|
const issues = [];
|
|
49
50
|
const knownIds = new Set(Object.keys(registry));
|
|
51
|
+
// empty-spec: nothing under this root declares intent, so no finding below is
|
|
52
|
+
// about a requirement — and the report has no line saying why. It is stated
|
|
53
|
+
// first because everything else this function can emit against an empty
|
|
54
|
+
// registry is fallout from it: every scenario in the project is an orphan, and
|
|
55
|
+
// the diagnosis after 135 of them is a diagnosis nobody reaches.
|
|
56
|
+
//
|
|
57
|
+
// Guarded on `unreadable`, which is the same withdrawal the orphan advice
|
|
58
|
+
// makes below and for the same reason. "Point attest at the directory holding
|
|
59
|
+
// your *.reqs.ts files" is right for a root that has none, and **wrong** for
|
|
60
|
+
// one whose registry is sitting right there and failed to load: the files were
|
|
61
|
+
// found, the fix is the load error already in the report, and following the
|
|
62
|
+
// hint would move a working path. A registry that could not be read is not an
|
|
63
|
+
// absent one, and the two take different repairs.
|
|
64
|
+
if (knownIds.size === 0 && unreadable.length === 0) {
|
|
65
|
+
issues.push({
|
|
66
|
+
level: 'ERROR',
|
|
67
|
+
code: 'empty-spec',
|
|
68
|
+
message: `No requirements found under this root, so there is no intent here to attest. ` +
|
|
69
|
+
`Point attest at the directory holding your *.reqs.ts files, or add one.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
50
72
|
// orphan-test: covers a requirement that does not exist.
|
|
51
73
|
//
|
|
52
74
|
// "Add it to the registry, or fix the id" is the right advice for an id that
|
package/package.json
CHANGED