declapract-typescript-ehmpathy 0.49.8 → 0.49.9

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.
@@ -0,0 +1,950 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { executeApply } from 'declapract';
6
+ import { genTempDir, given, then, useBeforeAll, useThen, when } from 'test-fns';
7
+
8
+ import { check as checkOfRhachet } from '../rhachet/best-practice/.gitignore.declapract';
9
+ import { check as checkOfGit } from './best-practice/.gitignore.declapract';
10
+
11
+ // executeApply is slow (90+ seconds per invocation due to full practice evaluation)
12
+ // .note = this suite scopes each apply with `file: '.gitignore'`, which narrows the
13
+ // work enough that a run settles in seconds. the cap matches the single-apply
14
+ // suites rather than a multiple of them, so a hang surfaces promptly instead
15
+ // of after a budget sized for a slowness we do not have.
16
+ jest.setTimeout(300_000); // 5 minutes
17
+
18
+ /**
19
+ * .what = env that cuts git off from the host's user and system config
20
+ * .why = `rule.require.hermetic-tests`: "would this test produce the same result on a
21
+ * fresh CI runner with no dotfiles?" without this, NO. git resolves ignores
22
+ * from the repo's `.gitignore` PLUS `core.excludesFile` from the host's global
23
+ * gitconfig -- and `.cache/` is among the most common entries in a personal
24
+ * global gitignore. on such a machine every `isPathIgnored` assertion here
25
+ * would pass no matter WHAT the practice declares, so the clamps would be
26
+ * hollow and their teeth would not bite. a false green, which is worse than a
27
+ * false red because no one investigates it.
28
+ * .note = latent on this host, not live -- measured: `git check-ignore -v .DS_Store
29
+ * foo.swp Thumbs.db .idea/x scratch~ tags` matches not one, so no global
30
+ * excludes file is in play here. that is exactly why it had to be closed by
31
+ * construction rather than by observation: the leak is invisible on the
32
+ * machine that would introduce it.
33
+ * .note = `/dev/null` for both, per git >= 2.32. it also covers `git init`, which
34
+ * otherwise reads the host's `init.templateDir` and could seed the temp repo
35
+ * with hooks or an `info/exclude` of its own.
36
+ */
37
+ const envGitHermetic = {
38
+ ...process.env,
39
+ GIT_CONFIG_GLOBAL: '/dev/null',
40
+ GIT_CONFIG_SYSTEM: '/dev/null',
41
+ };
42
+
43
+ /**
44
+ * .what = runs git with the host's user and system config cut off, always
45
+ * .why = the pit of success. an env passed by hand at each call site leaves the NEXT
46
+ * call site free to forget it and silently reopen the leak above -- and the
47
+ * reopened leak shows as a false GREEN, so no run would ever report it. one
48
+ * operation that cannot be called without the env removes the choice
49
+ * (`rule.prefer.prevent-over-correct`: design the error out rather than
50
+ * document it).
51
+ * .note = `[case4]` deliberately does NOT route through this. it is the case that
52
+ * proves the env works, so it must vary the env -- the one place where the
53
+ * choice is the subject rather than a hazard.
54
+ * .note = `run*` is NOT one of the sanctioned verbs, and that is deliberate.
55
+ * `rule.require.get-set-gen-verbs` scopes itself to `domain.operations/` and
56
+ * dao methods; a test-local operation is outside it, and the repo's own
57
+ * precedent agrees -- `runGuard` in husky's integration test, `read` and
58
+ * `applyForget` in persist-with-rds's. this mirrors `runGuard` on purpose.
59
+ * the corollary matters more than the exemption: an operation that DOES reach
60
+ * into the sanctioned vocabulary inherits its contract, which is why the two
61
+ * `get*` operations below carry the `All` cardinality the rule demands.
62
+ */
63
+ const runGit = (input: { cwd: string; args: string[] }): string =>
64
+ execFileSync('git', input.args, {
65
+ cwd: input.cwd,
66
+ env: envGitHermetic,
67
+ }).toString();
68
+
69
+ /**
70
+ * .what = does git actually ignore this path, per the rules in the repo?
71
+ * .why = line presence and path coverage are different claims. a test that only
72
+ * asserts `.cache/` is declared would stay green if someone re-scoped it to
73
+ * `/.cache/`, which would silently un-ignore `.agent/.cache/` everywhere.
74
+ * this asks git itself.
75
+ * .note = execFileSync matches the repo's one prior git-in-a-tempdir precedent,
76
+ * src/practices/husky/check.timestamps.play.declapract.integration.test.ts
77
+ * .note = this is a PURE query -- it creates no file and touches no disk. verified:
78
+ * `git check-ignore` matches a path that does not exist, dir patterns
79
+ * included, e.g. `zzz-absent-tree/.cache/x.json` -> `.gitignore:7:.cache/`.
80
+ * an earlier draft wrote a scratch file first, which made an `is*` query
81
+ * mutate the filesystem and let a probe under the symlinked `node_modules`
82
+ * write OUTSIDE the temp repo. no write, no escape, and the name is honest.
83
+ */
84
+ const isPathIgnored = (input: { cwd: string; filepath: string }): boolean => {
85
+ // `git check-ignore` exits 0 when ignored, 1 when not -- both are answers, not errors
86
+ // .note = the `as` cast below is the external-boundary exemption of
87
+ // rule.forbid.as-cast. @types/node types the throw as plain `Error`, but
88
+ // `execFileSync` attaches a numeric `status` (the child's exit code) at
89
+ // runtime -- and here that code IS the answer. no `ExecFileSyncError` type
90
+ // is declared upstream, so there is no narrower type to reach for.
91
+ // .fix = drop the cast once @types/node types the thrown error.
92
+ try {
93
+ runGit({ cwd: input.cwd, args: ['check-ignore', '--', input.filepath] });
94
+ return true;
95
+ } catch (error) {
96
+ if ((error as { status?: number }).status === 1) return false;
97
+ throw error;
98
+ }
99
+ };
100
+
101
+ /**
102
+ * .what = what git reports as untracked or modified, one porcelain line per path
103
+ * .why = `isPathIgnored` is a PURE query -- it asks git about a path that does not
104
+ * exist, one path at a time. this asks about the tree as it ACTUALLY stands,
105
+ * with the scratch materialized and every rule integrated into one answer.
106
+ * that is the precondition `git tree del` refuses on, and it is the
107
+ * instrument the wish's fourth acceptance criterion names by name.
108
+ * .note = an earlier draft justified this by claiming check-ignore and status diverge
109
+ * on a TRACKED path -- that check-ignore would call it ignored while status
110
+ * still listed it. that is backwards, and measured so:
111
+ * tracked `.route/.bouncer.cache.json`, default -> exit 1, NOT ignored
112
+ * the same path, `--no-index` -> ignored by `.route/.gitignore:2:*`
113
+ * check-ignore consults the index, so a tracked path reports NOT ignored and
114
+ * the suite's `isPathIgnored(...) === true` assertions would go RED. the #514
115
+ * already-committed case is therefore covered, and needs no clamp of its own.
116
+ * .note = `--porcelain` rather than `--short` because its format is a documented
117
+ * stability guarantee across git versions; `--short` is for humans.
118
+ */
119
+ const getAllStatusLines = (input: { cwd: string }): string[] =>
120
+ runGit({ cwd: input.cwd, args: ['status', '--porcelain'] })
121
+ .split('\n')
122
+ .filter((line) => !!line);
123
+
124
+ /**
125
+ * .what = the lines a file repeats, if any
126
+ * .why = the wish's first acceptance clause is "a repo that already carries a line
127
+ * stays untouched -- NO DUPLICATE LINE". the unit clamps prove that for the
128
+ * cache lines and for rhachet's own set; this states it of the whole settled
129
+ * file, at the grain where two practices each append an ordered tail.
130
+ * .note = returns the repeated lines rather than a boolean, so a failure names WHICH
131
+ * line duplicated instead of only that one did.
132
+ */
133
+ const getAllLinesDuplicated = (input: { contents: string }): string[] => {
134
+ const lines = input.contents.split('\n').filter((line) => !!line);
135
+
136
+ return lines.filter((line, index) => lines.indexOf(line) !== index);
137
+ };
138
+
139
+ /**
140
+ * .what = end to end proof of the gitignore practices against a real declapract run
141
+ * .why = the unit clamps prove each `fix` in isolation. they cannot see the pipeline:
142
+ * whether declapract wires the declared path to the target file, whether
143
+ * `check` gates `fix`, and -- above all -- what happens when TWO practices
144
+ * declare the same repo-root `.gitignore`. only a real run answers that.
145
+ */
146
+ describe('gitignore practices', () => {
147
+ given('[case1] a repo whose .gitignore lacks both cache dirs', () => {
148
+ const tempDir = genTempDir({
149
+ slug: 'git-cache-dirs',
150
+ clone: './src/practices/git/.test/assets/repo-without-cache-ignores',
151
+ symlink: [
152
+ { at: 'declarations', to: './.test/assets/git/declarations' },
153
+ { at: 'node_modules', to: 'node_modules' },
154
+ ],
155
+ });
156
+
157
+ useBeforeAll(async () => {
158
+ // git init so `git check-ignore` has a repo to answer within
159
+ // .note = `-q` matches the husky precedent; without it the init banner
160
+ // pollutes the test run's stdout
161
+ runGit({ cwd: tempDir, args: ['init', '-q'] });
162
+ }, 30_000);
163
+
164
+ when('[t0] before any apply', () => {
165
+ /**
166
+ * .why = a diff is only legible against a before. the after-snapshot in [t3]
167
+ * shows what settled; this one shows what it settled FROM.
168
+ */
169
+ then('the input file matches snapshot', async () => {
170
+ const contents = await fs.readFile(
171
+ path.join(tempDir, '.gitignore'),
172
+ 'utf-8',
173
+ );
174
+
175
+ expect(contents).toMatchSnapshot('.gitignore -- before');
176
+ });
177
+
178
+ then('neither cache dir is ignored yet', () => {
179
+ expect(
180
+ isPathIgnored({ cwd: tempDir, filepath: '.cache/scratch.json' }),
181
+ ).toEqual(false);
182
+ expect(
183
+ isPathIgnored({
184
+ cwd: tempDir,
185
+ filepath: '.agent/.cache/roles.json',
186
+ }),
187
+ ).toEqual(false);
188
+ });
189
+ });
190
+
191
+ when('[t1] the git practice is applied', () => {
192
+ useBeforeAll(async () => {
193
+ await executeApply({
194
+ config: path.join(tempDir, 'declapract.use.yml'),
195
+ practice: 'git',
196
+ file: '.gitignore',
197
+ });
198
+ }, 120_000);
199
+
200
+ // .note = one read, shared across the assertions below, per
201
+ // rule.prefer.usethen-and-usewhen-for-shared-results. the value is
202
+ // wrapped in an object because useThen hands back a proxy, and a bare
203
+ // string proxy compares as a char-indexed object.
204
+ const fileAfter = useThen('the apply settles', async () => ({
205
+ lines: (
206
+ await fs.readFile(path.join(tempDir, '.gitignore'), 'utf-8')
207
+ ).split('\n'),
208
+ }));
209
+
210
+ then('both cache dirs are declared', () => {
211
+ expect(fileAfter.lines).toContain('.cache/');
212
+ expect(fileAfter.lines).toContain('.agent/.cache/');
213
+ });
214
+
215
+ then('both cache PATHS are ignored by git itself', () => {
216
+ expect(
217
+ isPathIgnored({ cwd: tempDir, filepath: '.cache/scratch.json' }),
218
+ ).toEqual(true);
219
+ expect(
220
+ isPathIgnored({
221
+ cwd: tempDir,
222
+ filepath: '.agent/.cache/roles.json',
223
+ }),
224
+ ).toEqual(true);
225
+ });
226
+
227
+ then("the repo's own custom ignores survive the union", () => {
228
+ expect(fileAfter.lines).toContain('.idea');
229
+ });
230
+
231
+ then('the node_modules negations stay after their target', () => {
232
+ expect(fileAfter.lines.indexOf('node_modules')).toBeLessThan(
233
+ fileAfter.lines.indexOf('!.test*/**/node_modules'),
234
+ );
235
+ });
236
+
237
+ /**
238
+ * .what = the negation is LIVE, not merely last
239
+ * .why = line order and rule effect are different claims, the same way line
240
+ * presence and path coverage are (see `both cache PATHS are ignored`).
241
+ * the index assertion above is a proxy: it reads the file, never git.
242
+ * a negation that git does not honor -- because it was hoisted above
243
+ * its target, or because the target pattern was re-scoped -- still
244
+ * satisfies the proxy while it silently re-ignores every test-fixture
245
+ * node_modules the tail exists to protect. this asks git instead.
246
+ * .note = the hoist is not hypothetical. cicd-app-react-native-expo declared
247
+ * this same file with no ordered tail for ~22 months, so its sort put
248
+ * `!` above the alphanumerics -- exactly the inert-negation state
249
+ * (#537, fixed 2026-07-30). re-derive any declaration of this file by
250
+ * hand and this assertion reproduces the state on demand.
251
+ */
252
+ then('the node_modules negation is honored by git itself', () => {
253
+ // a plain node_modules is ignored, as the target pattern demands
254
+ // .note = probed at depth, not at the root -- the temp repo mounts its root
255
+ // `node_modules` as a symlink, and git refuses a pathspec that
256
+ // traverses one ("beyond a symbolic link")
257
+ expect(
258
+ isPathIgnored({
259
+ cwd: tempDir,
260
+ filepath: 'packages/demo/node_modules/pkg.js',
261
+ }),
262
+ ).toEqual(true);
263
+
264
+ // ...but a test-fixture one is re-included, because the negation is live
265
+ expect(
266
+ isPathIgnored({
267
+ cwd: tempDir,
268
+ filepath: '.test/assets/node_modules/pkg.js',
269
+ }),
270
+ ).toEqual(false);
271
+ });
272
+
273
+ then('no declapract template syntax leaked into the output', () => {
274
+ expect(fileAfter.lines.join('\n')).not.toContain('@declapract{');
275
+ });
276
+ });
277
+
278
+ when('[t2] the rhachet practice is applied on top', () => {
279
+ // .note = useBeforeAll hands back a proxy, so the value must be reached through
280
+ // a property. a bare string proxy compares as a char-indexed object.
281
+ const fileBefore = useBeforeAll(async () => ({
282
+ contents: await fs.readFile(path.join(tempDir, '.gitignore'), 'utf-8'),
283
+ }));
284
+
285
+ useBeforeAll(async () => {
286
+ await executeApply({
287
+ config: path.join(tempDir, 'declapract.use.yml'),
288
+ practice: 'rhachet',
289
+ file: '.gitignore',
290
+ });
291
+ }, 120_000);
292
+
293
+ // .note = one read, shared across the assertions below, per
294
+ // rule.prefer.usethen-and-usewhen-for-shared-results
295
+ const fileAfterRhachet = useThen('the second apply settles', async () => ({
296
+ lines: (
297
+ await fs.readFile(path.join(tempDir, '.gitignore'), 'utf-8')
298
+ ).split('\n'),
299
+ }));
300
+
301
+ /**
302
+ * .what = the convergence proof, at the pipeline grain
303
+ * .why = both practices declare this one file. they converge only because each
304
+ * unions the file's extant lines AND emits an identical ordered tail.
305
+ * if that ever drifts, `declapract fix` rewrites the file on every run,
306
+ * forever -- and a hoisted negation turns inert, which silently
307
+ * un-ignores the test-fixture node_modules the tail exists to protect.
308
+ */
309
+ then('the file is byte-identical -- the practices converge', async () => {
310
+ const contentsAfterRhachet = await fs.readFile(
311
+ path.join(tempDir, '.gitignore'),
312
+ 'utf-8',
313
+ );
314
+
315
+ expect(contentsAfterRhachet).toEqual(fileBefore.contents);
316
+ });
317
+
318
+ then('both cache paths are still ignored', () => {
319
+ expect(
320
+ isPathIgnored({ cwd: tempDir, filepath: '.cache/scratch.json' }),
321
+ ).toEqual(true);
322
+ expect(
323
+ isPathIgnored({
324
+ cwd: tempDir,
325
+ filepath: '.agent/.cache/roles.json',
326
+ }),
327
+ ).toEqual(true);
328
+ });
329
+
330
+ /**
331
+ * .what = the union and the tail, asked after the SECOND declarer has run
332
+ * .why = the same step-axis blind spot the leak check had. `[t1]` asks these of
333
+ * git's output; rhachet then rewrites this file at `[t2]` from a wholly
334
+ * different input -- one already full of git's 17 lines, rather than the
335
+ * 3-line file `[case2]` hands it. a union or tail defect can behave
336
+ * differently on that input, and this is the only place it is reachable.
337
+ * .note = `the practices converge` would go red too, but only as a DISAGREEMENT
338
+ * between the two declarers. a reader who saw that alone would look at
339
+ * the ordered tail, not at lost lines. these name the actual loss.
340
+ */
341
+ then("the repo's own custom ignores survive the second apply", () => {
342
+ expect(fileAfterRhachet.lines).toContain('.idea');
343
+ });
344
+
345
+ /**
346
+ * .what = the wish's "no duplicate line", where it is most at risk
347
+ * .why = this is the step where a SECOND declarer appends an ordered tail to a
348
+ * file that already carries one. the tail is filtered out before the
349
+ * sort and appended after, so it never passes through `dedupe` (the same
350
+ * fact gap 8 turned up at the unit grain) -- which means its safety here
351
+ * rests entirely on that filter, not on dedupe. if the filter ever
352
+ * stopped matching, `node_modules` and both negations would double, and
353
+ * a doubled `!` line is how #537's inert-negation hazard begins.
354
+ */
355
+ then('no line appears twice after the second declarer', () => {
356
+ expect(
357
+ getAllLinesDuplicated({ contents: fileAfterRhachet.lines.join('\n') }),
358
+ ).toEqual([]);
359
+ });
360
+
361
+ then('the negation is still live after the second apply', () => {
362
+ expect(
363
+ isPathIgnored({
364
+ cwd: tempDir,
365
+ filepath: 'packages/demo/node_modules/pkg.js',
366
+ }),
367
+ ).toEqual(true);
368
+ expect(
369
+ isPathIgnored({
370
+ cwd: tempDir,
371
+ filepath: '.test/assets/node_modules/pkg.js',
372
+ }),
373
+ ).toEqual(false);
374
+ });
375
+
376
+ /**
377
+ * .what = the leak check, asked AFTER the second declarer has run
378
+ * .why = `[t1]`'s leak check runs before this apply, so it can only ever see
379
+ * what `git` emitted. a leak introduced by `rhachet` reaches this file
380
+ * at THIS step, and `[t1]` is already past. measured: with a
381
+ * `@declapract{variable.*}` injected into the rhachet declaration,
382
+ * `[t1]`'s check stayed green and only `[case2]`'s caught it -- so this
383
+ * fixture, the one that exercises BOTH declarers, was blind to a leak
384
+ * from the second of them.
385
+ * .note = a leak check belongs after each apply, not once per fixture.
386
+ */
387
+ then('no declapract template syntax leaked from either practice', async () => {
388
+ const contents = await fs.readFile(
389
+ path.join(tempDir, '.gitignore'),
390
+ 'utf-8',
391
+ );
392
+
393
+ expect(contents).not.toContain('@declapract{');
394
+ });
395
+ });
396
+
397
+ when('[t3] both practices are applied a second time', () => {
398
+ const fileBefore = useBeforeAll(async () => ({
399
+ contents: await fs.readFile(path.join(tempDir, '.gitignore'), 'utf-8'),
400
+ }));
401
+
402
+ useBeforeAll(async () => {
403
+ await executeApply({
404
+ config: path.join(tempDir, 'declapract.use.yml'),
405
+ practice: 'git',
406
+ file: '.gitignore',
407
+ });
408
+ await executeApply({
409
+ config: path.join(tempDir, 'declapract.use.yml'),
410
+ practice: 'rhachet',
411
+ file: '.gitignore',
412
+ });
413
+ }, 120_000);
414
+
415
+ /**
416
+ * .note = this is the acceptance criterion, in its provable form. the wish asked
417
+ * for `fix(x) == x` given a repo that "already carries a line", which is
418
+ * false: `fix` always sorts and always relocates the negations, so a
419
+ * file that merely carries a line is still rewritten. the true claim is
420
+ * the FIXED POINT -- `fix(fix(x)) == fix(x)`.
421
+ */
422
+ then('the file is byte-identical -- the apply is a fixed point', async () => {
423
+ const contentsAfterRerun = await fs.readFile(
424
+ path.join(tempDir, '.gitignore'),
425
+ 'utf-8',
426
+ );
427
+
428
+ expect(contentsAfterRerun).toEqual(fileBefore.contents);
429
+ });
430
+
431
+ /**
432
+ * .what = the GATE clause of idempotency, asked of the BYTES ON DISK
433
+ * .why = rule.require.idempotent-fixes calls a check that still flags its own
434
+ * fixed output a blocker -- `declapract fix` would reflag the file
435
+ * forever. the unit clamps assert `check(fix(x))`, which is the value
436
+ * `fix` RETURNS. that is not what lands: declapract writes through
437
+ * `writeFileAsync`, which rewrites the final newline
438
+ * (`content.replace(/\n$/, '') + '\n'`). so `fix`'s return can satisfy
439
+ * `check` while the bytes on disk do not, and every unit test stays
440
+ * green while the real pipeline loops.
441
+ * .note = both declarers are asked, because either one that rejects the settled
442
+ * file is enough to make the loop.
443
+ */
444
+ then('both practices accept the settled file on disk', async () => {
445
+ const settled = await fs.readFile(
446
+ path.join(tempDir, '.gitignore'),
447
+ 'utf-8',
448
+ );
449
+
450
+ expect(() => checkOfGit(settled, {} as any)).not.toThrow();
451
+ expect(() => checkOfRhachet(settled, {} as any)).not.toThrow();
452
+ });
453
+
454
+ then('the settled file matches snapshot', async () => {
455
+ const contentsAfterRerun = await fs.readFile(
456
+ path.join(tempDir, '.gitignore'),
457
+ 'utf-8',
458
+ );
459
+
460
+ expect(contentsAfterRerun).toMatchSnapshot('.gitignore -- settled');
461
+ });
462
+
463
+ });
464
+
465
+ /**
466
+ * .what = the wish's FOURTH acceptance criterion, asked in its own instrument
467
+ * .why = "a fresh worktree shows a clean `git status` after a role boot". every
468
+ * other path assertion in this suite reaches `git check-ignore` through
469
+ * `isPathIgnored`, which is a PURE query -- deliberately so, per its own
470
+ * note -- and therefore only ever asks about paths that DO NOT EXIST.
471
+ * this is the one place the scratch is real on disk and git is asked about
472
+ * the tree as it stands. a rule can match a path pattern while the
473
+ * materialized tree still reports a line: a non-ignored neighbor under
474
+ * `.agent/`, or a negation that re-includes. status integrates every rule
475
+ * over the actual tree into one answer, which is what the beaver gets at
476
+ * teardown.
477
+ * .why = a `when` of its own, rather than a fourth `then` under `[t3]`. the boot
478
+ * is an ACTION that mutates the tree, and an action inside a `then` is a
479
+ * hidden side effect (`rule.forbid.hidden-side-effects`) that would make
480
+ * any later `then` order-dependent (`rule.forbid.order-dependence`). every
481
+ * other action in this suite already sits in a `useBeforeAll`; this one was
482
+ * the exception until r8 gap 16.
483
+ * .note = the scratch is written here rather than shipped in the fixture, because
484
+ * that is what a role boot does -- it creates these dirs AFTER the upgrade
485
+ * has settled. a fixture that carried them would prove a different claim.
486
+ */
487
+ when('[t4] a rhachet role boots and writes its scratch', () => {
488
+ useBeforeAll(async () => {
489
+ await fs.mkdir(path.join(tempDir, '.agent', '.cache'), {
490
+ recursive: true,
491
+ });
492
+ await fs.writeFile(
493
+ path.join(tempDir, '.agent', '.cache', 'roles.json'),
494
+ '{}',
495
+ );
496
+ await fs.mkdir(path.join(tempDir, '.cache'), { recursive: true });
497
+ await fs.writeFile(path.join(tempDir, '.cache', 'scratch.json'), '{}');
498
+ }, 30_000);
499
+
500
+ /**
501
+ * .note = the filter is the assertion, not a retreat from it. the tree still
502
+ * holds the fixture's own untracked files (`git init`, never committed),
503
+ * so a bare `toEqual([])` would fail for reasons unrelated to this
504
+ * practice. the claim is that git has NO WORD on either cache dir.
505
+ * .note = teeth, measured: with `.cache/` renamed in the git declaration, this
506
+ * goes red with `+ "?? .cache/"` -- the exact untracked line that makes
507
+ * `git tree del` refuse to fell a worktree.
508
+ */
509
+ then('git reports no line about either cache dir', () => {
510
+ const statusLines = getAllStatusLines({ cwd: tempDir });
511
+
512
+ expect(statusLines.filter((line) => line.includes('cache'))).toEqual(
513
+ [],
514
+ );
515
+ });
516
+
517
+ /**
518
+ * .what = the scratch really is on disk, so the assertion above is not vacuous
519
+ * .why = `git status` reports no cache line BOTH when the dirs are ignored and
520
+ * when they were never created. without this, a broken `useBeforeAll` --
521
+ * a bad path, a swallowed error -- would leave the clamp green while it
522
+ * proved not one claim. the same self-referential-fixture trap as
523
+ * edgecase 6, in a new place.
524
+ */
525
+ then('the scratch it wrote is genuinely present', async () => {
526
+ expect(
527
+ await fs.readFile(
528
+ path.join(tempDir, '.agent', '.cache', 'roles.json'),
529
+ 'utf-8',
530
+ ),
531
+ ).toEqual('{}');
532
+ expect(
533
+ await fs.readFile(
534
+ path.join(tempDir, '.cache', 'scratch.json'),
535
+ 'utf-8',
536
+ ),
537
+ ).toEqual('{}');
538
+ });
539
+ });
540
+ });
541
+
542
+ /**
543
+ * .what = a repo that takes `rhachet` WITHOUT `git`
544
+ * .why = this is the case that motivates the rhachet declaration at all. every
545
+ * real use-case pairs the two today, so the gap is latent -- which means
546
+ * prod can never reveal it and only a test can. absent this case, the
547
+ * rhachet declaration is justified by an argument rather than a proof.
548
+ */
549
+ given('[case2] a repo whose use case has rhachet but NOT git', () => {
550
+ const tempDir = genTempDir({
551
+ slug: 'git-rhachet-only',
552
+ clone: './src/practices/git/.test/assets/repo-with-rhachet-only',
553
+ symlink: [
554
+ { at: 'declarations', to: './.test/assets/git/declarations' },
555
+ { at: 'node_modules', to: 'node_modules' },
556
+ ],
557
+ });
558
+
559
+ useBeforeAll(async () => {
560
+ runGit({ cwd: tempDir, args: ['init', '-q'] });
561
+ }, 30_000);
562
+
563
+ when('[t0] before any apply', () => {
564
+ /**
565
+ * .why = rule.require.declapract-integration-tests asks for the BEFORE contents
566
+ * of each input file, so the after-diff is legible. this demo repo is a
567
+ * second input file, so it owes its own before-snapshot -- `[case1][t0]`
568
+ * has one, and the standard applies per fixture, not per suite.
569
+ */
570
+ then('the input file matches snapshot', async () => {
571
+ const contents = await fs.readFile(
572
+ path.join(tempDir, '.gitignore'),
573
+ 'utf-8',
574
+ );
575
+
576
+ expect(contents).toMatchSnapshot('.gitignore -- rhachet only -- before');
577
+ });
578
+
579
+ then('the agent cache is not ignored yet', () => {
580
+ expect(
581
+ isPathIgnored({
582
+ cwd: tempDir,
583
+ filepath: '.agent/.cache/roles.json',
584
+ }),
585
+ ).toEqual(false);
586
+ });
587
+ });
588
+
589
+ when('[t1] the rhachet practice is applied', () => {
590
+ useBeforeAll(async () => {
591
+ await executeApply({
592
+ config: path.join(tempDir, 'declapract.use.yml'),
593
+ practice: 'rhachet',
594
+ file: '.gitignore',
595
+ });
596
+ }, 120_000);
597
+
598
+ // .note = one read, shared across the assertions below, per
599
+ // rule.prefer.usethen-and-usewhen-for-shared-results
600
+ const fileAfter = useThen('the apply settles', async () => ({
601
+ lines: (
602
+ await fs.readFile(path.join(tempDir, '.gitignore'), 'utf-8')
603
+ ).split('\n'),
604
+ }));
605
+
606
+ then('the agent cache path is ignored, with no help from git', () => {
607
+ expect(
608
+ isPathIgnored({
609
+ cwd: tempDir,
610
+ filepath: '.agent/.cache/roles.json',
611
+ }),
612
+ ).toEqual(true);
613
+ });
614
+
615
+ then('rhachet declares only the dir rhachet writes', () => {
616
+ expect(fileAfter.lines).toContain('.agent/.cache/');
617
+ // `.cache/` is generic tool scratch and belongs to the git practice
618
+ expect(fileAfter.lines).not.toContain('.cache/');
619
+ });
620
+
621
+ /**
622
+ * .what = the wish's "no duplicate line", on the solo path
623
+ * .why = this fixture's `.gitignore` ALREADY carries `node_modules`, and
624
+ * rhachet's ordered tail declares it too -- so this step is a genuine
625
+ * findsert collision, not a hypothetical one. it is also the first apply
626
+ * the rhachet declaration ever performs on a real repo, which is the
627
+ * codepath this behavior ADDS rather than inherits.
628
+ */
629
+ then('no line appears twice', () => {
630
+ expect(
631
+ getAllLinesDuplicated({ contents: fileAfter.lines.join('\n') }),
632
+ ).toEqual([]);
633
+ });
634
+
635
+ /**
636
+ * .what = the findsert half of the contract -- a repo's own lines are UNIONED
637
+ * in, never replaced
638
+ * .why = `[case1][t1]` proves this for the git declaration. rhachet is the new
639
+ * declaration this behavior adds, and it needs the proof more, not less:
640
+ * its declared set is a single line, so a `defineExpectedContents` that
641
+ * was mis-copied without the extant-lines union would emit that one line
642
+ * plus the tail and DELETE every custom ignore a consumer wrote.
643
+ * .note = injected and observed, not predicted. under that wipe the fixed point
644
+ * in `[t2]` stays GREEN (a wipe is idempotent), `declares only the dir
645
+ * rhachet writes` stays GREEN (the wipe declares exactly that), and so
646
+ * does `the agent cache path is ignored`. within this fixture -- the one
647
+ * that exercises rhachet ALONE -- the sole other catch is the settled
648
+ * snapshot, and a snapshot can be blessed away by a `--resnap`. so this
649
+ * is the only NAMED assertion here that states the union as an intent.
650
+ * `[case1]` does catch it, via convergence, but only incidentally: there
651
+ * the git apply re-unions the lines, so what goes red is the two
652
+ * practices' disagreement, not the lost lines.
653
+ */
654
+ then("the repo's own custom ignores survive the union", () => {
655
+ expect(fileAfter.lines).toContain('.idea');
656
+ expect(fileAfter.lines).toContain('dist');
657
+ });
658
+
659
+ then('the node_modules negations are declared even here', () => {
660
+ expect(fileAfter.lines.indexOf('node_modules')).toBeLessThan(
661
+ fileAfter.lines.indexOf('!.test*/**/node_modules'),
662
+ );
663
+ });
664
+
665
+ /**
666
+ * .why = the tail is the whole safety condition of the fold-in, so its effect
667
+ * must be proven where rhachet declares it ALONE. with git present, a
668
+ * live negation could be credited to git; here only rhachet can own it.
669
+ * .note = rhachet IMPORTS the tail from `src/utils/defineExpectedGitignoreContents`
670
+ * rather than declares its own, so this proves the import reaches a
671
+ * consumer intact -- a shared constant that never lands in the emitted
672
+ * file would satisfy every unit assertion and fail right here.
673
+ */
674
+ then('the negation rhachet imports is honored by git itself', () => {
675
+ // .note = probed at depth, not at the root -- git refuses a pathspec that
676
+ // traverses the symlinked root `node_modules`
677
+ expect(
678
+ isPathIgnored({
679
+ cwd: tempDir,
680
+ filepath: 'packages/demo/node_modules/pkg.js',
681
+ }),
682
+ ).toEqual(true);
683
+ expect(
684
+ isPathIgnored({
685
+ cwd: tempDir,
686
+ filepath: '.test/assets/node_modules/pkg.js',
687
+ }),
688
+ ).toEqual(false);
689
+ });
690
+
691
+ // .why = the same leak check `[case1][t1]` makes. a template that resolves under
692
+ // one use case can still leak under another, since substitution is
693
+ // per-use-case -- so the assertion belongs to each fixture, not the suite.
694
+ then('no declapract template syntax leaked into the output', () => {
695
+ expect(fileAfter.lines.join('\n')).not.toContain('@declapract{');
696
+ });
697
+
698
+ then('the settled file matches snapshot', () => {
699
+ expect(fileAfter.lines.join('\n')).toMatchSnapshot(
700
+ '.gitignore -- rhachet only',
701
+ );
702
+ });
703
+ });
704
+
705
+ when('[t2] the rhachet practice is applied a second time', () => {
706
+ const fileBefore = useBeforeAll(async () => ({
707
+ contents: await fs.readFile(path.join(tempDir, '.gitignore'), 'utf-8'),
708
+ }));
709
+
710
+ useBeforeAll(async () => {
711
+ await executeApply({
712
+ config: path.join(tempDir, 'declapract.use.yml'),
713
+ practice: 'rhachet',
714
+ file: '.gitignore',
715
+ });
716
+ }, 120_000);
717
+
718
+ /**
719
+ * .what = the fixed point, on the solo path
720
+ * .why = [case1][t3] proves the fixed point where BOTH practices declare the
721
+ * file -- the arrangement that predates this behavior. the solo path
722
+ * is the one the rhachet declaration was added to serve, and it settles
723
+ * from a different input: no git lines are present, so rhachet's own
724
+ * union is the sole source of the sorted set. a fix that is a fixed
725
+ * point on one input is not thereby a fixed point on the other, so the
726
+ * wish's "prove it, do not assert it" stays unmet here until it is run.
727
+ * .note = the unit clamp asserts `fix(fix(x)) == fix(x)` for this declaration,
728
+ * but per rule.require.declapract-integration-tests a unit test of the
729
+ * exported fix is "necessary but NOT sufficient" -- only the pipeline
730
+ * shows whether a re-run of `declapract fix` settles or churns.
731
+ */
732
+ then('the file is byte-identical -- the solo apply is a fixed point', async () => {
733
+ const contentsAfterRerun = await fs.readFile(
734
+ path.join(tempDir, '.gitignore'),
735
+ 'utf-8',
736
+ );
737
+
738
+ expect(contentsAfterRerun).toEqual(fileBefore.contents);
739
+ });
740
+
741
+ // .why = the gate clause, on the solo path. `[case1][t3]` asks it where both
742
+ // practices declare the file; here rhachet's `check` is the only gate,
743
+ // so a `check` that never agreed would loop with no neighbor to mask it.
744
+ // only rhachet is asked, since git is absent from this use case.
745
+ then('rhachet accepts the settled file on disk', async () => {
746
+ const settled = await fs.readFile(
747
+ path.join(tempDir, '.gitignore'),
748
+ 'utf-8',
749
+ );
750
+
751
+ expect(() => checkOfRhachet(settled, {} as any)).not.toThrow();
752
+ });
753
+
754
+ then('the agent cache path is still ignored', () => {
755
+ expect(
756
+ isPathIgnored({
757
+ cwd: tempDir,
758
+ filepath: '.agent/.cache/roles.json',
759
+ }),
760
+ ).toEqual(true);
761
+ });
762
+ });
763
+ });
764
+
765
+ /**
766
+ * .what = THIS repo satisfies the declarations THIS repo ships
767
+ * .why = this repo is a consumer of its own practices: `declapract.use.yml` sets
768
+ * `useCase: npm-package`, which extends `typescript-project`, which carries
769
+ * both `git` and `rhachet`. yet no command runs `check` against it --
770
+ * `package.json` runs `declapract validate`, which only asserts the
771
+ * declaration set is well formed, never that this repo conforms to it.
772
+ * .why = so self-application drift is silent, and it has already happened once:
773
+ * #435 added `.cache/` to the template AND to this repo's file, but #477
774
+ * added `.agent/.cache/` to the template ALONE. that line stayed absent here
775
+ * for seven weeks with every gate green. move 2 of this behavior repaired the
776
+ * drift; this assertion is what keeps it repaired.
777
+ * .note = a filesystem read, so it belongs at this grain per
778
+ * rule.forbid.unit.remote-boundaries. the path is resolved from `__dirname`
779
+ * rather than cwd, so the assertion holds wherever jest is invoked from.
780
+ */
781
+ given('[case3] this repo, which consumes the practices it declares', () => {
782
+ const fileOwn = useBeforeAll(async () => ({
783
+ contents: await fs.readFile(
784
+ path.join(__dirname, '..', '..', '..', '.gitignore'),
785
+ 'utf-8',
786
+ ),
787
+ }));
788
+
789
+ when('[t0] the shipped declarations are asked about it', () => {
790
+ /**
791
+ * .note = the test NAMES below carry the fix, deliberately. jest prints the full
792
+ * `given > when > then` path on failure, and little else of use here --
793
+ * `expect(fn).not.toThrow()` reports a symptom ("it threw"), never a
794
+ * remedy. and this assertion is built to fire on someone who does NOT
795
+ * know this behavior's history: it caught a drift that already went
796
+ * unnoticed for seven weeks (#477), and the next one will land on
797
+ * whoever next edits `ignoresSortable`, months from now.
798
+ * so the remedy rides in the one string the runner is guaranteed to
799
+ * print (`rule.require.errors-name-the-fix`). the diff in the throw says
800
+ * WHICH line; the name says WHAT TO DO about it.
801
+ */
802
+ then(
803
+ 'the git practice is satisfied -- if red, add the absent line to the repo-root .gitignore, where the sort puts it',
804
+ () => {
805
+ expect(() => checkOfGit(fileOwn.contents, {} as any)).not.toThrow();
806
+ },
807
+ );
808
+
809
+ then(
810
+ 'the rhachet practice is satisfied -- if red, add the absent line to the repo-root .gitignore, where the sort puts it',
811
+ () => {
812
+ expect(() => checkOfRhachet(fileOwn.contents, {} as any)).not.toThrow();
813
+ },
814
+ );
815
+
816
+ then('both cache dirs are ignored in this very repo', () => {
817
+ const repoRoot = path.join(__dirname, '..', '..', '..');
818
+
819
+ expect(
820
+ isPathIgnored({ cwd: repoRoot, filepath: '.cache/scratch.json' }),
821
+ ).toEqual(true);
822
+ expect(
823
+ isPathIgnored({
824
+ cwd: repoRoot,
825
+ filepath: '.agent/.cache/roles.json',
826
+ }),
827
+ ).toEqual(true);
828
+ });
829
+ });
830
+ });
831
+
832
+ /**
833
+ * .what = a clamp on the ORACLE, not on the practices
834
+ * .why = every path assertion above trusts `git check-ignore` to report the repo's
835
+ * rules. git also folds in `core.excludesFile` from the host's global
836
+ * gitconfig, and `.cache/` is among the most common entries in a personal
837
+ * global gitignore. on such a machine those assertions would pass no matter
838
+ * what the practice declares -- a FALSE GREEN, which no one investigates.
839
+ * `envGitHermetic` closes that channel; this case proves the channel is real
840
+ * and that closing it works.
841
+ * .why = without this, `envGitHermetic` is a rationale in a comment, and a future
842
+ * reader who finds it needless can delete it with no signal at all. the
843
+ * guard on the oracle needs a guard of its own.
844
+ * .note = the A/B is the honest form of this proof. a real dev machine's leak cannot
845
+ * be reproduced on a runner that lacks the dotfile, so the CONTROL simulates
846
+ * the hostile host by a `GIT_CONFIG_GLOBAL` that points at a fake, and the
847
+ * TREATMENT is the exact env every git query in this file uses.
848
+ */
849
+ given('[case4] a host whose global gitconfig ignores paths of its own', () => {
850
+ /**
851
+ * .note = this fixture carries NO symlinks, unlike every other case here. the
852
+ * `declarations` and `node_modules` links exist so `executeApply` can
853
+ * look up the declaration set and load declapract from the temp root --
854
+ * and this case never calls it. a copied-in `node_modules` link would be
855
+ * worse than idle: git refuses a pathspec that traverses a symlink, which
856
+ * is the trap `[case1]` already had to probe at depth to dodge. this case
857
+ * runs git alone, so it carries only what git needs.
858
+ * .note = the clone STAYS. the oracle this case guards is the one the other cases
859
+ * use, and they use it inside a cloned repo that carries its own
860
+ * `.gitignore`. guarding it in a bare dir would guard a simpler oracle
861
+ * than the one in play.
862
+ */
863
+ const tempDir = genTempDir({
864
+ slug: 'git-hermetic-oracle',
865
+ clone: './src/practices/git/.test/assets/repo-with-rhachet-only',
866
+ });
867
+
868
+ const scene = useBeforeAll(async () => {
869
+ runGit({ cwd: tempDir, args: ['init', '-q'] });
870
+
871
+ // a global gitignore of the kind a developer keeps in their home dir
872
+ const pathOfExcludes = path.join(tempDir, 'fake-global.gitignore');
873
+ const pathOfConfig = path.join(tempDir, 'fake-global.gitconfig');
874
+ await fs.writeFile(pathOfExcludes, 'zzz-host-only-pattern/\n');
875
+ await fs.writeFile(
876
+ pathOfConfig,
877
+ `[core]\n\texcludesFile = ${pathOfExcludes}\n`,
878
+ );
879
+
880
+ return { pathOfConfig };
881
+ }, 30_000);
882
+
883
+ /**
884
+ * .what = ask git the same question under two envs, and compare
885
+ * .why = the probe path appears in NO `.gitignore` in the repo, so any match can
886
+ * only have come from the simulated host config. that makes the control a
887
+ * direct measurement of the leak channel rather than an argument about it.
888
+ * .note = this is the ONE place that calls `execFileSync` rather than `runGit`,
889
+ * and deliberately so: `runGit` exists to make the hermetic env
890
+ * unforgettable, but this case's whole subject IS the env, so it must be
891
+ * able to vary it. every other git call in this file goes through
892
+ * `runGit` and cannot opt out.
893
+ */
894
+ const askUnder = (input: { env: NodeJS.ProcessEnv }): boolean => {
895
+ try {
896
+ execFileSync(
897
+ 'git',
898
+ ['check-ignore', '--', 'zzz-host-only-pattern/x.json'],
899
+ { cwd: tempDir, env: input.env },
900
+ );
901
+ return true;
902
+ } catch (error) {
903
+ // .note = `as` cast at an external boundary, per rule.forbid.as-cast's one
904
+ // exemption. node types `execFileSync`'s throw as plain `Error`, but
905
+ // it attaches a numeric `status` (the child's exit code) at runtime --
906
+ // which is the ANSWER here, not a failure: check-ignore exits 1 for
907
+ // "not ignored". @types/node declares no `ExecFileSyncError`, so there
908
+ // is no type to reach for.
909
+ // .fix = drop the cast when @types/node types the thrown error.
910
+ if ((error as { status?: number }).status === 1) return false;
911
+ throw error;
912
+ }
913
+ };
914
+
915
+ when('[t0] the oracle is asked with the host config in play', () => {
916
+ then('the host rule LEAKS in -- the channel is real', () => {
917
+ expect(
918
+ askUnder({
919
+ env: { ...process.env, GIT_CONFIG_GLOBAL: scene.pathOfConfig },
920
+ }),
921
+ ).toEqual(true);
922
+ });
923
+ });
924
+
925
+ /**
926
+ * .why = the hostile env is spread FIRST and `envGitHermetic` LAST, on purpose.
927
+ * a first draft asked `askUnder(envGitHermetic)` alone -- and that is a
928
+ * clamp with no teeth: `envGitHermetic` spreads `process.env`, which on a
929
+ * clean host carries no `GIT_CONFIG_GLOBAL` at all, so delete the override
930
+ * and the query still finds no host rule and still returns false. GREEN
931
+ * under the very defect it exists to catch.
932
+ * spread in this order, a deleted override leaves the hostile value in
933
+ * place, because an absent key cannot overwrite a present one. that is the
934
+ * scenario that matters anyway: the host HAS the config, and our env must
935
+ * beat it rather than merely coexist with its absence.
936
+ */
937
+ when('[t1] the same host, asked with the env every query here uses', () => {
938
+ then('the host rule is shut out -- the channel is closed', () => {
939
+ const envHostile = {
940
+ ...process.env,
941
+ GIT_CONFIG_GLOBAL: scene.pathOfConfig,
942
+ };
943
+
944
+ expect(
945
+ askUnder({ env: { ...envHostile, ...envGitHermetic } }),
946
+ ).toEqual(false);
947
+ });
948
+ });
949
+ });
950
+ });