fix(engine): FILE-MISSING named the class for a trait-sourced requirement

security -> SECURITY.md read as "missing required file for class
`standalone`", sending the operator to a class definition that never
listed the requirement. requirementsFor now carries each required
file's origin (class vs. trait) and checkRequiredFiles names whichever
actually required it. Failing test written first per the Iron Law.

No release forced by this alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WuWwACwhun35j52AnTxRKV
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 18:51:21 +02:00
commit e5cd1bfaaf
4 changed files with 35 additions and 6 deletions

View file

@ -538,12 +538,19 @@ export function checkInstallPins({ readme, forgeTagsByRepo }, register) {
// outsider who finds a hole, and being solo does not remove them.
function requirementsFor(klass, traits, register) {
const cls = register.classes?.[klass] ?? {};
const files = [...(cls.required_files ?? [])];
const classSource = `class \`${klass}\``;
const files = (cls.required_files ?? []).map((file) => ({ file, source: classSource }));
const headings = [...(cls.required_headings ?? [])];
const seenFiles = new Set(files.map((f) => f.file));
for (const t of traits ?? []) {
const tr = register.trait_requirements?.[t];
if (!tr) continue;
for (const f of tr.required_files ?? []) if (!files.includes(f)) files.push(f);
const traitSource = `trait \`${t}\``;
for (const f of tr.required_files ?? []) {
if (seenFiles.has(f)) continue;
seenFiles.add(f);
files.push({ file: f, source: traitSource });
}
for (const h of tr.required_headings ?? []) if (!headings.includes(h)) headings.push(h);
}
return { files, headings };
@ -553,9 +560,9 @@ export function checkRequiredFiles({ present, klass, traits }, register) {
const { files: required } = requirementsFor(klass, traits, register);
const have = new Set(present ?? []);
const findings = [];
for (const f of required) {
if (!have.has(f)) {
findings.push({ level: 'ERROR', code: 'FILE-MISSING', bucket: 'missing', msg: `missing required file for class \`${klass}\`: ${f}` });
for (const { file, source } of required) {
if (!have.has(file)) {
findings.push({ level: 'ERROR', code: 'FILE-MISSING', bucket: 'missing', msg: `missing required file for ${source}: ${file}` });
}
}
if (findings.length === 0 && required.length > 0) {

View file

@ -1480,6 +1480,19 @@ test('a repo without the security trait owes no SECURITY.md', () => {
assert.equal(f.some((x) => x.msg.includes('SECURITY.md')), false);
});
test('a trait-required file is missing, FILE-MISSING names the trait, not the class', () => {
const f = checkRequiredFiles({ present: ['README.md', 'LICENSE'], klass: 'standalone', traits: ['security'] }, REGISTER);
const finding = f.find((x) => x.code === 'FILE-MISSING' && x.msg.includes('SECURITY.md'));
assert.equal(finding.msg.includes('trait `security`'), true);
assert.equal(finding.msg.includes('class `standalone`'), false);
});
test('a class-required file is missing, FILE-MISSING still names the class', () => {
const f = checkRequiredFiles({ present: [], klass: 'standalone' }, REGISTER);
const finding = f.find((x) => x.code === 'FILE-MISSING' && x.msg.includes('LICENSE'));
assert.equal(finding.msg.includes('class `standalone`'), true);
});
test('the security trait requires limitations to be stated', () => {
const f = checkHeadings({ readme: '# x\n## Install\n## Non-goals\n', klass: 'standalone', traits: ['security'] }, REGISTER);
assert.equal(f.some((x) => x.code === 'HEADING-MISSING' && x.msg.includes('Known limitations')), true);