@am_shork/attest 0.5.0 → 0.6.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.
@@ -1,7 +1,7 @@
1
1
  // High-level operations composing the core layers (design §9). These are
2
2
  // tool-agnostic; the CLI is a thin shell that calls them and renders the result.
3
3
  import { createLoader } from './loader.js';
4
- import { evalReader, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
4
+ import { evalReader, findChangeDirSpecs, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
5
5
  import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
6
6
  import { byCodeUnit } from './order.js';
7
7
  import { runAndCollect, BASE_EXCLUDE } from './runner.js';
@@ -13,6 +13,8 @@ import { renderMarkdown, staleIssue } from './render.js';
13
13
  import { mergeRedRecord, readRedRecord, redRecordPath, serialiseRedRecord, } from './red-record.js';
14
14
  import { DEFAULT_TARGET, resolveTargets } from './targets.js';
15
15
  import { writeAtomic } from './write.js';
16
+ import { applyMerge, mergedSpecPath } from './merge.js';
17
+ import { compilerIssue } from './compiler.js';
16
18
  import { mkdir, readFile } from 'node:fs/promises';
17
19
  import { basename, dirname, join } from 'node:path';
18
20
  import { relativePath } from './paths.js';
@@ -34,6 +36,9 @@ async function readRegistry(root, options, files) {
34
36
  }
35
37
  /** Static structural check (design §9: `attest check`). */
36
38
  export async function runCheck(root, options = {}) {
39
+ const unusable = compilerIssue();
40
+ if (unusable)
41
+ return [unusable];
37
42
  const scan = await scanProject(root);
38
43
  const { registry, issues } = await readRegistry(root, options, scan.reqsFiles);
39
44
  const plan = await parseSpecs(scan.specFiles, root);
@@ -42,8 +47,74 @@ export async function runCheck(root, options = {}) {
42
47
  ...validateStructure(registry, plan),
43
48
  ...detectPotentialDrift(registry, plan, plan.paramRefs),
44
49
  ...(await unclaimedProposedSpecIssues(root, scan, options)),
50
+ ...(await changeDirSpecIssues(root)),
51
+ ...proposedNameTakenIssues(root, scan),
45
52
  ];
46
53
  }
54
+ /**
55
+ * A proposed spec whose merged name is already taken (design §7).
56
+ *
57
+ * Merging a proposed spec is a rename in place, so the name it will take is
58
+ * decided the moment the file is created — and nothing stopped that name from
59
+ * being one a merged spec already holds. `isSpecFile` and `isProposedSpecFile`
60
+ * are deliberately disjoint, so `session.spec.ts` and `session.proposed.spec.ts`
61
+ * sit side by side without either predicate objecting; the collision only
62
+ * surfaces at the rename, where it would **destroy** the merged file.
63
+ *
64
+ * It is not a corner. A proposed spec is written beside the code it attests, so
65
+ * a change to a module that already has tests — the ordinary kind — reaches for
66
+ * exactly the colliding name.
67
+ *
68
+ * Reported here rather than only refused by `--apply` because the fix is a
69
+ * rename at authoring time and the cost of learning late is a merge stopped
70
+ * halfway. `--apply` checks it again regardless: this is the one failure mode of
71
+ * that command that would destroy work rather than halt, and a check living in a
72
+ * different command is not a guard.
73
+ */
74
+ function proposedNameTakenIssues(root, scan) {
75
+ const taken = new Set(scan.specFiles);
76
+ return scan.proposedSpecFiles
77
+ .filter((p) => taken.has(mergedSpecPath(p)))
78
+ .map((p) => ({
79
+ level: 'ERROR',
80
+ code: 'proposed-spec-name-taken',
81
+ file: relativePath(root, p),
82
+ message: `${relativePath(root, p)} merges to ${relativePath(root, mergedSpecPath(p))}, which already exists — the rename would overwrite it. ` +
83
+ `Give the proposed spec a name whose merged form is free; several spec files may sit beside one module, but only one may hold each name.`,
84
+ }));
85
+ }
86
+ /**
87
+ * A spec still sitting under `changes/` (design §7).
88
+ *
89
+ * The companion to `unclaimedProposedSpecIssues`, and the reason both exist is
90
+ * the same: a spec that runs nowhere. That one covers a file at the right path
91
+ * no delta reaches; this one covers a file no *scan* reaches, which is the
92
+ * blind spot moving specs to their merged location opened underneath it.
93
+ *
94
+ * `check` rather than the gate, for the reason ATX-47 gives about its sibling
95
+ * and one more of its own: the gate does speak here, but it says
96
+ * `uncovered-requirement` — the added ids genuinely have no scenario the plan
97
+ * can see — and sends an author who has written those scenarios off to write
98
+ * them again or defer a finished requirement. `check` is both the command a
99
+ * pipeline runs first and, until this, the one that said nothing at all.
100
+ *
101
+ * No `reqId`: the finding is about a file and a path, and no requirement is
102
+ * implicated by it — the ids the file covers are exactly what cannot be read
103
+ * without walking it.
104
+ */
105
+ async function changeDirSpecIssues(root) {
106
+ const files = await findChangeDirSpecs(root);
107
+ return files.map((abs) => {
108
+ const file = relativePath(root, abs);
109
+ return {
110
+ level: 'ERROR',
111
+ code: 'spec-in-change-dir',
112
+ file,
113
+ message: `${file} is a spec under changes/, which no command walks: it runs in neither the base suite nor any change's gate. ` +
114
+ `Move it beside the code it attests and name it *.proposed.spec.ts — that is where a change's specs live, and it is what lets merging one be a rename in place.`,
115
+ };
116
+ });
117
+ }
47
118
  /**
48
119
  * A `*.proposed.spec.ts` no change's delta claims (design §7).
49
120
  *
@@ -96,6 +167,22 @@ async function unclaimedProposedSpecIssues(root, scan, options) {
96
167
  }
97
168
  return issues;
98
169
  }
170
+ /**
171
+ * A verdict reached without running anything — the shape a refusal takes.
172
+ *
173
+ * `passed` is false rather than true: nothing ran, and a result claiming the
174
+ * suite passed because it never started is the silent-green this tool exists to
175
+ * refuse. The counts are zero for the same reason `Running 0 spec files` would
176
+ * be honest — they report what was looked at, and nothing was.
177
+ */
178
+ function emptyVerify(issues) {
179
+ return {
180
+ issues,
181
+ passed: false,
182
+ ok: false,
183
+ counts: { requirements: 0, scenarios: 0, specFiles: 0, attesting: 0 },
184
+ };
185
+ }
99
186
  /**
100
187
  * The spec files a run should execute: the ones that declare a `requirement()`.
101
188
  *
@@ -110,6 +197,9 @@ function attestingFiles(plan) {
110
197
  }
111
198
  /** Executable verification (design §9: `attest verify`). */
112
199
  export async function runVerify(root, options = {}) {
200
+ const unusable = compilerIssue();
201
+ if (unusable)
202
+ return emptyVerify([unusable]);
113
203
  const scan = await scanProject(root);
114
204
  const loader = await createLoader();
115
205
  let registry;
@@ -182,6 +272,9 @@ export async function runVerify(root, options = {}) {
182
272
  * shape it can take.
183
273
  */
184
274
  export async function runCover(root, options = {}) {
275
+ const unusable = compilerIssue();
276
+ if (unusable)
277
+ return { rows: [], issues: [unusable] };
185
278
  const scan = await scanProject(root);
186
279
  const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
187
280
  if (hasError(loadIssues))
@@ -209,6 +302,9 @@ export async function runCover(root, options = {}) {
209
302
  * every time a line moves in a test file.
210
303
  */
211
304
  export async function runRender(root, options = {}) {
305
+ const unusable = compilerIssue();
306
+ if (unusable)
307
+ return { markdown: '', issues: [unusable] };
212
308
  const { registry, issues } = await readRegistry(root, options);
213
309
  // A registry that failed to load yields a document that silently omits
214
310
  // requirements. An incomplete spec doc is worse than none, so refuse.
@@ -370,6 +466,24 @@ async function readDelta(root, changeName, options) {
370
466
  },
371
467
  };
372
468
  }
469
+ /**
470
+ * One entry per claimed proposed spec, carrying the ids it declares a scenario
471
+ * for — what `--apply` needs to know which registry to repoint its import at.
472
+ *
473
+ * Built from the plan rather than by re-parsing, so the files `--apply` touches
474
+ * are exactly the files the gate ran.
475
+ */
476
+ function claimedSpecsOf(root, plan) {
477
+ const byFile = new Map();
478
+ for (const sc of plan.scenarios) {
479
+ const set = byFile.get(sc.file) ?? new Set();
480
+ set.add(sc.reqId);
481
+ byFile.set(sc.file, set);
482
+ }
483
+ return [...byFile.entries()]
484
+ .sort(([a], [b]) => byCodeUnit(a, b))
485
+ .map(([file, ids]) => ({ file: join(root, file), reqIds: [...ids].sort(byCodeUnit) }));
486
+ }
373
487
  /** Split a plan over the proposed specs into the part one delta claims. */
374
488
  function claimedByDelta(proposed, delta) {
375
489
  const claimed = new Set(claimedIds(delta));
@@ -406,6 +520,7 @@ async function changeMergedPlan(root, delta, scan) {
406
520
  }
407
521
  /** Per-change progress (design §9: `attest status <change>`). */
408
522
  export async function runStatus(root, changeName, options = {}) {
523
+ const unusable = compilerIssue();
409
524
  const nothing = (issues) => ({
410
525
  change: changeName,
411
526
  rows: [],
@@ -416,6 +531,8 @@ export async function runStatus(root, changeName, options = {}) {
416
531
  // gate checks it: under `--eval` the name reaches a module that is executed.
417
532
  if (!isSafeChangeName(changeName))
418
533
  return nothing([invalidChangeNameIssue(changeName)]);
534
+ if (unusable)
535
+ return nothing([unusable]);
419
536
  const read = await readDelta(root, changeName, options);
420
537
  if ('issue' in read)
421
538
  return nothing([read.issue]);
@@ -427,16 +544,53 @@ export async function runStatus(root, changeName, options = {}) {
427
544
  }
428
545
  /** Archive gate for a change (design §8, §9: `attest archive <change>`). */
429
546
  export async function runArchive(root, changeName, options = {}) {
547
+ return (await archiveRun(root, changeName, options)).issues;
548
+ }
549
+ /**
550
+ * The gate, and — only if it passes — the merge it approved
551
+ * (`attest archive <change> --apply`).
552
+ *
553
+ * The gate runs first and unconditionally, in this same call. A merge acting on
554
+ * an earlier run's verdict would let an unfinished change be filed as done, and
555
+ * "done" having a hard definition is the whole claim of the tool; so a red gate
556
+ * writes nothing, and there is no flag that skips it.
557
+ */
558
+ export async function runArchiveApply(root, changeName, options = {}) {
559
+ const { issues, merge } = await archiveRun(root, changeName, options);
560
+ if (issues.some((i) => i.level === 'ERROR') || !merge)
561
+ return { issues, written: [] };
562
+ const result = await applyMerge({ root, changeName, ...merge });
563
+ // The gate's non-blocking output is kept: a WARNING the gate raised is still
564
+ // true after the merge, and dropping it here would make `--apply` quieter than
565
+ // the same command without it.
566
+ return { issues: [...issues, ...result.issues], written: result.written };
567
+ }
568
+ /**
569
+ * The gate, plus what finishing the merge would need.
570
+ *
571
+ * One function rather than a gate and a separate `--apply` path, because the
572
+ * merge must act on **this** run's verdict. A second traversal could reach a
573
+ * different answer than the one just printed — and a command able to file a
574
+ * change as done against a stale verdict removes the hard definition of "done"
575
+ * that is this tool's whole claim.
576
+ *
577
+ * `merge` is absent exactly when there is nothing to act on: a rejected name, an
578
+ * unreadable delta, or a registry that would not load.
579
+ */
580
+ async function archiveRun(root, changeName, options = {}) {
430
581
  // Checked before any loader starts: nothing should be resolved, let alone
431
582
  // evaluated, on behalf of a name we have already rejected.
432
583
  if (!isSafeChangeName(changeName))
433
- return [invalidChangeNameIssue(changeName)];
584
+ return { issues: [invalidChangeNameIssue(changeName)] };
585
+ const unusable = compilerIssue();
586
+ if (unusable)
587
+ return { issues: [unusable] };
434
588
  const scan = await scanProject(root);
435
589
  const loader = await createLoader();
436
590
  try {
437
- const { registry: base, issues } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
591
+ const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
438
592
  if (issues.some((i) => i.level === 'ERROR'))
439
- return issues;
593
+ return { issues };
440
594
  const deltaPath = changeDeltaPath(root, changeName);
441
595
  let delta;
442
596
  try {
@@ -453,11 +607,11 @@ export async function runArchive(root, changeName, options = {}) {
453
607
  delta = mod.default;
454
608
  }
455
609
  catch (err) {
456
- return [changeNotFoundIssue(root, deltaPath, changeName, err)];
610
+ return { issues: [changeNotFoundIssue(root, deltaPath, changeName, err)] };
457
611
  }
458
612
  const applied = applyDelta(base, delta);
459
613
  if (applied.issues.length > 0)
460
- return applied.issues;
614
+ return { issues: applied.issues };
461
615
  // Static plan = merged base suite + this change's specs (design §8), by the
462
616
  // same function `status` reports against — a progress report computed over a
463
617
  // different spec set than the gate uses would be a report about nothing.
@@ -498,14 +652,27 @@ export async function runArchive(root, changeName, options = {}) {
498
652
  // Against `base`, not `applied`: the child run imported the registry from
499
653
  // disk, so what matters is what that file has, not what the gate computed.
500
654
  const unmergedAddedIds = added.filter((id) => !Object.hasOwn(base, id));
501
- return evaluateGate({
502
- registry: applied.registry,
503
- plan,
504
- run,
505
- addedIds: added,
506
- unmergedAddedIds,
507
- firstRun,
508
- });
655
+ return {
656
+ issues: evaluateGate({
657
+ registry: applied.registry,
658
+ plan,
659
+ run,
660
+ addedIds: added,
661
+ unmergedAddedIds,
662
+ firstRun,
663
+ }),
664
+ merge: {
665
+ delta,
666
+ base,
667
+ applied: applied.registry,
668
+ prefixOwners,
669
+ // The plan's proposed files, not every proposed file on disk: `--apply`
670
+ // renames what *this* change claims, and a sibling proposal's spec is
671
+ // not this change's to move.
672
+ claimedSpecs: claimedSpecsOf(root, claimedByDelta(await parseSpecs(scan.proposedSpecFiles, root), delta)),
673
+ mergedSpecs: scan.specFiles,
674
+ },
675
+ };
509
676
  }
510
677
  finally {
511
678
  await loader.close();
@@ -163,6 +163,18 @@ Writing it at its merged location is what makes merging it a rename. Its relativ
163
163
  imports resolve now exactly as they will afterwards, so \`../fog\` never becomes
164
164
  \`../../../lib/game/fog\` and back again.
165
165
 
166
+ Two things to get right when you create the file:
167
+
168
+ - **Do not put it under \`changes/\`.** Nothing walks that directory, so a spec
169
+ left there executes in no suite and no gate — \`spec-in-change-dir\` from
170
+ \`attest check\`. The change folder holds \`proposal.md\`,
171
+ \`requirements.delta.ts\` and \`first-run.json\`, and nothing else.
172
+ - **Pick a name whose merged form is free.** Merging renames
173
+ \`fog.proposed.spec.ts\` to \`fog.spec.ts\`, so if that module already has a
174
+ \`fog.spec.ts\` the rename would overwrite it — \`proposed-spec-name-taken\`.
175
+ Name it \`fog.2fa.proposed.spec.ts\` instead. Several spec files may sit beside
176
+ one module; Attest attributes each by the ids it declares, not by its path.
177
+
166
178
  \`\`\`ts
167
179
  // changes/add-2fa/requirements.delta.ts
168
180
  import { delta } from '@am_shork/attest/define';
@@ -278,6 +290,9 @@ once. Branch on \`issues[].code\`, never on \`message\`:
278
290
  | \`registry-not-static\` | a registry file is not a literal the engine can read |
279
291
  | \`add-conflict\` | the delta adds an id that already exists with different content |
280
292
  | \`change-not-found\` | no \`requirements.delta.ts\` for that name |
293
+ | \`proposed-spec-name-taken\` | a proposed spec's merged name is already held by another spec |
294
+ | \`apply-unsupported-delta\` | \`--apply\` writes back ADDED only, and this delta carries more |
295
+ | \`apply-no-prefix-owner\` | no registry file owns the prefix of an id this change adds |
281
296
 
282
297
  ### Four things you must not do
283
298
 
@@ -309,15 +324,36 @@ failure this framework exists to make visible:
309
324
 
310
325
  Then, and not before:
311
326
 
312
- 1. Merge \`requirements.delta.ts\` into the main registry, and rename each of the
313
- change's \`*.proposed.spec.ts\` to \`*.spec.ts\` **in place**. Nothing moves and
314
- no import changes; if you find yourself editing a specifier, the spec was not
315
- written at its merged location and stage 1 was the place to fix that.
316
- 2. Move \`changes/<name>/\` to \`archive/<date>-<name>/\`.
317
- 3. If the project commits a rendering, regenerate it: \`attest render --out
318
- <file>\`. A committed document that no longer matches the registry is a
319
- \`stale-spec-doc\` ERROR.
320
- 4. Run \`attest verify\` on the merged result and report it.
327
+ \`\`\`
328
+ attest archive <name> --apply
329
+ \`\`\`
330
+
331
+ That finishes the merge the verdict just approved: it splices the change's ADDED
332
+ requirements into the registry file owning their id prefix, repoints each proposed
333
+ spec's import of \`requirements.delta.ts\` at that registry, renames the specs in
334
+ place, and moves \`changes/<name>/\` to \`archive/<date>-<name>/\`. It re-runs the
335
+ gate first and writes nothing if that fails, and it prints every path it touched.
336
+ If it stops partway, run it again — each step is derived from the tree as it is,
337
+ so a second run finishes the job rather than repeating it.
338
+
339
+ Two things it does not do, and both are still yours:
340
+
341
+ 1. **Regenerate a committed rendering.** Nothing records where yours lives, so run
342
+ \`attest render --out <file>\` afterwards. A document that no longer matches the
343
+ registry is a \`stale-spec-doc\` ERROR.
344
+ 2. **Run the merged suite.** \`attest verify\`, on the result, reported.
345
+
346
+ **It writes back ADDED only.** A delta also carrying RENAMED, REMOVED or MODIFIED
347
+ is refused whole as \`apply-unsupported-delta\`, with nothing written — the gate
348
+ still checked all four, so nothing about the change is unverified, but the rest is
349
+ merged by hand.
350
+
351
+ Merging by hand, when it refuses: splice the delta's entries into the registry
352
+ file that owns their prefix; then for each \`*.proposed.spec.ts\`, **repoint its
353
+ import of \`requirements.delta.ts\` at that registry** and rename it to
354
+ \`*.spec.ts\` in place. That import is the one specifier a merge changes, and it
355
+ changes because the delta moves to \`archive/\` — the spec file itself does not
356
+ move, so nothing else about it does either. Then move the change folder.
321
357
 
322
358
  ## The commands
323
359
 
@@ -327,7 +363,7 @@ Then, and not before:
327
363
  | \`attest verify\` | runs the suite, then reports coverage and drift. |
328
364
  | \`attest cover\` | which requirements lack a scenario. |
329
365
  | \`attest render\` | the registry as Markdown for human readers; \`--check\` gates a committed copy. |
330
- | \`attest archive <change>\` | the completion gate for a proposed change. |
366
+ | \`attest archive <change>\` | the completion gate for a proposed change; \`--apply\` also performs the merge it approves. |
331
367
  | \`attest status <change>\` | what that gate still wants, without running the suite. |
332
368
 
333
369
  \`verify\` starts the child run **isolated** — it does not read \`vitest.config.ts\`,
@@ -0,0 +1,52 @@
1
+ import type { Registry, Requirement } from './types.js';
2
+ /**
3
+ * One registry entry, at `indent`, with no trailing comma.
4
+ *
5
+ * The separator is the caller's because only the caller knows what it is
6
+ * inserting between: continuing a list needs a comma before, opening an empty
7
+ * body needs one after, and putting that decision here would mean this function
8
+ * had to be told which case it was in anyway.
9
+ *
10
+ * `params` and `outOfScope` are omitted when empty rather than written as `{}`
11
+ * and `[]`. The schema defaults both, so the two spellings mean the same thing,
12
+ * and the shorter one is what a person writing this entry by hand would have
13
+ * produced — which is the standard for a file `--apply` is merging into rather
14
+ * than generating.
15
+ */
16
+ export declare function requirementSource(id: string, req: Requirement, indent: string): string;
17
+ /**
18
+ * `source` with `additions` inserted into its registry literal.
19
+ *
20
+ * `undefined` when the file holds no literal this can be inserted into — the
21
+ * same condition `readRegistrySource` reports, and one the caller has already
22
+ * checked, so it is a refusal to guess rather than a diagnosis.
23
+ *
24
+ * Ids are inserted in code-unit order for the reason they are emitted in it:
25
+ * the same delta must produce the same file twice.
26
+ */
27
+ export declare function spliceRequirements(file: string, source: string, additions: Registry): string | undefined;
28
+ /**
29
+ * `source` with every import of `from` repointed at `to`.
30
+ *
31
+ * The second edit `--apply` makes to a file it did not write, and the one the
32
+ * design record missed. Merging a proposed spec was described as a rename in
33
+ * place, which is true of its *location*: the file already sits where it lands,
34
+ * so no relative specifier moves. But a stage-1 scenario reads its proposed
35
+ * params out of the change's delta (ATX-48, and the whole reason a delta reads
36
+ * as the registry it proposes), and the delta is what step 3 moves into
37
+ * `archive/`. Renaming without this leaves a merged spec importing a path that
38
+ * no longer exists — a suite that loads nothing, reported as `declared-not-run`
39
+ * against scenarios that are perfectly good.
40
+ *
41
+ * The expression around the import needs nothing done to it: `reqs['AUTH-7']
42
+ * .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
43
+ * So this replaces one string literal and touches nothing else — the same
44
+ * discipline as the splice, for the same reason.
45
+ *
46
+ * The extension is taken from the specifier being replaced rather than chosen
47
+ * here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
48
+ * its module resolution, uniform across the project, and already answered by the
49
+ * specifier sitting in front of us.
50
+ */
51
+ export declare function repointImport(file: string, source: string, from: string, to: string): string;
52
+ //# sourceMappingURL=splice.d.ts.map
@@ -0,0 +1,189 @@
1
+ // Writing a requirement into a registry file as text (design §7).
2
+ //
3
+ // This is the half of `--apply` the `AGENTS.md` rejection was about. That
4
+ // proposal was refused because "each of its failure modes is destructive on a
5
+ // file the user cannot regenerate", and the argument for why merging a delta is
6
+ // different has one load-bearing clause: the result of a splice is checkable by
7
+ // re-reading it. That clause only holds if the splice is a **pure insertion**.
8
+ //
9
+ // So nothing here renders a registry. Rendering one out of a `Registry` object
10
+ // would be far easier and would silently drop every comment, every blank line
11
+ // and every layout choice in a hand-written file — destroying exactly what
12
+ // cannot be regenerated, while passing a re-read with flying colours because the
13
+ // *values* all survived. Instead `registryInsertionPoint` hands back an offset,
14
+ // and the only edit made to the file is text inserted at it. Every other byte is
15
+ // the byte that was already there, which is a property a test can state.
16
+ //
17
+ // The generated text is therefore the one thing here that has to be right on its
18
+ // own, and it is generated conservatively: strings are escaped rather than
19
+ // interpolated, params are emitted in code-unit key order so the same delta
20
+ // produces the same bytes twice, and anything the schema does not permit cannot
21
+ // reach this file because the gate validated the delta before `--apply` ran.
22
+ import ts from 'typescript';
23
+ import { dirname, relative, resolve } from 'node:path';
24
+ import { registryInsertionPoint } from './static-registry.js';
25
+ import { toPosixPath } from './paths.js';
26
+ import { byCodeUnit } from './order.js';
27
+ /**
28
+ * A TypeScript single-quoted string literal holding exactly `value`.
29
+ *
30
+ * Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
31
+ * the registries this writes into are single-quoted throughout, and a merged
32
+ * entry that arrives double-quoted is a diff hunk about quotation marks in the
33
+ * middle of a merge the reviewer is trying to read. Control characters go out as
34
+ * `\uXXXX` rather than raw, so a statement someone pasted a newline into cannot
35
+ * produce a file that no longer parses.
36
+ */
37
+ function tsString(value) {
38
+ let out = "'";
39
+ for (const ch of value) {
40
+ const code = ch.codePointAt(0) ?? 0;
41
+ if (ch === '\\')
42
+ out += '\\\\';
43
+ else if (ch === "'")
44
+ out += "\\'";
45
+ else if (ch === '\n')
46
+ out += '\\n';
47
+ else if (ch === '\r')
48
+ out += '\\r';
49
+ else if (ch === '\t')
50
+ out += '\\t';
51
+ else if (code < 0x20 || code === 0x7f)
52
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
53
+ else
54
+ out += ch;
55
+ }
56
+ return `${out}'`;
57
+ }
58
+ /** A param key, bare when it is a plain identifier and quoted when it is not. */
59
+ function keySource(key) {
60
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : tsString(key);
61
+ }
62
+ function paramSource(value) {
63
+ if (Array.isArray(value))
64
+ return `[${value.map((v) => paramSource(v)).join(', ')}]`;
65
+ return typeof value === 'string' ? tsString(value) : String(value);
66
+ }
67
+ /**
68
+ * One registry entry, at `indent`, with no trailing comma.
69
+ *
70
+ * The separator is the caller's because only the caller knows what it is
71
+ * inserting between: continuing a list needs a comma before, opening an empty
72
+ * body needs one after, and putting that decision here would mean this function
73
+ * had to be told which case it was in anyway.
74
+ *
75
+ * `params` and `outOfScope` are omitted when empty rather than written as `{}`
76
+ * and `[]`. The schema defaults both, so the two spellings mean the same thing,
77
+ * and the shorter one is what a person writing this entry by hand would have
78
+ * produced — which is the standard for a file `--apply` is merging into rather
79
+ * than generating.
80
+ */
81
+ export function requirementSource(id, req, indent) {
82
+ const inner = `${indent} `;
83
+ const lines = [
84
+ `${indent}${tsString(id)}: {`,
85
+ `${inner}statement: ${tsString(req.statement)},`,
86
+ `${inner}rationale: ${tsString(req.rationale)},`,
87
+ ];
88
+ // Code-unit key order, so one delta applied twice writes the same bytes — the
89
+ // property `--apply`'s re-runnability rests on, and the reason `apply.ts`
90
+ // sorts the same way when it canonicalises for `add-conflict`.
91
+ const params = Object.entries(req.params).sort(([a], [b]) => byCodeUnit(a, b));
92
+ if (params.length > 0) {
93
+ const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
94
+ lines.push(`${inner}params: { ${body} },`);
95
+ }
96
+ if (req.outOfScope.length > 0) {
97
+ lines.push(`${inner}outOfScope: [${req.outOfScope.map((s) => tsString(s)).join(', ')}],`);
98
+ }
99
+ lines.push(`${indent}}`);
100
+ return lines.join('\n');
101
+ }
102
+ /**
103
+ * `source` with `additions` inserted into its registry literal.
104
+ *
105
+ * `undefined` when the file holds no literal this can be inserted into — the
106
+ * same condition `readRegistrySource` reports, and one the caller has already
107
+ * checked, so it is a refusal to guess rather than a diagnosis.
108
+ *
109
+ * Ids are inserted in code-unit order for the reason they are emitted in it:
110
+ * the same delta must produce the same file twice.
111
+ */
112
+ export function spliceRequirements(file, source, additions) {
113
+ const ids = Object.keys(additions).sort(byCodeUnit);
114
+ if (ids.length === 0)
115
+ return source;
116
+ const point = registryInsertionPoint(file, source);
117
+ if (!point)
118
+ return undefined;
119
+ const entries = ids
120
+ .map((id) => requirementSource(id, additions[id], point.indent))
121
+ .join(',\n');
122
+ const text = point.leadingComma ? `,\n${entries}` : `\n${entries},\n`;
123
+ return source.slice(0, point.offset) + text + source.slice(point.offset);
124
+ }
125
+ /**
126
+ * `source` with every import of `from` repointed at `to`.
127
+ *
128
+ * The second edit `--apply` makes to a file it did not write, and the one the
129
+ * design record missed. Merging a proposed spec was described as a rename in
130
+ * place, which is true of its *location*: the file already sits where it lands,
131
+ * so no relative specifier moves. But a stage-1 scenario reads its proposed
132
+ * params out of the change's delta (ATX-48, and the whole reason a delta reads
133
+ * as the registry it proposes), and the delta is what step 3 moves into
134
+ * `archive/`. Renaming without this leaves a merged spec importing a path that
135
+ * no longer exists — a suite that loads nothing, reported as `declared-not-run`
136
+ * against scenarios that are perfectly good.
137
+ *
138
+ * The expression around the import needs nothing done to it: `reqs['AUTH-7']
139
+ * .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
140
+ * So this replaces one string literal and touches nothing else — the same
141
+ * discipline as the splice, for the same reason.
142
+ *
143
+ * The extension is taken from the specifier being replaced rather than chosen
144
+ * here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
145
+ * its module resolution, uniform across the project, and already answered by the
146
+ * specifier sitting in front of us.
147
+ */
148
+ export function repointImport(file, source, from, to) {
149
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
150
+ const dir = dirname(file);
151
+ const edits = [];
152
+ for (const statement of sf.statements) {
153
+ if (!ts.isImportDeclaration(statement))
154
+ continue;
155
+ const spec = statement.moduleSpecifier;
156
+ if (!ts.isStringLiteral(spec))
157
+ continue;
158
+ if (!resolvesTo(dir, spec.text, from))
159
+ continue;
160
+ const ext = spec.text.endsWith('.js') ? '.js' : spec.text.endsWith('.ts') ? '.ts' : '';
161
+ let target = toPosixPath(relative(dir, to));
162
+ if (ext)
163
+ target = target.replace(/\.[^./]+$/, ext);
164
+ // A bare `x.reqs.js` is a package specifier, not a sibling file.
165
+ if (!target.startsWith('.'))
166
+ target = `./${target}`;
167
+ // Inside the quotes: the file's own quote style is left exactly as it was.
168
+ edits.push({ start: spec.getStart(sf) + 1, end: spec.getEnd() - 1, text: target });
169
+ }
170
+ let out = source;
171
+ for (const edit of edits.reverse()) {
172
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
173
+ }
174
+ return out;
175
+ }
176
+ /**
177
+ * Whether `specifier`, written in a file under `dir`, names `target`.
178
+ *
179
+ * The `.js`-for-`.ts` spelling is accepted because NodeNext resolution requires
180
+ * it, and a project using it writes every specifier that way — including the one
181
+ * this is looking for.
182
+ */
183
+ function resolvesTo(dir, specifier, target) {
184
+ if (!specifier.startsWith('.'))
185
+ return false;
186
+ const resolved = resolve(dir, specifier);
187
+ return resolved === target || resolved.replace(/\.js$/, '.ts') === target;
188
+ }
189
+ //# sourceMappingURL=splice.js.map
@@ -39,4 +39,34 @@ export type DeltaReadResult = {
39
39
  * (write the value inline, or `--eval`) is the same sentence either way.
40
40
  */
41
41
  export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
42
+ /**
43
+ * Where a new entry may be written into a registry file's literal, as an offset
44
+ * into its source (design §7).
45
+ *
46
+ * The insertion point rather than a rewritten file, because `--apply` must not
47
+ * regenerate a `*.reqs.ts`. Every registry this tool merges into is hand-written
48
+ * and hand-commented, and rendering one back out of a `Registry` object would
49
+ * silently drop every comment and every choice of layout in it — the
50
+ * "destructive on a file the user cannot regenerate" shape the `AGENTS.md`
51
+ * proposal was rejected for. An offset lets the splice be a pure insertion: every
52
+ * other byte of the file is the byte that was there before, which is a property
53
+ * that can be stated and tested rather than hoped for.
54
+ *
55
+ * Lives here because this is the module that already knows how to find the
56
+ * literal, and the one place `typescript` is imported for that job. A second
57
+ * walker would be a second answer to "where does this registry's body end".
58
+ *
59
+ * `undefined` when the file is not a registry this tool can read — the same
60
+ * condition `readRegistrySource` reports as `registry-not-static` or
61
+ * `registry-no-default`, and the caller has already run that check.
62
+ */
63
+ export interface RegistryInsertion {
64
+ /** Offset to insert at. Everything before and after it is preserved. */
65
+ offset: number;
66
+ /** The indentation the file's existing entries use, reproduced for new ones. */
67
+ indent: string;
68
+ /** Whether the insertion has to open with a `,` — false only for an empty registry. */
69
+ leadingComma: boolean;
70
+ }
71
+ export declare function registryInsertionPoint(file: string, source: string): RegistryInsertion | undefined;
42
72
  //# sourceMappingURL=static-registry.d.ts.map