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..afcd21f 100644 --- a/packages/tessera-learn/src/plugin/course-root.ts +++ b/packages/tessera-learn/src/plugin/course-root.ts @@ -32,29 +32,57 @@ 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[] { + 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(malformed: string[]): string { + 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); + const { courses, malformed } = scanCourses(workspaceRoot); if (courses.length === 0) { - return '\nNo courses found. Create one with `tessera new `.'; + return ( + '\nNo courses found. Create one with `tessera new `.' + + malformedHint(malformed) + ); } 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(malformed) ); } 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'); + } + }); });