Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions documents/patterns/surface-change-attractors.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions documents/relationships.mmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions website/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` would 404 on GitHub Pages. Add the override to any new `ReactMarkdown` call site.

### Category Configuration System

**Centralized in** `app/lib/category-config.ts`:
Expand Down
3 changes: 2 additions & 1 deletion website/app/[category]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -155,7 +156,7 @@ export default async function PatternPage({ params }: PatternPageProps) {
</header>

<article className={styles.content}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{pattern.content}
</ReactMarkdown>
</article>
Expand Down
22 changes: 22 additions & 0 deletions website/app/components/markdownComponents.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Image
src={isExternal(src) ? src : `${basePath}${src}`}
alt={alt ?? ""}
width={0}
height={0}
sizes="100vw"
style={{ width: "100%", height: "auto" }}
/>
);
}

export const markdownComponents = { img: MarkdownImage };
3 changes: 2 additions & 1 deletion website/app/pattern-catalog/CatalogView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -450,7 +451,7 @@ export default function CatalogView({ groups, title }: CatalogViewProps) {
)}
</div>
<div className={styles.detailBody}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{selected.item.content}
</ReactMarkdown>
</div>
Expand Down
3 changes: 2 additions & 1 deletion website/app/talk/PatternModal.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -47,7 +48,7 @@ export default function PatternModal({ pattern, onClose }: PatternModalProps) {
</span>
</header>
<article className={detailStyles.content}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{pattern.content}
</ReactMarkdown>
</article>
Expand Down
11 changes: 11 additions & 0 deletions website/public/images/example-coupling-map.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions website/tests/unit/components/MarkdownImage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MarkdownImage src="/images/example.svg" alt="An example figure" />)

expect(screen.getByAltText('An example figure')).toHaveAttribute(
'src',
'/augmented-coding-patterns/images/example.svg',
)
})

it('leaves the path alone for a root deployment', () => {
render(<MarkdownImage src="/images/example.svg" alt="An example figure" />)

expect(screen.getByAltText('An example figure')).toHaveAttribute('src', '/images/example.svg')
})

it('leaves an external image untouched', () => {
mockBasePath = '/augmented-coding-patterns'

render(<MarkdownImage src="https://example.com/figure.svg" alt="An external figure" />)

expect(screen.getByAltText('An external figure')).toHaveAttribute(
'src',
'https://example.com/figure.svg',
)
})

it('renders nothing without a source', () => {
const { container } = render(<MarkdownImage alt="Missing" />)

expect(container).toBeEmptyDOMElement()
})
})
Loading