From d5e27c4907140989295da89739b31f83696c4881 Mon Sep 17 00:00:00 2001 From: Derek Redmond Date: Wed, 3 Jun 2026 21:37:27 -0400 Subject: [PATCH 1/2] feat: surface malformed courses instead of silently skipping them A directory under courses/ that looks like a course attempt (it has a pages/ folder) but lacks course.config.js was silently filtered out of the course listing, leaving a confusing "course not found" with no cause. It is now reported under "Skipped (missing course.config.js)" when the workspace's courses are listed. Legitimate non-course content (a README, a shared-assets folder) is left out of the warning. Co-Authored-By: Claude Opus 4.8 --- .changeset/warn-malformed-course.md | 5 +++ .../tessera-learn/src/plugin/course-root.ts | 34 +++++++++++++++++-- .../tessera-learn/tests/course-root.test.ts | 28 +++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 .changeset/warn-malformed-course.md diff --git a/.changeset/warn-malformed-course.md b/.changeset/warn-malformed-course.md new file mode 100644 index 0000000..35e3355 --- /dev/null +++ b/.changeset/warn-malformed-course.md @@ -0,0 +1,5 @@ +--- +'tessera-learn': patch +--- + +A course directory under `courses/` that has a `pages/` folder but is missing its `course.config.js` is now reported as skipped when listing the workspace's courses, instead of being silently dropped. diff --git a/packages/tessera-learn/src/plugin/course-root.ts b/packages/tessera-learn/src/plugin/course-root.ts index bbec9f1..347cfd8 100644 --- a/packages/tessera-learn/src/plugin/course-root.ts +++ b/packages/tessera-learn/src/plugin/course-root.ts @@ -44,17 +44,47 @@ export function listCourses(workspaceRoot: string): string[] { } } +export function listMalformedCourses(workspaceRoot: string): string[] { + const coursesDir = join(workspaceRoot, 'courses'); + try { + return readdirSync(coursesDir, { withFileTypes: true }) + .filter( + (e) => + e.isDirectory() && + !isCourse(join(coursesDir, e.name)) && + isDir(join(coursesDir, e.name, 'pages')), + ) + .map((e) => e.name) + .sort(); + } catch { + return []; + } +} + const NOT_A_WORKSPACE = 'Not inside a Tessera workspace — no `courses/` directory was found at or above the current directory.'; +function malformedHint(workspaceRoot: string): string { + const malformed = listMalformedCourses(workspaceRoot); + if (malformed.length === 0) return ''; + return ( + `\nSkipped (missing course.config.js):\n` + + malformed.map((c) => ` courses/${c}`).join('\n') + ); +} + function listHint(workspaceRoot: string): string { const courses = listCourses(workspaceRoot); if (courses.length === 0) { - return '\nNo courses found. Create one with `tessera new `.'; + return ( + '\nNo courses found. Create one with `tessera new `.' + + malformedHint(workspaceRoot) + ); } return ( `\nAvailable courses:\n${courses.map((c) => ` ${c}`).join('\n')}` + - '\nName one (`tessera `) or cd into its folder.' + '\nName one (`tessera `) or cd into its folder.' + + malformedHint(workspaceRoot) ); } diff --git a/packages/tessera-learn/tests/course-root.test.ts b/packages/tessera-learn/tests/course-root.test.ts index a178c5b..acb8844 100644 --- a/packages/tessera-learn/tests/course-root.test.ts +++ b/packages/tessera-learn/tests/course-root.test.ts @@ -6,6 +6,7 @@ import { resolveCourse, findWorkspaceRoot, listCourses, + listMalformedCourses, } from '../src/plugin/course-root.js'; let ws: string; @@ -62,6 +63,23 @@ describe('listCourses', () => { }); }); +describe('listMalformedCourses', () => { + it('flags a directory that has pages/ but no course.config.js', () => { + mkdirSync(join(ws, 'courses', 'half-built', 'pages'), { recursive: true }); + expect(listMalformedCourses(ws)).toEqual(['half-built']); + }); + + it('does not flag legitimate non-course directories', () => { + mkdirSync(join(ws, 'courses', 'shared-assets'), { recursive: true }); + writeFileSync(join(ws, 'courses', 'README.md'), '# courses'); + expect(listMalformedCourses(ws)).toEqual([]); + }); + + it('does not flag valid courses', () => { + expect(listMalformedCourses(ws)).toEqual([]); + }); +}); + describe('resolveCourse', () => { it('resolves cwd as the course root when it holds course.config.js (no name)', () => { const cwd = join(ws, 'courses', 'getting-started'); @@ -135,4 +153,14 @@ describe('resolveCourse', () => { const result = resolveCourse(tmpdir(), 'getting-started'); expect(result.ok).toBe(false); }); + + it('surfaces a malformed course in the hint instead of silently dropping it', () => { + mkdirSync(join(ws, 'courses', 'half-built', 'pages'), { recursive: true }); + const result = resolveCourse(ws, 'half-built'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('half-built'); + expect(result.error).toContain('course.config.js'); + } + }); }); From 1ff4e82581a1c265b0eaab4f9b4f016bf95c72cf Mon Sep 17 00:00:00 2001 From: Derek Redmond Date: Wed, 3 Jun 2026 21:41:40 -0400 Subject: [PATCH 2/2] refactor: scan courses/ once when building list hints listHint called both listCourses and listMalformedCourses, each doing its own readdirSync plus per-entry stats. Partition the directory in a single scanCourses pass and derive both lists from it. Co-Authored-By: Claude Opus 4.8 --- .../tessera-learn/src/plugin/course-root.ts | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/tessera-learn/src/plugin/course-root.ts b/packages/tessera-learn/src/plugin/course-root.ts index 347cfd8..afcd21f 100644 --- a/packages/tessera-learn/src/plugin/course-root.ts +++ b/packages/tessera-learn/src/plugin/course-root.ts @@ -32,40 +32,38 @@ export function findWorkspaceRoot(cwd: string): string | null { } } -export function listCourses(workspaceRoot: string): string[] { +function scanCourses(workspaceRoot: string): { + courses: string[]; + malformed: string[]; +} { const coursesDir = join(workspaceRoot, 'courses'); + const courses: string[] = []; + const malformed: string[] = []; try { - return readdirSync(coursesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory() && isCourse(join(coursesDir, e.name))) - .map((e) => e.name) - .sort(); + for (const e of readdirSync(coursesDir, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + const dir = join(coursesDir, e.name); + if (isCourse(dir)) courses.push(e.name); + else if (isDir(join(dir, 'pages'))) malformed.push(e.name); + } } catch { - return []; + return { courses, malformed }; } + return { courses: courses.sort(), malformed: malformed.sort() }; +} + +export function listCourses(workspaceRoot: string): string[] { + return scanCourses(workspaceRoot).courses; } export function listMalformedCourses(workspaceRoot: string): string[] { - const coursesDir = join(workspaceRoot, 'courses'); - try { - return readdirSync(coursesDir, { withFileTypes: true }) - .filter( - (e) => - e.isDirectory() && - !isCourse(join(coursesDir, e.name)) && - isDir(join(coursesDir, e.name, 'pages')), - ) - .map((e) => e.name) - .sort(); - } catch { - return []; - } + return scanCourses(workspaceRoot).malformed; } const NOT_A_WORKSPACE = 'Not inside a Tessera workspace — no `courses/` directory was found at or above the current directory.'; -function malformedHint(workspaceRoot: string): string { - const malformed = listMalformedCourses(workspaceRoot); +function malformedHint(malformed: string[]): string { if (malformed.length === 0) return ''; return ( `\nSkipped (missing course.config.js):\n` + @@ -74,17 +72,17 @@ function malformedHint(workspaceRoot: string): string { } function listHint(workspaceRoot: string): string { - const courses = listCourses(workspaceRoot); + const { courses, malformed } = scanCourses(workspaceRoot); if (courses.length === 0) { return ( '\nNo courses found. Create one with `tessera new `.' + - malformedHint(workspaceRoot) + malformedHint(malformed) ); } return ( `\nAvailable courses:\n${courses.map((c) => ` ${c}`).join('\n')}` + '\nName one (`tessera `) or cd into its folder.' + - malformedHint(workspaceRoot) + malformedHint(malformed) ); }