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.
- package/dist/practices/cicd-app-react-native-expo/best-practice/.declapract.readme.md +27 -0
- package/dist/practices/cicd-app-react-native-expo/best-practice/.gitignore.declapract.ts +18 -11
- package/dist/practices/git/.declapract.integration.test.ts +950 -0
- package/dist/practices/git/.test/assets/repo-with-rhachet-only/declapract.use.yml +6 -0
- package/dist/practices/git/.test/assets/repo-with-rhachet-only/package.json +4 -0
- package/dist/practices/git/.test/assets/repo-without-cache-ignores/declapract.use.yml +9 -0
- package/dist/practices/git/.test/assets/repo-without-cache-ignores/package.json +4 -0
- package/dist/practices/git/__snapshots__/.declapract.integration.test.ts.snap +51 -0
- package/dist/practices/git/best-practice/.declapract.readme.md +23 -0
- package/dist/practices/git/best-practice/.gitignore.declapract.ts +59 -33
- package/dist/practices/rhachet/best-practice/.declapract.readme.md +32 -0
- package/dist/practices/rhachet/best-practice/.gitignore.declapract.ts +64 -0
- package/dist/utils/defineExpectedGitignoreContents.ts +84 -0
- package/package.json +7 -7
|
@@ -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
|
+
});
|