The log ran Jev's answers into the subject: a type pill, "moderate risk" in bold, and an italic "message may not match the diff", so a row read as a sentence and the risk as a finding. The type now has its own narrow column on the left; the risk is the score Jev gave on its 0 to 3 scale, one decimal, in a narrow Risk column, faint below moderate and colored from there, its title saying it is Jev's estimate from the diff; and a message that may not describe its diff gets a small mark after the subject, explained on hover. A commit Jev wasn't asked about has empty cells, and a log without answers has neither column. The commit page says "risk score 2.4 of 3, Jev's estimate". On a phone the type leads the line under the subject.
- commit
- cb2b99dc4c067a67617270fd95580fea80edc7f7
- parent
- 14012640ec591e8dd97500a5327f823f287fb5df
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 106s, signed by cat
- kind
- feat risk score 1.5 of 3, Jev's estimate
- review
- #10 Put a commit's type in a column, and its risk as a small score
ci attestation
forge-attestation 1 type ci commit cb2b99dc4c067a67617270fd95580fea80edc7f7 tree 0697ba87cdac1d5824e04167d91958f5e2a5487c script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a result pass exit 0 started 2026-10-19T00:29:14Z duration 106s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:bfda0426a56ca49db4b230bbeb4a42876aae0caf60c6d67e43b83629d16b0720 244 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |bde0e9c running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |bde0e9c pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |d66a7c3 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |d66a7c3 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |620bef1 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |620bef1 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 7c85925 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |5db8ec3 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |5db8ec3 pass in 0s, signed |pushed main |7f5db50 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |7f5db50 pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: deployed cad40ef -> /sites/dami/forge/guide/ |(node:54873) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:55112) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:55432) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:55714) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 3s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |(node:56866) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
| M | lib/ | 2 | + |
| M | lib/ | 7 | +++-- |
| M | lib/ | 2 | + |
| M | site/ | 15 | ++++++++--- |
| M | site/ | 18 | +++++++++++-- |
| A | test/ | 42 | ++++++++++++++++++++++++++++++ |
@@ -547,6 +547,8 @@ export const filters = {
547547 // machine name can say who someone is; the note in refs/notes/ci has the548548 // statement as signed, for checking the signature.549549 statement: (payload: string): string => payload.replace(/^runner .*$/m, 'runner (not shown here; refs/notes/ci has it)'),550+ // A score with one decimal, 1.0 rather than 1.551+ tenths: (n: number): string => Number(n).toFixed(1),550552 short: (sha: string | null | undefined): string => String(sha ?? '').slice(0, 7),551553 // Unix seconds (review logs) or an ISO date.552554 ago(ts: number | string | null): string {@@ -138,7 +138,7 @@ function messages(gitDir: string, shas: string[]): Map<string, Message> {
138138 // rebase and `git cherry` recognize a change. The patch streams from one git139139 // to the other without passing through here. A commit that changes nothing140140 // gets no id. Null when git failed.141-function patchIds(gitDir: string, shas: string[]): Map<string, string> | null {141+export function patchIds(gitDir: string, shas: string[]): Map<string, string> | null {142142 const ids = new Map<string, string>();143143 if (!shas.length) return ids;144144 const log = 'git -c core.quotepath=false --git-dir="$1" log --no-walk=unsorted --stdin --no-color --no-ext-diff --no-renames --diff-merges=first-parent --root -p --format="commit %H"';@@ -153,7 +153,7 @@ function patchIds(gitDir: string, shas: string[]): Map<string, string> | null {
153153 154154 // A commit's key; "empty" stands in for the patch id of a commit that changes155155 // no lines. Null when it changes lines but has no patch id.156-function commitKey(id: string | undefined, subject: string, body: string, lines: number): string | null {156+export function commitKey(id: string | undefined, subject: string, body: string, lines: number): string | null {157157 const pid = id || (lines ? null : 'empty');158158 return pid && `${pid}-${hash(`${subject}\n\n${body}`, 10)}`;159159 }@@ -277,6 +277,7 @@ function label(a: CommitAnswers): CommitLabel | null {
277277 return {278278 kind,279279 risk: risk >= 2.5 ? 'high' : risk >= 1.5 ? 'moderate' : null,280+ riskScore: a.risk?.score == null ? null : Math.round(a.risk.score * 10) / 10,280281 mismatch: (a.matches?.noul ?? 1) < MISMATCH,281282 section: section && Object.hasOwn(SECTIONS, section) ? section : null,282283 userFacing: (a.user_facing?.noul ?? 0) >= 0.5,@@ -315,7 +316,7 @@ export function labelCommits(gitDir: string, commits: Iterable<Labelled>, cache:
315316 }316317 317318 // What the pages show of a label, for page fingerprints.318-export const labelKey = (c: { jev?: CommitLabel | null }): string => (c.jev ? `${c.jev.kind}:${c.jev.risk ?? ''}:${c.jev.mismatch ? 1 : 0}` : '');319+export const labelKey = (c: { jev?: CommitLabel | null }): string => (c.jev ? `${c.jev.kind}:${c.jev.riskScore ?? ''}:${c.jev.mismatch ? 1 : 0}` : '');319320 320321 // What the repo is, if Jev was sure enough and it is one of the named kinds.321322 export function repoTopic(repo: Repo): string | null {@@ -120,6 +120,8 @@ export interface Commit extends CommitMeta {
120120 export interface CommitLabel {121121 kind: string;122122 risk: 'moderate' | 'high' | null;123+ // Jev's expected risk on RISK's 0 to 3 scale, one decimal; null unanswered.124+ riskScore: number | null;123125 mismatch: boolean;124126 section: string | null;125127 userFacing: boolean;@@ -25,18 +25,25 @@
2525 2626 {# Jev's label for a commit (lib/jev-commits.ts): its kind, its risk when2727 moderate or high, and a note when its message may not describe its diff. #}28-{% macro jevLabel(c) %}{% if c.jev %} <span class="jev-kind {{ c.jev.kind }}" title="kind of change, by Jev">{{ c.jev.kind }}</span>{% if c.jev.risk %} <span class="jev-risk {{ c.jev.risk }}" title="how likely it is to break something, by Jev">{{ c.jev.risk }} risk</span>{% endif %}{% if c.jev.mismatch %} <span class="jev-note">message may not match the diff</span>{% endif %}{% endif %}{% endmacro %}28+{# Jev on a commit (lib/jev-commits.ts): its kind, its risk as the score Jev29+ gave (0 can't break anything, 3 likely to), and a mark when the message30+ may not describe the diff. Estimates from the diff, not findings. #}31+{% macro jevKind(c) %}{% if c.jev %}<span class="jev-kind {{ c.jev.kind }}" title="kind of change, Jev's estimate">{{ c.jev.kind }}</span>{% endif %}{% endmacro %}32+{% macro jevRisk(c) %}{% if c.jev and c.jev.riskScore != null %}<span class="jev-score{% if c.jev.risk %} {{ c.jev.risk }}{% endif %}" title="risk score {{ c.jev.riskScore | tenths }} of 3: Jev's estimate from the diff of how likely this is to break something that worked (0 can't, 3 likely)">{{ c.jev.riskScore | tenths }}</span>{% endif %}{% endmacro %}33+{% macro jevMismatch(c) %}{% if c.jev and c.jev.mismatch %} <span class="jev-mismatch" title="the message may not describe the diff, Jev's estimate">≠</span>{% endif %}{% endmacro %}34+{% macro jevLabel(c) %}{% if c.jev %}{{ jevKind(c) }}{% if c.jev.riskScore != null %} <span class="desc">risk score</span> {{ jevRisk(c) }}<span class="desc"> of 3, Jev's estimate</span>{% endif %}{% if c.jev.mismatch %} <span class="jev-note">the message may not describe the diff</span>{% endif %}{% endif %}{% endmacro %}2935 3036 {# What a repo is, by Jev (lib/jev-commits.ts): s is a repo summary or a home page. #}3137 {% macro topic(s) %}{% if s.topic %} <span class="jev-topic" title="what this repo is, by Jev">{{ s.topic }}</span>{% endif %}{% endmacro %}3238 3339 {# Commit list: subject first; author, date, size and CI as the meta line on phones. #}3440 {% macro log(commits, repo) %}35-<table class="list stack nav">36-<thead><tr><th>Commit</th><th>Author</th><th>Date</th><th class="num">+</th><th class="num">-</th><th>CI</th></tr></thead>41+{% set jev = commits | selectattr('jev') | length %}42+<table class="list stack nav log">43+<thead><tr>{% if jev %}<th class="kind">Type</th>{% endif %}<th>Commit</th><th>Author</th><th>Date</th><th class="num">+</th><th class="num">-</th>{% if jev %}<th class="num" title="Jev's estimate of how likely a change is to break something, 0 to 3">Risk</th>{% endif %}<th>CI</th></tr></thead>3744 <tbody>3845 {% for c in commits %}39-<tr><td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevLabel(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td><td>{{ c.ci | ciHtml | safe }}</td></tr>46+<tr>{% if jev %}<td class="kind">{{ jevKind(c) }}</td>{% endif %}<td class="main"><a href="{{ repo.urls.commit(c.sha).href }}">{{ c.subject }}</a>{{ jevMismatch(c) }}</td><td class="nowrap">{{ c.author.name }}</td><td class="date">{{ c.author.date | date }}</td><td class="num i">+{{ c.stats.add }}</td><td class="num d">-{{ c.stats.del }}</td>{% if jev %}<td class="num risk">{{ jevRisk(c) }}</td>{% endif %}<td>{{ c.ci | ciHtml | safe }}</td></tr>4047 {% endfor %}4148 </tbody>4249 </table>@@ -342,10 +342,22 @@ details.att pre, details.howto pre { white-space: pre-wrap; overflow-wrap: anywh
342342 .jev-kind, .jev-topic { display: inline-block; padding: 0 6px; border: 1px solid var(--border); border-radius: 999px; font-size: 0.75rem; line-height: 1.5; font-weight: 500; color: var(--muted); white-space: nowrap; vertical-align: 1px; }343343 .jev-kind.feat { color: var(--pass); }344344 .jev-kind.fix { color: var(--merged); }345-.jev-risk { font-size: 0.75rem; font-weight: 600; white-space: nowrap; }346-.jev-risk.moderate { color: var(--warn); }347-.jev-risk.high { color: var(--fail); }345+/* Jev's risk score, 0 to 3: a small number, colored once it is moderate. */346+.jev-score { font-size: 0.8rem; font-variant-numeric: tabular-nums; color: var(--faint); white-space: nowrap; }347+.jev-score.moderate { color: var(--warn); }348+.jev-score.high { color: var(--fail); font-weight: 600; }349+.jev-mismatch { font-size: 0.85em; color: var(--faint); cursor: help; }348350 .jev-note { font-size: 0.8rem; font-style: italic; color: var(--faint); }351+/* The log: Jev's type in a narrow column on the left. */352+table.log th.kind, table.log td.kind { width: 1%; white-space: nowrap; padding-right: 4px; }353+table.log td.kind .jev-kind { vertical-align: 0; }354+table.log td.risk { width: 1%; }355+@media (max-width: 720px) {356+ /* Stacked: the type leads the line under the subject. */357+ table.log td.kind, table.log td.risk { width: auto; padding-right: 0; }358+ table.log td.kind { order: 1; }359+ table.log td.kind:empty { display: none; }360+}349361 .releases-link { margin-left: 8px; font-size: 0.8rem; font-weight: normal; }350362 .notes { padding: 2px 16px 12px; font-size: 0.95em; }351363 .notes h3 { margin: 12px 0 4px; font-size: 0.9rem; }@@ -0,0 +1,42 @@
1+# The log shows Jev's answers about each commit in columns: its type on the2+# left, its risk as the score Jev gave (0 to 3, one decimal, colored from3+# moderate up), and a mark after the subject when the message may not4+# describe the diff. A repo without answers has neither column.5+. "$(dirname "$0")/../lib.sh"6+user dami7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+at "$T/w/d" commit "plain"10+at "$T/w/d" commit "add a greeting"11+at "$T/w/d" commit "fix the wave"12+push_as dami "$T/w/d" origin main13+export FORGE_STRICT=114+build dami/greet15+lacks "$FORGE_WWW/dami/greet/log/main/index.html" '<th class="kind">'16+g=$FORGE_ROOT/dami/greet.git17+# Stored answers, keyed the way the build looks them up.18+answer() { # sha kind risk matches19+ node -e '20+ const [gitDir, sha, kind, risk, matches] = process.argv.slice(1);21+ import(process.cwd() + "/lib/jev-commits.ts").then(({ patchIds, commitKey }) => {22+ const fs = require("fs"), cp = require("child_process");23+ const subject = cp.execFileSync("git", ["--git-dir=" + gitDir, "log", "-1", "--format=%s", sha], { encoding: "utf8" }).trim();24+ const key = commitKey(patchIds(gitDir, [sha]).get(sha), subject, "", 1);25+ fs.mkdirSync(gitDir + "/forge/jev/commit.v1", { recursive: true });26+ fs.writeFileSync(gitDir + "/forge/jev/commit.v1/" + key, JSON.stringify({ model: "test", at: 1, tokens: null, answers: {27+ kind: { type: "choice", choice: kind }, risk: { type: "score", score: Number(risk) }, matches: { type: "noul", noul: Number(matches) } } }));28+ });' "$g" "$1" "$2" "$3" "$4"29+}30+(cd "$T_HOME" && answer "$(git --git-dir="$g" rev-parse main~1)" feat 0.44 0.9)31+(cd "$T_HOME" && answer "$(git --git-dir="$g" rev-parse main)" fix 2.43 0.05)32+rm -f "$FORGE_CACHE/repos/dami/greet.json"33+build dami/greet34+p=$FORGE_WWW/dami/greet/log/main/index.html35+has "$p" '<th class="kind">Type</th><th>Commit</th>'36+has "$p" '<td class="kind"><span class="jev-kind feat"[^>]*>feat</span></td><td class="main"><a [^>]*>add a greeting</a></td>'37+has "$p" '<td class="kind"><span class="jev-kind fix"[^>]*>fix</span></td><td class="main"><a [^>]*>fix the wave</a> <span class="jev-mismatch"'38+has "$p" '<td class="num risk"><span class="jev-score" title="risk score 0.4 of 3[^"]*">0.4</span></td>'39+has "$p" '<td class="num risk"><span class="jev-score moderate" title="risk score 2.4 of 3[^"]*">2.4</span></td>'40+# The plain commit has no answer: empty cells, not a guess.41+has "$p" '<td class="kind"></td><td class="main"><a [^>]*>plain</a></td>'42+has "$FORGE_WWW/dami/greet/commit/$(git --git-dir="$g" rev-parse main)/index.html" '<span class="desc">risk score</span> <span class="jev-score moderate"'