santifer/career-ops · error
Malformed states file at
Error message
Malformed states file at ${statesPath}: expected a top-level "states" list What it means
loadCanonicalStates parses templates/states.yml and requires the YAML document to have a top-level "states" key holding an array of state entries. If the file is missing, empty, or its root is not an object with a states list (e.g. it was overwritten, truncated, or has the states nested under another key), this error is thrown before any states are returned.
Solutions
- Open templates/states.yml and confirm it has a top-level `states:` key followed by a YAML list of entries with id, label, and aliases fields.
- If the file is empty or corrupted, restore it from the upstream career-ops repository (git checkout -- templates/states.yml or re-clone).
- If you passed a custom path, verify you are pointing at the real states schema file, not another YAML config.
- Validate the YAML parses to an object: node -e "console.log(require('js-yaml').load(require('fs').readFileSync('templates/states.yml','utf8')))" and check .states is an array.
- Re-apply or update career-ops system files (node update-system.mjs apply --confirm) to restore shipped templates.
Example fix
# before (malformed templates/states.yml)
Evaluated: some text
# after
canonical_states:
- id: evaluated
# correct schema (restored)
states:
- id: evaluated
label: Evaluated
aliases: [evaluated] Defensive patterns
Strategy: validation
Validate before calling
import { readFileSync } from 'node:fs';
import yaml from 'js-yaml';
const doc = yaml.load(readFileSync(statesPath, 'utf-8'));
if (!doc || !Array.isArray(doc.states)) {
throw new Error(`${statesPath} has no top-level "states" list; restore templates/states.yml`);
} Type guard
function hasStatesList(doc) {
return typeof doc === 'object' && doc !== null && Array.isArray(doc.states);
} Try / catch
try {
const states = loadCanonicalStates(statesPath);
} catch (err) {
if (err.message.includes('Malformed states file')) {
// fall back to a bundled copy or prompt the user to restore templates/states.yml
} else throw err;
} Prevention
- Never hand-edit templates/states.yml without validating the YAML afterwards.
- Keep the states schema file in git so corrupt/empty edits are recoverable with git checkout.
- Run node doctor.mjs or verify-pipeline.mjs after system updates to catch schema drift early.
- When pointing at a custom states file, lint it against the documented schema (states: [{id,label,aliases}]).
When it happens
Trigger: Calling loadCanonicalStates(statesPath) where the YAML file (a) does not exist and readFileSync throws is a different error, but (b) exists yet parses to null/undefined (empty file), (c) parses to a scalar or array instead of an object, or (d) is an object whose `states` property is absent or not an Array — all hit this exact throw.
Common situations: A user hand-edited templates/states.yml and renamed or indented the `states:` key; a system update or merge conflict left the file empty or half-written; a custom states file was pointed at via configuration that follows a different schema (states nested under e.g. `canonical_states:`); the wrong path is passed so a different YAML file (valid YAML but not a states schema) is loaded.
Understand the failure class
Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
- arbeitsagentur: entry
- Cannot read benchmarks at
- config/profile.yml is empty or invalid YAML: fill it in…
- ⚠️ Could not parse config/profile.yml
- ⚠️ Failed to parse profile.yml
AI-assisted analysis of santifer/career-ops@e7abd431fc (2026-09-16).
Data as JSON: /api/errors/6e34df61c8cd9489.
Report an issue: GitHub.
Appendix: source
Thrown at tracker-utils.mjs:713
/**
* Load the canonical tracker states from `templates/states.yml`.
*
* states.yml is the single source of truth for the 8 canonical states and
* their aliases. Parsing it here (instead of hardcoding the list) means a new
* state or alias lands in one file and every consumer follows.
*
* `description` and `terminal` are passed through for callers that EXPLAIN the
* states rather than list them (set-status.mjs --help). Both default rather
* than throw: an entry omitting them is still a usable state.
*
* @param {string} statesPath - Path to templates/states.yml.
* @returns {{id:string,label:string,aliases:string[],description:string,terminal:boolean}[]} Parsed state entries.
*/
export function loadCanonicalStates(statesPath) {
const doc = yaml.load(readFileSync(statesPath, 'utf-8'));
if (!doc || !Array.isArray(doc.states)) {
throw new Error(`Malformed states file at ${statesPath}: expected a top-level "states" list`);
}
return doc.states.map(s => ({
id: String(s.id ?? ''),
label: String(s.label ?? ''),
aliases: Array.isArray(s.aliases) ? s.aliases.map(String) : [],
description: String(s.description ?? ''),
terminal: s.terminal === true,
}));
}
/**
* Resolve user input to a canonical state label, strictly.
*
* Case-insensitive match against each state's label, id, and aliases, after
* stripping markdown bold. Unlike merge-tracker's lenient batch normalization
* (which defaults unknowns to "Evaluated" so a whole merge isn't lost), this
* is the strict variant for interactive/CLI use: unknown input returns null so
* the caller can reject it before anything touches the tracker.View on GitHub (pinned to e7abd431fc)