@0xcraft/powershot 1.1.3 → 1.1.5
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/README.md +2 -2
- package/dist/git.js +5 -3
- package/dist/ground.js +61 -13
- package/dist/lang/packs.js +170 -8
- package/dist/langtest.js +1144 -2
- package/dist/reinvention.js +334 -0
- package/dist/report/sarif.js +2 -2
- package/dist/review.js +5 -1
- package/dist/selftest.js +575 -8
- package/dist/verifiers/dropped-guard.js +188 -84
- package/dist/verifiers/foreign-dropped-guard.js +174 -50
- package/dist/verifiers/foreign-reinvented.js +87 -26
- package/dist/verifiers/foreign-tokens.js +232 -20
- package/dist/verifiers/guard-diff.js +138 -0
- package/dist/verifiers/reinvented.js +31 -11
- package/docs/architecture.md +26 -1
- package/package.json +1 -1
package/dist/selftest.js
CHANGED
|
@@ -22,7 +22,7 @@ import { stripControl } from './text.js';
|
|
|
22
22
|
import { review } from './review.js';
|
|
23
23
|
import { withTargetTree } from './snapshot.js';
|
|
24
24
|
import { loadConfig } from './config.js';
|
|
25
|
-
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
26
26
|
import { insideRepo, repoPath } from './fspolicy.js';
|
|
27
27
|
import { Budget, parseLimits } from './budget.js';
|
|
28
28
|
import { SelectionPlan, capabilitiesOf } from './plan.js';
|
|
@@ -52,6 +52,7 @@ import { summarizeRun } from './report/summary.js';
|
|
|
52
52
|
import { wrap } from './report/terminal.js';
|
|
53
53
|
import { highlight, isJsx } from './report/highlight.js';
|
|
54
54
|
import { buildGround, normalizeName, readEnvManifest, relPath } from './ground.js';
|
|
55
|
+
import { exportedDeclarations, implementationFingerprint, reinventionScope, typescriptImplementationFingerprint, } from './reinvention.js';
|
|
55
56
|
import { incompleteReasons } from './bench.js';
|
|
56
57
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
57
58
|
import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
@@ -69,6 +70,7 @@ function ground(files, deps = []) {
|
|
|
69
70
|
const lineCount = f.after.split('\n').length;
|
|
70
71
|
const c = {
|
|
71
72
|
path: f.path,
|
|
73
|
+
beforePath: f.beforePath,
|
|
72
74
|
added: new Set(Array.from({ length: lineCount }, (_, i) => i + 1)),
|
|
73
75
|
before: f.before,
|
|
74
76
|
};
|
|
@@ -76,20 +78,36 @@ function ground(files, deps = []) {
|
|
|
76
78
|
entries.push({
|
|
77
79
|
sf,
|
|
78
80
|
changed: c,
|
|
79
|
-
before: f.before === undefined
|
|
81
|
+
before: f.before === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: beforeProject.createSourceFile('/before/' + (f.beforePath ?? f.path), f.before, { overwrite: true }),
|
|
80
84
|
typed: true,
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
const symbolIndex = new Map();
|
|
84
88
|
for (const sf of project.getSourceFiles()) {
|
|
85
89
|
const rel = sf.getFilePath().slice(root.length + 1);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
const input = files.find((file) => file.path === rel);
|
|
91
|
+
for (const { name, node: decl } of exportedDeclarations(sf)) {
|
|
92
|
+
const fingerprint = typescriptImplementationFingerprint(decl, rel);
|
|
93
|
+
if (!fingerprint)
|
|
89
94
|
continue;
|
|
90
95
|
const key = normalizeName(name);
|
|
91
96
|
const list = symbolIndex.get(key) ?? [];
|
|
92
|
-
|
|
97
|
+
const beforePath = input?.beforePath ?? rel;
|
|
98
|
+
const before = input?.before === undefined ? undefined : beforeProject.getSourceFile('/before/' + beforePath);
|
|
99
|
+
const existedInBase = before && reinventionScope(root, beforePath) === reinventionScope(root, rel)
|
|
100
|
+
? exportedDeclarations(before).some((baseDeclaration) => baseDeclaration.name === name &&
|
|
101
|
+
typescriptImplementationFingerprint(baseDeclaration.node, beforePath) === fingerprint)
|
|
102
|
+
: false;
|
|
103
|
+
list.push({
|
|
104
|
+
file: rel,
|
|
105
|
+
name,
|
|
106
|
+
line: decl.getStartLineNumber(),
|
|
107
|
+
fingerprint,
|
|
108
|
+
existedInBase,
|
|
109
|
+
scope: reinventionScope(root, rel),
|
|
110
|
+
});
|
|
93
111
|
symbolIndex.set(key, list);
|
|
94
112
|
}
|
|
95
113
|
}
|
|
@@ -120,6 +138,22 @@ async function checkAsync(name, fn) {
|
|
|
120
138
|
function fires(v, g) {
|
|
121
139
|
return v.run(g).length > 0;
|
|
122
140
|
}
|
|
141
|
+
async function javascriptReinvention(candidate) {
|
|
142
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-javascript-reinvention-')));
|
|
143
|
+
try {
|
|
144
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
145
|
+
mkdirSync(join(dir, 'utils'), { recursive: true });
|
|
146
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}');
|
|
147
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["lib/**/*.mjs","utils/**/*.mjs"]}');
|
|
148
|
+
writeFileSync(join(dir, 'lib/normalize.mjs'), 'export function normalizePayload(value) { return value.trim() }\n');
|
|
149
|
+
writeFileSync(join(dir, 'utils/normalize.mjs'), candidate);
|
|
150
|
+
const g = await buildGround(dir, [{ path: 'utils/normalize.mjs', added: new Set([1]) }]);
|
|
151
|
+
return fires(reinvented, g);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
rmSync(dir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
123
157
|
console.log('\nphantom-dep');
|
|
124
158
|
check('fires on an import that is not a declared dependency', () => {
|
|
125
159
|
const g = ground([{ path: 'a.ts', after: "import ky from 'ky'\nexport const x = ky\n" }], ['zod']);
|
|
@@ -145,15 +179,115 @@ check('resolves a scoped subpath to its package', () => {
|
|
|
145
179
|
assert.equal(fires(phantomDep, g), false);
|
|
146
180
|
});
|
|
147
181
|
console.log('\nreinvented');
|
|
182
|
+
check('keeps every public alias for one exported declaration', () => {
|
|
183
|
+
const project = new Project({ useInMemoryFileSystem: true });
|
|
184
|
+
const source = project.createSourceFile('/aliases.mjs', 'function helper(value) { return value }\nexport { helper, helper as normalizePayload }\n');
|
|
185
|
+
assert.deepEqual(exportedDeclarations(source).map((declaration) => declaration.name).sort(), ['helper', 'normalizePayload']);
|
|
186
|
+
});
|
|
148
187
|
check('fires when a helper already exists elsewhere', () => {
|
|
188
|
+
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
149
189
|
const g = ground([
|
|
150
|
-
{ path: 'lib/currency.ts',
|
|
151
|
-
{ path: 'utils/money.ts', after:
|
|
190
|
+
{ path: 'lib/currency.ts', before: existing, after: existing },
|
|
191
|
+
{ path: 'utils/money.ts', after: existing },
|
|
152
192
|
]);
|
|
153
193
|
const found = reinvented.run(g);
|
|
154
194
|
assert.ok(found.length >= 1, 'expected a duplication finding');
|
|
155
195
|
assert.equal(found[0].confidence, 'firm'); // heuristic, never claims `proven`
|
|
156
196
|
});
|
|
197
|
+
await checkAsync('fires on an exact JavaScript helper already present in the base', async () => {
|
|
198
|
+
assert.equal(await javascriptReinvention('export function normalizePayload(value) { return value.trim() }\n'), true);
|
|
199
|
+
});
|
|
200
|
+
await checkAsync('silent on a same-name JavaScript helper with different behavior', async () => {
|
|
201
|
+
assert.equal(await javascriptReinvention('export function normalizePayload(value) { return JSON.stringify(value) }\n'), false);
|
|
202
|
+
});
|
|
203
|
+
check('binds an implementation fingerprint to its import source', () => {
|
|
204
|
+
const existing = [
|
|
205
|
+
"import { transform } from './alpha.js'",
|
|
206
|
+
'export function normalizePayload(value: string) { return transform(value) }',
|
|
207
|
+
'',
|
|
208
|
+
].join('\n');
|
|
209
|
+
const same = ground([
|
|
210
|
+
{ path: 'lib/existing.ts', before: existing, after: existing },
|
|
211
|
+
{ path: 'lib/added.ts', after: existing },
|
|
212
|
+
]);
|
|
213
|
+
const different = ground([
|
|
214
|
+
{ path: 'lib/existing.ts', before: existing, after: existing },
|
|
215
|
+
{ path: 'lib/added.ts', after: existing.replace("'./alpha.js'", "'./beta.js'") },
|
|
216
|
+
]);
|
|
217
|
+
assert.equal(fires(reinvented, same), true);
|
|
218
|
+
assert.equal(fires(reinvented, different), false);
|
|
219
|
+
});
|
|
220
|
+
check('binds an implementation fingerprint to referenced module locals', () => {
|
|
221
|
+
const source = (factor) => [
|
|
222
|
+
'const FACTOR = ' + factor,
|
|
223
|
+
'export function scalePayload(value: number) { return value * FACTOR }',
|
|
224
|
+
'',
|
|
225
|
+
].join('\n');
|
|
226
|
+
const same = ground([
|
|
227
|
+
{ path: 'lib/existing.ts', before: source(2), after: source(2) },
|
|
228
|
+
{ path: 'lib/added.ts', after: source(2) },
|
|
229
|
+
]);
|
|
230
|
+
const different = ground([
|
|
231
|
+
{ path: 'lib/existing.ts', before: source(2), after: source(2) },
|
|
232
|
+
{ path: 'lib/added.ts', after: source(3) },
|
|
233
|
+
]);
|
|
234
|
+
assert.equal(fires(reinvented, same), true);
|
|
235
|
+
assert.equal(fires(reinvented, different), false);
|
|
236
|
+
});
|
|
237
|
+
check('qualifies relative imports by their source directory', () => {
|
|
238
|
+
const source = [
|
|
239
|
+
"import { transform } from './transform.js'",
|
|
240
|
+
'export function normalizePayload(value: string) { return transform(value) }',
|
|
241
|
+
'',
|
|
242
|
+
].join('\n');
|
|
243
|
+
const sameDirectory = ground([
|
|
244
|
+
{ path: 'lib/existing.ts', before: source, after: source },
|
|
245
|
+
{ path: 'lib/added.ts', after: source },
|
|
246
|
+
]);
|
|
247
|
+
const differentDirectory = ground([
|
|
248
|
+
{ path: 'alpha/existing.ts', before: source, after: source },
|
|
249
|
+
{ path: 'beta/added.ts', after: source },
|
|
250
|
+
]);
|
|
251
|
+
assert.equal(fires(reinvented, sameDirectory), true);
|
|
252
|
+
assert.equal(fires(reinvented, differentDirectory), false);
|
|
253
|
+
});
|
|
254
|
+
check('binds an implementation fingerprint to TypeScript reference directives', () => {
|
|
255
|
+
const source = (target) => [
|
|
256
|
+
'/// <reference path="' + target + '" />',
|
|
257
|
+
'export function normalizePayload(value: Payload) { return value }',
|
|
258
|
+
'',
|
|
259
|
+
].join('\n');
|
|
260
|
+
const g = ground([
|
|
261
|
+
{ path: 'lib/existing.ts', before: source('./alpha.d.ts'), after: source('./alpha.d.ts') },
|
|
262
|
+
{ path: 'lib/added.ts', after: source('./beta.d.ts') },
|
|
263
|
+
]);
|
|
264
|
+
assert.equal(fires(reinvented, g), false);
|
|
265
|
+
});
|
|
266
|
+
check('silent when matching helpers are both new in the change', () => {
|
|
267
|
+
const added = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
268
|
+
const g = ground([
|
|
269
|
+
{ path: 'lib/currency.ts', after: added },
|
|
270
|
+
{ path: 'utils/money.ts', after: added },
|
|
271
|
+
]);
|
|
272
|
+
assert.equal(fires(reinvented, g), false);
|
|
273
|
+
});
|
|
274
|
+
check('silent when the matching implementation already existed in the changed file', () => {
|
|
275
|
+
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
276
|
+
const g = ground([
|
|
277
|
+
{ path: 'lib/currency.ts', before: existing, after: existing },
|
|
278
|
+
{ path: 'utils/money.ts', before: existing, after: existing },
|
|
279
|
+
]);
|
|
280
|
+
assert.equal(fires(reinvented, g), false);
|
|
281
|
+
});
|
|
282
|
+
check('silent when the candidate only became equivalent in this change', () => {
|
|
283
|
+
const before = 'export function formatMinorUnits(n: number) { return n / 10 }\n';
|
|
284
|
+
const after = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
285
|
+
const g = ground([
|
|
286
|
+
{ path: 'lib/currency.ts', before, after },
|
|
287
|
+
{ path: 'utils/money.ts', after },
|
|
288
|
+
]);
|
|
289
|
+
assert.equal(fires(reinvented, g), false);
|
|
290
|
+
});
|
|
157
291
|
check('silent on a genuinely new name', () => {
|
|
158
292
|
const g = ground([
|
|
159
293
|
{ path: 'lib/currency.ts', after: 'export function formatMinorUnits(n: number) { return n / 100 }\n' },
|
|
@@ -168,6 +302,125 @@ check('silent on short and generic names', () => {
|
|
|
168
302
|
]);
|
|
169
303
|
assert.equal(fires(reinvented, g), false);
|
|
170
304
|
});
|
|
305
|
+
check('silent when only the helper name matches', () => {
|
|
306
|
+
const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
|
|
307
|
+
const g = ground([
|
|
308
|
+
{ path: 'web/use-app-updater.ts', before: existing, after: existing },
|
|
309
|
+
{
|
|
310
|
+
path: 'scripts/check-import-boundaries.mjs',
|
|
311
|
+
after: 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n',
|
|
312
|
+
},
|
|
313
|
+
]);
|
|
314
|
+
assert.equal(fires(reinvented, g), false);
|
|
315
|
+
});
|
|
316
|
+
await checkAsync('silent across package boundaries', async () => {
|
|
317
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-scope-')));
|
|
318
|
+
try {
|
|
319
|
+
mkdirSync(join(dir, 'apps/web/src'), { recursive: true });
|
|
320
|
+
mkdirSync(join(dir, 'tools/scripts'), { recursive: true });
|
|
321
|
+
writeFileSync(join(dir, 'apps/web/package.json'), '{"name":"web","private":true}');
|
|
322
|
+
writeFileSync(join(dir, 'tools/scripts/package.json'), '{"name":"scripts","private":true}');
|
|
323
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["apps/**/*.mjs","tools/**/*.mjs"]}');
|
|
324
|
+
writeFileSync(join(dir, 'apps/web/src/normalize.mjs'), 'export function normalizePayload(value) { return value.trim() }\n');
|
|
325
|
+
writeFileSync(join(dir, 'tools/scripts/normalize.mjs'), 'function normalizePayload(value) { return value.trim() }\n');
|
|
326
|
+
const g = await buildGround(dir, [
|
|
327
|
+
{ path: 'tools/scripts/normalize.mjs', added: new Set([1]) },
|
|
328
|
+
]);
|
|
329
|
+
assert.equal(fires(reinvented, g), false);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
rmSync(dir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
await checkAsync('silent when only a new barrel alias gives the candidate a matching name', async () => {
|
|
336
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-alias-')));
|
|
337
|
+
try {
|
|
338
|
+
mkdirSync(join(dir, 'lib'), { recursive: true });
|
|
339
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
340
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}');
|
|
341
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"include":["lib/**/*.ts","scripts/**/*.ts"]}');
|
|
342
|
+
writeFileSync(join(dir, 'lib/base.ts'), 'export function calculateVersion(current: string, available: string) { return current !== available }\n');
|
|
343
|
+
writeFileSync(join(dir, 'lib/index.ts'), "export { calculateVersion as runCheck } from './base.js'\n");
|
|
344
|
+
writeFileSync(join(dir, 'scripts/run-check.ts'), 'function runCheck(current: string, available: string) { return current !== available }\n');
|
|
345
|
+
const g = await buildGround(dir, [
|
|
346
|
+
{ path: 'lib/index.ts', added: new Set([1]), before: '' },
|
|
347
|
+
{ path: 'scripts/run-check.ts', added: new Set([1]) },
|
|
348
|
+
]);
|
|
349
|
+
assert.equal(fires(reinvented, g), false);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
rmSync(dir, { recursive: true, force: true });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
check('recognizes package boundaries for every declared language family', () => {
|
|
356
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-markers-')));
|
|
357
|
+
try {
|
|
358
|
+
const packages = [
|
|
359
|
+
['typescript', 'package.json'],
|
|
360
|
+
['python', 'pyproject.toml'],
|
|
361
|
+
['rust', 'Cargo.toml'],
|
|
362
|
+
['go', 'go.mod'],
|
|
363
|
+
['jvm', 'build.gradle.kts'],
|
|
364
|
+
['c-cpp', 'CMakeLists.txt'],
|
|
365
|
+
['csharp', 'App.csproj'],
|
|
366
|
+
['php', 'composer.json'],
|
|
367
|
+
['ruby', 'Gemfile'],
|
|
368
|
+
['solidity', 'foundry.toml'],
|
|
369
|
+
];
|
|
370
|
+
for (const [name, marker] of packages) {
|
|
371
|
+
mkdirSync(join(dir, name, 'src'), { recursive: true });
|
|
372
|
+
writeFileSync(join(dir, name, marker), '');
|
|
373
|
+
assert.equal(reinventionScope(dir, name + '/src/file.txt'), name);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
rmSync(dir, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
check('implementation fingerprints cannot confuse token boundaries with token text', () => {
|
|
381
|
+
const oneToken = implementationFingerprint([{ type: '1', text: 'x\u00002\u0000y' }]);
|
|
382
|
+
const twoTokens = implementationFingerprint([{ type: '1', text: 'x' }, { type: '2', text: 'y' }]);
|
|
383
|
+
assert.notEqual(oneToken, twoTokens);
|
|
384
|
+
});
|
|
385
|
+
check('public CLI rejects the name-only repro and keeps an exact-match control', () => {
|
|
386
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-reinvented-cli-')));
|
|
387
|
+
const cli = join(process.cwd(), 'dist', 'cli.js');
|
|
388
|
+
const git = (...args) => {
|
|
389
|
+
execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
390
|
+
};
|
|
391
|
+
const run = () => {
|
|
392
|
+
const result = spawnSync(process.execPath, [cli, 'review', '--verify-only', '--checks', 'reinvented', '--from', 'HEAD~1', '--to', 'HEAD', '--format', 'compact'], { cwd: dir, env: { ...process.env, CI: 'true' }, encoding: 'utf8' });
|
|
393
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
394
|
+
};
|
|
395
|
+
try {
|
|
396
|
+
git('init', '-q', '.');
|
|
397
|
+
git('config', 'user.name', 'PowerShot Tests');
|
|
398
|
+
git('config', 'user.email', 'tests@powershot.invalid');
|
|
399
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
400
|
+
mkdirSync(join(dir, 'scripts'), { recursive: true });
|
|
401
|
+
writeFileSync(join(dir, 'package.json'), '{"name":"fixture","private":true}\n');
|
|
402
|
+
writeFileSync(join(dir, 'tsconfig.json'), '{"compilerOptions":{"allowJs":true},"include":["src/**/*.ts","scripts/**/*"]}\n');
|
|
403
|
+
const existing = 'export function runCheck(currentVersion: string, availableVersion: string) { return currentVersion !== availableVersion }\n';
|
|
404
|
+
writeFileSync(join(dir, 'src/use-app-updater.ts'), existing);
|
|
405
|
+
git('add', '.');
|
|
406
|
+
git('commit', '-q', '-m', 'base');
|
|
407
|
+
writeFileSync(join(dir, 'scripts/check-import-boundaries.mjs'), 'function runCheck({ srcRoot, allowlist }) { return allowlist.filter((entry) => !entry.startsWith(srcRoot)) }\n');
|
|
408
|
+
git('add', '.');
|
|
409
|
+
git('commit', '-q', '-m', 'different helper with same name');
|
|
410
|
+
const nameOnly = run();
|
|
411
|
+
assert.equal(nameOnly.status, 0, nameOnly.stderr || nameOnly.stdout);
|
|
412
|
+
assert.doesNotMatch(nameOnly.stdout, /\[reinvented\]/);
|
|
413
|
+
writeFileSync(join(dir, 'scripts/duplicate.ts'), existing);
|
|
414
|
+
git('add', '.');
|
|
415
|
+
git('commit', '-q', '-m', 'exact duplicate');
|
|
416
|
+
const exact = run();
|
|
417
|
+
assert.equal(exact.status, 1, exact.stderr || exact.stdout);
|
|
418
|
+
assert.match(exact.stdout, /\[reinvented\]/);
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
rmSync(dir, { recursive: true, force: true });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
171
424
|
console.log('\ndropped-guard');
|
|
172
425
|
check('fires when an early-return guard disappears', () => {
|
|
173
426
|
const before = 'export function close(inv: any) {\n if (!inv.customer) return null\n return inv.total\n}\n';
|
|
@@ -177,11 +430,33 @@ check('fires when an early-return guard disappears', () => {
|
|
|
177
430
|
assert.equal(found.length, 1);
|
|
178
431
|
assert.match(found[0].title, /!inv\.customer/);
|
|
179
432
|
});
|
|
433
|
+
check('fires when a JavaScript early-return guard disappears', () => {
|
|
434
|
+
const before = 'export function release(slot) {\n if (!slot.ready()) return false\n return slot.close()\n}\n';
|
|
435
|
+
const after = 'export function release(slot) {\n return slot.close()\n}\n';
|
|
436
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.mjs', after, before }])), true);
|
|
437
|
+
});
|
|
438
|
+
check('silent when JavaScript moves the guarded operation into a helper', () => {
|
|
439
|
+
const before = 'export function release(slot) {\n if (!slot.ready()) return false\n return slot.close()\n}\n';
|
|
440
|
+
const after = [
|
|
441
|
+
'export function release(slot) { return closeIfReady(slot) }',
|
|
442
|
+
'function closeIfReady(slot) {',
|
|
443
|
+
' if (!slot.ready()) return false',
|
|
444
|
+
' return slot.close()',
|
|
445
|
+
'}',
|
|
446
|
+
'',
|
|
447
|
+
].join('\n');
|
|
448
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.mjs', after, before }])), false);
|
|
449
|
+
});
|
|
180
450
|
check('fires when a throwing guard disappears', () => {
|
|
181
451
|
const before = 'export function pay(a: any) {\n if (a <= 0) { throw new Error("bad") }\n return a\n}\n';
|
|
182
452
|
const after = 'export function pay(a: any) {\n return a\n}\n';
|
|
183
453
|
assert.equal(fires(droppedGuard, ground([{ path: 'pay.ts', after, before }])), true);
|
|
184
454
|
});
|
|
455
|
+
check('fires inside an arrow function', () => {
|
|
456
|
+
const before = 'export const release = (slot: any) => {\n if (!slot.ready()) return false\n return slot.close()\n}\n';
|
|
457
|
+
const after = 'export const release = (slot: any) => {\n return slot.close()\n}\n';
|
|
458
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), true);
|
|
459
|
+
});
|
|
185
460
|
check('silent when the guard is kept, even if reformatted', () => {
|
|
186
461
|
const before = 'export function close(inv: any) {\n if (!inv.customer) return null\n return inv.total\n}\n';
|
|
187
462
|
const after = 'export function close(inv: any) {\n if (!inv.customer)\n return null\n return inv.total * 2\n}\n';
|
|
@@ -194,6 +469,258 @@ check('silent when a rewrite respells the same guard for a new type', () => {
|
|
|
194
469
|
const after = 'export function check(pool: number) {\n if (pool === 0) return null\n return pool\n}\n';
|
|
195
470
|
assert.equal(fires(droppedGuard, ground([{ path: 'c.ts', after, before }])), false);
|
|
196
471
|
});
|
|
472
|
+
check('silent when the callable contract narrows with the removed guard', () => {
|
|
473
|
+
const before = [
|
|
474
|
+
'export function release(slot: { close(): boolean } | null) {',
|
|
475
|
+
' if (!slot) return false',
|
|
476
|
+
' return slot.close()',
|
|
477
|
+
'}',
|
|
478
|
+
'',
|
|
479
|
+
].join('\n');
|
|
480
|
+
const after = [
|
|
481
|
+
'export function release(slot: { close(): boolean }) {',
|
|
482
|
+
' return slot.close()',
|
|
483
|
+
'}',
|
|
484
|
+
'',
|
|
485
|
+
].join('\n');
|
|
486
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
487
|
+
});
|
|
488
|
+
check('silent when the callable owner contract narrows with the removed guard', () => {
|
|
489
|
+
const before = [
|
|
490
|
+
'export class Batch<T extends { close(): boolean } | null> {',
|
|
491
|
+
' release(slot: T) {',
|
|
492
|
+
' if (!slot) return false',
|
|
493
|
+
' return slot.close()',
|
|
494
|
+
' }',
|
|
495
|
+
'}',
|
|
496
|
+
'',
|
|
497
|
+
].join('\n');
|
|
498
|
+
const after = [
|
|
499
|
+
'export class Batch<T extends { close(): boolean }> {',
|
|
500
|
+
' release(slot: T) {',
|
|
501
|
+
' return slot.close()',
|
|
502
|
+
' }',
|
|
503
|
+
'}',
|
|
504
|
+
'',
|
|
505
|
+
].join('\n');
|
|
506
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
507
|
+
});
|
|
508
|
+
check('fires when an unchanged outer branch still contains the guard-only deletion', () => {
|
|
509
|
+
const before = [
|
|
510
|
+
'export function release(slot: any, enabled: boolean) {',
|
|
511
|
+
' if (enabled) {',
|
|
512
|
+
' if (!slot) return false',
|
|
513
|
+
' return slot.close()',
|
|
514
|
+
' }',
|
|
515
|
+
' return true',
|
|
516
|
+
'}',
|
|
517
|
+
'',
|
|
518
|
+
].join('\n');
|
|
519
|
+
const after = before.replace(' if (!slot) return false\n', '');
|
|
520
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), true);
|
|
521
|
+
});
|
|
522
|
+
check('silent when an outer branch is strengthened as the inner guard is removed', () => {
|
|
523
|
+
const before = [
|
|
524
|
+
'export function release(slot: any, enabled: boolean) {',
|
|
525
|
+
' if (enabled) {',
|
|
526
|
+
' if (!slot) return false',
|
|
527
|
+
' return slot.close()',
|
|
528
|
+
' }',
|
|
529
|
+
' return true',
|
|
530
|
+
'}',
|
|
531
|
+
'',
|
|
532
|
+
].join('\n');
|
|
533
|
+
const after = before
|
|
534
|
+
.replace('if (enabled)', 'if (enabled && slot)')
|
|
535
|
+
.replace(' if (!slot) return false\n', '');
|
|
536
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
537
|
+
});
|
|
538
|
+
check('silent when a sibling statement changes as a nested guard is removed', () => {
|
|
539
|
+
const before = [
|
|
540
|
+
'export function release(slot: any, enabled: boolean) {',
|
|
541
|
+
' observe(slot)',
|
|
542
|
+
' if (enabled) {',
|
|
543
|
+
' if (!slot) return false',
|
|
544
|
+
' return slot.close()',
|
|
545
|
+
' }',
|
|
546
|
+
' return true',
|
|
547
|
+
'}',
|
|
548
|
+
'',
|
|
549
|
+
].join('\n');
|
|
550
|
+
const after = before
|
|
551
|
+
.replace('observe(slot)', 'ensureSlot(slot)')
|
|
552
|
+
.replace(' if (!slot) return false\n', '');
|
|
553
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
554
|
+
});
|
|
555
|
+
check('silent when an import binding changes as its guard is removed', () => {
|
|
556
|
+
const before = [
|
|
557
|
+
"import type { Slot } from './alpha.js'",
|
|
558
|
+
'export function release(slot: Slot | null) {',
|
|
559
|
+
' if (!slot) return false',
|
|
560
|
+
' return slot.close()',
|
|
561
|
+
'}',
|
|
562
|
+
'',
|
|
563
|
+
].join('\n');
|
|
564
|
+
const after = before
|
|
565
|
+
.replace("'./alpha.js'", "'./beta.js'")
|
|
566
|
+
.replace(' if (!slot) return false\n', '');
|
|
567
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
568
|
+
});
|
|
569
|
+
check('silent when a module type changes as its guard is removed', () => {
|
|
570
|
+
const before = [
|
|
571
|
+
'type Slot = { close(): boolean } | null',
|
|
572
|
+
'export function release(slot: Slot) {',
|
|
573
|
+
' if (!slot) return false',
|
|
574
|
+
' return slot.close()',
|
|
575
|
+
'}',
|
|
576
|
+
'',
|
|
577
|
+
].join('\n');
|
|
578
|
+
const after = before
|
|
579
|
+
.replace(' | null', '')
|
|
580
|
+
.replace(' if (!slot) return false\n', '');
|
|
581
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
582
|
+
});
|
|
583
|
+
check('silent when the same guard still protects the callable', () => {
|
|
584
|
+
const before = [
|
|
585
|
+
'export function release(slot: any) {',
|
|
586
|
+
' if (!slot.ready()) return false',
|
|
587
|
+
' if (slot.shouldClose()) {',
|
|
588
|
+
' if (!slot.ready()) return false',
|
|
589
|
+
' return slot.close()',
|
|
590
|
+
' }',
|
|
591
|
+
' return true',
|
|
592
|
+
'}',
|
|
593
|
+
'',
|
|
594
|
+
].join('\n');
|
|
595
|
+
const after = before.replace(' if (!slot.ready()) return false\n', '');
|
|
596
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
597
|
+
});
|
|
598
|
+
check('matches remaining guards by tokens rather than comments', () => {
|
|
599
|
+
const before = [
|
|
600
|
+
'export function release(slot: any, enabled: boolean) {',
|
|
601
|
+
' if (!slot /* already checked */) return false',
|
|
602
|
+
' if (enabled) {',
|
|
603
|
+
' if (!slot) return false',
|
|
604
|
+
' return slot.close()',
|
|
605
|
+
' }',
|
|
606
|
+
' return true',
|
|
607
|
+
'}',
|
|
608
|
+
'',
|
|
609
|
+
].join('\n');
|
|
610
|
+
const after = before.replace(' if (!slot) return false\n', '');
|
|
611
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
612
|
+
});
|
|
613
|
+
check('silent when the guarded operation moves behind a helper', () => {
|
|
614
|
+
const before = [
|
|
615
|
+
'export function release(slot: any) {',
|
|
616
|
+
' if (!slot.ready()) return false',
|
|
617
|
+
' return slot.close()',
|
|
618
|
+
'}',
|
|
619
|
+
'',
|
|
620
|
+
].join('\n');
|
|
621
|
+
const after = [
|
|
622
|
+
'export function release(slot: any) {',
|
|
623
|
+
' return closeIfReady(slot)',
|
|
624
|
+
'}',
|
|
625
|
+
'function closeIfReady(slot: any) {',
|
|
626
|
+
' if (!slot.ready()) return false',
|
|
627
|
+
' return slot.close()',
|
|
628
|
+
'}',
|
|
629
|
+
'',
|
|
630
|
+
].join('\n');
|
|
631
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
632
|
+
});
|
|
633
|
+
check('silent when an already-called helper absorbs the guard', () => {
|
|
634
|
+
const before = [
|
|
635
|
+
'function closeSlot(slot: any) { return slot.close() }',
|
|
636
|
+
'export function release(slot: any) {',
|
|
637
|
+
' if (!slot.ready()) return false',
|
|
638
|
+
' return closeSlot(slot)',
|
|
639
|
+
'}',
|
|
640
|
+
'',
|
|
641
|
+
].join('\n');
|
|
642
|
+
const after = [
|
|
643
|
+
'function closeSlot(slot: any) {',
|
|
644
|
+
' if (!slot.ready()) return false',
|
|
645
|
+
' return slot.close()',
|
|
646
|
+
'}',
|
|
647
|
+
'export function release(slot: any) {',
|
|
648
|
+
' return closeSlot(slot)',
|
|
649
|
+
'}',
|
|
650
|
+
'',
|
|
651
|
+
].join('\n');
|
|
652
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
653
|
+
});
|
|
654
|
+
check('silent when a nested callable binding changes with the guard deletion', () => {
|
|
655
|
+
const before = [
|
|
656
|
+
'export function release(slot: any, fallback: any) {',
|
|
657
|
+
' function closeSlot(value = slot) { return value.close() }',
|
|
658
|
+
' if (!slot) return false',
|
|
659
|
+
' return closeSlot()',
|
|
660
|
+
'}',
|
|
661
|
+
'',
|
|
662
|
+
].join('\n');
|
|
663
|
+
const after = before
|
|
664
|
+
.replace('value = slot', 'value = fallback')
|
|
665
|
+
.replace(' if (!slot) return false\n', '');
|
|
666
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
667
|
+
});
|
|
668
|
+
check('silent when a guarded file is renamed as the guard is deleted', () => {
|
|
669
|
+
const before = 'export function release(slot: any) {\n if (!slot) return false\n return slot.close()\n}\n';
|
|
670
|
+
const after = 'export function release(slot: any) {\n return slot.close()\n}\n';
|
|
671
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'new/release.ts', beforePath: 'old/release.ts', after, before }])), false);
|
|
672
|
+
});
|
|
673
|
+
check('silent when another source is renamed to an unsupported suffix', () => {
|
|
674
|
+
const before = 'export function release(slot: any) {\n if (!slot) return false\n return slot.close()\n}\n';
|
|
675
|
+
const after = 'export function release(slot: any) {\n return slot.close()\n}\n';
|
|
676
|
+
const g = ground([{ path: 'release.ts', after, before }]);
|
|
677
|
+
g.inventory = [
|
|
678
|
+
...g.changed,
|
|
679
|
+
{
|
|
680
|
+
path: 'notes/helper.txt',
|
|
681
|
+
beforePath: 'src/helper.ts',
|
|
682
|
+
added: new Set(),
|
|
683
|
+
before: 'export function closeSlot(slot: any) { return slot.close() }\n',
|
|
684
|
+
},
|
|
685
|
+
];
|
|
686
|
+
assert.equal(fires(droppedGuard, g), false);
|
|
687
|
+
});
|
|
688
|
+
check('silent when a TypeScript reference directive changes with the guard deletion', () => {
|
|
689
|
+
const before = 'export function release(slot: any) {\n if (!slot) return false\n return slot.close()\n}\n';
|
|
690
|
+
const after = 'export function release(slot: any) {\n return slot.close()\n}\n';
|
|
691
|
+
const refs = (target) => '/// <reference path="' + target + '" />\nexport const stable = true\n';
|
|
692
|
+
const g = ground([
|
|
693
|
+
{ path: 'release.ts', after, before },
|
|
694
|
+
{ path: 'bindings.ts', after: refs('./beta.d.ts'), before: refs('./alpha.d.ts') },
|
|
695
|
+
]);
|
|
696
|
+
assert.equal(fires(droppedGuard, g), false);
|
|
697
|
+
});
|
|
698
|
+
check('silent when a branch only conditionally returns', () => {
|
|
699
|
+
const before = [
|
|
700
|
+
'export function release(slot: any, force: boolean) {',
|
|
701
|
+
' if (!slot.ready()) {',
|
|
702
|
+
' if (force) return false',
|
|
703
|
+
' slot.recordMiss()',
|
|
704
|
+
' }',
|
|
705
|
+
' return slot.close()',
|
|
706
|
+
'}',
|
|
707
|
+
'',
|
|
708
|
+
].join('\n');
|
|
709
|
+
const after = 'export function release(slot: any, force: boolean) {\n return slot.close()\n}\n';
|
|
710
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
711
|
+
});
|
|
712
|
+
check('silent when the removed conditional had an else branch', () => {
|
|
713
|
+
const before = [
|
|
714
|
+
'export function release(slot: any) {',
|
|
715
|
+
' if (!slot.ready()) return false',
|
|
716
|
+
' else slot.recordReady()',
|
|
717
|
+
' return slot.close()',
|
|
718
|
+
'}',
|
|
719
|
+
'',
|
|
720
|
+
].join('\n');
|
|
721
|
+
const after = 'export function release(slot: any) {\n return slot.close()\n}\n';
|
|
722
|
+
assert.equal(fires(droppedGuard, ground([{ path: 'release.ts', after, before }])), false);
|
|
723
|
+
});
|
|
197
724
|
check('silent when the guard became obsolete with the code it protected', () => {
|
|
198
725
|
// found by bench: formatWeight moved from ounces to grams, so the old guards
|
|
199
726
|
// referenced locals the rewritten function no longer has
|
|
@@ -550,6 +1077,7 @@ check('Git paths are read as NUL-delimited data and passed back literally', () =
|
|
|
550
1077
|
const changes = collectChanges(dir, {});
|
|
551
1078
|
const moved = changes.find((change) => change.path === renamed);
|
|
552
1079
|
assert.ok(moved?.before?.includes('before9'));
|
|
1080
|
+
assert.equal(moved?.beforePath, tracked);
|
|
553
1081
|
assert.deepEqual([...moved.added], [10]);
|
|
554
1082
|
assert.ok(changes.some((change) => change.path === untracked));
|
|
555
1083
|
}
|
|
@@ -557,6 +1085,45 @@ check('Git paths are read as NUL-delimited data and passed back literally', () =
|
|
|
557
1085
|
rmSync(dir, { recursive: true, force: true });
|
|
558
1086
|
}
|
|
559
1087
|
});
|
|
1088
|
+
await checkAsync('deleted and policy-waived source stays in guard proof inventory', async () => {
|
|
1089
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-deleted-proof-')));
|
|
1090
|
+
const run = (...args) => {
|
|
1091
|
+
execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
1092
|
+
};
|
|
1093
|
+
try {
|
|
1094
|
+
run('init', '-q', '.');
|
|
1095
|
+
run('config', 'user.email', 'tests@powershot.invalid');
|
|
1096
|
+
run('config', 'user.name', 'PowerShot Tests');
|
|
1097
|
+
mkdirSync(join(dir, 'src'), { recursive: true });
|
|
1098
|
+
mkdirSync(join(dir, 'ignored'), { recursive: true });
|
|
1099
|
+
const guarded = 'export function release(slot: any) {\n if (!slot) return false\n return slot.close()\n}\n';
|
|
1100
|
+
const helper = 'export function closeSlot(slot: any) { return slot.close() }\n';
|
|
1101
|
+
writeFileSync(join(dir, 'src/release.ts'), guarded);
|
|
1102
|
+
writeFileSync(join(dir, 'ignored/helper.ts'), helper);
|
|
1103
|
+
run('add', '.');
|
|
1104
|
+
run('commit', '-qm', 'base');
|
|
1105
|
+
writeFileSync(join(dir, 'src/release.ts'), 'export function release(slot: any) {\n return slot.close()\n}\n');
|
|
1106
|
+
rmSync(join(dir, 'ignored/helper.ts'));
|
|
1107
|
+
const changes = collectChanges(dir, {});
|
|
1108
|
+
const deleted = changes.find((change) => change.path === 'ignored/helper.ts');
|
|
1109
|
+
assert.equal(deleted?.deleted, true);
|
|
1110
|
+
assert.equal(deleted?.before, helper);
|
|
1111
|
+
assert.equal(deleted?.added.size, 0);
|
|
1112
|
+
const result = await review({
|
|
1113
|
+
root: dir,
|
|
1114
|
+
range: {},
|
|
1115
|
+
config: { ...loadConfig(dir), ignore: ['ignored/**'] },
|
|
1116
|
+
verifyOnly: true,
|
|
1117
|
+
checks: ['dropped-guard'],
|
|
1118
|
+
});
|
|
1119
|
+
assert.equal(result.findings.some((finding) => finding.check === 'dropped-guard'), false);
|
|
1120
|
+
const deletedPlan = result.plan?.items().find((item) => item.path === 'ignored/helper.ts');
|
|
1121
|
+
assert.equal(deletedPlan?.disposition, 'waived');
|
|
1122
|
+
}
|
|
1123
|
+
finally {
|
|
1124
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
560
1127
|
check('lines split on either ending', () => {
|
|
561
1128
|
assert.deepEqual(splitLines('a\r\nb\nc'), ['a', 'b', 'c']);
|
|
562
1129
|
assert.equal(stripCR('a\r'), 'a');
|