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

  1. 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.
  2. If the file is empty or corrupted, restore it from the upstream career-ops repository (git checkout -- templates/states.yml or re-clone).
  3. If you passed a custom path, verify you are pointing at the real states schema file, not another YAML config.
  4. 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.
  5. 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

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.

Related errors


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)