@am_shork/attest 0.8.0 → 0.9.1
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 +886 -53
- package/README.md +3 -2
- package/bin/attest.js +42 -1
- package/dist/cli/report.js +19 -5
- 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 +116 -43
- 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 +255 -13
- package/dist/core/static-registry.d.ts +55 -0
- package/dist/core/static-registry.js +141 -17
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +22 -0
- package/package.json +3 -2
package/dist/core/splice.d.ts
CHANGED
|
@@ -36,6 +36,64 @@ export declare function requirementSource(id: string, req: Requirement, indent:
|
|
|
36
36
|
* the same delta must produce the same file twice.
|
|
37
37
|
*/
|
|
38
38
|
export declare function spliceRequirements(file: string, source: string, additions: Registry): string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* One requirement a change modifies: what the registry holds, and what the gate
|
|
41
|
+
* proved the end state to be.
|
|
42
|
+
*
|
|
43
|
+
* Both halves, rather than the delta's patch, for the reason `MergeInputs.applied`
|
|
44
|
+
* exists — the patch is the authoring shape and the end state is what was proved
|
|
45
|
+
* green. Which fields actually move is then a *derived* fact, computed here by
|
|
46
|
+
* comparing the two, so a delta that restates a value it does not change writes
|
|
47
|
+
* nothing.
|
|
48
|
+
*/
|
|
49
|
+
export interface Modification {
|
|
50
|
+
id: string;
|
|
51
|
+
/** The entry as the file holds it — the gate's `base`. */
|
|
52
|
+
before: Requirement;
|
|
53
|
+
/** The entry as the gate proved it — `applied`. */
|
|
54
|
+
after: Requirement;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Why one value could not be written over, if it could not.
|
|
58
|
+
*
|
|
59
|
+
* `field` is `statement`, `outOfScope`, `params.totpWindowSec` — the path of the
|
|
60
|
+
* value, spelled the way the author would say it — and absent only when the
|
|
61
|
+
* refusal is about the entry rather than a value in it. The caller turns these
|
|
62
|
+
* into diagnostics; nothing here writes prose, because a refusal from this
|
|
63
|
+
* module refuses a whole merge and only the caller knows what to say about that.
|
|
64
|
+
*/
|
|
65
|
+
export interface ModifyRefusal {
|
|
66
|
+
reqId: string;
|
|
67
|
+
field?: string;
|
|
68
|
+
reason: 'comment' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
|
|
69
|
+
}
|
|
70
|
+
export type ModifyResult = {
|
|
71
|
+
ok: true;
|
|
72
|
+
source: string;
|
|
73
|
+
} | {
|
|
74
|
+
ok: false;
|
|
75
|
+
refusals: ModifyRefusal[];
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* `source` with each modified requirement's **changed values** written over.
|
|
79
|
+
*
|
|
80
|
+
* The other half of `--apply`'s write-back, and the one the design refused for
|
|
81
|
+
* five releases. What makes it allowable is stated at the top of this file and
|
|
82
|
+
* is a property of *granularity*: this never replaces an entry, only the span of
|
|
83
|
+
* a value inside one. A `statement` is a single string literal, so a comment
|
|
84
|
+
* cannot be inside what it overwrites; a `params` key or an `outOfScope` list
|
|
85
|
+
* can be, and that span is the only place this has to ask. Everything else in
|
|
86
|
+
* the file — the keys, the commas, the layout, every comment attached to a field
|
|
87
|
+
* that did not move — is bytes this does not address.
|
|
88
|
+
*
|
|
89
|
+
* A refusal is per value and the caller's response is whole-merge: the list is
|
|
90
|
+
* returned complete rather than at the first one, so an author fixing them by
|
|
91
|
+
* hand sees the whole of what stopped it.
|
|
92
|
+
*
|
|
93
|
+
* `undefined` under the same condition `spliceRequirements` returns it, through
|
|
94
|
+
* the same reader.
|
|
95
|
+
*/
|
|
96
|
+
export declare function spliceModifications(file: string, source: string, changes: readonly Modification[]): ModifyResult | undefined;
|
|
39
97
|
/**
|
|
40
98
|
* `source` with every import of `from` repointed at `to`.
|
|
41
99
|
*
|
|
@@ -45,9 +103,10 @@ export declare function spliceRequirements(file: string, source: string, additio
|
|
|
45
103
|
* discipline as the splice, for the same reason.
|
|
46
104
|
*
|
|
47
105
|
* The extension is taken from the specifier being replaced rather than chosen
|
|
48
|
-
* here. Whether a project writes `./x.reqs.js` or `./x.reqs
|
|
49
|
-
* its module resolution, uniform across the project, and already
|
|
50
|
-
* specifier sitting in front of us
|
|
106
|
+
* here. Whether a project writes `./x.reqs.js`, `./x.reqs.ts` or `./x.reqs` is a
|
|
107
|
+
* property of its module resolution, uniform across the project, and already
|
|
108
|
+
* answered by the specifier sitting in front of us — the third spelling
|
|
109
|
+
* included, where the answer is "none".
|
|
51
110
|
*/
|
|
52
111
|
export declare function repointImport(file: string, source: string, from: string, to: string): string;
|
|
53
112
|
//# sourceMappingURL=splice.d.ts.map
|
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,218 @@ 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.has(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
|
+
// It is also why the layout is `Map`s rather than objects at every level; the
|
|
254
|
+
// container is what carries the order, and `registryEntryLayouts` says why an
|
|
255
|
+
// object cannot.
|
|
256
|
+
for (const [id, layout] of layouts) {
|
|
257
|
+
const change = wanted.get(id);
|
|
258
|
+
if (!change)
|
|
259
|
+
continue;
|
|
260
|
+
const { before, after } = change;
|
|
261
|
+
// Everything this entry writes goes through one of these three, which is
|
|
262
|
+
// what lets `editParams` be a function rather than a fourth copy of them.
|
|
263
|
+
const out = {
|
|
264
|
+
at: (field, span, text) => {
|
|
265
|
+
// The one question a replacement has that an insertion does not. Run over
|
|
266
|
+
// every span rather than only the two that can fail it, so the rule is
|
|
267
|
+
// "nothing is written over a comment" rather than a list of fields somebody
|
|
268
|
+
// has to keep in step with the schema — on `statement` and `rationale` it
|
|
269
|
+
// is a scan of one token and can never fire.
|
|
270
|
+
if (spanHasComment(source.slice(span.start, span.end))) {
|
|
271
|
+
refusals.push({ reqId: id, field, reason: 'comment' });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
edits.push({ start: span.start, end: span.end, text });
|
|
275
|
+
},
|
|
276
|
+
insert: (offset, text) => edits.push({ start: offset, end: offset, text }),
|
|
277
|
+
refuse: (field, reason) => refusals.push({ reqId: id, field, reason }),
|
|
278
|
+
};
|
|
279
|
+
// Fields the entry does not write yet. Collected rather than emitted, since
|
|
280
|
+
// they share one offset — the end of the entry's body — and several edits at
|
|
281
|
+
// one position is the one thing the back-to-front pass cannot order.
|
|
282
|
+
const opened = [];
|
|
283
|
+
const openField = (name, value) => {
|
|
284
|
+
opened.push(`${layout.fieldInsertion.indent}${name}: ${value}`);
|
|
285
|
+
};
|
|
286
|
+
for (const [name, span] of layout.fields) {
|
|
287
|
+
if (name === 'statement' || name === 'rationale') {
|
|
288
|
+
if (before[name] !== after[name])
|
|
289
|
+
out.at(name, span, tsString(after[name]));
|
|
290
|
+
}
|
|
291
|
+
else if (name === 'outOfScope') {
|
|
292
|
+
if (!sameValue(before.outOfScope, after.outOfScope)) {
|
|
293
|
+
out.at(name, span, outOfScopeSource(after.outOfScope));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else if (name === 'params') {
|
|
297
|
+
editParams(layout, before, after, out);
|
|
298
|
+
}
|
|
299
|
+
// Any other field is one the schema does not define, and not this
|
|
300
|
+
// module's to rewrite or to remove.
|
|
301
|
+
}
|
|
302
|
+
if (!layout.fields.has('statement') && before.statement !== after.statement) {
|
|
303
|
+
openField('statement', tsString(after.statement));
|
|
304
|
+
}
|
|
305
|
+
if (!layout.fields.has('rationale') && before.rationale !== after.rationale) {
|
|
306
|
+
openField('rationale', tsString(after.rationale));
|
|
307
|
+
}
|
|
308
|
+
if (!layout.fields.has('params')) {
|
|
309
|
+
const fresh = freshParams(before, after);
|
|
310
|
+
if (fresh.length > 0)
|
|
311
|
+
openField('params', `{ ${fresh.join(', ')} }`);
|
|
312
|
+
}
|
|
313
|
+
if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
|
|
314
|
+
openField('outOfScope', outOfScopeSource(after.outOfScope));
|
|
315
|
+
}
|
|
316
|
+
if (opened.length > 0) {
|
|
317
|
+
const point = layout.fieldInsertion;
|
|
318
|
+
const body = opened.join(',\n');
|
|
319
|
+
// The comma leads rather than trails, for the reason the registry splice's
|
|
320
|
+
// does: the offset sits *before* whatever trailing comma the entry already
|
|
321
|
+
// carries, so writing one here would produce two.
|
|
322
|
+
edits.push({
|
|
323
|
+
start: point.offset,
|
|
324
|
+
end: point.offset,
|
|
325
|
+
text: point.leadingComma ? `,\n${body}` : `\n${body},\n`,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (refusals.length > 0)
|
|
330
|
+
return { ok: false, refusals };
|
|
331
|
+
// Back to front, for the reason `repointImport` applies its edits that way: an
|
|
332
|
+
// edit cannot move an offset that lies before it, so every span stays valid as
|
|
333
|
+
// the string is rewritten under them. A `reverse()` and not a sort, because
|
|
334
|
+
// the walk above produced them in source order.
|
|
335
|
+
let out = source;
|
|
336
|
+
for (const edit of [...edits].reverse()) {
|
|
337
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
338
|
+
}
|
|
339
|
+
return { ok: true, source: out };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The `params` half of one entry: a value per changed key, and one insertion for
|
|
343
|
+
* the keys the entry does not have.
|
|
344
|
+
*
|
|
345
|
+
* Split out because it is the only field with structure of its own, and inlining
|
|
346
|
+
* it put three loops inside a loop inside a loop. Its edits are emitted in the
|
|
347
|
+
* order the file writes the keys, then the insertion at the end of the object —
|
|
348
|
+
* which is what keeps the caller's ascending-offset property true through it.
|
|
349
|
+
*/
|
|
350
|
+
function editParams(layout, before, after, out) {
|
|
351
|
+
// A key the end state has lost. Unreachable through the command — `applyDelta`
|
|
352
|
+
// merges `params` key by key and a patch has no spelling for a deletion — and
|
|
353
|
+
// refused rather than ignored, for the reason `keySource` refuses `__proto__`:
|
|
354
|
+
// this is the module that *writes*, and silently keeping a value the proved
|
|
355
|
+
// end state does not have is the one outcome worse than stopping.
|
|
356
|
+
for (const key of Object.keys(before.params).sort(byCodeUnit)) {
|
|
357
|
+
if (!Object.hasOwn(after.params, key)) {
|
|
358
|
+
out.refuse(`params.${key}`, 'param-dropped');
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// A key the file holds and this cannot locate: its value is not written as a
|
|
362
|
+
// literal, which the registry reader refuses before a merge can reach here.
|
|
363
|
+
// Named rather than skipped, so the guard does not depend on something
|
|
364
|
+
// upstream holding.
|
|
365
|
+
for (const key of Object.keys(before.params).sort(byCodeUnit)) {
|
|
366
|
+
if (!layout.paramKeys.has(key) && !sameValue(before.params[key], after.params[key])) {
|
|
367
|
+
out.refuse(`params.${key}`, 'not-a-literal');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const [key, span] of layout.paramKeys) {
|
|
371
|
+
if (!Object.hasOwn(after.params, key))
|
|
372
|
+
continue;
|
|
373
|
+
const value = after.params[key];
|
|
374
|
+
if (sameValue(before.params[key], value))
|
|
375
|
+
continue;
|
|
376
|
+
// The value alone: the span is the value's, and the key in front of it is a
|
|
377
|
+
// byte this does not address — the same rule that keeps the comma, the
|
|
378
|
+
// comment and the layout where they are.
|
|
379
|
+
out.at(`params.${key}`, span, paramSource(sortDeep(value)));
|
|
380
|
+
}
|
|
381
|
+
const fresh = freshParams(before, after);
|
|
382
|
+
if (fresh.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
if (!layout.paramsInsertion) {
|
|
385
|
+
// A `params` that is not an object literal, so there is no inside to write
|
|
386
|
+
// into. Same guard as the keys above, one level out.
|
|
387
|
+
out.refuse('params', 'not-a-literal');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const point = layout.paramsInsertion;
|
|
391
|
+
out.insert(point.offset, point.leadingComma ? `, ${fresh.join(', ')}` : ` ${fresh.join(', ')} `);
|
|
392
|
+
}
|
|
393
|
+
/** The params the end state has and the file does not, as source, in key order. */
|
|
394
|
+
function freshParams(before, after) {
|
|
395
|
+
return Object.keys(after.params)
|
|
396
|
+
.sort(byCodeUnit)
|
|
397
|
+
.filter((key) => !Object.hasOwn(before.params, key))
|
|
398
|
+
.map((key) => paramEntrySource(key, after.params[key]));
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Whether a span of source carries a comment.
|
|
402
|
+
*
|
|
403
|
+
* Scanned rather than matched, and that is the whole of why it is correct: `//`
|
|
404
|
+
* inside a string is not a comment, and a registry's statements are prose full
|
|
405
|
+
* of URLs. The scanner is the same one the reader's parser is built on, so the
|
|
406
|
+
* two agree about what a comment is by construction.
|
|
407
|
+
*/
|
|
408
|
+
function spanHasComment(text) {
|
|
409
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ false);
|
|
410
|
+
scanner.setText(text);
|
|
411
|
+
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
|
|
412
|
+
if (token === ts.SyntaxKind.SingleLineCommentTrivia ||
|
|
413
|
+
token === ts.SyntaxKind.MultiLineCommentTrivia) {
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
/** Content equality for one value, by the canonical form the engine compares on. */
|
|
420
|
+
function sameValue(a, b) {
|
|
421
|
+
return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b));
|
|
422
|
+
}
|
|
190
423
|
/**
|
|
191
424
|
* `source` with every import of `from` repointed at `to`.
|
|
192
425
|
*
|
|
@@ -196,9 +429,10 @@ export function spliceRequirements(file, source, additions) {
|
|
|
196
429
|
* discipline as the splice, for the same reason.
|
|
197
430
|
*
|
|
198
431
|
* 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
|
|
432
|
+
* here. Whether a project writes `./x.reqs.js`, `./x.reqs.ts` or `./x.reqs` is a
|
|
433
|
+
* property of its module resolution, uniform across the project, and already
|
|
434
|
+
* answered by the specifier sitting in front of us — the third spelling
|
|
435
|
+
* included, where the answer is "none".
|
|
202
436
|
*/
|
|
203
437
|
export function repointImport(file, source, from, to) {
|
|
204
438
|
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
@@ -212,10 +446,13 @@ export function repointImport(file, source, from, to) {
|
|
|
212
446
|
continue;
|
|
213
447
|
if (!resolvesTo(dir, spec.text, from))
|
|
214
448
|
continue;
|
|
449
|
+
// Unconditional, because "no extension" is an answer and not a missing one:
|
|
450
|
+
// a bundler-resolution project writes every specifier that way, so the empty
|
|
451
|
+
// case has to strip what `to` carries rather than leave it. Guarding this on
|
|
452
|
+
// a non-empty `ext` is only safe while `resolvesTo` rejects an extensionless
|
|
453
|
+
// specifier — the two move together.
|
|
215
454
|
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);
|
|
455
|
+
let target = toPosixPath(relative(dir, to)).replace(/\.[^./]+$/, ext);
|
|
219
456
|
// A bare `x.reqs.js` is a package specifier, not a sibling file.
|
|
220
457
|
if (!target.startsWith('.'))
|
|
221
458
|
target = `./${target}`;
|
|
@@ -248,14 +485,19 @@ export function repointImport(file, source, from, to) {
|
|
|
248
485
|
/**
|
|
249
486
|
* Whether `specifier`, written in a file under `dir`, names `target`.
|
|
250
487
|
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
488
|
+
* Three spellings of one import, because module resolution decides which a
|
|
489
|
+
* project writes and it writes that one everywhere — including here. The
|
|
490
|
+
* `.js`-for-`.ts` spelling is what NodeNext requires; the extensionless one is
|
|
491
|
+
* what bundler resolution produces, and matching it is the whole reason a
|
|
492
|
+
* specifier can be repointed at all in such a project.
|
|
254
493
|
*/
|
|
255
494
|
function resolvesTo(dir, specifier, target) {
|
|
256
495
|
if (!specifier.startsWith('.'))
|
|
257
496
|
return false;
|
|
258
497
|
const resolved = resolve(dir, specifier);
|
|
259
|
-
|
|
498
|
+
// Only `.ts` is appended, not `.js` as well: `target` is the delta, whose name
|
|
499
|
+
// is the constant `requirements.delta.ts`, so the symmetrical clause would be
|
|
500
|
+
// unreachable — a hypothetical seam rather than a real one.
|
|
501
|
+
return (resolved === target || resolved.replace(/\.js$/, '.ts') === target || `${resolved}.ts` === target);
|
|
260
502
|
}
|
|
261
503
|
//# sourceMappingURL=splice.js.map
|
|
@@ -101,4 +101,59 @@ 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, in file order. */
|
|
127
|
+
fields: Map<string, ValueSpan>;
|
|
128
|
+
/**
|
|
129
|
+
* Value span of each key of `params`, when the entry writes one as a literal,
|
|
130
|
+
* in file order.
|
|
131
|
+
*/
|
|
132
|
+
paramKeys: Map<string, ValueSpan>;
|
|
133
|
+
/** Where a new `params` key goes — absent when the entry writes no `params`. */
|
|
134
|
+
paramsInsertion?: RegistryInsertion;
|
|
135
|
+
/** Where a new field goes, inside the entry's own body. */
|
|
136
|
+
fieldInsertion: RegistryInsertion;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The layout of every entry in a registry file, by requirement id.
|
|
140
|
+
*
|
|
141
|
+
* `undefined` under exactly the condition `registryInsertionPoint` returns it —
|
|
142
|
+
* one walker answers "where is this registry's literal" for both, so a file the
|
|
143
|
+
* splice refuses cannot be one the modification accepts. Entries whose *shape*
|
|
144
|
+
* this cannot describe (a computed key, a value built by a call) are absent from
|
|
145
|
+
* the result rather than partially described, which the caller reads as a
|
|
146
|
+
* refusal for that id: an entry nothing can locate is not one to edit.
|
|
147
|
+
*
|
|
148
|
+
* **Maps at all three levels, because the iteration order is the contract.**
|
|
149
|
+
* `spliceModifications` walks this in file order to get its edits in ascending
|
|
150
|
+
* offset order, which is what lets it apply them with a `reverse()` rather than
|
|
151
|
+
* a sort. A plain object cannot carry that order: an *integer-like* key is
|
|
152
|
+
* hoisted to the front of every JavaScript object, and a `params` key can be
|
|
153
|
+
* one — `staticName` spells a numeric literal key as `String(value)`, and the
|
|
154
|
+
* schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
|
|
155
|
+
* order for every key type, so the property holds by construction rather than by
|
|
156
|
+
* the keys happening not to be numbers.
|
|
157
|
+
*/
|
|
158
|
+
export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
|
|
104
159
|
//# 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,127 @@ 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
|
+
* **Maps at all three levels, because the iteration order is the contract.**
|
|
515
|
+
* `spliceModifications` walks this in file order to get its edits in ascending
|
|
516
|
+
* offset order, which is what lets it apply them with a `reverse()` rather than
|
|
517
|
+
* a sort. A plain object cannot carry that order: an *integer-like* key is
|
|
518
|
+
* hoisted to the front of every JavaScript object, and a `params` key can be
|
|
519
|
+
* one — `staticName` spells a numeric literal key as `String(value)`, and the
|
|
520
|
+
* schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
|
|
521
|
+
* order for every key type, so the property holds by construction rather than by
|
|
522
|
+
* the keys happening not to be numbers.
|
|
523
|
+
*/
|
|
524
|
+
export function registryEntryLayouts(file, source) {
|
|
525
|
+
const found = registryLiteral(file, source);
|
|
526
|
+
if (!found)
|
|
527
|
+
return undefined;
|
|
528
|
+
const { sf, literal } = found;
|
|
529
|
+
const layouts = new Map();
|
|
530
|
+
for (const entry of literal.properties) {
|
|
531
|
+
if (!ts.isPropertyAssignment(entry))
|
|
532
|
+
continue;
|
|
533
|
+
const id = staticName(entry.name);
|
|
534
|
+
if (id === undefined || id === '__proto__')
|
|
535
|
+
continue;
|
|
536
|
+
const body = unwrap(entry.initializer);
|
|
537
|
+
if (!ts.isObjectLiteralExpression(body))
|
|
538
|
+
continue;
|
|
539
|
+
const fields = new Map();
|
|
540
|
+
const paramKeys = new Map();
|
|
541
|
+
let params;
|
|
542
|
+
for (const field of body.properties) {
|
|
543
|
+
if (!ts.isPropertyAssignment(field))
|
|
544
|
+
continue;
|
|
545
|
+
const name = staticName(field.name);
|
|
546
|
+
if (name === undefined || name === '__proto__')
|
|
547
|
+
continue;
|
|
548
|
+
// The unwrapped value, so an `as const` or a parenthesis stays outside the
|
|
549
|
+
// span and survives the replacement it wraps.
|
|
550
|
+
const value = unwrap(field.initializer);
|
|
551
|
+
fields.set(name, { start: value.getStart(sf), end: value.getEnd() });
|
|
552
|
+
if (name === 'params' && ts.isObjectLiteralExpression(value))
|
|
553
|
+
params = value;
|
|
554
|
+
}
|
|
555
|
+
if (params) {
|
|
556
|
+
for (const param of params.properties) {
|
|
557
|
+
if (!ts.isPropertyAssignment(param))
|
|
558
|
+
continue;
|
|
559
|
+
const key = staticName(param.name);
|
|
560
|
+
if (key === undefined || key === '__proto__')
|
|
561
|
+
continue;
|
|
562
|
+
const value = unwrap(param.initializer);
|
|
563
|
+
paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
layouts.set(id, {
|
|
567
|
+
fields,
|
|
568
|
+
paramKeys,
|
|
569
|
+
...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
|
|
570
|
+
fieldInsertion: objectInsertion(sf, source, body),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
return layouts;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* The `defineRequirements({ … })` literal of a registry file, with the source
|
|
577
|
+
* file it was parsed from.
|
|
578
|
+
*
|
|
579
|
+
* Both write-side readers go through it, for the reason there is one reader:
|
|
580
|
+
* "which literal is the registry" must have one answer, and a second walk that
|
|
581
|
+
* accepted a file the first refuses would let a modification edit a file the
|
|
582
|
+
* splice would not touch.
|
|
583
|
+
*/
|
|
584
|
+
function registryLiteral(file, source) {
|
|
450
585
|
// The write site, and the one where a recovered AST does damage rather than
|
|
451
586
|
// merely misreports: an offset taken from a file that does not compile would
|
|
452
587
|
// splice a new requirement into it. `undefined` is this function's documented
|
|
@@ -462,18 +597,7 @@ export function registryInsertionPoint(file, source) {
|
|
|
462
597
|
const arg = authoringCall(exported, sf, 'defineRequirements');
|
|
463
598
|
if (!arg || !ts.isObjectLiteralExpression(arg))
|
|
464
599
|
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 };
|
|
600
|
+
return { sf, literal: arg };
|
|
477
601
|
}
|
|
478
602
|
/**
|
|
479
603
|
* 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
|