santifer/career-ops · critical · Error

Malformed states file at ${statesPath}: expected a top-level

Error message

Malformed states file at ${statesPath}: expected a top-level "states" list

What it means

loadCanonicalStates() in tracker-utils.mjs reads templates/states.yml and, like loadLifecycle (error 355), requires a top-level `states` array. It throws an Error if the document is null or doc.states is not an array. This is the entry point used by set-status.mjs and the canonical state resolver, so a malformed file blocks all status updates.

Source

Thrown at tracker-utils.mjs:540

    rmSync(tmpPath, { force: true });
    throw err;
  }
}

/**
 * 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.
 *
 * @param {string} statesPath - Path to templates/states.yml.
 * @returns {{id:string,label:string,aliases:string[]}[]} 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) : [],
  }));
}

/**
 * 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.
 *
 * @param {string} input - Raw state text from the user or a script.

View on GitHub (pinned to 9b17a8ac97)

Solutions

  1. Restore the canonical file: git checkout HEAD -- templates/states.yml.
  2. Re-run node update-system.mjs apply.
  3. Validate structure: node -e "const y=require('js-yaml').load(require('fs').readFileSync('templates/states.yml','utf8')); console.assert(Array.isArray(y.states))".
  4. Run node verify-pipeline.mjs to catch related integrity issues.

Example fix

# restore the single source of truth
git checkout HEAD -- templates/states.yml
node verify-pipeline.mjs
Defensive patterns

Strategy: validation

Validate before calling

function validateStatesFile(statesPath) {
  const doc = yaml.load(readFileSync(statesPath, 'utf-8'));
  if (!doc || !Array.isArray(doc.states)) {
    throw new Error(`states.yml missing 'states' list — restore: git checkout HEAD -- templates/states.yml`);
  }
}

Try / catch

try {
  const states = loadCanonicalStates(STATES_FILE);
} catch (err) {
  if (err.message.includes('Malformed states file')) {
    console.error(err.message);
    console.error('Restoring system file and retrying...');
    execSync('git checkout HEAD -- templates/states.yml');
    // retry after restore
  } else throw err;
}

Prevention

When it happens

Trigger: templates/states.yml is empty, has wrong top-level key, contains non-object YAML, or is truncated. Any state-writing operation (set-status.mjs, merge-tracker.mjs) that calls resolveCanonicalState -> loadCanonicalStates will surface this.

Common situations: Hand-editing states.yml and breaking the schema; a botched update-system.mjs run; a git merge conflict; an editor that auto-converted tabs/spaces breaking YAML parsing.

Understand the failure class

Related errors


AI-assisted analysis of santifer/career-ops@9b17a8ac97 (2026-08-13). Data as JSON: /api/errors/6e34df61c8cd9489. Report an issue: GitHub.