Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions config/focus-setting-read-baseline.txt

Large diffs are not rendered by default.

178 changes: 133 additions & 45 deletions config/scripts/check-owner-routing-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,99 @@ import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'

// Ratchet for renderer code that routes host-owned work by the "Active Server" focus setting
// instead of by the resource's own host. The three helpers are counted together so renaming one
// into another never lowers the count. Per-file counts may only go down.
// Two ratchets for renderer code that routes host-owned work by the "Active Server" setting
// instead of by the resource's own host. Per-file counts may only go down.
// - Owner routing: the three focus-routing helpers, counted together so renaming one into another
// never lowers the count.
// - Focus reads: every read of the setting, including helpers that read it for the caller
// (`defaultCreationHost`, `getSettingsFocusedExecutionHostId`, …), so swapping one form for
// another never lowers it either.

const BASELINE_PATH = 'config/owner-routing-baseline.txt'
const SCAN_ROOT = 'src/renderer/src'
const HELPERS = '(?:getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner)'
const HELPER_NAMES = 'getActiveRuntimeTarget|legacyRouteFromSettings|settingsForRuntimeOwner'
const HELPERS = `(?:${HELPER_NAMES})`
const IMPORT_EXPORT_LIST = /\b(?:import|export)\s+(?:type\s+)?\{[^}]*\}/g
// Calls and value uses (`.map(helper)`); definitions and type queries are not routing.
const FOCUS_ROUTING_USE = new RegExp(`(?<!(?:function|typeof)\\s+)\\b${HELPERS}\\b`, 'g')
const FOCUS_ROUTING_ALIAS = new RegExp(`\\b${HELPERS}\\s+as\\b`)
// Helpers that read the setting on the caller's behalf, counted as reads by the focus ratchet.
const FOCUS_READER_NAMES = new Set([
...HELPER_NAMES.split('|'),
'defaultCreationHost',
'getSettingsFocusedExecutionHostId',
'getSingleFocusedRuntimeEnvironmentId'
])
const FOCUS_ROUTING_ALIAS = new RegExp(`\\b(?:${[...FOCUS_READER_NAMES].join('|')})\\s+as\\b`)

/** Imports and re-exports are not uses; an aliased one is reported by {@link hasFocusRoutingAlias}. */
export function countFocusRoutingCalls(sourceText) {
return sourceText.replace(IMPORT_EXPORT_LIST, '').match(FOCUS_ROUTING_USE)?.length ?? 0
}

const FOCUS_READER_USE = new RegExp(
`(?<!(?:function|typeof)\\s+)\\b(?:${[...FOCUS_READER_NAMES].join('|')})\\b`,
'g'
)
// Element reads after a PascalCase name or `>` are indexed types (`GlobalSettings['…']`), not reads.
const SETTING_MEMBER_READ =
/\??\.\s*activeRuntimeEnvironmentId\b|(?<!(?:\b[A-Z][\w$]*|>)\s*)\[\s*['"]activeRuntimeEnvironmentId['"]\s*\]/g
Comment on lines +30 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Exclude simple property assignments from the read count.

If code adds settings.activeRuntimeEnvironmentId = id, SETTING_MEMBER_READ counts the assignment as a read. The value of the existing property is not read. The new count can then fail checkRatchet for a setting write. Exclude assignment-only targets, but keep read-modify-write operations such as += in the count. Add a direct-assignment case to the write exclusions test.

// `const { activeRuntimeEnvironmentId } = s` or `{ activeRuntimeEnvironmentId: id }: T = s`;
// object literals and `({ … }) =>` parameters are not reads of the setting.
const SETTING_DESTRUCTURE_READ =
/\{[^{}]*\bactiveRuntimeEnvironmentId\b[^{}]*\}\s*(?::[^=;{}]*)?=(?![=>])/g

/**
* Reads of the setting (member, element and destructuring reads) plus every helper that reads it
* on the caller's behalf. Object-literal keys are writes and are not counted.
*/
export function countFocusSettingReads(sourceText) {
const body = sourceText.replace(IMPORT_EXPORT_LIST, '')
return (
(body.match(SETTING_MEMBER_READ)?.length ?? 0) +
(body.match(SETTING_DESTRUCTURE_READ)?.length ?? 0) +
(body.match(FOCUS_READER_USE)?.length ?? 0)
)
}

/** An alias hides later calls from the count, so it is refused outright. */
export function hasFocusRoutingAlias(sourceText) {
return (sourceText.match(IMPORT_EXPORT_LIST) ?? []).some((list) => FOCUS_ROUTING_ALIAS.test(list))
}

export const RATCHETS = [
{
name: 'owner-routing',
baselinePath: 'config/owner-routing-baseline.txt',
count: countFocusRoutingCalls,
header: [
'# Renderer call sites that route by the Active Server focus setting:',
'# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.',
'# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead.',
'# Prune after removing sites: pnpm check:owner-routing-ratchet --prune'
],
advice:
"Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting."
},
{
name: 'focus-setting-read',
baselinePath: 'config/focus-setting-read-baseline.txt',
count: countFocusSettingReads,
header: [
'# Renderer reads of the Active Server setting (member, element and destructuring reads of',
'# activeRuntimeEnvironmentId) plus helpers that read it for the caller, per file.',
'# This is a RATCHET: counts may only go DOWN. Only creation flows with no source row may read',
'# the default host, through defaultCreationHost. Everything else routes by the owner.',
'# Prune after removing reads: pnpm check:owner-routing-ratchet --prune'
],
advice:
"Route by the resource's owner. Only a creation flow with no source row may use defaultCreationHost."
}
]

export function isScannedPath(rel) {
return /\.(ts|tsx)$/.test(rel) && !/\.(test|spec)\.tsx?$/.test(rel)
}

/** `<count> <path>` lines; `#` comments and blanks ignored. */
/** `<count> <path> [# note]` lines; `#` comment lines and blanks ignored. */
export function parseBaseline(text) {
const counts = new Map()
for (const raw of text.split('\n')) {
Expand All @@ -44,19 +110,26 @@ export function parseBaseline(text) {
return counts
}

export function formatBaseline(counts) {
const header = [
'# Renderer call sites that route by the Active Server focus setting:',
'# getActiveRuntimeTarget( + legacyRouteFromSettings( + settingsForRuntimeOwner(, per file.',
'# This is a RATCHET: counts may only go DOWN. Route new work by the resource owner instead.',
'# Prune after removing sites: pnpm check:owner-routing-ratchet --prune',
''
].join('\n')
/** Trailing `# note` per row, so a pruned baseline keeps why an entry is still allowed. */
export function parseBaselineNotes(text) {
const notes = new Map()
for (const raw of text.split('\n')) {
const match = /^\s*\d+\s+(\S+)\s+#\s*(.+?)\s*$/.exec(raw)
if (match) {
notes.set(match[1], match[2])
}
}
return notes
}

export function formatBaseline(counts, header = RATCHETS[0].header, notes = new Map()) {
const rows = [...counts]
.filter(([, count]) => count > 0)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([file, count]) => `${count} ${file}`)
return `${header}${rows.join('\n')}\n`
.map(([file, count]) =>
notes.has(file) ? `${count} ${file} # ${notes.get(file)}` : `${count} ${file}`
)
return `${[...header, ''].join('\n')}${rows.join('\n')}${rows.length > 0 ? '\n' : ''}`
}

/** `grown`: above baseline (fails). `shrunk`: below baseline, must be pruned so it cannot regrow. */
Expand All @@ -76,7 +149,7 @@ export function diffCounts(current, baseline) {
return { grown: grown.sort(byFile), shrunk: shrunk.sort(byFile) }
}

export function collectCurrentCounts(root = process.cwd()) {
export function collectCurrentCounts(root = process.cwd(), count = countFocusRoutingCalls) {
const tracked = execFileSync('git', ['ls-files', SCAN_ROOT], {
cwd: root,
encoding: 'utf8',
Expand All @@ -93,9 +166,9 @@ export function collectCurrentCounts(root = process.cwd()) {
} catch {
continue
}
const count = countFocusRoutingCalls(source)
if (count > 0) {
counts.set(rel, count)
const found = count(source)
if (found > 0) {
counts.set(rel, found)
}
if (hasFocusRoutingAlias(source)) {
aliased.push(rel)
Expand All @@ -108,50 +181,65 @@ function total(counts) {
return [...counts.values()].reduce((sum, count) => sum + count, 0)
}

export function main(root = process.cwd()) {
const baselineFile = path.join(root, BASELINE_PATH)
function checkRatchet(root, ratchet) {
const baselineFile = path.join(root, ratchet.baselinePath)
if (!fs.existsSync(baselineFile)) {
console.error(`::error::Missing ${BASELINE_PATH}.`)
return 1
console.error(`::error::Missing ${ratchet.baselinePath}.`)
return { ok: false, total: 0 }
}
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const { counts: current, aliased } = collectCurrentCounts(root)
const { counts: current } = collectCurrentCounts(root, ratchet.count)
const { grown, shrunk } = diffCounts(current, baseline)
for (const file of aliased) {
for (const { file, now, allowed } of grown) {
console.error(
`::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.`
`::error file=${file}::${ratchet.name}: ${now} use(s), baseline allows ${allowed}. ${ratchet.advice}`
)
}
for (const { file, now, allowed } of grown) {
for (const { file, now, allowed } of shrunk) {
console.error(
`::error file=${file}::${now} focus-routed call(s), baseline allows ${allowed}. Route by the resource's owner (resolveOwner + callHostRoute) instead of the Active Server setting.`
`::error file=${file}::${ratchet.name}: ${now} use(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune`
)
}
for (const { file, now, allowed } of shrunk) {
return { ok: grown.length === 0 && shrunk.length === 0, total: total(current) }
}

export function main(root = process.cwd()) {
const { aliased } = collectCurrentCounts(root)
for (const file of aliased) {
console.error(
`::error file=${file}::${now} focus-routed call(s), baseline still allows ${allowed}. Run: pnpm check:owner-routing-ratchet --prune`
`::error file=${file}::A focus-routing helper is imported or exported under another name, which hides its calls from this ratchet. Use the original name.`
)
}
if (aliased.length > 0 || grown.length > 0 || shrunk.length > 0) {
return 1
let ok = aliased.length === 0
for (const ratchet of RATCHETS) {
const result = checkRatchet(root, ratchet)
ok = ok && result.ok
if (result.ok) {
console.log(`${ratchet.name} ratchet OK — ${result.total} use(s).`)
}
}
console.log(`Owner-routing ratchet OK — ${total(current)} focus-routed call site(s).`)
return 0
return ok ? 0 : 1
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
if (process.argv[2] === '--prune') {
const baselineFile = path.join(root, BASELINE_PATH)
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
const { counts: current } = collectCurrentCounts(root)
export function prune(root = process.cwd()) {
for (const ratchet of RATCHETS) {
const baselineFile = path.join(root, ratchet.baselinePath)
const text = fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : ''
const baseline = parseBaseline(text)
const { counts: current } = collectCurrentCounts(root, ratchet.count)
// Lowers entries only; growth still has to be fixed in the code.
const pruned = new Map(
[...baseline].map(([file, allowed]) => [file, Math.min(allowed, current.get(file) ?? 0)])
)
fs.writeFileSync(baselineFile, formatBaseline(pruned))
console.log(`Pruned ${BASELINE_PATH} to ${total(pruned)} call site(s).`)
process.exit(main(root))
fs.writeFileSync(baselineFile, formatBaseline(pruned, ratchet.header, parseBaselineNotes(text)))
console.log(`Pruned ${ratchet.baselinePath} to ${total(pruned)} use(s).`)
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = process.cwd()
if (process.argv[2] === '--prune') {
prune(root)
}
process.exit(main(root))
}
64 changes: 63 additions & 1 deletion config/scripts/check-owner-routing-ratchet.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest'

import {
countFocusRoutingCalls,
countFocusSettingReads,
diffCounts,
formatBaseline,
hasFocusRoutingAlias,
isScannedPath,
parseBaseline
parseBaseline,
parseBaselineNotes
} from './check-owner-routing-ratchet.mjs'

describe('countFocusRoutingCalls', () => {
Expand Down Expand Up @@ -39,6 +41,51 @@ describe('countFocusRoutingCalls', () => {
})
})

describe('countFocusSettingReads', () => {
it('counts setting reads, the helpers and the creation default together', () => {
const src = [
"import { defaultCreationHost } from './default-creation-host'",
'const a = settings?.activeRuntimeEnvironmentId',
'const b = state.settings.activeRuntimeEnvironmentId',
"const c = settings['activeRuntimeEnvironmentId']",
'const d = getActiveRuntimeTarget(settings)',
'const e = defaultCreationHost(settings)',
'export function defaultCreationHost(settings) {}'
].join('\n')
expect(countFocusSettingReads(src)).toBe(5)
})

it('counts a shared focus helper, so wrapping one in an owner transport lowers nothing', () => {
expect(
countFocusSettingReads(
'const t = runtimeTargetForOwnerHostId(getSettingsFocusedExecutionHostId(s))'
)
).toBe(1)
expect(countFocusSettingReads('const id = getSingleFocusedRuntimeEnvironmentId(state)')).toBe(1)
})

it('counts destructuring reads', () => {
expect(countFocusSettingReads('const { activeRuntimeEnvironmentId } = s')).toBe(1)
expect(
countFocusSettingReads(
"const { theme, activeRuntimeEnvironmentId: id }: Pick<GlobalSettings, 'theme'> = s"
)
).toBe(1)
})

it('does not count writes, keys, props or type positions', () => {
const src = [
'const owner = { activeRuntimeEnvironmentId: id }',
'settings = { ...settings, activeRuntimeEnvironmentId: null }',
"type T = GlobalSettings['activeRuntimeEnvironmentId']",
'updateSettings({ activeRuntimeEnvironmentId: null })',
'const f = ({ activeRuntimeEnvironmentId }) => activeRuntimeEnvironmentId',
'if (a === b) { x = { activeRuntimeEnvironmentId } }'
].join('\n')
expect(countFocusSettingReads(src)).toBe(0)
})
})

describe('hasFocusRoutingAlias', () => {
it('refuses an aliased import or re-export, which would hide its calls', () => {
expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget as route } from './rpc'")).toBe(
Expand All @@ -47,6 +94,10 @@ describe('hasFocusRoutingAlias', () => {
expect(
hasFocusRoutingAlias("export {\n settingsForRuntimeOwner as owner\n} from './target'")
).toBe(true)
expect(hasFocusRoutingAlias("import { defaultCreationHost as host } from './d'")).toBe(true)
expect(
hasFocusRoutingAlias("import { getSettingsFocusedExecutionHostId as h } from './e'")
).toBe(true)
expect(hasFocusRoutingAlias("import { getActiveRuntimeTarget } from './rpc'")).toBe(false)
})
})
Expand Down Expand Up @@ -75,6 +126,17 @@ describe('baseline', () => {
)
})

it('keeps a row note through a prune', () => {
const text = formatBaseline(
new Map([['src/a.ts', 1]]),
['# header'],
new Map([['src/a.ts', 'waits for V4b']])
)
expect(text).toBe('# header\n1 src/a.ts # waits for V4b\n')
expect(parseBaseline(text)).toEqual(new Map([['src/a.ts', 1]]))
expect(parseBaselineNotes(text)).toEqual(new Map([['src/a.ts', 'waits for V4b']]))
})

it('fails growth, including a new file, and asks to prune shrinkage', () => {
const { grown, shrunk } = diffCounts(
new Map([
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/src/lib/default-creation-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { defaultCreationHost } from './default-creation-host'

describe('defaultCreationHost', () => {
it('is this computer unless a server is chosen', () => {
expect(defaultCreationHost(null)).toEqual({ kind: 'local' })
expect(defaultCreationHost({ activeRuntimeEnvironmentId: null })).toEqual({ kind: 'local' })
expect(defaultCreationHost({ activeRuntimeEnvironmentId: ' env-a ' })).toEqual({
kind: 'environment',
environmentId: 'env-a'
})
})
})
13 changes: 13 additions & 0 deletions src/renderer/src/lib/default-creation-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'

/**
* The "Default host for new projects" setting. Only a creation flow with no source row reads it;
* everything else routes by the resource's owner.
*/
export function defaultCreationHost(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): RuntimeClientTarget {
const environmentId = settings?.activeRuntimeEnvironmentId?.trim()
return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }
}
Loading
Loading