fix(archive): derive <company>_<role> as a single path component (#329)

Extends the canonical Subfolder-naming rule by citation to all six archive derivation sites (apply, gmail-sync, interview, notion-sync, outcome, assistant SKILL.md), adds a fail-closed guard for an empty derived name, and pins every site with mutation-verified tests. framework_version 1.3.3 -> 1.3.4.

jakob1379 independently specified the same fix in his fork's issue #22 before this PR's rework.

Co-authored-by: ayobamiseun <66267222+ayobamiseun@users.noreply.github.com>
This commit is contained in:
Ayobami Adegoke
2026-08-18 21:03:16 +02:00
committed by GitHub
co-authored by ayobamiseun
parent 5c423b4064
commit 2ff1085254
9 changed files with 122 additions and 9 deletions
+2
View File
@@ -81,6 +81,8 @@ Also read the most recent existing CV and cover letter files for concrete struct
- **Engage nice-to-haves by name** where the profile supports honest adjacency (e.g. "conceptually aligned with <named tool>"), and use the posting's own term over a synonym wherever it is truthfully applicable - including in CV section headings (a posting hiring for "MLOps" should find a heading containing "MLOps", not only a paraphrase). - **Engage nice-to-haves by name** where the profile supports honest adjacency (e.g. "conceptually aligned with <named tool>"), and use the posting's own term over a synonym wherever it is truthfully applicable - including in CV section headings (a posting hiring for "MLOps" should find a heading containing "MLOps", not only a paraphrase).
- **Address stated logistics and prerequisites** in the cover letter where the posting raises them: security clearance willingness, start date or availability, commute or location fit, and the posting's reference/job ID where one exists. When the employer operates across several countries, a truthful language-capabilities sentence mapped to their footprint is high-value targeting. - **Address stated logistics and prerequisites** in the cover letter where the posting raises them: security clearance willingness, start date or availability, commute or location fit, and the posting's reference/job ID where one exists. When the employer operates across several countries, a truthful language-capabilities sentence mapped to their footprint is high-value targeting.
*In both filenames below, `<company>_<role>` is derived by the **Subfolder naming** rule in `documents/README.md` — the same rule `/outcome` Step 1.4 uses for the archive folder, so a `/` or other path character in a company or role name can never split the filename across directories.*
### CV (`cv/main_<company>_<role><CV_EXT>`) ### CV (`cv/main_<company>_<role><CV_EXT>`)
- In the **CV language from the profile** (the `CV language:` line in CLAUDE.md's Identity section). When the profile does not set one, default to **English**. Never switch language per posting - the CV language is a profile-level choice, so all CVs stay consistent and reusable - In the **CV language from the profile** (the `CV language:` line in CLAUDE.md's Identity section). When the profile does not set one, default to **English**. Never switch language per posting - the CV language is a profile-level choice, so all CVs stay consistent and reusable
- Follow the moderncv/banking format from `05-cv-templates.md` - Follow the moderncv/banking format from `05-cv-templates.md`
+1 -1
View File
@@ -28,7 +28,7 @@ Confirm the Gmail MCP tools (`mcp__claude_ai_Gmail__*`) are available. If not, t
1. Read `job_search_tracker.csv`. If it does not exist, tell the user there is nothing to sync against yet (suggest `/outcome` or `/apply` first) and stop. Do not create it here - `/gmail-sync` never originates new applications, only updates existing ones. 1. Read `job_search_tracker.csv`. If it does not exist, tell the user there is nothing to sync against yet (suggest `/outcome` or `/apply` first) and stop. Do not create it here - `/gmail-sync` never originates new applications, only updates existing ones.
2. Read `gmail_sync/state.json` (create if missing: `{"last_sync": null, "processed_message_ids": []}`). 2. Read `gmail_sync/state.json` (create if missing: `{"last_sync": null, "processed_message_ids": []}`).
3. Build the set of **open applications**: tracker rows whose `status` is not **Final** (per the **Tracker status vocabulary** in `/outcome`). For each, derive its archive folder `documents/applications/<company>_<role>/` (lowercase, underscores - same convention as `/outcome`) and check whether `outcome.md` exists there. 3. Build the set of **open applications**: tracker rows whose `status` is not **Final** (per the **Tracker status vocabulary** in `/outcome`). For each, derive its archive folder `documents/applications/<company>_<role>/` by the **Subfolder naming** rule in `documents/README.md` and check whether `outcome.md` exists there. Reuse this exact derived path for any write in Step 7a.
**`drafted` rows stay in this set, and are the reason it is worth searching.** `/apply` writes them but never submits; the user submits by hand and may not think to run `/outcome`. A reply arriving against a row still marked `drafted` is exactly that case, and the row holds the company name the search needs. **`drafted` rows stay in this set, and are the reason it is worth searching.** `/apply` writes them but never submits; the user submits by hand and may not think to run `/outcome`. A reply arriving against a row still marked `drafted` is exactly that case, and the row holds the company name the search needs.
4. If `$ARGUMENTS` named a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess. 4. If `$ARGUMENTS` named a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess.
+3 -3
View File
@@ -21,7 +21,7 @@ v1 preps for a **specific application**. Generic no-target practice is out of sc
## Step 1: Load the Application Context ## Step 1: Load the Application Context
1. **The archive** (started by `/apply`, maintained by `/outcome`): `documents/applications/<company>_<role>/` 1. **The archive** (started by `/apply`, maintained by `/outcome`): derive `<company>_<role>` by the **Subfolder naming** rule in `documents/README.md`, then use `documents/applications/<company>_<role>/`.
- `job_posting.md` - the exact posting the user applied to - `job_posting.md` - the exact posting the user applied to
- `cv_draft.tex` and `cover_letter.tex` - what was actually submitted. **These are what the interviewer read**; every talking point must be consistent with their claims. - `cv_draft.tex` and `cover_letter.tex` - what was actually submitted. **These are what the interviewer read**; every talking point must be consistent with their claims.
- `outcome.md` - the stage reached so far and any recorded feedback from earlier stages. Feedback from stage N is the highest-value input for stage N+1 prep. - `outcome.md` - the stage reached so far and any recorded feedback from earlier stages. Feedback from stage N is the highest-value input for stage N+1 prep.
@@ -76,7 +76,7 @@ Pick 4-6 from `07`'s categories, customized to the research and the stage: role
### 6. Logistics ### 6. Logistics
The phone/video tips from `07` when the format calls for them, plus date and interviewer names as a header. The phone/video tips from `07` when the format calls for them, plus date and interviewer names as a header.
Save the pack to `documents/applications/<company>_<role>/interview_prep_<stage>.md` (create the folder if this application predates `/outcome`). The folder is gitignored, so the pack stays personal; one file per stage, so earlier packs remain as history. Present the pack in chat as well - the file is the artifact, the conversation is the delivery. Save the pack in the archive folder derived in Step 1 as `interview_prep_<stage>.md` (create the folder if this application predates `/outcome`). The folder is gitignored, so the pack stays personal; one file per stage, so earlier packs remain as history. Present the pack in chat as well - the file is the artifact, the conversation is the delivery.
--- ---
@@ -104,6 +104,6 @@ If Step 3 drafted new STAR answers the user approved for keeps, remind them thos
2. **Honesty on gaps.** Weak matches get bridge answers (acknowledge → adjacent experience → learning path), never invented experience. Same rule as everywhere else in this repo. 2. **Honesty on gaps.** Weak matches get bridge answers (acknowledge → adjacent experience → learning path), never invented experience. Same rule as everywhere else in this repo.
3. **Verified research only.** Company specifics go in the pack only after independent confirmation. Interviewer notes stick to public professional information. 3. **Verified research only.** Company specifics go in the pack only after independent confirmation. Interviewer notes stick to public professional information.
4. **Stage-appropriate prep.** A phone screen pack and a final-round pack are different documents; recorded feedback from earlier stages takes priority over generic question lists. 4. **Stage-appropriate prep.** A phone screen pack and a final-round pack are different documents; recorded feedback from earlier stages takes priority over generic question lists.
5. **Write only to the application archive** — with one exception. The prep pack lands in `documents/applications/<company>_<role>/`; framework files are not edited, except appending user-approved STAR examples to `07-interview-prep.md` on explicit request. 5. **Write only to the application archive** — with one exception. The prep pack lands in the archive folder derived in Step 1; framework files are not edited, except appending user-approved STAR examples to `07-interview-prep.md` on explicit request.
**The exception is `01-candidate-profile.md`.** Interview prep is where new facts surface most often: the user recalls a metric, corrects a scope, or fills in a STAR stub. When that happens, write the fact into the profile, as well as putting it in the prep pack. A fact recorded only in prep material reads as unsupported to a later drafting session and gets stripped from CVs as a fabrication. Prep files are not a substitute for the profile. **The exception is `01-candidate-profile.md`.** Interview prep is where new facts surface most often: the user recalls a metric, corrects a scope, or fills in a STAR stub. When that happens, write the fact into the profile, as well as putting it in the prep pack. A fact recorded only in prep material reads as unsupported to a later drafting session and gets stripped from CVs as a fabrication. Prep files are not a substitute for the profile.
+1 -1
View File
@@ -102,7 +102,7 @@ The page body is what makes a row worth clicking. Build it **only from stored da
1. **Fit summary** - a short section from `seen_jobs.json` fields: score, verdict, quick-fit level, first-seen and ranked dates. If the job is in the tracker, add the application timeline (date applied, channel, current status, dated notes from the `notes` column) and name the submitted documents from `cv_file`/`cover_letter_file` (filenames only - the documents themselves never sync). **When the status is `drafted`, write "drafted YYYY-MM-DD, not yet submitted" instead of a date applied, and call the files drafts rather than submitted documents** (page bodies are write-once - Step 4.3). 1. **Fit summary** - a short section from `seen_jobs.json` fields: score, verdict, quick-fit level, first-seen and ranked dates. If the job is in the tracker, add the application timeline (date applied, channel, current status, dated notes from the `notes` column) and name the submitted documents from `cv_file`/`cover_letter_file` (filenames only - the documents themselves never sync). **When the status is `drafted`, write "drafted YYYY-MM-DD, not yet submitted" instead of a date applied, and call the files drafts rather than submitted documents** (page bodies are write-once - Step 4.3).
2. **The posting** - WebFetch the job URL and write a readable digest: what the role is, key requirements, practical details (location, deadline, salary if stated). Retry a 403 with browser headers per `.claude/skills/job-application-assistant/09-web-research.md` first. If the fetch still fails or redirects to a listing page, write "Posting no longer available (checked YYYY-MM-DD)" - **never reconstruct a posting from memory**. 2. **The posting** - WebFetch the job URL and write a readable digest: what the role is, key requirements, practical details (location, deadline, salary if stated). Retry a 403 with browser headers per `.claude/skills/job-application-assistant/09-web-research.md` first. If the fetch still fails or redirects to a listing page, write "Posting no longer available (checked YYYY-MM-DD)" - **never reconstruct a posting from memory**.
3. **Links** - the posting URL; if `documents/applications/<company>_<role>/` exists locally, name it as the local archive path (plain text - the destination cannot link into the filesystem). 3. **Links** - the posting URL; derive `<company>_<role>` by the **Subfolder naming** rule in `documents/README.md`, and if that archive exists locally, name its path (plain text - the destination cannot link into the filesystem).
Keep the page under ~40 blocks; this is a briefing, not a mirror of the posting. Keep the page under ~40 blocks; this is a briefing, not a mirror of the posting.
+2 -2
View File
@@ -36,9 +36,9 @@ Follow these steps **in order**.
3. **Without an argument:** list all rows whose status is not final (see **Tracker status vocabulary** below) as a numbered table (company, role, date applied, current status, deadline, days quiet, follow-ups sent) and ask which to update. The two derived columns come straight from existing data: **days quiet** counts from the row's `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. If any open row is 10+ days quiet with fewer than two follow-ups sent, add one line under the table: "Some of these have gone quiet - want a follow-up draft? (Step 2b)". If every row is resolved, say so and stop. 3. **Without an argument:** list all rows whose status is not final (see **Tracker status vocabulary** below) as a numbered table (company, role, date applied, current status, deadline, days quiet, follow-ups sent) and ask which to update. The two derived columns come straight from existing data: **days quiet** counts from the row's `date` or the latest dated entry in `notes`, whichever is more recent; **follow-ups sent** counts the `followed up YYYY-MM-DD` markers in `notes`. If any open row is 10+ days quiet with fewer than two follow-ups sent, add one line under the table: "Some of these have gone quiet - want a follow-up draft? (Step 2b)". If every row is resolved, say so and stop.
**`drafted` rows are listed but never counted as quiet** - nothing was sent, so nobody is late replying. List them under their own heading ("Drafted, not yet submitted"), leave **days quiet** and **follow-ups sent** blank, and keep them out of the follow-up offer above. **`drafted` rows are listed but never counted as quiet** - nothing was sent, so nobody is late replying. List them under their own heading ("Drafted, not yet submitted"), leave **days quiet** and **follow-ups sent** blank, and keep them out of the follow-up offer above.
**Deadline urgency is the one clock that does apply to a drafted row.** Show the `deadline` column when the row has one and leave it blank otherwise. Mark a deadline within 7 days with 🔥 and one that has already passed with ⚠, on the same 7-day threshold `/rank` Step 3 uses so the two commands never disagree. A passed deadline on a `drafted` row is the failure this column exists to catch - documents written, never sent, and now unsendable - so name it in one line under the table rather than leaving the user to compare dates. This changes nothing about the follow-up offer: a drafted row is still never chased, because nobody is late replying to something that was never sent. **Deadline urgency is the one clock that does apply to a drafted row.** Show the `deadline` column when the row has one and leave it blank otherwise. Mark a deadline within 7 days with 🔥 and one that has already passed with ⚠, on the same 7-day threshold `/rank` Step 3 uses so the two commands never disagree. A passed deadline on a `drafted` row is the failure this column exists to catch - documents written, never sent, and now unsendable - so name it in one line under the table rather than leaving the user to compare dates. This changes nothing about the follow-up offer: a drafted row is still never chased, because nobody is late replying to something that was never sent.
4. Derive the archive folder name: `documents/applications/<company>_<role>/` - lowercase, underscores for spaces (the convention documented in `documents/README.md`). Check whether the folder and an `outcome.md` already exist - if so, you are updating, not creating.
4. Derive the archive folder name: `documents/applications/<company>_<role>/` by the **Subfolder naming** rule in `documents/README.md`. Check whether the folder and an `outcome.md` already exist - if so, you are updating, not creating.
--- ---
@@ -5,7 +5,7 @@ description: >
and preparing for interviews. Triggers on keywords like: job posting, job application, CV, and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Bash, Edit, Write, AskUserQuestion allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Bash, Edit, Write, AskUserQuestion
framework_version: 1.3.3 framework_version: 1.3.4
--- ---
# Job Application Assistant # Job Application Assistant
@@ -27,6 +27,7 @@ When the user provides a job posting (URL or text), follow this workflow:
- Ask the user if they want to proceed with an application - Ask the user if they want to proceed with an application
### Step 2: Tailor CV ### Step 2: Tailor CV
- Before writing either document, derive `<company>_<role>` once by the **Subfolder naming** rule in `documents/README.md`; reuse that exact value for the CV, cover letter, and Step 3b archive path. If the rule says to stop because the derived name is empty, stop before creating any file.
- Read the most relevant existing CV variant from `cv/` as a starting point - Read the most relevant existing CV variant from `cv/` as a starting point
- Follow the guidelines in `05-cv-templates.md` - Follow the guidelines in `05-cv-templates.md`
- Create `cv/main_<company>_<role>.tex` with tailored content - Create `cv/main_<company>_<role>.tex` with tailored content
+14 -1
View File
@@ -32,6 +32,20 @@ per-file diff commands.
### Fixed ### Fixed
- **A `/` in a company or role name no longer nests the application archive one level too deep**
(jakob1379/ai-job-search#22). `Novo Nordisk A/S` derived
`documents/applications/novo_nordisk_a/s_data_scientist/` - written and found by every command
that derives the path, silently skipped by the two that enumerate it, so the application never
appeared in `/html-report`'s dashboard and `/setup`'s calibration never learned from it. The
**Subfolder naming** rule in `documents/README.md` now drops every character that is not a
letter, digit or underscore (collapsing underscore runs, trimming the ends), and the derivation
sites - `/apply`, `/outcome`, the direct application skill, `/gmail-sync`, `/interview`, and
`/notion-sync` - cite that rule instead of paraphrasing it. An all-punctuation value that derives
to an empty name now stops for user correction instead of writing into the archive root. The
application assistant's `framework_version` moves 1.3.3 → 1.3.4. **Already-nested archives are
not migrated**: an archive written under the old rule stays where it is until the user moves it;
only newly derived names change. Thanks @jakob1379 for the report.
- **The `/html-report` dashboard now reads and renders the tracker's `deadline`** (follow-up to - **The `/html-report` dashboard now reads and renders the tracker's `deadline`** (follow-up to
#319). The tracker gained a fourteenth `deadline` column and every other consumer (`/outcome`, #319). The tracker gained a fourteenth `deadline` column and every other consumer (`/outcome`,
`/upskill`, `/notion-sync`) was updated to know it, but the dashboard's Step 1 field `/upskill`, `/notion-sync`) was updated to know it, but the dashboard's Step 1 field
@@ -95,7 +109,6 @@ per-file diff commands.
every CV open in fullscreen presentation mode. `05-cv-templates.md`'s preamble copy stays every CV open in fullscreen presentation mode. `05-cv-templates.md`'s preamble copy stays
in lockstep (framework_version 1.4.0 -> 1.4.1). Verified on moderncv 2.5.1: exit 0, in lockstep (framework_version 1.4.0 -> 1.4.1). Verified on moderncv 2.5.1: exit 0,
exactly 2 pages, rendering unchanged. exactly 2 pages, rendering unchanged.
## [1.5.0] - 2026-08-12 ## [1.5.0] - 2026-08-12
### Added ### Added
+5
View File
@@ -116,6 +116,11 @@ A record of past job applications. Each subfolder is one application.
You can maintain these folders by hand, or let the **`/outcome`** command do it: it records progress updates and final results conversationally, archives the submitted drafts and, if `/apply` has not already written it, the posting text, keeps `outcome.md` in the format below, and updates `job_search_tracker.csv` in the same step. You can maintain these folders by hand, or let the **`/outcome`** command do it: it records progress updates and final results conversationally, archives the submitted drafts and, if `/apply` has not already written it, the posting text, keeps `outcome.md` in the format below, and updates `job_search_tracker.csv` in the same step.
**Subfolder naming:** `<company>_<role>` — lowercase, underscores for spaces. **Subfolder naming:** `<company>_<role>` — lowercase, underscores for spaces.
Every character that is not a letter, digit or underscore is dropped (so `Novo Nordisk A/S`
becomes `novo_nordisk_as`), runs of underscores collapse to one, and leading and trailing
underscores are trimmed. If the derived name is empty, stop and ask the user for a company or
role containing at least one letter or digit; do not create a file or directory. Every non-empty
result is therefore a single path component whatever the posting contains.
Examples: Examples:
``` ```
+92
View File
@@ -29,9 +29,11 @@ APPLY = COMMANDS / "apply.md"
OUTCOME = COMMANDS / "outcome.md" OUTCOME = COMMANDS / "outcome.md"
GMAIL_SYNC = COMMANDS / "gmail-sync.md" GMAIL_SYNC = COMMANDS / "gmail-sync.md"
HTML_REPORT = COMMANDS / "html-report.md" HTML_REPORT = COMMANDS / "html-report.md"
INTERVIEW = COMMANDS / "interview.md"
NOTION_SYNC = COMMANDS / "notion-sync.md" NOTION_SYNC = COMMANDS / "notion-sync.md"
SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md" SKILL = REPO / ".claude" / "skills" / "job-application-assistant" / "SKILL.md"
SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md" SCRAPER = REPO / ".claude" / "skills" / "job-scraper" / "SKILL.md"
DOCS_README = REPO / "documents" / "README.md"
TRACKER_HEADER = ( TRACKER_HEADER = (
"date,company,sector,role,role_type,channel,status,contact_person," "date,company,sector,role,role_type,channel,status,contact_person,"
@@ -383,5 +385,95 @@ class DeadlineSurvivesEveryWrite(unittest.TestCase):
self.assertIn(needle, haystack, why) self.assertIn(needle, haystack, why)
class ArchiveNameIsOnePathComponent(unittest.TestCase):
"""`<company>_<role>` must derive a single path component.
`Novo Nordisk A/S` used to derive `novo_nordisk_a/s_<role>/`: every
command that *derives* the path agrees and keeps working, while the
two that *enumerate* `documents/applications/*/` (/setup Path A,
/html-report's glob) silently skip the nested archive. The character
rule lives in one place - documents/README.md's Subfolder naming
block - and the derivation sites cite it rather than restating it
(jakob1379/ai-job-search#22).
"""
CASES = [
(DOCS_README, "## applications/",
"not a letter, digit or underscore is dropped",
"the character rule is stated nowhere else; without it the naming "
"convention leaves `/` untouched and the archive nests"),
(DOCS_README, "## applications/",
"single path component",
"the sentence that says why the rule exists; without it the next "
"edit simplifies the rule back to spaces-only"),
(OUTCOME, "## Step 1: Load State and Identify the Application",
"by the **Subfolder naming** rule in `documents/README.md`",
"Step 1.4 is the derivation every other writer cites; paraphrasing "
"the rule here is how the two copies drifted apart originally"),
(APPLY, "### Requirement coverage (both documents)",
"the same rule `/outcome` Step 1.4 uses",
"CV and cover-letter filenames use the same unsanitised values; a "
"`/` there sends the draft to a path lualatex never writes a PDF "
"back to, and the Step 4 compile check fails on a phantom path"),
(SKILL, "### Step 2: Tailor CV",
"by the **Subfolder naming** rule in `documents/README.md`",
"the /scrape path writes its documents before Step 3b consults /apply, "
"so /apply's filename rule cannot protect it"),
(GMAIL_SYNC, "## Step 2: Load State",
"by the **Subfolder naming** rule in `documents/README.md`",
"gmail-sync both locates and creates archives; its old spaces-only "
"paraphrase would split state across two folders"),
(INTERVIEW, "## Step 1: Load the Application Context",
"by the **Subfolder naming** rule in `documents/README.md`",
"interview must read the same archive /apply and /outcome wrote"),
(INTERVIEW, "### 6. Logistics",
"archive folder derived in Step 1",
"interview must reuse its canonical read path when writing the prep pack"),
(NOTION_SYNC, "## Step 5: Write the Detail Page",
"by the **Subfolder naming** rule in `documents/README.md`",
"notion-sync otherwise reports that the sanitized local archive is absent"),
(DOCS_README, "## applications/",
"If the derived name is empty",
"dropping untrusted punctuation can produce no component at all, which "
"would write files directly under documents/applications"),
]
def test_the_rule_has_one_home_and_every_deriver_cites_it(self):
for path, heading, needle, why in self.CASES:
with self.subTest(file=path.name, rule=needle):
self.assertIn(needle, section(path, heading), why)
@staticmethod
def derive(company, role):
"""The Subfolder naming rule, executed exactly as documented:
lowercase, underscores for spaces, drop every character that is
not a letter/digit/underscore, collapse runs, trim the ends.
(\\w is Unicode in Python 3, so Danish letters survive.)"""
name = f"{company}_{role}".lower().replace(" ", "_")
name = re.sub(r"[^\w]", "", name)
name = re.sub(r"_+", "_", name).strip("_")
return name or None
DERIVATIONS = [
("Novo Nordisk A/S", "Data Scientist", "novo_nordisk_as_data_scientist"),
("Acme", "Data Scientist / ML Engineer", "acme_data_scientist_ml_engineer"),
("Ørsted A/S", "ML Engineer", "ørsted_as_ml_engineer"),
# company/role reach the derivation from untrusted posting text
# (apply.md Step 0), so `..` must not survive either
("../..", "Data Scientist", "data_scientist"),
("../..", "///", None),
]
def test_documented_rule_yields_a_single_path_component(self):
for company, role, expected in self.DERIVATIONS:
with self.subTest(company=company, role=role):
name = self.derive(company, role)
self.assertEqual(name, expected)
if name is None:
continue
self.assertNotIn("/", name)
self.assertNotIn("..", name)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()