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
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,12 @@
"validate:docs-page": "node scripts/validate-docs-page-assets.cjs",
"validate:documentation": "node scripts/validate-documentation-contract.cjs",
"validate:workflows": "node scripts/validate-workflow-contract.cjs",
"generate:specifications": "node scripts/validate-specification-catalog.cjs --write && node scripts/validate-github-communication-test-budget.cjs",
"validate:specifications": "node scripts/validate-specification-catalog.cjs && node scripts/validate-github-communication-test-budget.cjs",
"generate:specifications": "node scripts/validate-specification-catalog.cjs --write && node scripts/validate-github-communication-test-budget.cjs && pnpm run validate:browser-extension-specifications",
"validate:specifications": "node scripts/validate-specification-catalog.cjs && node scripts/validate-github-communication-test-budget.cjs && pnpm run validate:browser-extension-specifications",
"validate:setup-acceptance": "node scripts/validate-setup-assistant-acceptance.cjs",
"lint:fix": "eslint src --fix",
"postinstall": "node scripts/install-git-hooks.cjs"
"postinstall": "node scripts/install-git-hooks.cjs",
"validate:browser-extension-specifications": "node --test test-support/browser-extension-specifications.test.cjs && node scripts/validate-browser-extension-specifications.cjs"
},
"keywords": [],
"author": "Efra Espada",
Expand All @@ -98,6 +99,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^22.9.1",
"@vercel/ncc": "^0.36.1",
"ajv": "6.15.0",
"eslint": "^10.10.0",
"jest": "^30.5.1",
"svelte": "^5.57.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

155 changes: 155 additions & 0 deletions scripts/validate-browser-extension-specifications.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env node

const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const Ajv = require('ajv');

const ROOT = path.resolve(__dirname, '..');
const DATA = 'specs/browser-extension';
const MODES = { local: 3, settings: 4, read: 4, draft: 5, write: 7, setup: 7, monitor: 7 };
const WRITE_ACTOR = new Set(['fix', 'dismiss', 'implement', 'remember', 'sync-branch']);
const BRANCH = new Set(['fix', 'implement', 'remember', 'sync-branch']);
const NO_AGENT = new Set(['help', 'status', 'sdd-diff', 'doctor', 'history', 'dismiss', 'remember']);
const DOCUMENTS = [
'github-browser-extension.md', 'github-browser-extension-action-integration.md',
'github-browser-extension-platforms-and-delivery.md',
'github-browser-extension-functional-catalog.md',
'github-browser-extension-control-center-and-setup.md',
];

function readJson(root, filename) {
return JSON.parse(fs.readFileSync(path.join(root, DATA, filename), 'utf8'));
}

function compiler(root, filename) {
const ajv = new Ajv({ allErrors: true, strictKeywords: true, strictDefaults: true });
return ajv.compile(readJson(root, filename));
}

function requireSchema(validate, value, name) {
assert(validate(value), `${name} violates its JSON schema: ${JSON.stringify(validate.errors)}`);
}

function unique(values, name) {
assert.equal(new Set(values).size, values.length, `${name} must be unique`);
}

function loadFeatureCatalog(root = ROOT, index = readJson(root, 'feature-catalog.v1.json')) {
requireSchema(compiler(root, 'feature-catalog-index.schema.json'), index, 'feature index');
unique(index.families.map(family => family.id), 'indexed family IDs');
const families = index.families.map(reference => {
assert.equal(reference.file, `features/${reference.id}.v1.json`, `${reference.id} family path mismatch`);
assert(fs.statSync(path.join(root, DATA, reference.file)).size <= 96 * 1024, `${reference.id} family exceeds reviewable file budget`);
const family = readJson(root, reference.file);
assert.equal(family.id, reference.id, `${reference.id} indexed family identity mismatch`);
assert.equal(family.title, reference.title, `${reference.id} indexed family title mismatch`);
return family;
});
return { ...index, $schema: './feature-catalog.schema.json', families };
}

function validateFeatures(catalog, schema) {
requireSchema(schema, catalog, 'feature catalog');
unique(catalog.families.map(family => family.id), 'family IDs');
const features = catalog.families.flatMap(family => {
for (const feature of family.features) {
assert.equal(feature.family, family.id, `${feature.id} family mismatch`);
assert(feature.id.startsWith(`BEX-${family.id}-`), `${feature.id} ID namespace mismatch`);
assert.deepEqual(feature.requirements, [`CAT-${family.id}`], `${feature.id} requirement mismatch`);
assert.equal(feature.tests.length, MODES[feature.mode], `${feature.id} risk-case allocation mismatch`);
for (const item of feature.tests) {
assert(item.id.startsWith(`bexp-${family.id}${feature.id.slice(-2)}-C`), `${item.id} case namespace mismatch`);
}
}
return family.features;
});
unique(features.map(feature => feature.id), 'feature IDs');
const cases = features.flatMap(feature => feature.tests);
unique(cases.map(item => item.id), 'feature case IDs');
unique(cases.map(item => item.scenario), 'feature scenarios');
assert.equal(features.length, 150, 'The full product requires its 150 registered capabilities');
assert.equal(cases.length, 659, 'Feature risk inventory must contain 659 distinct cases');
return { features, featureCases: cases.length };
}

function validateFeatureTraceability(catalog, text) {
const section = text.split(/^## 17\. Requirements traceability$/m)[1]?.split(/^## 18\. /m)[0];
assert(section, 'Feature requirements traceability section missing');
const rows = section.split('\n').filter(line => line.includes('|'))
.map(line => line.trim().replace(/^\||\|$/g, '').split('|').map(cell => cell.trim()))
.filter(cells => /^CAT-/i.test(cells[0]));
for (const family of catalog.families) {
const matches = rows.filter(cells => cells[0] === `CAT-${family.id}`);
assert.equal(matches.length, 1, `CAT-${family.id} must have exactly one traceability row`);
const cells = matches[0];
assert.equal(cells[1], family.features.map(feature => feature.id).join(', '), `CAT-${family.id} feature traceability mismatch`);
const count = family.features.reduce((total, feature) => total + feature.tests.length, 0);
assert.equal(cells[2], `${count} explicit cases in the JSON test inventory`, `CAT-${family.id} case traceability mismatch`);
}
assert.equal(rows.length, catalog.families.length, 'Unregistered feature traceability rows');
}

function validateRequests(descriptor, schema, knownNames) {
requireSchema(schema, descriptor, 'request catalog');
unique(descriptor.commands.map(command => command.name), 'command names');
assert.deepEqual(descriptor.commands.map(command => command.name).sort(), [...knownNames].sort(), 'Command catalog must match the documented contract');
for (const command of descriptor.commands) {
assert.equal(command.requiresWriteActor, WRITE_ACTOR.has(command.name), `${command.name} write-actor guard mismatch`);
assert.equal(command.requiresVerifiedBranch, BRANCH.has(command.name), `${command.name} branch guard mismatch`);
const effect = BRANCH.has(command.name) ? 'files' : command.name === 'dismiss' ? 'finding-state'
: command.name === 'description' ? 'pr-description' : 'publication';
assert.equal(command.effect, effect, `${command.name} effect classification mismatch`);
const agent = NO_AGENT.has(command.name) ? 'never' : command.name === 'sync-branch' ? 'conditional' : 'may-run';
assert.equal(command.agent, agent, `${command.name} agent classification mismatch`);
if (['fix', 'implement'].includes(command.name)) {
assert.deepEqual(command.contexts, ['review-thread'], `${command.name} cannot broaden the example's authoritative route`);
}
}
}

function validate(root = ROOT) {
const catalog = loadFeatureCatalog(root);
const result = validateFeatures(catalog, compiler(root, 'feature-catalog.schema.json'));
const commandReference = fs.readFileSync(path.join(root, 'docs/issues/comment-commands.mdx'), 'utf8');
const names = [...commandReference.matchAll(/^\| `\/copilot ([a-z-]+)/gm)].map(match => match[1]);
unique(names, 'documented command names');
validateRequests(readJson(root, 'examples/requests-v2.json'), compiler(root, 'requests-v2.schema.json'), names);
const manifest = compiler(root, 'manifest-v1.schema.json');
const status = compiler(root, 'status-v1.schema.json');
requireSchema(manifest, readJson(root, 'examples/manifest-v1.json'), 'manifest fixture');
for (const filename of ['status-pr-v1.json', 'status-issue-v1.json']) {
requireSchema(status, readJson(root, `examples/${filename}`), filename);
}
assert.equal(status(readJson(root, 'examples/status-invalid-head-v1.json')), false, 'Invalid-head negative fixture must reject');
const texts = new Map(DOCUMENTS.map(name => [name, fs.readFileSync(path.join(root, 'specs', name), 'utf8')]));
for (const [name, text] of texts) {
assert(!/\bMVP\b/i.test(text), `${name} must express the complete product contract`);
for (let section = 1; section <= 20; section++) {
assert.equal([...text.matchAll(new RegExp(`^## ${section}\\. `, 'gm'))].length, 1, `${name} must have one section ${section}`);
}
assert(/\b803\b/.test(text), `${name} must agree on the complete 803-case budget`);
}
const primary = texts.get(DOCUMENTS[0]);
const core = [...primary.matchAll(/^\| (bex-[A-Z]-C\d+) \|/gm)].map(match => match[1]);
const center = [...texts.get(DOCUMENTS[4]).matchAll(/^\| (bexhub-[A-Z]+-C\d+) \|/gm)].map(match => match[1]);
unique([...core, ...center], 'cross-cutting case IDs');
assert.equal(core.length, 96, 'Retain all 96 core cases');
assert.equal(center.length, 48, 'Retain all 48 center/setup integration cases');
assert.equal(core.length + center.length + result.featureCases, 803);
assert(primary.includes('| specs/github-browser-extension.md | create |'), 'PR publication inventory must classify the new primary as create');
const catalogText = texts.get(DOCUMENTS[3]);
validateFeatureTraceability(catalog, catalogText);
for (const feature of result.features) assert(catalogText.includes(`| ${feature.id} — `), `${feature.id} missing from the readable catalog`);
const capability = JSON.parse(fs.readFileSync(path.join(root, 'specs/catalog.json'), 'utf8')).capabilities.find(item => item.id === 'github-browser-extension');
assert.equal(catalog.runtimeVerified, false, 'Specification inventory must not claim runtime evidence');
for (const name of DOCUMENTS) assert(capability.specs.includes(`specs/${name}`), `${name} missing from capability ownership`);
return { features: result.features.length, commands: names.length, cases: 803, schemas: 5, positiveFixtures: 6, expectedNegativeFixtures: 1 };
}

if (require.main === module) {
try { console.log(`Browser-extension specification contract: PASS ${JSON.stringify(validate())}`); }
catch (error) { console.error(`Browser-extension specification contract: FAIL ${error.message}`); process.exitCode = 1; }
}

module.exports = { validate, validateFeatures, validateFeatureTraceability, validateRequests, compiler, readJson, loadFeatureCatalog };
16 changes: 14 additions & 2 deletions specs/CATALOG.md

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions specs/browser-extension/examples/manifest-v1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"enabled": true,
"repositoryId": 1,
"producerActorId": 2,
"workflowPaths": [
".github/workflows/copilot_issue.yml",
".github/workflows/copilot_pull_request.yml",
".github/workflows/copilot_issue_comment.yml"
],
"capabilities": [
"context.v1",
"findings.v1",
"command-drafts.v1"
],
"statusSchemaMajor": 1,
"publication": "current-status-comment"
}
Loading
Loading