@0xcraft/powershot 1.1.4 → 1.2.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/README.md +23 -9
- package/dist/cli/args.js +2 -1
- package/dist/cli/review-command.js +30 -12
- package/dist/cli/session-command.js +1 -0
- package/dist/delegate.js +89 -13
- package/dist/git.js +5 -3
- package/dist/ground.js +19 -12
- package/dist/lang/packs.js +170 -8
- package/dist/langtest.js +1056 -6
- package/dist/plan.js +25 -0
- package/dist/reinvention.js +227 -2
- package/dist/report/sarif.js +2 -2
- package/dist/report/summary.js +12 -4
- package/dist/review.js +5 -13
- package/dist/selftest.js +507 -17
- package/dist/session.js +1 -0
- package/dist/verifiers/dropped-guard.js +188 -84
- package/dist/verifiers/foreign-dropped-guard.js +174 -50
- package/dist/verifiers/foreign-reinvented.js +22 -7
- package/dist/verifiers/foreign-tokens.js +228 -19
- package/dist/verifiers/guard-diff.js +138 -0
- package/dist/verifiers/reinvented.js +7 -5
- package/docs/architecture.md +33 -0
- package/docs/ci.md +9 -4
- package/package.json +1 -1
package/dist/selftest.js
CHANGED
|
@@ -35,7 +35,7 @@ import { dirname, join, sep } from 'node:path';
|
|
|
35
35
|
import { runTool } from './judges/tools.js';
|
|
36
36
|
import { codeQuality } from './report/codequality.js';
|
|
37
37
|
import { viewer } from './report/viewer.js';
|
|
38
|
-
import { absorbDelegated, delegateBrief } from './delegate.js';
|
|
38
|
+
import { absorbDelegated, buildDelegateTask, delegateBrief } from './delegate.js';
|
|
39
39
|
import { TARGETS, findTarget } from './agents.js';
|
|
40
40
|
import { PACKS, packFor, parseIsolated } from './lang/packs.js';
|
|
41
41
|
import { isPhantom, pythonManifest, localModules } from './lang/python-deps.js';
|
|
@@ -52,7 +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 { implementationFingerprint, reinventionScope, typescriptImplementationFingerprint } from './reinvention.js';
|
|
55
|
+
import { exportedDeclarations, implementationFingerprint, reinventionScope, typescriptImplementationFingerprint, } from './reinvention.js';
|
|
56
56
|
import { incompleteReasons } from './bench.js';
|
|
57
57
|
import { PACKAGE_NAME, PACKAGE_VERSION } from './package-meta.js';
|
|
58
58
|
import { addedLinesFromPatch, inlineMarker, parseReviewFindings, reconcileInlineComments, selectInlineComments, syncInlineComments, } from './github/inline-comments.js';
|
|
@@ -70,6 +70,7 @@ function ground(files, deps = []) {
|
|
|
70
70
|
const lineCount = f.after.split('\n').length;
|
|
71
71
|
const c = {
|
|
72
72
|
path: f.path,
|
|
73
|
+
beforePath: f.beforePath,
|
|
73
74
|
added: new Set(Array.from({ length: lineCount }, (_, i) => i + 1)),
|
|
74
75
|
before: f.before,
|
|
75
76
|
};
|
|
@@ -77,7 +78,9 @@ function ground(files, deps = []) {
|
|
|
77
78
|
entries.push({
|
|
78
79
|
sf,
|
|
79
80
|
changed: c,
|
|
80
|
-
before: f.before === undefined
|
|
81
|
+
before: f.before === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: beforeProject.createSourceFile('/before/' + (f.beforePath ?? f.path), f.before, { overwrite: true }),
|
|
81
84
|
typed: true,
|
|
82
85
|
});
|
|
83
86
|
}
|
|
@@ -85,17 +88,18 @@ function ground(files, deps = []) {
|
|
|
85
88
|
for (const sf of project.getSourceFiles()) {
|
|
86
89
|
const rel = sf.getFilePath().slice(root.length + 1);
|
|
87
90
|
const input = files.find((file) => file.path === rel);
|
|
88
|
-
for (const
|
|
89
|
-
const
|
|
90
|
-
if (!decl)
|
|
91
|
-
continue;
|
|
92
|
-
const fingerprint = typescriptImplementationFingerprint(decl);
|
|
91
|
+
for (const { name, node: decl } of exportedDeclarations(sf)) {
|
|
92
|
+
const fingerprint = typescriptImplementationFingerprint(decl, rel);
|
|
93
93
|
if (!fingerprint)
|
|
94
94
|
continue;
|
|
95
95
|
const key = normalizeName(name);
|
|
96
96
|
const list = symbolIndex.get(key) ?? [];
|
|
97
|
-
const
|
|
98
|
-
const
|
|
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;
|
|
99
103
|
list.push({
|
|
100
104
|
file: rel,
|
|
101
105
|
name,
|
|
@@ -134,6 +138,22 @@ async function checkAsync(name, fn) {
|
|
|
134
138
|
function fires(v, g) {
|
|
135
139
|
return v.run(g).length > 0;
|
|
136
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
|
+
}
|
|
137
157
|
console.log('\nphantom-dep');
|
|
138
158
|
check('fires on an import that is not a declared dependency', () => {
|
|
139
159
|
const g = ground([{ path: 'a.ts', after: "import ky from 'ky'\nexport const x = ky\n" }], ['zod']);
|
|
@@ -159,6 +179,11 @@ check('resolves a scoped subpath to its package', () => {
|
|
|
159
179
|
assert.equal(fires(phantomDep, g), false);
|
|
160
180
|
});
|
|
161
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
|
+
});
|
|
162
187
|
check('fires when a helper already exists elsewhere', () => {
|
|
163
188
|
const existing = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
164
189
|
const g = ground([
|
|
@@ -169,6 +194,75 @@ check('fires when a helper already exists elsewhere', () => {
|
|
|
169
194
|
assert.ok(found.length >= 1, 'expected a duplication finding');
|
|
170
195
|
assert.equal(found[0].confidence, 'firm'); // heuristic, never claims `proven`
|
|
171
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
|
+
});
|
|
172
266
|
check('silent when matching helpers are both new in the change', () => {
|
|
173
267
|
const added = 'export function formatMinorUnits(n: number) { return n / 100 }\n';
|
|
174
268
|
const g = ground([
|
|
@@ -336,11 +430,33 @@ check('fires when an early-return guard disappears', () => {
|
|
|
336
430
|
assert.equal(found.length, 1);
|
|
337
431
|
assert.match(found[0].title, /!inv\.customer/);
|
|
338
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
|
+
});
|
|
339
450
|
check('fires when a throwing guard disappears', () => {
|
|
340
451
|
const before = 'export function pay(a: any) {\n if (a <= 0) { throw new Error("bad") }\n return a\n}\n';
|
|
341
452
|
const after = 'export function pay(a: any) {\n return a\n}\n';
|
|
342
453
|
assert.equal(fires(droppedGuard, ground([{ path: 'pay.ts', after, before }])), true);
|
|
343
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
|
+
});
|
|
344
460
|
check('silent when the guard is kept, even if reformatted', () => {
|
|
345
461
|
const before = 'export function close(inv: any) {\n if (!inv.customer) return null\n return inv.total\n}\n';
|
|
346
462
|
const after = 'export function close(inv: any) {\n if (!inv.customer)\n return null\n return inv.total * 2\n}\n';
|
|
@@ -353,6 +469,258 @@ check('silent when a rewrite respells the same guard for a new type', () => {
|
|
|
353
469
|
const after = 'export function check(pool: number) {\n if (pool === 0) return null\n return pool\n}\n';
|
|
354
470
|
assert.equal(fires(droppedGuard, ground([{ path: 'c.ts', after, before }])), false);
|
|
355
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
|
+
});
|
|
356
724
|
check('silent when the guard became obsolete with the code it protected', () => {
|
|
357
725
|
// found by bench: formatWeight moved from ounces to grams, so the old guards
|
|
358
726
|
// referenced locals the rewritten function no longer has
|
|
@@ -709,6 +1077,7 @@ check('Git paths are read as NUL-delimited data and passed back literally', () =
|
|
|
709
1077
|
const changes = collectChanges(dir, {});
|
|
710
1078
|
const moved = changes.find((change) => change.path === renamed);
|
|
711
1079
|
assert.ok(moved?.before?.includes('before9'));
|
|
1080
|
+
assert.equal(moved?.beforePath, tracked);
|
|
712
1081
|
assert.deepEqual([...moved.added], [10]);
|
|
713
1082
|
assert.ok(changes.some((change) => change.path === untracked));
|
|
714
1083
|
}
|
|
@@ -716,6 +1085,45 @@ check('Git paths are read as NUL-delimited data and passed back literally', () =
|
|
|
716
1085
|
rmSync(dir, { recursive: true, force: true });
|
|
717
1086
|
}
|
|
718
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
|
+
});
|
|
719
1127
|
check('lines split on either ending', () => {
|
|
720
1128
|
assert.deepEqual(splitLines('a\r\nb\nc'), ['a', 'b', 'c']);
|
|
721
1129
|
assert.equal(stripCR('a\r'), 'a');
|
|
@@ -767,9 +1175,14 @@ check('portable coverage is a verdict, but never masquerades as full semantic co
|
|
|
767
1175
|
filesReviewed: 1, deterministicChecks: 1, scopeDetails: unavailable,
|
|
768
1176
|
});
|
|
769
1177
|
assert.match(out, /No medium-or-higher deterministic findings\./);
|
|
770
|
-
assert.match(out, /1 file reviewed · 1 deterministic check · portable coverage/);
|
|
1178
|
+
assert.match(out, /1 file reviewed · 1 deterministic check · portable oracle coverage/);
|
|
771
1179
|
assert.match(out, /1 reviewed file lacked type information and a reference graph/);
|
|
772
1180
|
assert.doesNotMatch(out, /not a verdict/);
|
|
1181
|
+
const full = terminal([], {
|
|
1182
|
+
subtitle: 'workspace', verified: 0, judged: 0, state: 'complete', notLookedAt: [],
|
|
1183
|
+
coverage: 'full', filesReviewed: 2, filesChanged: 2, deterministicChecks: 3,
|
|
1184
|
+
});
|
|
1185
|
+
assert.match(full, /2\/2 changed files reviewed · 3 deterministic checks · full applicable-oracle coverage/);
|
|
773
1186
|
const selected = Array.from({ length: 20 }, (_, index) => ({
|
|
774
1187
|
path: 'web/file-' + index + '.ts',
|
|
775
1188
|
disposition: 'selected',
|
|
@@ -794,7 +1207,7 @@ check('portable coverage is a verdict, but never masquerades as full semantic co
|
|
|
794
1207
|
},
|
|
795
1208
|
}));
|
|
796
1209
|
assert.match(md, /✅ \*\*No medium-or-higher deterministic findings\*\*/);
|
|
797
|
-
assert.match(md, /20 files reviewed · 19 deterministic checks · portable coverage/);
|
|
1210
|
+
assert.match(md, /20\/36 changed files reviewed · 19 deterministic checks · portable oracle coverage/);
|
|
798
1211
|
assert.match(md, /Model review was disabled \(`verify-only`\)\./);
|
|
799
1212
|
assert.match(md, /<summary>Coverage details<\/summary>/);
|
|
800
1213
|
const rendered = md.replace(/\\/g, '');
|
|
@@ -1550,6 +1963,16 @@ check('the public action persists judge answers and publishes only a verdict', (
|
|
|
1550
1963
|
assert.match(action, /m\.coverage === "full" \|\| m\.coverage === "portable" \? m\.coverage : "unknown"/);
|
|
1551
1964
|
assert.match(action, /Approve a clean review[\s\S]+steps\.review\.outputs\.coverage == 'full'/);
|
|
1552
1965
|
});
|
|
1966
|
+
check('the public action keeps every pull request write inside the base repository', () => {
|
|
1967
|
+
const action = readFileSync(join(process.cwd(), 'action.yml'), 'utf8');
|
|
1968
|
+
for (const name of ['Post inline comments', 'Comment on the pull request', 'Approve a clean review']) {
|
|
1969
|
+
const start = action.indexOf(' - name: ' + name);
|
|
1970
|
+
const end = action.indexOf('\n - name: ', start + 1);
|
|
1971
|
+
const step = action.slice(start, end === -1 ? undefined : end);
|
|
1972
|
+
assert.ok(start >= 0, name + ' step is missing');
|
|
1973
|
+
assert.match(step, /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/, name);
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1553
1976
|
check('published CI examples preserve one verdict and its exit status', () => {
|
|
1554
1977
|
const action = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'action.yml'), 'utf8');
|
|
1555
1978
|
const github = readFileSync(join(process.cwd(), 'examples', 'github-actions', 'cli.yml'), 'utf8');
|
|
@@ -1589,10 +2012,11 @@ check('the viewer labels a complete portable session', () => {
|
|
|
1589
2012
|
const html = viewer([], {
|
|
1590
2013
|
id: 'portable', target: 'workspace', started: '2026-01-01T10:00:00Z',
|
|
1591
2014
|
state: 'complete', notLookedAt: [], coverage: 'portable',
|
|
1592
|
-
verifyOnly: true, minSeverity: 'medium', filesReviewed: 1,
|
|
2015
|
+
verifyOnly: true, minSeverity: 'medium', filesReviewed: 1, filesChanged: 3,
|
|
2016
|
+
deterministicChecks: 2,
|
|
1593
2017
|
scopeDetails: ['1 reviewed file lacked type information'],
|
|
1594
2018
|
});
|
|
1595
|
-
assert.match(html, /1
|
|
2019
|
+
assert.match(html, /1\/3 changed files reviewed · 2 deterministic checks · portable oracle coverage/);
|
|
1596
2020
|
assert.match(html, /No medium-or-higher deterministic findings\./);
|
|
1597
2021
|
assert.match(html, /<summary>Coverage details<\/summary>/);
|
|
1598
2022
|
});
|
|
@@ -1625,9 +2049,11 @@ check('delegate --checks selects only the requested judging brief', () => {
|
|
|
1625
2049
|
provider: 'anthropic', model: 'm', verifiers: ['*'], judges: ['*'],
|
|
1626
2050
|
minSeverity: 'low', ignore: [], coverage: 'portable', promptCache: true,
|
|
1627
2051
|
};
|
|
1628
|
-
const
|
|
1629
|
-
|
|
1630
|
-
|
|
2052
|
+
const task = buildDelegateTask(ground([{ path: 'a.ts', after: 'export const a = 1\n' }]), cfg, [{
|
|
2053
|
+
path: 'a.ts', disposition: 'selected', bytes: 19, addedLines: 1,
|
|
2054
|
+
language: 'typescript', checks: [],
|
|
2055
|
+
}], { checks: ['intent'], intent: 'add a' });
|
|
2056
|
+
const brief = delegateBrief(task);
|
|
1631
2057
|
assert.match(brief, /## Judge: intent/);
|
|
1632
2058
|
assert.doesNotMatch(brief, /## Judge: plausible-logic/);
|
|
1633
2059
|
});
|
|
@@ -1782,6 +2208,17 @@ check('a finding is matched past a line that moved under it', () => {
|
|
|
1782
2208
|
assert.equal(Session.compare(at(12), at(22)).remaining.length, 1);
|
|
1783
2209
|
rmSync(dir, { recursive: true, force: true });
|
|
1784
2210
|
});
|
|
2211
|
+
check('a finished session preserves the reviewed and changed file counts', () => {
|
|
2212
|
+
const dir = mkdtempSync(join(tmpdir(), 'psh-scope-session-'));
|
|
2213
|
+
const session = Session.create(dir, 'workspace');
|
|
2214
|
+
session.saveReport([], {
|
|
2215
|
+
state: 'complete', notLookedAt: [], filesReviewed: 2, filesChanged: 5,
|
|
2216
|
+
});
|
|
2217
|
+
const reopened = Session.open(dir, session.id);
|
|
2218
|
+
assert.equal(reopened?.report?.filesReviewed, 2);
|
|
2219
|
+
assert.equal(reopened?.report?.filesChanged, 5);
|
|
2220
|
+
rmSync(dir, { recursive: true, force: true });
|
|
2221
|
+
});
|
|
1785
2222
|
check('partial sessions cannot be compared or rendered as clean', () => {
|
|
1786
2223
|
const dir = mkdtempSync(join(tmpdir(), 'psh-partial-session-'));
|
|
1787
2224
|
const partial = Session.create(dir, 'workspace');
|
|
@@ -3067,6 +3504,7 @@ check('CLI rejects selections that would otherwise run nothing and report clean'
|
|
|
3067
3504
|
assert.equal(status(['scan', 'example.rb', '--verify-only', '--checks', 'plausible-logic', '--format', 'manifest']), 2);
|
|
3068
3505
|
assert.equal(status(['delegate', '--checks', 'phantom-api']), 2);
|
|
3069
3506
|
assert.equal(status(['delegate']), 0);
|
|
3507
|
+
assert.equal(status(['delegate', '--format', 'manifest']), 2);
|
|
3070
3508
|
assert.equal(existsSync(join(dir, '.powershot', 'sessions')), false, 'delegate must not create an unused session');
|
|
3071
3509
|
assert.equal(status(['scan', 'example.rb', '--format', 'unknown']), 2);
|
|
3072
3510
|
assert.equal(status(['scan', 'example.rb', '--report', 'unknown=report.txt']), 2);
|
|
@@ -3081,6 +3519,58 @@ check('CLI rejects selections that would otherwise run nothing and report clean'
|
|
|
3081
3519
|
rmSync(dir, { recursive: true, force: true });
|
|
3082
3520
|
}
|
|
3083
3521
|
});
|
|
3522
|
+
check('delegate JSON uses the review selection contract and names excluded files', () => {
|
|
3523
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'psh-delegate-plan-')));
|
|
3524
|
+
const cli = join(process.cwd(), 'dist', 'cli.js');
|
|
3525
|
+
try {
|
|
3526
|
+
execFileSync('git', ['init', '-q', '.'], { cwd: dir });
|
|
3527
|
+
writeFileSync(join(dir, 'source.rs'), 'pub fn value() -> i32 { 1 }\n');
|
|
3528
|
+
writeFileSync(join(dir, 'notes.md'), '# Notes\n');
|
|
3529
|
+
writeFileSync(join(dir, 'large.ts'), 'x'.repeat(512 * 1024 + 1));
|
|
3530
|
+
const raw = execFileSync(process.execPath, [cli, 'delegate', '--format', 'json'], {
|
|
3531
|
+
cwd: dir,
|
|
3532
|
+
env: { ...process.env, CI: 'true' },
|
|
3533
|
+
encoding: 'utf8',
|
|
3534
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
3535
|
+
});
|
|
3536
|
+
const task = JSON.parse(raw);
|
|
3537
|
+
const files = new Map(task.files.map((file) => [file.path, file]));
|
|
3538
|
+
assert.equal(task.schema, 'powershot.delegate/v1');
|
|
3539
|
+
assert.equal(task.state, 'complete');
|
|
3540
|
+
assert.equal(files.get('source.rs')?.disposition, 'selected');
|
|
3541
|
+
assert.equal(files.get('notes.md')?.disposition, 'waived');
|
|
3542
|
+
assert.match(files.get('notes.md')?.reason ?? '', /no parser/);
|
|
3543
|
+
assert.equal(files.get('large.ts')?.disposition, 'waived');
|
|
3544
|
+
assert.match(files.get('large.ts')?.reason ?? '', /over 512KB/);
|
|
3545
|
+
assert.ok(task.judges.some((judge) => judge.name === 'plausible-logic' && judge.applicable));
|
|
3546
|
+
assert.deepEqual([...new Set(task.units.flatMap((unit) => unit.files))], ['source.rs']);
|
|
3547
|
+
assert.match(task.units[0]?.changes ?? '', /pub fn value/);
|
|
3548
|
+
const markdown = execFileSync(process.execPath, [cli, 'delegate'], {
|
|
3549
|
+
cwd: dir,
|
|
3550
|
+
env: { ...process.env, CI: 'true' },
|
|
3551
|
+
encoding: 'utf8',
|
|
3552
|
+
maxBuffer: 2 * 1024 * 1024,
|
|
3553
|
+
});
|
|
3554
|
+
assert.match(markdown, /## Scope/);
|
|
3555
|
+
assert.match(markdown, /source\.rs.*selected/);
|
|
3556
|
+
assert.match(markdown, /notes\.md.*no parser/);
|
|
3557
|
+
assert.match(markdown, /large\.ts.*over 512KB/);
|
|
3558
|
+
assert.ok(markdown.length < 100_000, 'an oversized source leaked into the delegate brief');
|
|
3559
|
+
assert.equal(existsSync(join(dir, '.powershot', 'sessions')), false, 'delegate must stay LLM-free');
|
|
3560
|
+
rmSync(join(dir, 'source.rs'));
|
|
3561
|
+
rmSync(join(dir, 'large.ts'));
|
|
3562
|
+
const waivedOnly = JSON.parse(execFileSync(process.execPath, [cli, 'delegate', '--format', 'json'], { cwd: dir, env: { ...process.env, CI: 'true' }, encoding: 'utf8' }));
|
|
3563
|
+
assert.equal(waivedOnly.state, 'complete');
|
|
3564
|
+
assert.deepEqual(waivedOnly.files, [{
|
|
3565
|
+
path: 'notes.md', disposition: 'waived', bytes: 8, addedLines: 2, language: 'other',
|
|
3566
|
+
reason: 'no parser for this language',
|
|
3567
|
+
}]);
|
|
3568
|
+
assert.deepEqual(waivedOnly.units, []);
|
|
3569
|
+
}
|
|
3570
|
+
finally {
|
|
3571
|
+
rmSync(dir, { recursive: true, force: true });
|
|
3572
|
+
}
|
|
3573
|
+
});
|
|
3084
3574
|
check('a budget stop is partial and the manifest names what was not reviewed', () => {
|
|
3085
3575
|
const b = new Budget({ requests: 1 }, 0);
|
|
3086
3576
|
b.spend({ requests: 1 });
|