{"record":{"id":"70b90d632be0c9f9","repo":"santifer/career-ops","slug":"portals-yml-not-found","errorCode":null,"errorMessage":"portals.yml not found","messagePattern":"portals\\.yml not found","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"openrouter-runner.mjs","lineNumber":472,"sourceCode":"    if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);\n    const html = await r.text();\n    return html.replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim().slice(0, 16_000);\n  } catch (e) {\n    throw new Error(`Could not fetch job page: ${e.message}`);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// portals.yml parser — reads the canonical schema with js-yaml (same library and\n// field names as scan.mjs: `title_filter.positive/negative` + `tracked_companies`),\n// so it never drifts from the main scanner. The runner's no-CLI scan path covers\n// companies that expose a direct JSON `api:`; careers_url-only / Playwright /\n// search-query companies are handled by the full /career-ops scan pipeline.\n// `rawOverride` lets tests feed YAML text directly (see test-all.mjs drift guard).\n// ---------------------------------------------------------------------------\nexport function parsePortals(rawOverride) {\n  const raw = rawOverride ?? readFile('portals.yml');\n  if (!raw) throw new Error('portals.yml not found');\n  const config = yaml.load(raw) || {};\n\n  // The shared predicate rather than a second copy of the matching rules. This\n  // path kept its own `includes` loop, and the two had drifted three ways: an\n  // empty positive list accepted every title in scan.mjs and rejected every\n  // title here, AND-groups worked only in scan.mjs, and a non-string YAML entry\n  // was dropped there but coerced into a live keyword here. A `word:` prefix\n  // would have become the fourth — read as literal text, it would have matched\n  // nothing, so the shipped `word:Intern` would stop rejecting \"Operations\n  // Intern\" here while still working in scan.mjs.\n  //\n  // Side effect worth naming, since it changes this path's verdicts rather than\n  // just its structure: it now also gets the 2-3 char rule. Measured over 2324\n  // real titles that moves one verdict, and it moves it the permissive way —\n  // the negative \"iOS\" had been matching inside \"Biosamples\". Nothing becomes\n  // newly rejected.\n  const titleMatches = buildTitleFilter(config.title_filter);\n","sourceCodeStart":454,"sourceCodeEnd":490,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/openrouter-runner.mjs#L454-L490","documentation":"parsePortals reads portals.yml (the scanner configuration defining title filters and tracked companies) and throws this error when the file is missing or empty. The library refuses to proceed because no portal configuration means no companies or filters to scan.","triggerScenarios":"parsePortals() called when portals.yml does not exist in the working directory; readFile('portals.yml') returns empty string (empty file); tests calling parsePortals() without rawOverride while the repo has no portals.yml.","commonSituations":"Fresh clone before onboarding Step 3 (portals.yml is copied from templates/portals.example.yml); running the script from the wrong working directory; user deleted or renamed portals.yml; empty file after a failed write.","solutions":["Copy the template: cp templates/portals.example.yml portals.yml","Run the career-ops onboarding (doctor.mjs / first-run flow) which creates portals.yml","Check you are running from the repository root, not a subdirectory","If reading programmatically, pass the YAML text directly via parsePortals(rawOverride)"],"exampleFix":"// before\nconst config = parsePortals(); // throws if portals.yml missing\n// after\nimport { existsSync } from 'fs';\nif (!existsSync('portals.yml')) copyFileSync('templates/portals.example.yml', 'portals.yml');\nconst config = parsePortals();","handlingStrategy":"validation","validationCode":"if (!existsSync('portals.yml')) { copyFileSync('templates/portals.example.yml', 'portals.yml'); }","typeGuard":"const hasPortals = (root) => existsSync(path.join(root, 'portals.yml'));","tryCatchPattern":"try { const config = parsePortals(); } catch (e) { if (e.message === 'portals.yml not found') { console.error('Run onboarding: cp templates/portals.example.yml portals.yml'); process.exit(1); } throw e; }","preventionTips":["Complete onboarding (doctor.mjs) which creates portals.yml","Run scripts from the repo root or set the working directory explicitly","Do not delete portals.yml; customize it instead","In tests, always pass rawOverride YAML text"],"tags":["config","yaml","file-not-found"],"backgroundTag":"config-file-not-found","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}