@codyswann/lisa 4.6.9 → 4.6.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/anchored-rewrite.d.ts +153 -0
- package/dist/core/anchored-rewrite.d.ts.map +1 -0
- package/dist/core/anchored-rewrite.js +192 -0
- package/dist/core/anchored-rewrite.js.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +5 -0
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/check-whole-output-guards.mjs +620 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-whole-output-guards — refuse a multi-step text transform whose only
|
|
4
|
+
* correctness check compares its finished output to its input
|
|
5
|
+
* (CodySwannGT/lisa#3081).
|
|
6
|
+
*
|
|
7
|
+
* ## The defect
|
|
8
|
+
*
|
|
9
|
+
* A transform with N anchored rewrites, guarded by comparing the finished
|
|
10
|
+
* result to the starting value, passes as soon as **any one** step lands:
|
|
11
|
+
*
|
|
12
|
+
* const out = source.replace(A, A2).replace(B, B2);
|
|
13
|
+
* if (out === source) throw new Error("the rewrite no longer applies");
|
|
14
|
+
*
|
|
15
|
+
* The guard asks "did something change". It cannot answer "did everything I
|
|
16
|
+
* asked for happen". The other N−1 steps can silently stop matching and it
|
|
17
|
+
* stays green — and it degrades in the direction nobody watches, because it is
|
|
18
|
+
* written when N is 1, where the check is sound, and becomes unsound the moment
|
|
19
|
+
* someone adds a second step without noticing that the guard's question changed
|
|
20
|
+
* underneath them.
|
|
21
|
+
*
|
|
22
|
+
* It has already bitten this repository. CodySwannGT/lisa#2980 deleted the
|
|
23
|
+
* import line the FIRST rewrite of a test fixture anchored on; the second still
|
|
24
|
+
* matched, so `out !== source`, so the guard passed, and the fixture built a
|
|
25
|
+
* module referencing `fileURLToPath` without importing it. Every case
|
|
26
|
+
* downstream failed inside a module-resolution error, in a test about
|
|
27
|
+
* repository layout. The partially-applied output was not merely incomplete, it
|
|
28
|
+
* was INVALID, and it surfaced far from the guard that let it through. That one
|
|
29
|
+
* instance was found by luck — the file happened to carry a separate positive
|
|
30
|
+
* control — which is a property of that file, not of the idiom.
|
|
31
|
+
*
|
|
32
|
+
* ## What counts as a finding
|
|
33
|
+
*
|
|
34
|
+
* A scope is reported when ALL of these hold:
|
|
35
|
+
*
|
|
36
|
+
* 1. It performs TWO OR MORE anchored replacement steps. An anchor is a
|
|
37
|
+
* literal string, a template literal, a non-global regular expression, or —
|
|
38
|
+
* inside a loop or an array-method callback — a dynamic expression, which
|
|
39
|
+
* is by construction more than one step.
|
|
40
|
+
* 2. Something in the scope throws on a WHOLE-OUTPUT comparison: an `if` whose
|
|
41
|
+
* test is `===`/`!==` between two plain expressions, at least one of which
|
|
42
|
+
* is the transform's own input or output variable, and whose branch throws
|
|
43
|
+
* or exits; or an `if` on a bare `changed`-style flag that throws.
|
|
44
|
+
* 3. At least one step is UNACCOUNTED FOR — its anchor is not asserted present
|
|
45
|
+
* anywhere in the scope (no `includes`/`indexOf`/`test`/`match` guard that
|
|
46
|
+
* throws on that same anchor expression), and it does not come from
|
|
47
|
+
* `replaceOrThrow` / `replaceOptional` / `applyRewrites`.
|
|
48
|
+
*
|
|
49
|
+
* Accountability is matched by ANCHOR EXPRESSION TEXT, not by counting
|
|
50
|
+
* assertions, which is what lets one assertion inside a `reduce` body cover
|
|
51
|
+
* every iteration of it while two chained `.replace` calls still need two.
|
|
52
|
+
*
|
|
53
|
+
* ## Declared blind spots
|
|
54
|
+
*
|
|
55
|
+
* Stated rather than hidden, in the manner of the sibling sweeps:
|
|
56
|
+
*
|
|
57
|
+
* - A GLOBAL regular expression (`/…/g`) is not an anchored step. Matching
|
|
58
|
+
* zero times is a routine, correct outcome for a `/g` rewrite over a
|
|
59
|
+
* document, so counting them would turn the commonest correct idiom into a
|
|
60
|
+
* wall of findings and the sweep would be turned off.
|
|
61
|
+
* - A single-step transform is inspected and passed. The whole-output
|
|
62
|
+
* comparison is SOUND at N=1; this is about steps, not about style.
|
|
63
|
+
* - A scope whose only comparison is a `return`/short-circuit rather than a
|
|
64
|
+
* throw is inspected and passed — a write-if-changed idempotence check is
|
|
65
|
+
* not a correctness claim. `src/core/instruction-files-migration.ts`'s
|
|
66
|
+
* `reconcileManagedBlocks` is the worked example: three steps, a whole-output
|
|
67
|
+
* comparison, and no defect, because it neither throws nor claims every step
|
|
68
|
+
* fired — it compares each step's output SEPARATELY to report what changed.
|
|
69
|
+
*
|
|
70
|
+
* ## Why it fails at zero inspected
|
|
71
|
+
*
|
|
72
|
+
* An empty inspection and a clean tree print the same tick. This repository has
|
|
73
|
+
* shipped guards that reported success while inert often enough to have a rule
|
|
74
|
+
* about it, so the count of transforms actually parsed is part of the report,
|
|
75
|
+
* and a count of zero is exit 2 rather than exit 0. A glob that matches
|
|
76
|
+
* nothing, a root that does not exist, and a parser that silently stopped all
|
|
77
|
+
* reach that branch.
|
|
78
|
+
*
|
|
79
|
+
* Determinism: Node built-ins plus the `typescript` parser, no network, no
|
|
80
|
+
* clock, no `Math.random`. The scanned root is a parameter so the suite can
|
|
81
|
+
* point it at a fixture tree holding a known offender.
|
|
82
|
+
*
|
|
83
|
+
* CLI:
|
|
84
|
+
* node scripts/check-whole-output-guards.mjs [--json] [root]
|
|
85
|
+
*
|
|
86
|
+
* Exit codes (mirroring the sibling check-* scripts):
|
|
87
|
+
* 0 — transforms were inspected and none is guarded only whole-output.
|
|
88
|
+
* 1 — >=1 finding.
|
|
89
|
+
* 2 — operational error: unknown flag, unreadable root, or ZERO transforms
|
|
90
|
+
* inspected.
|
|
91
|
+
*
|
|
92
|
+
* @module scripts/check-whole-output-guards
|
|
93
|
+
*/
|
|
94
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
95
|
+
import path from "node:path";
|
|
96
|
+
import process from "node:process";
|
|
97
|
+
import ts from "typescript";
|
|
98
|
+
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
99
|
+
|
|
100
|
+
/** Directories holding source this repository authors and runs. */
|
|
101
|
+
export const SCANNED_ROOTS = Object.freeze([
|
|
102
|
+
"all",
|
|
103
|
+
"cdk",
|
|
104
|
+
"expo",
|
|
105
|
+
"harper-fabric",
|
|
106
|
+
"nestjs",
|
|
107
|
+
"npm-package",
|
|
108
|
+
"phaser",
|
|
109
|
+
"plugins/src",
|
|
110
|
+
"rails",
|
|
111
|
+
"scripts",
|
|
112
|
+
"src",
|
|
113
|
+
"tests",
|
|
114
|
+
"typescript",
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Directory names never descended into: nothing under them is authored here,
|
|
119
|
+
* so a finding inside one names somebody else's code — or, for `dist`, names
|
|
120
|
+
* this repository's own source a second time from its build output.
|
|
121
|
+
*/
|
|
122
|
+
const SKIPPED_DIRECTORIES = Object.freeze(["node_modules", "dist", ".git"]);
|
|
123
|
+
|
|
124
|
+
/** Array methods whose callback body runs once per element. */
|
|
125
|
+
const ITERATION_METHODS = Object.freeze([
|
|
126
|
+
"reduce",
|
|
127
|
+
"reduceRight",
|
|
128
|
+
"map",
|
|
129
|
+
"flatMap",
|
|
130
|
+
"forEach",
|
|
131
|
+
"filter",
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
/** Calls that make a step individually accountable by construction. */
|
|
135
|
+
export const ACCOUNTABLE_CALLS = Object.freeze([
|
|
136
|
+
"replaceOrThrow",
|
|
137
|
+
"replaceOptional",
|
|
138
|
+
"applyRewrites",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
/** Methods whose throwing `if` asserts that an anchor is present. */
|
|
142
|
+
const PRESENCE_METHODS = Object.freeze([
|
|
143
|
+
"includes",
|
|
144
|
+
"indexOf",
|
|
145
|
+
"test",
|
|
146
|
+
"match",
|
|
147
|
+
"search",
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Identifier names read as a "did anything change" flag.
|
|
152
|
+
*
|
|
153
|
+
* Narrow on purpose. A broader pattern would catch `isValid`, `ok` and every
|
|
154
|
+
* other boolean in the tree, and a sweep whose findings are mostly noise is a
|
|
155
|
+
* sweep nobody reads.
|
|
156
|
+
*/
|
|
157
|
+
const CHANGE_FLAG =
|
|
158
|
+
/^(changed|modified|applied|dirty|touched|rewritten|replaced)$/i;
|
|
159
|
+
|
|
160
|
+
/** Source extensions parsed. `.d.ts` files declare types and transform nothing. */
|
|
161
|
+
const SOURCE_EXTENSIONS = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
162
|
+
|
|
163
|
+
/** Collapse whitespace so an anchor spanning lines still matches its assertion. */
|
|
164
|
+
const normalize = text => text.replace(/\s+/g, " ").trim();
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether a function node is an array-method callback.
|
|
168
|
+
* @param {object} fn - A function-like AST node.
|
|
169
|
+
* @returns {boolean} True when it is the callback of an iteration method.
|
|
170
|
+
*/
|
|
171
|
+
export function isIterationCallback(fn) {
|
|
172
|
+
const parent = fn.parent;
|
|
173
|
+
return Boolean(
|
|
174
|
+
parent &&
|
|
175
|
+
ts.isCallExpression(parent) &&
|
|
176
|
+
ts.isPropertyAccessExpression(parent.expression) &&
|
|
177
|
+
ITERATION_METHODS.includes(parent.expression.name.text)
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a node sits inside something that runs more than once.
|
|
183
|
+
*
|
|
184
|
+
* Stops at a real function boundary: a helper CALLED from a loop is not itself
|
|
185
|
+
* a loop, and treating it as one would report every string utility in the tree.
|
|
186
|
+
* @param {object} node - Any AST node.
|
|
187
|
+
* @returns {boolean} True when the node runs once per element/iteration.
|
|
188
|
+
*/
|
|
189
|
+
export function insideIteration(node) {
|
|
190
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
191
|
+
if (
|
|
192
|
+
ts.isForStatement(current) ||
|
|
193
|
+
ts.isForOfStatement(current) ||
|
|
194
|
+
ts.isForInStatement(current) ||
|
|
195
|
+
ts.isWhileStatement(current) ||
|
|
196
|
+
ts.isDoStatement(current)
|
|
197
|
+
) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const isFunction =
|
|
201
|
+
ts.isFunctionDeclaration(current) ||
|
|
202
|
+
ts.isFunctionExpression(current) ||
|
|
203
|
+
ts.isArrowFunction(current) ||
|
|
204
|
+
ts.isMethodDeclaration(current);
|
|
205
|
+
if (isFunction) return isIterationCallback(current);
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The scope a node's evidence belongs to.
|
|
212
|
+
*
|
|
213
|
+
* An iteration CALLBACK is deliberately merged into the function that contains
|
|
214
|
+
* it. The steps of a `reduce`-driven transform live in the callback while the
|
|
215
|
+
* guard that is supposed to cover them lives outside it, and a scope model that
|
|
216
|
+
* separated the two would look at the exact shape this sweep exists for and see
|
|
217
|
+
* a transform with no guard next to a guard with no transform.
|
|
218
|
+
* @param {object} node - Any AST node.
|
|
219
|
+
* @param {object} sourceFile - The file's root node, used as the module scope.
|
|
220
|
+
* @returns {object} The owning scope node.
|
|
221
|
+
*/
|
|
222
|
+
export function scopeOf(node, sourceFile) {
|
|
223
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
224
|
+
if (ts.isSourceFile(current)) return current;
|
|
225
|
+
const isFunction =
|
|
226
|
+
ts.isFunctionDeclaration(current) ||
|
|
227
|
+
ts.isFunctionExpression(current) ||
|
|
228
|
+
ts.isArrowFunction(current) ||
|
|
229
|
+
ts.isMethodDeclaration(current);
|
|
230
|
+
if (isFunction && !isIterationCallback(current)) return current;
|
|
231
|
+
}
|
|
232
|
+
return sourceFile;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Whether a statement transfers control on failure.
|
|
237
|
+
* @param {object | undefined} statement - The branch taken when a test holds.
|
|
238
|
+
* @returns {boolean} True when it throws or exits.
|
|
239
|
+
*/
|
|
240
|
+
function branchFails(statement) {
|
|
241
|
+
if (!statement) return false;
|
|
242
|
+
const text = statement.getText();
|
|
243
|
+
return /\bthrow\b/.test(text) || /process\.exit\s*\(/.test(text);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The root identifier of an expression, for relating a guard to a transform.
|
|
248
|
+
* @param {object} node - An expression node.
|
|
249
|
+
* @returns {string} The base identifier's text, or `""`.
|
|
250
|
+
*/
|
|
251
|
+
export function rootIdentifier(node) {
|
|
252
|
+
let current = node;
|
|
253
|
+
while (current && ts.isPropertyAccessExpression(current)) {
|
|
254
|
+
current = current.expression;
|
|
255
|
+
}
|
|
256
|
+
return current && ts.isIdentifier(current) ? current.text : "";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The variable a transform's result is bound to, walking out of any callback.
|
|
261
|
+
* @param {object} node - The replacement call.
|
|
262
|
+
* @param {object} scope - The owning scope node.
|
|
263
|
+
* @returns {string} The bound name, or `""` when the result is not bound.
|
|
264
|
+
*/
|
|
265
|
+
export function boundName(node, scope) {
|
|
266
|
+
for (
|
|
267
|
+
let current = node.parent;
|
|
268
|
+
current && current !== scope.parent;
|
|
269
|
+
current = current.parent
|
|
270
|
+
) {
|
|
271
|
+
if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name)) {
|
|
272
|
+
return current.name.text;
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
ts.isBinaryExpression(current) &&
|
|
276
|
+
current.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
277
|
+
ts.isIdentifier(current.left)
|
|
278
|
+
) {
|
|
279
|
+
return current.left.text;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Classify a replacement call's anchor.
|
|
287
|
+
* @param {object} node - A `.replace`/`.replaceAll` call expression.
|
|
288
|
+
* @returns {{ anchored: boolean, iterated: boolean }} How it counts.
|
|
289
|
+
*/
|
|
290
|
+
export function classifyAnchor(node) {
|
|
291
|
+
const anchor = node.arguments[0];
|
|
292
|
+
const iterated = insideIteration(node);
|
|
293
|
+
if (!anchor) return { anchored: false, iterated };
|
|
294
|
+
if (ts.isRegularExpressionLiteral(anchor)) {
|
|
295
|
+
// A `/g` rewrite over a document matching nothing is routine and correct.
|
|
296
|
+
const global = /\/[a-z]*g[a-z]*$/.test(anchor.text);
|
|
297
|
+
return { anchored: !global, iterated };
|
|
298
|
+
}
|
|
299
|
+
const literal =
|
|
300
|
+
ts.isStringLiteral(anchor) ||
|
|
301
|
+
ts.isNoSubstitutionTemplateLiteral(anchor) ||
|
|
302
|
+
ts.isTemplateExpression(anchor);
|
|
303
|
+
return { anchored: literal || iterated, iterated };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Collect every scope's transform evidence from one parsed file.
|
|
308
|
+
* @param {object} sourceFile - The parsed source file.
|
|
309
|
+
* @returns {Map<object, object>} Scope node to its collected evidence.
|
|
310
|
+
*/
|
|
311
|
+
export function collectScopes(sourceFile) {
|
|
312
|
+
const scopes = new Map();
|
|
313
|
+
const evidenceFor = node => {
|
|
314
|
+
const scope = scopeOf(node, sourceFile);
|
|
315
|
+
if (!scopes.has(scope)) {
|
|
316
|
+
scopes.set(scope, {
|
|
317
|
+
steps: [],
|
|
318
|
+
guards: [],
|
|
319
|
+
assertedAnchors: new Set(),
|
|
320
|
+
accountableCalls: 0,
|
|
321
|
+
variables: new Set(),
|
|
322
|
+
name:
|
|
323
|
+
scope === sourceFile
|
|
324
|
+
? "<module>"
|
|
325
|
+
: (scope.name?.getText() ?? "<anonymous>"),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return scopes.get(scope);
|
|
329
|
+
};
|
|
330
|
+
const lineOf = node =>
|
|
331
|
+
sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
|
332
|
+
|
|
333
|
+
const visitCall = node => {
|
|
334
|
+
const callee = ts.isPropertyAccessExpression(node.expression)
|
|
335
|
+
? node.expression.name.text
|
|
336
|
+
: ts.isIdentifier(node.expression)
|
|
337
|
+
? node.expression.text
|
|
338
|
+
: "";
|
|
339
|
+
if (ACCOUNTABLE_CALLS.includes(callee)) {
|
|
340
|
+
evidenceFor(node).accountableCalls += 1;
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (
|
|
344
|
+
(callee === "replace" || callee === "replaceAll") &&
|
|
345
|
+
node.arguments.length >= 1
|
|
346
|
+
) {
|
|
347
|
+
const { anchored, iterated } = classifyAnchor(node);
|
|
348
|
+
if (!anchored) return;
|
|
349
|
+
const evidence = evidenceFor(node);
|
|
350
|
+
const scope = scopeOf(node, sourceFile);
|
|
351
|
+
const receiver = ts.isPropertyAccessExpression(node.expression)
|
|
352
|
+
? rootIdentifier(node.expression.expression)
|
|
353
|
+
: "";
|
|
354
|
+
const bound = boundName(node, scope);
|
|
355
|
+
if (receiver) evidence.variables.add(receiver);
|
|
356
|
+
if (bound) evidence.variables.add(bound);
|
|
357
|
+
evidence.steps.push({
|
|
358
|
+
anchor: normalize(node.arguments[0].getText()),
|
|
359
|
+
iterated,
|
|
360
|
+
line: lineOf(node.arguments[0]),
|
|
361
|
+
// A chained `a.replace(A, …).replace(B, …)` is visited outermost-first,
|
|
362
|
+
// so B is seen before A. Recording the anchor's offset lets the report
|
|
363
|
+
// be re-sorted into SOURCE order; a finding listed back-to-front reads
|
|
364
|
+
// as though the sweep found a different transform than the one on screen.
|
|
365
|
+
position: node.arguments[0].getStart(),
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!PRESENCE_METHODS.includes(callee)) return;
|
|
370
|
+
// An anchor-presence check only counts when something acts on it.
|
|
371
|
+
let enclosing = node.parent;
|
|
372
|
+
while (enclosing && !ts.isIfStatement(enclosing)) {
|
|
373
|
+
if (ts.isSourceFile(enclosing)) return;
|
|
374
|
+
enclosing = enclosing.parent;
|
|
375
|
+
}
|
|
376
|
+
if (!enclosing || !branchFails(enclosing.thenStatement)) return;
|
|
377
|
+
const evidence = evidenceFor(node);
|
|
378
|
+
// `anchor.test(text)` names the anchor on the RECEIVER; the others name it
|
|
379
|
+
// in the first argument. Recording both spellings costs nothing and stops
|
|
380
|
+
// an assertion written the other way round from reading as absent.
|
|
381
|
+
if (node.arguments[0]) {
|
|
382
|
+
evidence.assertedAnchors.add(normalize(node.arguments[0].getText()));
|
|
383
|
+
}
|
|
384
|
+
if (ts.isPropertyAccessExpression(node.expression)) {
|
|
385
|
+
evidence.assertedAnchors.add(
|
|
386
|
+
normalize(node.expression.expression.getText())
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const visitIf = node => {
|
|
392
|
+
// TypeScript 6 renamed `IfStatement.condition` to `.expression`; reading
|
|
393
|
+
// both keeps the sweep working either side of that rename instead of
|
|
394
|
+
// silently inspecting zero guards, which is the failure mode this whole
|
|
395
|
+
// family of scripts exists to refuse.
|
|
396
|
+
const test = node.expression ?? node.condition;
|
|
397
|
+
if (!test || !branchFails(node.thenStatement)) return;
|
|
398
|
+
const flagged =
|
|
399
|
+
(ts.isPrefixUnaryExpression(test) &&
|
|
400
|
+
ts.isIdentifier(test.operand) &&
|
|
401
|
+
CHANGE_FLAG.test(test.operand.text)) ||
|
|
402
|
+
(ts.isIdentifier(test) && CHANGE_FLAG.test(test.text));
|
|
403
|
+
if (flagged) {
|
|
404
|
+
evidenceFor(node).guards.push({
|
|
405
|
+
line: lineOf(node),
|
|
406
|
+
test: normalize(test.getText()),
|
|
407
|
+
operands: [],
|
|
408
|
+
});
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const comparison =
|
|
412
|
+
ts.isBinaryExpression(test) &&
|
|
413
|
+
(test.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
414
|
+
test.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken);
|
|
415
|
+
if (!comparison) return;
|
|
416
|
+
const plain = side =>
|
|
417
|
+
ts.isIdentifier(side) || ts.isPropertyAccessExpression(side);
|
|
418
|
+
if (!plain(test.left) || !plain(test.right)) return;
|
|
419
|
+
evidenceFor(node).guards.push({
|
|
420
|
+
line: lineOf(node),
|
|
421
|
+
test: normalize(test.getText()),
|
|
422
|
+
operands: [rootIdentifier(test.left), rootIdentifier(test.right)],
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const walk = node => {
|
|
427
|
+
if (ts.isCallExpression(node)) visitCall(node);
|
|
428
|
+
if (ts.isIfStatement(node)) visitIf(node);
|
|
429
|
+
ts.forEachChild(node, walk);
|
|
430
|
+
};
|
|
431
|
+
walk(sourceFile);
|
|
432
|
+
return scopes;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Order two steps by where their anchors sit in the file.
|
|
437
|
+
* @param {{ position?: number }} left - One step.
|
|
438
|
+
* @param {{ position?: number }} right - The other step.
|
|
439
|
+
* @returns {number} Negative when `left` comes first.
|
|
440
|
+
*/
|
|
441
|
+
export function byPosition(left, right) {
|
|
442
|
+
return (left.position ?? 0) - (right.position ?? 0);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Turn one scope's evidence into a finding, or `null`.
|
|
447
|
+
* @param {object} evidence - Collected evidence for the scope.
|
|
448
|
+
* @param {string} file - Repository-relative path, for reporting.
|
|
449
|
+
* @returns {object | null} A finding, or `null` when the scope is sound.
|
|
450
|
+
*/
|
|
451
|
+
export function judgeScope(evidence, file) {
|
|
452
|
+
const stepCount = evidence.steps.reduce(
|
|
453
|
+
(total, step) => total + (step.iterated ? 2 : 1),
|
|
454
|
+
0
|
|
455
|
+
);
|
|
456
|
+
if (stepCount < 2) return null;
|
|
457
|
+
const guards = evidence.guards.filter(
|
|
458
|
+
guard =>
|
|
459
|
+
guard.operands.length === 0 ||
|
|
460
|
+
guard.operands.some(name => evidence.variables.has(name))
|
|
461
|
+
);
|
|
462
|
+
if (guards.length === 0) return null;
|
|
463
|
+
if (evidence.accountableCalls >= evidence.steps.length) return null;
|
|
464
|
+
const unaccounted = evidence.steps
|
|
465
|
+
.filter(step => !evidence.assertedAnchors.has(step.anchor))
|
|
466
|
+
// Named comparator, never a bare `.sort()`: the default sorts by string
|
|
467
|
+
// coercion, which orders offsets 2, 10, 9 as "10", "2", "9".
|
|
468
|
+
.sort(byPosition);
|
|
469
|
+
if (unaccounted.length === 0) return null;
|
|
470
|
+
return {
|
|
471
|
+
file,
|
|
472
|
+
scope: evidence.name,
|
|
473
|
+
steps: stepCount,
|
|
474
|
+
guards,
|
|
475
|
+
unaccounted: unaccounted.map(step => ({
|
|
476
|
+
line: step.line,
|
|
477
|
+
anchor:
|
|
478
|
+
step.anchor.length > 70 ? `${step.anchor.slice(0, 70)}…` : step.anchor,
|
|
479
|
+
})),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Walk a directory tree, yielding source files the sweep can parse.
|
|
485
|
+
* @param {string} root - Absolute directory to walk.
|
|
486
|
+
* @param {string} repoRoot - Absolute repository root, for relative paths.
|
|
487
|
+
* @returns {{ absolute: string, relative: string }[]} Files, in a stable order.
|
|
488
|
+
*/
|
|
489
|
+
export function collectFiles(root, repoRoot) {
|
|
490
|
+
const found = [];
|
|
491
|
+
const walk = directory => {
|
|
492
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
493
|
+
for (const entry of [...entries].sort((a, b) =>
|
|
494
|
+
a.name < b.name ? -1 : 1
|
|
495
|
+
)) {
|
|
496
|
+
if (SKIPPED_DIRECTORIES.includes(entry.name)) continue;
|
|
497
|
+
const absolute = path.join(directory, entry.name);
|
|
498
|
+
if (entry.isDirectory()) {
|
|
499
|
+
walk(absolute);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (!entry.isFile()) continue;
|
|
503
|
+
if (!SOURCE_EXTENSIONS.test(entry.name)) continue;
|
|
504
|
+
if (/\.d\.(ts|mts|cts)$/.test(entry.name)) continue;
|
|
505
|
+
found.push({ absolute, relative: path.relative(repoRoot, absolute) });
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
walk(root);
|
|
509
|
+
return found;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Run the sweep over a tree.
|
|
514
|
+
* @param {string} repoRoot - Absolute path of the tree to inspect.
|
|
515
|
+
* @param {readonly string[]} [roots] - Sub-directories to scan.
|
|
516
|
+
* @returns {{ inspected: number, files: number, findings: object[] }} Report.
|
|
517
|
+
*/
|
|
518
|
+
export function sweep(repoRoot, roots = SCANNED_ROOTS) {
|
|
519
|
+
const report = { inspected: 0, files: 0, findings: [] };
|
|
520
|
+
for (const root of roots) {
|
|
521
|
+
const absolute = path.join(repoRoot, root);
|
|
522
|
+
try {
|
|
523
|
+
if (!statSync(absolute).isDirectory()) continue;
|
|
524
|
+
} catch {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
for (const file of collectFiles(absolute, repoRoot)) {
|
|
528
|
+
report.files += 1;
|
|
529
|
+
const text = readFileSync(file.absolute, "utf8");
|
|
530
|
+
if (!/\.replace(All)?\s*\(/.test(text)) continue;
|
|
531
|
+
const sourceFile = ts.createSourceFile(
|
|
532
|
+
file.absolute,
|
|
533
|
+
text,
|
|
534
|
+
ts.ScriptTarget.Latest,
|
|
535
|
+
true,
|
|
536
|
+
/\.tsx?$/.test(file.absolute) ? ts.ScriptKind.TS : ts.ScriptKind.JS
|
|
537
|
+
);
|
|
538
|
+
for (const evidence of collectScopes(sourceFile).values()) {
|
|
539
|
+
if (evidence.steps.length === 0) continue;
|
|
540
|
+
report.inspected += 1;
|
|
541
|
+
const finding = judgeScope(evidence, file.relative);
|
|
542
|
+
if (finding) report.findings.push(finding);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return report;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Render the human-readable report.
|
|
551
|
+
* @param {{ inspected: number, files: number, findings: object[] }} report - Result.
|
|
552
|
+
* @returns {string} The report text.
|
|
553
|
+
*/
|
|
554
|
+
export function formatReport(report) {
|
|
555
|
+
const lines = [
|
|
556
|
+
`check:whole-output-guards — inspected ${report.inspected} anchored transform(s) across ${report.files} file(s).`,
|
|
557
|
+
];
|
|
558
|
+
if (report.inspected === 0) {
|
|
559
|
+
lines.push(
|
|
560
|
+
" ✖ ZERO transforms inspected. A sweep that parsed nothing cannot report a clean tree; treating this as a failure, not an all-clear."
|
|
561
|
+
);
|
|
562
|
+
return lines.join("\n");
|
|
563
|
+
}
|
|
564
|
+
for (const finding of report.findings) {
|
|
565
|
+
lines.push(
|
|
566
|
+
` ✖ ${finding.file} — ${finding.scope}() applies ${finding.steps} anchored rewrites`
|
|
567
|
+
);
|
|
568
|
+
for (const guard of finding.guards) {
|
|
569
|
+
lines.push(
|
|
570
|
+
` line ${guard.line}: \`${guard.test}\` throws only when NOTHING changed`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
for (const step of finding.unaccounted) {
|
|
574
|
+
lines.push(
|
|
575
|
+
` line ${step.line}: ${step.anchor} — never asserted present`
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (report.findings.length === 0) {
|
|
580
|
+
lines.push(
|
|
581
|
+
" ✔ Every multi-step anchored transform holds each of its steps to account."
|
|
582
|
+
);
|
|
583
|
+
return lines.join("\n");
|
|
584
|
+
}
|
|
585
|
+
lines.push(
|
|
586
|
+
"",
|
|
587
|
+
"Fix: assert each anchor BEFORE replacing, and name the missing one —",
|
|
588
|
+
' import { applyRewrites } from "…/core/anchored-rewrite.js";',
|
|
589
|
+
" const out = applyRewrites(source, [",
|
|
590
|
+
' { anchor: A, replacement: A2, label: "the import" },',
|
|
591
|
+
' { anchor: B, replacement: B2, label: "the default root" },',
|
|
592
|
+
" ], PROVER_SOURCE);",
|
|
593
|
+
"A step that may legitimately find nothing declares `optional: true` (or calls `replaceOptional`) rather than being left unguarded."
|
|
594
|
+
);
|
|
595
|
+
return lines.join("\n");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* CLI entry point.
|
|
600
|
+
* @returns {void}
|
|
601
|
+
*/
|
|
602
|
+
export function main() {
|
|
603
|
+
const args = process.argv.slice(2);
|
|
604
|
+
const unknown = args.find(arg => arg.startsWith("--") && arg !== "--json");
|
|
605
|
+
if (unknown) {
|
|
606
|
+
console.error(`check:whole-output-guards: unknown flag ${unknown}`);
|
|
607
|
+
process.exitCode = 2;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const json = args.includes("--json");
|
|
611
|
+
const repoRoot = path.resolve(args.find(arg => !arg.startsWith("--")) ?? ".");
|
|
612
|
+
const report = sweep(repoRoot);
|
|
613
|
+
console.log(json ? JSON.stringify(report, null, 2) : formatReport(report));
|
|
614
|
+
if (report.inspected === 0) process.exitCode = 2;
|
|
615
|
+
else if (report.findings.length > 0) process.exitCode = 1;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (invokedAsScript(import.meta.url)) {
|
|
619
|
+
main();
|
|
620
|
+
}
|