This is an automated email from the ASF dual-hosted git repository.

spmallette pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git

commit f4222773666e1c01af706a723adaf44232f6ad8e
Author: Stephen Mallette <[email protected]>
AuthorDate: Thu Oct 8 14:29:11 2026 -0400

    Guide tinker-review walk with questions and code links
    
    Each Guided Walk item now poses the questions a reviewer should ask, with
    chips that open the code answering each one at the reviewed commit.
    
    Assisted-by: Claude Code:claude-opus-5-5
---
 .skills/tinker-review/SKILL.md                     |  27 +++-
 .skills/tinker-review/references/interfaces.md     |  50 ++++++-
 .../tinker-review/scripts/extraction/code-index.js |  99 +++++++++++++
 .../scripts/extraction/tree-sitter.js              |   2 +
 .../tinker-review/scripts/renderer/code-links.js   | 124 ++++++++++++++++
 .skills/tinker-review/scripts/renderer/render.js   |  41 +++++-
 .../tinker-review/scripts/renderer/template.html   |   6 +
 .skills/tinker-review/scripts/review.js            |   7 +
 .skills/tinker-review/test/code-links.test.js      | 161 +++++++++++++++++++++
 9 files changed, 504 insertions(+), 13 deletions(-)

diff --git a/.skills/tinker-review/SKILL.md b/.skills/tinker-review/SKILL.md
index eec76e278d..34564b25e5 100644
--- a/.skills/tinker-review/SKILL.md
+++ b/.skills/tinker-review/SKILL.md
@@ -245,11 +245,32 @@ produce a complete evidence-with-narrative JSON file. 
Write it to
   characters; the box truncates anything longer. Name the concern, not the 
structure:
   the renderer already shows the kind (`implementation`, `test code`, `type 
hierarchy`,
   `file group`) next to the name. Any community left unnamed renders as 
"Community N".
-- `guidedWalk` — array of `{ title, badge, badgeText, body }` objects
+- `guidedWalk` — array of `{ title, badge, badgeText, intro, questions, refs 
}` objects, one
+  per area of the change, that **orient a reviewer who does not know the 
code**. Write for a
+  first-time reader: say what the area does in plain terms, not as a tour of 
class and method
+  names.
+  - `intro` — HTML, one or two plain sentences on what this part of the PR 
does and why.
+  - `questions` — `[{ text, refs }]`, the questions a reviewer should hold 
while reading this
+    part. Each `text` (HTML) asks something the reviewer can check or judge 
(*"Can two graphs
+    ever write to the same directory?"*, *"Is keeping index definitions 
outside the log
+    reasonable?"*) and may cite what you found (a finding, a functional-test 
label). Invite
+    judgment of trade-offs rather than asserting them.
+  - `refs` on a question — the code that answers it, as `{ file, symbol?, 
lines?, label? }`:
+    `file` is a repo path or unique suffix (`"TinkerTransaction.java"`); 
`symbol` a function
+    or type name, or `"Type.member"` for a member 
(`"TinkerStorageGraph.TinkerStorageGraph"`
+    is the constructor; bare `"TinkerStorageGraph"` the class); `label` the 
chip text. Name
+    the code — **never write line numbers or URLs**: the renderer resolves 
each ref against
+    `evidence.codeIndex` and links to the file pinned at the reviewed commit 
with the
+    symbol's lines highlighted. Two or three
+    refs per question is plenty.
+  - `refs` on the item — optional "Also see" chips for code that belongs to 
the whole area.
+
+  `render.js` prints any ref it cannot resolve; fix the `file`/`symbol` and 
re-render. An
+  item with only an HTML `body` still renders (older reports).
 - `findings` — array of `{ title, snippet, body }` objects, ordered 
most-severe-first (Interpret grades each blocking / high / low)
 - `openQuestions` — array of `{ title, body, meta }` objects
 - `functionalTest` — `{ plan, results: [{name, pass, output}], observations }` 
(if testing was done).
-  `plan` and `observations` are HTML and surface **themes and insights** — what
+  `plan` is HTML and `observations` is an array of HTML strings (one per 
insight); both and surface **themes and insights** — what
   families of behavior were exercised and what was learned — not a per-scenario
   list. `results` rows are **theme-level**: each `name` names a theme and the
   scenario labels it spans (e.g. `"Barrier family (A4–A12)"`), so the PASS/FAIL
@@ -263,7 +284,7 @@ produce a complete evidence-with-narrative JSON file. Write 
it to
   subagent (see step 4); it is the source of truth the Functional Test section
   summarizes by label.
 
-All `body` fields (and `functionalTest.plan`/`observations`, 
`appendixFunctional.environment`)
+All `body`, `intro` and question `text` fields (and 
`functionalTest.plan`/`observations`, `appendixFunctional.environment`)
 are HTML — use `<code>`, `<strong>`, `<ul>`, `<p>` as needed. The raw-text 
fields
 noted above are the exception. The renderer handles all layout, CSS, and 
structure.
 
diff --git a/.skills/tinker-review/references/interfaces.md 
b/.skills/tinker-review/references/interfaces.md
index b3644c73cb..60043ef934 100644
--- a/.skills/tinker-review/references/interfaces.md
+++ b/.skills/tinker-review/references/interfaces.md
@@ -103,12 +103,15 @@ interface PopulationSummary {
 //   ExternalsResult      scripts/patterns/classify-externals.js
 //   OrphanResult         scripts/patterns/orphans.js
 //   ArchitectureResult   scripts/patterns/architecture.js
+//   CodeIndex            scripts/extraction/code-index.js
+//   CodeRef              scripts/renderer/code-links.js
 
 // === Evidence (evidence.json — what Phase 1 writes; the fields Interpret 
cites) ===
 
 interface Evidence {
   meta: {
     pr: number;
+    headSha: string | null;      // PR head commit reviewed; pins report code 
links
     title: string;
     domains: string[];           // e.g. ["general", "glv", "driver-server"]
     language: string;
@@ -129,6 +132,7 @@ interface Evidence {
   };
   discussions: DiscussionsResult;   // jiras[], devList[], secondary[], 
proposals[], prComments{}
   changedFiles: string[];
+  codeIndex: CodeIndex;             // changed files → hunks + symbol line 
ranges (scripts/extraction/code-index.js)
 }
 
 // === ReportPackage (report.json — renderer input; Evidence + agent 
narrative) ===
@@ -141,11 +145,19 @@ interface ReportPackage extends Evidence {
   communityNames?: { [id: string]: string };
     // content-derived name per community, keyed by 
checks.communities.communities[].id
     // (0-based; also the stamped `community` vertex property). Unnamed → 
"Community N".
-  guidedWalk: { title; badge; badgeText; body }[];
+  guidedWalk: {
+    title; badge; badgeText;               // badge: "attention" | "info" | 
"safe"
+    intro: string;                         // HTML — what this area does, in 
plain terms
+    questions: { text: string; refs?: CodeRef[] }[];   // text: HTML
+    refs?: CodeRef[];                      // optional item-level "Also see"
+  }[];
+    // CodeRef = { file; symbol?; lines?: [number, number]; label? } — see
+    // scripts/renderer/code-links.js. The renderer resolves it against 
codeIndex;
+    // the agent never writes line numbers or URLs. Legacy { body } items 
still render.
   findings: { title; snippet; body }[];
   openQuestions: { title; body; meta }[];
-  functionalTest?: { plan; results: { name; pass; output }[]; observations };
-    // plan/observations: HTML, theme-level. results rows are THEMES, each 
`name`
+  functionalTest?: { plan; results: { name; pass; output }[]; observations: 
string[] };
+    // plan: HTML; observations: array of HTML strings; both theme-level. 
results rows are THEMES, each `name`
     // naming the scenario labels it spans — not one row per scenario.
   appendixFunctional?: { environment; testCode; fullOutput };
     // environment: HTML. testCode/fullOutput: RAW TEXT (renderer wraps in
@@ -311,9 +323,39 @@ export async function coverageGaps(g, params = {}) {}
  * Render an evidence package to a self-contained HTML page.
  *
  * @param {EvidencePackage} evidence - Structured evidence data
+ * @param {object} [options]
+ * @param {string[]} [options.warnings] - receives one entry per Guided Walk 
code ref that
+ *   could not be resolved (the CLI prints them to stderr)
  * @returns {string} - Complete HTML document as a string
  */
-export function render(evidence) {}
+export function render(evidence, options = {}) {}
+```
+
+### renderer/code-links.js
+
+```javascript
+/**
+ * Resolve a {file, symbol?, lines?, label?} reference against 
evidence.codeIndex to a
+ * link: the file pinned at meta.headSha with the symbol's lines highlighted 
(the whole
+ * file when no symbol or lines). Without headSha it falls back to the PR 
"Files changed"
+ * anchor at the first changed lines, else the file in the diff. url is null 
(with
+ * the reason in title) when the reference does not resolve.
+ *
+ * @returns {{label, url: string|null, title, target: 
"diff"|"blob"|"file"|null}}
+ */
+export function resolveRef(ref, { pr, headSha, codeIndex }) {}
+```
+
+### extraction/code-index.js
+
+```javascript
+/**
+ * Build evidence.codeIndex from the extraction and `git diff --unified=0`: 
for each
+ * changed file, its changed line ranges on the PR head side and the line 
range of every
+ * function and type in it. Called by Phase 1.
+ */
+export function buildCodeIndex({ changedFiles, extraction, diffText }) {}
+export function parseHunks(diffText) {}
 ```
 
 **Responsibilities:**
diff --git a/.skills/tinker-review/scripts/extraction/code-index.js 
b/.skills/tinker-review/scripts/extraction/code-index.js
new file mode 100644
index 0000000000..7e330490d2
--- /dev/null
+++ b/.skills/tinker-review/scripts/extraction/code-index.js
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * @typedef {object} CodeSymbol
+ * @property {string} name - function or type name as extracted
+ * @property {"function"|"type"} kind
+ * @property {number} start - first line (1-based, PR head side)
+ * @property {number} end - last line (inclusive)
+ */
+
+/**
+ * @typedef {object} CodeIndexFile
+ * @property {Array<[number, number]>} hunks - changed line ranges on the PR 
head side, ascending
+ * @property {CodeSymbol[]} symbols
+ */
+
+/**
+ * @typedef {object} CodeIndex
+ * @property {Object<string, CodeIndexFile>} files - keyed by repo-relative 
path
+ */
+
+/**
+ * Parse `git diff --unified=0` output into the changed line ranges on the new
+ * (PR head) side of each file. A pure deletion (`+N,0`) has no new-side 
lines, so it
+ * is recorded as `[N, N]`, the head line it follows, which GitHub shows as 
context
+ * beside the removed lines; that lets a symbol whose only change is a removal 
still
+ * link into the diff. A file deleted by the PR has no entry.
+ *
+ * @param {string} diffText
+ * @returns {Object<string, Array<[number, number]>>}
+ */
+export function parseHunks(diffText) {
+  const hunks = {};
+  let current = null;
+  for (const line of (diffText || "").split("\n")) {
+    if (line.startsWith("+++ ")) {
+      const target = line.slice(4).trim();
+      current = target === "/dev/null" ? null : target.replace(/^b\//, "");
+      if (current && !hunks[current]) hunks[current] = [];
+      continue;
+    }
+    if (!current || !line.startsWith("@@")) continue;
+    const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
+    if (!m) continue;
+    const start = Number(m[1]);
+    const count = m[2] === undefined ? 1 : Number(m[2]);
+    if (count > 0) hunks[current].push([start, start + count - 1]);
+    else hunks[current].push([Math.max(start, 1), Math.max(start, 1)]);
+  }
+  for (const ranges of Object.values(hunks)) ranges.sort((a, b) => a[0] - 
b[0]);
+  return hunks;
+}
+
+/**
+ * Build the code index the renderer uses to turn a `{file, symbol}` reference 
into
+ * a link: for every changed file, its changed hunks and the line range of each
+ * function and type extracted from it. Files outside `changedFiles` (such as 
the
+ * hierarchy neighborhood parsed for context) are left out.
+ *
+ * @param {object} params
+ * @param {string[]} params.changedFiles
+ * @param {{functions: object[], types: object[]}} params.extraction
+ * @param {string} params.diffText - `git diff --unified=0` of the PR against 
its merge base
+ * @returns {CodeIndex}
+ */
+export function buildCodeIndex({ changedFiles, extraction, diffText }) {
+  const hunks = parseHunks(diffText);
+  const files = {};
+  for (const path of changedFiles) {
+    if (!hunks[path]) continue;
+    files[path] = { hunks: hunks[path], symbols: [] };
+  }
+  const add = (record, kind) => {
+    const entry = files[record.filePath];
+    if (!entry || !record.linesStart || !record.linesEnd) return;
+    entry.symbols.push({ name: record.name, kind, start: record.linesStart, 
end: record.linesEnd });
+  };
+  for (const t of extraction.types || []) add(t, "type");
+  for (const f of extraction.functions || []) add(f, "function");
+  for (const entry of Object.values(files)) entry.symbols.sort((a, b) => 
a.start - b.start);
+  return { files };
+}
diff --git a/.skills/tinker-review/scripts/extraction/tree-sitter.js 
b/.skills/tinker-review/scripts/extraction/tree-sitter.js
index ba4dc9bd48..c8f7d4bfca 100644
--- a/.skills/tinker-review/scripts/extraction/tree-sitter.js
+++ b/.skills/tinker-review/scripts/extraction/tree-sitter.js
@@ -476,6 +476,8 @@ function extractTypesFromTree(tree, filePath, language) {
           language,
           supertypes,
           signature: typeSignature(node, language, kind, visibility, 
supertypes),
+          linesStart: node.startPosition.row + 1,
+          linesEnd: node.endPosition.row + 1,
           rawHash: rawHash(node),
           normHash: normHash(node),
         });
diff --git a/.skills/tinker-review/scripts/renderer/code-links.js 
b/.skills/tinker-review/scripts/renderer/code-links.js
new file mode 100644
index 0000000000..2a1e9001f3
--- /dev/null
+++ b/.skills/tinker-review/scripts/renderer/code-links.js
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createHash } from "node:crypto";
+
+const REPO = "https://github.com/apache/tinkerpop";;
+
+/**
+ * A reference from the report narrative to code in the PR. The agent names the
+ * code; the renderer finds the lines, so no line number or URL is ever 
written by
+ * hand.
+ *
+ * @typedef {object} CodeRef
+ * @property {string} file - repo-relative path, or a unique suffix of one 
(e.g. "TinkerTransaction.java")
+ * @property {string} [symbol] - function or type name; "Type.member" picks a 
member of that type
+ *   (so "TinkerStorageGraph.TinkerStorageGraph" is the constructor, bare 
"TinkerStorageGraph" the class)
+ * @property {[number, number]} [lines] - explicit line range on the PR head 
side, used instead of symbol
+ * @property {string} [label] - chip text; defaults to the symbol, else the 
file name
+ */
+
+/**
+ * @typedef {object} ResolvedRef
+ * @property {string} label
+ * @property {string|null} url - null when the reference could not be resolved
+ * @property {string} title - tooltip: path and lines, or why it did not 
resolve
+ * @property {"diff"|"blob"|"file"|null} target - which kind of link was 
produced
+ */
+
+/**
+ * The anchor GitHub gives a file in a pull request's "Files changed" view.
+ */
+export function diffAnchor(path) {
+  return "diff-" + createHash("sha256").update(path).digest("hex");
+}
+
+function findFile(codeIndex, file) {
+  const files = (codeIndex && codeIndex.files) || {};
+  if (files[file]) return { path: file };
+  const matches = Object.keys(files).filter((p) => p.endsWith("/" + file));
+  if (matches.length === 1) return { path: matches[0] };
+  return { error: matches.length === 0 ? `no changed file matches "${file}"` : 
`"${file}" matches ${matches.length} changed files` };
+}
+
+function findSymbol(entry, symbol) {
+  const dot = symbol.lastIndexOf(".");
+  if (dot > 0) {
+    const owner = symbol.slice(0, dot);
+    const member = symbol.slice(dot + 1);
+    const types = entry.symbols.filter((s) => s.kind === "type" && s.name === 
owner);
+    for (const t of types) {
+      const fn = entry.symbols.find((s) => s.kind === "function" && s.name === 
member && s.start >= t.start && s.end <= t.end);
+      if (fn) return fn;
+    }
+    return null;
+  }
+  // a bare name means the type when one exists (a constructor shares its 
class's name), else the first function
+  return entry.symbols.find((s) => s.kind === "type" && s.name === symbol)
+    || entry.symbols.find((s) => s.kind === "function" && s.name === symbol)
+    || null;
+}
+
+/**
+ * Resolve a reference to a link. With the reviewed commit recorded, every 
reference
+ * links to the file pinned at that commit, highlighting the symbol's lines 
(or the
+ * whole file when there is no symbol or lines). The PR's "Files changed" view 
is not
+ * used because GitHub loads most files there only after the page opens, so 
its line
+ * anchors rarely land. Without a recorded commit, links fall back to that 
view: at the
+ * first changed lines inside the range, else the file.
+ *
+ * @param {CodeRef} ref
+ * @param {{pr: number|string, headSha?: string|null, codeIndex?: object}} ctx
+ * @returns {ResolvedRef}
+ */
+export function resolveRef(ref, ctx) {
+  const label = ref.label || ref.symbol || (ref.file || "").split("/").pop() 
|| "code";
+  const fail = (why) => ({ label, url: null, title: `unresolved: ${why}`, 
target: null });
+  if (!ref.file) return fail("reference has no file");
+
+  const found = findFile(ctx.codeIndex, ref.file);
+  if (found.error) return fail(found.error);
+  const path = found.path;
+  const entry = ctx.codeIndex.files[path];
+  const filesUrl = `${REPO}/pull/${ctx.pr}/files#${diffAnchor(path)}`;
+  const blobUrl = ctx.headSha ? `${REPO}/blob/${ctx.headSha}/${path}` : null;
+
+  let range = null;
+  if (Array.isArray(ref.lines) && ref.lines.length === 2) {
+    range = [Number(ref.lines[0]), Number(ref.lines[1])];
+  } else if (ref.symbol) {
+    const sym = findSymbol(entry, ref.symbol);
+    if (!sym) return fail(`no symbol "${ref.symbol}" in ${path}`);
+    range = [sym.start, sym.end];
+  }
+  if (!range) return { label, url: blobUrl || filesUrl, title: path, target: 
"file" };
+
+  const [start, end] = range;
+  const hunk = entry.hunks.find(([hs, he]) => hs <= end && he >= start);
+  const changed = hunk ? `changed at ${Math.max(start, hunk[0])}` : 
"unchanged";
+  if (blobUrl) {
+    return { label, url: `${blobUrl}#L${start}-L${end}`, title: 
`${path}:${start}-${end} (${changed})`, target: "blob" };
+  }
+  if (hunk) {
+    const a = Math.max(start, hunk[0]);
+    const b = Math.min(end, hunk[1]);
+    return { label, url: `${filesUrl}R${a}${b > a ? `-R${b}` : ""}`, title: 
`${path}:${a}-${b} (changed lines)`, target: "diff" };
+  }
+  return { label, url: filesUrl, title: `${path}:${start}-${end} (no commit 
recorded; linking the file)`, target: "file" };
+}
diff --git a/.skills/tinker-review/scripts/renderer/render.js 
b/.skills/tinker-review/scripts/renderer/render.js
index cb221255ae..aeafc0ea27 100644
--- a/.skills/tinker-review/scripts/renderer/render.js
+++ b/.skills/tinker-review/scripts/renderer/render.js
@@ -20,6 +20,7 @@
 import { readFileSync } from "node:fs";
 import { dirname, join } from "node:path";
 import { fileURLToPath } from "node:url";
+import { resolveRef } from "./code-links.js";
 
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const TEMPLATE = readFileSync(join(__dirname, "template.html"), "utf-8");
@@ -83,7 +84,10 @@ function stripCodeWrapper(str) {
  * Input contract — narrative fields (from agent/synthesize.js):
  *   summary: "HTML string"
  *   clusters: { svg: "<svg>...</svg>", assessment: "HTML string" }
- *   guidedWalk: [{ title, badge: "attention|info|safe", badgeText, body: 
"HTML" }]
+ *   guidedWalk: [{ title, badge: "attention|info|safe", badgeText,
+ *                  intro: "HTML", questions: [{ text: "HTML", refs: [CodeRef] 
}], refs: [CodeRef] }]
+ *     (CodeRef = { file, symbol?, lines?, label? } — see code-links.js; the 
renderer resolves it
+ *      against evidence.codeIndex into a link. A legacy item with only body: 
"HTML" still renders.)
  *   functionalTest: { plan: "HTML", results: [{name, pass, output}], 
observations: ["HTML"] }
  *     (results rows are THEME-level, each naming the scenario labels it 
covers)
  *   findings: [{ title, snippet: "code", body: "HTML" }]
@@ -95,7 +99,8 @@ function notProvided(sectionId, title) {
   return `<section id="${sectionId}">\n  <h2>${esc(title)}</h2>\n  <p 
class="section-intro" style="color: var(--danger);">Section not 
provided.</p>\n</section>`;
 }
 
-export function render(evidence) {
+export function render(evidence, options = {}) {
+  const warnings = options.warnings || [];
   const { meta, graphStats, checks, discussions, summary, clusters, 
communityAssessment, communityNames,
     guidedWalk, functionalTest, findings, openQuestions, appendixFunctional } 
= evidence;
 
@@ -106,7 +111,7 @@ export function render(evidence) {
   parts.push(discussions ? renderContext(discussions) : notProvided("context", 
"Discovered Context"));
   parts.push(renderClusters(clusters, checks && checks.clusters, 
evidence.architecture));
   parts.push(renderCommunitySection(checks && checks.communities, 
communityAssessment, communityNames));
-  parts.push(guidedWalk && guidedWalk.length > 0 ? 
renderGuidedWalk(guidedWalk) : notProvided("guided-walk", "Guided Walk"));
+  parts.push(guidedWalk && guidedWalk.length > 0 ? 
renderGuidedWalk(guidedWalk, { pr: meta.pr, headSha: meta.headSha, codeIndex: 
evidence.codeIndex }, warnings) : notProvided("guided-walk", "Guided Walk"));
   parts.push(functionalTest ? renderFunctionalTest(functionalTest) : 
notProvided("functional-test", "Functional Test"));
   parts.push(findings && findings.length > 0 ? renderFindings(findings) : 
notProvided("findings", "Findings"));
   parts.push(openQuestions && openQuestions.length > 0 ? 
renderOpenQuestions(openQuestions) : notProvided("open-questions", "Open 
Questions"));
@@ -412,14 +417,36 @@ function generateCommunitySvg(communityData, names) {
 </svg>`;
 }
 
-function renderGuidedWalk(walk) {
+function renderCodeRefs(refs, ctx, warnings, where, cls = "code-refs") {
+  if (!Array.isArray(refs) || refs.length === 0) return "";
+  const chips = refs.map((ref) => {
+    const r = resolveRef(ref, ctx);
+    if (!r.url) {
+      warnings.push(`${where}: ${r.title} (${JSON.stringify(ref)})`);
+      return `<span class="code-ref code-ref-unresolved" 
title="${esc(r.title)}">${esc(r.label)}</span>`;
+    }
+    return `<a class="code-ref code-ref-${r.target}" href="${esc(r.url)}" 
target="_blank" rel="noopener" title="${esc(r.title)}">${esc(r.label)}</a>`;
+  }).join(" ");
+  return `<div class="${cls}">${chips}</div>`;
+}
+
+function renderGuidedWalk(walk, ctx, warnings) {
   const steps = walk.map((step, i) => {
     const cls = step.badge === "attention" ? "card-attention" : step.badge === 
"safe" ? "card-safe" : "card-info";
     const badgeCls = step.badge === "attention" ? "badge-attention" : 
step.badge === "safe" ? "badge-safe" : "";
     const badge = step.badgeText ? ` <span class="badge 
${badgeCls}">${esc(step.badgeText)}</span>` : "";
+    const where = `guidedWalk[${i}]`;
+    const intro = step.intro || step.body || "";
+    const questions = Array.isArray(step.questions) && step.questions.length > 0
+      ? `<ul class="walk-questions">\n      ${step.questions.map((q, j) =>
+          `<li>${q.text}${renderCodeRefs(q.refs, ctx, warnings, 
`${where}.questions[${j}]`)}</li>`).join("\n      ")}\n    </ul>`
+      : "";
+    const alsoSee = renderCodeRefs(step.refs, ctx, warnings, `${where}.refs`, 
"code-refs walk-refs");
     return `<div class="card ${cls}">
     <h3><span class="walk-number">${i + 1}</span> ${step.title}${badge}</h3>
-    ${step.body}
+    ${intro}
+    ${questions}
+    ${alsoSee}
   </div>`;
   }).join("\n  ");
   return `<section id="guided-walk">\n  <h2>Guided Walk</h2>\n  
${steps}\n</section>`;
@@ -622,7 +649,9 @@ if (process.argv[1] && basename(process.argv[1]) === 
"render.js") {
   const outputPath = process.argv[3] || inputPath.replace(/\.json$/, ".html");
   const { readFileSync, writeFileSync } = await import("node:fs");
   const evidence = JSON.parse(readFileSync(inputPath, "utf-8"));
-  const html = render(evidence);
+  const warnings = [];
+  const html = render(evidence, { warnings });
   writeFileSync(outputPath, html, "utf-8");
   console.log(`Report written: ${outputPath}`);
+  for (const w of warnings) console.error(`[render] unresolved code ref — 
${w}`);
 }
diff --git a/.skills/tinker-review/scripts/renderer/template.html 
b/.skills/tinker-review/scripts/renderer/template.html
index 53901292c2..0e2a6da0e6 100644
--- a/.skills/tinker-review/scripts/renderer/template.html
+++ b/.skills/tinker-review/scripts/renderer/template.html
@@ -51,6 +51,12 @@ code { background: var(--bg-alt); padding: 0.1rem 0.4rem; 
border-radius: 3px; fo
 pre { background: var(--bg-alt); border: 1px solid var(--border); 
border-radius: var(--radius); padding: 0.75rem; overflow-x: auto; font-size: 
0.8rem; line-height: 1.5; margin: 0.75rem 0; }
 pre code { background: none; padding: 0; }
 .walk-number { display: inline-flex; align-items: center; justify-content: 
center; width: 1.75rem; height: 1.75rem; border-radius: 50%; background: 
var(--primary); color: #fff; font-size: 0.8rem; font-weight: 700; margin-right: 
0.5rem; }
+.walk-questions { margin: 0.5rem 0 0.25rem 1.1rem; padding: 0; } 
.walk-questions li { margin-bottom: 0.6rem; }
+.code-refs { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.3rem; 
}
+.walk-refs { margin-top: 0.6rem; } .walk-refs::before { content: "Also see"; 
font-size: 0.75rem; color: var(--text-muted); align-self: center; margin-right: 
0.15rem; }
+.code-ref { display: inline-block; padding: 0.1rem 0.5rem; border: 1px solid 
var(--primary); border-radius: 3px; font-family: ui-monospace, SFMono-Regular, 
Menlo, monospace; font-size: 0.75rem; color: var(--primary); text-decoration: 
none; }
+.code-ref::after { content: " \2197"; } .code-ref:hover { background: 
var(--primary); color: #fff; }
+.code-ref-unresolved { border-color: var(--border); color: var(--text-muted); 
} .code-ref-unresolved::after { content: " ?"; }
 .finding { border-left: 3px solid var(--warning); } .finding-title { 
font-weight: 600; margin-bottom: 0.25rem; }
 .test-pass { color: var(--success); font-weight: 600; } .test-fail { color: 
var(--danger); font-weight: 600; }
 .test-grid { display: grid; grid-template-columns: auto 1fr auto; gap: 0.25rem 
0.75rem; font-size: 0.875rem; align-items: baseline; }
diff --git a/.skills/tinker-review/scripts/review.js 
b/.skills/tinker-review/scripts/review.js
index 3aa600785a..4ac6f24c4c 100644
--- a/.skills/tinker-review/scripts/review.js
+++ b/.skills/tinker-review/scripts/review.js
@@ -39,6 +39,7 @@ import { confidenceAudit } from 
"./patterns/confidence-audit.js";
 import { classifyExternals } from "./patterns/classify-externals.js";
 import { findRemovalRefs } from "./patterns/removal-refs.js";
 import { orphans } from "./patterns/orphans.js";
+import { buildCodeIndex } from "./extraction/code-index.js";
 import { createPrDiscussion } from "./enrichment/api.js";
 import { discoverDiscussions } from "./discovery/discussions.js";
 
@@ -441,9 +442,14 @@ export async function phase1(session) {
   const architectureResult = await architecture(g, { clusterResult, 
changedOnly: true });
   log(`  architecture: ${architectureResult.nodes.length} nodes, 
${architectureResult.edges.length} edges`);
 
+  // pin code links in the report to the exact commit reviewed, so they don't 
drift when the PR is pushed again
+  const { stdout: headShaOut } = await exec("git", ["rev-parse", prBranch], { 
cwd: repoPath }).catch(() => ({ stdout: "" }));
+  const codeIndex = buildCodeIndex({ changedFiles, extraction, diffText });
+
   const evidence = {
     meta: {
       pr,
+      headSha: headShaOut.trim() || null,
       title: prTitle.trim(),
       domains,
       language,
@@ -467,6 +473,7 @@ export async function phase1(session) {
     },
     discussions,
     changedFiles,
+    codeIndex,
   };
 
   const jsonPath = join(workDir, "evidence.json");
diff --git a/.skills/tinker-review/test/code-links.test.js 
b/.skills/tinker-review/test/code-links.test.js
new file mode 100644
index 0000000000..6993790562
--- /dev/null
+++ b/.skills/tinker-review/test/code-links.test.js
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+
+import { parseHunks, buildCodeIndex } from 
"../scripts/extraction/code-index.js";
+import { resolveRef, diffAnchor } from "../scripts/renderer/code-links.js";
+import { render } from "../scripts/renderer/render.js";
+
+const TX = "tinkergraph-gremlin/src/main/java/org/x/TinkerTransaction.java";
+const GRAPH = 
"tinkergraph-gremlin/src/main/java/org/x/TinkerStorageGraph.java";
+const OTHER_GRAPH = "gremlin-core/src/main/java/org/y/TinkerStorageGraph.java";
+
+const DIFF = [
+  `diff --git a/${TX} b/${TX}`,
+  `--- a/${TX}`,
+  `+++ b/${TX}`,
+  "@@ -174,3 +181,40 @@ class X {",
+  "@@ -300 +340 @@",
+  "@@ -400,5 +379,0 @@",
+  `diff --git a/${GRAPH} b/${GRAPH}`,
+  "--- /dev/null",
+  `+++ b/${GRAPH}`,
+  "@@ -0,0 +1,120 @@",
+  "diff --git a/gone.java b/gone.java",
+  "--- a/gone.java",
+  "+++ /dev/null",
+  "@@ -1,10 +0,0 @@",
+].join("\n");
+
+const extraction = {
+  types: [
+    { name: "TinkerTransaction", filePath: TX, linesStart: 30, linesEnd: 400 },
+    { name: "TinkerStorageGraph", filePath: GRAPH, linesStart: 10, linesEnd: 
120 },
+    { name: "Neighbor", filePath: "not/changed/Neighbor.java", linesStart: 1, 
linesEnd: 9 },
+  ],
+  functions: [
+    { name: "doCommit", filePath: TX, linesStart: 147, linesEnd: 245 },
+    { name: "doRollback", filePath: TX, linesStart: 290, linesEnd: 320 },
+    { name: "TinkerStorageGraph", filePath: GRAPH, linesStart: 40, linesEnd: 
60 },
+  ],
+};
+
+const codeIndex = buildCodeIndex({ changedFiles: [TX, GRAPH, "gone.java"], 
extraction, diffText: DIFF });
+const ctx = { pr: 3639, headSha: "abc123", codeIndex };
+
+test("parseHunks keeps new-side ranges, marks pure deletions at the line they 
follow, and drops deleted files", () => {
+  const h = parseHunks(DIFF);
+  assert.deepEqual(h[TX], [[181, 220], [340, 340], [379, 379]]);
+  assert.deepEqual(h[GRAPH], [[1, 120]]);
+  assert.equal(h["gone.java"], undefined);
+});
+
+test("buildCodeIndex indexes only changed files, with sorted symbols", () => {
+  assert.deepEqual(Object.keys(codeIndex.files).sort(), [GRAPH, TX].sort());
+  assert.deepEqual(codeIndex.files[TX].symbols.map((s) => s.name), 
["TinkerTransaction", "doCommit", "doRollback"]);
+  assert.equal(codeIndex.files[TX].symbols[0].kind, "type");
+});
+
+test("diffAnchor matches GitHub's sha256-of-path anchor", () => {
+  assert.equal(diffAnchor(TX), "diff-" + 
createHash("sha256").update(TX).digest("hex"));
+});
+
+test("a symbol links to the file pinned at the reviewed commit with its lines 
highlighted", () => {
+  const r = resolveRef({ file: "TinkerTransaction.java", symbol: "doCommit" }, 
ctx);
+  assert.equal(r.target, "blob");
+  assert.equal(r.url, 
`https://github.com/apache/tinkerpop/blob/abc123/${TX}#L147-L245`);
+  assert.equal(r.label, "doCommit");
+  assert.match(r.title, /changed at 181/);
+  const unchanged = resolveRef({ file: TX, symbol: "doRollback", label: 
"rollback" }, ctx);
+  assert.equal(unchanged.url, 
`https://github.com/apache/tinkerpop/blob/abc123/${TX}#L290-L320`);
+  assert.equal(unchanged.label, "rollback");
+  assert.match(unchanged.title, /unchanged/);
+});
+
+test("without a recorded head commit links fall back to the PR diff", () => {
+  const noSha = { ...ctx, headSha: null };
+  const changed = resolveRef({ file: TX, symbol: "doCommit" }, noSha);
+  assert.equal(changed.target, "diff");
+  assert.equal(changed.url, 
`https://github.com/apache/tinkerpop/pull/3639/files#${diffAnchor(TX)}R181-R220`);
+  const unchanged = resolveRef({ file: TX, symbol: "doRollback" }, noSha);
+  assert.equal(unchanged.target, "file");
+  assert.equal(unchanged.url, 
`https://github.com/apache/tinkerpop/pull/3639/files#${diffAnchor(TX)}`);
+});
+
+test("a bare name means the type; Type.member picks the member inside it", () 
=> {
+  const type = resolveRef({ file: GRAPH, symbol: "TinkerStorageGraph" }, ctx);
+  assert.match(type.url, /#L10-L120$/);
+  const ctor = resolveRef({ file: GRAPH, symbol: 
"TinkerStorageGraph.TinkerStorageGraph" }, ctx);
+  assert.match(ctor.url, /#L40-L60$/);
+});
+
+test("a symbol whose only change is a removal links into the diff at the 
removal when no commit is recorded", () => {
+  const index = buildCodeIndex({
+    changedFiles: ["A.java"],
+    extraction: { types: [], functions: [{ name: "ctor", filePath: "A.java", 
linesStart: 89, linesEnd: 107 }] },
+    diffText: "+++ b/A.java\n@@ -96,9 +95,0 @@",
+  });
+  const r = resolveRef({ file: "A.java", symbol: "ctor" }, { pr: 1, headSha: 
null, codeIndex: index });
+  assert.equal(r.target, "diff");
+  assert.match(r.url, /R95$/);
+});
+
+test("explicit lines and file-only references resolve", () => {
+  assert.match(resolveRef({ file: TX, lines: [340, 345] }, ctx).url, 
/#L340-L345$/);
+  const whole = resolveRef({ file: GRAPH }, ctx);
+  assert.equal(whole.target, "file");
+  assert.equal(whole.url, 
`https://github.com/apache/tinkerpop/blob/abc123/${GRAPH}`);
+  assert.equal(whole.label, "TinkerStorageGraph.java");
+});
+
+test("unknown, ambiguous and symbol-less references come back unresolved with 
a reason", () => {
+  assert.match(resolveRef({ file: "Nope.java" }, ctx).title, /no changed 
file/);
+  assert.match(resolveRef({ file: TX, symbol: "missing" }, ctx).title, /no 
symbol "missing"/);
+  const ambiguous = { pr: 1, codeIndex: { files: { [GRAPH]: { hunks: [], 
symbols: [] }, [OTHER_GRAPH]: { hunks: [], symbols: [] } } } };
+  const r = resolveRef({ file: "TinkerStorageGraph.java" }, ambiguous);
+  assert.equal(r.url, null);
+  assert.match(r.title, /matches 2 changed files/);
+});
+
+test("the guided walk renders questions with chips and reports unresolved 
refs", () => {
+  const warnings = [];
+  const html = render({
+    meta: { pr: 3639, headSha: "abc123", title: "t", domains: [], timestamp: 
"now" },
+    codeIndex,
+    guidedWalk: [
+      {
+        title: "Commit path", badge: "attention", badgeText: "defect",
+        intro: "<p>What a commit does now.</p>",
+        questions: [{ text: "Is the index restored?", refs: [{ file: TX, 
symbol: "doCommit" }, { file: TX, symbol: "ghost" }] }],
+        refs: [{ file: GRAPH }],
+      },
+      { title: "Legacy", badge: "info", body: "<p>Old-style body still 
renders.</p>" },
+    ],
+  }, { warnings });
+  assert.match(html, /<ul class="walk-questions">/);
+  assert.match(html, /class="code-ref code-ref-blob" 
href="https:\/\/github\.com\/apache\/tinkerpop\/blob\/abc123\/[^"]*TinkerTransaction\.java#L147-L245"/);
+  assert.match(html, /class="code-ref code-ref-unresolved"[^>]*>ghost</);
+  assert.match(html, /class="code-refs walk-refs"/);
+  assert.match(html, /Old-style body still renders/);
+  assert.equal(warnings.length, 1);
+  assert.match(warnings[0], /guidedWalk\[0\]\.questions\[0\]: unresolved: no 
symbol "ghost"/);
+});

Reply via email to