Skip to content

Add cohort-specific study plans, prerequisite extraction and two new programmes - #61

Merged
cohm merged 2 commits into
mainfrom
cohort-view
Aug 22, 2026
Merged

Add cohort-specific study plans, prerequisite extraction and two new programmes#61
cohm merged 2 commits into
mainfrom
cohort-view

Conversation

@cohm

@cohm cohm commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Summary

A student picks their admission year and sees the plan as they will study it — years 1–3, with
prerequisites resolved to the kursplan version that applied to their round. CMAST and CMATD are added
from scratch on the same pipeline, taking the app from 6 programmes to 8 (7 offered; TIEMM is
withdrawn, see below).

?program=CTFYS&cohort=HT2023

Previously the extractor assembled one calendar läsår from three different cohorts — right for a
snapshot of a teaching year, wrong for a student reading their own plan.


Why a cohort's plan has to be stitched together

KTH publishes the läsår currently being taught and the next one, deletes the years a cohort has
already passed, and lists its future years with all-zero creditsPerPeriod:

cohort year 1 year 2 year 3
HT2023 gone gone own
HT2024 gone own own
HT2025 own own future
HT2026 own future future

No cohort has all three of its own years, and the gap runs in both directions. Worth flagging
because it's the opposite of the obvious assumption: the problem is not only "next year isn't
scheduled yet", it's also "your first year has been deleted".

Each missing year is borrowed from the nearest cohort that publishes it, preferring the earlier one on
a tie. Availability is probed, not computed — the publishing window moves every year, and a probe
is self-correcting where a formula goes stale.

Provenance is recorded, and shown

Each cohort file opens with a cohortMeta entry giving per year: sourceCohort, approximated, and
a confidence of exact / high / low / unknown. Confidence comes from comparing a different
year both cohorts publish — agreement is evidence they follow the same layout, not proof: CTFYS year 1
is identical between 2025 and 2026 while its year 3 changed between 2023 and 2024. The UI keeps that
distinction:

År 1 och 2 är uppskattade ⓘ

⤷ Årskurs 1: hämtad från HT2025 — ej kontrollerbar
⤷ Årskurs 2: hämtad från HT2024 — osäker
Utbildningsplanen på KTH:s webb gäller alltid före det som visas här →

Naming KTH's published plan as the authority is deliberate — everything borrowed is a best guess, and
the student should be one click from the real thing.


KOPPS is retired; the course page is the live source

This started as "why do the DD courses in CTMAT get no prerequisites?" and ended somewhere more
useful. The KOPPS API still answers but no longer receives updates:

DD1328 page  VT2026: "…slutförd kurs DD1333/DD1310-DD1319/DD1331/DD1337/DD100N/ID1018/ID1022"
DD1328 page  VT2024: "…slutförd kurs DD1310-DD1319/DD1331/DD1337/DD100N/ID1018"
DD1328 KOPPS       : the VT2024 text, verbatim

DD1333 is CTMAT's own first-year programming course, so reading KOPPS left DD1328, DD1380 and
DD1385 with no prerequisites at all. Measured over all 217 course codes: 166 identical, 51
different
, and the page is the fuller text every time — EI1320's API text is
"Slutförd kurs motsvarande SI1200" while the page has "…SI1200 eller SF1693", dropping an
alternative entirely.

The course page's render state is now primary for prerequisites, grading scale, cycle level and
examination modules. The one field only KOPPS has is the English title — the English course page
returns HTTP 500, exactly like the English study-plan route — so nameEn inherits KOPPS's staleness
and its typos (KD1000 is "Chemical Principles for Sustainabillty" there).

Kursplan versions are per-cohort, and the PDFs turned out unnecessary

Prerequisites change, so a cohort must get the version that applied to its round. EI1320:

valid from särskild behörighet
20261 "…motsvarande slutförd kurs SI1200 eller SF1693" + a second requirement naming SK1104/SH1017
20212 "Slutförd kurs motsvarande SI1200"
20192 as 20212
20191, 20182 prose describing knowledge areas, no course codes

Read off the five PDFs in the kursutveckling archive — but the page's syllabusList holds exactly
those five versions
, each tagged with course_valid_from, and that value is the KTH term code.
So the PDFs never need parsing; the structured history was already in the state we were fetching.

CTFYS HT2023 sits EI1320 (y3, P1+P2) in 20252 -> kursplan 20212 -> SI1200 alone
CTFYS HT2024 sits it in 20262                  -> kursplan 20261 -> SI1200, SK1104

23 CTFYS courses have more than one version, 15 in CTMAT, 100 in TIEMM. Any cohort resolving to a
non-newest version says so in the review file.


Prerequisites from free text

The eligibility field is prose; the schema wants two typed lists. Three things make it tractable:
intersecting with the programme's own courses collapses the alternative lists (of
DD1310/…/DD1331 only DD1331 is in CTFYS) and keeps cross-programme codes out of the arrows; type is
decided per clause, so one text can yield both kinds; and a clause with no type marker inherits one
signalled elsewhere in the same text.

Two parsing bugs worth calling out, both found by review rather than by the tests:

  • Ranges were being missed. KOPPS writes alternatives as DD1310-DD1319 as well as slash lists,
    and a plain code scan sees only the endpoints. 43 range expressions across the programmes;
    expanding CINEK's DD1418 recovers DD1317, DD1324 and SF1918.
  • Requirements aren't separated by punctuation. EI1320's 20261 syllabus runs two of them together
    with no period between. Split on sentence boundaries alone, SI1200 and SK1104 — both required
    were reported as alternatives, telling a coordinator to "decide which applies". Splitting also on
    capitalised requirement openers fixes it, and surfaced a category that had been dropped silently:
    requirements on a single examination module ("slutfört moment LAB1 i SH1017").

Measured against the 19 hand-curated CTFYS prerequisites: 19/19 exact, 0 missed, 0 spurious. Two
of the rules came from that set, and CTFYS is the only programme with curated prerequisites, so read
that as a fit to the only labels available rather than proof it generalises.

Uncertain items go to prerequisite-review/<PROGRAM>.md. Two sections are aimed at the program
director
, not at data entry:

  • "The course's own prerequisite list looks out of date — report it" — the course asks for a
    knowledge area this programme teaches while its list of qualifying courses omits ours. The action is
    to report it to that course's owner, not patch it locally. DD1385 and DD1380 are the live cases,
    neither revised since HT2021; DD1328 needed the same and was fixed in its 2026 revision.
  • "Prerequisite does not precede the course" — 15 across the programmes. The rule differs by type:
    slutförd must finish before the course starts, deltagande may overlap (CTFYS's SK1104 spans
    P2–P3 and legitimately participates in SF1674 in P3, so it isn't flagged).

Full-time load as a data-quality check

Full-time study is 15 hp per period, so a year should add up to 15 in every period. validate-data
now warns on any cell that doesn't, merging inriktning lanes that say the same thing (CMAST has 17
lanes and produced 34 identical warnings before).

  • Short — almost always the missing space for valfria kurser.
  • Over — double-counted option groups, untagged inriktningar, or a "minst N hp ur grupp" pool.

CTFYS produces no warnings at all — the calibration check: quiet on the programme that was already
right. Only excesses ≥3 hp are reported, because CTFYS year 1 P1 is legitimately 16.5 (DD1301 is an
optional 1.5 hp course).

Filling the gap, corroborated by the prose

study plan text computed shortfall
CTMAT y3 "Utrymmet för valfria kurser är 7,5 hp per period hela läsåret." 7.5 in P1–P4 agrees
CTFYS y3 "På våren i årskurs 3 finns ett utrymme på 15,0 hp valfria kurser." 7.5 in P3+P4 agrees

The CTFYS case reproduces the hand-authored XY123Z/XY456Z placeholders exactly, which is the
strongest evidence the rule is right. Filling is refused where a year also has a period over
full-time — an excess means the model is incomplete, and placeholders on top would pile invention on a
wrong base. CFATE year 3 gets its arithmetic spelled out instead:

36 hp obligatorisk + 21 hp in option groups = 57 of 60 hp, leaving 3 hp — but 5 villkorligt valfria
courses totalling 26 hp are listed. That is a "minst 3 hp ur grupp" pool; kind: 'minCredits' can
express it, but which courses belong and what the threshold is are editorial calls.


Two renderer fixes

Chart height was a feedback loop. It was seeded from svgRef.current.clientHeight — the height
its own previous render had written onto that node — then only ever grown:

CTFYS fresh        659
-> TIEMM          2994
-> back to CTFYS  3882      <- larger than TIEMM, with 73 rects vs 197
CTFYS reloaded     659

Not merely "failed to shrink" — it compounded. Now derived from the data and set unconditionally.

Course order controls vertical alignment, because each period's bars stack in file order, so a
course spanning periods sits at whatever cumulative height precedes it in each period. I measured
before choosing an approach, since a drag-to-reorder edit mode was the alternative:

approach drift misaligned
current order 250 40 / 66
best simple sort 226 33 / 66
worst simple sort 487 38 / 66
multi-start local search 62 31 / 66

Every simple sort was worse than the hand-curated order — the hand ordering encodes alignment no
comparator can see, so sorting would have destroyed information. The search seeds from the file's own
order plus two alternatives and keeps the best, so it can never degrade a file, and it's deterministic.
CTFYS 15→6, CTMAT 26→5, CFATE 50→15, CINEK 118→9, CMAST 316→5, CMATD 44→3.

A drag UI was rejected because the cohort archive regenerates: hand-placed order would be erased on
every re-extraction unless stored separately, which is 30+ files to maintain by hand against an
automatic ~90 % reduction that survives regeneration.


Two new programmes, and what they revealed

CMAST and CMATD were added with no hand-curated data, pointing dataFile at their HT2025 cohort file
rather than duplicating it into a curated one.

Structure follows the owning school; wording does not. The SCI programmes (CTFYS, CTMAT, CFATE,
COPEN) return a single curriculum with no inriktningar in years 1–3. The ITM ones split from year 2:
CINEK 4, CMATD 6, CMAST 3 in year 2 and 14 in year 3. So both new programmes needed the
specializations registry that no SCI programme does.

Prerequisite wording doesn't split that way, because the text belongs to the course — 23 courses
appear in both an SCI and an ITM programme with identical text. The variation tracks department and
cycle level: TIEMM is the outlier on every measure (80 % "motsvarande" against 15–29 % for the
bachelor programmes) because it's a master's programme drawing 28 DD courses.

CMAST introduced electiveCondition: R (rekommenderad) — 144 participations, used by no other
programme, one per course recommended for the master track an inriktning leads to. The
KNOWN_CONDITIONS assertion caught them rather than dropping them silently, which is what that check
exists for. They're a pool, not a prescribed set — adding them takes AEE year 3 from 15/15/0/0 to
15/33/30/6 hp — so they get the same treatment as V.

This also fixed a bug of mine: --fill-electives was asking KOPPS whether a programme has
inriktningar, and KOPPS returns the master choices for years 4–5 as specialisations (46 for CMAST, 24
for CMATD, dozens for CTFYS). It would have refused to fill elective space in every civilingenjör
programme; CTMAT only escaped because it happens to have zero. Now keyed off the data.


TIEMM withdrawn

A new disabled: true flag takes a programme out of the UI entirely — absent from the dropdown and
not reachable via ?program=. verified: false only hides a plan behind the "show unverified"
checkbox, which is the right level for "extracted but not signed off"; TIEMM needs more than that,
because it's a master's programme whose years 4–5 structure the bachelor-oriented renderer doesn't yet
handle. Its data is still validated — the point is to stop showing it, not to stop checking it.

Master's programmes want a dedicated pass.


Also in here

  • Palette convention. Matematik is blue and elective space yellow in every programme; the rest take
    green, brick and turquoise. Yellow previously meant Datateknik in CTMAT and CFATE and Ingenjörsämnen
    in COPEN, colliding with the elective boxes. Placeholders are coloured by category rather than by a
    cosmetics entry, because their codes are generated (XY{year}{period}0Z) and a cosmetics list would
    need re-syncing on every regeneration. CINEK and TIEMM still have Matematik = green with blue held by
    Industriell ekonomi — both at the five-family cap, so aligning them is a swap, left out of scope.
  • CFATE recategorisation. SI1146 → Matematik; SD1002, MJ1401, ME1003 → Ingenjörsämnen. All four
    were in no group and rendering default blue.
  • --align refuses non-course files. programs.json, the cosmetics files and
    academic-periods.json are all JSON arrays too, so a shell glob could rewrite them — harmlessly in
    content, but expanding their compact arrays into a 400-line diff. It now requires a positive signal.
  • STUDY-PLAN-CONVENTIONS.md — a new document recording how study plans differ between schools,
    written for discussion with the people who own them rather than as a spec.
  • Docs. README.md covers the cohort view, the archive, the two visibility flags, the extractor
    and the new scripts. CLAUDE.md carries the durable detail, including a step-by-step for adding a
    programme written from doing these two.

The size budget goes 250 → 330 kB

The committed cohort archive counts toward size-limit, and production chunk names are hashed, so app
and data can't be separated by path — I checked for a structural split and there isn't one. Raised
deliberately, with the composition recorded in package.json and ci.yml (~201 kB app, ~50 kB
archive, ~2 kB per cohort file) so a future failure can be attributed rather than guessed at. Current:
270.63 kB of 330 kB.

npm run validate-data now passes --cohorts, so CI validates the archive too.


Test plan

  • All eight programmes × four cohorts extract and validate — 0 errors (191 warnings: the
    pre-existing cosmetics ones, now counted per cohort file, plus the new full-time load reports)
  • npm run lint, npx tsc --noEmit, npm run build clean
  • npm run size 270.63 kB of 330 kB
  • Driven in real Chrome across every programme: program selector, cohort selector, URL state,
    chart, provenance line and tooltip all render with no console errors
  • Height regression specifically: CTFYS → TIEMM → CTFYS returns to 659, matching a fresh load
  • TIEMM absent from the dropdown, and ?program=TIEMM falls back to CTFYS
  • --align verified lossless on the curated files: identical entry multiset, identical field
    values, trailing-newline convention preserved, so the diff is purely moved lines
  • --prereqs verified additive: CTFYS's 19 curated prerequisites survive every regeneration

Known gaps

  • The prerequisite rules are validated against one programme's labels. The ~170 review items
    across the programmes are the honest measure of what still needs a human. TIEMM has the most and is
    where the interpretation is thinnest.
  • exams for multi-period courses is a convention, not a fact. One exam per exam-bearing module in
    the highest-credit periods, 10/17 against curated values; every placement is flagged. Closing it
    needs real timetable data — Ladok's aktivitetstillfällen, which the sibling
    academic-performance-portal already imports.
  • CMAST year 2 is 4.5 hp short in P3 and P4 for all 14 inriktningar that have it — an identical
    shortfall across every lane, so almost certainly common-part elective space. Not filled, because
    fillElectiveSpace refuses whenever a programme has inriktningar and sums all lanes together rather
    than per-lane. Making it lane-aware would handle uniform cases like this while still refusing
    genuinely mixed years.
  • teacher and description are never populated, and cosmetics files still need a human.

…programmes

A student admitted in HT2023 can now pick their cohort and see years 1-3 as they
will study them, with prerequisites resolved to the kursplan that applied to
their round. CMAST and CMATD are added from scratch using the same pipeline.
Previously the extractor assembled one calendar läsår from three different
cohorts, which is the wrong thing for a student reading their own plan.

WHY A COHORT'S PLAN HAS TO BE STITCHED TOGETHER
KTH publishes the läsår currently being taught and the next one, deletes the
years a cohort has already passed, and lists its future years with all-zero
creditsPerPeriod:

  cohort   year 1   year 2   year 3
  HT2023   gone     gone     own
  HT2024   gone     own      own
  HT2025   own      own      future
  HT2026   own      future   future

No cohort has all three of its own years, and the gap runs in both directions.
Each missing year is borrowed from the nearest cohort that publishes it,
preferring the earlier one on a tie. Availability is probed rather than computed,
because the publishing window moves every year.

Each cohort file opens with a cohortMeta entry recording, per year, the
sourceCohort, whether it was approximated, and a confidence derived by comparing
a year both cohorts publish. Agreement is evidence, not proof: CTFYS year 1 is
identical between 2025 and 2026 while its year 3 changed between 2023 and 2024.
The UI shows a one-line summary under the selectors with the detail behind an
info affordance, which links to KTH's published plan as the authority.

KOPPS IS RETIRED - THE COURSE PAGE IS THE LIVE SOURCE
The KOPPS API still answers but no longer receives updates. DD1328's course page
carries two kursplan versions (VT2026 and VT2024); the API returns only VT2024.
The newer text lists DD1333, CTMAT's own first-year programming course, so
reading KOPPS left DD1328/DD1380/DD1385 with no prerequisites at all. Measured
over all 217 course codes: 166 identical, 51 different, and the page is the
fuller text every time. The page's render state is now primary for
prerequisites, grading scale, cycle level and examination modules. Only the
English title still comes from KOPPS, because the English course page returns
HTTP 500.

KURSPLAN VERSIONS ARE PER-COHORT
Prerequisites change, so a cohort must get the version that applied to its round.
The course page's syllabusList carries every version tagged with
course_valid_from, and that value is the KTH term code - verified against the
kursutveckling archive, where EI1320's five PDFs match the five versions exactly.
The PDFs therefore never need parsing. CTFYS HT2023 sits EI1320 in 20252 and gets
the 20212 kursplan (SI1200 alone); HT2024 sits it in 20262 and gets 20261
(SI1200 or SF1693). 23 CTFYS courses have more than one version.

PREREQUISITES COME FROM FREE TEXT
They live in the syllabus eligibility field as prose, while the schema wants
prerequisitesCompleted and prerequisitesParticipation. Three things make the
interpretation tractable: intersecting with the programme's own courses collapses
alternative lists and keeps cross-programme codes out of the arrows; type is
decided per clause; and a clause with no type marker inherits one signalled
elsewhere in the same text. Ranges are expanded - KOPPS writes alternatives as
DD1310-DD1319 as well as slash lists, and expanding CINEK's DD1418 recovers
DD1317, DD1324 and SF1918. Requirements are split on capitalised openers, since
EI1320's syllabus runs two of them together with no punctuation between.

Measured against the 19 hand-curated CTFYS prerequisites: 19/19 exact, none
missed, none spurious. Two rules were derived from that set, and CTFYS is the
only programme with curated prerequisites, so treat it as a fit to the available
labels rather than proof it generalises. Uncertain items go to
prerequisite-review/<PROG>.md; two of its sections are aimed at the program
director rather than at data entry - a course whose own list of qualifying
courses omits ours (report it to that course's owner), and a prerequisite that
does not precede the course.

FULL-TIME LOAD AS A DATA-QUALITY CHECK
Full-time study is 15 hp per period, so a year should add up to 15 in every
period. validate-data now warns on any cell that does not, merging inriktning
lanes that say the same thing. Short is almost always the missing space for
valfria kurser; over means double-counted option groups or a "minst N hp ur
grupp" pool the schema cannot express. CTFYS produces no warnings at all, which
is the calibration check.

Placeholders are filled from the shortfall and corroborated by the prose: CTMAT
says "Utrymmet för valfria kurser är 7,5 hp per period hela läsåret" and CTFYS
"På våren i årskurs 3 finns ett utrymme på 15,0 hp valfria kurser" - both agree
with the computed gap, and the CTFYS case reproduces the hand-authored
XY123Z/XY456Z placeholders exactly. Filling is refused where a year also has a
period over full-time; CFATE year 3 gets its arithmetic spelled out instead.

TWO RENDERER FIXES
Chart height was a feedback loop: it was seeded from the height its own previous
render had written onto the SVG node, then only ever grown. Switching from a tall
programme to a short one stranded the legend far below the chart - measured CTFYS
659 -> TIEMM 2994 -> CTFYS 3882 against 659 on a fresh load. It now derives from
the data and is set unconditionally.

Course order controls vertical alignment, because each period's bars stack in
file order. alignEntries() minimises total drift with a multi-start local search:
every simple comparator tested was worse than the hand-curated order, so it seeds
from the file's own order plus two alternatives and keeps the best, which means it
can never degrade a file. CTFYS 15->6, CTMAT 26->5, CFATE 50->15, CINEK 118->9,
CMAST 316->5, CMATD 44->3.

TWO NEW PROGRAMMES, AND WHAT THEY REVEALED
CMAST and CMATD were added with no hand-curated data, pointing dataFile at their
HT2025 cohort file. Both needed the specializations registry that no SCI
programme does: structure follows the owning school (ITM programmes split by
inriktning from year 2, SCI ones do not), while prerequisite wording does not,
because the text belongs to the course rather than the programme.

CMAST also introduced electiveCondition 'R' (rekommenderad), used by no other
programme - 144 participations, one per course recommended for the master track
an inriktning leads to. The KNOWN_CONDITIONS assertion caught them rather than
dropping them silently. They are a pool, not a prescribed set (adding them takes
AEE year 3 from 15/15/0/0 to 15/33/30/6 hp), so they get the same treatment as
'V': reported, written only with --electives.

ALSO
  - TIEMM is withdrawn from the UI via a new `disabled` flag. `verified: false`
    only hides a plan behind a checkbox, which is right for "extracted but not
    signed off"; a master's programme whose years 4-5 structure the renderer
    does not yet handle needs to be off entirely. Its data is still validated.
  - Palette convention: Matematik is blue and elective space yellow in every
    programme; the rest take green, brick and turquoise. Yellow previously meant
    Datateknik in CTMAT and CFATE and Ingenjörsämnen in COPEN, colliding with the
    elective boxes. Placeholders are coloured by category rather than by a
    cosmetics entry, since their codes are generated.
  - CFATE recategorisation: SI1146 to Matematik, SD1002/MJ1401/ME1003 to
    Ingenjörsämnen. All four were previously in no group and rendering default.
  - --align refuses files that are not course data. programs.json, the cosmetics
    files and academic-periods.json are all JSON arrays too, so a shell glob
    could rewrite them - harmlessly in content, but expanding their compact
    arrays into a 400-line diff.
  - STUDY-PLAN-CONVENTIONS.md records how study plans differ between schools,
    for discussion with the people who own them.
  - The size-limit budget goes 250 -> 330 kB. The committed cohort archive counts
    toward it and production chunk names are hashed, so app and data cannot be
    split by path; package.json and ci.yml record the composition.

Verified: all eight programmes extract four cohorts each and validate with 0
errors; lint, tsc --noEmit and build clean; size 270.63 kB of 330 kB. The UI was
driven in real Chrome across every programme - selector, cohort selector, URL
state, chart, height behaviour on programme switch, provenance tooltip and the
withdrawal of TIEMM all check out with no console errors.
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
program-visualization Ready Ready Preview Aug 22, 2026 9:53am

Comment thread scripts/extract-from-kopps.mjs Fixed
Comment thread scripts/extract-from-kopps.mjs Fixed
Comment thread scripts/extract-from-kopps.mjs Fixed
Comment thread scripts/extract-from-kopps.mjs Fixed
CodeQL flagged one double-unescaping bug and three time-of-check/time-of-use
file reads on PR #61. All four are in scripts/extract-from-kopps.mjs. The
extractor's output is unchanged: regenerating CTFYS, CTMAT and CMAST from the
live source gives files byte-identical to the ones already committed.

DOUBLE UNESCAPING (js/double-escaping)
decodeHtmlText chained six .replace() calls and then ran the whole chain a
second time, so one replacement's output could be re-read by the next.
Unescaping "&amp;" to "&" ahead of the "&quot;" step turned "&amp;quot;" into a
double quote rather than the literal "&quot;" it encodes, and the second pass
did the same thing to anything the first pass produced.

The comment claimed KTH input is "sometimes double-encoded", so the second pass
was measured rather than assumed. Over 87 pages - all eight programmes, three
terms, fifteen course pages, 146819 string values - the entities that occur are
&nbsp; (788), &#228; (25), &amp; (25), &#229; (23), &quot; (18), &#246; (13),
&#39; (12) and &#160; (5). None is double-encoded. The second pass never fired;
it only created the hazard.

Now one regex over one pass, so `replace` cannot rescan what it has written.
Checked against the old implementation on all 146819 measured strings: zero
differences. The two behave differently only on input the source does not
produce, and there the new one is right - "&amp;#39;" decodes to the literal
"&#39;" instead of an apostrophe, and "&#128512;" to the intended character
rather than a truncated one, because String.fromCodePoint replaces
String.fromCharCode. A code point outside Unicode's range is left as written
instead of crashing.

&lt; and &gt; are still deliberately left encoded. The tag stripper runs
afterwards, so decoding them would hand it angle brackets that were never
markup and delete the text between. Neither entity occurs in the measured data.

TIME-OF-CHECK/TIME-OF-USE (js/file-system-race, three instances)
alignFiles, fillPrereqs and fillElectivesInCuratedFile each called existsSync
and then read the file. Replaced with a readTextOrNull helper that reads and
handles ENOENT, so there is no gap between the check and the use. An existence
check also passes for a directory, a broken symlink and a file the process
cannot read, all of which fail at the read a moment later, so this is more
accurate as well as shorter.

lasarFromPeriods and writeCohortIndex had the same shape without being flagged
- the first because its read sits inside a try, the second because it is a
readdir - and are converted too, which retires the existsSync import. Error
messages and exit behaviour are unchanged: a missing --align target still warns
and continues, a missing curated file still throws the same text.

No app code is touched. The script is not part of the Next build graph; it is
reachable only through `npm run extract-plan`.

Verified: lint, tsc --noEmit and validate-data --cohorts all clean (191
warnings, unchanged). Both --align and --fill-electives exercised, including
their missing-file paths. CTFYS, CTMAT and CMAST re-extracted for all four
cohorts against the live source: output byte-identical to the committed files,
and identical to what the pre-patch script produces from the same input.
@cohm
cohm merged commit f1b8cfa into main Aug 22, 2026
5 checks passed
@cohm
cohm deleted the cohort-view branch August 22, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants