Skip to content

Commit 0ea3822

Browse files
Feat(cli): Attribute a named export list hunk to its names (#1758)
1 parent 1579964 commit 0ea3822

4 files changed

Lines changed: 211 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@attalabs/vinaya': patch
3+
---
4+
5+
The pre-push test selection attributes a change inside a named export list (`export { a, b as c } from '…'`) to the names on the changed lines, so adding one name to a package barrel no longer selects every test that reaches the barrel. A change to the list's braces, its `from` clause, a one-line list or an `export *` still makes the whole file affected.

‎apps/cli/specs/self-hosting.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ An ordinary modification selects none of them. `apps/cli/tests/lib/pre-push-scan
108108

109109
**Resolution is the TypeScript compiler's, not a text scan.** A regular expression can read the specifier `'./demo.js'`; it cannot know the file on disk is `demo.ts`. This repository writes 482 of its 1275 relative import specifiers with the extension the EMIT will have, which is what TypeScript's own ESM guidance says to do — and every one of those edges was absent from the regex graph, across 144 importer files and 104 distinct targets. The visible symptom: a change to `apps/cli/src/commands/demo.ts` selected no test at all, though `apps/cli/tests/demo.test.ts` imports it by name. A selector that drops edges does not over-select; it silently under-selects, and its reported reductions are lost edges wearing precision's clothes. `lib/ts-module-graph.ts` now resolves every specifier with `ts.resolveModuleName` and follows each imported name to its defining file along the checker's own alias chain, hop by hop, so a re-export chain and a cross-package barrel both resolve to the file that actually defines the name. The compiler is resolved at runtime from the repository under analysis (`createRequire`), never bundled into the published artifact and never added to an adopter's install; a repository without it falls back to the text-scan graph, which is a strict over-approximation.
110110

111-
**Selection follows the changed exported NAMES, not the changed file.** `lib/changed-names.ts` attributes each diff hunk to the top-level declarations it touches — on both sides of the diff, so a deleted declaration is attributed too — and closes that set backwards over intra-file references, since a change to a local helper affects every exported declaration that uses it. A test is selected when its import closure reaches a USE of an affected name. Any hunk that cannot be attributed to exactly one declaration — module-scope code, an `import`/`export` statement, an `export *`, `export default`, a destructuring binding, an unparseable region, a range outside every declaration — makes the whole file affected, which is the file-level answer; so is a changed file whose names were never computed. Narrowing is something the selector has to earn, every time.
111+
**Selection follows the changed exported NAMES, not the changed file.** `lib/changed-names.ts` attributes each diff hunk to the top-level declarations it touches — on both sides of the diff, so a deleted declaration is attributed too — and closes that set backwards over intra-file references, since a change to a local helper affects every exported declaration that uses it. A test is selected when its import closure reaches a USE of an affected name. A hunk inside a named export list — `export { a, b as c } from '…'` or `export { a, b }` — is attributed to the specifiers written on its lines, by the name each EXPORTS (`c`, the `as` alias, not `b`), so a one-name addition to a package barrel selects only the tests that import that name, which before the change is none; a local list's exported name is also affected whenever the declaration it names is. Only the lines strictly between the list's braces narrow: a hunk on the `export {` line, the `}` line, the `from` clause or the module specifier re-points every name the statement exports, and so does a one-line list, whose single line holds all of them. Any other hunk that cannot be attributed to exactly one declaration — module-scope code, an `import` statement, an `export` statement outside a named list's specifier lines, an `export *`, `export default`, a destructuring binding, an unparseable region, a range outside every declaration — makes the whole file affected, which is the file-level answer; so is a changed file whose names were never computed. Narrowing is something the selector has to earn, every time.
112112

113113
A bare workspace-package import is not treated as a dependency on the whole package: the imported NAMES are resolved through the target package's declared entrypoint (`exports`/`main`, handed to the compiler as `paths` so the answer does not depend on the install layout) and its named, aliased, star and cross-package re-export graph, to the files that actually define them. Every shape the compiler cannot prove keeps the whole-package edge — a namespace, default, side-effect or dynamic import, an `export *` supplying one name from two sources, `import x = require(...)`, an alias chain that does not terminate in a declaration, or a package whose entrypoint cannot be derived from its manifest — so the refinement can over-select but never omits a truly affected test.
114114

‎apps/cli/src/lib/changed-names.ts‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,29 @@
2929
* over-approximates (a shadowing local of the same name creates an edge that
3030
* isn't real) — the safe direction.
3131
*
32+
* ## A named export list is attributed line by line
33+
*
34+
* A hunk inside `export { a, b as c } from '…'` or `export { a, b }` is
35+
* attributed to the specifiers written on its lines, by the name each one
36+
* EXPORTS (`c`, not `b`) — the name a test imports. That is what keeps a
37+
* one-name addition to a package barrel from selecting every test that reaches
38+
* the barrel. Only the lines strictly between the list's braces narrow: a hunk
39+
* on the `export {` line, the `}` line, the `from` clause or the module
40+
* specifier re-points every name the statement exports, so it widens, as does a
41+
* line inside the braces that holds no specifier. A local list's exported names
42+
* join the intra-file closure through the declarations they name, so a change
43+
* to `b` above also affects `c`.
44+
*
3245
* ## When it gives up, it gives up completely
3346
*
3447
* {@link ALL_NAMES} — the whole file is affected — is the answer for every hunk
3548
* that cannot be attributed to exactly one declaration: module-scope executable
36-
* code, an `import` or `export` statement (including `export *`, which can
37-
* supply any name, and `export default`), a destructuring or computed binding,
38-
* a region that parses to nothing, and any range falling outside every
39-
* declaration. This is never a narrowing: it is the file-level answer, which is
40-
* what the selector does when it is told nothing at all.
49+
* code, an `import` statement, an `export` statement outside a named export
50+
* list's specifier lines (including `export *`, which can supply any name, and
51+
* `export default`), a destructuring or computed binding, a region that parses
52+
* to nothing, and any range falling outside every declaration. This is never a
53+
* narrowing: it is the file-level answer, which is what the selector does when
54+
* it is told nothing at all.
4155
*/
4256
import type { TypeScriptApi } from './ts-module-graph.js'
4357

@@ -81,8 +95,27 @@ function namesForRanges(
8195
const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.ES2022, true)
8296
const lineOf = (pos: number): number => sourceFile.getLineAndCharacterOfPosition(pos).line + 1
8397

84-
/** Every top-level declaration, with the line span it owns and the name it declares. A `null` name is a statement no hunk can be attributed to. */
85-
const declarations: Array<{ name: string | null; start: number; end: number; node: import('typescript').Node }> = []
98+
/**
99+
* A named export list's own layout: the lines of its `{` and `}`, and each
100+
* specifier's exported name, the local name it re-exports when the statement
101+
* has no module specifier, and its line span.
102+
*/
103+
type ExportList = {
104+
open: number
105+
close: number
106+
specifiers: Array<{ exported: string; local: string | null; start: number; end: number }>
107+
}
108+
/**
109+
* Every top-level declaration, with the line span it owns and the name it declares. A `null` name is a statement no
110+
* hunk can be attributed to — unless it carries an `exportList`, whose specifier lines are attributed one by one.
111+
*/
112+
const declarations: Array<{
113+
name: string | null
114+
start: number
115+
end: number
116+
node: import('typescript').Node
117+
exportList?: ExportList
118+
}> = []
86119
/**
87120
* Where a declaration's OWN text begins: its first leading comment when it has
88121
* one (a doc comment is part of the declaration for attribution — editing it
@@ -109,6 +142,28 @@ function namesForRanges(
109142
}
110143
continue
111144
}
145+
// `export { … }` and `export { … } from '…'`; an `export *` (with or
146+
// without `as ns`) has no list and stays unattributable.
147+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
148+
const clause = statement.exportClause
149+
const local = statement.moduleSpecifier === undefined
150+
declarations.push({
151+
name: null,
152+
...span,
153+
node: statement,
154+
exportList: {
155+
open: lineOf(clause.getStart(sourceFile)),
156+
close: lineOf(clause.getEnd()),
157+
specifiers: clause.elements.map((element) => ({
158+
exported: element.name.text,
159+
local: local ? (element.propertyName ?? element.name).text : null,
160+
start: lineOf(element.getStart(sourceFile)),
161+
end: lineOf(element.getEnd())
162+
}))
163+
}
164+
})
165+
continue
166+
}
112167
const named =
113168
ts.isFunctionDeclaration(statement) ||
114169
ts.isClassDeclaration(statement) ||
@@ -133,6 +188,18 @@ function namesForRanges(
133188
// can follow — either way the file is wholly affected.
134189
if (overlapping.length === 0) return null
135190
for (const d of overlapping) {
191+
if (d.exportList) {
192+
const list = d.exportList
193+
for (let line = Math.max(range.start, d.start); line <= Math.min(range.end, d.end); line++) {
194+
// The braces' own lines, and everything outside them — a leading
195+
// comment, `export {`, `} from '…'` — re-point every name.
196+
if (line <= list.open || line >= list.close) return null
197+
const onLine = list.specifiers.filter((s) => s.start <= line && line <= s.end)
198+
if (onLine.length === 0) return null
199+
for (const s of onLine) seeded.add(s.exported)
200+
}
201+
continue
202+
}
136203
if (d.name === null) return null
137204
seeded.add(d.name)
138205
}
@@ -151,6 +218,16 @@ function namesForRanges(
151218
ts.forEachChild(d.node, visit)
152219
references.set(d.name, used)
153220
}
221+
// A local export list's exported name is affected whenever the declaration
222+
// it names is.
223+
for (const d of declarations) {
224+
for (const s of d.exportList?.specifiers ?? []) {
225+
if (s.local === null || s.local === s.exported || !topLevelNames.has(s.local)) continue
226+
const used = references.get(s.exported) ?? new Set<string>()
227+
used.add(s.local)
228+
references.set(s.exported, used)
229+
}
230+
}
154231
const affected = new Set(seeded)
155232
for (let grew = true; grew; ) {
156233
grew = false

‎apps/cli/tests/lib/test-selector.test.ts‎

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { cliSpawnEdgeOf } from '../../src/lib/cli-spawn-tests'
2323
import { scannedRootsOf } from '../../src/lib/repo-scanner-tests'
2424
import { loadTypeScript } from '../../src/lib/ts-module-graph'
25+
import { fixtureDir } from './test-temp-fixture'
2526

2627
function fixtureRepo(): string {
2728
const root = mkdtempSync(join(tmpdir(), 'vinaya-selector-'))
@@ -403,8 +404,10 @@ describe('a second real CI failure in the SAME task-files-v1/1 change set (Issue
403404
type PkgSpec = { name: string; main?: string; exports?: unknown; test?: string; files: Record<string, string> }
404405

405406
/** Writes a throwaway multi-package workspace to a temp dir and returns its root plus a `dir(name)` locator. */
406-
function mkWorkspace(pkgs: PkgSpec[]): { root: string; dir: (name: string) => string } {
407-
const root = mkdtempSync(join(tmpdir(), 'vinaya-sym-'))
407+
function mkWorkspace(
408+
pkgs: PkgSpec[],
409+
root = mkdtempSync(join(tmpdir(), 'vinaya-sym-'))
410+
): { root: string; dir: (name: string) => string } {
408411
writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] }))
409412
const dirs = new Map<string, string>()
410413
pkgs.forEach((p, i) => {
@@ -1542,6 +1545,71 @@ describe('changed-name attribution (O3)', () => {
15421545
})
15431546
}
15441547

1548+
const EXPORT_LISTS = [
1549+
'export {', // 1
1550+
' alpha,', // 2
1551+
' beta as renamed,', // 3
1552+
"} from './a.js'", // 4
1553+
"export { solo } from './b.js'", // 5
1554+
"export * from './c.js'", // 6
1555+
'function local() { return 1 }', // 7
1556+
'export {', // 8
1557+
' local as publicLocal', // 9
1558+
'}', // 10
1559+
'export {', // 11
1560+
' moved', // 12
1561+
'}', // 13
1562+
" from './d.js'" // 14
1563+
].join('\n')
1564+
1565+
it('a hunk on a specifier line of a named export list attributes to that exported name', () => {
1566+
expect(modified(EXPORT_LISTS, [{ start: 2, end: 2 }])).toEqual(new Set(['alpha']))
1567+
})
1568+
1569+
it('an aliased specifier attributes to the name it exports, not the one it re-exports', () => {
1570+
expect(modified(EXPORT_LISTS, [{ start: 3, end: 3 }])).toEqual(new Set(['renamed']))
1571+
expect(modified(EXPORT_LISTS, [{ start: 9, end: 9 }])).toEqual(new Set(['publicLocal']))
1572+
})
1573+
1574+
it('a hunk across several specifier lines attributes to each of them', () => {
1575+
expect(modified(EXPORT_LISTS, [{ start: 2, end: 3 }])).toEqual(new Set(['alpha', 'renamed']))
1576+
})
1577+
1578+
it('a local declaration exported under an alias affects the exported name too', () => {
1579+
expect(modified(EXPORT_LISTS, [{ start: 7, end: 7 }])).toEqual(new Set(['local', 'publicLocal']))
1580+
})
1581+
1582+
for (const [label, line] of [
1583+
['the `export {` line', 1],
1584+
['the `} from` line', 4],
1585+
['a one-line list, whose line holds its braces and module specifier', 5],
1586+
['an `export *` statement', 6],
1587+
['a local list’s closing brace', 10],
1588+
['a module specifier on its own line', 14]
1589+
] as const) {
1590+
it(`a hunk on ${label} widens to the whole file`, () => {
1591+
expect(modified(EXPORT_LISTS, [{ start: line, end: line }])).toBe(ALL_NAMES)
1592+
})
1593+
}
1594+
1595+
it('a hunk spanning a specifier line and the statement’s brace widens to the whole file', () => {
1596+
expect(modified(EXPORT_LISTS, [{ start: 3, end: 4 }])).toBe(ALL_NAMES)
1597+
})
1598+
1599+
it('a specifier removed by the diff is attributed from the OLD side', () => {
1600+
const before = "export {\n kept,\n gone,\n} from './a.js'\n"
1601+
const after = "export {\n kept,\n} from './a.js'\n"
1602+
expect(
1603+
affectedNamesForFile(ts, {
1604+
file: '/w/mod.ts',
1605+
after,
1606+
before,
1607+
afterRanges: [{ start: 3, end: 2 }],
1608+
beforeRanges: [{ start: 3, end: 3 }]
1609+
})
1610+
).toEqual(new Set(['gone']))
1611+
})
1612+
15451613
it('a declaration deleted by the diff is attributed from the OLD side', () => {
15461614
const before = 'export function gone() { return 1 }\nexport function kept() { return 2 }\n'
15471615
const after = 'export function kept() { return 2 }\n'
@@ -1638,6 +1706,57 @@ describe('selection follows the changed names, and widens to file level when it
16381706
}
16391707
})
16401708

1709+
it('a barrel that gains one name selects no test through the barrel, only what the diff adds and alwaysRun', () => {
1710+
const ts = loadTypeScript(REPO_ROOT) as NonNullable<ReturnType<typeof loadTypeScript>>
1711+
const before = "export {\n hot,\n} from './hot.js'\nexport { cold } from './cold.js'\n"
1712+
const after = "export {\n hot,\n fresh,\n} from './hot.js'\nexport { cold } from './cold.js'\n"
1713+
const { root, dir } = mkWorkspace(
1714+
[
1715+
{
1716+
name: '@o2/lib',
1717+
main: INDEX_MAIN,
1718+
files: {
1719+
'src/index.ts': after,
1720+
'src/hot.ts': 'export function hot() { return 1 }\nexport function fresh() { return 3 }\n',
1721+
'src/cold.ts': 'export function cold() { return 2 }\n',
1722+
'src/index.test.ts': "import { hot, cold } from './index.js'\ntest('both', () => hot() + cold())\n"
1723+
}
1724+
},
1725+
{
1726+
name: '@o2/app',
1727+
files: {
1728+
'src/uses-hot.test.ts': "import { hot } from '@o2/lib'\ntest('hot', () => hot())\n",
1729+
'src/uses-cold.test.ts': "import { cold } from '@o2/lib'\ntest('cold', () => cold())\n",
1730+
'src/uses-fresh.test.ts': "import { fresh } from '@o2/lib'\ntest('fresh', () => fresh())\n",
1731+
'src/unrelated.test.ts': "test('unrelated', () => 1)\n"
1732+
}
1733+
}
1734+
],
1735+
fixtureDir('vinaya-barrel-')
1736+
)
1737+
const index = join(dir('@o2/lib'), 'src/index.ts')
1738+
const added = join(dir('@o2/app'), 'src/uses-fresh.test.ts')
1739+
const names = affectedNamesForFile(ts, {
1740+
file: index,
1741+
after,
1742+
before,
1743+
afterRanges: [{ start: 3, end: 3 }],
1744+
beforeRanges: [{ start: 2, end: 1 }]
1745+
})
1746+
expect(names).toEqual(new Set(['fresh']))
1747+
const alwaysRun = 'packages/p1/src/unrelated.test.ts'
1748+
const selected = selectAffectedTestFiles(root, [index, added], {
1749+
affectedNames: new Map([[index, names]]),
1750+
alwaysRun: [alwaysRun]
1751+
}).selected
1752+
expect(new Set(selected)).toEqual(new Set([added, join(root, alwaysRun)]))
1753+
// The barrel is reached: at file level the same change selects every test through it.
1754+
const fileLevel = new Set(selectAffectedTestFiles(root, [index]).selected)
1755+
expect(fileLevel).toContain(join(dir('@o2/app'), 'src/uses-hot.test.ts'))
1756+
expect(fileLevel).toContain(join(dir('@o2/app'), 'src/uses-cold.test.ts'))
1757+
expect(fileLevel).toContain(join(dir('@o2/lib'), 'src/index.test.ts'))
1758+
})
1759+
16411760
// Two whole-repository selections, each walking every workspace package and
16421761
// parsing its sources — seconds of real work apiece on this repository's
16431762
// current size, and several times that when the suite runs it alongside

0 commit comments

Comments
 (0)