Skip to content

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions docs/viewers/web/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,44 @@ without suggesting the same command or offering another port.
This page is the browser surface. The shared viewer rules live in
[Viewers](../index.md).

## Live code measurements

On the live working-tree map, **Settings → Colour by** offers **None**,
**Commits**, **Committers**, **Co-change**, and **Instability**. None restores
the ordinary building colours. The legend uses the same blue-to-orange scale
in light, dark, and blueprint themes, from zero to the largest component value.
Instability always uses a zero-to-one scale.

Git history is read in the background after the first map paint and again
after each live update, which keeps the previous colours until the new answer
arrives. **History window (commits)** defaults to the last 500 commits on the
current branch; merge commits are not counted. A changed window lasts until the
page is reloaded. Commits counts the commits touching a component's currently
owned files: a commit counts once for a component, however many of its files it
touches. Committers counts the distinct author email addresses of those
commits. It does not use the committer address, which is the hosting service's
own for a commit merged on its web page. Co-change sums the number of commits
shared with each other component. Component details list the five highest
co-change counts, link to those components, and mark pairs with no relationship
in either direction. Instability is outgoing relationships divided by incoming
plus outgoing relationships; a component with no relationships has value zero.
The details pane shows these values. In a repository without commits the legend
says "No Git history", no metric colours a building, Instability included, and
the details pane shows only the outlier line.

A component with at least ten distinct files gets an orange triangle when its
file count, lines of code, or connection count is at least four times its
container's median. Each measure is compared separately against all components
in the same container, and a measure whose median is zero marks no component.
Connections count the distinct elements of any kind at the other end of a
component's relationships, incoming and outgoing counted separately. Web and
terminal details name every outlying measure.

These are runtime measurements of existing C4 components, not new architecture
elements or stored OKF fields. No metric or control is written to architecture
Markdown or included in static exports. Historical and comparison views hide
the live controls and measurements.

## Static publication

`groma export <directory>` reads stored architecture and writes the Web view as static HTML,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ groma:
file: src/history/comparison.ts
- scanner: typescript
file: src/history/snapshots.ts
- scanner: typescript
file: src/history/code-history.ts
group: Architecture records
description: Loads Git snapshots of architecture without changing working files
description: Reads Git revisions and component change counts without changing working files
---

Reads architecture revisions from Git. Loads the selected revision for a viewer without changing the working files.
Reads architecture revisions from Git and loads the selected revision for a viewer without changing the working files. For the live map, counts commits, distinct committers and shared commits touching currently owned component files within the requested history window.

## Relationships

Expand Down
4 changes: 3 additions & 1 deletion groma/systems/groma-md/containers/cli/components/src-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ groma:
- scanner: typescript
file: src/source-coverage.ts
symbol: missingOwnerReason
- scanner: typescript
file: src/component-metrics.ts
group: Architecture records
description: Prepares element details and display order for the viewers
---

Prepares element details, file sizes, and relationships for the viewers. Supplies the plain text view and shared display order.
Prepares element details, file sizes, and relationships for the viewers. Supplies the plain text view and shared display order. Shared runtime measurements calculate component instability and identify file, line, or connection outliers relative to their container.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
type: C4 Component
title: Code measurements
status: stable
groma:
id: code-measurements
parent: export
code:
- scanner: typescript
file: src/viewers/web/metrics/control.ts
symbol: createMetricsControl
- scanner: typescript
file: src/viewers/web/metrics/details.ts
symbol: paintMetricDetails
group: Browser controls
description: Colours live buildings by code history and identifies unusually large components
---

After the live map paints, requests code history for the selected commit window. Owns the Colour by setting, metric legend, building tints and outlier marks, and the runtime measurements shown in component details. Published maps contain no measurement controls or data.

## Derived relationships

| Source | Target | Description | Technology |
| --- | --- | --- | --- |
| [src/viewers/web/metrics/control.ts](../../../../../../src/viewers/web/metrics/control.ts) | [src/viewers/web/render.ts](../../../../../../src/viewers/web/render.ts) | Invokes supplied callbacks: generation, live, repaint, world | typescript |
102 changes: 102 additions & 0 deletions src/component-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { AnnotatedElement, AnnotatedRelationship, ArchitectureGraph } from './types.ts'

const OUTLIER_MEASURES = ['files', 'lines', 'connections'] as const
type OutlierMeasure = typeof OUTLIER_MEASURES[number]

/** A component with fewer files is never an outlier, whatever its measures. */
const OUTLIER_MIN_FILES = 10
/** A measure marks a component that reaches this many times its container's median of that measure. */
const OUTLIER_MEDIAN_FACTOR = 4

export interface ComponentMetrics {
files: number
lines: number
connections: number
instability: number
/** The measures in which the component stands far above the others in its container. */
outliers: OutlierMeasure[]
}

function median(values: readonly number[]): number {
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
}

function neighbours(world: ArchitectureGraph) {
const incoming = new Map<string, Set<string>>()
const outgoing = new Map<string, Set<string>>()
for (const relationship of world.relationships) {
if (relationship.source === relationship.target) continue
const sources = incoming.get(relationship.target) ?? new Set<string>()
sources.add(relationship.source)
incoming.set(relationship.target, sources)
const targets = outgoing.get(relationship.source) ?? new Set<string>()
targets.add(relationship.target)
outgoing.set(relationship.source, targets)
}
return { incoming, outgoing }
}

function markOutliers(siblings: ComponentMetrics[]): void {
for (const measure of OUTLIER_MEASURES) {
const middle = median(siblings.map(metric => metric[measure]))
// A median of 0 means at least half the container has nothing of this measure, often no connection. Every
// value reaches any multiple of 0, so such a measure marks nobody.
if (middle === 0) continue
for (const metric of siblings) {
if (metric.files >= OUTLIER_MIN_FILES && metric[measure] >= OUTLIER_MEDIAN_FACTOR * middle) metric.outliers.push(measure)
}
}
}

interface Computed {
relationships: readonly AnnotatedRelationship[]
metrics: ReadonlyMap<string, ComponentMetrics>
}

/** One result per loaded elements snapshot, released with that snapshot, as in source-index.ts. */
const computed = new WeakMap<readonly AnnotatedElement[], Computed>()

/**
* Runtime evidence about existing components, never architecture metadata.
* Computed once per loaded world: again only when the elements or the relationships array is a different one.
*/
export function componentMetrics(world: ArchitectureGraph): ReadonlyMap<string, ComponentMetrics> {
const existing = computed.get(world.elements)
if (existing?.relationships === world.relationships) return existing.metrics
const { incoming, outgoing } = neighbours(world)
const byId = new Map(world.elements.map(element => [element.id, element]))
const result = new Map<string, ComponentMetrics>()
const siblings = new Map<string, ComponentMetrics[]>()
for (const element of world.elements) {
if (element.kind !== 'component') continue
const ins = incoming.get(element.id)?.size ?? 0
const outs = outgoing.get(element.id)?.size ?? 0
const metrics: ComponentMetrics = {
files: new Set(element.code.map(reference => reference.file)).size,
lines: element.codeLines ?? 0,
connections: ins + outs,
instability: ins + outs === 0 ? 0 : outs / (ins + outs),
outliers: [],
}
result.set(element.id, metrics)
if (element.parent === null) continue
const parent = byId.get(element.parent)
if (parent?.kind !== 'container') continue
const group = siblings.get(element.parent) ?? []
group.push(metrics)
siblings.set(element.parent, group)
}
for (const group of siblings.values()) markOutliers(group)
computed.set(world.elements, { relationships: world.relationships, metrics: result })
return result
}

const measureWords: Record<OutlierMeasure, string> = { files: 'files', lines: 'lines of code', connections: 'connections' }

/** The one wording of an outlier: the web and terminal details print it and the map's outlier mark carries it as its label. */
export function outlierLine(metrics: ComponentMetrics | undefined): string | undefined {
if (!metrics?.outliers.length) return undefined
return `Outlier: ${metrics.outliers.map(measure => measureWords[measure]).join(', ')} (at least ${OUTLIER_MEDIAN_FACTOR}× container median)`
}
90 changes: 90 additions & 0 deletions src/history/code-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { sourceIndex } from '../source-index.ts'
import type { ArchitectureGraph } from '../types.ts'
import { hasCommits, runGit } from './revisions.ts'

// This module runs Git, so it runs on the host only. Browser code may import its types, never its functions:
// a value import pulls node:child_process into the browser bundle and the build fails.

/** What the commits read from the current branch did to one component's files. */
export interface ComponentHistory {
commits: number
/** Distinct author email addresses. */
committers: number
/** The commits shared with each other component, added up over `peers`. */
cochange: number
/** The other components touched in the same commits, most shared commits first. */
peers: { id: string; commits: number }[]
}

export interface CodeHistory {
/** The number of commits asked for; the branch may have fewer. */
window: number
components: Record<string, ComponentHistory>
}

interface ComponentChanges {
id: string
commits: number
/** Author email addresses; their number is the Committers metric. */
authors: Set<string>
pairs: Map<string, number>
}

function parseCommit(record: string, owners: ReadonlyMap<string, string>, components: Map<string, ComponentChanges>) {
const [, author, ...files] = record.split('\0')
const touched = new Set<ComponentChanges>()
for (const file of files) {
const owner = owners.get(file.replace(/^\n/, ''))
const component = owner === undefined ? undefined : components.get(owner)
if (component !== undefined) touched.add(component)
}
return { author: author!, touched }
}

function recordCommit(author: string, touched: Set<ComponentChanges>): void {
for (const component of touched) {
component.commits += 1
component.authors.add(author)
for (const peer of touched) {
if (peer !== component) component.pairs.set(peer.id, (component.pairs.get(peer.id) ?? 0) + 1)
}
}
}

function componentHistory(component: ComponentChanges): ComponentHistory {
const peers = [...component.pairs].map(([id, commits]) => ({ id, commits }))
.sort((a, b) => b.commits - a.commits || a.id.localeCompare(b.id))
return {
commits: component.commits,
committers: component.authors.size,
cochange: peers.reduce((total, peer) => total + peer.commits, 0),
peers,
}
}

/**
* Runs `git log` in `root` over the last `window` commits of the current branch, merge commits left out, and counts
* for every component the commits and authors that touched its files and the commits it shares with each other
* component. Returns null when the repository has no commit. Nothing is stored.
*/
export async function readCodeHistory(root: string, world: ArchitectureGraph, window: number): Promise<CodeHistory | null> {
if (!await hasCommits(root)) return null
// %aE is the author's address: the committer's (%cE) belongs to the hosting service for a commit merged on its web
// page. --relative prints paths from `root`, as components record them, also when `root` is below the Git top level.
// --no-merges: a merge commit would count its branch's changes a second time, as one commit by whoever merged.
const output = await runGit([
'log', `-n${window}`, '--format=%x1e%H%x00%aE', '--name-only', '--relative', '-z', '--no-renames', '--no-merges',
], root)
const owners = sourceIndex(world.elements).byFile
const components = new Map<string, ComponentChanges>()
for (const element of world.elements) {
if (element.kind === 'component') components.set(element.id, {
id: element.id, commits: 0, authors: new Set(), pairs: new Map(),
})
}
for (const record of output.split('\x1e').slice(1)) {
const { author, touched } = parseCommit(record, owners, components)
recordCommit(author, touched)
}
return { window, components: Object.fromEntries([...components].map(([id, component]) => [id, componentHistory(component)])) }
}
5 changes: 3 additions & 2 deletions src/history/revisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export interface GromaRevision extends GitRevision {
compatible: boolean
}

function runGit(arguments_: string[], repositoryRoot: string): Promise<string> {
/** Runs Git in the repository and resolves with what it printed, or rejects with its error text. */
export function runGit(arguments_: string[], repositoryRoot: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('git', arguments_, {
cwd: repositoryRoot,
Expand Down Expand Up @@ -83,7 +84,7 @@ export async function readGitText(
}

/** A repository whose branch has no commit yet has no history to offer. */
function hasCommits(repositoryRoot: string): Promise<boolean> {
export function hasCommits(repositoryRoot: string): Promise<boolean> {
return runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], repositoryRoot)
.then(() => true, () => false)
}
Expand Down
7 changes: 5 additions & 2 deletions src/viewers/tui/panes/details.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { TextAttributes } from '@opentui/core'

import { copiesOfSymbol, type OperationCopies } from '../../../architecture-findings.ts'
import { componentMetrics, outlierLine } from '../../../component-metrics.ts'
import { pairDescriptions, type RelationshipPair } from '../../relationship-text.ts'
import { kindLabel } from '../../atoms/kind.ts'
import type { ProjectProfile } from '../../../project-profile.ts'
Expand Down Expand Up @@ -225,9 +226,11 @@ export function detailsLines(
: tab === 'how'
? howLines(theme, element, world, width, actionCursor, structure)
: whatLines(theme, element, world, byId, width, pickedId, actionCursor)
const outlier = outlierLine(componentMetrics(world).get(element.id))
const metricLines: Line[] = outlier === undefined ? [] : wrap(outlier, width).map(line => [plain(theme, line)])
return {
lines: [head, ...body.lines],
...(body.cursor === undefined ? {} : { cursor: body.cursor + 1 }),
lines: [head, ...metricLines, ...body.lines],
...(body.cursor === undefined ? {} : { cursor: body.cursor + 1 + metricLines.length }),
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/viewers/web/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ScannerSettings, ScannerSettingsAction } from '../../scanner/modul
import type { AcceptInput, AddInput, DraftInput, EditArchitectureInput, RemoveInput } from '../../authoring.ts'
import type { WorkItemDetails } from '../../types.ts'
import { PUBLISHED_EVENT, PUBLISHED_VERSION_EVENT } from './payload.ts'
import type { WebBootPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts'
import type { WebBootPayload, WebCodeHistoryPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts'
import type { SourcePayload } from '../source/read.ts'
import type { CodeFile } from '../source/structure.ts'
import type { TaskDiffPayload } from '../source/diff.ts'
Expand All @@ -17,6 +17,8 @@ export interface WebDataSource {
readSource(element: string, file: string, revision?: string, from?: string): Promise<SourcePayload>
readTask(id: string): Promise<WorkItemDetails>
readTaskDiff(id: string): Promise<TaskDiffPayload>
/** Git counts for the components of the live map, absent in the published delivery, which has no repository. */
readCodeHistory?(window: number): Promise<WebCodeHistoryPayload>
/** The writers, absent in the published delivery, which has none. */
draft?(input: DraftInput): Promise<void>
add?(input: AddInput): Promise<void>
Expand Down Expand Up @@ -83,6 +85,9 @@ function liveDataSource(): WebDataSource {
readTaskDiff(id) {
return responseJson(selected('/task-diff.json', { task: id }))
},
readCodeHistory(window) {
return responseJson(selected('/code-history.json', { window: String(window) }))
},
draft: input => send('/draft', input),
add: input => send('/add', input),
remove: input => send('/remove', input),
Expand Down
10 changes: 9 additions & 1 deletion src/viewers/web/map-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import { ownedFiles } from '../../history/comparison.ts'
import { readComparison } from '../../history/snapshots.ts'
import { measuredSheetScene } from '../../sheet/scene.ts'
import { listGitRevisions, withGitRevision } from '../../history/revisions.ts'
import { readCodeHistory } from '../../history/code-history.ts'
import { renderPage } from './page.ts'
import type { WebMapPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts'
import type { WebCodeHistoryPayload, WebMapPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts'
import { bundleRenderer, loadMapRoot } from './runtime.ts'
import { coverThemes, generateCovers, type CoverImages } from './sharing/images.ts'
import { coverFile } from './sharing/metadata.ts'
Expand Down Expand Up @@ -391,6 +392,13 @@ export async function createWebMapSession(
}

const routes = new Map<string, Route>([
['/code-history.json', async (_request, url) => {
const window = Number(url.searchParams.get('window'))
if (!Number.isSafeInteger(window) || window < 1) return new Response('History window must be a positive integer', { status: 400 })
const current = map
const answer: WebCodeHistoryPayload = { generation: current.generation, history: await readCodeHistory(repositoryRoot, current.world, window) }
return Response.json(answer)
}],
['/scanner-settings', async (_request, url) => {
const settings = await scannerSession.refresh()
return Response.json(url.searchParams.has('updates') ? await withScannerUpgrades(settings) : settings)
Expand Down
Loading
Loading