diff --git a/.github/scripts/change-scope.cjs b/.github/scripts/change-scope.cjs index 3148fd31cf8eb..b353014d2f59d 100644 --- a/.github/scripts/change-scope.cjs +++ b/.github/scripts/change-scope.cjs @@ -7,6 +7,7 @@ const { isComponentSpecRecordPath, isLegacyComponentSpecPath, + isPackageArchitectureRecordPath, isPlatformComponentSpecPath, } = require('./knowledge-paths.cjs'); @@ -143,6 +144,8 @@ function isKnowledgeRecordPath(filePath) { isSpecRecordPath(filePath) || // Not a record and never spec-only, but the owner gate must still see it. isLegacyComponentSpecPath(filePath) || + // A package's architecture record, owner-gated like docs/architecture/. + isPackageArchitectureRecordPath(filePath) || KNOWLEDGE_RECORD_PATTERNS.some(pattern => pattern.test(filePath)) ); } diff --git a/.github/scripts/change-scope.test.mjs b/.github/scripts/change-scope.test.mjs index e503e35dc8566..c150dd81f882b 100644 --- a/.github/scripts/change-scope.test.mjs +++ b/.github/scripts/change-scope.test.mjs @@ -65,6 +65,18 @@ describe('spec-only change scope', () => { } }); + it('owner-gates a package architecture record like docs/architecture/', () => { + const result = classifyChanges([ + {filename: 'packages/native/specs/swift/slots.spec.md'}, + ]); + expect(result.touchesKnowledgeRecords).toBe(true); + expect(result.specOnly).toBe(false); + expect( + classifyChanges([{filename: 'packages/native/specs/README.md'}]) + .touchesKnowledgeRecords, + ).toBe(false); + }); + it('owner-gates architecture records without granting the spec-only fast path', () => { const result = classifyChanges([ {filename: 'docs/architecture/knowledge-contracts.md'}, diff --git a/.github/scripts/knowledge-paths.cjs b/.github/scripts/knowledge-paths.cjs index 563795c804d77..5b37061f85acd 100644 --- a/.github/scripts/knowledge-paths.cjs +++ b/.github/scripts/knowledge-paths.cjs @@ -158,6 +158,42 @@ function isPlatformComponentSpecPath(filePath) { return classifyPlatformComponentSpecPath(filePath) !== null; } +/** + * A package's own architecture record: + * `packages//specs/…/.spec.md`. Its id is `architecture:` + * followed by the package name, each directory between `specs/` and the file, + * and the slug, joined by hyphens + * (`packages/native/specs/swift/accessibility.spec.md` is + * `architecture:native-swift-accessibility`). Null for any other path. + */ +function classifyPackageArchitectureRecordPath(filePath) { + const match = /^packages\/([^/]+)\/specs\/(.+)$/.exec( + normalizePath(filePath), + ); + if (!match) return null; + const segments = match[2].split('/'); + const fileName = segments.pop(); + if ( + !/^[a-z0-9][a-z0-9-]*\.spec\.md$/.test(fileName) || + fileName.endsWith('.generated.spec.md') || + segments.some( + segment => + segment.startsWith('.') || isIgnoredComponentKnowledgeSegment(segment), + ) + ) { + return null; + } + const slug = fileName.slice(0, -'.spec.md'.length); + return { + packageName: match[1], + id: `architecture:${[match[1], ...segments, slug].join('-')}`, + }; +} + +function isPackageArchitectureRecordPath(filePath) { + return classifyPackageArchitectureRecordPath(filePath) !== null; +} + /** * A platform language's component code file: * `///…/`. Returns the language, @@ -349,11 +385,13 @@ module.exports = { PLATFORM_SOURCE_ROOTS, PLATFORM_SPEC_LANGUAGES, classifyComponentKnowledgePath, + classifyPackageArchitectureRecordPath, classifyPlatformComponentSpecPath, componentSpecPathForSource, isComponentSpecRecordPath, isIgnoredComponentKnowledgeSegment, isLegacyComponentSpecPath, + isPackageArchitectureRecordPath, isPlatformComponentSpecPath, locateComponentKnowledgePath, locatePlatformComponentSource, diff --git a/.github/scripts/knowledge-paths.test.mjs b/.github/scripts/knowledge-paths.test.mjs index 19e71545f7376..a9cc948d34603 100644 --- a/.github/scripts/knowledge-paths.test.mjs +++ b/.github/scripts/knowledge-paths.test.mjs @@ -13,6 +13,8 @@ const { componentSpecPathForSource, isComponentSpecRecordPath, isLegacyComponentSpecPath, + isPackageArchitectureRecordPath, + classifyPackageArchitectureRecordPath, isPlatformComponentSpecPath, locateComponentKnowledgePath, locatePlatformComponentSource, @@ -131,6 +133,33 @@ describe('component knowledge paths', () => { } }); + it('derives a package architecture record id from its path', () => { + expect( + classifyPackageArchitectureRecordPath( + 'packages/native/specs/swift/accessibility.spec.md', + ), + ).toEqual({ + packageName: 'native', + id: 'architecture:native-swift-accessibility', + }); + expect( + classifyPackageArchitectureRecordPath( + 'packages/native/specs/swift/library/tooltip.spec.md', + ).id, + ).toBe('architecture:native-swift-library-tooltip'); + for (const filePath of [ + 'packages/native/specs/README.md', + 'packages/native/specs/swift/library/styles.md', + 'packages/native/specs/swift/__fixtures__/x.spec.md', + 'packages/native/specs/swift/x.generated.spec.md', + 'packages/native/specs/swift/Button.spec.md', + 'packages/native/src/specs/x.spec.md', + 'docs/specs/components/Button/Button.spec.md', + ]) { + expect(isPackageArchitectureRecordPath(filePath)).toBe(false); + } + }); + it('recognizes a spec left at a pre-move record location', () => { for (const filePath of [ 'packages/core/src/Button/Button.spec.md', diff --git a/.github/scripts/spec-owner-reconcile.test.mjs b/.github/scripts/spec-owner-reconcile.test.mjs index 8b8a9dd3db339..93dce0312575b 100644 --- a/.github/scripts/spec-owner-reconcile.test.mjs +++ b/.github/scripts/spec-owner-reconcile.test.mjs @@ -609,6 +609,49 @@ describe('spec owner workflow reconciliation', () => { }, ); + it('keeps a package architecture record made current pending without exact-head approval', async () => { + const record = authority => + `---\nkind: architecture\nid: architecture:native-swift-slots\nauthority: ${authority}\n---\n\nA slot is a typed view.\n`; + const harness = createHarness({ + changedFile: { + filename: 'packages/native/specs/swift/slots.spec.md', + status: 'modified', + }, + baseContent: record('draft'), + headContent: record('current'), + }); + + await run(harness, context({runId: 100n, action: 'synchronize'})); + + expect(latestGateStatus(harness.state)).toMatchObject({ + state: 'pending', + description: 'Mirrors review-required · spec waiting (eng)', + }); + expect(harness.state.calls).not.toContain('enable-auto-merge'); + }); + + it('needs no owner approval for an edit to a draft package architecture record', async () => { + const record = body => + `---\nkind: architecture\nid: architecture:native-swift-slots\nauthority: draft\n---\n\n${body}\n`; + const harness = createHarness({ + changedFile: { + filename: 'packages/native/specs/swift/slots.spec.md', + status: 'modified', + }, + baseContent: record('A slot is a view.'), + headContent: record('A slot is a typed view.'), + }); + + await run(harness, context({runId: 100n, action: 'synchronize'})); + + expect(latestReviewGate(harness.state).state).toBe('success'); + expect( + harness.state.calls.some(call => + call.includes('No knowledge records changed'), + ), + ).toBe(false); + }); + it('keeps a Swift platform record made current pending without exact-head approval', async () => { const harness = createHarness({ changedFile: { diff --git a/docs/architecture/knowledge-contracts.md b/docs/architecture/knowledge-contracts.md index 638ac54b5e6aa..f9c05994ea7df 100644 --- a/docs/architecture/knowledge-contracts.md +++ b/docs/architecture/knowledge-contracts.md @@ -1153,19 +1153,26 @@ needs its own spec location. | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | INV22–INV24 | Contributor/reviewer evidence inventory; public-link and byte verification; retained remote refs; unchanged fixture/baseline/media paths | Disposable captures enter the source tree, cleanup loses a referenced image, a temporary branch is called durable, or storage changes overwrite an owner's proof or retention contract | | INV25, INV26, DEC-12 | `scripts/specs-for-file.test.mjs`, `scripts/check-knowledge.test.mjs`, and `pnpm check:knowledge` | A current record is reached by no tracked non-record path, a lookup infers a link from a name, a pattern matches nothing or the whole repository, or a pattern match widens a claim or outranks a narrower owner | -| INV27, DEC-13 | `scripts/check-knowledge.test.mjs` and `pnpm check:knowledge` | A `*.spec.md` file or a Markdown file under a `specs` directory without valid record frontmatter, outside its kind's location, or following a package-local template passes | +| INV27, DEC-13 | `scripts/check-knowledge.test.mjs`, `.github/scripts/change-scope.test.mjs`, and `pnpm check:knowledge` | A `*.spec.md` file or a Markdown file under a `specs` directory without valid record frontmatter, outside its kind's location, or following a package-local template passes | | DEC-15 | `.github/scripts/knowledge-paths.test.mjs`, `.github/scripts/change-scope.test.mjs`, `scripts/check-knowledge.test.mjs`, `scripts/specs-for-file.test.mjs`, and `pnpm check:knowledge` | A component or module record outside `docs/specs/components//`, a `*.spec.md` left in a component package's source tree or beside a Swift view, a record matched to the wrong package when two share a root, a record whose component root no registered package's source tree has, a platform record whose id or `platform_of` does not match its path, or a platform record change that skips the spec-owner gate | Evidence placement is enforced by contributors and reviewers. The local `docs/pr-assets/` ignore prevents accidental staging; it is not a blocking CI check and does not prevent force-added evidence elsewhere. -Current enforcement gap: discovery does not yet read record candidates outside -`docs/` and themes; component records have no `platform_of` field until the -next schema version; and the component id pattern does not yet accept a -language-qualified id such as `component:swift/Button`. Until all three land, -package records under `packages/native/specs/` are checked only by review. -`pnpm check:knowledge` checks a platform record +Discovery reads each package's architecture records +(`packages//specs/…/.spec.md`): `pnpm check:knowledge` checks +them as it checks any architecture record, plus their kind and the id their +path gives, and the spec-owner gate treats them like records in +`docs/architecture/`. + +Current enforcement gap: discovery does not yet read other record candidates +outside `docs/`, themes, and package `specs/` records, such as Markdown under a +`specs` directory that is not a `.spec.md`; component records have no +`platform_of` field until the next schema version; and the component id +pattern does not yet accept a language-qualified id such as +`component:swift/Button`. Until all three land, those other candidates are +checked only by review. `pnpm check:knowledge` checks a platform record (`docs/specs/components//..spec.md`) as it checks a core component record (its folder and public name, schema rules, applicability blocks, and id), accepting its language-qualified id, and also diff --git a/scripts/check-knowledge.mjs b/scripts/check-knowledge.mjs index a55334287766b..427e5092978d4 100644 --- a/scripts/check-knowledge.mjs +++ b/scripts/check-knowledge.mjs @@ -32,6 +32,7 @@ const { const { COMPONENT_SPEC_ROOT, PLATFORM_SOURCE_ROOTS, + classifyPackageArchitectureRecordPath, classifyPlatformComponentSpecPath, componentSpecPathForSource, isComponentSpecRecordPath, @@ -538,6 +539,7 @@ function isKnowledgeRecordPath(filePath) { filePath, ); if (themeMatch) return themeMatch[1] === themeMatch[2]; + if (classifyPackageArchitectureRecordPath(filePath)) return true; return isComponentSpecRecordPath(filePath); } @@ -943,6 +945,25 @@ export function discoverThemeRecordCandidates(root = DEFAULT_ROOT) { return {records: records.sort(), problems}; } +/** + * Every package's own architecture records: + * `packages//specs/…/.spec.md`. + */ +export function discoverPackageArchitectureRecords(root = DEFAULT_ROOT) { + return immediateDirectories(path.join(root, 'packages')) + .flatMap(packageDirectory => + matchingFilesRecursively( + path.join(packageDirectory, 'specs'), + (_name, candidate) => + classifyPackageArchitectureRecordPath( + path.relative(root, candidate).split(path.sep).join('/'), + ) !== null, + {skipDirectory: isIgnoredComponentKnowledgeSegment}, + ), + ) + .sort(); +} + export function discoverKnowledgeRecords(root = DEFAULT_ROOT) { const records = []; @@ -971,6 +992,7 @@ export function discoverKnowledgeRecords(root = DEFAULT_ROOT) { ); records.push(...discoverThemeRecordCandidates(root).records); + records.push(...discoverPackageArchitectureRecords(root)); records.push( ...matchingFilesRecursively( @@ -2297,6 +2319,21 @@ export async function validateKnowledgeRoot(root = DEFAULT_ROOT) { `${filePath}: active ${kind} records must use latest schema_version ${latestKindVersion} for that kind.`, ); } + const packageRecord = classifyPackageArchitectureRecordPath( + filePath.split(path.sep).join('/'), + ); + if (packageRecord) { + if (kind !== 'architecture') { + problems.push( + `${filePath}: a record under packages/${packageRecord.packageName}/specs/ must be kind: architecture.`, + ); + } + if (document.frontmatter.get('id') !== packageRecord.id) { + problems.push( + `${filePath}: id must be ${packageRecord.id} to match its path.`, + ); + } + } if (kind === 'component' || kind === 'module') { const designDecisions = parseDesignDecisionsBlock(content, filePath); problems.push( @@ -2381,8 +2418,9 @@ export async function validateKnowledgeRoot(root = DEFAULT_ROOT) { for (const {filePath, document} of records) { if (document.frontmatter.get('authority') !== 'current') continue; + // An unknown kind is already reported above. const kindSchema = schema.kinds[document.frontmatter.get('kind')]; - for (const field of kindSchema.referenceFields ?? []) { + for (const field of kindSchema?.referenceFields ?? []) { const references = document.frontmatter.get(field); if (!Array.isArray(references)) continue; for (const reference of references) { diff --git a/scripts/check-knowledge.test.mjs b/scripts/check-knowledge.test.mjs index ce8e10d6e18b2..f513122ee5fb8 100644 --- a/scripts/check-knowledge.test.mjs +++ b/scripts/check-knowledge.test.mjs @@ -1867,6 +1867,47 @@ describe('knowledge validation', () => { ); }); + it('checks a package architecture record under packages//specs/', async () => { + const root = fixtureRoot(); + const template = fs.readFileSync( + path.join(root, 'docs/templates/knowledge/architecture.md'), + 'utf8', + ); + const slots = path.join(root, 'packages/native/specs/swift/slots.spec.md'); + const writeSlots = (id, kind = 'architecture') => { + fs.mkdirSync(path.dirname(slots), {recursive: true}); + fs.writeFileSync( + slots, + template + .replace('id: architecture:', `id: ${id}`) + .replace('kind: architecture', `kind: ${kind}`), + ); + }; + const packageProblems = async () => + (await validateKnowledgeRoot(root)).filter(problem => + problem.startsWith('packages/native/specs/'), + ); + // Guidance Markdown that is not a `.spec.md` is not a candidate. + fs.mkdirSync(path.join(root, 'packages/native/specs'), {recursive: true}); + fs.writeFileSync( + path.join(root, 'packages/native/specs/README.md'), + '# Native specs\n', + ); + + writeSlots('architecture:native-swift-slots'); + expect(await packageProblems()).toEqual([]); + + writeSlots('architecture:swift-slots'); + expect(await packageProblems()).toEqual([ + 'packages/native/specs/swift/slots.spec.md: id must be architecture:native-swift-slots to match its path.', + ]); + + writeSlots('architecture:native-swift-slots', 'bogus'); + expect(await packageProblems()).toContain( + 'packages/native/specs/swift/slots.spec.md: a record under packages/native/specs/ must be kind: architecture.', + ); + }); + it('rejects a nested component record instead of treating it as a module', async () => { const root = fixtureRoot(); const componentDirectory = path.join(root, 'packages/core/src/Button');