Skip to content

Commit bff7a4c

Browse files
committed
fix(integrations): treat an untraceable namespace read as reading every export in check:integration-access
1 parent 84a9b82 commit bff7a4c

2 files changed

Lines changed: 47 additions & 10 deletions

File tree

‎scripts/check-integration-access.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ export const types = metadata.INTEGRATION_METADATA.map((entry) => entry.type)`
3737
])
3838
})
3939

40+
it('treats a namespace used other than by member access as reading every export', () => {
41+
const destructured = `import * as matcher from '@/blocks/integration-matcher'
42+
const { listIntegrations } = matcher`
43+
const indexed = `import * as matcher from '@/blocks/integration-matcher'
44+
export const all = matcher['listIntegrations']()`
45+
const policy = `import * as access from '@/hooks/use-integration-access'
46+
const { useIntegrationAccessPolicy } = access`
47+
expect(check({ 'app/a.ts': destructured, 'app/b.ts': indexed, 'app/c.ts': policy })).toEqual([
48+
expect.objectContaining({
49+
path: 'app/a.ts',
50+
names: ['listIntegrations', 'listIntegrationsByPopularity'],
51+
}),
52+
expect.objectContaining({
53+
path: 'app/b.ts',
54+
names: ['listIntegrations', 'listIntegrationsByPopularity'],
55+
}),
56+
expect.objectContaining({ path: 'app/c.ts', names: ['useIntegrationAccessPolicy'] }),
57+
])
58+
})
59+
4060
it('flags a raw listing that imports the gate without calling it', () => {
4161
const source = `import { useIntegrationAccess } from '@/hooks/use-integration-access'
4262
import { listIntegrations } from '@/blocks/integration-matcher'`

‎scripts/check-integration-access.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,24 @@ const NAMESPACE_IMPORT = /import\s+\*\s+as\s+([\w$]+)\s+from\s*['"]([^'"]+)['"]/
7575
/** One export of a module, and the expression the importing module reaches it through. */
7676
interface ExportReference {
7777
exported: string
78-
/** The local binding, or `namespace.member` for a read through a namespace import. */
79-
local: string
78+
/**
79+
* The local binding, or `namespace.member` for a member read; absent when a
80+
* namespace escapes member access, where the read cannot be traced.
81+
*/
82+
local?: string
8083
}
8184

82-
/** The exports of `from` a module reaches as values, through named or namespace imports. */
83-
function exportReferences(source: string, from: string): ExportReference[] {
85+
/**
86+
* The exports of `from` a module reaches as values, through named or namespace
87+
* imports. A namespace used other than as `namespace.member` — destructured,
88+
* indexed, or passed along — is taken to read every one of `exports`, so an
89+
* untraceable read is never mistaken for none.
90+
*/
91+
function exportReferences(
92+
source: string,
93+
from: string,
94+
exports: readonly string[]
95+
): ExportReference[] {
8496
const references: ExportReference[] = []
8597
for (const [, typeOnly, specifiers, module] of source.matchAll(NAMED_IMPORT)) {
8698
if (module !== from || typeOnly) continue
@@ -89,25 +101,30 @@ function exportReferences(source: string, from: string): ExportReference[] {
89101
if (exported && !exported.startsWith('type ')) references.push({ exported, local })
90102
}
91103
}
92-
for (const [, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) {
104+
for (const [declaration, namespace, module] of source.matchAll(NAMESPACE_IMPORT)) {
93105
if (module !== from) continue
94-
const memberRead = new RegExp(`(?<![\\w$.])${escapeRegExp(namespace)}\\.([\\w$]+)`, 'g')
95-
for (const [local, exported] of source.matchAll(memberRead)) {
106+
const binding = `(?<![\\w$.])${escapeRegExp(namespace)}(?![\\w$])`
107+
for (const [local, exported] of source.matchAll(new RegExp(`${binding}\\.([\\w$]+)`, 'g'))) {
96108
references.push({ exported, local })
97109
}
110+
const body = source.replace(declaration, '')
111+
if (new RegExp(`${binding}(?!\\s*\\.[\\w$])`).test(body)) {
112+
references.push(...exports.map((exported) => ({ exported })))
113+
}
98114
}
99115
return references
100116
}
101117

102-
function isCalled(source: string, local: string): boolean {
118+
function isCalled(source: string, local: string | undefined): boolean {
119+
if (local === undefined) return false
103120
return new RegExp(`(?<![\\w$.])${escapeRegExp(local)}\\s*\\(`).test(source)
104121
}
105122

106123
/** The raw catalog exports a module reads. */
107124
function rawCatalogReads(source: string): string[] {
108125
const names = new Set<string>()
109126
for (const [module, catalog] of Object.entries(RAW_CATALOGS)) {
110-
for (const { exported } of exportReferences(source, module)) {
127+
for (const { exported } of exportReferences(source, module, catalog)) {
111128
if (catalog.includes(exported)) names.add(exported)
112129
}
113130
}
@@ -127,7 +144,7 @@ export function findIntegrationAccessViolations(
127144
const violations: IntegrationAccessViolation[] = []
128145
const usedExemptions = new Set<string>()
129146
for (const { path, source } of files) {
130-
const gateReferences = exportReferences(source, GATE_MODULE)
147+
const gateReferences = exportReferences(source, GATE_MODULE, [GATE_NAME, FAIL_OPEN_NAME])
131148
const readsFailOpen = gateReferences.some(({ exported }) => exported === FAIL_OPEN_NAME)
132149
if (readsFailOpen && path !== FAIL_OPEN_READER) {
133150
violations.push({ path, names: [FAIL_OPEN_NAME], stale: false })

0 commit comments

Comments
 (0)