Skip to content

jd-match/skills.ts: 139 entries have no display label, so a recognized skill renders its lowercase id (sql, aws, ios) #681

Description

@rohithgollapalli

Problem

A recognized skill renders as its canonical id whenever its dictionary entry carries no label, so the UI shows sql, javascript, aws, postgresql, nlp — lowercase, un-branded — sitting beside title-cased labels and title-cased free-text chips. The /jobs/ skills row reads:

People Management Machine Learning python sql Underwater Basket Weaving

and SkillTermGuidance's summary line on / reads "Already in your résumé: Machine Learning, python, sql".

This is the residual of #607. That issue fixed the 28 entries whose label was authored in lowercase ("people management" → "People Management") and its acceptance criterion — every label starts with a capital or a digit — is enforced by skills.test.ts:44. But the criterion only constrains entries that have a label. 139 of the 176 entries have none and fall through idToLabel's ?? id to a lowercase kebab id. The card's casing is therefore still a function of how a dictionary entry happened to be written, which is exactly the leak #607 set out to close.

iOS is the sharpest case: { id: "ios", aliases: ["ios"] } renders the mobile platform as ios.

Root cause

getSkillIndex() builds idToLabel with an id fallback (src/lib/jd-match/skills.ts:331):

idToLabel.set(entry.id, entry.label ?? entry.id);

Ids are lowercase by construction (they are stable keys, matched case-folded), so the fallback is a key being used as display text. Every render site inherits it:

Site Line What the user sees
deriveSkills/jobs/ chips + JobQuerySummary + SkillTermGuidance's "Already in your résumé" src/lib/job-search/query-builder.ts:386 sql chip beside Machine Learning chip
missingTermLabel — the + <skill> suggestion pills src/components/features/TermQualityAdvisory.tsx:83 + sql
extractSkillPass — JD requirement terms on /jd-fit/ and on the job result cards src/lib/jd-match/extract-jd-terms.ts:424 → rendered at src/components/features/JdMatch.tsx:132 and src/components/features/JobResultCard.tsx:134 graphql in the requirements list

Counted on main (dd6527c):

$ node -e ""   # regex over the SKILLS array
total 176 | labelled 37 | unlabelled 139

The unlabelled 139 include every language, framework, datastore, cloud, and tool: javascript, typescript, python, sql, html, css, react, next.js, vue, node.js, graphql, postgresql, mysql, mongodb, redis, aws, gcp, azure, kubernetes, docker, terraform, kafka, spark, pytorch, tensorflow, nlp, llm, rag, etl, dbt, ios, android, jest, vitest, jira, figma, oauth, jwt, saml, sso, soc2, gdpr, hipaa, …

Why not derive the casing at render time

This was tried during #607 and rejected — do not re-propose it. A title-caser produces Sql, Aws, Jwt, Nlp, Ios, Postgresql, which is worse than the lowercase it replaces. An "uppercase-anywhere means authored intent" variant does not help either, because the id fallback never contains an uppercase letter to begin with. Correct display casing here is brand casingSQL, PostgreSQL, Node.js, GraphQL, iOS, jQuery, dbt — and no rule derives it from a lowercase key. It has to be authored, once, per entry.

Scope

Author a label for the 139 entries that lack one, in src/lib/jd-match/skills.ts. Data only: no render site changes, no new helper, no matching change.

What is safe, and what actually moves

Verified on main before scoping — matching is case-insensitive on every path a display label reaches, so this is a display-only change:

  • search.ts:161-162 lowercases each query.skills entry before building its match pattern.
  • normalizeSkillKey (role-profiles.ts) lowercases and strips separators, so term-quality.ts's verdicts/suppression and query-builder.ts's getCanonicalSkillKeys are unaffected.
  • coverage.ts matches a skill-sourced term by term.id, never by display (src/lib/jd-match/coverage.ts:72), and its phrase path lowercases with an i flag (corpusMentionsPhrase, :143-148).
  • Aliases are untouched, so nothing about recognition changes.

Two things do move, and both belong in the PR body:

  1. Egress casing. providers/keywords.ts sends query.skills entries verbatim — primaryKeyword becomes Jobicy's tag=, and searchPhrase becomes Remotive/Arbeitnow's search= when the résumé has no title. tag=python becomes tag=Python. No new field egresses and the privacy posture is unchanged (still the user-editable query terms, never résumé text), but confirm the feeds still return results for the changed case — a tag index that is case-sensitive would silently return fewer postings.
  2. Test expectations. Any assertion on a display string moves; assertions on ids must not. role-profiles.test.ts and coverage.test.ts use ids ("python" as a RoleProfile.skills entry) and must stay lowercase. query-builder.test.ts, extract-jd-terms.test.ts, term-quality.test.ts, deep-links.test.ts, JdMatch.test.ts, FindJobsLauncher.test.tsx, and useJobSearch.test.tsx each carry display-string expectations to re-read one by one. Do not blanket find-and-replace: the same literal "python" is an id in one file and a display label in the next.

Implementation plan

1. Author the 139 labels

In src/lib/jd-match/skills.ts, section by section, following the file's existing convention ({ id: "power-bi", label: "Power BI", aliases: [...] }). Use the project's own branded spelling, not a mechanical transform:

{ id: "javascript", label: "JavaScript", aliases: ["javascript", "js", "ecmascript"] },
{ id: "sql", label: "SQL", aliases: ["sql"] },
{ id: "node.js", label: "Node.js", aliases: [...] },
{ id: "postgresql", label: "PostgreSQL", aliases: [...] },
{ id: "ios", label: "iOS", aliases: ["ios"] },
{ id: "dbt", label: "dbt", aliases: ["dbt"] },   // lowercase is the brand

Leave aliases exactly as they are — they are matched lowercased and never shown, and skills.test.ts asserts that.

2. Fix the label-casing invariant so brand casing survives

skills.test.ts:44 currently asserts every label starts with [A-Z0-9]. iOS, dbt, and jQuery are correct and would fail it. Replace the blanket rule with an explicit, commented allowlist so a genuinely-lowercase brand is a deliberate entry rather than an accident:

/** Brands whose own casing starts lowercase. Every other label must not. */
const LOWERCASE_BRAND_LABELS = new Set(["iOS", "dbt", "jQuery"]);

…and assert LOWERCASE_BRAND_LABELS.has(label) || /^[A-Z0-9]/.test(label). Keep #607's failure message shape (it names the offending id: "label" pairs), because the next person to add an entry will meet this test before they meet this issue.

3. Add the "no entry falls back to its id" invariant

The point of the change is that the fallback stops being reachable for display. Pin it:

it("gives every entry an authored display label, so nothing renders its kebab id", () => {
  expect(SKILLS.filter((s) => !s.label).map((s) => s.id)).toEqual([]);
});

Keep the ?? entry.id fallback in getSkillIndex — it is the total-function guard for a malformed entry, and this test is what makes it unreachable in practice.

4. Bump SKILLS_DICTIONARY_VERSION

src/lib/jd-match/skills.ts:70 — 1.1 → 1.2, with a changelog line. Display text is part of this table's data. TERM_QUALITY_VERSION and ROLE_PROFILES_VERSION do not move: no rule and no profile changes, same call #607 made.

5. Re-read the affected test expectations

Run npx vitest run and work the failures one at a time, deciding per assertion whether the literal is an id (leave) or a display label (update). Add a line of intent where the update is not obvious.

Acceptance criteria

  • Every entry in src/lib/jd-match/skills.ts has a label; asserted by a test in skills.test.ts.
  • Every label either starts with [A-Z0-9] or is in the documented lowercase-brand allowlist; asserted, with the offending entries named in the failure.
  • No aliases array changed — the alias-lowercase test and the multi-word leadership-alias test still pass unmodified.
  • /jobs/ renders no lowercase kebab chip for a recognized skill: a résumé with ["SQL", "AWS", "iOS", "machine learning"] produces chips SQL, AWS, iOS, Machine Learning. Asserted in query-builder.test.ts.
  • The + <skill> suggestion pills read the same way — a missing entry for ci-cd/sql renders CI/CD/SQL, asserted through missingTermLabel.
  • Recognition is unchanged: getSkillIndex().aliasToId maps exactly the same alias set to the same ids as before the change (spot-checked in a test, e.g. k8s → kubernetes, js → javascript).
  • term-quality.ts verdicts and suppressions are unchanged for a query whose chips only changed case — in particular the jd-match/skills.ts: leadership terms have no aliases, so the advisory suggests skills the résumé already has #594/Skill guidance suggests skills the résumé already states in other words (and renders canonical labels lowercase) #607 end-to-end suppression cases in term-quality.test.ts still pass untouched.
  • SKILLS_DICTIONARY_VERSION is 1.2 and its pinning test is updated; TERM_QUALITY_VERSION stays 1.2 and ROLE_PROFILES_VERSION stays 1.1.
  • No id literal was changed to satisfy a display assertion (role-profiles.ts skill lists and RoleProfile.skills expectations still read lowercase kebab ids).
  • npm run verify is green (fallow findings are report-only — see CLAUDE.md).
  • PR body states what the egress change is (tag=pythontag=Python) and that a feed still returns results for the new casing.

Reuse analysis

Capability: choosing the display string for a canonical skill.

Existing surfaces found: getSkillIndex().idToLabel (src/lib/jd-match/skills.ts:331) is the single mapping; its three consumers are query-builder.ts:386, TermQualityAdvisory.tsx:83, and extract-jd-terms.ts:424.

Decision: extend the existing data. No new component, no new helper, no second mapping. #607 already established that a derived casing rule beside this table is the wrong shape — it cannot reproduce brand casing, and a second rule that disagrees with the authored labels is worse than the lowercase it replaces.

Notes

  • No PDF fixture is involved; nothing here touches tests/fixtures/pdfs/, so the fixture-PII rules do not apply.
  • Good candidate for a newcomer: the work is bounded, the file is flat data, and the tests say when it is done. The judgement is per-entry brand casing, not architecture.

Metadata

Metadata

Labels

bugSomething isn't workinggood first issueGood for newcomersimprovementEnhancing existing functionality

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions