{"record":{"id":"3d712ff8301e453d","repo":"santifer/career-ops","slug":"mycareersfuture-entry-entry-name-unnamed","errorCode":null,"errorMessage":"mycareersfuture: entry \"${entry?.name || '(unnamed)'}\" has no mycareersfuture.keywords[] and no config/profile.yml target_roles to fall back to","messagePattern":"mycareersfuture: entry \"(.+?)\" has no mycareersfuture\\.keywords\\[\\] and no config/profile\\.yml target_roles to fall back to","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"providers/mycareersfuture.mjs","lineNumber":167,"sourceCode":"  },\n\n  /**\n   * Fetches and normalizes postings from MyCareersFuture's public search API.\n   * @param {{ name?: string, mycareersfuture?: any, max_pages?: unknown }} entry\n   * @param {{ fetchJson: (url: string, opts?: object) => Promise<any>, maxPages?: number }} ctx\n   * @returns {Promise<Array<{title: string, url: string, company: string, location: string, postedAt?: number}>>}\n   */\n  async fetch(entry, ctx) {\n    const { size, maxPages: configuredMaxPages, keywords: ownKeywords } = parseConfig(entry);\n    let keywords = ownKeywords;\n    // Fall back to config/profile.yml's target_roles when this entry has no\n    // mycareersfuture.keywords[] of its own — same convention vdab.mjs and\n    // jobbankca.mjs use, so a user who already onboarded with target roles\n    // doesn't have to duplicate them into every keyword-required provider's\n    // config by hand.\n    if (!keywords.length) keywords = resolveProfileKeywords();\n    if (!keywords.length) {\n      throw new Error(`mycareersfuture: entry \"${entry?.name || '(unnamed)'}\" has no mycareersfuture.keywords[] and no config/profile.yml target_roles to fall back to`);\n    }\n\n    // Same probe-vs-real-scan split as vdab.mjs/jobbankca.mjs: verify-portals.mjs's\n    // bounded health probe passes ctx.maxPages so a liveness check can't walk\n    // the whole board; a real scan (ctx.maxPages unset) uses the configured cap.\n    const probing = Number.isInteger(ctx?.maxPages) && ctx.maxPages > 0;\n    const pageLimit = probing ? Math.min(ctx.maxPages, configuredMaxPages) : configuredMaxPages;\n\n    /** @param {string} keyword */\n    const fetchKeyword = async (keyword) => {\n      const out = [];\n      for (let page = 0; page < pageLimit; page++) {\n        const json = await fetchJsonWithRetry(ctx, `${API_URL}?limit=${size}&page=${page}`, {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({ search: keyword, sortBy: ['new_posting_date'], page }),\n          redirect: 'error',\n          timeoutMs: 12_000,","sourceCodeStart":149,"sourceCodeEnd":185,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/providers/mycareersfuture.mjs#L149-L185","documentation":"mycareersfuture is a keyword-driven provider: it needs search keywords either from the entry's mycareersfuture.keywords[] or, as a fallback, from target_roles in config/profile.yml. fetch() throws this error when both sources are empty, naming the entry so the user knows which board config to fix. It fails fast instead of issuing a useless query-less request.","triggerScenarios":"Running a scan where the mycareersfuture entry defines no keywords[] AND config/profile.yml has no target_roles (or resolveProfileKeywords() returns empty because the profile file is missing/unpersonalized).","commonSituations":"A fresh onboarding where config/profile.yml still contains placeholder/empty target_roles; a hand-written portals.yml entry for mycareersfuture that omitted keywords; a renamed profile field after a config schema change so resolveProfileKeywords() finds nothing.","solutions":["Add keywords to the entry in portals.yml: e.g. mycareersfuture: { keywords: ['data engineer', 'backend developer'] }.","Or set target_roles in config/profile.yml to your role list so the fallback resolves.","If target_roles should exist, complete profile personalization — an unpersonalized template profile yields no keywords.","Re-run the scan after configuring; no code change is needed."],"exampleFix":"// before (portals.yml)\n- name: MyCareersFuture\n  careers_url: https://www.mycareersfuture.gov.sg\n// after\n- name: MyCareersFuture\n  careers_url: https://www.mycareersfuture.gov.sg\n  mycareersfuture:\n    keywords: [\"data engineer\", \"backend developer\"]","handlingStrategy":"validation","validationCode":"const keywords = entry?.mycareersfuture?.keywords ?? [];\nconst profileRoles = config?.profile?.target_roles ?? [];\nif (!keywords.length && !profileRoles.length) {\n  throw new Error('mycareersfuture: configure keywords[] or profile target_roles before scanning');\n}","typeGuard":null,"tryCatchPattern":"try {\n  await provider.fetch(entry, ctx);\n} catch (err) {\n  if (String(err.message).includes('no mycareersfuture.keywords[]')) {\n    console.error('Add keywords to the entry or set target_roles in config/profile.yml');\n    return [];\n  }\n  throw err;\n}","preventionTips":["Personalize config/profile.yml target_roles during onboarding — the fallback depends on it.","Give keyword-required providers explicit keywords[] in portals.yml rather than relying on the profile fallback.","Run a config lint before scans to catch entries missing required keyword fields.","Re-check provider config after profile schema changes; renamed fields silently empty the fallback."],"tags":["config","keywords","mycareersfuture"],"backgroundTag":"missing-required-config-field","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"}