fallow 2.76.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 +57 -0
- 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/gotchas.md +16 -3
- package/skills/fallow/references/patterns.md +1 -1
- package/types/output-contract.d.ts +234 -4
|
@@ -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
|
|
@@ -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
|
|