diff --git a/docs/reference/repository-health.md b/docs/reference/repository-health.md index 9ebb08770..e7a7ec895 100644 --- a/docs/reference/repository-health.md +++ b/docs/reference/repository-health.md @@ -2,9 +2,9 @@ id: repository-health title: 'Repository Health Controls' kind: reference -version: '1.4.0' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +version: '1.4.1' +last_updated: '2026-08-04' +last_verified: '2026-08-04' review_cadence_days: 30 status: stable tags: [reference, governance, quality, security, releases] @@ -37,6 +37,11 @@ The profile definitions in the same file describe the scripts and package metadata required for Node, browser, CLI, React Native, WASM, private, documentation, and conformance projects. Profiles standardize the externally observable contract without requiring every package to use the same build tool. +Packages that compile authored files outside their own workspace directory +declare those paths as `sourceRoots`; CI treats a change beneath any declared +root as a direct package change, including its dedicated browser or mobile +consumer gates. + For every public package, the control also enforces the exact supported Node.js 22 runtime floor, explicit public npm access, and an explicit tree-shaking side-effect declaration. diff --git a/governance/browser-artifact-policy.json b/governance/browser-artifact-policy.json index 2cd7c75f0..9ee419686 100644 --- a/governance/browser-artifact-policy.json +++ b/governance/browser-artifact-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-07-30", + "lastReviewed": "2026-08-04", "owner": "ts-stack-maintainers", "reportRetentionDays": 30, "growthPolicy": "Every browser consumer is measured from its exact packed dependency graph with Vite and esbuild (or the governed platform equivalent). A budget increase requires a versioned source change, composition evidence, and explicit review; generated reports preserve package/module composition for comparison.", diff --git a/governance/repository-health/projects.json b/governance/repository-health/projects.json index b9d3703eb..370b08ebf 100644 --- a/governance/repository-health/projects.json +++ b/governance/repository-health/projects.json @@ -726,6 +726,7 @@ { "path": "packages/wallet/wallet-toolbox/client", "name": "@bsv/wallet-toolbox-client", + "sourceRoots": ["packages/wallet/wallet-toolbox/src"], "owner": "ts-stack-maintainers", "area": "wallet", "profile": "browser-library", @@ -737,6 +738,7 @@ { "path": "packages/wallet/wallet-toolbox/mobile", "name": "@bsv/wallet-toolbox-mobile", + "sourceRoots": ["packages/wallet/wallet-toolbox/src"], "owner": "ts-stack-maintainers", "area": "wallet", "profile": "react-native-library", diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index 4e499311b..28454b298 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,7 +2,7 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1525000, + "raw": 1527000, "gzip": 360000, "brotli": 283000 }, diff --git a/scripts/ci-affected-scope.mjs b/scripts/ci-affected-scope.mjs index a6944ffd2..277039507 100644 --- a/scripts/ci-affected-scope.mjs +++ b/scripts/ci-affected-scope.mjs @@ -112,8 +112,9 @@ export function changedLockfileImporters(baseSource, headSource) { .sort((left, right) => left.localeCompare(right)) } -function projectOwnsFile(projectPath, file) { - return projectPath !== '.' && (file === projectPath || file.startsWith(`${projectPath}/`)) +function projectOwnsFile(project, file) { + const roots = [project.path, ...(project.sourceRoots ?? [])] + return roots.some(root => root !== '.' && (file === root || file.startsWith(`${root}/`))) } function documentationOnlyProjectFile(projectPath, file) { @@ -165,7 +166,7 @@ export function selectWorkspaceScope(projects, changedFiles, changedImporters = if ( files.some( file => - projectOwnsFile(project.path, file) && !documentationOnlyProjectFile(project.path, file) + projectOwnsFile(project, file) && !documentationOnlyProjectFile(project.path, file) ) || changedImporters.includes(project.path) ) { @@ -275,7 +276,13 @@ function loadProjects() { const manifest = JSON.parse( readFileSync(path.join(REPOSITORY_ROOT, project.path, 'package.json'), 'utf8') ) - return { name: manifest.name, path: normalized(project.path), manifest } + return { + ...project, + name: manifest.name, + path: normalized(project.path), + sourceRoots: (project.sourceRoots ?? []).map(normalized), + manifest + } }) } diff --git a/scripts/ci-affected-scope.test.mjs b/scripts/ci-affected-scope.test.mjs index 619b8212a..2359a3bd3 100644 --- a/scripts/ci-affected-scope.test.mjs +++ b/scripts/ci-affected-scope.test.mjs @@ -53,6 +53,28 @@ test('workspace scope tests direct changes, typechecks dependents, and builds de assert.deepEqual(scope.build, ['@bsv/base', '@bsv/consumer', '@bsv/direct']) }) +test('workspace scope directly selects packages that compile a shared source root', () => { + const sharedProjects = [ + ...projects, + { + name: '@bsv/direct-browser', + path: 'packages/direct/browser', + sourceRoots: ['packages/direct/src'], + manifest: { name: '@bsv/direct-browser' } + }, + { + name: '@bsv/direct-mobile', + path: 'packages/direct/mobile', + sourceRoots: ['packages/direct/src'], + manifest: { name: '@bsv/direct-mobile' } + } + ] + + const scope = selectWorkspaceScope(sharedProjects, ['packages/direct/src/index.ts']) + + assert.deepEqual(scope.direct, ['@bsv/direct', '@bsv/direct-browser', '@bsv/direct-mobile']) +}) + test('documentation and QA policy changes do not fan out package tests', () => { assert.deepEqual( selectWorkspaceScope(projects, [ diff --git a/scripts/repository-health.mjs b/scripts/repository-health.mjs index c8c5a5f97..f2f7ba63b 100644 --- a/scripts/repository-health.mjs +++ b/scripts/repository-health.mjs @@ -297,6 +297,39 @@ function validateHostPeerDependencies(project, prefix) { return errors } +function validateSourceRoots(project, prefix) { + if (project.sourceRoots === undefined) return [] + const roots = project.sourceRoots + if (!Array.isArray(roots) || roots.length === 0) { + return [`${prefix} sourceRoots must be a non-empty array`] + } + + const errors = [] + for (const root of roots) { + const parts = typeof root === 'string' ? root.split('/') : [] + if ( + !isNonEmptyString(root) || + root.startsWith('/') || + root.includes('\\') || + parts.some(part => part === '' || part === '.' || part === '..') + ) { + errors.push(`${prefix} has invalid source root ${JSON.stringify(root)}`) + continue + } + if (root === project.path || root.startsWith(`${project.path}/`)) { + errors.push(`${prefix} source root ${root} is already owned by the project path`) + } + } + for (const duplicate of duplicateValues(roots)) { + errors.push(`${prefix} repeats source root ${duplicate}`) + } + const canonical = [...roots].sort((left, right) => left.localeCompare(right)) + if (JSON.stringify(roots) !== JSON.stringify(canonical)) { + errors.push(`${prefix} sourceRoots must use canonical lexical order`) + } + return errors +} + function validateConsumerProfiles(project, prefix) { const profiles = project.consumerProfiles if (project.release !== 'npm-oidc') { @@ -352,6 +385,7 @@ function validateProjectMetadata(project, registry) { errors.push( ...validateDeclarationDependencies(project, prefix), ...validateHostPeerDependencies(project, prefix), + ...validateSourceRoots(project, prefix), ...validateConsumerProfiles(project, prefix) ) return errors diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index a7a0e3b2d..78c627b06 100644 --- a/scripts/repository-health.test.mjs +++ b/scripts/repository-health.test.mjs @@ -70,6 +70,26 @@ test('current repository health controls and ratchet are internally consistent', assert.equal(result.findings.length, 0) }) +test('shared package source roots are explicit and repository-relative', () => { + const sharedSourceProjects = projects.projects.filter(project => project.sourceRoots) + assert.deepEqual( + sharedSourceProjects.map(project => [project.name, project.sourceRoots]), + [ + ['@bsv/wallet-toolbox-client', ['packages/wallet/wallet-toolbox/src']], + ['@bsv/wallet-toolbox-mobile', ['packages/wallet/wallet-toolbox/src']] + ] + ) + + const invalidProjects = structuredClone(projects) + invalidProjects.projects.find( + project => project.name === '@bsv/wallet-toolbox-client' + ).sourceRoots = ['../wallet-toolbox/src'] + assert.match( + validateProjectRegistry(invalidProjects, discoverWorkspaceProjects()).join('\n'), + /invalid source root/ + ) +}) + test('CI performance baseline retains representative full and targeted cohorts', () => { const baseline = readJson(path.join(REPOSITORY_ROOT, 'governance/ci-performance-baseline.json')) assert.deepEqual(validateCiPerformanceBaseline(baseline), [])