syntaxai/tdd.md · commit bd8f3aa

SAMA-native docs layout: sidebar + on-this-page + prev/next + edit-on-GitHub

GitBook-style chrome around /sama, /guides, /blog content pages. One
codebase, one deploy, content stays in content/<section>/<slug>.md.
Built per the SAMA conventions the site itself preaches.

  src/c31_docs_nav.ts (new c31 — pure data registry)
    Combines ALL_SAMA + ALL_GUIDES + ALL_POSTS into one hierarchical
    SITE_NAV the sidebar walks. Resolves a path to its section +
    prev/next neighbours.

  src/c32_anchor_extract.ts (new c32 — pure logic)
  src/c32_anchor_extract.test.ts (sibling test, 8 cases)
    Parses rendered HTML for h2/h3 headings, extracts anchor entries
    for the right-rail "on this page" navigator. Slugifies headings
    that lack an explicit id attribute.

  src/c32_sama_verify.test.ts (sibling test, 10 cases)
    Tests verifySama directly — covers Sorted (c14->c51 flagged,
    c21->c51 not flagged, c31->c32 not flagged), Architecture
    (unknown prefix flagged), Modeled (c32 without sibling flagged,
    c31 informational), Atomic (oversized + placeholder), and
    overallPassed semantics.

  src/c32_sama_verify.ts
    Adds stripStringsAndComments() pre-pass. Both collectRelative-
    Imports and findPlaceholderTestsLite now skip matches whose
    keyword is inside a string literal or comment in the source —
    fixes the test-fixture false positive where the verifier was
    flagging its own test file's data fixtures as real imports /
    real placeholder tests. Real path/body parsing still works.

  src/c51_render_docs_layout.ts (new c51 — chrome wrapper)
    renderDocsPage() composes a left sidebar (from c31_docs_nav),
    right-rail anchor list (from c32_anchor_extract), prev/next
    navigation, and an edit-on-GitHub link, then delegates to the
    existing renderPage. Heading anchor enrichment adds clickable
    `#` links on hover.

  src/c51_render_layout.ts
    PageOptions gains optional bodyHtml (alternative to bodyMarkdown
    when the docs layout has already done its own marked.parse) and
    bodyClass (lets the docs layout request "docs-body" so CSS can
    reset main.md's max-width and padding for the wider three-column
    layout).

  src/c21_app.ts
    /sama, /sama/:slug, /sama/skill, /sama/verify (form + report +
    error paths), /guides, /guides/:slug, /blog, /blog/:slug all now
    use renderDocsPage. /reports/* and /games/* keep renderPage
    because they aren't doc-content pages.

  public/style.css
    GitBook-style three-column layout (240px sidebar / content /
    220px rail). Sticky positioning. Mobile collapse at 768px.
    Heading anchor icons on hover.

Dogfood after this commit:
  S — Sorted: ✓ pass (was: ✗ 2 false positives from test fixtures
                       containing import-shaped strings)
  A — Architecture: ✓ pass
  M — Modeled: ✗ 4 violations (was 5 — c32_sama_verify.ts now has
                                its sibling test; c32_anchor_extract
                                also has one. Remaining: c32_judge,
                                c32_session, c32_real_reports,
                                c32_real_tests.)
  A — Atomic: ✗ 1 violation (was 2 false positives plus the real
                              c21_app.ts > 700 lines finding. Now
                              just the real one.)

The verifier improvements (stripStringsAndComments) are general-
purpose and ship to every consumer of /sama/verify and the sama-cli.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-10 07:31:10 +01:00
parent
ecad9eb
commit
bd8f3aa6d73724482e62f4caf6352d662b96d212

9 files changed · +777 −19

modified public/style.css +192 −0
@@ -507,3 +507,195 @@ main.md table.test-stability td.test-stab-num {
507507 margin: 0 0 1.2rem;
508508 }
509509 .project-form-error strong { color: var(--red); }
510+
511+/* -----------------------------------------------------------------
512+ Docs layout — GitBook-style sidebar + content + on-this-page rail.
513+ Used by /sama/*, /guides/*, /blog/* via renderDocsPage. Mobile
514+ stacks vertically; sidebar collapses behind a details/summary on
515+ narrow viewports.
516+----------------------------------------------------------------- */
517+
518+.docs-body main.md {
519+ max-width: none;
520+ padding: 0;
521+}
522+
523+.docs-layout {
524+ display: grid;
525+ grid-template-columns: 240px minmax(0, 1fr) 220px;
526+ gap: 2rem;
527+ max-width: 1400px;
528+ margin: 0 auto;
529+ padding: 1rem 1.5rem 4rem;
530+ align-items: start;
531+}
532+
533+.docs-sidebar,
534+.docs-rail {
535+ position: sticky;
536+ top: 1rem;
537+ font-size: 0.88rem;
538+ align-self: start;
539+ max-height: calc(100vh - 2rem);
540+ overflow-y: auto;
541+}
542+
543+.docs-sidebar { padding-right: 0.5rem; }
544+
545+.docs-side-section { margin: 0 0 1.5rem; }
546+.docs-side-title {
547+ margin: 0 0 0.4rem;
548+ font-size: 0.75rem;
549+ text-transform: uppercase;
550+ letter-spacing: 0.06em;
551+ color: var(--muted);
552+}
553+.docs-side-title a {
554+ color: inherit;
555+ text-decoration: none;
556+}
557+.docs-side-title a:hover { color: var(--fg); }
558+
559+.docs-side-list {
560+ list-style: none;
561+ padding: 0;
562+ margin: 0;
563+ border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
564+}
565+.docs-side-list li { margin: 0; }
566+
567+.docs-side-link {
568+ display: block;
569+ padding: 0.3rem 0.6rem;
570+ margin-left: -1px;
571+ border-left: 2px solid transparent;
572+ color: var(--muted);
573+ text-decoration: none;
574+ line-height: 1.35;
575+}
576+.docs-side-link:hover {
577+ color: var(--fg);
578+ border-left-color: color-mix(in srgb, var(--fg) 40%, transparent);
579+}
580+.docs-side-link-active {
581+ color: var(--accent);
582+ border-left-color: var(--accent);
583+ font-weight: 600;
584+}
585+
586+.docs-content {
587+ min-width: 0;
588+ font-size: 1rem;
589+ line-height: 1.65;
590+}
591+.docs-content > h1:first-child { margin-top: 0; }
592+.docs-content h2,
593+.docs-content h3 {
594+ scroll-margin-top: 1rem;
595+ position: relative;
596+}
597+.docs-h-anchor {
598+ position: absolute;
599+ left: -1.2rem;
600+ color: var(--muted);
601+ text-decoration: none;
602+ opacity: 0;
603+ transition: opacity 0.1s;
604+ font-weight: normal;
605+}
606+.docs-content h2:hover .docs-h-anchor,
607+.docs-content h3:hover .docs-h-anchor {
608+ opacity: 1;
609+}
610+.docs-h-anchor:hover { color: var(--accent); }
611+
612+.docs-edit {
613+ text-align: right;
614+ font-size: 0.85rem;
615+ margin: 0 0 0.5rem;
616+}
617+.docs-edit a {
618+ color: var(--muted);
619+ text-decoration: none;
620+}
621+.docs-edit a:hover { color: var(--accent); }
622+
623+.docs-rail-title {
624+ margin: 0 0 0.5rem;
625+ font-size: 0.75rem;
626+ text-transform: uppercase;
627+ letter-spacing: 0.06em;
628+ color: var(--muted);
629+}
630+.docs-rail-list {
631+ list-style: none;
632+ padding: 0;
633+ margin: 0;
634+ border-left: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
635+}
636+.docs-rail-link {
637+ display: block;
638+ padding: 0.25rem 0.6rem;
639+ margin-left: -1px;
640+ border-left: 2px solid transparent;
641+ color: var(--muted);
642+ text-decoration: none;
643+ line-height: 1.35;
644+}
645+.docs-rail-link:hover {
646+ color: var(--fg);
647+ border-left-color: color-mix(in srgb, var(--fg) 40%, transparent);
648+}
649+.docs-rail-link-h3 { padding-left: 1.4rem; font-size: 0.82rem; }
650+
651+.docs-prev-next {
652+ display: grid;
653+ grid-template-columns: 1fr 1fr;
654+ gap: 1rem;
655+ margin: 3rem 0 0;
656+ border-top: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
657+ padding-top: 1.5rem;
658+}
659+.docs-pn-prev,
660+.docs-pn-next {
661+ display: flex;
662+ align-items: center;
663+ gap: 0.5rem;
664+ padding: 0.8rem 1rem;
665+ border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent);
666+ border-radius: 6px;
667+ text-decoration: none;
668+ color: var(--fg);
669+ background: color-mix(in srgb, var(--muted) 6%, transparent);
670+}
671+.docs-pn-prev:hover,
672+.docs-pn-next:hover {
673+ border-color: var(--accent);
674+ background: color-mix(in srgb, var(--accent) 10%, transparent);
675+}
676+.docs-pn-prev { justify-content: flex-start; }
677+.docs-pn-next { justify-content: flex-end; text-align: right; }
678+.docs-pn-arrow { color: var(--muted); font-size: 1.1rem; }
679+.docs-pn-label { font-weight: 500; }
680+.docs-pn-spacer {}
681+
682+@media (max-width: 1080px) {
683+ .docs-layout {
684+ grid-template-columns: 220px minmax(0, 1fr);
685+ }
686+ .docs-rail { display: none; }
687+}
688+
689+@media (max-width: 768px) {
690+ .docs-layout {
691+ grid-template-columns: 1fr;
692+ }
693+ .docs-sidebar {
694+ position: static;
695+ max-height: none;
696+ border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
697+ border-radius: 6px;
698+ padding: 1rem;
699+ margin-bottom: 1.5rem;
700+ }
701+}
modified src/c21_app.ts +29 −11
@@ -8,6 +8,7 @@ import {
88 htmlResponse,
99 escape,
1010 } from "./c51_render_layout.ts";
11+import { renderDocsPage } from "./c51_render_docs_layout.ts";
1112 import {
1213 projectsLandingMd,
1314 projectRegisterMd,
@@ -349,12 +350,14 @@ ${rows}
349350
350351 [← back to tdd.md](/) · [the guides](/guides) · [the katas](/games)
351352 `;
352- const html = await renderPage({
353+ const html = await renderDocsPage({
353354 title: "Blog — tdd.md",
354355 description: "Posts on test-driven development for AI coding agents — how to apply TDD with Claude Code, Cursor, and Aider, what we learn from the verdicts.",
355356 bodyMarkdown: body,
356357 ogPath: "https://tdd.md/blog",
357358 active: "blog",
359+ pathForDocs: "/blog",
360+ editPathOverride: null,
358361 });
359362 return htmlResponse(html);
360363 },
@@ -372,12 +375,13 @@ ${rows}
372375 return htmlResponse(html, 404);
373376 }
374377 const md = await file.text();
375- const html = await renderPage({
378+ const html = await renderDocsPage({
376379 title: `${entry.title} — tdd.md`,
377380 description: entry.description,
378381 bodyMarkdown: md,
379382 ogPath: `https://tdd.md/blog/${slug}`,
380383 active: "blog",
384+ pathForDocs: `/blog/${slug}`,
381385 jsonLd: {
382386 "@context": "https://schema.org",
383387 "@type": "BlogPosting",
@@ -605,12 +609,14 @@ ${rows}
605609
606610 [← play a kata](/games) · [register your agent →](/you)
607611 `;
608- const html = await renderPage({
612+ const html = await renderDocsPage({
609613 title: "TDD guides for agentic coding tools — tdd.md",
610614 description: "Practical TDD walkthroughs for Claude Code, Cursor, Aider and other AI coding agents — keep your agent honest with red→green→refactor commits, scored by tdd.md.",
611615 bodyMarkdown: body,
612616 ogPath: "https://tdd.md/guides",
613617 active: "guides",
618+ pathForDocs: "/guides",
619+ editPathOverride: null,
614620 });
615621 return htmlResponse(html);
616622 },
@@ -628,12 +634,13 @@ ${rows}
628634 return htmlResponse(html, 404);
629635 }
630636 const md = await file.text();
631- const html = await renderPage({
637+ const html = await renderDocsPage({
632638 title: `${entry.title} — tdd.md`,
633639 description: entry.description,
634640 bodyMarkdown: md,
635641 ogPath: `https://tdd.md/guides/${slug}`,
636642 active: "guides",
643+ pathForDocs: `/guides/${slug}`,
637644 });
638645 return htmlResponse(html);
639646 },
@@ -673,12 +680,13 @@ ${rows}
673680 > The frontmatter at the top of the file (\`name\`, \`description\`) is what your agent's loader keys off — don't edit it. [View raw markdown →](/skills/sama.md)
674681 `;
675682 const body = `${installNote}\n\n${stripped}\n\n---\n\n[← /sama](/sama) · [the four disciplines](/sama) · [back to tdd.md](/)\n`;
676- const html = await renderPage({
683+ const html = await renderDocsPage({
677684 title: "SAMA skill — drop into your agent — tdd.md",
678685 description: "An obra/superpowers-style SKILL.md for the SAMA file-naming convention. Save it to ~/.claude/skills/sama.md and your agent will load the layer-prefix discipline on demand.",
679686 bodyMarkdown: body,
680687 ogPath: "https://tdd.md/sama/skill",
681688 active: "sama",
689+ pathForDocs: "/sama/skill",
682690 });
683691 return htmlResponse(html);
684692 },
@@ -706,22 +714,25 @@ Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses
706714 `;
707715
708716 if (!repoArg) {
709- const html = await renderPage({
717+ const html = await renderDocsPage({
710718 title: "SAMA verify — tdd.md",
711719 description: "Paste a public GitHub repo, get the four SAMA disciplines verified mechanically: sorted (lower never imports higher), architecture (known layer prefixes), modeled (sibling tests), atomic (700-line + placeholder-test detection).",
712720 bodyMarkdown: formMd,
713721 ogPath: "https://tdd.md/sama/verify",
714722 active: "sama",
723+ pathForDocs: "/sama/verify",
715724 });
716725 return htmlResponse(html);
717726 }
718727
719728 const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(repoArg);
720729 if (!m) {
721- const html = await renderPage({
730+ const html = await renderDocsPage({
722731 title: "SAMA verify · bad input — tdd.md",
723732 description: "SAMA verify expects an owner/name repo identifier.",
724733 bodyMarkdown: `# SAMA verify\n\n> Couldn't parse \`${repoArg}\`. Use the form: \`owner/name\`.\n\n[← back](/sama/verify)\n`,
734+ pathForDocs: "/sama/verify",
735+ editPathOverride: null,
725736 ogPath: "https://tdd.md/sama/verify",
726737 active: "sama",
727738 noindex: true,
@@ -781,13 +792,15 @@ Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses
781792 }
782793 } catch (e) {
783794 const msg = e instanceof Error ? e.message : String(e);
784- const html = await renderPage({
795+ const html = await renderDocsPage({
785796 title: `SAMA verify · ${owner}/${name} · error — tdd.md`,
786797 description: `SAMA verify could not inspect ${owner}/${name}.`,
787798 bodyMarkdown: `# SAMA verify · \`${owner}/${name}\`\n\n> Couldn't fetch the repo: ${escape(msg)}\n\nMost common causes: the repo is private, the name is wrong, or you've hit GitHub's anonymous rate limit (60/hour). [← try another repo](/sama/verify)\n`,
788799 ogPath: `https://tdd.md/sama/verify?repo=${owner}/${name}`,
789800 active: "sama",
790801 noindex: true,
802+ pathForDocs: "/sama/verify",
803+ editPathOverride: null,
791804 });
792805 return htmlResponse(html, 502);
793806 }
@@ -815,12 +828,14 @@ ${checkBlocks}
815828
816829 [← verify another repo](/sama/verify) · [the four SAMA disciplines →](/sama) · [SAMA skill for your agent →](/sama/skill)
817830 `;
818- const html = await renderPage({
831+ const html = await renderDocsPage({
819832 title: `SAMA verify · ${report.repoSlug} — tdd.md`,
820833 description: `SAMA verification for ${report.repoSlug}: ${report.overallPassed ? "all four checks passed" : `${report.checks.filter((c) => !c.passed).length}/4 checks failed`}.`,
821834 bodyMarkdown: reportMd,
822835 ogPath: `https://tdd.md/sama/verify?repo=${report.repoSlug}`,
823836 active: "sama",
837+ pathForDocs: "/sama/verify",
838+ editPathOverride: null,
824839 });
825840 return htmlResponse(html);
826841 },
@@ -941,12 +956,14 @@ The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) arg
941956
942957 [← back to tdd.md](/) · [the blog](/blog) · [the guides](/guides)
943958 `;
944- const html = await renderPage({
959+ const html = await renderDocsPage({
945960 title: "SAMA — sorted, architecture, modeled, atomic — tdd.md",
946961 description: "SAMA is a four-property file-naming and module convention for codebases that AI agents work in: sorted by layer prefix, architecture as a contract, models with siblings, atomic files. One page per discipline.",
947962 bodyMarkdown: body,
948963 ogPath: "https://tdd.md/sama",
949964 active: "sama",
965+ pathForDocs: "/sama",
966+ editPathOverride: null,
950967 });
951968 return htmlResponse(html);
952969 },
@@ -964,12 +981,13 @@ The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) arg
964981 return htmlResponse(html, 404);
965982 }
966983 const md = await file.text();
967- const html = await renderPage({
984+ const html = await renderDocsPage({
968985 title: `SAMA · ${entry.letter} — ${entry.title} — tdd.md`,
969986 description: entry.description,
970987 bodyMarkdown: md,
971988 ogPath: `https://tdd.md/sama/${slug}`,
972989 active: "sama",
990+ pathForDocs: `/sama/${slug}`,
973991 });
974992 return htmlResponse(html);
975993 },
added src/c31_docs_nav.ts +93 −0
@@ -0,0 +1,93 @@
1+// c31 — model: hierarchical site-nav for the GitBook-style docs
2+// chrome. Pure data: combines the existing per-section registries
3+// (sama, guides, blog) into one structure the sidebar walks at
4+// render-time, plus a flat per-section list the prev/next navigator
5+// uses to compute neighbours.
6+
7+import { ALL_SAMA, type SamaDiscipline } from "./c31_sama.ts";
8+import { ALL_GUIDES, type GuideEntry } from "./c31_guides.ts";
9+import { ALL_POSTS, type BlogEntry } from "./c31_blog.ts";
10+
11+export interface DocsNavLink {
12+ href: string;
13+ label: string;
14+ // GitHub raw-edit URL for the source markdown, when applicable.
15+ // null for pages whose body is built inline in c21_app.ts.
16+ editPath: string | null;
17+}
18+
19+export interface DocsNavSection {
20+ id: "sama" | "guides" | "blog";
21+ title: string;
22+ rootHref: string;
23+ links: DocsNavLink[];
24+}
25+
26+const samaLink = (d: SamaDiscipline): DocsNavLink => ({
27+ href: `/sama/${d.slug}`,
28+ label: `${d.letter} — ${d.title}`,
29+ editPath: `content/sama/${d.slug}.md`,
30+});
31+
32+const guideLink = (g: GuideEntry): DocsNavLink => ({
33+ href: `/guides/${g.slug}`,
34+ label: g.title,
35+ editPath: `content/guides/${g.slug}.md`,
36+});
37+
38+const blogLink = (p: BlogEntry): DocsNavLink => ({
39+ href: `/blog/${p.slug}`,
40+ label: p.title,
41+ editPath: `content/blog/${p.slug}.md`,
42+});
43+
44+export const SITE_NAV: DocsNavSection[] = [
45+ {
46+ id: "sama",
47+ title: "SAMA",
48+ rootHref: "/sama",
49+ links: [
50+ ...ALL_SAMA.map(samaLink),
51+ { href: "/sama/skill", label: "SKILL.md (drop into your agent)", editPath: "content/sama/skill.md" },
52+ { href: "/sama/verify", label: "verify a public repo", editPath: null },
53+ ],
54+ },
55+ {
56+ id: "guides",
57+ title: "Guides",
58+ rootHref: "/guides",
59+ links: ALL_GUIDES.map(guideLink),
60+ },
61+ {
62+ id: "blog",
63+ title: "Blog",
64+ rootHref: "/blog",
65+ links: ALL_POSTS.map(blogLink),
66+ },
67+];
68+
69+// Resolve the section + position of a given path. Used by the
70+// docs layout to select the right sidebar section and to compute
71+// prev/next neighbours.
72+export interface ResolvedDocsLocation {
73+ section: DocsNavSection;
74+ index: number;
75+ current: DocsNavLink;
76+ prev: DocsNavLink | null;
77+ next: DocsNavLink | null;
78+}
79+
80+export const resolveDocsLocation = (path: string): ResolvedDocsLocation | null => {
81+ for (const section of SITE_NAV) {
82+ const i = section.links.findIndex((l) => l.href === path);
83+ if (i === -1) continue;
84+ return {
85+ section,
86+ index: i,
87+ current: section.links[i]!,
88+ prev: i > 0 ? section.links[i - 1]! : null,
89+ next: i < section.links.length - 1 ? section.links[i + 1]! : null,
90+ };
91+ }
92+ return null;
93+};
added src/c32_anchor_extract.test.ts +57 −0
@@ -0,0 +1,57 @@
1+import { test, expect } from "bun:test";
2+import { extractAnchors } from "./c32_anchor_extract.ts";
3+
4+test("extracts h2 with explicit id", () => {
5+ const html = `<h2 id="getting-started">Getting started</h2>`;
6+ expect(extractAnchors(html)).toEqual([
7+ { level: 2, text: "Getting started", id: "getting-started" },
8+ ]);
9+});
10+
11+test("extracts h3 with explicit id", () => {
12+ const html = `<h3 id="why">Why</h3>`;
13+ expect(extractAnchors(html)).toEqual([
14+ { level: 3, text: "Why", id: "why" },
15+ ]);
16+});
17+
18+test("ignores h1 and h4+", () => {
19+ const html = `<h1 id="t">T</h1><h2 id="a">A</h2><h4 id="b">B</h4>`;
20+ const anchors = extractAnchors(html);
21+ expect(anchors.map((a) => a.id)).toEqual(["a"]);
22+});
23+
24+test("slugifies when id attribute is missing", () => {
25+ const html = `<h2>What this number does *not* measure</h2>`;
26+ const anchors = extractAnchors(html);
27+ expect(anchors[0]?.id).toBe("what-this-number-does-not-measure");
28+});
29+
30+test("strips inline tags from text and id source", () => {
31+ const html = `<h3><code>red:</code> phase</h3>`;
32+ const anchors = extractAnchors(html);
33+ expect(anchors[0]?.text).toBe("red: phase");
34+ expect(anchors[0]?.id).toBe("red-phase");
35+});
36+
37+test("returns multiple anchors in document order", () => {
38+ const html = `<h2 id="one">One</h2><p>x</p><h3 id="two">Two</h3><h2 id="three">Three</h2>`;
39+ const anchors = extractAnchors(html);
40+ expect(anchors.map((a) => `${a.level}:${a.id}`)).toEqual([
41+ "2:one",
42+ "3:two",
43+ "2:three",
44+ ]);
45+});
46+
47+test("skips empty headings", () => {
48+ const html = `<h2 id="empty"></h2><h2 id="real">Real</h2>`;
49+ expect(extractAnchors(html).length).toBe(1);
50+});
51+
52+test("handles HTML entities in text", () => {
53+ const html = `<h2>Tom &amp; Jerry</h2>`;
54+ const anchors = extractAnchors(html);
55+ expect(anchors[0]?.text).toBe("Tom & Jerry");
56+ expect(anchors[0]?.id).toBe("tom-jerry");
57+});
added src/c32_anchor_extract.ts +44 −0
@@ -0,0 +1,44 @@
1+// c32 — pure: parse rendered HTML and extract anchor entries for
2+// h2/h3 headings. Used by the docs layout to build the right-rail
3+// "on this page" navigator. No I/O; given a string in, returns a
4+// list of anchors out.
5+//
6+// Input shape: HTML produced by `marked` (which adds `id` attrs to
7+// headings via the GFM-slugger by default in our config). When an
8+// id is missing, we slug-ify the heading text ourselves so the
9+// anchor link still works.
10+
11+export interface Anchor {
12+ level: 2 | 3;
13+ text: string;
14+ id: string;
15+}
16+
17+const slugify = (raw: string): string =>
18+ raw
19+ .toLowerCase()
20+ .replace(/<[^>]*>/g, "")
21+ .replace(/&[a-z]+;/g, " ")
22+ .replace(/[^a-z0-9\s-]/g, "")
23+ .trim()
24+ .replace(/\s+/g, "-");
25+
26+const stripTags = (s: string): string => s.replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").trim();
27+
28+export const extractAnchors = (html: string): Anchor[] => {
29+ const out: Anchor[] = [];
30+ const re = /<h([23])(?:\s+([^>]*))?>([\s\S]*?)<\/h\1>/g;
31+ let m: RegExpExecArray | null;
32+ while ((m = re.exec(html)) !== null) {
33+ const level = parseInt(m[1] ?? "2", 10) as 2 | 3;
34+ const attrs = m[2] ?? "";
35+ const inner = m[3] ?? "";
36+ const idMatch = /\bid="([^"]+)"/.exec(attrs);
37+ const text = stripTags(inner);
38+ if (!text) continue;
39+ const id = idMatch?.[1] ?? slugify(text);
40+ if (!id) continue;
41+ out.push({ level, text, id });
42+ }
43+ return out;
44+};
added src/c32_sama_verify.test.ts +137 −0
@@ -0,0 +1,137 @@
1+import { test, expect } from "bun:test";
2+import { verifySama } from "./c32_sama_verify.ts";
3+
4+const baseInput = {
5+ repoOwner: "test",
6+ repoName: "repo",
7+ defaultBranch: "main",
8+ srcPaths: [] as string[],
9+ contents: new Map<string, string>(),
10+};
11+
12+test("empty input: all checks pass, sorted has a 'no SAMA files' note", () => {
13+ const r = verifySama(baseInput);
14+ expect(r.overallPassed).toBe(true);
15+ const sorted = r.checks.find((c) => c.letter === "S")!;
16+ expect(sorted.passed).toBe(true);
17+ expect(sorted.examined).toBe(0);
18+ expect(sorted.note).toMatch(/no cXX_\*\.ts files found/);
19+});
20+
21+test("Sorted: c14 importing c51 is flagged", () => {
22+ const r = verifySama({
23+ ...baseInput,
24+ srcPaths: ["c14_io.ts", "c51_render.ts"],
25+ contents: new Map([
26+ ["c14_io.ts", `import { x } from "./c51_render.ts";`],
27+ ["c51_render.ts", "export const x = 1;"],
28+ ]),
29+ });
30+ const sorted = r.checks.find((c) => c.letter === "S")!;
31+ expect(sorted.passed).toBe(false);
32+ expect(sorted.violations[0]?.file).toBe("c14_io.ts");
33+ expect(sorted.violations[0]?.detail).toMatch(/UI/);
34+});
35+
36+test("Sorted: c21 importing c51 is NOT flagged (handlers may compose UI)", () => {
37+ const r = verifySama({
38+ ...baseInput,
39+ srcPaths: ["c21_handler.ts", "c51_render.ts"],
40+ contents: new Map([
41+ ["c21_handler.ts", `import { x } from "./c51_render.ts";`],
42+ ["c51_render.ts", "export const x = 1;"],
43+ ]),
44+ });
45+ const sorted = r.checks.find((c) => c.letter === "S")!;
46+ expect(sorted.passed).toBe(true);
47+});
48+
49+test("Sorted: c31 importing c32 (sibling layer, non-UI) is NOT flagged", () => {
50+ const r = verifySama({
51+ ...baseInput,
52+ srcPaths: ["c31_model.ts", "c32_logic.ts"],
53+ contents: new Map([
54+ ["c31_model.ts", `import { x } from "./c32_logic.ts";`],
55+ ["c32_logic.ts", "export const x = 1;"],
56+ ]),
57+ });
58+ const sorted = r.checks.find((c) => c.letter === "S")!;
59+ expect(sorted.passed).toBe(true);
60+});
61+
62+test("Architecture: unknown prefix is flagged", () => {
63+ const r = verifySama({
64+ ...baseInput,
65+ srcPaths: ["c99_thing.ts"],
66+ contents: new Map([["c99_thing.ts", "export const x = 1;"]]),
67+ });
68+ const arch = r.checks.find((c) => c.property === "Architecture")!;
69+ expect(arch.passed).toBe(false);
70+ expect(arch.violations[0]?.detail).toMatch(/unknown layer prefix/);
71+});
72+
73+test("Modeled: c32 file without sibling test is flagged; c31 without sibling is informational", () => {
74+ const r = verifySama({
75+ ...baseInput,
76+ srcPaths: ["c32_logic.ts", "c31_model.ts"],
77+ contents: new Map([
78+ ["c32_logic.ts", "export const x = 1;"],
79+ ["c31_model.ts", "export const y = 2;"],
80+ ]),
81+ });
82+ const modeled = r.checks.find((c) => c.property === "Modeled")!;
83+ expect(modeled.passed).toBe(false);
84+ expect(modeled.violations.length).toBe(1);
85+ expect(modeled.violations[0]?.file).toBe("c32_logic.ts");
86+ expect(modeled.note).toMatch(/c31_\* file/);
87+});
88+
89+test("Modeled: c32 file with sibling test passes", () => {
90+ const r = verifySama({
91+ ...baseInput,
92+ srcPaths: ["c32_logic.ts", "c32_logic.test.ts"],
93+ contents: new Map([
94+ ["c32_logic.ts", "export const x = 1;"],
95+ ["c32_logic.test.ts", "test('x', () => { expect(true).toBe(true); });"],
96+ ]),
97+ });
98+ const modeled = r.checks.find((c) => c.property === "Modeled")!;
99+ expect(modeled.passed).toBe(true);
100+});
101+
102+test("Atomic: file over 700 lines is flagged", () => {
103+ const big = "// line\n".repeat(800);
104+ const r = verifySama({
105+ ...baseInput,
106+ srcPaths: ["c21_huge.ts"],
107+ contents: new Map([["c21_huge.ts", big]]),
108+ });
109+ const atomic = r.checks.find((c) => c.property === "Atomic")!;
110+ expect(atomic.passed).toBe(false);
111+ expect(atomic.violations[0]?.detail).toMatch(/line/);
112+});
113+
114+test("Atomic: placeholder test (zero expect calls) is flagged", () => {
115+ const placeholderFixture = `test("does nothing", () => { /* TODO */ })`;
116+ const r = verifySama({
117+ ...baseInput,
118+ srcPaths: ["c32_x.test.ts"],
119+ contents: new Map([["c32_x.test.ts", placeholderFixture]]),
120+ });
121+ const atomic = r.checks.find((c) => c.property === "Atomic")!;
122+ expect(atomic.passed).toBe(false);
123+ expect(atomic.violations[0]?.detail).toMatch(/placeholder test/);
124+});
125+
126+test("overallPassed reflects every check passing", () => {
127+ const r = verifySama({
128+ ...baseInput,
129+ srcPaths: ["c31_model.ts", "c32_logic.ts", "c32_logic.test.ts"],
130+ contents: new Map([
131+ ["c31_model.ts", "export const x = 1;"],
132+ ["c32_logic.ts", `import { x } from "./c31_model.ts";\nexport const y = x + 1;`],
133+ ["c32_logic.test.ts", `import { y } from "./c32_logic.ts";\ntest("y", () => { expect(y).toBe(2); });`],
134+ ]),
135+ });
136+ expect(r.overallPassed).toBe(true);
137+});
modified src/c32_sama_verify.ts +80 −5
@@ -44,6 +44,56 @@ export interface SamaVerifyInput {
4444
4545 const SAMA_PREFIX = /^c(\d{2})_/;
4646
47+// Strip JS string literals and comments from source, preserving
48+// position/length by replacing each character with whitespace. This
49+// is the cheapest reliable fix for the test-fixture false-positive:
50+// import strings and `test(...)` patterns inside literals/comments
51+// would otherwise trigger Sorted/Atomic violations.
52+const stripStringsAndComments = (src: string): string => {
53+ let out = "";
54+ let i = 0;
55+ while (i < src.length) {
56+ const c = src[i];
57+ const n = src[i + 1];
58+ if (c === "/" && n === "/") {
59+ out += " ";
60+ i += 2;
61+ while (i < src.length && src[i] !== "\n") {
62+ out += " ";
63+ i++;
64+ }
65+ } else if (c === "/" && n === "*") {
66+ out += " ";
67+ i += 2;
68+ while (i < src.length - 1 && !(src[i] === "*" && src[i + 1] === "/")) {
69+ out += src[i] === "\n" ? "\n" : " ";
70+ i++;
71+ }
72+ out += " ";
73+ i += 2;
74+ } else if (c === '"' || c === "'" || c === "`") {
75+ const quote = c;
76+ out += " ";
77+ i++;
78+ while (i < src.length && src[i] !== quote) {
79+ if (src[i] === "\\" && i + 1 < src.length) {
80+ out += " ";
81+ i += 2;
82+ continue;
83+ }
84+ out += src[i] === "\n" ? "\n" : " ";
85+ i++;
86+ }
87+ out += " ";
88+ i++;
89+ } else {
90+ out += c ?? "";
91+ i++;
92+ }
93+ }
94+ return out;
95+};
96+
4797 const isSamaFile = (p: string): boolean => SAMA_PREFIX.test(p) && p.endsWith(".ts");
4898 const isTestFile = (p: string): boolean => p.endsWith(".test.ts");
4999
@@ -55,14 +105,32 @@ const layerOf = (filename: string): number | null => {
55105
56106 // Pull import targets out of a TypeScript source. Recognizes both
57107 // static `import ... from "./x.ts"` and dynamic `import("./x.ts")`.
58-// We only care about relative imports (the cross-layer ones).
108+// We only care about relative imports (the cross-layer ones). We
109+// scan against a stripped source (string literals + comments
110+// blanked out) so test fixtures that quote import statements as
111+// data don't cause false positives.
59112 const collectRelativeImports = (source: string): string[] => {
60113 const out: string[] = [];
61- const staticRe = /\bfrom\s+["']\s*(\.\/[^"']+)["']/g;
62- const dynRe = /\bimport\s*\(\s*["']\s*(\.\/[^"']+)["']/g;
114+ // Match against the original source so the captured import path
115+ // text is preserved; but only accept matches whose start position
116+ // is NOT inside a string-literal/comment region (we test that by
117+ // checking the stripped source's character at the path-open quote).
118+ const stripped = stripStringsAndComments(source);
119+ const staticRe = /\bfrom\s+(["'])\s*(\.\/[^"']+)\1/g;
120+ const dynRe = /\bimport\s*\(\s*(["'])\s*(\.\/[^"']+)\1/g;
63121 let m: RegExpExecArray | null;
64- while ((m = staticRe.exec(source)) !== null) if (m[1]) out.push(m[1]);
65- while ((m = dynRe.exec(source)) !== null) if (m[1]) out.push(m[1]);
122+ const pushIfReal = (mm: RegExpExecArray, pathIdx: number) => {
123+ // Check the start of the match (the `f` of `from` or `i` of
124+ // `import`). If that keyword is inside a string literal/comment
125+ // in the original source, the stripped version replaces it with
126+ // whitespace and we skip the match. The PATH itself is always
127+ // inside quotes (that's how imports are written), so we never
128+ // gate on the path's position — only the keyword's.
129+ if (stripped[mm.index] === " " || stripped[mm.index] === "\n") return;
130+ out.push(mm[pathIdx]!);
131+ };
132+ while ((m = staticRe.exec(source)) !== null) pushIfReal(m, 2);
133+ while ((m = dynRe.exec(source)) !== null) pushIfReal(m, 2);
66134 return out;
67135 };
68136
@@ -185,9 +253,16 @@ const checkModeled = (input: SamaVerifyInput): SamaCheckResult => {
185253 // testing surface that Atomic owns.
186254 const findPlaceholderTestsLite = (file: string, content: string): SamaViolation[] => {
187255 const out: SamaViolation[] = [];
256+ // Same string/comment-aware trick as collectRelativeImports: only
257+ // count test()/it() calls whose `test`/`it` keyword is real code,
258+ // not a literal in a fixture.
259+ const stripped = stripStringsAndComments(content);
188260 const re = /\b(test|it)\s*\(\s*(["'`])((?:\\.|(?!\2).)*)\2\s*,\s*(?:async\s+)?(?:\([^)]*\)|[^=()]*?)\s*=>\s*\{/g;
189261 let m: RegExpExecArray | null;
190262 while ((m = re.exec(content)) !== null) {
263+ // Skip matches whose `test`/`it` keyword is inside a string literal
264+ // or comment (the stripped version replaces those with whitespace).
265+ if (stripped[m.index] === " " || stripped[m.index] === "\n") continue;
191266 const name = m[3] ?? "";
192267 const startBrace = re.lastIndex - 1;
193268 let depth = 1;
added src/c51_render_docs_layout.ts +135 −0
@@ -0,0 +1,135 @@
1+// c51 (docs-layout) — UI: GitBook-style chrome around the existing
2+// renderPage. Wraps content with a left sidebar (sections from
3+// SITE_NAV), a right "on this page" anchor rail (h2/h3 from the
4+// rendered body), an edit-on-GitHub link at the top of content, and
5+// a prev/next navigator at the bottom. Per SAMA: imports c31 (data),
6+// c32 (logic), and c51_render_layout (chrome). No I/O of its own.
7+
8+import { marked } from "marked";
9+import {
10+ SITE_NAV,
11+ resolveDocsLocation,
12+ type DocsNavLink,
13+ type DocsNavSection,
14+ type ResolvedDocsLocation,
15+} from "./c31_docs_nav.ts";
16+import { extractAnchors, type Anchor } from "./c32_anchor_extract.ts";
17+import {
18+ renderPage,
19+ escape,
20+ type PageOptions,
21+} from "./c51_render_layout.ts";
22+
23+const REPO_EDIT_BASE = "https://github.com/syntaxai/tdd.md/edit/main";
24+
25+export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> {
26+ // The route path the user is on, e.g. "/sama/sorted". Used to
27+ // highlight the active sidebar entry and compute prev/next.
28+ pathForDocs: string;
29+ // Optional override of which file the "edit on GitHub" link
30+ // targets, when the body isn't a content/<section>/<slug>.md.
31+ // Defaults to the editPath from the resolved nav location.
32+ editPathOverride?: string | null;
33+}
34+
35+const sidebarLink = (link: DocsNavLink, current: string): string => {
36+ const cls = link.href === current ? "docs-side-link docs-side-link-active" : "docs-side-link";
37+ return `<li><a class="${cls}" href="${link.href}">${escape(link.label)}</a></li>`;
38+};
39+
40+const renderSidebar = (currentPath: string): string => {
41+ const sections = SITE_NAV.map((section: DocsNavSection) => {
42+ const items = section.links.map((l) => sidebarLink(l, currentPath)).join("");
43+ const sectionCls = section.links.some((l) => l.href === currentPath)
44+ ? "docs-side-section docs-side-section-active"
45+ : "docs-side-section";
46+ return `<div class="${sectionCls}">
47+ <p class="docs-side-title"><a href="${section.rootHref}">${escape(section.title)}</a></p>
48+ <ul class="docs-side-list">${items}</ul>
49+</div>`;
50+ }).join("\n");
51+ return `<aside class="docs-sidebar" aria-label="documentation navigation">
52+${sections}
53+</aside>`;
54+};
55+
56+const renderAnchorRail = (anchors: Anchor[]): string => {
57+ if (anchors.length === 0) return "";
58+ const items = anchors
59+ .map((a) => {
60+ const cls = a.level === 3 ? "docs-rail-link docs-rail-link-h3" : "docs-rail-link";
61+ return `<li><a class="${cls}" href="#${escape(a.id)}">${escape(a.text)}</a></li>`;
62+ })
63+ .join("");
64+ return `<aside class="docs-rail" aria-label="on this page">
65+ <p class="docs-rail-title">on this page</p>
66+ <ul class="docs-rail-list">${items}</ul>
67+</aside>`;
68+};
69+
70+const renderEditLink = (editPath: string | null): string => {
71+ if (!editPath) return "";
72+ const url = `${REPO_EDIT_BASE}/${editPath}`;
73+ return `<p class="docs-edit"><a href="${escape(url)}" rel="noopener" target="_blank">edit this page on GitHub →</a></p>`;
74+};
75+
76+const renderPrevNext = (loc: ResolvedDocsLocation | null): string => {
77+ if (!loc) return "";
78+ const prev = loc.prev
79+ ? `<a class="docs-pn-prev" href="${loc.prev.href}"><span class="docs-pn-arrow">←</span><span class="docs-pn-label">${escape(loc.prev.label)}</span></a>`
80+ : `<span class="docs-pn-spacer"></span>`;
81+ const next = loc.next
82+ ? `<a class="docs-pn-next" href="${loc.next.href}"><span class="docs-pn-label">${escape(loc.next.label)}</span><span class="docs-pn-arrow">→</span></a>`
83+ : `<span class="docs-pn-spacer"></span>`;
84+ return `<nav class="docs-prev-next" aria-label="previous and next page">${prev}${next}</nav>`;
85+};
86+
87+// Wrap a heading element with an anchor link for hover-click access.
88+// Also injects an `id` if marked didn't (rare with our config but
89+// possible). Operates on the rendered HTML before composing the page.
90+const enrichHeadings = (html: string): string =>
91+ html.replace(
92+ /<h([23])(\s+[^>]*)?>([\s\S]*?)<\/h\1>/g,
93+ (_full, level, attrs, inner) => {
94+ const idMatch = /\bid="([^"]+)"/.exec(attrs ?? "");
95+ const id = idMatch?.[1] ?? inner.replace(/<[^>]*>/g, "").toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-");
96+ const finalAttrs = idMatch ? attrs : `${attrs ?? ""} id="${id}"`;
97+ return `<h${level}${finalAttrs}><a class="docs-h-anchor" href="#${id}" aria-label="link to this section">#</a>${inner}</h${level}>`;
98+ },
99+ );
100+
101+export const renderDocsPage = async (opts: DocsPageOptions): Promise<string> => {
102+ const rawHtml = opts.bodyMarkdown
103+ ? await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false })
104+ : "";
105+ const enriched = enrichHeadings(rawHtml);
106+ const anchors = extractAnchors(enriched);
107+ const loc = resolveDocsLocation(opts.pathForDocs);
108+ const editPath = opts.editPathOverride !== undefined ? opts.editPathOverride : loc?.current.editPath ?? null;
109+
110+ const sidebar = renderSidebar(opts.pathForDocs);
111+ const rail = renderAnchorRail(anchors);
112+ const editLink = renderEditLink(editPath);
113+ const prevNext = renderPrevNext(loc);
114+
115+ const composed = `<div class="docs-layout">
116+${sidebar}
117+<article class="docs-content">
118+${editLink}
119+${enriched}
120+${prevNext}
121+</article>
122+${rail}
123+</div>`;
124+
125+ return renderPage({
126+ title: opts.title,
127+ bodyHtml: composed,
128+ description: opts.description,
129+ ogPath: opts.ogPath,
130+ active: opts.active,
131+ noindex: opts.noindex,
132+ jsonLd: opts.jsonLd,
133+ bodyClass: "docs-body",
134+ });
135+};
modified src/c51_render_layout.ts +10 −3
@@ -15,12 +15,18 @@ export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderb
1515
1616 export interface PageOptions {
1717 title: string;
18- bodyMarkdown: string;
18+ // Provide either bodyMarkdown (parsed by marked) or bodyHtml
19+ // (passed through as-is). bodyHtml is what the docs layout uses
20+ // when it has already done its own marked.parse and wrapped the
21+ // result in sidebar/content/anchor-rail chrome.
22+ bodyMarkdown?: string;
23+ bodyHtml?: string;
1924 description?: string;
2025 ogPath?: string;
2126 active?: Section;
2227 noindex?: boolean;
2328 jsonLd?: Record<string, unknown>;
29+ bodyClass?: string;
2430 }
2531
2632 const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts.";
@@ -36,8 +42,9 @@ const navLink = (href: string, label: string, active: boolean): string => {
3642 const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
3743
3844 export const renderPage = async (opts: PageOptions): Promise<string> => {
39- const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false });
45+ const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false });
4046 const description = opts.description ?? SITE_DESCRIPTION;
47+ const bodyClassAttr = opts.bodyClass ? ` class="${escape(opts.bodyClass)}"` : "";
4148 const ogPath = opts.ogPath ?? "https://tdd.md";
4249 const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : "";
4350 const jsonLd = opts.jsonLd
@@ -67,7 +74,7 @@ ${robots}<link rel="canonical" href="${escape(ogPath)}">
6774 <title>${escape(opts.title)}</title>
6875 ${jsonLd}<style>${css}</style>
6976 </head>
70-<body>
77+<body${bodyClassAttr}>
7178 ${nav(opts.active)}
7279 <main class="md">
7380 ${body}