Found by the os-dev seat while implementing #15578 (the sibling defect one module over). Out of scope there and deliberately not folded in: different file, different function, and #15578's scope is fenced to the throwing-generator path in metadata-eval.ts. Unassigned and bare, for triage.
What
packages/cli/src/lint/score.ts, in scoreMetadata (located by content, not by line):
// 2) Lint (naming/labels/structure + data-model conventions).
let issues: LintIssue[] = [];
try {
issues = lintConfig(normalized) as LintIssue[];
} catch {
// A linter crash shouldn't mask the schema verdict — treat as no lint data.
issues = [];
}
issues is the sole input to everything downstream of it:
const penalty =
schemaErrors.length * SCORE_WEIGHTS.schemaError +
errors.length * SCORE_WEIGHTS.error +
warnings.length * SCORE_WEIGHTS.warning +
suggestions.length * SCORE_WEIGHTS.suggestion;
const score = Math.max(0, Math.min(100, 100 - penalty));
…
valid: schemaErrors.length === 0 && errors.length === 0,
⇒ when the linter crashes on a schema-valid stack, errors/warnings/suggestions are all empty, penalty is 0, and the verdict is 100 / grade A / valid: true, counts all zero, issues: [] — byte-for-byte the verdict a genuinely clean stack gets. Nothing in MetadataScore records that half the rubric never ran.
The comment states the intent honestly and the intent is defensible: a linter crash should not mask the schema verdict. What is not defensible is the score that comes out of it. "The linter found nothing" and "the linter never ran" are different facts, and this collapses them into the better-looking one.
Why it is the same family as #15578
#15578 is the eval harness stamping scoreMetadata({}) — 100 / A / valid: true — on a case whose generator threw. Same shape one level down: a failure path that terminates in the best possible number rather than an honest one. #15578 is being repaired; this one is untouched and reaches a wider surface, because scoreMetadata has a second caller.
Blast radius — two published faces, not one
scoreMetadata is called from os lint --score as well as from the eval harness, so the swallowed crash can surface as:
os lint --score reporting a clean 100 / A for a stack whose lint never ran;
- an eval case scored 100 / A /
valid: true and therefore passing its minScore bar (passed guards on generationError, which is not set here — this path never sets one), which then feeds meanScore and ok.
⚠️ That second bullet is the part worth grading carefully: unlike #15578, this path can turn a failure into a PASS, not merely into a wrong mean.
Evidence, and its honest limits
⛔ Not driven. No input was constructed that makes lintConfig throw while surviving normalizeStackInput and ObjectStackDefinitionSchema.safeParse, both of which walk the value first. The reading is of the code path, plus one measured fact that pins the terminal state: scoreMetadata({}) was driven on this tree at origin/main d30ccb9bd96 and returns
{"score":100,"grade":"A","valid":true,"counts":{"schemaErrors":0,"errors":0,"warnings":0,"suggestions":0}}
which is exactly the state the catch produces for any schema-valid stack. ⚠️ So the open question a triager should settle first is reachability — whether lintConfig can throw on a value that already passed the two walks ahead of it. If it cannot, this is dead defensive code and the right repair may be to delete the catch, not to widen the report.
Prior art in the same family, both closed, both elsewhere
⇒ The repair shape this repo has chosen before is to make the failure visible in the output rather than to remove the guard. ⛔ Not a decision here — routing information for triage.
Not deduped beyond one search
One targeted search was run against this repository with a positive control in the same session (the control returned #15578, so the search was live, not silently empty). It surfaced no open duplicate.
Found by the
os-devseat while implementing #15578 (the sibling defect one module over). Out of scope there and deliberately not folded in: different file, different function, and #15578's scope is fenced to the throwing-generator path inmetadata-eval.ts. Unassigned and bare, for triage.What
packages/cli/src/lint/score.ts, inscoreMetadata(located by content, not by line):issuesis the sole input to everything downstream of it:⇒ when the linter crashes on a schema-valid stack,
errors/warnings/suggestionsare all empty,penaltyis 0, and the verdict is 100 / gradeA/valid: true,countsall zero,issues: []— byte-for-byte the verdict a genuinely clean stack gets. Nothing inMetadataScorerecords that half the rubric never ran.The comment states the intent honestly and the intent is defensible: a linter crash should not mask the schema verdict. What is not defensible is the score that comes out of it. "The linter found nothing" and "the linter never ran" are different facts, and this collapses them into the better-looking one.
Why it is the same family as #15578
#15578 is the eval harness stamping
scoreMetadata({})— 100 / A /valid: true— on a case whose generator threw. Same shape one level down: a failure path that terminates in the best possible number rather than an honest one. #15578 is being repaired; this one is untouched and reaches a wider surface, becausescoreMetadatahas a second caller.Blast radius — two published faces, not one
scoreMetadatais called fromos lint --scoreas well as from the eval harness, so the swallowed crash can surface as:os lint --scorereporting a clean 100 / A for a stack whose lint never ran;valid: trueand therefore passing itsminScorebar (passedguards ongenerationError, which is not set here — this path never sets one), which then feedsmeanScoreandok.Evidence, and its honest limits
⛔ Not driven. No input was constructed that makes
lintConfigthrow while survivingnormalizeStackInputandObjectStackDefinitionSchema.safeParse, both of which walk the value first. The reading is of the code path, plus one measured fact that pins the terminal state:scoreMetadata({})was driven on this tree atorigin/maind30ccb9bd96and returnswhich is exactly the state the⚠️ So the open question a triager should settle first is reachability — whether
catchproduces for any schema-valid stack.lintConfigcan throw on a value that already passed the two walks ahead of it. If it cannot, this is dead defensive code and the right repair may be to delete thecatch, not to widen the report.Prior art in the same family, both closed, both elsewhere
parseDiagnostics, not the try/catch.⇒ The repair shape this repo has chosen before is to make the failure visible in the output rather than to remove the guard. ⛔ Not a decision here — routing information for triage.
Not deduped beyond one search
One targeted search was run against this repository with a positive control in the same session (the control returned #15578, so the search was live, not silently empty). It surfaced no open duplicate.