diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 9549b569a1..bee1081815 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -389,7 +389,7 @@ jobs: # and /ru/learn//, and every course a landing at its share path; the # check refuses a page, card or learn/sitemap.xml that lags the specs. - name: Course - run: npm run check:course && npm run test:course && npm run check:course-pages + run: npm run check:course && npm run test:course && npm run check:course-pages && npm run check:course-recipe # The IGLA board on WARS: specs/queen/igla_board.t27 is generated in # gHashTag/igla-coder-gpu from committed artifacts. The gate recompiles it, diff --git a/apps/website/package.json b/apps/website/package.json index 50cc30d175..6bb4ff033f 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -123,6 +123,7 @@ "check:course": "node scripts/course-from-spec.mjs --check", "test:course": "node --test scripts/course-from-spec.test.mjs", "gen:course-pages": "node scripts/course-pages.mjs", + "check:course-recipe": "node scripts/check-course-recipe.mjs", "check:course-pages": "node scripts/course-pages.mjs --check && node --test scripts/course-pages.test.mjs", "check:widget-pages": "node scripts/widget-pages-from-spec.mjs --check", "test:widget-pages": "node --test scripts/widget-pages-from-spec.test.mjs", diff --git a/apps/website/scripts/check-course-recipe.mjs b/apps/website/scripts/check-course-recipe.mjs new file mode 100644 index 0000000000..46f071006a --- /dev/null +++ b/apps/website/scripts/check-course-recipe.mjs @@ -0,0 +1,30 @@ +// Compile every specs/course_recipe/*.t27 with the site's wasm compiler and run its test blocks. +// Exit 1 on a compile error, a failing assert, a file with zero tests, or an empty directory, +// so a recipe step cannot be dropped silently. Run from apps/website: npm run check:course-recipe +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadCompiler, verdictOf, constsOf } from './agents-from-specs.mjs' +import { runSpecTests } from './viewport-from-spec.mjs' + +const site = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const dir = path.resolve(site, process.argv[2] ?? '../../specs/course_recipe') +const files = fs.readdirSync(dir).filter((f) => f.endsWith('.t27')).sort() +if (!files.length) { + console.error(`check:course-recipe: no .t27 files in ${dir}`) + process.exit(1) +} +const analyze = await loadCompiler(fs.readFileSync(path.join(site, 'public/t27/t27_compiler.wasm'))) +let bad = 0 +for (const f of files) { + const a = analyze(fs.readFileSync(path.join(dir, f), 'utf8')) + const v = verdictOf(a) + const env = Object.fromEntries(Object.entries(constsOf(a)).map(([k, c]) => [k, c.value])) + const r = runSpecTests(a, env) + const ok = v.typecheckOk && !v.errors && !v.discarded && v.hirOk && r.tests > 0 && !r.failures.length + if (!ok) bad++ + console.log(`${ok ? 'ok ' : 'FAIL'} ${f} module ${a.ast?.name} tests ${r.tests} asserts ${r.asserts} ${JSON.stringify(v)}`) + if (!r.tests) console.log(' no tests') + for (const x of r.failures) console.log(' fail:', typeof x === 'string' ? x : JSON.stringify(x)) +} +process.exit(bad ? 1 : 0) diff --git a/apps/website/src/data/blog/bodies/a-recipe-step-cannot-be-dropped-silently.ts b/apps/website/src/data/blog/bodies/a-recipe-step-cannot-be-dropped-silently.ts new file mode 100644 index 0000000000..d4505b30c3 --- /dev/null +++ b/apps/website/src/data/blog/bodies/a-recipe-step-cannot-be-dropped-silently.ts @@ -0,0 +1,19 @@ +import type { Block } from '../types' + +export const body: Block[] = [ + { "kind": "h", "text": "What changed" }, + { "kind": "p", "text": "The course recipe in specs/course_recipe had tests, but nothing ran them, so a step could be deleted and nobody would notice. The Course job of the website checks now runs npm run check:course-recipe, which compiles every .t27 file in that directory with the site's own wasm compiler and runs its test blocks." }, + { "kind": "h", "text": "What makes it fail" }, + { "kind": "p", "text": "A compile error, a failing assert, a file with zero tests, or an empty directory. To prove it, a mutant changed an asserted constant in roadmap.t27: the check went red with exit code 1, and after the file was restored it went green again." }, + { "kind": "h", "text": "Limits" }, + { "kind": "p", "text": "The check runs the tests the modules already have. It does not judge whether those tests cover every step of the recipe." } +] + +export const ruBody: Block[] = [ + { "kind": "h", "text": "Что изменилось" }, + { "kind": "p", "text": "У рецепта курса в specs/course_recipe были тесты, но их никто не запускал, так что шаг можно было удалить незаметно. Теперь задание Course в проверках сайта запускает npm run check:course-recipe: он собирает каждый файл .t27 в этой папке wasm-компилятором самого сайта и прогоняет его тесты." }, + { "kind": "h", "text": "Когда проверка падает" }, + { "kind": "p", "text": "Ошибка сборки, проваленный assert, файл без тестов или пустая папка. Для доказательства мутант поменял проверяемую константу в roadmap.t27: проверка покраснела с кодом 1, а после восстановления файла снова позеленела." }, + { "kind": "h", "text": "Ограничения" }, + { "kind": "p", "text": "Проверка гоняет те тесты, что уже есть в модулях. Покрывают ли они каждый шаг рецепта, она не судит." } +] diff --git a/apps/website/src/data/blog/index.ts b/apps/website/src/data/blog/index.ts index a9b0f87569..303b971564 100644 --- a/apps/website/src/data/blog/index.ts +++ b/apps/website/src/data/blog/index.ts @@ -31,6 +31,29 @@ export const postsIndex: PostMeta[] = [ ], }, }, + { + slug: "a-recipe-step-cannot-be-dropped-silently", + title: "A recipe step cannot be dropped silently", + summary: "[a CI gate, not new lessons] The website checks now compile and run the tests of the t27 course recipe, and fail on a compile error, a failing assert or a module with no tests.", + date: "2026-10-07", + readingMinutes: 1, + tags: ["t27", "Course", "CI"], + receipts: [ + { label: "The gate, apps/website/scripts/check-course-recipe.mjs", href: "https://github.com/gHashTag/trinity/blob/main/apps/website/scripts/check-course-recipe.mjs" }, + { label: "The recipe, specs/course_recipe/course-27.t27", href: "https://github.com/gHashTag/trinity/blob/main/specs/course_recipe/course-27.t27" }, + ], + openQuestions: [ + "Whether the recipe's tests cover every step is not checked.", + ], + published: true, + ru: { + title: "Шаг рецепта больше не пропадёт незаметно", + summary: "[проверка CI, а не новые уроки] Проверки сайта теперь собирают и запускают тесты рецепта курса t27 и падают на ошибке сборки, проваленном assert или модуле без тестов.", + openQuestions: [ + "Покрывают ли тесты рецепта каждый шаг, не проверяется.", + ], + }, + }, { slug: "clocks-resets-and-cdc", title: "Clocks, resets and clock-domain crossings, in 27 lessons", diff --git a/apps/website/src/data/blog/posts.ts b/apps/website/src/data/blog/posts.ts index e22ed349ae..f7db18fc67 100644 --- a/apps/website/src/data/blog/posts.ts +++ b/apps/website/src/data/blog/posts.ts @@ -4,6 +4,7 @@ import { body as body_course_three_verifying_hardware, ruBody as ruBody_course_t import { body as body_course_zero_t27_basics, ruBody as ruBody_course_zero_t27_basics } from './bodies/course-zero-t27-basics' import { body as body_tri_test_now_runs_the_tests, ruBody as ruBody_tri_test_now_runs_the_tests } from './bodies/tri-test-now-runs-the-tests' import { body as body_two_courses_twenty_seven_lessons_each, ruBody as ruBody_two_courses_twenty_seven_lessons_each } from './bodies/two-courses-twenty-seven-lessons-each' +import { body as body_a_recipe_step_cannot_be_dropped_silently, ruBody as ruBody_a_recipe_step_cannot_be_dropped_silently } from './bodies/a-recipe-step-cannot-be-dropped-silently' import { body as body_the_queen_knows_how_to_build_a_course, ruBody as ruBody_the_queen_knows_how_to_build_a_course } from './bodies/the-queen-knows-how-to-build-a-course' import { body as body_a_course_pr_carries_its_post, ruBody as ruBody_a_course_pr_carries_its_post } from './bodies/a-course-pr-carries-its-post' import { body as body_one_outlier_twenty_three_zeros, ruBody as ruBody_one_outlier_twenty_three_zeros } from './bodies/one-outlier-twenty-three-zeros' @@ -111,6 +112,7 @@ const bodies: Record = { 'course-zero-t27-basics': { body: body_course_zero_t27_basics, ruBody: ruBody_course_zero_t27_basics }, 'tri-test-now-runs-the-tests': { body: body_tri_test_now_runs_the_tests, ruBody: ruBody_tri_test_now_runs_the_tests }, 'two-courses-twenty-seven-lessons-each': { body: body_two_courses_twenty_seven_lessons_each, ruBody: ruBody_two_courses_twenty_seven_lessons_each }, + 'a-recipe-step-cannot-be-dropped-silently': { body: body_a_recipe_step_cannot_be_dropped_silently, ruBody: ruBody_a_recipe_step_cannot_be_dropped_silently }, 'the-queen-knows-how-to-build-a-course': { body: body_the_queen_knows_how_to_build_a_course, ruBody: ruBody_the_queen_knows_how_to_build_a_course }, 'a-course-pr-carries-its-post': { body: body_a_course_pr_carries_its_post, ruBody: ruBody_a_course_pr_carries_its_post }, 'one-outlier-twenty-three-zeros': { body: body_one_outlier_twenty_three_zeros, ruBody: ruBody_one_outlier_twenty_three_zeros },