fallow 2.75.0 → 2.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -12
- package/schema.json +118 -1
- package/scripts/postinstall.js +32 -1
- package/scripts/verify-binary.js +347 -0
- package/scripts/verify-binary.test.js +402 -0
- package/skills/fallow/SKILL.md +3 -3
- package/skills/fallow/references/cli-reference.md +32 -15
- package/skills/fallow/references/gotchas.md +16 -3
- package/skills/fallow/references/patterns.md +1 -1
- package/types/output-contract.d.ts +4670 -1490
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
_verifyWithKey,
|
|
10
|
+
verifyBinaryAt,
|
|
11
|
+
verifyDigestAt,
|
|
12
|
+
verifyInstalled,
|
|
13
|
+
sha256Hex,
|
|
14
|
+
normalizeDigest,
|
|
15
|
+
EMBEDDED_PUBLIC_KEY,
|
|
16
|
+
ED25519_SPKI_HEADER,
|
|
17
|
+
SKIP_ENV,
|
|
18
|
+
} = require('./verify-binary');
|
|
19
|
+
const { getPlatformPackage } = require('./platform-package');
|
|
20
|
+
|
|
21
|
+
function makeDigestProvider(dir) {
|
|
22
|
+
return ({ assetName, binaryPath }) => {
|
|
23
|
+
const data = fs.readFileSync(binaryPath);
|
|
24
|
+
return Promise.resolve('sha256:' + crypto.createHash('sha256').update(data).digest('hex'));
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeMismatchedDigestProvider() {
|
|
29
|
+
return () => Promise.resolve('sha256:' + 'a'.repeat(64));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeKeypair() {
|
|
33
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
|
|
34
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' });
|
|
35
|
+
const rawPub = spki.subarray(spki.length - 32);
|
|
36
|
+
return { privateKey, rawPub };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeFixture(binaryBytes, signFn) {
|
|
40
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-'));
|
|
41
|
+
const binaryPath = path.join(dir, 'fallow');
|
|
42
|
+
fs.writeFileSync(binaryPath, binaryBytes);
|
|
43
|
+
if (signFn) {
|
|
44
|
+
fs.writeFileSync(`${binaryPath}.sig`, signFn(binaryBytes));
|
|
45
|
+
}
|
|
46
|
+
return { dir, binaryPath };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cleanup(dir) {
|
|
50
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('embedded public key is 32 bytes and SPKI header is 12 bytes', () => {
|
|
54
|
+
assert.equal(EMBEDDED_PUBLIC_KEY.length, 32);
|
|
55
|
+
assert.equal(ED25519_SPKI_HEADER.length, 12);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('embedded public key reconstructs a valid Ed25519 SPKI key', () => {
|
|
59
|
+
const spki = Buffer.concat([ED25519_SPKI_HEADER, EMBEDDED_PUBLIC_KEY]);
|
|
60
|
+
const key = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
|
|
61
|
+
assert.equal(key.asymmetricKeyType, 'ed25519');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('_verifyWithKey returns ok for a valid signature', () => {
|
|
65
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
66
|
+
const content = Buffer.from('hello world');
|
|
67
|
+
const { dir, binaryPath } = makeFixture(content, (data) =>
|
|
68
|
+
crypto.sign(null, data, privateKey),
|
|
69
|
+
);
|
|
70
|
+
try {
|
|
71
|
+
const result = _verifyWithKey(binaryPath, rawPub);
|
|
72
|
+
assert.deepEqual(result, { ok: true });
|
|
73
|
+
} finally {
|
|
74
|
+
cleanup(dir);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('_verifyWithKey returns sig-invalid when the signature is corrupted', () => {
|
|
79
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
80
|
+
const content = Buffer.from('hello world');
|
|
81
|
+
const { dir, binaryPath } = makeFixture(content, (data) =>
|
|
82
|
+
crypto.sign(null, data, privateKey),
|
|
83
|
+
);
|
|
84
|
+
try {
|
|
85
|
+
const sig = fs.readFileSync(`${binaryPath}.sig`);
|
|
86
|
+
sig[0] ^= 0xff;
|
|
87
|
+
fs.writeFileSync(`${binaryPath}.sig`, sig);
|
|
88
|
+
const result = _verifyWithKey(binaryPath, rawPub);
|
|
89
|
+
assert.equal(result.ok, false);
|
|
90
|
+
assert.equal(result.code, 'sig-invalid');
|
|
91
|
+
} finally {
|
|
92
|
+
cleanup(dir);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('_verifyWithKey returns sig-invalid for the wrong key', () => {
|
|
97
|
+
const { rawPub } = makeKeypair();
|
|
98
|
+
const wrongKey = makeKeypair();
|
|
99
|
+
const content = Buffer.from('hello world');
|
|
100
|
+
const { dir, binaryPath } = makeFixture(content, (data) =>
|
|
101
|
+
crypto.sign(null, data, wrongKey.privateKey),
|
|
102
|
+
);
|
|
103
|
+
try {
|
|
104
|
+
const result = _verifyWithKey(binaryPath, rawPub);
|
|
105
|
+
assert.equal(result.ok, false);
|
|
106
|
+
assert.equal(result.code, 'sig-invalid');
|
|
107
|
+
} finally {
|
|
108
|
+
cleanup(dir);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('_verifyWithKey returns sig-invalid when the binary bytes are tampered', () => {
|
|
113
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
114
|
+
const original = Buffer.from('hello world');
|
|
115
|
+
const { dir, binaryPath } = makeFixture(original, (data) =>
|
|
116
|
+
crypto.sign(null, data, privateKey),
|
|
117
|
+
);
|
|
118
|
+
try {
|
|
119
|
+
fs.writeFileSync(binaryPath, Buffer.from('tampered'));
|
|
120
|
+
const result = _verifyWithKey(binaryPath, rawPub);
|
|
121
|
+
assert.equal(result.ok, false);
|
|
122
|
+
assert.equal(result.code, 'sig-invalid');
|
|
123
|
+
} finally {
|
|
124
|
+
cleanup(dir);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('_verifyWithKey returns sig-missing when the signature file does not exist', () => {
|
|
129
|
+
const { rawPub } = makeKeypair();
|
|
130
|
+
const { dir, binaryPath } = makeFixture(Buffer.from('hello world'));
|
|
131
|
+
try {
|
|
132
|
+
const result = _verifyWithKey(binaryPath, rawPub);
|
|
133
|
+
assert.equal(result.ok, false);
|
|
134
|
+
assert.equal(result.code, 'sig-missing');
|
|
135
|
+
} finally {
|
|
136
|
+
cleanup(dir);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('_verifyWithKey returns binary-missing when the binary does not exist', () => {
|
|
141
|
+
const { rawPub } = makeKeypair();
|
|
142
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-'));
|
|
143
|
+
try {
|
|
144
|
+
const result = _verifyWithKey(path.join(dir, 'nonexistent'), rawPub);
|
|
145
|
+
assert.equal(result.ok, false);
|
|
146
|
+
assert.equal(result.code, 'binary-missing');
|
|
147
|
+
} finally {
|
|
148
|
+
cleanup(dir);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('_verifyWithKey returns sig-invalid when the signature length is wrong', () => {
|
|
153
|
+
const { rawPub } = makeKeypair();
|
|
154
|
+
const { dir, binaryPath } = makeFixture(Buffer.from('hello'));
|
|
155
|
+
try {
|
|
156
|
+
fs.writeFileSync(`${binaryPath}.sig`, Buffer.from('short'));
|
|
157
|
+
const result = _verifyWithKey(binaryPath, rawPub);
|
|
158
|
+
assert.equal(result.ok, false);
|
|
159
|
+
assert.equal(result.code, 'sig-invalid');
|
|
160
|
+
} finally {
|
|
161
|
+
cleanup(dir);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('_verifyWithKey throws when given a non-32-byte raw public key', () => {
|
|
166
|
+
const { dir, binaryPath } = makeFixture(Buffer.from('hello world'));
|
|
167
|
+
try {
|
|
168
|
+
const result = _verifyWithKey(binaryPath, Buffer.from('too short'));
|
|
169
|
+
assert.equal(result.ok, false);
|
|
170
|
+
assert.equal(result.code, 'sig-missing');
|
|
171
|
+
} finally {
|
|
172
|
+
cleanup(dir);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('verifyBinaryAt uses the embedded production public key', () => {
|
|
177
|
+
// The embedded key cannot sign our test data because we do not have the
|
|
178
|
+
// private key, so we only assert that verifyBinaryAt returns sig-invalid
|
|
179
|
+
// for a random signature against the production key, not the underlying
|
|
180
|
+
// crypto throwing. This locks in that the public API uses the embedded
|
|
181
|
+
// key path.
|
|
182
|
+
const { privateKey } = makeKeypair();
|
|
183
|
+
const content = Buffer.from('hello world');
|
|
184
|
+
const { dir, binaryPath } = makeFixture(content, (data) =>
|
|
185
|
+
crypto.sign(null, data, privateKey),
|
|
186
|
+
);
|
|
187
|
+
try {
|
|
188
|
+
const result = verifyBinaryAt(binaryPath);
|
|
189
|
+
assert.equal(result.ok, false);
|
|
190
|
+
assert.equal(result.code, 'sig-invalid');
|
|
191
|
+
} finally {
|
|
192
|
+
cleanup(dir);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function makePlatformDir(privateKey, options) {
|
|
197
|
+
const opts = options || {};
|
|
198
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-'));
|
|
199
|
+
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
200
|
+
for (const base of ['fallow', 'fallow-lsp', 'fallow-mcp']) {
|
|
201
|
+
const binaryPath = path.join(dir, `${base}${ext}`);
|
|
202
|
+
const content = Buffer.from(`mock ${base} contents`);
|
|
203
|
+
fs.writeFileSync(binaryPath, content);
|
|
204
|
+
if (opts.skipSigFor === base) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const data = opts.corruptBinaryFor === base ? Buffer.from('tampered') : content;
|
|
208
|
+
const sig = crypto.sign(null, data, privateKey);
|
|
209
|
+
if (opts.corruptSigFor === base) {
|
|
210
|
+
sig[0] ^= 0xff;
|
|
211
|
+
}
|
|
212
|
+
fs.writeFileSync(`${binaryPath}.sig`, sig);
|
|
213
|
+
}
|
|
214
|
+
return dir;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function currentPlatformPackage() {
|
|
218
|
+
if (process.platform !== 'linux') {
|
|
219
|
+
return getPlatformPackage(process.platform, process.arch);
|
|
220
|
+
}
|
|
221
|
+
let libcFamily;
|
|
222
|
+
try {
|
|
223
|
+
libcFamily = require('detect-libc').familySync();
|
|
224
|
+
} catch {
|
|
225
|
+
libcFamily = undefined;
|
|
226
|
+
}
|
|
227
|
+
return getPlatformPackage(process.platform, process.arch, libcFamily);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
test('normalizeDigest accepts sha256: prefix and bare hex', () => {
|
|
231
|
+
const sample = 'a'.repeat(64);
|
|
232
|
+
assert.equal(normalizeDigest('sha256:' + sample), sample);
|
|
233
|
+
assert.equal(normalizeDigest(sample), sample);
|
|
234
|
+
assert.equal(normalizeDigest('SHA256:' + sample.toUpperCase()), sample);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('normalizeDigest rejects malformed digests', () => {
|
|
238
|
+
assert.equal(normalizeDigest(null), null);
|
|
239
|
+
assert.equal(normalizeDigest(''), null);
|
|
240
|
+
assert.equal(normalizeDigest('not-hex'), null);
|
|
241
|
+
assert.equal(normalizeDigest('a'.repeat(63)), null);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('sha256Hex returns 64-char hex over file bytes', () => {
|
|
245
|
+
const { dir, binaryPath } = makeFixture(Buffer.from('hello world'));
|
|
246
|
+
try {
|
|
247
|
+
const result = sha256Hex(binaryPath);
|
|
248
|
+
assert.equal(result.ok, true);
|
|
249
|
+
assert.equal(result.digest, crypto.createHash('sha256').update(Buffer.from('hello world')).digest('hex'));
|
|
250
|
+
} finally {
|
|
251
|
+
cleanup(dir);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('verifyDigestAt accepts matching digest and rejects mismatched', () => {
|
|
256
|
+
const { dir, binaryPath } = makeFixture(Buffer.from('hello world'));
|
|
257
|
+
try {
|
|
258
|
+
const correct = crypto.createHash('sha256').update(Buffer.from('hello world')).digest('hex');
|
|
259
|
+
assert.deepEqual(verifyDigestAt(binaryPath, 'sha256:' + correct), { ok: true });
|
|
260
|
+
const wrong = 'b'.repeat(64);
|
|
261
|
+
const bad = verifyDigestAt(binaryPath, wrong);
|
|
262
|
+
assert.equal(bad.ok, false);
|
|
263
|
+
assert.equal(bad.code, 'digest-mismatch');
|
|
264
|
+
} finally {
|
|
265
|
+
cleanup(dir);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('verifyInstalled with dirOverride returns ok when every binary verifies', async (t) => {
|
|
270
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
271
|
+
const dir = makePlatformDir(privateKey);
|
|
272
|
+
t.after(() => cleanup(dir));
|
|
273
|
+
const result = await verifyInstalled({
|
|
274
|
+
dirOverride: dir,
|
|
275
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
276
|
+
digestProvider: makeDigestProvider(dir),
|
|
277
|
+
});
|
|
278
|
+
assert.equal(result.ok, true);
|
|
279
|
+
assert.equal(result.package, '<override>');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('verifyInstalled resolves a global npm install from the fallow package directory', async (t) => {
|
|
283
|
+
const pkg = currentPlatformPackage();
|
|
284
|
+
if (!pkg) {
|
|
285
|
+
t.skip('unsupported platform');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
290
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fallow-vbtest-global-'));
|
|
291
|
+
t.after(() => cleanup(root));
|
|
292
|
+
|
|
293
|
+
const resolveFrom = path.join(root, 'node_modules', 'fallow');
|
|
294
|
+
const platformDir = path.join(root, 'node_modules', ...pkg.split('/'));
|
|
295
|
+
fs.mkdirSync(resolveFrom, { recursive: true });
|
|
296
|
+
fs.mkdirSync(platformDir, { recursive: true });
|
|
297
|
+
fs.writeFileSync(path.join(platformDir, 'package.json'), JSON.stringify({ name: pkg, version: '9.9.9' }));
|
|
298
|
+
|
|
299
|
+
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
300
|
+
for (const base of ['fallow', 'fallow-lsp', 'fallow-mcp']) {
|
|
301
|
+
const binaryPath = path.join(platformDir, `${base}${ext}`);
|
|
302
|
+
const content = Buffer.from(`global install ${base}`);
|
|
303
|
+
fs.writeFileSync(binaryPath, content);
|
|
304
|
+
fs.writeFileSync(`${binaryPath}.sig`, crypto.sign(null, content, privateKey));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const result = await verifyInstalled({
|
|
308
|
+
resolveFrom,
|
|
309
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
310
|
+
digestProvider: ({ binaryPath }) => crypto.createHash('sha256').update(fs.readFileSync(binaryPath)).digest('hex'),
|
|
311
|
+
});
|
|
312
|
+
assert.equal(result.ok, true);
|
|
313
|
+
assert.equal(result.package, pkg);
|
|
314
|
+
assert.equal(result.version, '9.9.9');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('verifyInstalled with dirOverride fails fast on the first bad signature', async (t) => {
|
|
318
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
319
|
+
const dir = makePlatformDir(privateKey, { corruptSigFor: 'fallow-lsp' });
|
|
320
|
+
t.after(() => cleanup(dir));
|
|
321
|
+
const result = await verifyInstalled({
|
|
322
|
+
dirOverride: dir,
|
|
323
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
324
|
+
digestProvider: makeDigestProvider(dir),
|
|
325
|
+
});
|
|
326
|
+
assert.equal(result.ok, false);
|
|
327
|
+
assert.equal(result.code, 'sig-invalid');
|
|
328
|
+
assert.match(result.binary, /fallow-lsp/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('verifyInstalled with dirOverride reports sig-missing when a .sig is absent', async (t) => {
|
|
332
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
333
|
+
const dir = makePlatformDir(privateKey, { skipSigFor: 'fallow-mcp' });
|
|
334
|
+
t.after(() => cleanup(dir));
|
|
335
|
+
const result = await verifyInstalled({
|
|
336
|
+
dirOverride: dir,
|
|
337
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
338
|
+
digestProvider: makeDigestProvider(dir),
|
|
339
|
+
});
|
|
340
|
+
assert.equal(result.ok, false);
|
|
341
|
+
assert.equal(result.code, 'sig-missing');
|
|
342
|
+
assert.match(result.binary, /fallow-mcp/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('verifyInstalled reports digest-mismatch when SHA-256 disagrees with the provider', async (t) => {
|
|
346
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
347
|
+
const dir = makePlatformDir(privateKey);
|
|
348
|
+
t.after(() => cleanup(dir));
|
|
349
|
+
const result = await verifyInstalled({
|
|
350
|
+
dirOverride: dir,
|
|
351
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
352
|
+
digestProvider: makeMismatchedDigestProvider(),
|
|
353
|
+
});
|
|
354
|
+
assert.equal(result.ok, false);
|
|
355
|
+
assert.equal(result.code, 'digest-mismatch');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('verifyInstalled reports digest-unavailable when the provider rejects', async (t) => {
|
|
359
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
360
|
+
const dir = makePlatformDir(privateKey);
|
|
361
|
+
t.after(() => cleanup(dir));
|
|
362
|
+
const result = await verifyInstalled({
|
|
363
|
+
dirOverride: dir,
|
|
364
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
365
|
+
digestProvider: () => Promise.reject(new Error('network down')),
|
|
366
|
+
});
|
|
367
|
+
assert.equal(result.ok, false);
|
|
368
|
+
assert.equal(result.code, 'digest-unavailable');
|
|
369
|
+
assert.match(result.message, /network down/);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('verifyInstalled honors FALLOW_SKIP_BINARY_VERIFY', async (t) => {
|
|
373
|
+
const previous = process.env[SKIP_ENV];
|
|
374
|
+
process.env[SKIP_ENV] = '1';
|
|
375
|
+
t.after(() => {
|
|
376
|
+
if (previous === undefined) delete process.env[SKIP_ENV];
|
|
377
|
+
else process.env[SKIP_ENV] = previous;
|
|
378
|
+
});
|
|
379
|
+
const result = await verifyInstalled({ dirOverride: '/does/not/exist' });
|
|
380
|
+
assert.equal(result.ok, true);
|
|
381
|
+
assert.equal(result.skipped, true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('verifyInstalled ignores skip env when allowSkipEnv is false', async (t) => {
|
|
385
|
+
const previous = process.env[SKIP_ENV];
|
|
386
|
+
process.env[SKIP_ENV] = '1';
|
|
387
|
+
t.after(() => {
|
|
388
|
+
if (previous === undefined) delete process.env[SKIP_ENV];
|
|
389
|
+
else process.env[SKIP_ENV] = previous;
|
|
390
|
+
});
|
|
391
|
+
const { privateKey, rawPub } = makeKeypair();
|
|
392
|
+
const dir = makePlatformDir(privateKey);
|
|
393
|
+
t.after(() => cleanup(dir));
|
|
394
|
+
const result = await verifyInstalled({
|
|
395
|
+
dirOverride: dir,
|
|
396
|
+
verifyFn: (p) => _verifyWithKey(p, rawPub),
|
|
397
|
+
digestProvider: makeDigestProvider(dir),
|
|
398
|
+
allowSkipEnv: false,
|
|
399
|
+
});
|
|
400
|
+
assert.equal(result.ok, true);
|
|
401
|
+
assert.notEqual(result.skipped, true);
|
|
402
|
+
});
|
package/skills/fallow/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fallow
|
|
3
|
-
description: Codebase intelligence for JavaScript and TypeScript. Free static layer finds unused code (files, exports, types, dependencies), code duplication, circular dependencies, complexity hotspots, architecture boundary violations, and feature flag patterns. Runtime coverage merges production execution data into the same health report for hot-path review, cold-path deletion confidence, and stale-flag evidence - a single local capture is free, while continuous/cloud runtime monitoring is paid.
|
|
3
|
+
description: Codebase intelligence for JavaScript and TypeScript. Free static layer finds unused code (files, exports, types, dependencies), code duplication, circular dependencies, complexity hotspots, architecture boundary violations, and feature flag patterns. Runtime coverage merges production execution data into the same health report for hot-path review, cold-path deletion confidence, and stale-flag evidence - a single local capture is free, while continuous/cloud runtime monitoring is paid. 95 framework plugins, zero configuration, sub-second static analysis. Use when asked to analyze code health, find unused code, detect duplicates, check circular dependencies, audit complexity, check architecture boundaries, detect feature flags, clean up the codebase, auto-fix issues, merge runtime coverage, or run fallow.
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: Bart Waardenburg
|
|
@@ -10,7 +10,7 @@ metadata:
|
|
|
10
10
|
|
|
11
11
|
# Fallow: codebase intelligence for JavaScript and TypeScript
|
|
12
12
|
|
|
13
|
-
Codebase intelligence for JavaScript and TypeScript. The free static layer finds unused code, circular dependencies, code duplication, complexity hotspots, architecture boundary violations, and feature flag patterns. Runtime coverage merges production execution data into the same `fallow health` report for hot-path review, cold-path deletion confidence, and stale-flag evidence: a single local capture is free, while continuous/cloud runtime monitoring is paid.
|
|
13
|
+
Codebase intelligence for JavaScript and TypeScript. The free static layer finds unused code, circular dependencies, code duplication, complexity hotspots, architecture boundary violations, and feature flag patterns. Runtime coverage merges production execution data into the same `fallow health` report for hot-path review, cold-path deletion confidence, and stale-flag evidence: a single local capture is free, while continuous/cloud runtime monitoring is paid. 95 framework plugins, zero configuration, sub-second static analysis.
|
|
14
14
|
|
|
15
15
|
## When to Use
|
|
16
16
|
|
|
@@ -372,7 +372,7 @@ export const deprecatedHelper = () => {};
|
|
|
372
372
|
## Key Gotchas
|
|
373
373
|
|
|
374
374
|
- **`fix --yes` is required** in non-TTY (agent) environments. Without it, `fix` exits with code 2
|
|
375
|
-
- **Zero config by default.**
|
|
375
|
+
- **Zero config by default.** 95 framework plugins auto-detect, including tap and tsd test entry points. Don't create config unless customization is needed
|
|
376
376
|
- **Syntactic analysis only.** No TypeScript compiler, so fully dynamic `import(variable)` is not resolved
|
|
377
377
|
- **Function overloads are deduplicated.** TypeScript function overload signatures are merged into a single export (not reported as separate unused exports)
|
|
378
378
|
- **Re-export chains are resolved.** Exports through barrel files are tracked, not falsely flagged
|
|
@@ -203,9 +203,10 @@ Auto-removes unused exports, dependencies, enum members, and pnpm catalog entrie
|
|
|
203
203
|
|
|
204
204
|
| Flag | Type | Default | Description |
|
|
205
205
|
|------|------|---------|-------------|
|
|
206
|
-
| `--dry-run` | bool | `false` | Show what would be removed without modifying files |
|
|
206
|
+
| `--dry-run` | bool | `false` | Show what would be removed without modifying files. For `add-to-config` actions, prints a unified-diff preview of the proposed config write; JSON mode includes the diff under a `proposed_diff` field on the fix entry. |
|
|
207
207
|
| `--yes` | bool | `false` | Skip confirmation prompt (**required** in non-TTY) |
|
|
208
208
|
| `--force` | bool | `false` | Alias for `--yes` |
|
|
209
|
+
| `--no-create-config` | bool | `false` | Refuse to create a new `.fallowrc.json` when none exists. The duplicate-export config-add path is skipped with `skip_reason: "no_create_config"`; source-file edits proceed normally. Use in pre-commit hooks, CI bots, and `fallow watch` where silently materialising a new top-level file would surprise the user. |
|
|
209
210
|
| `--format` | `human\|json` | `human` | Output format |
|
|
210
211
|
| `--quiet` | bool | `false` | Suppress progress bars |
|
|
211
212
|
|
|
@@ -214,7 +215,8 @@ Auto-removes unused exports, dependencies, enum members, and pnpm catalog entrie
|
|
|
214
215
|
- Unused exports (removes the `export` keyword; whole-enum block when every member is unused)
|
|
215
216
|
- Unused dependencies (removed from `package.json`)
|
|
216
217
|
- Unused enum members (removed from the declaration)
|
|
217
|
-
- Unused pnpm catalog entries (removed from `pnpm-workspace.yaml` by line-aware deletion; comments
|
|
218
|
+
- Unused pnpm catalog entries (removed from `pnpm-workspace.yaml` by line-aware deletion). Object-form entries are removed as one block. By default, fallow also removes a contiguous YAML comment block immediately above the entry when it clearly belongs to that entry; configure this with `fix.catalog.deletePrecedingComments` (`"auto"`, `"always"`, or `"never"`). Two escape hatches keep curated comments safe regardless of policy: a `# fallow-keep` marker on any line in the block preserves it, and the `auto` policy additionally preserves section-banner blocks whose body starts with three or more `=`, `-`, `*`, `_`, `~`, `+`, or `#` characters (e.g. `# === React 18 production pins ===`). Other comments and stylistic choices are preserved. When the last entry of a catalog group is removed, the header is rewritten to `catalog: {}` / `<name>: {}` so pnpm doesn't reject the resulting null value. Entries with non-empty `hardcoded_consumers` are skipped to avoid breaking `pnpm install`; the skip is surfaced in the JSON fix output as `{"type": "remove_catalog_entry", "applied": false, "skipped": true, "skip_reason": "hardcoded_consumers", "consumers": [...]}`. The JSON action carries both `line` (first deleted line, the leading comment when policy absorbs one) and `entry_line` (the catalog entry's original 1-based line); use `entry_line` as a stable anchor across policy changes. After a successful catalog edit the CLI emits a one-line `Run pnpm install to refresh pnpm-lock.yaml` reminder, and the human stderr summary appends `(+M catalog comment lines)` to the fixed-issue count when comment lines were absorbed. The JSON envelope carries a top-level `"skipped"` count alongside `"total_fixed"` for partial-fix gating.
|
|
219
|
+
- Duplicate exports (appends an `ignoreExports` rule to your fallow config file). When no fallow config file exists, `.fallowrc.json` is created using the same scaffolding `fallow init` would emit (framework detection, `$schema`, `entry`, `ignorePatterns`, etc.) and the rules are layered on top. Inside a monorepo subpackage (`pnpm-workspace.yaml`, `package.json#workspaces`, `turbo.json`, `lerna.json`, or `rush.json` above the invocation directory) the create-fallback refuses to fire and emits `skip_reason: "monorepo_subpackage"` with a relative `workspace_root` path pointing at the workspace root. The applied entry carries `created_files: [".fallowrc.json"]` so consumers can detect file-creation side effects programmatically.
|
|
218
220
|
|
|
219
221
|
### Examples
|
|
220
222
|
|
|
@@ -239,7 +241,7 @@ Inspect discovered files, entry points, detected frameworks, and architecture bo
|
|
|
239
241
|
| `--files` | bool | List all discovered files |
|
|
240
242
|
| `--entry-points` | bool | List detected entry points |
|
|
241
243
|
| `--plugins` | bool | List active framework plugins |
|
|
242
|
-
| `--boundaries` | bool | Show architecture boundary zones, rules,
|
|
244
|
+
| `--boundaries` | bool | Show architecture boundary zones, rules, per-zone file counts, and `logical_groups[]` for `autoDiscover` parents |
|
|
243
245
|
| `--format` | `human\|json` | Output format |
|
|
244
246
|
| `--quiet` | bool | Suppress progress bars |
|
|
245
247
|
|
|
@@ -252,6 +254,8 @@ fallow list --plugins --format json --quiet
|
|
|
252
254
|
fallow list --boundaries --format json --quiet
|
|
253
255
|
```
|
|
254
256
|
|
|
257
|
+
The `--boundaries` JSON output carries `boundaries.logical_groups[]` alongside the existing `zones[]` / `rules[]` arrays. Each logical-group entry surfaces a user-authored `autoDiscover` parent zone (which expansion otherwise flattens into per-child zones like `features/auth` / `features/billing`): `name`, `children`, `auto_discover` (verbatim user strings), `status` (`ok` / `empty` / `invalid_path`), `source_zone_index`, summed `file_count`, optional `authored_rule` (the pre-expansion `{ allow, allowTypeOnly }` keyed on the parent), optional `fallback_zone` cross-reference when the parent also kept its own `patterns` (Bulletproof case), optional `merged_from` (parent zone indices when the user declared the same parent name twice; surfaces the duplicate in JSON instead of only in `tracing::warn!`), optional `original_zone_root` (echo of the parent's `root` subtree scope for monorepo patchers), and optional `child_source_indices` (parallel to `children`, attributing each child to a specific `auto_discover` entry when multiple paths were authored). The full shape is documented in `docs/output-schema.json` under `ListBoundariesOutput`.
|
|
258
|
+
|
|
255
259
|
---
|
|
256
260
|
|
|
257
261
|
## `init`: Config Generation
|
|
@@ -437,7 +441,7 @@ fallow health --format json --quiet --trend
|
|
|
437
441
|
```json
|
|
438
442
|
{
|
|
439
443
|
"schema_version": 3,
|
|
440
|
-
"version": "2.
|
|
444
|
+
"version": "2.75.0",
|
|
441
445
|
"elapsed_ms": 32,
|
|
442
446
|
"summary": {
|
|
443
447
|
"files_analyzed": 482,
|
|
@@ -823,7 +827,7 @@ fallow audit \
|
|
|
823
827
|
```json
|
|
824
828
|
{
|
|
825
829
|
"schema_version": 3,
|
|
826
|
-
"version": "2.
|
|
830
|
+
"version": "2.75.0",
|
|
827
831
|
"command": "audit",
|
|
828
832
|
"verdict": "fail",
|
|
829
833
|
"changed_files_count": 12,
|
|
@@ -896,7 +900,7 @@ fallow flags --format json --quiet --workspace my-package
|
|
|
896
900
|
```json
|
|
897
901
|
{
|
|
898
902
|
"schema_version": 3,
|
|
899
|
-
"version": "2.
|
|
903
|
+
"version": "2.75.0",
|
|
900
904
|
"elapsed_ms": 116,
|
|
901
905
|
"feature_flags": [],
|
|
902
906
|
"total_flags": 0
|
|
@@ -1131,7 +1135,7 @@ Coverage CI helper for bundled/minified runtime coverage. It scans a build direc
|
|
|
1131
1135
|
| `--dir <PATH>` | path | `dist` | Directory scanned recursively. |
|
|
1132
1136
|
| `--include <GLOB>` | glob | `**/*.map` | Include glob relative to `--dir`. |
|
|
1133
1137
|
| `--exclude <GLOB>` | glob | `**/node_modules/**` | Exclude glob, repeatable. |
|
|
1134
|
-
| `--repo <NAME>` | string | `package.json` `repository.url`, then `git remote get-url origin` | Repo
|
|
1138
|
+
| `--repo <NAME>` | string | `package.json` `repository.url`, then `git remote get-url origin` parsed to `owner/repo` | Repo identifier used in the source-map API path. Must match the beacon's `projectId` (and `upload-inventory`'s `--project-id`); pass `--repo <bare-name>` explicitly if the beacon reports a bare name. |
|
|
1135
1139
|
| `--git-sha <SHA>` | string | `$GITHUB_SHA` -> `$CI_COMMIT_SHA` -> `$COMMIT_SHA` -> `git rev-parse HEAD` | Commit SHA, 7-40 hex chars. |
|
|
1136
1140
|
| `--endpoint <URL>` | string | `$FALLOW_API_URL` or `https://api.fallow.cloud` | Override for staging / on-prem. |
|
|
1137
1141
|
| `--strip-path <BOOL>` | bool | `true` | Upload basename-only `fileName` values. Use `--strip-path=false` when runtime coverage reports paths like `assets/app.js`. |
|
|
@@ -1315,7 +1319,7 @@ The HTTP layer mirrors the bash `gh_api_retry` / `curl_retry` helpers: `FALLOW_A
|
|
|
1315
1319
|
```json
|
|
1316
1320
|
{
|
|
1317
1321
|
"schema_version": 3,
|
|
1318
|
-
"version": "2.
|
|
1322
|
+
"version": "2.75.0",
|
|
1319
1323
|
"elapsed_ms": 45,
|
|
1320
1324
|
"total_issues": 12,
|
|
1321
1325
|
"entry_points": {
|
|
@@ -1367,7 +1371,7 @@ Every issue in `dead-code` JSON output includes an `actions` array with structur
|
|
|
1367
1371
|
| Field | Type | Required | Description |
|
|
1368
1372
|
|-------|------|----------|-------------|
|
|
1369
1373
|
| `type` | string | yes | Action type in kebab-case (for example `remove-export`, `remove-file`, `remove-dependency`, `move-dependency`, `suppress-line`, `add-to-config`) |
|
|
1370
|
-
| `auto_fixable` | bool | yes | `true` if `fallow fix` handles this action automatically |
|
|
1374
|
+
| `auto_fixable` | bool | yes | `true` if `fallow fix` handles this action automatically. Evaluated PER FINDING, not per action type: the same `type` may carry `true` on one finding and `false` on another when per-instance guards in the applier discriminate. Filter on this bool of each individual action, not on `type` alone. |
|
|
1371
1375
|
| `description` | string | yes | Human-readable description of the action |
|
|
1372
1376
|
| `comment` | string | no | Suppression comment text (on `suppress-line` actions) |
|
|
1373
1377
|
| `note` | string | no | Additional context on non-auto-fixable items |
|
|
@@ -1424,6 +1428,14 @@ Dependency issues use `add-to-config` with `config_key` and `value`:
|
|
|
1424
1428
|
|
|
1425
1429
|
When a dependency action is `move-dependency`, `auto_fixable` is `false`; the package is imported from another workspace and needs a package.json ownership move rather than removal.
|
|
1426
1430
|
|
|
1431
|
+
Per-instance `auto_fixable` flips today (the same action `type` flipping between findings):
|
|
1432
|
+
|
|
1433
|
+
- `remove-catalog-entry` (unused-catalog-entries): `true` only when `hardcoded_consumers` is empty; `false` otherwise (the applier skips the entry to avoid breaking `pnpm install`).
|
|
1434
|
+
- `remove-dependency` vs `move-dependency` (dependency findings): primary action flips between `remove-dependency` (`true`) and `move-dependency` (`false`) on `used_in_workspaces`.
|
|
1435
|
+
- `add-to-config` for `ignoreExports` (duplicate-exports): `true` when `fallow fix` can safely apply the action, which today means EITHER a fallow config file already exists OR no config exists and the working directory is NOT inside a monorepo subpackage. In the second case the applier creates `.fallowrc.json` using `fallow init`'s framework-aware scaffolding and layers the new rules on top. `false` inside a monorepo subpackage with no workspace-root config (the applier refuses to fragment per-package configs). Pass `--no-create-config` to `fallow fix` from pre-commit hooks, CI bots, and `fallow watch` to opt out of the create-fallback; the action then surfaces with `auto_fixable: false`.
|
|
1436
|
+
- `update-catalog-reference` (unresolved-catalog-references): always `false` today; non-singleton on the wire so a future applier can promote it without a schema change.
|
|
1437
|
+
- All `suppress-line` and `suppress-file` actions are uniformly `false`.
|
|
1438
|
+
|
|
1427
1439
|
#### Health `actions` array (CRAP findings)
|
|
1428
1440
|
|
|
1429
1441
|
Health findings (`fallow health` JSON output) include an `actions` array. Primary action selection is formula-aware: the rule first checks whether full coverage CAN bring CRAP under threshold (CRAP bottoms out at `cyclomatic` at 100% coverage, so `cyclomatic < maxCrap` means coverage is a viable remediation), then uses `coverage_tier` to choose the description.
|
|
@@ -1437,6 +1449,8 @@ Health findings (`fallow health` JSON output) include an `actions` array. Primar
|
|
|
1437
1449
|
|
|
1438
1450
|
The `coverage_tier` field is `"none"` (file not test-reachable / Istanbul 0%), `"partial"` (Istanbul `(0, 70)` / estimated 40%), or `"high"` (Istanbul `>= 70` / estimated 85%).
|
|
1439
1451
|
|
|
1452
|
+
Each CRAP finding also carries a `coverage_source` discriminator: `"istanbul"` (direct fnMap match for this function), `"estimated"` (graph-based estimate evaluated against the finding's own file), or `"estimated_component_inherited"` (graph-based estimate inherited from an Angular component `.ts` reached via the inverse `templateUrl` edge). Synthetic `<template>` findings on Angular `.html` templates use the `estimated_component_inherited` source and ship an `inherited_from` field with the project-relative path to the owning `.component.ts`. When the inherit path applies, the primary `increase-coverage` action targets that `.ts` file (description names the component path explicitly and includes a `target_path` field) so AI agents add component tests rather than scaffolding tests against a structurally untestable `.html` path. The human `fallow health` output renders `(inherited from foo.component.ts)` after the CRAP score on those rows. This is the JIT-test fallback (Angular's runtime renders templates via `ɵɵconditional` / `ɵɵrepeaterCreate` calls; Istanbul never has `fnMap` entries keyed at `.html` paths). AOT-compiled coverage with source-map back-mapping is planned as a tier 2 follow-up; when it lands, `coverage_source` will gain a `"measured_aot_source_map"` variant.
|
|
1453
|
+
|
|
1440
1454
|
When CRAP-only with cyclomatic count within 5 of `maxCyclomatic` AND cognitive at or above `maxCognitive / 2`, a secondary `refactor-function` is appended. The cognitive floor suppresses false positives on flat type-tag dispatchers and JSX render maps (high CC, near-zero cog). A single finding can carry multiple action types: e.g. a finding that exceeds both cyclomatic and CRAP at `coverage_tier=partial` gets `increase-coverage` AND `refactor-function`. Treat the first non-`suppress-line` action as primary.
|
|
1441
1455
|
|
|
1442
1456
|
The `suppress-line` action is auto-omitted when `--baseline`/`--save-baseline` is set, OR when `health.suggestInlineSuppression: false` in config. The report root carries an `actions_meta: { suppression_hints_omitted: true, reason: "baseline-active" | "config-disabled" }` breadcrumb in that case.
|
|
@@ -1462,7 +1476,7 @@ When `--baseline` is used in combined output, the JSON includes a `baseline_delt
|
|
|
1462
1476
|
```json
|
|
1463
1477
|
{
|
|
1464
1478
|
"schema_version": 3,
|
|
1465
|
-
"version": "2.
|
|
1479
|
+
"version": "2.75.0",
|
|
1466
1480
|
"elapsed_ms": 82,
|
|
1467
1481
|
"total_clones": 15,
|
|
1468
1482
|
"total_lines_duplicated": 230,
|
|
@@ -1506,7 +1520,7 @@ When running `fallow` with no subcommand (all analyses), the JSON output combine
|
|
|
1506
1520
|
{
|
|
1507
1521
|
"check": {
|
|
1508
1522
|
"schema_version": 3,
|
|
1509
|
-
"version": "2.
|
|
1523
|
+
"version": "2.75.0",
|
|
1510
1524
|
"elapsed_ms": 45,
|
|
1511
1525
|
"total_issues": 12,
|
|
1512
1526
|
"unused_files": [],
|
|
@@ -1528,7 +1542,7 @@ When running `fallow` with no subcommand (all analyses), the JSON output combine
|
|
|
1528
1542
|
},
|
|
1529
1543
|
"dupes": {
|
|
1530
1544
|
"schema_version": 3,
|
|
1531
|
-
"version": "2.
|
|
1545
|
+
"version": "2.75.0",
|
|
1532
1546
|
"elapsed_ms": 82,
|
|
1533
1547
|
"total_clones": 15,
|
|
1534
1548
|
"total_lines_duplicated": 230,
|
|
@@ -1537,7 +1551,7 @@ When running `fallow` with no subcommand (all analyses), the JSON output combine
|
|
|
1537
1551
|
},
|
|
1538
1552
|
"health": {
|
|
1539
1553
|
"schema_version": 3,
|
|
1540
|
-
"version": "2.
|
|
1554
|
+
"version": "2.75.0",
|
|
1541
1555
|
"elapsed_ms": 32,
|
|
1542
1556
|
"summary": {},
|
|
1543
1557
|
"findings": [],
|
|
@@ -1621,17 +1635,20 @@ Config files are searched in priority order: `.fallowrc.json` > `.fallowrc.jsonc
|
|
|
1621
1635
|
|
|
1622
1636
|
// Architecture boundaries (preset, custom zones/rules, or auto-discovered feature zones)
|
|
1623
1637
|
// Presets: "layered", "hexagonal", "feature-sliced", "bulletproof"
|
|
1638
|
+
// Rules accept an optional `allowTypeOnly: [zones]` list that admits type-only imports
|
|
1639
|
+
// (`import type`, inline `{ type Foo }`, namespace type imports, and `export type` re-exports)
|
|
1640
|
+
// to the listed zones even when not present in `allow`. Mixed-specifier imports still fire.
|
|
1624
1641
|
"boundaries": {
|
|
1625
1642
|
"preset": "bulletproof"
|
|
1626
1643
|
// Or:
|
|
1627
1644
|
// "zones": [
|
|
1628
1645
|
// { "name": "app", "patterns": ["src/app/**"] },
|
|
1629
|
-
// { "name": "features", "autoDiscover": ["src/features"] },
|
|
1646
|
+
// { "name": "features", "patterns": ["src/features/**"], "autoDiscover": ["src/features"] },
|
|
1630
1647
|
// { "name": "shared", "patterns": ["src/shared/**"] }
|
|
1631
1648
|
// ],
|
|
1632
1649
|
// "rules": [
|
|
1633
1650
|
// { "from": "app", "allow": ["features", "shared"] },
|
|
1634
|
-
// { "from": "features", "allow": ["shared"] }
|
|
1651
|
+
// { "from": "features", "allow": ["shared"], "allowTypeOnly": ["features"] }
|
|
1635
1652
|
// ]
|
|
1636
1653
|
},
|
|
1637
1654
|
|
|
@@ -297,9 +297,9 @@ This is separate from the dead code suppression tokens. See the full list of val
|
|
|
297
297
|
|
|
298
298
|
---
|
|
299
299
|
|
|
300
|
-
## Decorated Members Are Skipped
|
|
300
|
+
## Decorated Members Are Skipped By Default
|
|
301
301
|
|
|
302
|
-
Class members with decorators (NestJS `@Get()`, Angular `@Input()`, TypeORM `@Column()`, etc.) are
|
|
302
|
+
Class members with decorators (NestJS `@Get()`, Angular `@Input()`, TypeORM `@Column()`, etc.) are excluded from unused member detection by default. Decorator-driven frameworks consume these via reflection at runtime, so reporting them as unused would be a false positive.
|
|
303
303
|
|
|
304
304
|
```typescript
|
|
305
305
|
class UserController {
|
|
@@ -308,7 +308,20 @@ class UserController {
|
|
|
308
308
|
}
|
|
309
309
|
```
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
### Opt specific decorators out via `ignoreDecorators`
|
|
312
|
+
|
|
313
|
+
If you use utility decorators that DO NOT imply reflective use (Playwright's `@step("label")`, internal labeling decorators like `@measure`, `@log`, `@retry`), list their names in the `ignoreDecorators` config option so the methods carrying them are checked for usage like undecorated methods.
|
|
314
|
+
|
|
315
|
+
```jsonc
|
|
316
|
+
// .fallowrc.json
|
|
317
|
+
{
|
|
318
|
+
"ignoreDecorators": ["@step"]
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Conservative semantics: a method carrying any decorator NOT in the list still gets skipped. So `@step` + `@Inject` on the same method stays treated as framework-managed. Matching rule: entries containing `.` (`"decorators.log"`) match the full dotted path; bare entries (`"step"` or `"decorators"`) match the leftmost segment, so a single bare `"decorators"` entry collapses an entire `@decorators.*` namespace. Both `"@step"` and `"step"` round-trip equivalently. Unmatched entries (a decorator name in the config that never appears in your codebase) surface as a one-time warning at end of run.
|
|
323
|
+
|
|
324
|
+
The default empty list preserves today's skip-all behavior, so existing NestJS / Angular / TypeORM projects see no change.
|
|
312
325
|
|
|
313
326
|
---
|
|
314
327
|
|
|
@@ -621,7 +621,7 @@ Focus on findings that are BOTH dead code and duplicated:
|
|
|
621
621
|
|
|
622
622
|
## Custom Plugin Setup
|
|
623
623
|
|
|
624
|
-
For frameworks not covered by the
|
|
624
|
+
For frameworks not covered by the 95 built-in plugins.
|
|
625
625
|
|
|
626
626
|
### Option 1: Inline framework config
|
|
627
627
|
|