Skip to content

Commit 1b17394

Browse files
committed
fix: eliminate false positives in v1.51.0 codemods
experimental-form-decorators-to-stable: - Skip property accesses (member expressions) when renaming object keys. Previously, `obj.EXPERIMENTAL_formDecorators` inside a pair value was incorrectly renamed, turning `X ?? EXPERIMENTAL_X` into `X ?? X`. migrate-policy-query-user: - Scope object pattern processing to PolicyQueryUser-typed patterns only. Previously, ANY `{ token }` destructuring in files importing from @backstage/plugin-permission-node was modified, causing false positives on unrelated patterns like `req.body` and type aliases. - Clean up empty destructuring: `const {} = await expr` becomes `await expr`. - Preserve variable declarations whose bindings are used elsewhere: add TODO comment above instead of replacing the entire statement. - Detect shorthand_property_identifier usages when checking if a declared binding is referenced downstream.
1 parent 15e06a4 commit 1b17394

14 files changed

Lines changed: 305 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@backstage/experimental-form-decorators-to-stable': patch
3+
---
4+
5+
Fixed false positive where property accesses like `template.spec.EXPERIMENTAL_formDecorators` inside a nullish coalescing fallback were incorrectly renamed, turning `formDecorators ?? EXPERIMENTAL_formDecorators` into the redundant `formDecorators ?? formDecorators`.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@backstage/migrate-policy-query-user': patch
3+
---
4+
5+
Fixed false positives where unrelated `token` fields were incorrectly removed from destructuring patterns. The codemod now only processes object patterns that are directly typed as `PolicyQueryUser` or destructure a tracked `PolicyQueryUser` binding, instead of modifying every `{ token }` destructuring in files that import from `@backstage/plugin-permission-node`.
6+
7+
Also fixed empty destructuring cleanup (`const {} = await expr` now simplifies to `await expr`), and variable declarations that define bindings used elsewhere are no longer deleted (a TODO comment is added above instead).

codemods/v1.51.0/experimental-form-decorators-to-stable/scripts/codemod.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ const transform: Codemod<TSX> = async (root) => {
2828
})
2929

3030
for (const key of propertyKeys) {
31+
// Skip property accesses (e.g. template.spec.EXPERIMENTAL_formDecorators)
32+
// — only rename actual object literal keys, not member-expression properties
33+
const parent = key.parent()
34+
if (parent?.kind() === 'member_expression') {
35+
continue
36+
}
3137
renames.increment()
3238
edits.push(key.replace(NEW_KEY))
3339
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// The fallback `?? EXPERIMENTAL_formDecorators` is a property access,
2+
// not an object key — the codemod must leave it alone so backward-compat
3+
// fallback patterns are preserved.
4+
export function getDecorators(template: any) {
5+
return {
6+
formDecorators:
7+
template.spec.formDecorators ??
8+
template.spec.EXPERIMENTAL_formDecorators,
9+
};
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// The fallback `?? EXPERIMENTAL_formDecorators` is a property access,
2+
// not an object key — the codemod must leave it alone so backward-compat
3+
// fallback patterns are preserved.
4+
export function getDecorators(template: any) {
5+
return {
6+
formDecorators:
7+
template.spec.formDecorators ??
8+
template.spec.EXPERIMENTAL_formDecorators,
9+
};
10+
}

codemods/v1.51.0/migrate-policy-query-user/scripts/codemod.ts

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,75 @@ function rebuildObjectPattern(objectPattern: SgNode<TSX>): { text: string; renam
181181
return { text: `{ ${kept.join(', ')} }`, renamedLocals }
182182
}
183183

184-
function processObjectPattern(objectPattern: SgNode<TSX>, edits: Edit[]): Map<string, string> {
184+
function isPolicyQueryUserObjectPattern(objectPattern: SgNode<TSX>, userBindings: Set<string>): boolean {
185+
const parent = objectPattern.parent()
186+
if (!parent) {
187+
return false
188+
}
189+
190+
// Direct parameter annotation: ({ token, credentials }: PolicyQueryUser)
191+
if (parent.is('required_parameter') || parent.is('optional_parameter')) {
192+
const typeNode = parent.field('type')
193+
return isPolicyQueryUserTypeAnnotation(typeNode)
194+
}
195+
196+
// Variable declaration: const { token } = user (where user is a tracked PolicyQueryUser binding)
197+
if (parent.is('variable_declarator')) {
198+
const initNode = parent.field('value')
199+
if (initNode?.is('identifier') && userBindings.has(initNode.text())) {
200+
return true
201+
}
202+
// Explicit type annotation: const { token }: PolicyQueryUser = ...
203+
const typeNode = parent.field('type')
204+
if (isPolicyQueryUserTypeAnnotation(typeNode)) {
205+
return true
206+
}
207+
}
208+
209+
return false
210+
}
211+
212+
function processObjectPattern(objectPattern: SgNode<TSX>, edits: Edit[], source: string): Map<string, string> {
185213
const rebuilt = rebuildObjectPattern(objectPattern)
186214
if (!rebuilt) {
187215
return new Map()
188216
}
189217

218+
// When all fields have been removed, the destructuring becomes `const {} = expr`
219+
// which is useless. Clean up the enclosing statement instead.
220+
if (rebuilt.text === '{}') {
221+
const statement = getEnclosingStatement(objectPattern)
222+
if (statement) {
223+
const kind = statement.kind()
224+
if (kind === 'lexical_declaration' || kind === 'variable_declarator') {
225+
const decl = kind === 'variable_declarator' ? statement.parent() : statement
226+
if (decl) {
227+
// If the RHS contains an await, keep the await for side-effects
228+
const awaitExpr = decl.find({ rule: { kind: 'await_expression' } })
229+
if (awaitExpr) {
230+
const range = decl.range()
231+
const indent = getLineIndent(source, range.start.index)
232+
const endPos = consumeTrailingNewline(source, range.end.index)
233+
const lineStart = source.lastIndexOf('\n', range.start.index - 1) + 1
234+
edits.push({
235+
startPos: lineStart,
236+
endPos,
237+
insertedText: `${indent}${awaitExpr.text()};\n`,
238+
})
239+
} else {
240+
// No side-effects — remove the entire statement
241+
const range = decl.range()
242+
const lineStart = source.lastIndexOf('\n', range.start.index - 1) + 1
243+
const endPos = consumeTrailingNewline(source, range.end.index)
244+
edits.push({ startPos: lineStart, endPos, insertedText: '' })
245+
}
246+
migrationMetric.increment({ action: 'empty-destructuring-cleaned' })
247+
return rebuilt.renamedLocals
248+
}
249+
}
250+
}
251+
}
252+
190253
edits.push(objectPattern.replace(rebuilt.text))
191254
migrationMetric.increment({ action: 'object-pattern-updated' })
192255
return rebuilt.renamedLocals
@@ -328,10 +391,60 @@ function getEnclosingStatement(node: SgNode<TSX>): SgNode<TSX> | null {
328391
return null
329392
}
330393

331-
function replaceStatementWithTodo(statement: SgNode<TSX>, source: string, edits: Edit[]): void {
394+
function declaresUsedBinding(statement: SgNode<TSX>, rootNode: SgNode<TSX>): boolean {
395+
const kind = statement.kind()
396+
const decl = kind === 'lexical_declaration' ? statement : kind === 'variable_declarator' ? statement.parent() : null
397+
if (!decl) {
398+
return false
399+
}
400+
401+
const declarators = decl.findAll({ rule: { kind: 'variable_declarator' } })
402+
for (const d of declarators) {
403+
const nameNode = d.field('name')
404+
if (!nameNode?.is('identifier')) {
405+
continue
406+
}
407+
const name = nameNode.text()
408+
// Check if the declared name is used outside this statement.
409+
// Search both regular identifiers and shorthand object properties
410+
// (e.g. `{ secrets }` parses as shorthand_property_identifier).
411+
const identifierKinds = ['identifier', 'shorthand_property_identifier'] as const
412+
for (const identKind of identifierKinds) {
413+
const usages = rootNode.findAll({
414+
rule: { kind: identKind, regex: exactMatchRegex(name) },
415+
})
416+
for (const usage of usages) {
417+
if (isBindingIdentifier(usage)) {
418+
continue
419+
}
420+
// Usage is outside the declaration statement
421+
const usageIdx = usage.range().start.index
422+
if (usageIdx < decl.range().start.index || usageIdx >= decl.range().end.index) {
423+
return true
424+
}
425+
}
426+
}
427+
}
428+
return false
429+
}
430+
431+
function replaceStatementWithTodo(statement: SgNode<TSX>, source: string, edits: Edit[], rootNode: SgNode<TSX>): void {
332432
const range = statement.range()
333433
const lineStart = source.lastIndexOf('\n', range.start.index - 1) + 1
334434
const indent = getLineIndent(source, range.start.index)
435+
436+
// If the statement defines a variable used elsewhere, add a TODO comment
437+
// above instead of deleting it — otherwise downstream references break.
438+
if (declaresUsedBinding(statement, rootNode)) {
439+
edits.push({
440+
startPos: lineStart,
441+
endPos: lineStart,
442+
insertedText: `${indent}${TODO_TOKEN}\n`,
443+
})
444+
migrationMetric.increment({ action: 'token-usage-todo-comment' })
445+
return
446+
}
447+
335448
const endPos = consumeTrailingNewline(source, range.end.index)
336449

337450
edits.push({
@@ -356,13 +469,16 @@ const transform: Codemod<TSX> = async (root) => {
356469
const statementsWithTodo = new Set<number>()
357470

358471
for (const objectPattern of rootNode.findAll({ rule: { kind: 'object_pattern' } })) {
472+
if (!isPolicyQueryUserObjectPattern(objectPattern, userBindings)) {
473+
continue
474+
}
359475
const bindings = collectObjectPatternBindings(objectPattern)
360476
for (const binding of bindings) {
361477
if (REMOVED_FIELDS.has(binding.fieldName)) {
362478
tokenLocals.add(binding.localName)
363479
}
364480
}
365-
const renamed = processObjectPattern(objectPattern, edits)
481+
const renamed = processObjectPattern(objectPattern, edits, source)
366482
for (const [from, to] of renamed) {
367483
renamedLocals.set(from, to)
368484
}
@@ -399,15 +515,15 @@ const transform: Codemod<TSX> = async (root) => {
399515
const statement = getEnclosingStatement(member)
400516
if (statement && !statementsWithTodo.has(statement.range().start.index)) {
401517
statementsWithTodo.add(statement.range().start.index)
402-
replaceStatementWithTodo(statement, source, edits)
518+
replaceStatementWithTodo(statement, source, edits, rootNode)
403519
}
404520
}
405521

406522
for (const member of findMemberAccessOnBinding(rootNode, binding, 'expiresInSeconds')) {
407523
const statement = getEnclosingStatement(member)
408524
if (statement && !statementsWithTodo.has(statement.range().start.index)) {
409525
statementsWithTodo.add(statement.range().start.index)
410-
replaceStatementWithTodo(statement, source, edits)
526+
replaceStatementWithTodo(statement, source, edits, rootNode)
411527
}
412528
}
413529
}
@@ -420,7 +536,7 @@ const transform: Codemod<TSX> = async (root) => {
420536
const statement = getEnclosingStatement(node)
421537
if (statement && !statementsWithTodo.has(statement.range().start.index)) {
422538
statementsWithTodo.add(statement.range().start.index)
423-
replaceStatementWithTodo(statement, source, edits)
539+
replaceStatementWithTodo(statement, source, edits, rootNode)
424540
}
425541
}
426542
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PolicyQueryUser } from '@backstage/plugin-permission-node';
2+
3+
async function example() {
4+
await fetchUser();
5+
// TODO(backstage-codemod): migrate to credentials via coreServices.auth
6+
}
7+
8+
async function fetchUser(): Promise<PolicyQueryUser> {
9+
return {} as PolicyQueryUser;
10+
}
11+
12+
async function doSomething(_token: string) {}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PolicyQueryUser } from '@backstage/plugin-permission-node';
2+
3+
async function example() {
4+
const { token }: PolicyQueryUser = await fetchUser();
5+
await doSomething(token);
6+
}
7+
8+
async function fetchUser(): Promise<PolicyQueryUser> {
9+
return {} as PolicyQueryUser;
10+
}
11+
12+
async function doSomething(_token: string) {}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"migrate-policy-query-user": [
3+
{
4+
"cardinality": {
5+
"action": "empty-destructuring-cleaned"
6+
},
7+
"count": 1
8+
},
9+
{
10+
"cardinality": {
11+
"action": "token-usage-todo"
12+
},
13+
"count": 1
14+
}
15+
]
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { PermissionPolicy, PolicyQuery, PolicyQueryUser } from '@backstage/plugin-permission-node';
2+
3+
// The codemod must NOT touch `token` fields in non-PolicyQueryUser patterns,
4+
// even when the file imports from @backstage/plugin-permission-node.
5+
async function handleAutocomplete(req: any) {
6+
const { token, context } = req.body;
7+
if (!token) throw new Error('Missing token');
8+
return { token, context };
9+
}
10+
11+
type AutocompleteHandler = ({ resource, token, context }: {
12+
resource: string;
13+
token: string;
14+
context: Record<string, string>;
15+
}) => Promise<{ results: { id: string }[] }>;
16+
17+
export { handleAutocomplete, AutocompleteHandler };

0 commit comments

Comments
 (0)