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/workflows/build-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ jobs:
if: github.ref != 'refs/heads/3.0.x' && github.base_ref != '3.0.x'
run: npm run test:error-code-drift

- name: Check docs against compliance error codes
run: npm run test:doc-compliance-drift

- name: Check policy registry publication completeness
run: npm run check:registry

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"test:patch-3-0-compat-bundle": "node --test --test-force-exit --test-timeout=30000 tests/patch-3-0-compat-bundle.test.cjs",
"test:error-codes": "node scripts/lint-error-codes.cjs",
"test:error-code-drift": "node scripts/lint-error-code-drift.cjs",
"test:doc-compliance-drift": "node --test --test-force-exit --test-timeout=30000 tests/lint-doc-compliance-drift.test.cjs",
"test:substitution-vector-names": "node scripts/lint-substitution-vector-names.cjs",
"test:unit": "npm run test:canonical-pixel-ratio && npm run test:governance-jws-vectors && vitest run --dir tests/ --pool=threads",
"test:redteam": "tsx server/src/addie/testing/redteam-cli.ts",
Expand All @@ -119,7 +120,7 @@
"audit:oneof": "node scripts/audit-oneof.mjs",
"test:schema-utf8": "node scripts/normalize-schema-utf8.mjs --check",
"fix:schema-utf8": "node scripts/normalize-schema-utf8.mjs",
"test": "npm run test:docs-nav && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:demographic-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck",
"test": "npm run test:docs-nav && npm run test:release-docs-nav && npm run test:rewrite-dist-redirect-links && npm run test:rewrite-dist-links-idempotency && npm run test:docs-error-handling-copy && npm run test:schemas && npm run test:mcp-schema-projection && npm run test:dist-schema-version-ids && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:audio-radio && npm run test:adagents-catalog-only && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:webhook-signing-vectors && npm run test:webhook-receiver-envelope && npm run test:oauth-setup-vectors && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:demographic-targeting && npm run test:attestation-vectors && npm run test:rights-attestations && npm run test:governance-runtime-attestations && npm run test:governance-conditions-storyboard && npm run test:audience-evidence && npm run test:status-as-of-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:compliance-packaged-refs && npm run test:compliance-source-authority && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:update-media-buy-affected-packages && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:refine-finalize-validation-ids && npm run test:run-storyboards-schema-root && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:sdk-shims && npm run test:format-identity-boundaries && npm run test:sdk-runner-capability-gates && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:chat-streaming-code-fences && npm run test:certification-demo-formatting && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:release-workflow && npm run test:immutable-release-artifacts && npm run test:patch-3-0-compat-bundle && npm run test:error-codes && npm run test:doc-compliance-drift && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck",
"test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck",
"precommit:server-unit": "node scripts/precommit-server-unit.cjs",
"precommit": "bash scripts/with-timeout.sh 180 npm run test:unit && npm run test:test-dynamic-imports && npm run test:format-identity-boundaries && npm run test:callapi-state-change && bash scripts/with-timeout.sh 240 npm run precommit:server-unit && npm run typecheck",
Expand Down
168 changes: 168 additions & 0 deletions scripts/lint-doc-compliance-drift.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* Keep the request-signing guide's error-code claims aligned with the
* conformance vectors it tells implementers to satisfy.
*
* Scope is intentionally narrow. The broader security guide documents
* discovery/runtime errors that are not graded by request-signing vectors.
* This lint checks concrete request_signature_* literals and first-column
* code-table entries inside request-signing.mdx's "Error codes" section — the
* exact surface that previously published seven invented, unprefixed codes.
*/

'use strict';

const fs = require('node:fs');
const path = require('node:path');

const ROOT = path.resolve(__dirname, '..');
const DEFAULT_DOC_PATH = path.join(ROOT, 'docs', 'building', 'by-layer', 'L1', 'request-signing.mdx');
const DEFAULT_CONTRACT_ROOT = path.join(
ROOT,
'static',
'compliance',
'source',
'test-vectors',
'request-signing',
);
const CONCRETE_REQUEST_CODE_PATTERN = /\brequest_signature_[a-z0-9_]*[a-z0-9]\b/g;
const EXACT_REQUEST_CODE_PATTERN = /^request_signature_[a-z0-9_]*[a-z0-9]$/;
const SNAKE_CASE_CODE_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/;

function walkJsonFiles(directory) {
if (!fs.existsSync(directory)) return [];
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...walkJsonFiles(entryPath));
else if (entry.isFile() && entry.name.endsWith('.json')) files.push(entryPath);
}
return files;
}

function collectContractCodes(contractRoot = DEFAULT_CONTRACT_ROOT) {
const vectorFiles = walkJsonFiles(contractRoot)
.filter(file => file.split(path.sep).includes('negative'));

if (vectorFiles.length === 0) {
throw new Error(`No request-signing negative vectors found under ${contractRoot}`);
}

const codes = new Set();
for (const file of vectorFiles) {
let vector;
try {
vector = JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (error) {
throw new Error(`Could not parse ${file}: ${error.message}`);
}
const code = vector && vector.expected_outcome && vector.expected_outcome.error_code;
if (typeof code !== 'string' || !EXACT_REQUEST_CODE_PATTERN.test(code)) {
throw new Error(`${file}: negative vector is missing a request_signature_* expected_outcome.error_code`);
}
codes.add(code);
}
return codes;
}

function extractErrorCodesSection(markdown) {
const lines = markdown.split(/\r?\n/);
const section = [];
let collecting = false;
let fenceMarker = null;

for (const line of lines) {
const fence = /^\s*(`{3,}|~{3,})/.exec(line);
if (fence) {
const marker = fence[1][0];
if (fenceMarker === null) fenceMarker = marker;
else if (fenceMarker === marker) fenceMarker = null;
if (collecting) section.push(line);
continue;
}

if (fenceMarker === null) {
if (!collecting && /^### Error codes[ \t]*$/.test(line)) {
collecting = true;
continue;
}
if (collecting && /^#{1,3}\s+/.test(line)) break;
}
if (collecting) section.push(line);
}

return collecting ? section.join('\n') : null;
}

function extractDocCodeClaims(markdown) {
const section = extractErrorCodesSection(markdown);
if (section === null) return null;
const codes = new Set(section.match(CONCRETE_REQUEST_CODE_PATTERN) || []);

// A code taxonomy table puts its machine value in the first column. Limit
// generic snake_case matching to that structural position so prose such as
// `brand_json_url` is not mistaken for an error-code claim.
const tableCodePattern = /^\|\s*`([^`\r\n]+)`\s*\|/gm;
for (const match of section.matchAll(tableCodePattern)) {
const candidate = match[1].trim();
if (SNAKE_CASE_CODE_PATTERN.test(candidate)) codes.add(candidate);
}
return codes;
}

function lint({
root = ROOT,
docPath = DEFAULT_DOC_PATH,
contractRoot = DEFAULT_CONTRACT_ROOT,
} = {}) {
const contractCodes = collectContractCodes(contractRoot);
const docCodes = extractDocCodeClaims(fs.readFileSync(docPath, 'utf8'));
const displayPath = path.relative(root, docPath) || docPath;
const errors = [];

if (docCodes === null) {
errors.push(`${displayPath}: missing expected "### Error codes" section`);
return { errors, contractCodes, docCodes: new Set() };
}

for (const code of [...docCodes].sort()) {
if (!contractCodes.has(code)) {
errors.push(
`${displayPath}: \`${code}\` is not an expected error code in ` +
'static/compliance/source/test-vectors/request-signing/**/negative/*.json',
);
}
}

return { errors, contractCodes, docCodes };
}

function main() {
const result = lint();
console.log(
`doc-compliance drift lint: ${result.docCodes.size} concrete guide claim(s), ` +
`${result.contractCodes.size} current contract code(s)`,
);
if (result.errors.length > 0) {
for (const error of result.errors) console.error(` - ${error}`);
return 1;
}
console.log('✓ request-signing documentation matches the current conformance contract.');
return 0;
}

if (require.main === module) {
try {
process.exit(main());
} catch (error) {
console.error(`doc-compliance drift lint failed: ${error.message}`);
process.exit(2);
}
}

module.exports = {
collectContractCodes,
extractDocCodeClaims,
extractErrorCodesSection,
lint,
};
143 changes: 143 additions & 0 deletions tests/lint-doc-compliance-drift.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env node

'use strict';

const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const assert = require('node:assert/strict');

const {
extractDocCodeClaims,
lint,
} = require('../scripts/lint-doc-compliance-drift.cjs');

function makeFixture({ code = 'request_signature_required' } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lint-doc-compliance-drift-'));
const contractRoot = path.join(
root,
'static',
'compliance',
'source',
'test-vectors',
'request-signing',
);
const negativeDir = path.join(contractRoot, 'negative');
const docPath = path.join(root, 'docs', 'building', 'by-layer', 'L1', 'request-signing.mdx');
fs.mkdirSync(negativeDir, { recursive: true });
fs.mkdirSync(path.dirname(docPath), { recursive: true });
fs.writeFileSync(
path.join(negativeDir, '001-required.json'),
JSON.stringify({ expected_outcome: { success: false, error_code: code } }),
);
return { root, contractRoot, docPath };
}

test('repository request-signing guide matches the current source vectors', () => {
const result = lint();
assert.deepEqual(result.errors, []);
assert.ok(result.contractCodes.has('request_signature_required'));
});

test('the original seven-code phantom table fails completely', () => {
const fixture = makeFixture();
fs.writeFileSync(fixture.docPath, `# Guide

### Error codes

| Code | Meaning |
|---|---|
| \`missing_signature\` | missing |
| \`invalid_signature\` | invalid |
| \`expired_signature\` | expired |
| \`replayed_nonce\` | replayed |
| \`revoked_key\` | revoked |
| \`unknown_key\` | unknown |
| \`unsupported_algorithm\` | unsupported |

## Related
`);

const result = lint(fixture);
assert.equal(result.errors.length, 7);
for (const code of [
'missing_signature',
'invalid_signature',
'expired_signature',
'replayed_nonce',
'revoked_key',
'unknown_key',
'unsupported_algorithm',
]) {
assert.ok(result.errors.some(error => error.includes(`\`${code}\``)), code);
}
});

test('a table grounded in a vector code passes', () => {
const fixture = makeFixture();
fs.writeFileSync(
fixture.docPath,
'### Error codes\n\n| Code | Meaning |\n|---|---|\n| `request_signature_required` | missing |\n',
);

const result = lint(fixture);
assert.deepEqual(result.errors, []);
assert.deepEqual([...result.docCodes], ['request_signature_required']);
});

test('taxonomy prose and family references are outside the vector-code claim scope', () => {
const fixture = makeFixture();
fs.writeFileSync(fixture.docPath, `# Guide

The discovery path may return \`request_signature_brand_json_url_missing\`.

### Error codes

The \`brand_json_url\` field raises \`request_signature_brand_*\` and
\`request_signature_key_origin_*\` families documented elsewhere.

## Related
`);

const result = lint(fixture);
assert.deepEqual(result.errors, []);
assert.deepEqual([...result.docCodes], []);
});

test('concrete request-signing literals in the section are checked outside tables too', () => {
const claims = extractDocCodeClaims(`### Error codes

Return \`request_signature_required\`. The \`request_signature_brand_*\` family is separate.

## Related
`);
assert.deepEqual([...claims], ['request_signature_required']);
});

test('headings inside fenced examples do not terminate the guarded section', () => {
const fixture = makeFixture();
fs.writeFileSync(fixture.docPath, `### Error codes

\`\`\`md
## Example response heading
\`\`\`

| Code | Meaning |
|---|---|
| \`missing_signature\` | invented |

## Related
`);
const result = lint(fixture);
assert.equal(result.errors.length, 1);
assert.match(result.errors[0], /missing_signature/);
});

test('removing the guarded section fails instead of silently disabling the lint', () => {
const fixture = makeFixture();
fs.writeFileSync(fixture.docPath, '# Guide\n\nNo taxonomy here.\n');
const result = lint(fixture);
assert.equal(result.errors.length, 1);
assert.match(result.errors[0], /missing expected "### Error codes" section/);
});
Loading