Skip to content

Commit c6cf3f7

Browse files
feat(protocol): validate requirement-id uniqueness in CI (#2050) (#2098)
Protocol 1.0 ratification requires requirement ids to be unique and validated in CI (#2050), but nothing enforced it — `sync-suite.ts` only checked the vendored suite's file digests against the lock, and the catalog schema has no uniqueness constraint. A duplicate id arriving from the RFC source would go unnoticed. Add `checkCatalog()` to the `protocol:check` path: it loads the vendored `catalog.json`, asserts every requirement has a non-empty string id, and fails on any duplicate. Extracted `duplicateRequirementIds` as a pure, unit-tested helper, and guarded the CLI dispatch behind `import.meta.main` so the module is importable (matching the sibling protocol scripts). Scopes only the "unique + validated in CI" acceptance criterion of #2050; the normative inventory, profile/badge ratification, and whitepaper authoring live in the rfcs/whitepaper repos and remain open.
1 parent 98815a3 commit c6cf3f7

2 files changed

Lines changed: 84 additions & 8 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { readFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import { describe, expect, it } from 'bun:test'
4+
import { duplicateRequirementIds } from './sync-suite'
5+
6+
// Protocol 1.0 ratification requires requirement ids to be unique and validated
7+
// in CI (stacksjs/stacks#2050). `protocol:check` now enforces this on the
8+
// vendored catalog; these cover the pure detector plus a regression guard on
9+
// the real catalog.
10+
describe('protocol requirement-id uniqueness (stacksjs/stacks#2050)', () => {
11+
it('reports no duplicates for a unique list', () => {
12+
expect(duplicateRequirementIds(['CORE-CONV-01', 'CORE-CONV-02', 'CORE-MVA-01'])).toEqual([])
13+
})
14+
15+
it('reports each duplicated id once, sorted', () => {
16+
expect(duplicateRequirementIds(['B-1', 'A-1', 'B-1', 'A-1', 'A-1', 'C-1'])).toEqual(['A-1', 'B-1'])
17+
})
18+
19+
it('handles an empty list', () => {
20+
expect(duplicateRequirementIds([])).toEqual([])
21+
})
22+
23+
it('the vendored catalog.json has unique requirement ids', () => {
24+
const catalog = JSON.parse(readFileSync(resolve(import.meta.dir, '../../protocol/suite/1.0-draft/catalog.json'), 'utf8')) as {
25+
requirements: Array<{ id: string }>
26+
}
27+
const ids = catalog.requirements.map(requirement => requirement.id)
28+
expect(ids.length).toBeGreaterThan(0)
29+
expect(duplicateRequirementIds(ids)).toEqual([])
30+
})
31+
})

scripts/protocol/sync-suite.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,59 @@ function checkSnapshot(): void {
8989
console.log(`Protocol suite matches ${basename(lock.sourceRepository)}@${lock.rfcsRevision} (${actualFiles.length} files)`)
9090
}
9191

92-
if (process.argv.includes('--write')) {
93-
const source = resolve(argument('--source') || process.env.STACKS_RFC_SOURCE || resolve(root, '../rfcs'))
94-
writeSnapshot(source)
92+
/** Requirement ids that appear more than once in `ids` (sorted, deduped). */
93+
export function duplicateRequirementIds(ids: string[]): string[] {
94+
const seen = new Set<string>()
95+
const duplicates = new Set<string>()
96+
for (const id of ids) {
97+
if (seen.has(id))
98+
duplicates.add(id)
99+
else
100+
seen.add(id)
101+
}
102+
return [...duplicates].sort()
95103
}
96-
else if (process.argv.includes('--check')) {
97-
checkSnapshot()
104+
105+
/**
106+
* Validate the vendored requirement catalog: every requirement carries a
107+
* non-empty string id and those ids are unique. Protocol 1.0 ratification
108+
* requires requirement ids to be unique and validated in CI
109+
* (stacksjs/stacks#2050); the vendored `catalog.json` had no such check, so a
110+
* duplicate id sneaking in from the RFC source would go unnoticed.
111+
*/
112+
function checkCatalog(): void {
113+
const catalogPath = resolve(suiteRoot, 'catalog.json')
114+
if (!existsSync(catalogPath))
115+
throw new Error(`protocol catalog missing at ${relative(root, catalogPath)}; run bun run protocol:sync`)
116+
117+
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8')) as { requirements?: Array<{ id?: unknown }> }
118+
const requirements = Array.isArray(catalog.requirements) ? catalog.requirements : []
119+
if (requirements.length === 0)
120+
throw new Error('protocol catalog has no requirements')
121+
122+
const ids = requirements.map(requirement => requirement?.id)
123+
const missing = ids.filter(id => typeof id !== 'string' || id.length === 0).length
124+
if (missing > 0)
125+
throw new Error(`protocol catalog has ${missing} requirement(s) without a string id`)
126+
127+
const duplicates = duplicateRequirementIds(ids as string[])
128+
if (duplicates.length > 0)
129+
throw new Error(`protocol catalog has duplicate requirement id(s): ${duplicates.join(', ')}`)
130+
131+
console.log(`Protocol catalog: ${ids.length} requirement ids, all unique`)
98132
}
99-
else {
100-
console.error('usage: bun scripts/protocol/sync-suite.ts --write [--source ../rfcs] | --check')
101-
process.exit(2)
133+
134+
if (import.meta.main) {
135+
if (process.argv.includes('--write')) {
136+
const source = resolve(argument('--source') || process.env.STACKS_RFC_SOURCE || resolve(root, '../rfcs'))
137+
writeSnapshot(source)
138+
}
139+
else if (process.argv.includes('--check')) {
140+
checkSnapshot()
141+
checkCatalog()
142+
}
143+
else {
144+
console.error('usage: bun scripts/protocol/sync-suite.ts --write [--source ../rfcs] | --check')
145+
process.exit(2)
146+
}
102147
}

0 commit comments

Comments
 (0)