diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index a8b3f8f..ed11049 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -33,6 +33,8 @@ cd website && npm run fetch:videos This updates `website/lib/video-titles.json` (commit it alongside the markdown change). +**Images:** Put the file in `website/public/images/` and reference it as `![alt text](/images/{file})`. The site prepends the deployment base path when rendering. + **Relationships:** Define in `documents/relationships.mmd` using Mermaid graph syntax: ```mermaid patterns/your-pattern -->|solves| obstacles/some-obstacle diff --git a/documents/patterns/surface-change-attractors.md b/documents/patterns/surface-change-attractors.md new file mode 100644 index 0000000..05160d7 --- /dev/null +++ b/documents/patterns/surface-change-attractors.md @@ -0,0 +1,35 @@ +--- +authors: [ivett_ordog] +alternative_titles: ["Hotspot Analysis"] +--- + +# Surface Change Attractors + +## Problem +Architectural problems hide as churn. Some files attract every change; some always change together even though the module structure claims they're independent. Humans normalize this friction. Agents pay for it too: hot files get re-read into context constantly, and edits concentrate exactly where the conflicts and bugs live. The structure looks fine; only the history shows the problem. + +## Pattern +Mine version control for behavioral signals (Adam Tornhill-style analysis): + +- **Hotspots**: change frequency × complexity — files that are both churned and complicated +- **Change coupling**: files that repeatedly change in the same commits despite no visible dependency + +Hand the findings to the agent as design constraints: + +- "These five files change together in most commits — propose a redesign that lets them change independently" +- "This file is touched by every feature — split it along its reasons to change" + +Refactor incrementally toward the proposed design. + +The metrics nominate candidates, they never decide. Churn says where change lands, the code says why — read every candidate before acting on it. + +Canary in the Code Mine reads the agent's live struggle; Surface Change Attractors finds the same signal in your history, before the struggle. + +An open-source implementation is available at https://github.com/devill/ivetts-skills#hotspot-rec + +![Temporal coupling map: every line joins two files that changed in the same commit, drawn over the package map](/images/example-coupling-map.svg) + +*Circles are files, sized by lines and colored by commits; outlined circles are packages. Orange dashed lines join files that change together across a package boundary — co-change the architecture says should not happen.* + +## Example +A script over `git log` counts per-file commit frequency and co-change pairs (code-maat and CodeScene do this out of the box — or Offload Deterministic: have the agent write the script). The top attractor is a 900-line "service" touched in 70% of commits, co-changing with a validator and a serializer in two other modules. Given those constraints, the agent proposes moving validation and serialization behind the service's interface. After the refactor, features stop fanning out across three modules. diff --git a/documents/relationships.mmd b/documents/relationships.mmd index b4046f6..38d038a 100644 --- a/documents/relationships.mmd +++ b/documents/relationships.mmd @@ -10,6 +10,9 @@ graph LR patterns/active-partner -->|solves| obstacles/obedient-contractor patterns/canary-in-the-code-mine -->|solves| obstacles/degrades-under-complexity patterns/canary-in-the-code-mine -->|solves| obstacles/context-rot + patterns/surface-change-attractors -->|solves| obstacles/degrades-under-complexity + patterns/surface-change-attractors <-->|similar| patterns/canary-in-the-code-mine + patterns/surface-change-attractors -->|uses| patterns/offload-deterministic patterns/chain-of-small-steps -->|solves| obstacles/degrades-under-complexity patterns/chain-of-small-steps -->|solves| obstacles/limited-focus patterns/check-alignment -->|solves| anti-patterns/silent-misalignment diff --git a/website/CLAUDE.md b/website/CLAUDE.md index 9d08a8c..dbbf9a7 100644 --- a/website/CLAUDE.md +++ b/website/CLAUDE.md @@ -47,6 +47,8 @@ npx playwright test path/to/test.spec.ts **Important**: The first H1 in markdown files is extracted as the page title and removed from the rendered content to maintain semantic HTML (only one H1 per page). +**Images in documents** (agent decision): document images live in `public/images/` and are referenced from markdown as `/images/{file}`. Every `ReactMarkdown` call passes `markdownComponents` from `app/components/markdownComponents.tsx`, which renders images through `next/image` with the deployment `basePath` prepended — a bare `` would 404 on GitHub Pages. Add the override to any new `ReactMarkdown` call site. + ### Category Configuration System **Centralized in** `app/lib/category-config.ts`: diff --git a/website/app/[category]/[slug]/page.tsx b/website/app/[category]/[slug]/page.tsx index ae345fd..702da51 100644 --- a/website/app/[category]/[slug]/page.tsx +++ b/website/app/[category]/[slug]/page.tsx @@ -9,6 +9,7 @@ import { PatternCategory } from "@/lib/types"; import Authors from "@/app/components/Authors"; import RelatedLinks from "@/app/components/RelatedLinks"; import VideoThumbnail from "@/app/components/VideoThumbnail"; +import { markdownComponents } from "@/app/components/markdownComponents"; import styles from "../../pattern-detail.module.css"; interface PatternPageProps { @@ -155,7 +156,7 @@ export default async function PatternPage({ params }: PatternPageProps) {
- + {pattern.content}
diff --git a/website/app/components/markdownComponents.tsx b/website/app/components/markdownComponents.tsx new file mode 100644 index 0000000..d3eb8c7 --- /dev/null +++ b/website/app/components/markdownComponents.tsx @@ -0,0 +1,22 @@ +import type { ComponentPropsWithoutRef } from "react"; +import Image from "next/image"; +import { basePath } from "@/lib/config"; + +const isExternal = (src: string) => /^https?:\/\//.test(src); + +export function MarkdownImage({ src, alt }: ComponentPropsWithoutRef<"img">) { + if (typeof src !== "string") return null; + + return ( + {alt + ); +} + +export const markdownComponents = { img: MarkdownImage }; diff --git a/website/app/pattern-catalog/CatalogView.tsx b/website/app/pattern-catalog/CatalogView.tsx index e5f6b6a..2c30d27 100644 --- a/website/app/pattern-catalog/CatalogView.tsx +++ b/website/app/pattern-catalog/CatalogView.tsx @@ -11,6 +11,7 @@ import { COMPLETE_CATALOG_TEST_IDS } from "./test-ids"; import { getCategoryConfig } from "@/app/lib/category-config"; import SearchBar from "@/app/components/SearchBar"; import VideoThumbnail from "@/app/components/VideoThumbnail"; +import { markdownComponents } from "@/app/components/markdownComponents"; import { PatternContent } from "@/lib/types"; interface CatalogViewProps { @@ -450,7 +451,7 @@ export default function CatalogView({ groups, title }: CatalogViewProps) { )}
- + {selected.item.content}
diff --git a/website/app/talk/PatternModal.tsx b/website/app/talk/PatternModal.tsx index 620797a..d6e36d8 100644 --- a/website/app/talk/PatternModal.tsx +++ b/website/app/talk/PatternModal.tsx @@ -1,5 +1,6 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { markdownComponents } from "@/app/components/markdownComponents"; import styles from "./PatternModal.module.css"; import detailStyles from "../pattern-detail.module.css"; @@ -47,7 +48,7 @@ export default function PatternModal({ pattern, onClose }: PatternModalProps) {
- + {pattern.content}
diff --git a/website/public/images/example-coupling-map.svg b/website/public/images/example-coupling-map.svg new file mode 100644 index 0000000..1aac18d --- /dev/null +++ b/website/public/images/example-coupling-map.svg @@ -0,0 +1,11 @@ + diff --git a/website/tests/unit/components/MarkdownImage.test.tsx b/website/tests/unit/components/MarkdownImage.test.tsx new file mode 100644 index 0000000..381f370 --- /dev/null +++ b/website/tests/unit/components/MarkdownImage.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { MarkdownImage } from '@/app/components/markdownComponents' + +let mockBasePath = '' + +jest.mock('@/lib/config', () => ({ + get basePath() { + return mockBasePath + }, +})) + +describe('MarkdownImage', () => { + afterEach(() => { + mockBasePath = '' + }) + + it('prefixes a document image with the deployment base path', () => { + mockBasePath = '/augmented-coding-patterns' + + render() + + expect(screen.getByAltText('An example figure')).toHaveAttribute( + 'src', + '/augmented-coding-patterns/images/example.svg', + ) + }) + + it('leaves the path alone for a root deployment', () => { + render() + + expect(screen.getByAltText('An example figure')).toHaveAttribute('src', '/images/example.svg') + }) + + it('leaves an external image untouched', () => { + mockBasePath = '/augmented-coding-patterns' + + render() + + expect(screen.getByAltText('An external figure')).toHaveAttribute( + 'src', + 'https://example.com/figure.svg', + ) + }) + + it('renders nothing without a source', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) +})