You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 ManagementMachine LearningpythonsqlUnderwater 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é"
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 casing — SQL, 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:
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.
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:
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. */constLOWERCASE_BRAND_LABELS=newSet(["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).
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=python → tag=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.
Problem
A recognized skill renders as its canonical
idwhenever its dictionary entry carries nolabel, so the UI showssql,javascript,aws,postgresql,nlp— lowercase, un-branded — sitting beside title-cased labels and title-cased free-text chips. The/jobs/skills row reads: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
labelwas authored in lowercase ("people management" → "People Management") and its acceptance criterion — everylabelstarts with a capital or a digit — is enforced byskills.test.ts:44. But the criterion only constrains entries that have a label. 139 of the 176 entries have none and fall throughidToLabel's?? idto 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.iOSis the sharpest case:{ id: "ios", aliases: ["ios"] }renders the mobile platform asios.Root cause
getSkillIndex()buildsidToLabelwith an id fallback (src/lib/jd-match/skills.ts:331):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:
deriveSkills—/jobs/chips +JobQuerySummary+SkillTermGuidance's "Already in your résumé"src/lib/job-search/query-builder.ts:386sqlchip besideMachine LearningchipmissingTermLabel— the+ <skill>suggestion pillssrc/components/features/TermQualityAdvisory.tsx:83+ sqlextractSkillPass— JD requirement terms on/jd-fit/and on the job result cardssrc/lib/jd-match/extract-jd-terms.ts:424→ rendered atsrc/components/features/JdMatch.tsx:132andsrc/components/features/JobResultCard.tsx:134graphqlin the requirements listCounted on
main(dd6527c):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 casing —SQL,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
labelfor the 139 entries that lack one, insrc/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
mainbefore scoping — matching is case-insensitive on every path a display label reaches, so this is a display-only change:search.ts:161-162lowercases eachquery.skillsentry before building its match pattern.normalizeSkillKey(role-profiles.ts) lowercases and strips separators, soterm-quality.ts's verdicts/suppression andquery-builder.ts'sgetCanonicalSkillKeysare unaffected.coverage.tsmatches a skill-sourced term byterm.id, never bydisplay(src/lib/jd-match/coverage.ts:72), and its phrase path lowercases with aniflag (corpusMentionsPhrase,:143-148).Two things do move, and both belong in the PR body:
providers/keywords.tssendsquery.skillsentries verbatim —primaryKeywordbecomes Jobicy'stag=, andsearchPhrasebecomes Remotive/Arbeitnow'ssearch=when the résumé has no title.tag=pythonbecomestag=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.role-profiles.test.tsandcoverage.test.tsuse ids ("python"as aRoleProfile.skillsentry) 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, anduseJobSearch.test.tsxeach 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:Leave
aliasesexactly as they are — they are matched lowercased and never shown, andskills.test.tsasserts that.2. Fix the label-casing invariant so brand casing survives
skills.test.ts:44currently asserts every label starts with[A-Z0-9].iOS,dbt, andjQueryare 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:…and assert
LOWERCASE_BRAND_LABELS.has(label) || /^[A-Z0-9]/.test(label). Keep #607's failure message shape (it names the offendingid: "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:
Keep the
?? entry.idfallback ingetSkillIndex— it is the total-function guard for a malformed entry, and this test is what makes it unreachable in practice.4. Bump
SKILLS_DICTIONARY_VERSIONsrc/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_VERSIONandROLE_PROFILES_VERSIONdo not move: no rule and no profile changes, same call #607 made.5. Re-read the affected test expectations
Run
npx vitest runand 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
src/lib/jd-match/skills.tshas alabel; asserted by a test inskills.test.ts.[A-Z0-9]or is in the documented lowercase-brand allowlist; asserted, with the offending entries named in the failure.aliasesarray 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 chipsSQL, AWS, iOS, Machine Learning. Asserted inquery-builder.test.ts.+ <skill>suggestion pills read the same way — amissingentry forci-cd/sqlrendersCI/CD/SQL, asserted throughmissingTermLabel.getSkillIndex().aliasToIdmaps 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.tsverdicts 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 interm-quality.test.tsstill pass untouched.SKILLS_DICTIONARY_VERSIONis 1.2 and its pinning test is updated;TERM_QUALITY_VERSIONstays 1.2 andROLE_PROFILES_VERSIONstays 1.1.role-profiles.tsskill lists andRoleProfile.skillsexpectations still read lowercase kebab ids).npm run verifyis green (fallow findings are report-only — seeCLAUDE.md).tag=python→tag=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 arequery-builder.ts:386,TermQualityAdvisory.tsx:83, andextract-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
tests/fixtures/pdfs/, so the fixture-PII rules do not apply.