@am_shork/attest 0.9.3 → 0.10.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.
@@ -148,11 +148,11 @@ function paramSource(value) {
148
148
  * body needs one after, and putting that decision here would mean this function
149
149
  * had to be told which case it was in anyway.
150
150
  *
151
- * `params` and `outOfScope` are omitted when empty rather than written as `{}`
152
- * and `[]`. The schema defaults both, so the two spellings mean the same thing,
153
- * and the shorter one is what a person writing this entry by hand would have
154
- * produced — which is the standard for a file `--apply` is merging into rather
155
- * than generating.
151
+ * `params`, `outOfScope` and `open` are omitted when empty rather than written
152
+ * as `{}` and `[]`. The schema defaults all three, so the two spellings mean the
153
+ * same thing, and the shorter one is what a person writing this entry by hand
154
+ * would have produced — which is the standard for a file `--apply` is merging
155
+ * into rather than generating.
156
156
  */
157
157
  export function requirementSource(id, req, indent) {
158
158
  const inner = `${indent} `;
@@ -172,13 +172,28 @@ export function requirementSource(id, req, indent) {
172
172
  lines.push(`${inner}params: { ${body} },`);
173
173
  }
174
174
  if (req.outOfScope.length > 0) {
175
- lines.push(`${inner}outOfScope: ${outOfScopeSource(req.outOfScope)},`);
175
+ lines.push(`${inner}outOfScope: ${stringListSource(req.outOfScope)},`);
176
+ }
177
+ // Reachable only through a delta the gate never approves — `open-unresolved`
178
+ // blocks any end state that still carries one, so an ADDED entry written here
179
+ // has an empty list. Emitted anyway rather than left out: this function's
180
+ // contract is to write the requirement it is given, and a writer silently
181
+ // shorter than the schema is exactly the divergence the MODIFIED half below
182
+ // was found to have.
183
+ if (req.open.length > 0) {
184
+ lines.push(`${inner}open: ${stringListSource(req.open)},`);
176
185
  }
177
186
  lines.push(`${indent}}`);
178
187
  return lines.join('\n');
179
188
  }
180
- /** One `outOfScope` list as source. Shared with the modification writer below. */
181
- function outOfScopeSource(entries) {
189
+ /**
190
+ * One list-of-strings field as source — `outOfScope` or `open`.
191
+ *
192
+ * One function for both, and named for the shape rather than for either field,
193
+ * so a third such field does not arrive with a third spelling of the same
194
+ * emitter. Shared with the modification writer below.
195
+ */
196
+ function stringListSource(entries) {
182
197
  return `[${entries.map((s) => tsString(s)).join(', ')}]`;
183
198
  }
184
199
  /** One `params` key as source, at the key order the emitter writes everywhere. */
@@ -237,9 +252,18 @@ export function spliceModifications(file, source, changes) {
237
252
  const edits = [];
238
253
  const wanted = new Map(changes.map((change) => [change.id, change]));
239
254
  // 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.
255
+ // position to be reported at, and the walk below is driven by positions. An
256
+ // entry the reader refused to describe is reported as *why* rather than as
257
+ // absent — a file that writes one key twice does hold the entry, and
258
+ // `entry-not-found` would send its reader looking for something that is
259
+ // there. Both are decided before a single edit is pushed, because a refusal
260
+ // arriving mid-walk would leave `edits` half-built for an entry whose offsets
261
+ // are exactly the ones not to be trusted.
241
262
  for (const id of [...wanted.keys()].sort(byCodeUnit)) {
242
- if (!layouts.has(id))
263
+ const twice = layouts.repeated.get(id);
264
+ if (twice)
265
+ refusals.push({ reqId: id, ...twice, reason: 'duplicate-key' });
266
+ else if (!layouts.byId.has(id))
243
267
  refusals.push({ reqId: id, reason: 'entry-not-found' });
244
268
  }
245
269
  // **The file front to back, not the delta.** Driving the walk from the layout
@@ -253,7 +277,7 @@ export function spliceModifications(file, source, changes) {
253
277
  // It is also why the layout is `Map`s rather than objects at every level; the
254
278
  // container is what carries the order, and `registryEntryLayouts` says why an
255
279
  // object cannot.
256
- for (const [id, layout] of layouts) {
280
+ for (const [id, layout] of layouts.byId) {
257
281
  const change = wanted.get(id);
258
282
  if (!change)
259
283
  continue;
@@ -290,14 +314,24 @@ export function spliceModifications(file, source, changes) {
290
314
  }
291
315
  else if (name === 'outOfScope') {
292
316
  if (!sameValue(before.outOfScope, after.outOfScope)) {
293
- out.at(name, span, outOfScopeSource(after.outOfScope));
317
+ out.at(name, span, stringListSource(after.outOfScope));
318
+ }
319
+ }
320
+ else if (name === 'open') {
321
+ if (!sameValue(before.open, after.open)) {
322
+ out.at(name, span, stringListSource(after.open));
294
323
  }
295
324
  }
296
325
  else if (name === 'params') {
297
326
  editParams(layout, before, after, out);
298
327
  }
299
328
  // Any other field is one the schema does not define, and not this
300
- // module's to rewrite or to remove.
329
+ // module's to rewrite or to remove. `open` is above rather than here for
330
+ // exactly that reason: adding it to the schema without adding it to this
331
+ // loop would let the gate approve an end state where a question is closed
332
+ // while the file on disk still carries it — a divergence between the
333
+ // registry the verdict was reached on and the registry the merge commits,
334
+ // with nothing anywhere comparing the two.
301
335
  }
302
336
  if (!layout.fields.has('statement') && before.statement !== after.statement) {
303
337
  openField('statement', tsString(after.statement));
@@ -311,7 +345,10 @@ export function spliceModifications(file, source, changes) {
311
345
  openField('params', `{ ${fresh.join(', ')} }`);
312
346
  }
313
347
  if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
314
- openField('outOfScope', outOfScopeSource(after.outOfScope));
348
+ openField('outOfScope', stringListSource(after.outOfScope));
349
+ }
350
+ if (!layout.fields.has('open') && !sameValue(before.open, after.open)) {
351
+ openField('open', stringListSource(after.open));
315
352
  }
316
353
  if (opened.length > 0) {
317
354
  const point = layout.fieldInsertion;
@@ -135,6 +135,31 @@ export interface RegistryEntryLayout {
135
135
  /** Where a new field goes, inside the entry's own body. */
136
136
  fieldInsertion: RegistryInsertion;
137
137
  }
138
+ /**
139
+ * The key an entry wrote twice, as the path the author would say it —
140
+ * `statement`, `params.limit`.
141
+ *
142
+ * Absent when what repeats is the entry's **own id**, which no field path
143
+ * names: `params.limit` is a place inside a requirement, and a second entry
144
+ * under one id is not inside anything.
145
+ */
146
+ export interface RepeatedKey {
147
+ field?: string;
148
+ }
149
+ /** Every entry's layout, and the entries this reader refuses to describe. */
150
+ export interface RegistryLayouts {
151
+ /**
152
+ * Value spans per requirement id, in the order the file writes them.
153
+ *
154
+ * Named `byId` rather than `entries` so nothing reads `layouts.entries` as
155
+ * the `Map` method of that name — the order this carries is the contract,
156
+ * and a reader who thinks they are calling `Map.prototype.entries` is a
157
+ * reader who has stopped seeing it.
158
+ */
159
+ byId: Map<string, RegistryEntryLayout>;
160
+ /** Ids left out of {@link RegistryLayouts.byId} because a key repeats. */
161
+ repeated: Map<string, RepeatedKey>;
162
+ }
138
163
  /**
139
164
  * The layout of every entry in a registry file, by requirement id.
140
165
  *
@@ -154,6 +179,26 @@ export interface RegistryEntryLayout {
154
179
  * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
155
180
  * order for every key type, so the property holds by construction rather than by
156
181
  * the keys happening not to be numbers.
182
+ *
183
+ * **A key written twice defeats that pairing, which is why it is refused rather
184
+ * than described.** `Map.set` on a key it already holds keeps the *first*
185
+ * insertion's position and takes the *last* call's value — so an entry writing
186
+ * `params: { limit: 1, other: 'x', limit: 1 }` yields `limit` at position one
187
+ * carrying the span of the occurrence that sits after `other`, and the walk's
188
+ * offsets stop ascending — so an earlier edit moves the bytes under a later one
189
+ * and the replacement lands outside the value it was addressing.
190
+ *
191
+ * Nothing upstream refuses such a file, which is why the guard is here: a
192
+ * duplicate key is a TypeScript *semantic* error and `parseSource` asks only
193
+ * the syntactic question, so both readers take the file and hand back the last
194
+ * occurrence, and `--apply` is the one command that writes.
195
+ *
196
+ * Refused at every level for one reason, and the id level is not the mild one:
197
+ * two entries under a single id put a *later* entry's spans at an *earlier*
198
+ * entry's position, so the disorder is between entries rather than inside one.
199
+ * Nothing narrower is worth the reach — an entry whose keys are ambiguous is
200
+ * one no verdict describes, and the file is a defect to fix rather than one to
201
+ * edit around.
157
202
  */
158
- export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
203
+ export declare function registryEntryLayouts(file: string, source: string): RegistryLayouts | undefined;
159
204
  //# sourceMappingURL=static-registry.d.ts.map
@@ -295,11 +295,23 @@ function constInitializer(sf, name) {
295
295
  * The callee is matched against the local name the import bound — an alias or a
296
296
  * namespace import is the same call — rather than against any single-argument
297
297
  * call, so `buildRegistry({…})` is not mistaken for a registry.
298
+ *
299
+ * **Unwrapped here, once, rather than by each caller.** `as const`, `satisfies`
300
+ * and a bare pair of parentheses do not change the value, so `literalValue`
301
+ * strips them before it reads — while the write side asks
302
+ * `ts.isObjectLiteralExpression` of whatever this hands back. A wrapper returned
303
+ * from here is therefore a registry the readers accept and the writers cannot
304
+ * locate, on the one command that edits a `*.reqs.ts`. Both sides come through
305
+ * this function precisely so they cannot disagree about which literal is the
306
+ * registry; leaving the unwrap to each caller is what puts the disagreement
307
+ * inside it.
298
308
  */
299
309
  function authoringCall(expr, sf, fn) {
300
310
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
301
311
  return undefined;
302
- return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
312
+ if (!callsAuthoringFn(expr, localNames(sf, fn), fn))
313
+ return undefined;
314
+ return unwrap(expr.arguments[0]);
303
315
  }
304
316
  /**
305
317
  * Whether a call is a call of `fn`, by the local names the imports bound to it.
@@ -520,31 +532,66 @@ function objectInsertion(sf, source, obj) {
520
532
  * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
521
533
  * order for every key type, so the property holds by construction rather than by
522
534
  * the keys happening not to be numbers.
535
+ *
536
+ * **A key written twice defeats that pairing, which is why it is refused rather
537
+ * than described.** `Map.set` on a key it already holds keeps the *first*
538
+ * insertion's position and takes the *last* call's value — so an entry writing
539
+ * `params: { limit: 1, other: 'x', limit: 1 }` yields `limit` at position one
540
+ * carrying the span of the occurrence that sits after `other`, and the walk's
541
+ * offsets stop ascending — so an earlier edit moves the bytes under a later one
542
+ * and the replacement lands outside the value it was addressing.
543
+ *
544
+ * Nothing upstream refuses such a file, which is why the guard is here: a
545
+ * duplicate key is a TypeScript *semantic* error and `parseSource` asks only
546
+ * the syntactic question, so both readers take the file and hand back the last
547
+ * occurrence, and `--apply` is the one command that writes.
548
+ *
549
+ * Refused at every level for one reason, and the id level is not the mild one:
550
+ * two entries under a single id put a *later* entry's spans at an *earlier*
551
+ * entry's position, so the disorder is between entries rather than inside one.
552
+ * Nothing narrower is worth the reach — an entry whose keys are ambiguous is
553
+ * one no verdict describes, and the file is a defect to fix rather than one to
554
+ * edit around.
523
555
  */
524
556
  export function registryEntryLayouts(file, source) {
525
557
  const found = registryLiteral(file, source);
526
558
  if (!found)
527
559
  return undefined;
528
560
  const { sf, literal } = found;
529
- const layouts = new Map();
561
+ const byId = new Map();
562
+ const repeated = new Map();
530
563
  for (const entry of literal.properties) {
531
564
  if (!ts.isPropertyAssignment(entry))
532
565
  continue;
533
566
  const id = staticName(entry.name);
534
567
  if (id === undefined || id === '__proto__')
535
568
  continue;
569
+ if (byId.has(id) || repeated.has(id)) {
570
+ // Deleted, not left as the first occurrence: both readers take the *last*
571
+ // one, so describing the first would hand the writer a span whose value
572
+ // nothing evaluates.
573
+ byId.delete(id);
574
+ repeated.set(id, {});
575
+ continue;
576
+ }
536
577
  const body = unwrap(entry.initializer);
537
578
  if (!ts.isObjectLiteralExpression(body))
538
579
  continue;
539
580
  const fields = new Map();
540
581
  const paramKeys = new Map();
541
582
  let params;
583
+ /** The first repeated key seen, as the path the refusal names. */
584
+ let twice;
542
585
  for (const field of body.properties) {
543
586
  if (!ts.isPropertyAssignment(field))
544
587
  continue;
545
588
  const name = staticName(field.name);
546
589
  if (name === undefined || name === '__proto__')
547
590
  continue;
591
+ if (fields.has(name)) {
592
+ twice ??= name;
593
+ continue;
594
+ }
548
595
  // The unwrapped value, so an `as const` or a parenthesis stays outside the
549
596
  // span and survives the replacement it wraps.
550
597
  const value = unwrap(field.initializer);
@@ -559,18 +606,26 @@ export function registryEntryLayouts(file, source) {
559
606
  const key = staticName(param.name);
560
607
  if (key === undefined || key === '__proto__')
561
608
  continue;
609
+ if (paramKeys.has(key)) {
610
+ twice ??= `params.${key}`;
611
+ continue;
612
+ }
562
613
  const value = unwrap(param.initializer);
563
614
  paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
564
615
  }
565
616
  }
566
- layouts.set(id, {
617
+ if (twice !== undefined) {
618
+ repeated.set(id, { field: twice });
619
+ continue;
620
+ }
621
+ byId.set(id, {
567
622
  fields,
568
623
  paramKeys,
569
624
  ...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
570
625
  fieldInsertion: objectInsertion(sf, source, body),
571
626
  });
572
627
  }
573
- return layouts;
628
+ return { byId, repeated };
574
629
  }
575
630
  /**
576
631
  * The `defineRequirements({ … })` literal of a registry file, with the source
@@ -19,8 +19,9 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
19
19
  * - unbound-param: a statement placeholder has no matching param
20
20
  * - non-scalar-interpolation: a statement placeholder names a structured param
21
21
  *
22
- * and one WARNING:
22
+ * and two WARNINGs:
23
23
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
24
+ * - requirement-open: the requirement names something still undecided about it
24
25
  *
25
26
  * and one more WARNING, which exists only when a registry file failed to load:
26
27
  * - orphan-from-failed-registry: the scenarios attesting ids that file declares
@@ -34,8 +34,9 @@ export function uncoveredIssues(registry, plan) {
34
34
  * - unbound-param: a statement placeholder has no matching param
35
35
  * - non-scalar-interpolation: a statement placeholder names a structured param
36
36
  *
37
- * and one WARNING:
37
+ * and two WARNINGs:
38
38
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
39
+ * - requirement-open: the requirement names something still undecided about it
39
40
  *
40
41
  * and one more WARNING, which exists only when a registry file failed to load:
41
42
  * - orphan-from-failed-registry: the scenarios attesting ids that file declares
@@ -208,6 +209,24 @@ export function validateStructure(registry, plan, unreadable = []) {
208
209
  });
209
210
  }
210
211
  }
212
+ // requirement-open: the requirement says what is still undecided about it.
213
+ // WARNING and not an ERROR, because the four static commands are where an
214
+ // author works while a proposal legitimately still has questions in it —
215
+ // failing here would push them back to inventing a value, which is the
216
+ // behaviour the field exists to replace. What refuses to call such a registry
217
+ // done is the archive gate (`open-unresolved`), the same asymmetry `never-red`
218
+ // runs on. One issue per question rather than one per requirement: each is a
219
+ // separate thing to answer, and a reader resolving them wants them listed.
220
+ for (const [id, req] of Object.entries(registry)) {
221
+ for (const question of req.open) {
222
+ issues.push({
223
+ level: 'WARNING',
224
+ code: 'requirement-open',
225
+ reqId: id,
226
+ message: `Requirement "${id}" is still open: ${question} Answer it and remove the entry from open — the archive gate refuses a registry that still carries one.`,
227
+ });
228
+ }
229
+ }
211
230
  return issues;
212
231
  }
213
232
  /**
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.28.0",
6
7
  "engines": {
7
8
  "node": ">=20.19"
8
9
  },
@@ -41,6 +42,24 @@
41
42
  "publishConfig": {
42
43
  "access": "public"
43
44
  },
45
+ "scripts": {
46
+ "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
47
+ "build": "pnpm run clean && tsc -p tsconfig.json",
48
+ "typecheck": "tsc -p tsconfig.json --noEmit",
49
+ "typecheck:all": "tsc -p tsconfig.typecheck.json",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
53
+ "lint": "eslint .",
54
+ "prepack": "pnpm run build",
55
+ "prepublishOnly": "pnpm run test:consumer",
56
+ "attest": "node bin/attest.js",
57
+ "check:self": "node bin/attest.js check self",
58
+ "verify:self": "node bin/attest.js verify self",
59
+ "cover:self": "node bin/attest.js cover self",
60
+ "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
61
+ "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
62
+ },
44
63
  "keywords": [
45
64
  "tdd",
46
65
  "spec",
@@ -67,20 +86,15 @@
67
86
  "vite": "^8.1.5",
68
87
  "vitest": "^4.1.10"
69
88
  },
70
- "scripts": {
71
- "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
72
- "build": "pnpm run clean && tsc -p tsconfig.json",
73
- "typecheck": "tsc -p tsconfig.json --noEmit",
74
- "typecheck:all": "tsc -p tsconfig.typecheck.json",
75
- "test": "vitest run",
76
- "test:watch": "vitest",
77
- "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
78
- "lint": "eslint .",
79
- "attest": "node bin/attest.js",
80
- "check:self": "node bin/attest.js check self",
81
- "verify:self": "node bin/attest.js verify self",
82
- "cover:self": "node bin/attest.js cover self",
83
- "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
84
- "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
89
+ "pnpm": {
90
+ "onlyBuiltDependencies": [
91
+ "esbuild"
92
+ ],
93
+ "overrides": {
94
+ "brace-expansion@<5.0.9": ">=5.0.9",
95
+ "js-yaml@<4.3.1": ">=4.3.1",
96
+ "nanoid@<3.3.17": ">=3.3.17",
97
+ "postcss@<8.5.23": ">=8.5.23"
98
+ }
85
99
  }
86
- }
100
+ }