From 20790139ce315abbd4f491ad2d2fd609eb6befd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:39:33 +0000 Subject: [PATCH 1/3] feat: move challenge packages under challenges/ and discover them by convention --- .markdownlint-cli2.jsonc | 2 +- AGENTS.md | 22 +-- README.md | 19 +-- .../birdsbirdbirds}/README.md | 0 .../birdsbirdbirds}/bird-watcher.mjs | 0 .../birdsbirdbirds}/challenge.json | 0 .../birdsbirdbirds}/check-bird.mjs | 0 .../birdsbirdbirds}/cleanup | 0 .../birdsbirdbirds}/extension.mjs | 0 .../birdsbirdbirds}/hooks/cleanup.mjs | 0 .../birdsbirdbirds}/hooks/setup.mjs | 0 .../birdsbirdbirds}/hooks/state.mjs | 0 .../birdsbirdbirds}/icon.txt | 0 .../birdsbirdbirds}/intro.md | 0 .../birdsbirdbirds}/page/app.mjs | 0 .../birdsbirdbirds}/page/bird-label.mjs | 0 .../birdsbirdbirds}/page/index.html | 0 .../birdsbirdbirds}/page/style.css | 0 .../birdsbirdbirds}/plugin.json | 0 .../birdsbirdbirds}/server.mjs | 0 .../birdsbirdbirds}/setup | 0 .../skills/start-challenge/SKILL.md | 0 .../birdsbirdbirds}/workspace-file.mjs | 0 .../hello-world}/README.md | 0 .../hello-world}/challenge.json | 0 .../hello-world}/cleanup | 0 .../hello-world}/intro.md | 0 .../hello-world}/plugin.json | 0 {hello-world => challenges/hello-world}/setup | 0 .../skills/start-challenge/SKILL.md | 0 .../vibe-coding-mad-libs}/README.md | 0 .../vibe-coding-mad-libs}/challenge.json | 0 .../vibe-coding-mad-libs}/cleanup | 0 .../vibe-coding-mad-libs}/dice-sheet.md | 0 .../vibe-coding-mad-libs}/install | 0 .../vibe-coding-mad-libs}/intro.md | 0 .../vibe-coding-mad-libs}/mad-libs.mjs | 0 .../vibe-coding-mad-libs}/package-lock.json | 0 .../vibe-coding-mad-libs}/package.json | 0 .../vibe-coding-mad-libs}/plugin.json | 0 .../vibe-coding-mad-libs}/preview.mjs | 0 .../vibe-coding-mad-libs}/prompt.mjs | 0 .../vibe-coding-mad-libs}/setup | 0 .../skills/start-challenge/SKILL.md | 0 docs/style/testing.md | 4 +- test/birdsbirdbirds.test.mjs | 12 +- .../{ => challenges}/one/challenge.json | 0 .../duplicate/{ => challenges}/one/intro.md | 0 .../{ => challenges}/one/plugin.json | 0 .../one/skills/start-challenge/SKILL.md | 0 .../{ => challenges}/two/challenge.json | 0 .../duplicate/{ => challenges}/two/intro.md | 0 .../{ => challenges}/two/plugin.json | 0 .../two/skills/start-challenge/SKILL.md | 0 .../challenges/beginner/gamma/challenge.json | 22 +++ .../challenges/beginner/gamma/intro.md | 3 + .../challenges/beginner/gamma/plugin.json | 6 + .../gamma}/skills/start-challenge/SKILL.md | 0 .../{ => challenges}/broken/challenge.json | 0 .../invalid/{ => challenges}/broken/intro.md | 0 .../broken}/skills/start-challenge/SKILL.md | 0 .../{ => challenges}/.hidden/challenge.json | 0 .../holder/nested/challenge.json | 0 .../roots/layout/{ => challenges}/linked-a | 0 .../roots/layout/{ => challenges}/loose.txt | 0 .../{ => challenges}/no-manifest/README.md | 0 .../{ => challenges}/top-a/challenge.json | 0 .../{ => challenges}/top-b/challenge.json | 0 .../ok/{ => challenges}/alpha/challenge.json | 0 .../roots/ok/{ => challenges}/alpha/intro.md | 0 .../ok/{ => challenges}/alpha/plugin.json | 0 .../alpha}/skills/start-challenge/SKILL.md | 0 .../ok/{ => challenges}/beta/challenge.json | 0 .../roots/ok/{ => challenges}/beta/intro.md | 0 .../ok/{ => challenges}/beta/plugin.json | 0 .../beta/skills/start-challenge/SKILL.md | 8 ++ .../outside/challenges/alpha/challenge.json | 22 +++ .../roots/outside/challenges/alpha/intro.md | 3 + .../outside/challenges/alpha/plugin.json | 6 + .../alpha/skills/start-challenge/SKILL.md | 8 ++ .../roots/outside/stray/challenge.json | 22 +++ test/fixtures/roots/outside/stray/intro.md | 3 + test/fixtures/roots/outside/stray/plugin.json | 6 + .../stray/skills/start-challenge/SKILL.md | 8 ++ test/hello-world.test.mjs | 2 +- test/verify.test.mjs | 119 ++++++++++++++--- test/vibe-coding-mad-libs.test.mjs | 4 +- tools/verify.mjs | 126 ++++++++++++++---- 88 files changed, 356 insertions(+), 71 deletions(-) rename {birdsbirdbirds => challenges/birdsbirdbirds}/README.md (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/bird-watcher.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/challenge.json (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/check-bird.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/cleanup (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/extension.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/hooks/cleanup.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/hooks/setup.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/hooks/state.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/icon.txt (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/intro.md (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/page/app.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/page/bird-label.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/page/index.html (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/page/style.css (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/plugin.json (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/server.mjs (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/setup (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/skills/start-challenge/SKILL.md (100%) rename {birdsbirdbirds => challenges/birdsbirdbirds}/workspace-file.mjs (100%) rename {hello-world => challenges/hello-world}/README.md (100%) rename {hello-world => challenges/hello-world}/challenge.json (100%) rename {hello-world => challenges/hello-world}/cleanup (100%) rename {hello-world => challenges/hello-world}/intro.md (100%) rename {hello-world => challenges/hello-world}/plugin.json (100%) rename {hello-world => challenges/hello-world}/setup (100%) rename {hello-world => challenges/hello-world}/skills/start-challenge/SKILL.md (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/README.md (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/challenge.json (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/cleanup (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/dice-sheet.md (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/install (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/intro.md (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/mad-libs.mjs (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/package-lock.json (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/package.json (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/plugin.json (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/preview.mjs (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/prompt.mjs (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/setup (100%) rename {vibe-coding-mad-libs => challenges/vibe-coding-mad-libs}/skills/start-challenge/SKILL.md (100%) rename test/fixtures/roots/duplicate/{ => challenges}/one/challenge.json (100%) rename test/fixtures/roots/duplicate/{ => challenges}/one/intro.md (100%) rename test/fixtures/roots/duplicate/{ => challenges}/one/plugin.json (100%) rename test/fixtures/roots/duplicate/{ => challenges}/one/skills/start-challenge/SKILL.md (100%) rename test/fixtures/roots/duplicate/{ => challenges}/two/challenge.json (100%) rename test/fixtures/roots/duplicate/{ => challenges}/two/intro.md (100%) rename test/fixtures/roots/duplicate/{ => challenges}/two/plugin.json (100%) rename test/fixtures/roots/duplicate/{ => challenges}/two/skills/start-challenge/SKILL.md (100%) create mode 100644 test/fixtures/roots/grouped/challenges/beginner/gamma/challenge.json create mode 100644 test/fixtures/roots/grouped/challenges/beginner/gamma/intro.md create mode 100644 test/fixtures/roots/grouped/challenges/beginner/gamma/plugin.json rename test/fixtures/roots/{invalid/broken => grouped/challenges/beginner/gamma}/skills/start-challenge/SKILL.md (100%) rename test/fixtures/roots/invalid/{ => challenges}/broken/challenge.json (100%) rename test/fixtures/roots/invalid/{ => challenges}/broken/intro.md (100%) rename test/fixtures/roots/{ok/alpha => invalid/challenges/broken}/skills/start-challenge/SKILL.md (100%) rename test/fixtures/roots/layout/{ => challenges}/.hidden/challenge.json (100%) rename test/fixtures/roots/layout/{ => challenges}/holder/nested/challenge.json (100%) rename test/fixtures/roots/layout/{ => challenges}/linked-a (100%) rename test/fixtures/roots/layout/{ => challenges}/loose.txt (100%) rename test/fixtures/roots/layout/{ => challenges}/no-manifest/README.md (100%) rename test/fixtures/roots/layout/{ => challenges}/top-a/challenge.json (100%) rename test/fixtures/roots/layout/{ => challenges}/top-b/challenge.json (100%) rename test/fixtures/roots/ok/{ => challenges}/alpha/challenge.json (100%) rename test/fixtures/roots/ok/{ => challenges}/alpha/intro.md (100%) rename test/fixtures/roots/ok/{ => challenges}/alpha/plugin.json (100%) rename test/fixtures/roots/ok/{beta => challenges/alpha}/skills/start-challenge/SKILL.md (100%) rename test/fixtures/roots/ok/{ => challenges}/beta/challenge.json (100%) rename test/fixtures/roots/ok/{ => challenges}/beta/intro.md (100%) rename test/fixtures/roots/ok/{ => challenges}/beta/plugin.json (100%) create mode 100644 test/fixtures/roots/ok/challenges/beta/skills/start-challenge/SKILL.md create mode 100644 test/fixtures/roots/outside/challenges/alpha/challenge.json create mode 100644 test/fixtures/roots/outside/challenges/alpha/intro.md create mode 100644 test/fixtures/roots/outside/challenges/alpha/plugin.json create mode 100644 test/fixtures/roots/outside/challenges/alpha/skills/start-challenge/SKILL.md create mode 100644 test/fixtures/roots/outside/stray/challenge.json create mode 100644 test/fixtures/roots/outside/stray/intro.md create mode 100644 test/fixtures/roots/outside/stray/plugin.json create mode 100644 test/fixtures/roots/outside/stray/skills/start-challenge/SKILL.md diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index e3b618b..55071db 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -5,7 +5,7 @@ // Its placeholders read as HTML, and it opens without a heading by design. ".github/pull_request_template.md", // The challenge spec bars a title in intro.md; the Intro screen shows it from the manifest. - "*/intro.md", + "challenges/**/intro.md", // Fixture Markdown is test data, not documentation. "test/fixtures/**", "**/node_modules/**" diff --git a/AGENTS.md b/AGENTS.md index 91e189b..fa4d3a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # microhackathon-challenges -Portable challenge packages for MLH microhackathons. Each top-level directory -is one challenge package that boothstrap.sh stations and `boothstrap dev` can -run. The repo is plain JavaScript (`.mjs`) on Node 26, and every PR is checked -by the same package validator stations run. +Portable challenge packages for MLH microhackathons. Each package under +`challenges/` is one challenge package that boothstrap.sh stations and +`boothstrap dev` can run. The repo is plain JavaScript (`.mjs`) on Node 26, and +every PR is checked by the same package validator stations run. ## Autonomy @@ -36,8 +36,11 @@ by the same package validator stations run. ## Challenge packages -- Each top-level directory holding a `challenge.json` is one package, named - after its `id`. No two packages share an id. +- Each directory under `challenges/` holding a `challenge.json` is one + package, named after its `id`; grouping folders such as + `challenges/beginner/` are allowed. Event configs list `challenges/` + (or the nested path). A `challenge.json` outside `challenges/` fails verify. + No two packages share an id. - The contract is boothstrap.sh's `docs/specs/2026-09-26-mvp-challenge-spec.md`, with `docs/authoring-challenges.md` for the workflow. The spec wins over this repo's docs. boothstrap.sh is internal: in a cloud session, attach @@ -47,8 +50,9 @@ by the same package validator stations run. - A package's dependencies install through its own `install` hook, never at the repo root. The root `package.json` has one devDependency, `@mlhacks/boothstrap`, pinned exactly; bumping it is a one-line PR. -- In-repo symlinks are followed: a symlinked top-level directory holding a - `challenge.json` is a package. A top-level symlink that is absolute, +- In-repo symlinks are followed: a symlinked directory under `challenges/` + holding a `challenge.json` is a package. A symlink at the repo root or under + `challenges/` that is absolute, dangling, looping, or points outside the repo fails verify, as the station refuses it. `npm run lint` also lints Markdown inside packages, so the house Markdown rules apply there. @@ -82,7 +86,7 @@ by the same package validator stations run. ## Project structure -- `/`: one challenge package per top-level directory, with a +- `challenges//`: one challenge package per directory, with a `challenge.json` and its own `README.md`. - `tools/`: scripts run with `node`; `verify.mjs` runs the validator over every package. diff --git a/README.md b/README.md index fb336dd..f649d51 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,15 @@ Portable challenge packages for MLH microhackathons. ## Challenges -Each top-level directory is one challenge package, named after its `id`. Every -row links to the package's own README. +Packages live under `challenges/`, one directory per package, named after its +`id`; grouping folders such as `challenges/beginner/` also work. Every row +links to the package's own README. | Challenge | Description | | --- | --- | -| [hello-world](hello-world/README.md) | Meet your AI guide and make your first file. | -| [vibe-coding-mad-libs](vibe-coding-mad-libs/README.md) | Fill four blanks, and Pi builds the web app you described. | -| [birdsbirdbirds](birdsbirdbirds/README.md) | Draw a bird and watch it fly across the sky. | +| [hello-world](challenges/hello-world/README.md) | Meet your AI guide and make your first file. | +| [vibe-coding-mad-libs](challenges/vibe-coding-mad-libs/README.md) | Fill four blanks, and Pi builds the web app you described. | +| [birdsbirdbirds](challenges/birdsbirdbirds/README.md) | Draw a bird and watch it fly across the sky. | Challenges follow the contract in boothstrap.sh's challenge spec and authoring guide; agents working here start from [AGENTS.md](AGENTS.md). @@ -21,8 +22,9 @@ authoring guide; agents working here start from [AGENTS.md](AGENTS.md). CI runs on every pull request and on every push to `main`, on Ubuntu with Node 26. It runs `npm ci`, then: -- `npm run verify`: runs `boothstrap dev --verify` on every package and fails - when two packages share an id. +- `npm run verify`: runs `boothstrap dev --verify` on every package under + `challenges/` and fails when two packages share an id or a `challenge.json` + sits outside `challenges/`. - `npm test`: the tests (`test/*.test.mjs`); their rules are in [docs/style/testing.md](docs/style/testing.md). - `npm run lint`: markdownlint over every Markdown file. @@ -36,7 +38,8 @@ npm test npm run lint ``` -A package's dependencies install through its own `install` hook, never at the +To try one package alone, run `npx boothstrap dev challenges/hello-world`. A +package's dependencies install through its own `install` hook, never at the root. ## Docs diff --git a/birdsbirdbirds/README.md b/challenges/birdsbirdbirds/README.md similarity index 100% rename from birdsbirdbirds/README.md rename to challenges/birdsbirdbirds/README.md diff --git a/birdsbirdbirds/bird-watcher.mjs b/challenges/birdsbirdbirds/bird-watcher.mjs similarity index 100% rename from birdsbirdbirds/bird-watcher.mjs rename to challenges/birdsbirdbirds/bird-watcher.mjs diff --git a/birdsbirdbirds/challenge.json b/challenges/birdsbirdbirds/challenge.json similarity index 100% rename from birdsbirdbirds/challenge.json rename to challenges/birdsbirdbirds/challenge.json diff --git a/birdsbirdbirds/check-bird.mjs b/challenges/birdsbirdbirds/check-bird.mjs similarity index 100% rename from birdsbirdbirds/check-bird.mjs rename to challenges/birdsbirdbirds/check-bird.mjs diff --git a/birdsbirdbirds/cleanup b/challenges/birdsbirdbirds/cleanup similarity index 100% rename from birdsbirdbirds/cleanup rename to challenges/birdsbirdbirds/cleanup diff --git a/birdsbirdbirds/extension.mjs b/challenges/birdsbirdbirds/extension.mjs similarity index 100% rename from birdsbirdbirds/extension.mjs rename to challenges/birdsbirdbirds/extension.mjs diff --git a/birdsbirdbirds/hooks/cleanup.mjs b/challenges/birdsbirdbirds/hooks/cleanup.mjs similarity index 100% rename from birdsbirdbirds/hooks/cleanup.mjs rename to challenges/birdsbirdbirds/hooks/cleanup.mjs diff --git a/birdsbirdbirds/hooks/setup.mjs b/challenges/birdsbirdbirds/hooks/setup.mjs similarity index 100% rename from birdsbirdbirds/hooks/setup.mjs rename to challenges/birdsbirdbirds/hooks/setup.mjs diff --git a/birdsbirdbirds/hooks/state.mjs b/challenges/birdsbirdbirds/hooks/state.mjs similarity index 100% rename from birdsbirdbirds/hooks/state.mjs rename to challenges/birdsbirdbirds/hooks/state.mjs diff --git a/birdsbirdbirds/icon.txt b/challenges/birdsbirdbirds/icon.txt similarity index 100% rename from birdsbirdbirds/icon.txt rename to challenges/birdsbirdbirds/icon.txt diff --git a/birdsbirdbirds/intro.md b/challenges/birdsbirdbirds/intro.md similarity index 100% rename from birdsbirdbirds/intro.md rename to challenges/birdsbirdbirds/intro.md diff --git a/birdsbirdbirds/page/app.mjs b/challenges/birdsbirdbirds/page/app.mjs similarity index 100% rename from birdsbirdbirds/page/app.mjs rename to challenges/birdsbirdbirds/page/app.mjs diff --git a/birdsbirdbirds/page/bird-label.mjs b/challenges/birdsbirdbirds/page/bird-label.mjs similarity index 100% rename from birdsbirdbirds/page/bird-label.mjs rename to challenges/birdsbirdbirds/page/bird-label.mjs diff --git a/birdsbirdbirds/page/index.html b/challenges/birdsbirdbirds/page/index.html similarity index 100% rename from birdsbirdbirds/page/index.html rename to challenges/birdsbirdbirds/page/index.html diff --git a/birdsbirdbirds/page/style.css b/challenges/birdsbirdbirds/page/style.css similarity index 100% rename from birdsbirdbirds/page/style.css rename to challenges/birdsbirdbirds/page/style.css diff --git a/birdsbirdbirds/plugin.json b/challenges/birdsbirdbirds/plugin.json similarity index 100% rename from birdsbirdbirds/plugin.json rename to challenges/birdsbirdbirds/plugin.json diff --git a/birdsbirdbirds/server.mjs b/challenges/birdsbirdbirds/server.mjs similarity index 100% rename from birdsbirdbirds/server.mjs rename to challenges/birdsbirdbirds/server.mjs diff --git a/birdsbirdbirds/setup b/challenges/birdsbirdbirds/setup similarity index 100% rename from birdsbirdbirds/setup rename to challenges/birdsbirdbirds/setup diff --git a/birdsbirdbirds/skills/start-challenge/SKILL.md b/challenges/birdsbirdbirds/skills/start-challenge/SKILL.md similarity index 100% rename from birdsbirdbirds/skills/start-challenge/SKILL.md rename to challenges/birdsbirdbirds/skills/start-challenge/SKILL.md diff --git a/birdsbirdbirds/workspace-file.mjs b/challenges/birdsbirdbirds/workspace-file.mjs similarity index 100% rename from birdsbirdbirds/workspace-file.mjs rename to challenges/birdsbirdbirds/workspace-file.mjs diff --git a/hello-world/README.md b/challenges/hello-world/README.md similarity index 100% rename from hello-world/README.md rename to challenges/hello-world/README.md diff --git a/hello-world/challenge.json b/challenges/hello-world/challenge.json similarity index 100% rename from hello-world/challenge.json rename to challenges/hello-world/challenge.json diff --git a/hello-world/cleanup b/challenges/hello-world/cleanup similarity index 100% rename from hello-world/cleanup rename to challenges/hello-world/cleanup diff --git a/hello-world/intro.md b/challenges/hello-world/intro.md similarity index 100% rename from hello-world/intro.md rename to challenges/hello-world/intro.md diff --git a/hello-world/plugin.json b/challenges/hello-world/plugin.json similarity index 100% rename from hello-world/plugin.json rename to challenges/hello-world/plugin.json diff --git a/hello-world/setup b/challenges/hello-world/setup similarity index 100% rename from hello-world/setup rename to challenges/hello-world/setup diff --git a/hello-world/skills/start-challenge/SKILL.md b/challenges/hello-world/skills/start-challenge/SKILL.md similarity index 100% rename from hello-world/skills/start-challenge/SKILL.md rename to challenges/hello-world/skills/start-challenge/SKILL.md diff --git a/vibe-coding-mad-libs/README.md b/challenges/vibe-coding-mad-libs/README.md similarity index 100% rename from vibe-coding-mad-libs/README.md rename to challenges/vibe-coding-mad-libs/README.md diff --git a/vibe-coding-mad-libs/challenge.json b/challenges/vibe-coding-mad-libs/challenge.json similarity index 100% rename from vibe-coding-mad-libs/challenge.json rename to challenges/vibe-coding-mad-libs/challenge.json diff --git a/vibe-coding-mad-libs/cleanup b/challenges/vibe-coding-mad-libs/cleanup similarity index 100% rename from vibe-coding-mad-libs/cleanup rename to challenges/vibe-coding-mad-libs/cleanup diff --git a/vibe-coding-mad-libs/dice-sheet.md b/challenges/vibe-coding-mad-libs/dice-sheet.md similarity index 100% rename from vibe-coding-mad-libs/dice-sheet.md rename to challenges/vibe-coding-mad-libs/dice-sheet.md diff --git a/vibe-coding-mad-libs/install b/challenges/vibe-coding-mad-libs/install similarity index 100% rename from vibe-coding-mad-libs/install rename to challenges/vibe-coding-mad-libs/install diff --git a/vibe-coding-mad-libs/intro.md b/challenges/vibe-coding-mad-libs/intro.md similarity index 100% rename from vibe-coding-mad-libs/intro.md rename to challenges/vibe-coding-mad-libs/intro.md diff --git a/vibe-coding-mad-libs/mad-libs.mjs b/challenges/vibe-coding-mad-libs/mad-libs.mjs similarity index 100% rename from vibe-coding-mad-libs/mad-libs.mjs rename to challenges/vibe-coding-mad-libs/mad-libs.mjs diff --git a/vibe-coding-mad-libs/package-lock.json b/challenges/vibe-coding-mad-libs/package-lock.json similarity index 100% rename from vibe-coding-mad-libs/package-lock.json rename to challenges/vibe-coding-mad-libs/package-lock.json diff --git a/vibe-coding-mad-libs/package.json b/challenges/vibe-coding-mad-libs/package.json similarity index 100% rename from vibe-coding-mad-libs/package.json rename to challenges/vibe-coding-mad-libs/package.json diff --git a/vibe-coding-mad-libs/plugin.json b/challenges/vibe-coding-mad-libs/plugin.json similarity index 100% rename from vibe-coding-mad-libs/plugin.json rename to challenges/vibe-coding-mad-libs/plugin.json diff --git a/vibe-coding-mad-libs/preview.mjs b/challenges/vibe-coding-mad-libs/preview.mjs similarity index 100% rename from vibe-coding-mad-libs/preview.mjs rename to challenges/vibe-coding-mad-libs/preview.mjs diff --git a/vibe-coding-mad-libs/prompt.mjs b/challenges/vibe-coding-mad-libs/prompt.mjs similarity index 100% rename from vibe-coding-mad-libs/prompt.mjs rename to challenges/vibe-coding-mad-libs/prompt.mjs diff --git a/vibe-coding-mad-libs/setup b/challenges/vibe-coding-mad-libs/setup similarity index 100% rename from vibe-coding-mad-libs/setup rename to challenges/vibe-coding-mad-libs/setup diff --git a/vibe-coding-mad-libs/skills/start-challenge/SKILL.md b/challenges/vibe-coding-mad-libs/skills/start-challenge/SKILL.md similarity index 100% rename from vibe-coding-mad-libs/skills/start-challenge/SKILL.md rename to challenges/vibe-coding-mad-libs/skills/start-challenge/SKILL.md diff --git a/docs/style/testing.md b/docs/style/testing.md index 0b6dc80..4f8d8a9 100644 --- a/docs/style/testing.md +++ b/docs/style/testing.md @@ -27,8 +27,8 @@ repositories. test may use is the npm registry, and only to run a package's `install` hook; everything else stays offline. - **Fixtures live in `test/fixtures/`.** A fixture is a small repo layout with - one challenge package per top-level directory; a fixture's Markdown is test - data, not documentation. + one challenge package per directory under its `challenges/`; a fixture's + Markdown is test data, not documentation. - **`test/fixtures/canary.test.mjs` throws if executed.** It proves the test glob skips fixtures; keep fixture files out of top-level `test/*.test.mjs`. - **A test's path mirrors the file or flow it covers.** diff --git a/test/birdsbirdbirds.test.mjs b/test/birdsbirdbirds.test.mjs index 0da56e8..2e5754d 100644 --- a/test/birdsbirdbirds.test.mjs +++ b/test/birdsbirdbirds.test.mjs @@ -17,14 +17,14 @@ import { test } from 'node:test' import { fileURLToPath } from 'node:url' import { crc32, deflateSync } from 'node:zlib' -import { DEFAULT_DEBOUNCE_MS, watchWorkspace } from '../birdsbirdbirds/bird-watcher.mjs' -import { checkBird, loadBirdFile, MAX_BIRD_FILE_BYTES, MAX_KEYFRAMES_BYTES } from '../birdsbirdbirds/check-bird.mjs' -import birdsbirdbirdsExtension from '../birdsbirdbirds/extension.mjs' -import { createBirdLabel } from '../birdsbirdbirds/page/bird-label.mjs' -import { createBirdServer, MAX_PNG_BYTES } from '../birdsbirdbirds/server.mjs' +import { DEFAULT_DEBOUNCE_MS, watchWorkspace } from '../challenges/birdsbirdbirds/bird-watcher.mjs' +import { checkBird, loadBirdFile, MAX_BIRD_FILE_BYTES, MAX_KEYFRAMES_BYTES } from '../challenges/birdsbirdbirds/check-bird.mjs' +import birdsbirdbirdsExtension from '../challenges/birdsbirdbirds/extension.mjs' +import { createBirdLabel } from '../challenges/birdsbirdbirds/page/bird-label.mjs' +import { createBirdServer, MAX_PNG_BYTES } from '../challenges/birdsbirdbirds/server.mjs' const testDirectory = path.dirname(fileURLToPath(import.meta.url)) -const packageDirectory = path.join(testDirectory, '..', 'birdsbirdbirds') +const packageDirectory = path.join(testDirectory, '..', 'challenges', 'birdsbirdbirds') const checkerScript = path.join(packageDirectory, 'check-bird.mjs') const serverScript = path.join(packageDirectory, 'server.mjs') diff --git a/test/fixtures/roots/duplicate/one/challenge.json b/test/fixtures/roots/duplicate/challenges/one/challenge.json similarity index 100% rename from test/fixtures/roots/duplicate/one/challenge.json rename to test/fixtures/roots/duplicate/challenges/one/challenge.json diff --git a/test/fixtures/roots/duplicate/one/intro.md b/test/fixtures/roots/duplicate/challenges/one/intro.md similarity index 100% rename from test/fixtures/roots/duplicate/one/intro.md rename to test/fixtures/roots/duplicate/challenges/one/intro.md diff --git a/test/fixtures/roots/duplicate/one/plugin.json b/test/fixtures/roots/duplicate/challenges/one/plugin.json similarity index 100% rename from test/fixtures/roots/duplicate/one/plugin.json rename to test/fixtures/roots/duplicate/challenges/one/plugin.json diff --git a/test/fixtures/roots/duplicate/one/skills/start-challenge/SKILL.md b/test/fixtures/roots/duplicate/challenges/one/skills/start-challenge/SKILL.md similarity index 100% rename from test/fixtures/roots/duplicate/one/skills/start-challenge/SKILL.md rename to test/fixtures/roots/duplicate/challenges/one/skills/start-challenge/SKILL.md diff --git a/test/fixtures/roots/duplicate/two/challenge.json b/test/fixtures/roots/duplicate/challenges/two/challenge.json similarity index 100% rename from test/fixtures/roots/duplicate/two/challenge.json rename to test/fixtures/roots/duplicate/challenges/two/challenge.json diff --git a/test/fixtures/roots/duplicate/two/intro.md b/test/fixtures/roots/duplicate/challenges/two/intro.md similarity index 100% rename from test/fixtures/roots/duplicate/two/intro.md rename to test/fixtures/roots/duplicate/challenges/two/intro.md diff --git a/test/fixtures/roots/duplicate/two/plugin.json b/test/fixtures/roots/duplicate/challenges/two/plugin.json similarity index 100% rename from test/fixtures/roots/duplicate/two/plugin.json rename to test/fixtures/roots/duplicate/challenges/two/plugin.json diff --git a/test/fixtures/roots/duplicate/two/skills/start-challenge/SKILL.md b/test/fixtures/roots/duplicate/challenges/two/skills/start-challenge/SKILL.md similarity index 100% rename from test/fixtures/roots/duplicate/two/skills/start-challenge/SKILL.md rename to test/fixtures/roots/duplicate/challenges/two/skills/start-challenge/SKILL.md diff --git a/test/fixtures/roots/grouped/challenges/beginner/gamma/challenge.json b/test/fixtures/roots/grouped/challenges/beginner/gamma/challenge.json new file mode 100644 index 0000000..c4d755d --- /dev/null +++ b/test/fixtures/roots/grouped/challenges/beginner/gamma/challenge.json @@ -0,0 +1,22 @@ +{ + "id": "fixture-gamma", + "title": "Fixture fixture-gamma", + "tagline": "Minimal valid package.", + "type": "terminal", + "agent": "pi", + "model": "google/gemini-2.5-flash", + "vault_services": ["gemini"], + "points": 10, + "estimated_minutes": 2, + "learning_objectives": ["talking to an agent"], + "featured_tech": ["Pi"], + "intro": "intro.md", + "steps": [ + { + "id": "hello", + "title": "Say hello", + "hint": "Ask the agent to print hello.", + "done_when": "The agent has printed hello in the terminal." + } + ] +} diff --git a/test/fixtures/roots/grouped/challenges/beginner/gamma/intro.md b/test/fixtures/roots/grouped/challenges/beginner/gamma/intro.md new file mode 100644 index 0000000..05b4fec --- /dev/null +++ b/test/fixtures/roots/grouped/challenges/beginner/gamma/intro.md @@ -0,0 +1,3 @@ +# fixture-gamma + +Minimal fixture. diff --git a/test/fixtures/roots/grouped/challenges/beginner/gamma/plugin.json b/test/fixtures/roots/grouped/challenges/beginner/gamma/plugin.json new file mode 100644 index 0000000..afc716e --- /dev/null +++ b/test/fixtures/roots/grouped/challenges/beginner/gamma/plugin.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "fixture-gamma", + "version": "0.0.0", + "description": "Minimal test fixture." +} diff --git a/test/fixtures/roots/invalid/broken/skills/start-challenge/SKILL.md b/test/fixtures/roots/grouped/challenges/beginner/gamma/skills/start-challenge/SKILL.md similarity index 100% rename from test/fixtures/roots/invalid/broken/skills/start-challenge/SKILL.md rename to test/fixtures/roots/grouped/challenges/beginner/gamma/skills/start-challenge/SKILL.md diff --git a/test/fixtures/roots/invalid/broken/challenge.json b/test/fixtures/roots/invalid/challenges/broken/challenge.json similarity index 100% rename from test/fixtures/roots/invalid/broken/challenge.json rename to test/fixtures/roots/invalid/challenges/broken/challenge.json diff --git a/test/fixtures/roots/invalid/broken/intro.md b/test/fixtures/roots/invalid/challenges/broken/intro.md similarity index 100% rename from test/fixtures/roots/invalid/broken/intro.md rename to test/fixtures/roots/invalid/challenges/broken/intro.md diff --git a/test/fixtures/roots/ok/alpha/skills/start-challenge/SKILL.md b/test/fixtures/roots/invalid/challenges/broken/skills/start-challenge/SKILL.md similarity index 100% rename from test/fixtures/roots/ok/alpha/skills/start-challenge/SKILL.md rename to test/fixtures/roots/invalid/challenges/broken/skills/start-challenge/SKILL.md diff --git a/test/fixtures/roots/layout/.hidden/challenge.json b/test/fixtures/roots/layout/challenges/.hidden/challenge.json similarity index 100% rename from test/fixtures/roots/layout/.hidden/challenge.json rename to test/fixtures/roots/layout/challenges/.hidden/challenge.json diff --git a/test/fixtures/roots/layout/holder/nested/challenge.json b/test/fixtures/roots/layout/challenges/holder/nested/challenge.json similarity index 100% rename from test/fixtures/roots/layout/holder/nested/challenge.json rename to test/fixtures/roots/layout/challenges/holder/nested/challenge.json diff --git a/test/fixtures/roots/layout/linked-a b/test/fixtures/roots/layout/challenges/linked-a similarity index 100% rename from test/fixtures/roots/layout/linked-a rename to test/fixtures/roots/layout/challenges/linked-a diff --git a/test/fixtures/roots/layout/loose.txt b/test/fixtures/roots/layout/challenges/loose.txt similarity index 100% rename from test/fixtures/roots/layout/loose.txt rename to test/fixtures/roots/layout/challenges/loose.txt diff --git a/test/fixtures/roots/layout/no-manifest/README.md b/test/fixtures/roots/layout/challenges/no-manifest/README.md similarity index 100% rename from test/fixtures/roots/layout/no-manifest/README.md rename to test/fixtures/roots/layout/challenges/no-manifest/README.md diff --git a/test/fixtures/roots/layout/top-a/challenge.json b/test/fixtures/roots/layout/challenges/top-a/challenge.json similarity index 100% rename from test/fixtures/roots/layout/top-a/challenge.json rename to test/fixtures/roots/layout/challenges/top-a/challenge.json diff --git a/test/fixtures/roots/layout/top-b/challenge.json b/test/fixtures/roots/layout/challenges/top-b/challenge.json similarity index 100% rename from test/fixtures/roots/layout/top-b/challenge.json rename to test/fixtures/roots/layout/challenges/top-b/challenge.json diff --git a/test/fixtures/roots/ok/alpha/challenge.json b/test/fixtures/roots/ok/challenges/alpha/challenge.json similarity index 100% rename from test/fixtures/roots/ok/alpha/challenge.json rename to test/fixtures/roots/ok/challenges/alpha/challenge.json diff --git a/test/fixtures/roots/ok/alpha/intro.md b/test/fixtures/roots/ok/challenges/alpha/intro.md similarity index 100% rename from test/fixtures/roots/ok/alpha/intro.md rename to test/fixtures/roots/ok/challenges/alpha/intro.md diff --git a/test/fixtures/roots/ok/alpha/plugin.json b/test/fixtures/roots/ok/challenges/alpha/plugin.json similarity index 100% rename from test/fixtures/roots/ok/alpha/plugin.json rename to test/fixtures/roots/ok/challenges/alpha/plugin.json diff --git a/test/fixtures/roots/ok/beta/skills/start-challenge/SKILL.md b/test/fixtures/roots/ok/challenges/alpha/skills/start-challenge/SKILL.md similarity index 100% rename from test/fixtures/roots/ok/beta/skills/start-challenge/SKILL.md rename to test/fixtures/roots/ok/challenges/alpha/skills/start-challenge/SKILL.md diff --git a/test/fixtures/roots/ok/beta/challenge.json b/test/fixtures/roots/ok/challenges/beta/challenge.json similarity index 100% rename from test/fixtures/roots/ok/beta/challenge.json rename to test/fixtures/roots/ok/challenges/beta/challenge.json diff --git a/test/fixtures/roots/ok/beta/intro.md b/test/fixtures/roots/ok/challenges/beta/intro.md similarity index 100% rename from test/fixtures/roots/ok/beta/intro.md rename to test/fixtures/roots/ok/challenges/beta/intro.md diff --git a/test/fixtures/roots/ok/beta/plugin.json b/test/fixtures/roots/ok/challenges/beta/plugin.json similarity index 100% rename from test/fixtures/roots/ok/beta/plugin.json rename to test/fixtures/roots/ok/challenges/beta/plugin.json diff --git a/test/fixtures/roots/ok/challenges/beta/skills/start-challenge/SKILL.md b/test/fixtures/roots/ok/challenges/beta/skills/start-challenge/SKILL.md new file mode 100644 index 0000000..7f714bd --- /dev/null +++ b/test/fixtures/roots/ok/challenges/beta/skills/start-challenge/SKILL.md @@ -0,0 +1,8 @@ +--- +name: start-challenge +description: Guides the fixture. Use when the participant starts it. +--- + +# Guide + +Say hello. diff --git a/test/fixtures/roots/outside/challenges/alpha/challenge.json b/test/fixtures/roots/outside/challenges/alpha/challenge.json new file mode 100644 index 0000000..5b4e8af --- /dev/null +++ b/test/fixtures/roots/outside/challenges/alpha/challenge.json @@ -0,0 +1,22 @@ +{ + "id": "fixture-alpha", + "title": "Fixture fixture-alpha", + "tagline": "Minimal valid package.", + "type": "terminal", + "agent": "pi", + "model": "google/gemini-2.5-flash", + "vault_services": ["gemini"], + "points": 10, + "estimated_minutes": 2, + "learning_objectives": ["talking to an agent"], + "featured_tech": ["Pi"], + "intro": "intro.md", + "steps": [ + { + "id": "hello", + "title": "Say hello", + "hint": "Ask the agent to print hello.", + "done_when": "The agent has printed hello in the terminal." + } + ] +} diff --git a/test/fixtures/roots/outside/challenges/alpha/intro.md b/test/fixtures/roots/outside/challenges/alpha/intro.md new file mode 100644 index 0000000..b694b4a --- /dev/null +++ b/test/fixtures/roots/outside/challenges/alpha/intro.md @@ -0,0 +1,3 @@ +# fixture-alpha + +Minimal fixture. diff --git a/test/fixtures/roots/outside/challenges/alpha/plugin.json b/test/fixtures/roots/outside/challenges/alpha/plugin.json new file mode 100644 index 0000000..bc6e215 --- /dev/null +++ b/test/fixtures/roots/outside/challenges/alpha/plugin.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "fixture-alpha", + "version": "0.0.0", + "description": "Minimal test fixture." +} diff --git a/test/fixtures/roots/outside/challenges/alpha/skills/start-challenge/SKILL.md b/test/fixtures/roots/outside/challenges/alpha/skills/start-challenge/SKILL.md new file mode 100644 index 0000000..7f714bd --- /dev/null +++ b/test/fixtures/roots/outside/challenges/alpha/skills/start-challenge/SKILL.md @@ -0,0 +1,8 @@ +--- +name: start-challenge +description: Guides the fixture. Use when the participant starts it. +--- + +# Guide + +Say hello. diff --git a/test/fixtures/roots/outside/stray/challenge.json b/test/fixtures/roots/outside/stray/challenge.json new file mode 100644 index 0000000..92b1144 --- /dev/null +++ b/test/fixtures/roots/outside/stray/challenge.json @@ -0,0 +1,22 @@ +{ + "id": "fixture-stray", + "title": "Fixture fixture-stray", + "tagline": "Minimal valid package.", + "type": "terminal", + "agent": "pi", + "model": "google/gemini-2.5-flash", + "vault_services": ["gemini"], + "points": 10, + "estimated_minutes": 2, + "learning_objectives": ["talking to an agent"], + "featured_tech": ["Pi"], + "intro": "intro.md", + "steps": [ + { + "id": "hello", + "title": "Say hello", + "hint": "Ask the agent to print hello.", + "done_when": "The agent has printed hello in the terminal." + } + ] +} diff --git a/test/fixtures/roots/outside/stray/intro.md b/test/fixtures/roots/outside/stray/intro.md new file mode 100644 index 0000000..19f8ff4 --- /dev/null +++ b/test/fixtures/roots/outside/stray/intro.md @@ -0,0 +1,3 @@ +# fixture-stray + +Minimal fixture. diff --git a/test/fixtures/roots/outside/stray/plugin.json b/test/fixtures/roots/outside/stray/plugin.json new file mode 100644 index 0000000..2db3ae6 --- /dev/null +++ b/test/fixtures/roots/outside/stray/plugin.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "fixture-stray", + "version": "0.0.0", + "description": "Minimal test fixture." +} diff --git a/test/fixtures/roots/outside/stray/skills/start-challenge/SKILL.md b/test/fixtures/roots/outside/stray/skills/start-challenge/SKILL.md new file mode 100644 index 0000000..7f714bd --- /dev/null +++ b/test/fixtures/roots/outside/stray/skills/start-challenge/SKILL.md @@ -0,0 +1,8 @@ +--- +name: start-challenge +description: Guides the fixture. Use when the participant starts it. +--- + +# Guide + +Say hello. diff --git a/test/hello-world.test.mjs b/test/hello-world.test.mjs index 8193c29..79c4ada 100644 --- a/test/hello-world.test.mjs +++ b/test/hello-world.test.mjs @@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' const execFileAsync = promisify(execFile) -const challengeDirectory = fileURLToPath(new URL('../hello-world/', import.meta.url)) +const challengeDirectory = fileURLToPath(new URL('../challenges/hello-world/', import.meta.url)) /** Creates a temporary directory that is removed when the test ends. */ const makeDirectory = async (context) => { diff --git a/test/verify.test.mjs b/test/verify.test.mjs index d8c6eb1..60aa92b 100644 --- a/test/verify.test.mjs +++ b/test/verify.test.mjs @@ -9,7 +9,7 @@ import path from 'node:path' import { test } from 'node:test' import { fileURLToPath } from 'node:url' -import { findDuplicateIds, findPackages, main } from '../tools/verify.mjs' +import { findDuplicateIds, findPackages, findPackagesOutsideChallenges, main } from '../tools/verify.mjs' const testDirectory = path.dirname(fileURLToPath(import.meta.url)) const verifyScript = path.join(testDirectory, '..', 'tools', 'verify.mjs') @@ -43,16 +43,69 @@ const withTemporaryDirectory = async (body) => { } } -test('finds top-level directories, symlinked ones included, that hold a challenge.json', () => { +/** Makes a fresh temporary repo root with an empty challenges/ directory, runs `body` with it, and removes it afterwards. */ +const withTemporaryRoot = (body) => withTemporaryDirectory(async (root) => { + await mkdir(path.join(root, 'challenges')) + + return body(root, path.join(root, 'challenges')) +}) + +test('finds directories under challenges/, symlinked and nested ones included, that hold a challenge.json', () => { const names = findPackages(fixtureRoot('layout')).map((entry) => entry.name) - assert.deepEqual(names, ['linked-a', 'top-a', 'top-b']) + assert.deepEqual(names, ['challenges/holder/nested', 'challenges/linked-a', 'challenges/top-a', 'challenges/top-b']) +}) + +test('finds a package inside a grouping folder', () => { + const names = findPackages(fixtureRoot('grouped')).map((entry) => entry.name) + + assert.deepEqual(names, ['challenges/beginner/gamma']) }) +test('finds no packages when there is no challenges/ directory', () => withTemporaryDirectory(async (root) => { + await mkdir(path.join(root, 'stray')) + await writeFile(path.join(root, 'stray', 'challenge.json'), '{"id": "stray"}') + + const names = findPackages(root).map((entry) => entry.name) + + assert.deepEqual(names, []) +})) + +test('does not look inside a package, node_modules, or a dot-directory for more packages', () => withTemporaryRoot(async (_, challenges) => { + const manifestPaths = ['outer', 'outer/inner', 'node_modules/dependency', '.cache/entry', 'group/real'] + for (const manifestPath of manifestPaths) { + await mkdir(path.join(challenges, manifestPath), { recursive: true }) + await writeFile(path.join(challenges, manifestPath, 'challenge.json'), '{}') + } + + const names = findPackages(path.dirname(challenges)).map((entry) => entry.name) + + assert.deepEqual(names, ['challenges/group/real', 'challenges/outer']) +})) + +test('does not walk forever through a symlink back to an ancestor directory', () => withTemporaryRoot(async (root, challenges) => { + await mkdir(path.join(challenges, 'group')) + await symlink('..', path.join(challenges, 'group', 'up')) + + const names = findPackages(root).map((entry) => entry.name) + + assert.deepEqual(names, []) +})) + +test('reports a package at the root or in a top-level directory other than challenges/', () => withTemporaryRoot(async (root) => { + await writeFile(path.join(root, 'challenge.json'), '{}') + await mkdir(path.join(root, 'stray')) + await writeFile(path.join(root, 'stray', 'challenge.json'), '{}') + + const strayManifests = findPackagesOutsideChallenges(root) + + assert.deepEqual(strayManifests, ['challenge.json', 'stray/challenge.json']) +})) + test('reports packages that share a challenge id with the directories involved', () => { const packages = findPackages(fixtureRoot('duplicate')) - assert.deepEqual(findDuplicateIds(packages), [{ id: 'fixture-alpha', names: ['one', 'two'] }]) + assert.deepEqual(findDuplicateIds(packages), [{ id: 'fixture-alpha', names: ['challenges/one', 'challenges/two'] }]) }) test('reports no duplicates when every id is unique', () => { @@ -61,15 +114,15 @@ test('reports no duplicates when every id is unique', () => { assert.deepEqual(findDuplicateIds(packages), []) }) -test('skips a dangling top-level symlink', () => withTemporaryDirectory(async (root) => { - await symlink(path.join(root, 'gone'), path.join(root, 'dangling')) +test('skips a dangling symlink under challenges/', () => withTemporaryRoot(async (root, challenges) => { + await symlink(path.join(challenges, 'gone'), path.join(challenges, 'dangling')) const names = findPackages(root).map((entry) => entry.name) assert.deepEqual(names, []) })) -test('leaves a package with an unparseable or id-less manifest out of the duplicate check', () => withTemporaryDirectory(async (root) => { +test('leaves a package with an unparseable or id-less manifest out of the duplicate check', () => withTemporaryRoot(async (root, challenges) => { const manifests = { first: '{"id": "shared"}', second: '{"id": "shared"}', @@ -78,13 +131,13 @@ test('leaves a package with an unparseable or id-less manifest out of the duplic 'numeric-id': '{"id": 7}', } for (const [name, manifest] of Object.entries(manifests)) { - await mkdir(path.join(root, name)) - await writeFile(path.join(root, name, 'challenge.json'), manifest) + await mkdir(path.join(challenges, name)) + await writeFile(path.join(challenges, name, 'challenge.json'), manifest) } const duplicates = findDuplicateIds(findPackages(root)) - assert.deepEqual(duplicates, [{ id: 'shared', names: ['first', 'second'] }]) + assert.deepEqual(duplicates, [{ id: 'shared', names: ['challenges/first', 'challenges/second'] }]) })) test('exits 0 when every package verifies and ids are unique', () => { @@ -96,6 +149,15 @@ test('exits 0 when every package verifies and ids are unique', () => { assert.deepEqual(directories.map((dir) => path.basename(dir)), ['alpha', 'beta']) }) +test('exits 1 without verifying when a package sits outside challenges/', () => { + const { directories, verify } = fakeVerifier() + + const code = main({ root: fixtureRoot('outside'), verify }) + + assert.equal(code, 1) + assert.deepEqual(directories.map((dir) => path.basename(dir)), ['alpha']) +}) + test('exits 1 on duplicate ids even when every package verifies', () => { const code = main({ root: fixtureRoot('duplicate'), verify: fakeVerifier().verify }) @@ -130,11 +192,26 @@ test('CLI exits 1 for a package the real boothstrap rejects', () => { assert.match(result.stderr, /not a valid challenge package/) }) -test('CLI verifies a symlinked package and still reports the others after a failure', () => withTemporaryDirectory(async (root) => { - await cp(path.join(fixtureRoot('invalid'), 'broken'), path.join(root, 'broken'), { recursive: true }) - await cp(path.join(fixtureRoot('ok'), 'alpha'), path.join(root, 'alpha'), { recursive: true }) - await symlink('broken', path.join(root, 'a-broken')) - await symlink('alpha', path.join(root, 'b-alpha')) +test('CLI exits 0 for a package in a grouping folder', () => { + const result = runCli(fixtureRoot('grouped')) + + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /ok .*gamma/) +}) + +test('CLI exits 1 naming a package outside challenges/ while still verifying the others', () => { + const result = runCli(fixtureRoot('outside')) + + assert.equal(result.status, 1) + assert.match(result.stderr, /^stray\/challenge\.json is outside challenges\/; move its package under challenges\/$/m) + assert.match(result.stdout, /ok .*alpha/) +}) + +test('CLI verifies a symlinked package and still reports the others after a failure', () => withTemporaryRoot(async (root, challenges) => { + await cp(path.join(fixtureRoot('invalid'), 'challenges', 'broken'), path.join(challenges, 'broken'), { recursive: true }) + await cp(path.join(fixtureRoot('ok'), 'challenges', 'alpha'), path.join(challenges, 'alpha'), { recursive: true }) + await symlink('broken', path.join(challenges, 'a-broken')) + await symlink('alpha', path.join(challenges, 'b-alpha')) const result = runCli(root) @@ -164,6 +241,16 @@ for (const { name, setup, target, reason } of unsafeSymlinkCases) { })) } +test('CLI exits 1 naming an unsafe symlink inside a grouping folder under challenges/', () => withTemporaryRoot(async (root, challenges) => { + await mkdir(path.join(challenges, 'group')) + await symlink('../../..', path.join(challenges, 'group', 'link')) + + const result = runCli(root) + + assert.equal(result.status, 1) + assert.match(result.stderr, /^Unsafe symlink "challenges\/group\/link" -> \.\.\/\.\.\/\.\.: points outside the repo$/m) +})) + test('CLI exits 1 naming a dot-named top-level symlink', () => withTemporaryDirectory(async (root) => { await symlink('..', path.join(root, '.hidden')) @@ -177,7 +264,7 @@ test('CLI exits 1 and names the directories for duplicate ids', () => { const result = runCli(fixtureRoot('duplicate')) assert.equal(result.status, 1) - assert.match(result.stderr, /Duplicate challenge id "fixture-alpha" in: one, two/) + assert.match(result.stderr, /Duplicate challenge id "fixture-alpha" in: challenges\/one, challenges\/two/) }) test('CLI rejects an unknown flag with a one-line message and no stack', () => { diff --git a/test/vibe-coding-mad-libs.test.mjs b/test/vibe-coding-mad-libs.test.mjs index 32d9a8b..e816196 100644 --- a/test/vibe-coding-mad-libs.test.mjs +++ b/test/vibe-coding-mad-libs.test.mjs @@ -11,10 +11,10 @@ import path from 'node:path' import { test } from 'node:test' import { fileURLToPath, pathToFileURL } from 'node:url' -import { assemblePrompt, fillSentence, hasUnfilledBlank, isFinishedPrompt } from '../vibe-coding-mad-libs/prompt.mjs' +import { assemblePrompt, fillSentence, hasUnfilledBlank, isFinishedPrompt } from '../challenges/vibe-coding-mad-libs/prompt.mjs' const testDirectory = path.dirname(fileURLToPath(import.meta.url)) -const packageDirectory = path.join(testDirectory, '..', 'vibe-coding-mad-libs') +const packageDirectory = path.join(testDirectory, '..', 'challenges', 'vibe-coding-mad-libs') const installTimeoutMilliseconds = 300_000 const serverExitTimeoutMilliseconds = 5_000 diff --git a/tools/verify.mjs b/tools/verify.mjs index 6f6fc85..f2a4dcb 100644 --- a/tools/verify.mjs +++ b/tools/verify.mjs @@ -1,5 +1,6 @@ -// Verifies every challenge package at the top level of the repo with the -// station's own `boothstrap dev --verify`, and rejects duplicate challenge ids. +// Verifies every challenge package under the repo's challenges/ directory with +// the station's own `boothstrap dev --verify`, and rejects duplicate challenge +// ids, packages outside challenges/, and unsafe symlinks. import { spawnSync } from 'node:child_process' import { existsSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs' @@ -17,6 +18,9 @@ function compareNames(left, right) { return 0 } +/** The directory, relative to the repo root, that holds every challenge package. */ +const CHALLENGES_DIRECTORY = 'challenges' + /** Error codes for a symlink that dangles, loops, or runs through a file. */ const BROKEN_LINK_CODES = ['ENOENT', 'ENOTDIR', 'ELOOP'] @@ -25,27 +29,88 @@ const BROKEN_LINK_CODES = ['ENOENT', 'ENOTDIR', 'ELOOP'] * keeps in-repo symlinks. A broken symlink is not a directory; see * {@link findUnsafeSymlinks} for how it fails verification. */ -function isDirectoryFollowingSymlinks(root, entry) { +function isDirectoryFollowingSymlinks(directory, entry) { try { - return statSync(path.join(root, entry.name)).isDirectory() + return statSync(path.join(directory, entry.name)).isDirectory() } catch (error) { if (BROKEN_LINK_CODES.includes(error.code)) return false throw error } } +/** True when `directory` holds a challenge.json. */ +function holdsManifest(directory) { + return existsSync(path.join(directory, 'challenge.json')) +} + +/** Lists the subdirectories of `directory`, symlinks to directories included, skipping dot-directories and node_modules. */ +function listSubdirectories(directory) { + return readdirSync(directory, { withFileTypes: true }) + .filter((entry) => !entry.name.startsWith('.') && entry.name !== 'node_modules') + .filter((entry) => isDirectoryFollowingSymlinks(directory, entry)) + .map((entry) => entry.name) + .sort(compareNames) +} + /** - * Lists the top-level directories of `root` (symlinks to directories - * included) that hold a challenge.json, skipping dot-directories and - * node_modules. Returns `{ dir, name }` sorted by name. + * Walks `directory` for packages and for the grouping directories between + * them. A directory holding a challenge.json is a package and is not entered; + * a directory that resolves to one of its own ancestors is not entered again. + * Returns `{ packages, groupings }`, where each package is `{ dir, name }` + * with `name` relative to `root`, and each grouping is a directory walked. + */ +function walkForPackages(root, directory, ancestors) { + const realDirectory = realpathSync(directory) + if (ancestors.includes(realDirectory)) return { packages: [], groupings: [] } + + const found = { packages: [], groupings: [directory] } + for (const name of listSubdirectories(directory)) { + const child = path.join(directory, name) + if (holdsManifest(child)) { + found.packages.push({ dir: child, name: path.relative(root, child).split(path.sep).join('/') }) + continue + } + + const nested = walkForPackages(root, child, [...ancestors, realDirectory]) + found.packages.push(...nested.packages) + found.groupings.push(...nested.groupings) + } + + return found +} + +/** Walks `root`/challenges, or returns nothing when it does not exist as a directory. */ +function walkChallenges(root) { + const challenges = path.join(root, CHALLENGES_DIRECTORY) + const hasChallenges = existsSync(challenges) && statSync(challenges).isDirectory() + + return hasChallenges ? walkForPackages(root, challenges, []) : { packages: [], groupings: [] } +} + +/** + * Lists the packages under `root`/challenges at any depth: directories + * (symlinks to directories included) that hold a challenge.json, skipping + * dot-directories and node_modules and never entering a package. Returns + * `{ dir, name }` sorted by name, where `name` is relative to `root`. */ export function findPackages(root) { - return readdirSync(root, { withFileTypes: true }) - .filter((entry) => !entry.name.startsWith('.') && entry.name !== 'node_modules') - .filter((entry) => isDirectoryFollowingSymlinks(root, entry)) - .filter((entry) => existsSync(path.join(root, entry.name, 'challenge.json'))) - .map((entry) => ({ name: entry.name, dir: path.join(root, entry.name) })) - .sort((left, right) => compareNames(left.name, right.name)) + return walkChallenges(root).packages.sort((left, right) => compareNames(left.name, right.name)) +} + +/** + * Lists the directories outside `root`/challenges that hold a challenge.json: + * `root` itself and its top-level directories (symlinks to directories + * included, dot-directories and node_modules skipped). A package there is + * invisible to the station, so each one fails verification. Returns the + * manifest paths relative to `root`. + */ +export function findPackagesOutsideChallenges(root) { + const strayDirectories = listSubdirectories(root).filter((name) => name !== CHALLENGES_DIRECTORY) + const strayManifests = strayDirectories + .filter((name) => holdsManifest(path.join(root, name))) + .map((name) => `${name}/challenge.json`) + + return holdsManifest(root) ? ['challenge.json', ...strayManifests] : strayManifests } /** True when `candidate` is `parent` or lies beneath it. */ @@ -55,7 +120,7 @@ function isInside(parent, candidate) { return !relative.startsWith('..') && !path.isAbsolute(relative) } -/** Explains why a top-level symlink is unsafe, or returns undefined when it is fine. */ +/** Explains why a symlink is unsafe, or returns undefined when it is fine. */ function explainUnsafeSymlink(realRoot, linkPath) { if (path.isAbsolute(readlinkSync(linkPath))) return 'has an absolute target' @@ -71,21 +136,24 @@ function explainUnsafeSymlink(realRoot, linkPath) { } /** - * Lists the top-level symlinks of `root` that the station refuses to unpack: - * absolute, dangling, looping, or resolving outside `root`. Returns one - * message per link, naming it and its target. + * Lists the symlinks directly inside `root` and inside every directory walked + * under `root`/challenges that the station refuses to unpack: absolute, + * dangling, looping, or resolving outside `root`. Returns one message per + * link, naming it relative to `root` and its target. */ export function findUnsafeSymlinks(root) { const realRoot = realpathSync(root) + const walkedDirectories = [root, ...walkChallenges(root).groupings] - return readdirSync(root, { withFileTypes: true }) + return walkedDirectories.flatMap((directory) => readdirSync(directory, { withFileTypes: true }) .filter((entry) => entry.isSymbolicLink() && entry.name !== 'node_modules') .flatMap((entry) => { - const linkPath = path.join(root, entry.name) + const linkPath = path.join(directory, entry.name) const reason = explainUnsafeSymlink(realRoot, linkPath) + const displayName = path.relative(root, linkPath).split(path.sep).join('/') - return reason === undefined ? [] : [`Unsafe symlink "${entry.name}" -> ${readlinkSync(linkPath)}: ${reason}`] - }) + return reason === undefined ? [] : [`Unsafe symlink "${displayName}" -> ${readlinkSync(linkPath)}: ${reason}`] + })) } /** @@ -136,18 +204,24 @@ export function verifyWithBoothstrap(packageDirs, binary = boothstrapBin) { } /** - * Verifies all packages under `root` and returns the process exit code: - * 0 when every package verifies, ids are unique and no top-level symlink is - * unsafe (or no packages exist), else 1. + * Verifies all packages under `root`/challenges and returns the process exit + * code: 0 when every package verifies, ids are unique, no package sits outside + * challenges/ and no symlink is unsafe (or no packages exist), else 1. */ export function main({ root = repoRoot, verify = verifyWithBoothstrap } = {}) { const unsafeSymlinks = findUnsafeSymlinks(root) for (const message of unsafeSymlinks) console.error(message) + const strayManifests = findPackagesOutsideChallenges(root) + for (const manifest of strayManifests) { + console.error(`${manifest} is outside ${CHALLENGES_DIRECTORY}/; move its package under ${CHALLENGES_DIRECTORY}/`) + } + + const structureIsValid = unsafeSymlinks.length === 0 && strayManifests.length === 0 const packages = findPackages(root) if (packages.length === 0) { console.log('No challenge packages found.') - return unsafeSymlinks.length === 0 ? 0 : 1 + return structureIsValid ? 0 : 1 } const allVerified = verify(packages.map(({ dir }) => dir)) @@ -156,7 +230,7 @@ export function main({ root = repoRoot, verify = verifyWithBoothstrap } = {}) { console.error(`Duplicate challenge id "${id}" in: ${names.join(', ')}`) } - return allVerified && duplicates.length === 0 && unsafeSymlinks.length === 0 ? 0 : 1 + return allVerified && duplicates.length === 0 && structureIsValid ? 0 : 1 } /** Signals a bad command line; the CLI prints its message without a stack. */ From 0a19612bb781c45e64cd3f20ba113fbe010e1442 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:42:46 +0000 Subject: [PATCH 2/3] fix: keep the package walk inside challenges/ and flag strays beside it --- AGENTS.md | 10 +++--- challenges/hello-world/README.md | 4 +-- test/verify.test.mjs | 24 ++++++++++++-- tools/verify.mjs | 56 ++++++++++++++++++++++---------- 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa4d3a7..ae4d3aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,8 @@ every PR is checked by the same package validator stations run. - Each directory under `challenges/` holding a `challenge.json` is one package, named after its `id`; grouping folders such as `challenges/beginner/` are allowed. Event configs list `challenges/` - (or the nested path). A `challenge.json` outside `challenges/` fails verify. + (or the nested path). A `challenge.json` at the repo root, directly in + `challenges/`, or in another top-level directory fails verify. No two packages share an id. - The contract is boothstrap.sh's `docs/specs/2026-09-26-mvp-challenge-spec.md`, with `docs/authoring-challenges.md` for the workflow. The spec wins over this @@ -52,10 +53,9 @@ every PR is checked by the same package validator stations run. `@mlhacks/boothstrap`, pinned exactly; bumping it is a one-line PR. - In-repo symlinks are followed: a symlinked directory under `challenges/` holding a `challenge.json` is a package. A symlink at the repo root or under - `challenges/` that is absolute, - dangling, looping, or points outside the repo fails verify, as the station - refuses it. `npm run lint` also lints Markdown inside packages, so the house - Markdown rules apply there. + `challenges/` that is absolute, dangling, looping, or points outside the + repo fails verify, as the station refuses it. `npm run lint` also lints + Markdown inside packages, so the house Markdown rules apply there. - `npm run verify` runs `boothstrap dev --verify` on every package. Add no rule the station doesn't enforce. - Test rules, including the network rule, are in diff --git a/challenges/hello-world/README.md b/challenges/hello-world/README.md index 40c65f8..ab03f01 100644 --- a/challenges/hello-world/README.md +++ b/challenges/hello-world/README.md @@ -10,7 +10,7 @@ package has no `install` hook, no Pi extensions, and no MCP servers. Export `GEMINI_API_KEY`, then set the package path: ```sh -challenge=/absolute/path/to/microhackathon-challenges/hello-world +challenge=/absolute/path/to/microhackathon-challenges/challenges/hello-world ``` Install Pi and the adapter, at the versions boothstrap pins: @@ -26,7 +26,7 @@ Load the package by adding its absolute path to `settings.agentPluginPaths` in ```json { "settings": { - "agentPluginPaths": ["/absolute/path/to/microhackathon-challenges/hello-world"] + "agentPluginPaths": ["/absolute/path/to/microhackathon-challenges/challenges/hello-world"] } } ``` diff --git a/test/verify.test.mjs b/test/verify.test.mjs index 60aa92b..8838fc9 100644 --- a/test/verify.test.mjs +++ b/test/verify.test.mjs @@ -94,12 +94,32 @@ test('does not walk forever through a symlink back to an ancestor directory', () test('reports a package at the root or in a top-level directory other than challenges/', () => withTemporaryRoot(async (root) => { await writeFile(path.join(root, 'challenge.json'), '{}') + await writeFile(path.join(root, 'challenges', 'challenge.json'), '{}') await mkdir(path.join(root, 'stray')) await writeFile(path.join(root, 'stray', 'challenge.json'), '{}') const strayManifests = findPackagesOutsideChallenges(root) - assert.deepEqual(strayManifests, ['challenge.json', 'stray/challenge.json']) + assert.deepEqual(strayManifests, ['challenge.json', 'challenges/challenge.json', 'stray/challenge.json']) +})) + +test('does not walk a symlink that leaves challenges/, even to a place inside the repo', () => withTemporaryRoot(async (root, challenges) => { + const outsideRoot = await mkdtemp(path.join(tmpdir(), 'verify-outside-')) + try { + await mkdir(path.join(outsideRoot, 'evil')) + await writeFile(path.join(outsideRoot, 'evil', 'challenge.json'), '{}') + await mkdir(path.join(root, 'test', 'fixture'), { recursive: true }) + await writeFile(path.join(root, 'test', 'fixture', 'challenge.json'), '{}') + await mkdir(path.join(challenges, 'group')) + await symlink(outsideRoot, path.join(challenges, 'group', 'outside')) + await symlink('../../test', path.join(challenges, 'group', 'inside')) + + const names = findPackages(root).map((entry) => entry.name) + + assert.deepEqual(names, []) + } finally { + await rm(outsideRoot, { recursive: true, force: true }) + } })) test('reports packages that share a challenge id with the directories involved', () => { @@ -149,7 +169,7 @@ test('exits 0 when every package verifies and ids are unique', () => { assert.deepEqual(directories.map((dir) => path.basename(dir)), ['alpha', 'beta']) }) -test('exits 1 without verifying when a package sits outside challenges/', () => { +test('exits 1 when a package sits outside challenges/, still verifying the rest', () => { const { directories, verify } = fakeVerifier() const code = main({ root: fixtureRoot('outside'), verify }) diff --git a/tools/verify.mjs b/tools/verify.mjs index f2a4dcb..4058eac 100644 --- a/tools/verify.mjs +++ b/tools/verify.mjs @@ -43,35 +43,44 @@ function holdsManifest(directory) { return existsSync(path.join(directory, 'challenge.json')) } -/** Lists the subdirectories of `directory`, symlinks to directories included, skipping dot-directories and node_modules. */ +/** + * Lists the subdirectories of `directory`, symlinks to directories included, + * skipping dot-directories and node_modules. Each is `{ name, isSymlink }`. + */ function listSubdirectories(directory) { return readdirSync(directory, { withFileTypes: true }) .filter((entry) => !entry.name.startsWith('.') && entry.name !== 'node_modules') .filter((entry) => isDirectoryFollowingSymlinks(directory, entry)) - .map((entry) => entry.name) - .sort(compareNames) + .map((entry) => ({ name: entry.name, isSymlink: entry.isSymbolicLink() })) + .sort((left, right) => compareNames(left.name, right.name)) } /** * Walks `directory` for packages and for the grouping directories between - * them. A directory holding a challenge.json is a package and is not entered; - * a directory that resolves to one of its own ancestors is not entered again. - * Returns `{ packages, groupings }`, where each package is `{ dir, name }` - * with `name` relative to `root`, and each grouping is a directory walked. + * them, within `bounds` (`{ root, challenges }`, both real paths). A directory + * holding a challenge.json is a package and is not entered. Unsafe symlinks + * are never followed (see {@link findUnsafeSymlinks}, which reports them), a + * grouping directory must resolve inside the real challenges/ directory, and + * one that resolves to its own ancestor is not entered again. Returns + * `{ packages, groupings }`, where each package is `{ dir, name }` with + * `name` relative to `root`, and each grouping is a directory walked. */ -function walkForPackages(root, directory, ancestors) { +function walkForPackages(root, bounds, directory, ancestors) { const realDirectory = realpathSync(directory) if (ancestors.includes(realDirectory)) return { packages: [], groupings: [] } const found = { packages: [], groupings: [directory] } - for (const name of listSubdirectories(directory)) { + for (const { name, isSymlink } of listSubdirectories(directory)) { const child = path.join(directory, name) + if (isSymlink && explainUnsafeSymlink(bounds.root, child) !== undefined) continue + if (holdsManifest(child)) { found.packages.push({ dir: child, name: path.relative(root, child).split(path.sep).join('/') }) continue } + if (!isInside(bounds.challenges, realpathSync(child))) continue - const nested = walkForPackages(root, child, [...ancestors, realDirectory]) + const nested = walkForPackages(root, bounds, child, [...ancestors, realDirectory]) found.packages.push(...nested.packages) found.groupings.push(...nested.groupings) } @@ -83,8 +92,11 @@ function walkForPackages(root, directory, ancestors) { function walkChallenges(root) { const challenges = path.join(root, CHALLENGES_DIRECTORY) const hasChallenges = existsSync(challenges) && statSync(challenges).isDirectory() + if (!hasChallenges) return { packages: [], groupings: [] } - return hasChallenges ? walkForPackages(root, challenges, []) : { packages: [], groupings: [] } + const bounds = { root: realpathSync(root), challenges: realpathSync(challenges) } + + return walkForPackages(root, bounds, challenges, []) } /** @@ -98,19 +110,27 @@ export function findPackages(root) { } /** - * Lists the directories outside `root`/challenges that hold a challenge.json: - * `root` itself and its top-level directories (symlinks to directories - * included, dot-directories and node_modules skipped). A package there is - * invisible to the station, so each one fails verification. Returns the - * manifest paths relative to `root`. + * Lists the challenge.json files that sit where the station will not look: + * directly in `root`, directly in `root`/challenges, or in a top-level + * directory of `root` other than challenges (symlinks to directories + * included, dot-directories and node_modules skipped). Deeper strays are not + * found, because test fixtures legitimately hold manifests there. Each one + * fails verification. Returns the manifest paths relative to `root`. */ export function findPackagesOutsideChallenges(root) { - const strayDirectories = listSubdirectories(root).filter((name) => name !== CHALLENGES_DIRECTORY) + const strayDirectories = listSubdirectories(root) + .map(({ name }) => name) + .filter((name) => name !== CHALLENGES_DIRECTORY) const strayManifests = strayDirectories .filter((name) => holdsManifest(path.join(root, name))) .map((name) => `${name}/challenge.json`) + const challenges = path.join(root, CHALLENGES_DIRECTORY) + const directStrays = [ + ...(holdsManifest(root) ? ['challenge.json'] : []), + ...(holdsManifest(challenges) ? [`${CHALLENGES_DIRECTORY}/challenge.json`] : []), + ] - return holdsManifest(root) ? ['challenge.json', ...strayManifests] : strayManifests + return [...directStrays, ...strayManifests] } /** True when `candidate` is `parent` or lies beneath it. */ From 9c1171d05bd1e4877bfa2d2add81c79a3a0c02f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:48:43 +0000 Subject: [PATCH 3/3] fix: refuse an unsafe challenges symlink and tighten the layout docs --- AGENTS.md | 15 ++++++++------- README.md | 5 +++-- test/verify.test.mjs | 33 +++++++++++++++++++++++++++++++++ tools/verify.mjs | 14 +++++++++++--- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae4d3aa..4b73f13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # microhackathon-challenges -Portable challenge packages for MLH microhackathons. Each package under -`challenges/` is one challenge package that boothstrap.sh stations and -`boothstrap dev` can run. The repo is plain JavaScript (`.mjs`) on Node 26, and +Portable challenge packages for MLH microhackathons. Challenge packages live +under `challenges/`; each one runs on boothstrap.sh stations and with +`boothstrap dev`. The repo is plain JavaScript (`.mjs`) on Node 26, and every PR is checked by the same package validator stations run. ## Autonomy @@ -52,10 +52,11 @@ every PR is checked by the same package validator stations run. the repo root. The root `package.json` has one devDependency, `@mlhacks/boothstrap`, pinned exactly; bumping it is a one-line PR. - In-repo symlinks are followed: a symlinked directory under `challenges/` - holding a `challenge.json` is a package. A symlink at the repo root or under - `challenges/` that is absolute, dangling, looping, or points outside the - repo fails verify, as the station refuses it. `npm run lint` also lints - Markdown inside packages, so the house Markdown rules apply there. + holding a `challenge.json` is a package. A symlink at the repo root, in + `challenges/`, or in a grouping folder under it that is absolute, dangling, + looping, or points outside the repo fails verify, as the station refuses it. + `npm run lint` also lints Markdown inside packages, so the house Markdown + rules apply there. - `npm run verify` runs `boothstrap dev --verify` on every package. Add no rule the station doesn't enforce. - Test rules, including the network rule, are in diff --git a/README.md b/README.md index f649d51..cd75c0c 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ CI runs on every pull request and on every push to `main`, on Ubuntu with Node 26. It runs `npm ci`, then: - `npm run verify`: runs `boothstrap dev --verify` on every package under - `challenges/` and fails when two packages share an id or a `challenge.json` - sits outside `challenges/`. + `challenges/` and fails when two packages share an id or a package sits at + the repo root or in another top-level directory instead of under + `challenges/`. - `npm test`: the tests (`test/*.test.mjs`); their rules are in [docs/style/testing.md](docs/style/testing.md). - `npm run lint`: markdownlint over every Markdown file. diff --git a/test/verify.test.mjs b/test/verify.test.mjs index 8838fc9..9124608 100644 --- a/test/verify.test.mjs +++ b/test/verify.test.mjs @@ -103,6 +103,17 @@ test('reports a package at the root or in a top-level directory other than chall assert.deepEqual(strayManifests, ['challenge.json', 'challenges/challenge.json', 'stray/challenge.json']) })) +test('finds duplicate ids across grouping folders and names both nested paths', () => withTemporaryRoot(async (root, challenges) => { + for (const packagePath of ['beginner/one', 'advanced/two']) { + await mkdir(path.join(challenges, packagePath), { recursive: true }) + await writeFile(path.join(challenges, packagePath, 'challenge.json'), '{"id": "shared"}') + } + + const duplicates = findDuplicateIds(findPackages(root)) + + assert.deepEqual(duplicates, [{ id: 'shared', names: ['challenges/advanced/two', 'challenges/beginner/one'] }]) +})) + test('does not walk a symlink that leaves challenges/, even to a place inside the repo', () => withTemporaryRoot(async (root, challenges) => { const outsideRoot = await mkdtemp(path.join(tmpdir(), 'verify-outside-')) try { @@ -271,6 +282,28 @@ test('CLI exits 1 naming an unsafe symlink inside a grouping folder under challe assert.match(result.stderr, /^Unsafe symlink "challenges\/group\/link" -> \.\.\/\.\.\/\.\.: points outside the repo$/m) })) +const unsafeChallengesLinkCases = [ + { name: 'absolute', target: (outsideRoot) => outsideRoot }, + { name: 'relative and pointing outside the repo', target: (outsideRoot, root) => path.relative(root, outsideRoot) }, +] + +for (const { name, target } of unsafeChallengesLinkCases) { + test(`CLI verifies nothing and exits 1 naming a challenges/ symlink that is ${name}`, () => withTemporaryDirectory(async (container) => { + const root = path.join(container, 'repo') + const outsideRoot = path.join(container, 'outside') + await mkdir(root) + await cp(path.join(fixtureRoot('ok'), 'challenges'), outsideRoot, { recursive: true }) + await symlink(target(outsideRoot, root), path.join(root, 'challenges')) + + const result = runCli(root) + + assert.equal(result.status, 1) + assert.match(result.stderr, /^Unsafe symlink "challenges" -> .*: (has an absolute target|points outside the repo)$/m) + assert.match(result.stdout, /No challenge packages found/) + assert.doesNotMatch(result.stdout, /^ok /m) + })) +} + test('CLI exits 1 naming a dot-named top-level symlink', () => withTemporaryDirectory(async (root) => { await symlink('..', path.join(root, '.hidden')) diff --git a/tools/verify.mjs b/tools/verify.mjs index 4058eac..c47eaf2 100644 --- a/tools/verify.mjs +++ b/tools/verify.mjs @@ -3,7 +3,7 @@ // ids, packages outside challenges/, and unsafe symlinks. import { spawnSync } from 'node:child_process' -import { existsSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs' +import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -88,13 +88,21 @@ function walkForPackages(root, bounds, directory, ancestors) { return found } -/** Walks `root`/challenges, or returns nothing when it does not exist as a directory. */ +/** + * Walks `root`/challenges, or returns nothing when it does not exist as a + * directory or is a symlink that is unsafe (see {@link findUnsafeSymlinks}, + * which reports it). + */ function walkChallenges(root) { const challenges = path.join(root, CHALLENGES_DIRECTORY) const hasChallenges = existsSync(challenges) && statSync(challenges).isDirectory() if (!hasChallenges) return { packages: [], groupings: [] } - const bounds = { root: realpathSync(root), challenges: realpathSync(challenges) } + const realRoot = realpathSync(root) + const isUnsafeLink = lstatSync(challenges).isSymbolicLink() && explainUnsafeSymlink(realRoot, challenges) !== undefined + if (isUnsafeLink) return { packages: [], groupings: [] } + + const bounds = { root: realRoot, challenges: realpathSync(challenges) } return walkForPackages(root, bounds, challenges, []) }