Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .github/scripts/change-scope.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const {
isComponentSpecRecordPath,
isLegacyComponentSpecPath,
isPackageArchitectureRecordPath,
isPlatformComponentSpecPath,
} = require('./knowledge-paths.cjs');

Expand Down Expand Up @@ -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))
);
}
Expand Down
12 changes: 12 additions & 0 deletions .github/scripts/change-scope.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down
38 changes: 38 additions & 0 deletions .github/scripts/knowledge-paths.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,42 @@ function isPlatformComponentSpecPath(filePath) {
return classifyPlatformComponentSpecPath(filePath) !== null;
}

/**
* A package's own architecture record:
* `packages/<package>/specs/…/<slug>.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:
* `<source root>/<Module>/<ComponentRoot>/…/<file>`. Returns the language,
Expand Down Expand Up @@ -349,11 +385,13 @@ module.exports = {
PLATFORM_SOURCE_ROOTS,
PLATFORM_SPEC_LANGUAGES,
classifyComponentKnowledgePath,
classifyPackageArchitectureRecordPath,
classifyPlatformComponentSpecPath,
componentSpecPathForSource,
isComponentSpecRecordPath,
isIgnoredComponentKnowledgeSegment,
isLegacyComponentSpecPath,
isPackageArchitectureRecordPath,
isPlatformComponentSpecPath,
locateComponentKnowledgePath,
locatePlatformComponentSource,
Expand Down
29 changes: 29 additions & 0 deletions .github/scripts/knowledge-paths.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const {
componentSpecPathForSource,
isComponentSpecRecordPath,
isLegacyComponentSpecPath,
isPackageArchitectureRecordPath,
classifyPackageArchitectureRecordPath,
isPlatformComponentSpecPath,
locateComponentKnowledgePath,
locatePlatformComponentSource,
Expand Down Expand Up @@ -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',
Expand Down
43 changes: 43 additions & 0 deletions .github/scripts/spec-owner-reconcile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
21 changes: 14 additions & 7 deletions docs/architecture/knowledge-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ComponentRoot>/`, 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/<package>/specs/…/<slug>.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 `<slug>.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/<ComponentRoot>/<PublicName>.<language>.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
Expand Down
40 changes: 39 additions & 1 deletion scripts/check-knowledge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const {
const {
COMPONENT_SPEC_ROOT,
PLATFORM_SOURCE_ROOTS,
classifyPackageArchitectureRecordPath,
classifyPlatformComponentSpecPath,
componentSpecPathForSource,
isComponentSpecRecordPath,
Expand Down Expand Up @@ -538,6 +539,7 @@ function isKnowledgeRecordPath(filePath) {
filePath,
);
if (themeMatch) return themeMatch[1] === themeMatch[2];
if (classifyPackageArchitectureRecordPath(filePath)) return true;
return isComponentSpecRecordPath(filePath);
}

Expand Down Expand Up @@ -943,6 +945,25 @@ export function discoverThemeRecordCandidates(root = DEFAULT_ROOT) {
return {records: records.sort(), problems};
}

/**
* Every package's own architecture records:
* `packages/<package>/specs/…/<slug>.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 = [];

Expand Down Expand Up @@ -971,6 +992,7 @@ export function discoverKnowledgeRecords(root = DEFAULT_ROOT) {
);

records.push(...discoverThemeRecordCandidates(root).records);
records.push(...discoverPackageArchitectureRecords(root));

records.push(
...matchingFilesRecursively(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
41 changes: 41 additions & 0 deletions scripts/check-knowledge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,47 @@ describe('knowledge validation', () => {
);
});

it('checks a package architecture record under packages/<package>/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:<surface>', `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 `<slug>.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');
Expand Down
Loading