You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tinkerpop.apache.org by sp...@apache.org on 2026/06/16 11:59:52 UTC
(tinkerpop) branch master updated: Add graph-based PR review skill (tinker-review)
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
The following commit(s) were added to refs/heads/master by this push:
new 01efb1e207 Add graph-based PR review skill (tinker-review)
01efb1e207 is described below
commit 01efb1e2074f0d656f0aad90436a6739c7f513f9
Author: Stephen Mallette <st...@amazon.com>
AuthorDate: Wed Jun 10 20:52:14 2026 +0000
Add graph-based PR review skill (tinker-review)
Introduces the tinker-review skill, which reviews TinkerPop PRs by
building a code knowledge graph from PR source, enriching it with
domain playbooks, running structural analyses, and producing an HTML
evidence package.
Pipeline:
- Extraction: tree-sitter parses PR sources into vertices/edges
- Graph: populate a Gremlin graph (Docker-backed), with an OLAP
source for connectedComponent()-based cluster analysis
- Enrichment: CLI + session files so agents can add semantic
relationships; multi-domain classification per PR
- Patterns: centrality, blast-radius, clusters, architecture,
completeness, coverage-gaps, and orphan analysis
- Discovery: pull related JIRA, dev-list, and PR discussions
- Rendering: deterministic HTML report generated from evidence,
with PR title/body fetched from the GitHub API
Lifecycle is split into setup/phase1/teardown, driven from
session.json. Playbooks cover bug-fix, driver-server, glv, grammar,
new-step, and general PRs. Adds ASF license headers and gitignores
generated reports.
Also renames the tinkerpop-dev skill to tinker-dev and updates
bin/agent-setup.sh accordingly.
---
.skills/{tinkerpop-dev => tinker-dev}/SKILL.md | 2 +-
.../references/beads-workflow.md | 0
.../references/build-dotnet.md | 0
.../references/build-go.md | 0
.../references/build-java.md | 0
.../references/build-javascript.md | 0
.../references/build-python.md | 0
.../references/dev-environment-setup.md | 0
.../references/documentation.md | 0
.../references/gremlin-mcp.md | 0
.../scripts/check-env.sh | 0
.skills/tinker-review/.gitignore | 2 +
.skills/tinker-review/SKILL.md | 194 +++++++
.skills/tinker-review/package-lock.json | 240 +++++++++
.skills/tinker-review/package.json | 19 +
.skills/tinker-review/playbooks/bug-fix.md | 66 +++
.skills/tinker-review/playbooks/driver-server.md | 86 +++
.skills/tinker-review/playbooks/general.md | 71 +++
.skills/tinker-review/playbooks/glv.md | 74 +++
.skills/tinker-review/playbooks/grammar.md | 56 ++
.skills/tinker-review/playbooks/new-step.md | 68 +++
.skills/tinker-review/references/interfaces.md | 377 +++++++++++++
.skills/tinker-review/references/schema.md | 105 ++++
.../tinker-review/scripts/discovery/discussions.js | 280 ++++++++++
.skills/tinker-review/scripts/enrichment/api.js | 168 ++++++
.skills/tinker-review/scripts/enrichment/cli.js | 156 ++++++
.../scripts/extraction/tree-sitter.js | 592 +++++++++++++++++++++
.../scripts/graph/populate-discussions.js | 279 ++++++++++
.skills/tinker-review/scripts/graph/populate.js | 170 ++++++
.../tinker-review/scripts/infrastructure/docker.js | 128 +++++
.../tinker-review/scripts/patterns/architecture.js | 161 ++++++
.../tinker-review/scripts/patterns/blast-radius.js | 93 ++++
.../tinker-review/scripts/patterns/centrality.js | 100 ++++
.../scripts/patterns/cluster-analysis.js | 71 +++
.../tinker-review/scripts/patterns/completeness.js | 93 ++++
.../scripts/patterns/coverage-gaps.js | 69 +++
.skills/tinker-review/scripts/patterns/orphans.js | 74 +++
.skills/tinker-review/scripts/renderer/render.js | 422 +++++++++++++++
.../tinker-review/scripts/renderer/template.html | 73 +++
.skills/tinker-review/scripts/review.js | 406 ++++++++++++++
bin/agent-setup.sh | 84 +--
41 files changed, 4743 insertions(+), 36 deletions(-)
diff --git a/.skills/tinkerpop-dev/SKILL.md b/.skills/tinker-dev/SKILL.md
similarity index 99%
rename from .skills/tinkerpop-dev/SKILL.md
rename to .skills/tinker-dev/SKILL.md
index 3fc8100c2f..4336370bb2 100644
--- a/.skills/tinkerpop-dev/SKILL.md
+++ b/.skills/tinker-dev/SKILL.md
@@ -1,5 +1,5 @@
---
-name: tinkerpop-dev
+name: tinker-dev
description: >
Development guidance for the Apache TinkerPop monorepo. Use when building,
testing, or contributing to TinkerPop's graph computing framework and its
diff --git a/.skills/tinkerpop-dev/references/beads-workflow.md b/.skills/tinker-dev/references/beads-workflow.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/beads-workflow.md
rename to .skills/tinker-dev/references/beads-workflow.md
diff --git a/.skills/tinkerpop-dev/references/build-dotnet.md b/.skills/tinker-dev/references/build-dotnet.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/build-dotnet.md
rename to .skills/tinker-dev/references/build-dotnet.md
diff --git a/.skills/tinkerpop-dev/references/build-go.md b/.skills/tinker-dev/references/build-go.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/build-go.md
rename to .skills/tinker-dev/references/build-go.md
diff --git a/.skills/tinkerpop-dev/references/build-java.md b/.skills/tinker-dev/references/build-java.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/build-java.md
rename to .skills/tinker-dev/references/build-java.md
diff --git a/.skills/tinkerpop-dev/references/build-javascript.md b/.skills/tinker-dev/references/build-javascript.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/build-javascript.md
rename to .skills/tinker-dev/references/build-javascript.md
diff --git a/.skills/tinkerpop-dev/references/build-python.md b/.skills/tinker-dev/references/build-python.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/build-python.md
rename to .skills/tinker-dev/references/build-python.md
diff --git a/.skills/tinkerpop-dev/references/dev-environment-setup.md b/.skills/tinker-dev/references/dev-environment-setup.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/dev-environment-setup.md
rename to .skills/tinker-dev/references/dev-environment-setup.md
diff --git a/.skills/tinkerpop-dev/references/documentation.md b/.skills/tinker-dev/references/documentation.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/documentation.md
rename to .skills/tinker-dev/references/documentation.md
diff --git a/.skills/tinkerpop-dev/references/gremlin-mcp.md b/.skills/tinker-dev/references/gremlin-mcp.md
similarity index 100%
rename from .skills/tinkerpop-dev/references/gremlin-mcp.md
rename to .skills/tinker-dev/references/gremlin-mcp.md
diff --git a/.skills/tinkerpop-dev/scripts/check-env.sh b/.skills/tinker-dev/scripts/check-env.sh
similarity index 100%
rename from .skills/tinkerpop-dev/scripts/check-env.sh
rename to .skills/tinker-dev/scripts/check-env.sh
diff --git a/.skills/tinker-review/.gitignore b/.skills/tinker-review/.gitignore
new file mode 100644
index 0000000000..b1bd373ae5
--- /dev/null
+++ b/.skills/tinker-review/.gitignore
@@ -0,0 +1,2 @@
+pr-review-*.html
+pr-review-*.json
diff --git a/.skills/tinker-review/SKILL.md b/.skills/tinker-review/SKILL.md
new file mode 100644
index 0000000000..084785509d
--- /dev/null
+++ b/.skills/tinker-review/SKILL.md
@@ -0,0 +1,194 @@
+---
+name: tinker-review
+description: >
+ Graph-based PR review for Apache TinkerPop. Builds a knowledge graph from
+ PR source code, enriches it with semantic relationships guided by domain
+ playbooks, runs structural analysis, and produces an HTML evidence package.
+ Use when asked to review a TinkerPop PR by number.
+license: Apache-2.0
+compatibility: Requires Docker, Node.js 20+, git. Network access for fetching PR refs.
+metadata:
+ version: "0.1.0"
+ project: Apache TinkerPop
+---
+
+# Graph Review Skill
+
+## Prerequisites
+
+- Docker running (for Gremlin Server)
+- `upstream` remote pointing to `git@github.com:apache/tinkerpop.git` (fetch only)
+- Node.js 20+ with dependencies installed in `.skills/tinker-review/`
+
+
+## References (load on demand)
+
+- Read [references/schema.md](references/schema.md) when you need to understand what vertices, edges, or properties exist in the knowledge graph (typically during enrichment or when writing raw Gremlin)
+- Read [references/interfaces.md](references/interfaces.md) when you need the exact function signatures or data type definitions for a module
+
+## Execution Sequence
+
+When invoked with `/review <pr-number>`:
+
+### 1. Setup + Phase 1 (deterministic)
+
+Run the review script. This handles everything mechanical:
+
+```bash
+npm install --prefix .skills/tinker-review # only needed once
+node .skills/tinker-review/scripts/review.js <pr-number> <repo-path>
+```
+
+This performs: fetch PR → create worktree → start Gremlin Server → extract
+structure via Tree-sitter → populate knowledge graph → discover discussions
+(JIRA, dev list, proposals, PR comments) → run pattern checks (completeness,
+coverage gaps, centrality, blast radius, cluster analysis) → write evidence JSON.
+
+**Output:** `/tmp/pr-review-<pr>/evidence.json`
+**Server:** remains running (the agent needs it for enrichment)
+**Worktree:** available at `/tmp/pr-review-<pr>/src/`
+
+If re-running, the script cleans up stale worktrees/branches automatically.
+
+### 2. Classify and Load Playbooks
+
+**Always load** `playbooks/general.md` — it applies to every PR.
+
+Then determine which domain-specific playbooks apply from changed file paths:
+- `gremlin-dart/`, `gremlin-go/`, `gremlin-python/`, `gremlin-dotnet/`, `gremlin-js/` → `playbooks/glv.md`
+- `gremlin-core/` with new step patterns → `playbooks/new-step.md`
+- `gremlin-driver/`, `gremlin-server/`, `gremlin-util/` → `playbooks/driver-server.md`
+- Small change set with linked issue → `playbooks/bug-fix.md`
+- `gremlin-language/` or `*.g4` → `playbooks/grammar.md`
+
+Load ALL matching playbooks. Execute enrichment for each in sequence.
+
+### 3. Phase 2 — Enrichment (agent-driven)
+
+The Gremlin Server is still running. Use `scripts/enrichment/cli.js` to
+read from and write to the knowledge graph. Do NOT write your own connection
+code or import gremlin-js directly — the CLI handles connections for you.
+
+**Usage:**
+```bash
+node scripts/enrichment/cli.js <command> --workDir /tmp/pr-review-<pr> [--options...]
+```
+
+**Read commands:**
+```bash
+node scripts/enrichment/cli.js listFunctions --workDir /tmp/pr-review-<pr> --changed true --visibility public
+node scripts/enrichment/cli.js listTypes --workDir /tmp/pr-review-<pr> --kind class
+node scripts/enrichment/cli.js getCallsFrom --workDir /tmp/pr-review-<pr> --function <name> --file <path>
+node scripts/enrichment/cli.js getCanonicalSteps --workDir /tmp/pr-review-<pr>
+```
+
+**Write commands:**
+```bash
+node scripts/enrichment/cli.js mapStep --workDir /tmp/pr-review-<pr> --function <name> --file <path> --step <canonicalName>
+node scripts/enrichment/cli.js linkDiscussion --workDir /tmp/pr-review-<pr> --url <url> --source jira --title <title>
+node scripts/enrichment/cli.js linkDoc --workDir /tmp/pr-review-<pr> --entity Step --name <name> --doc <path>
+node scripts/enrichment/cli.js addGrammarRule --workDir /tmp/pr-review-<pr> --name <name>
+node scripts/enrichment/cli.js annotate --workDir /tmp/pr-review-<pr> --label Function --name <name> --key <key> --value <value>
+```
+
+Each command connects, executes, prints JSON result to stdout, and disconnects.
+The connection info is read from `/tmp/pr-review-<pr>/session.json` automatically.
+
+**Read source files:** worktree at `/tmp/pr-review-<pr>/src/`
+
+Follow the playbook's Enrich section. Check Escape conditions.
+
+### 4. Functional Testing (subagent)
+
+**IMPORTANT: Functional testing is NOT running the project's unit tests.**
+DO NOT use `mvn test`. DO NOT run TreeTest, DO NOT run any existing test class.
+The project's own tests are the author's responsibility — running them tells
+the reviewer nothing new.
+
+Functional testing means: test the feature AS A USER WOULD. Connect to a
+running Gremlin Server and submit traversals. Try to use the feature based
+only on what the documentation says. Try to break it with adversarial inputs.
+
+**Build the full project:**
+```bash
+git worktree add /tmp/pr-review-<pr>/build pr-review/<pr>
+mvn -f /tmp/pr-review-<pr>/build/pom.xml clean install -DskipTests
+```
+
+**Start Gremlin Server from built artifacts** on a random port (different from
+the knowledge graph server). Use the built assembly at:
+`/tmp/pr-review-<pr>/build/gremlin-server/target/apache-tinkerpop-gremlin-server-*-standalone/`
+
+**Test from the outside.** Spawn a subagent that receives ONLY:
+- PR title/description (what the change claims to do)
+- Relevant documentation sections
+- Relevant Gherkin test features (for expected behavior reference)
+- The Gremlin Server URL
+
+The subagent does NOT get: source code, the knowledge graph, code review findings,
+or access to the analysis worktree. It tests blind — like a user who read the docs.
+
+**The subagent tests by submitting Gremlin traversals** via the Gremlin Console
+or a GLV client. It devises its own test plan from the docs, executes traversals,
+and attempts adversarial edge cases.
+
+**Layer decision:**
+- **Layer 1 (embedded):** Gremlin Console with TinkerGraph. For core logic changes.
+- **Layer 2 (per-GLV wire):** Connect from each GLV to the server. For
+ serialization/type changes. Skip if purely computational.
+
+The subagent returns: test plan, results, adversarial findings, exact test code.
+
+### 5. Produce Report
+
+Write the narrative JSON, then render.
+
+**Step A:** Read `/tmp/pr-review-<pr>/evidence.json`. Add narrative fields to
+produce a complete evidence-with-narrative JSON file. Write it to
+`/tmp/pr-review-<pr>/report.json`. The narrative fields you must provide:
+
+- `summary` — HTML paragraph describing the PR
+- `clusters.assessment` — HTML prose about what the clusters mean
+- `guidedWalk` — array of `{ title, badge, badgeText, body }` objects
+- `findings` — array of `{ title, snippet, body }` objects
+- `openQuestions` — array of `{ title, body, meta }` objects
+- `functionalTest` — `{ plan, results: [{name, pass, output}], observations }` (if testing was done)
+- `appendixFunctional` — `{ environment, testCode, fullOutput }` (if testing was done)
+
+All `body` fields are HTML. Use `<code>`, `<strong>`, `<ul>`, `<p>` as needed.
+The renderer handles all layout, CSS, and structure.
+
+**Step B:** Render the report:
+
+```bash
+node .skills/tinker-review/scripts/renderer/render.js /tmp/pr-review-<pr>/report.json /tmp/pr-review-<pr>/report.html
+```
+
+The renderer produces a complete HTML report with consistent structure.
+Every section is always present — if you didn't provide a field, it shows
+"Section not provided" in red. DO NOT write HTML yourself.
+
+### 6. Teardown
+
+```bash
+node -e "import { teardown } from './scripts/review.js'; await teardown('/tmp/pr-review-<pr>');"
+```
+
+Or simply stop the Docker container and clean up manually:
+```bash
+docker stop $(cat /tmp/pr-review-<pr>/session.json | python3 -c "import json,sys; print(json.load(sys.stdin)['containerId'])")
+rm -rf /tmp/pr-review-<pr>
+git worktree prune
+git branch -D pr-review/<pr>
+```
+
+This stops the knowledge graph server, removes worktrees, deletes the branch.
+Call this ONLY after all phases are complete.
+
+## Important Notes
+
+- NEVER push to the `upstream` remote. It is fetch-only.
+- ALL output goes to `/tmp/pr-review-<pr>/` — never write inside the git repo.
+- The Gremlin Server stays alive until teardown. Don't kill it early.
+- If re-running, the script auto-cleans stale state.
+- The agent populates JSON narrative fields; the renderer produces HTML. No agent writes raw HTML.
diff --git a/.skills/tinker-review/package-lock.json b/.skills/tinker-review/package-lock.json
new file mode 100644
index 0000000000..9a6c7781d3
--- /dev/null
+++ b/.skills/tinker-review/package-lock.json
@@ -0,0 +1,240 @@
+{
+ "name": "@tinkerpop/graph-review",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@tinkerpop/graph-review",
+ "version": "0.1.0",
+ "dependencies": {
+ "gremlin": "^3.8.1",
+ "tree-sitter-wasms": "^0.1.13",
+ "web-tree-sitter": "^0.24.7"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "license": "MIT",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/gremlin": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/gremlin/-/gremlin-3.8.1.tgz",
+ "integrity": "sha512-ft0R47ugLkyQys7v3qbi0kUkeSe8VY2HUj4YqwKuxC2OYxQJtFMuSNGCyXpAND4vA34PJLJOMo0vTCEqwinVTg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "buffer": "^6.0.3",
+ "eventemitter3": "^5.0.1",
+ "readable-stream": "^4.5.2",
+ "uuid": "^9.0.1",
+ "ws": "^8.16.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/tree-sitter-wasms": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz",
+ "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==",
+ "license": "Unlicense",
+ "dependencies": {
+ "tree-sitter-wasms": "^0.1.11"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
+ "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/web-tree-sitter": {
+ "version": "0.24.7",
+ "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.7.tgz",
+ "integrity": "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==",
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/.skills/tinker-review/package.json b/.skills/tinker-review/package.json
new file mode 100644
index 0000000000..27dd211819
--- /dev/null
+++ b/.skills/tinker-review/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@tinkerpop/graph-review",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Graph-based PR review skill for Apache TinkerPop",
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "scripts": {
+ "test": "node --test test/",
+ "review": "node review.js"
+ },
+ "dependencies": {
+ "gremlin": "^3.8.1",
+ "web-tree-sitter": "^0.24.7",
+ "tree-sitter-wasms": "^0.1.13"
+ }
+}
diff --git a/.skills/tinker-review/playbooks/bug-fix.md b/.skills/tinker-review/playbooks/bug-fix.md
new file mode 100644
index 0000000000..df634511ed
--- /dev/null
+++ b/.skills/tinker-review/playbooks/bug-fix.md
@@ -0,0 +1,66 @@
+<!--
+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.
+-->
+
+# Playbook: Bug Fix
+
+## Context
+A fix for a reported issue. Should be minimal, targeted, include a
+regression test, and not introduce new API surface. The fix should
+address the root cause, not just the symptom.
+
+## Enrich
+Identify the fix location — which functions were modified to address
+the bug. Trace from the PR title/description to understand the reported
+symptom. Check if the regression test actually reproduces that symptom.
+
+Look for:
+- Changes outside the issue's scope — modifications to functions not
+ related to the reported bug may indicate scope creep
+- Error messages: are they meaningful to users, not just developers?
+ (A common reviewer concern at TinkerPop)
+- Log level changes: error should remain error for unexpected failures,
+ don't downgrade to info without justification
+- Resource cleanup on error paths: if the bug involves connection/channel
+ handling, verify resources aren't leaked when the fix triggers
+
+If the PR references a JIRA ticket (TINKERPOP-XXXX), link it as a discussion.
+
+## Checks
+- completeness(pr, ["addresses"])
+- coverage_gaps(pr.tests(), pr.modified())
+- blast_radius(pr.modified(), 3)
+- high_centrality(pr.modified())
+
+## Interpret
+High blast radius on a bug fix is a warning signal — the fix touches
+something many callers depend on. This doesn't mean it's wrong, but it
+means the reviewer should verify the fix doesn't subtly change behavior
+for existing callers.
+
+Changes outside the issue scope aren't automatically bad — sometimes
+fixing a bug requires touching adjacent code. But they should be
+explainable. Flag them with "necessary for fix?" not "wrong."
+
+If high-centrality functions are modified, emphasize that the reviewer
+should check all callers for behavioral changes. A fix in a hot function
+can silently break things far from the fix site.
+
+## Escape
+- if no linked issue — "Cannot assess whether fix is correct without knowing the bug"
+- if public API signature changed — "Bug fix changes public API — needs broader discussion before merge"
diff --git a/.skills/tinker-review/playbooks/driver-server.md b/.skills/tinker-review/playbooks/driver-server.md
new file mode 100644
index 0000000000..3a599dc23d
--- /dev/null
+++ b/.skills/tinker-review/playbooks/driver-server.md
@@ -0,0 +1,86 @@
+<!--
+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.
+-->
+
+# Playbook: Driver / Server / Serialization
+
+## Context
+Changes to gremlin-driver, gremlin-server, or gremlin-util. These modules
+handle connections, protocol, serialization, and request lifecycle. Changes
+here affect all users and all GLV drivers. Correctness under concurrency
+and backwards compatibility are critical concerns.
+
+## Enrich
+Identify what layer of the driver/server stack is being modified:
+- Connection lifecycle (pooling, creation, cleanup)
+- Protocol handling (HTTP, WebSocket, request/response framing)
+- Serialization (GraphBinary, GraphSON, type registration)
+- Server initialization (configuration, script engines)
+- Authentication/authorization
+
+For each layer, look for:
+
+**Connection management:**
+- Are HttpClient/WebSocket instances shared or created per-connection?
+ (Shared is correct for pooling; per-connection defeats the pool)
+- Could pool size = 1 cause deadlock?
+- Data structure choices for connection tracking: CopyOnWriteArraySet has
+ write overhead, ConcurrentLinkedQueue is better for frequent insert/remove
+- Are settings kept as a cohesive object or extracted into individual fields?
+ (Prefer the object — individual fields drift from the source of truth)
+
+**Serialization:**
+- De-bulking: should happen lazily at traversal iteration, not eagerly when
+ responses arrive (eager de-bulking puts all objects in memory at once)
+- Removed type IDs: if a serializer type code is removed, leave a comment
+ documenting what it was ("122 was Bytecode until removed in 4.x")
+- Error response fallback: GraphBinary errors may come back as JSON if the
+ server can't serialize the error in binary
+- Numeric types: longs need 'L' suffix in GremlinLang text format
+
+**API migration:**
+- New code must use GremlinLang, not Bytecode (removed in 4.x)
+- New code must use with_(), not withRemote (deprecated)
+- Server configuration: gremlin-lang expressions, not Groovy scripts
+- Don't leave commented-out old code — remove it cleanly
+
+## Checks
+- blast_radius(pr.modified(), 3)
+- high_centrality(pr.modified())
+- coverage_gaps(pr.tests(), pr.modified())
+- orphans("Function", "tests", { changedOnly: true })
+
+## Interpret
+Driver/server changes have inherently high blast radius — they're shared
+infrastructure. Don't flag blast radius as surprising, but DO highlight
+which specific callers are most affected.
+
+Connection pooling and concurrency code should have explicit test coverage.
+If coverage gaps exist in connection lifecycle code, flag prominently —
+these are the hardest bugs to reproduce and the most impactful in production.
+
+Serialization changes that add/remove type codes need upgrade documentation.
+Check if the PR includes corresponding upgrade doc entries.
+
+For "good enough for now" patterns (strategy handling, migration scaffolding),
+note them as acceptable if they're clearly marked as temporary, but flag if
+they look like they'll become permanent debt.
+
+## Escape
+- if connection pool logic modified without concurrency tests — "Pool changes need concurrency testing — flag for manual review of deadlock/race conditions"
+- if serialization type codes added/removed without IO doc update — "Serialization changes need IO documentation updates"
diff --git a/.skills/tinker-review/playbooks/general.md b/.skills/tinker-review/playbooks/general.md
new file mode 100644
index 0000000000..d8c41abf5f
--- /dev/null
+++ b/.skills/tinker-review/playbooks/general.md
@@ -0,0 +1,71 @@
+<!--
+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.
+-->
+
+# Playbook: General (applies to all PRs)
+
+## Context
+These are concerns TinkerPop reviewers consistently raise regardless of
+the type of change. This playbook always applies in addition to any
+domain-specific playbook.
+
+## Enrich
+Look for these patterns in the changed code and annotate them:
+
+**Style violations:**
+- Wildcard imports in Java (import foo.*)
+- Formatting/indentation changes mixed with functional changes
+- Unused variables or imports
+- Non-final variables that should be final
+
+**Deprecated API usage:**
+- Use of withRemote (deprecated in 4.0, use with_())
+- Groovy script strings where gremlin-lang should be used
+- Any API marked @Deprecated being used in new code
+
+**Test concerns:**
+- Tests that drop/clear all data instead of isolating with specialized labels
+- Assertions that don't clearly explain what they verify
+- Error/exception paths that aren't tested
+- Test helpers without guard clauses (missing else/throw for invalid input)
+
+**Resource safety:**
+- Connections, channels, or streams opened without clear cleanup paths
+- Log levels: error for unexpected failures, info for expected lifecycle events
+- Data structures with concurrency implications (note if CopyOnWriteArraySet,
+ synchronized collections, etc. are introduced without profiling justification)
+
+## Checks
+- coverage_gaps(pr.tests(), pr.modified())
+- orphans("Function", "tests", { changedOnly: true })
+
+## Interpret
+Style nits and unused variables are low severity — note them but don't
+make them the focus of the report. Prioritize safety concerns (resource
+leaks, concurrency risks, missing error handling) and test quality issues.
+
+Formatting changes mixed with functional changes are worth flagging
+prominently — they make the PR harder to review and should ideally be
+separate commits.
+
+Deprecated API usage in NEW code is always a concern. Deprecated usage
+in MODIFIED code (that was already there) is lower priority unless the
+PR is specifically about migrating away from the deprecated API.
+
+## Escape
+None — this playbook always completes. No conditions warrant stopping.
diff --git a/.skills/tinker-review/playbooks/glv.md b/.skills/tinker-review/playbooks/glv.md
new file mode 100644
index 0000000000..9bac8c8ee8
--- /dev/null
+++ b/.skills/tinker-review/playbooks/glv.md
@@ -0,0 +1,74 @@
+<!--
+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.
+-->
+
+# Playbook: GLV Implementation
+
+## Context
+A new or updated Gremlin Language Variant. Implements the full traversal
+API in a host language. Must pass the Gremlin test suite. Should be
+idiomatic for the target language, not a mechanical translation. The
+reference GLV for structural comparison is typically gremlin-go (most
+recent accepted GLV).
+
+## Enrich
+Map public methods in the GLV to their canonical Gremlin steps. Only map
+methods that are actual traversal step implementations — the methods a user
+calls to build a traversal. Do NOT map inherited language boilerplate
+(toString, hashCode, equals, clone, close, etc.) or internal helper methods
+(getLocalChildren, setTraversal, getRequirements, etc.) to steps.
+
+A step implementation is typically:
+- A method on a traversal class that returns the traversal (fluent API)
+- Named to match the canonical step (cased per language convention)
+- Part of the public traversal DSL, not internal plumbing
+
+In Java specifically, the step *class* (e.g., TreeStep) contains internal
+methods — only the method on GraphTraversal/GraphTraversalSource that users
+call (e.g., `tree()`) should map to the step. In a GLV, the equivalent is
+the method on the traversal DSL class.
+
+If the PR references a JIRA ticket (TINKERPOP-XXXX), link it as a discussion.
+
+For the driver layer, identify connection acquisition and release points.
+Trace resource lifecycle through error paths — the common GLV bug is
+leaking connections when a traversal fails mid-execution.
+
+## Checks
+- completeness(glv, canonical_step_list())
+- coverage_gaps(pr.tests(), pr.modified())
+- high_centrality(pr.modified())
+
+## Interpret
+When reporting completeness gaps, distinguish between missing steps and
+steps that exist but use a language-specific name (e.g., Python uses
+`addV` but Go uses `AddV` — same step, different convention).
+
+When reporting divergence from the reference GLV, the question isn't
+"is it different?" — it's "is the difference justified by the host
+language?" A Go GLV using goroutines where Python uses asyncio is fine.
+A Go GLV using a different serialization format is a concern.
+
+Coverage gaps in a GLV are expected for driver internals (connection
+management, serialization) — but traversal step methods should have
+corresponding test coverage.
+
+## Escape
+- if not test_suite_passes(glv): stop("Cannot proceed — GLV must pass test suite first")
+- if not exists(reference_glv(language_family(glv))): escalate("Need human familiar with language")
+- if step_mapping_confidence < 0.7: escalate("Cannot reliably map methods to steps — need human verification of API surface")
diff --git a/.skills/tinker-review/playbooks/grammar.md b/.skills/tinker-review/playbooks/grammar.md
new file mode 100644
index 0000000000..3909b26c78
--- /dev/null
+++ b/.skills/tinker-review/playbooks/grammar.md
@@ -0,0 +1,56 @@
+<!--
+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.
+-->
+
+# Playbook: Grammar Change
+
+## Context
+A change to Gremlin.g4 or related language infrastructure. Grammar changes
+are inherently high-risk — they affect all parsers, all GLVs, and all
+downstream tooling. Backwards compatibility is critical.
+
+## Enrich
+Identify which grammar rules were added or modified. Check:
+- Is this adding new syntax or modifying existing syntax?
+- If modifying: could existing valid Gremlin become invalid?
+- Are all ANTLR targets updated? (Java, Python, Go parsers)
+- Is there a corresponding step implementation for new grammar rules?
+
+Link the proposal/discussion — grammar changes should always have prior
+community discussion.
+
+## Checks
+- completeness(grammarRule, ["in:has_rule"])
+- blast_radius(pr.modified(), 2)
+- high_centrality(pr.modified())
+
+## Interpret
+Grammar changes have outsized blast radius by nature — the grammar
+touches everything. Focus on whether the change is backwards compatible.
+
+A new rule that adds syntax (existing queries still work) is low risk.
+A modified rule that changes parsing of existing syntax is high risk and
+needs explicit backwards-compatibility analysis.
+
+Look for keywords being added — TinkerPop has specific handling for
+allowing keywords as map keys (#3091). New keywords can break existing
+queries that use them as identifiers.
+
+## Escape
+- if no proposal or dev-list discussion found — "Grammar changes require community consensus — flagging for discussion"
+- if existing grammar rules are modified (not just added) — "Potential backwards-incompatible change — needs explicit compatibility analysis"
diff --git a/.skills/tinker-review/playbooks/new-step.md b/.skills/tinker-review/playbooks/new-step.md
new file mode 100644
index 0000000000..4669bbd3f9
--- /dev/null
+++ b/.skills/tinker-review/playbooks/new-step.md
@@ -0,0 +1,68 @@
+<!--
+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.
+-->
+
+# Playbook: New Step
+
+## Context
+A new traversal step added to Gremlin. Must propagate across grammar,
+core implementation, all active GLVs, documentation, and test suite.
+The proposal (dev list or JIRA) is the source of truth for intended
+semantics.
+
+## Enrich
+Link the step's core implementation to its canonical name. The step
+class (e.g., TreeStep) contains internal methods — only the method on
+GraphTraversal/GraphTraversalSource that users call should map to the step.
+
+Check for:
+- A linked proposal (TINKERPOP-XXXX in title/description, or dev list thread)
+- Whether the step appears in Gremlin.g4 (grammar rule)
+- Whether GLV implementations exist for the step
+- Whether documentation references the step
+
+For API design concerns (mined from TinkerPop reviewer patterns):
+- Default implementations on interfaces need justification — if something
+ implements an interface, should it have a proper implementation?
+- Naming should be consistent with existing patterns (look at sibling steps)
+- Class design: wrapping + extending the same parent is suspicious
+- Type restrictions should not be too narrow (provider implementations vary)
+
+## Checks
+- completeness(step, ["in:implements_step", "out:has_rule", "in:covers", "in:documents", "out:proposed_in"])
+- coverage_gaps(pr.tests(), pr.modified())
+- high_centrality(pr.modified())
+- blast_radius(pr.modified(), 3)
+
+## Interpret
+A step missing from some GLVs is acceptable if tracked in a follow-up
+issue — check if the PR or linked JIRA mentions phased rollout. Missing
+documentation is not acceptable — a step without docs is undiscoverable.
+
+When comparing signatures across GLVs, parameter count should match but
+parameter types will differ by language. Focus on semantic equivalence,
+not syntactic identity.
+
+High centrality in step infrastructure (TraversalStrategy, Step interface
+implementations) is expected — these are shared abstractions. Flag it
+for attention but don't treat it as a problem.
+
+## Escape
+- if missing: proposal — "Cannot assess intent — need human to confirm expected semantics"
+- if touches: graph_computer — "GraphComputer interactions require deep architectural knowledge — flagging for senior review"
+- if step has no core_impl in PR — "Step concept referenced but no core implementation found — is this a GLV-only addition?"
diff --git a/.skills/tinker-review/references/interfaces.md b/.skills/tinker-review/references/interfaces.md
new file mode 100644
index 0000000000..f4d3031259
--- /dev/null
+++ b/.skills/tinker-review/references/interfaces.md
@@ -0,0 +1,377 @@
+<!--
+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.
+-->
+
+# Graph Review Skill — Module Interfaces
+
+Module contracts for the Phase 1 vertical slice. Each module is independently
+implementable against these signatures and data shapes.
+
+Design reference: `~/graph-review.md`
+
+---
+
+## Data Types
+
+```typescript
+// === Extraction Output ===
+
+interface ExtractionResult {
+ language: string;
+ files: FileInfo[];
+ functions: FunctionInfo[];
+ types: TypeInfo[];
+ calls: CallInfo[];
+ imports: ImportInfo[];
+}
+
+interface FileInfo {
+ path: string; // relative to worktree root
+ language: string; // e.g., "dart", "java", "go"
+ changed: boolean; // true if modified in this PR
+}
+
+interface FunctionInfo {
+ name: string;
+ signature: string; // full signature as string
+ visibility: "public" | "private" | "protected" | "internal";
+ filePath: string; // which file this lives in
+ linesStart: number;
+ linesEnd: number;
+ changed: boolean; // true if modified in this PR
+}
+
+interface TypeInfo {
+ name: string;
+ kind: "class" | "interface" | "struct" | "enum";
+ visibility: "public" | "private" | "protected" | "internal";
+ filePath: string;
+}
+
+interface CallInfo {
+ callerName: string; // function making the call
+ callerFile: string;
+ calleeName: string; // function being called
+ line: number; // line of the call site
+}
+
+interface ImportInfo {
+ filePath: string; // file containing the import
+ importedPath: string; // what is being imported
+ importedName: string; // specific symbol if applicable, or "*"
+}
+
+// === Infrastructure ===
+
+interface ServerHandle {
+ port: number;
+ containerId: string;
+ url: string; // ws://localhost:${port}/gremlin
+}
+
+// === Graph Population ===
+
+interface PopulationSummary {
+ vertices: number;
+ edges: number;
+ breakdown: {
+ files: number;
+ functions: number;
+ types: number;
+ calls: number;
+ defines: number;
+ dependsOn: number;
+ };
+}
+
+// === Pattern Results ===
+
+interface CompletenessResult {
+ node: string; // vertex id or identifier checked
+ present: string[]; // edge labels that exist
+ missing: string[]; // edge labels that are absent
+ score: number; // present.length / (present.length + missing.length)
+}
+
+interface CoverageGapResult {
+ uncovered: {
+ name: string;
+ signature: string;
+ filePath: string;
+ linesStart: number;
+ linesEnd: number;
+ }[];
+ totalChanged: number;
+ totalCovered: number;
+}
+
+// === Evidence Package (renderer input) ===
+
+interface EvidencePackage {
+ meta: {
+ pr: number;
+ title: string;
+ domain: string;
+ timestamp: string;
+ };
+ summary: string;
+ graphStats: PopulationSummary;
+ checks: {
+ completeness: CompletenessResult[];
+ coverageGaps: CoverageGapResult;
+ };
+}
+```
+
+---
+
+## Module Signatures
+
+### extraction/tree-sitter.js
+
+```javascript
+/**
+ * Parse source files in a directory using Tree-sitter.
+ * Returns structured extraction data for graph population.
+ *
+ * @param {string} directory - Absolute path to the PR worktree
+ * @param {string} language - Primary language to parse (e.g., "dart")
+ * @param {object} options
+ * @param {string[]} [options.changedFiles] - List of files changed in PR (relative paths)
+ * @returns {Promise<ExtractionResult>}
+ */
+export async function extract(directory, language, options = {}) {}
+```
+
+**Responsibilities:**
+- Load the appropriate tree-sitter grammar for the language
+- Walk the directory, parse each source file
+- Run queries to extract functions, types, call sites, imports
+- Mark `changed: true` on files/functions that appear in `options.changedFiles`
+- Return the structured `ExtractionResult`
+
+**Does NOT:**
+- Connect to Gremlin Server
+- Create graph vertices/edges
+- Understand Gremlin step semantics
+
+---
+
+### infrastructure/docker.js
+
+```javascript
+/**
+ * Start a Gremlin Server Docker container with TinkerGraph.
+ * Uses a random available port. Polls until server is ready.
+ *
+ * @param {object} options
+ * @param {string} [options.image] - Docker image (default: "tinkerpop/gremlin-server:4.0.1")
+ * @param {number} [options.timeoutMs] - Max wait for readiness (default: 30000)
+ * @returns {Promise<ServerHandle>}
+ */
+export async function startServer(options = {}) {}
+
+/**
+ * Stop and remove the Gremlin Server container.
+ *
+ * @param {ServerHandle} handle - Handle returned by startServer
+ * @returns {Promise<void>}
+ */
+export async function stopServer(handle) {}
+```
+
+**Responsibilities:**
+- Find a random unused port
+- Run `docker run` with correct config (empty graph, GraphBinary)
+- Poll the HTTP endpoint until ready
+- Return connection details
+- Clean up container on stop
+
+**Does NOT:**
+- Manage the gremlin-js client connection (that's the caller's job)
+- Know anything about the graph schema
+
+---
+
+### graph/populate.js
+
+```javascript
+/**
+ * Populate TinkerGraph with extraction data.
+ * Creates vertices and edges matching the PR knowledge graph schema.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {ExtractionResult} extraction - Output from tree-sitter module
+ * @returns {Promise<PopulationSummary>}
+ */
+export async function populate(g, extraction) {}
+```
+
+**Schema mapping:**
+
+| Extraction data | Graph vertex | Key properties |
+|---|---|---|
+| `files[]` | `File` | path, language, changed |
+| `functions[]` | `Function` | name, signature, visibility, lines_start, lines_end, changed |
+| `types[]` | `Type` | name, kind, visibility |
+
+| Extraction data | Graph edge | From → To |
+|---|---|---|
+| `calls[]` | `calls` | Function → Function (matched by name+file) |
+| `functions[].filePath` | `defines` | File → Function |
+| `types[].filePath` | `defines` | File → Type |
+| `imports[]` | `depends_on` | File → File |
+
+**Responsibilities:**
+- Create all vertices with properties
+- Create all edges (matching by name for call targets)
+- Handle cases where call targets don't resolve (function in external dependency) — skip edge, don't fail
+- Return population summary with counts
+
+**Does NOT:**
+- Create semantic edges (implements_step, tests, covers) — that's agent enrichment
+- Parse source files — that's the extraction module's job
+
+---
+
+### patterns/completeness.js
+
+```javascript
+/**
+ * Check that a vertex has all expected outgoing/incoming edge labels.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {string} params.vertexLabel - Label of vertex to check (e.g., "Step")
+ * @param {string} [params.vertexName] - Name property to filter by
+ * @param {string[]} params.expectedEdges - Edge labels that should exist
+ * Prefix with "in:" or "out:" for direction (default: "out:")
+ * e.g., ["out:has_rule", "in:implements_step", "in:covers", "in:documents"]
+ * @returns {Promise<CompletenessResult[]>}
+ */
+export async function completeness(g, params) {}
+```
+
+---
+
+### patterns/coverage-gaps.js
+
+```javascript
+/**
+ * Find changed functions that have no incoming 'tests' edge.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {boolean} [params.changedOnly] - Only check functions with changed=true (default: true)
+ * @returns {Promise<CoverageGapResult>}
+ */
+export async function coverageGaps(g, params = {}) {}
+```
+
+---
+
+### renderer/render.js
+
+```javascript
+/**
+ * Render an evidence package to a self-contained HTML page.
+ *
+ * @param {EvidencePackage} evidence - Structured evidence data
+ * @returns {string} - Complete HTML document as a string
+ */
+export function render(evidence) {}
+```
+
+**Responsibilities:**
+- Produce a single self-contained HTML string (embedded CSS, no external deps)
+- Render header with PR metadata
+- Render completeness results as a visual checklist
+- Render coverage gaps as a list with file/line links
+- Render graph population summary
+
+**Does NOT:**
+- Fetch data or run queries
+- Produce graph visualizations (Phase 4)
+- Generate the guided walk narrative (needs agent synthesis)
+
+---
+
+### review.js (orchestrator)
+
+```javascript
+/**
+ * Execute a graph-based review of a PR.
+ * This is the skill entry point.
+ *
+ * @param {object} params
+ * @param {number} params.pr - PR number
+ * @param {string} params.repoPath - Path to the git repository
+ * @param {object} [params.options]
+ * @param {string} [params.options.outputPath] - Where to write the HTML (default: ./pr-review-${pr}.html)
+ * @returns {Promise<string>} - Path to the generated HTML file
+ */
+export async function review(params) {}
+```
+
+**Orchestration steps:**
+1. `git fetch origin pull/${pr}/head:pr-review/${pr}`
+2. `git worktree add /tmp/pr-review-${pr} pr-review/${pr}`
+3. Determine changed files via `git diff --name-only ${base}...pr-review/${pr}`
+4. `startServer()`
+5. Connect gremlin-js to `handle.url`
+6. `extract(worktreePath, language, { changedFiles })`
+7. `populate(g, extraction)`
+8. `completeness(g, { ... })`
+9. `coverageGaps(g, { ... })`
+10. Assemble `EvidencePackage`
+11. `render(evidence)` → write to file
+12. `stopServer(handle)`
+13. `git worktree remove ...` + `git branch -D ...`
+
+**Progress output** (emitted to stdout between steps):
+```
+[review] PR #${pr} — fetching...
+[review] Starting Gremlin Server on port ${port}...
+[review] Phase 1: Extracting structure...
+[review] Phase 1 complete: ${vertices} vertices, ${edges} edges
+[review] Running checks...
+[review] Done. Guidebook: ${outputPath}
+```
+
+---
+
+## Dependency Graph
+
+```
+INTERFACES.md (this file — defines contracts)
+ │
+ ├──→ extraction/tree-sitter.js ──┐
+ ├──→ infrastructure/docker.js ──┤
+ ├──→ renderer/render.js │
+ │ ▼
+ │ graph/populate.js
+ │ │
+ │ ▼
+ │ patterns/completeness.js
+ │ patterns/coverage-gaps.js
+ │ │
+ └──→ review.js (orchestrator) ◄───┘
+```
+
+Modules above the line can be implemented in parallel.
+Modules below the line depend on those above.
diff --git a/.skills/tinker-review/references/schema.md b/.skills/tinker-review/references/schema.md
new file mode 100644
index 0000000000..9cf903da55
--- /dev/null
+++ b/.skills/tinker-review/references/schema.md
@@ -0,0 +1,105 @@
+<!--
+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.
+-->
+
+# PR Knowledge Graph Schema
+
+## Vertices (9 labels)
+
+### Code structure
+
+**File** `{ path, language, changed }`
+A source file in the PR. `changed: true` if modified in this PR.
+
+**Function** `{ name, signature, visibility, filePath, lines_start, lines_end, changed }`
+A function or method. The primary unit of analysis.
+
+**Type** `{ name, kind, visibility, filePath }`
+A class, interface, struct, or enum. `kind` is one of: class, interface, struct, enum.
+
+### TinkerPop domain
+
+**Step** `{ name, canonical_name }`
+A Gremlin traversal step as a concept (e.g., "addV", "has", "out"). Created during enrichment when functions are mapped to steps.
+
+**GrammarRule** `{ name, production }`
+An ANTLR production in Gremlin.g4.
+
+**GLV** `{ language }`
+A Gremlin Language Variant (e.g., "dart", "go", "python").
+
+### Verification
+
+**Test** `{ name, type }`
+A test function. `type` is one of: unit, integration, suite.
+
+**Doc** `{ path, section }`
+A documentation file or section that references code.
+
+### Discussion
+
+**Discussion** `{ url, source, title, body }`
+Any discussion artifact. `source` is one of: devlist, jira, proposal, pr.
+The PR itself is a Discussion with `source: "pr"`.
+
+**Comment** `{ author, body, timestamp }`
+A comment on a Discussion.
+
+## Edges (14 labels)
+
+### Code relationships
+
+| Edge | From | To | Meaning |
+|------|------|----|---------|
+| `calls` | Function | Function | Function invokes another function |
+| `defines` | File | Function or Type | File contains this definition |
+| `implements` | Function | Type | Function implements an interface |
+| `depends_on` | File | File | File imports/requires another file |
+
+### Domain relationships
+
+| Edge | From | To | Meaning |
+|------|------|----|---------|
+| `implements_step` | Function | Step | This function is a GLV's implementation of a Gremlin step |
+| `has_rule` | Step | GrammarRule | This step is defined by this grammar production |
+| `provides` | GLV | Step | This GLV implements this step |
+
+### Verification
+
+| Edge | From | To | Meaning |
+|------|------|----|---------|
+| `tests` | Test | Function | This test exercises this function |
+| `covers` | Test | Step | This test covers this step's behavior |
+| `documents` | Doc | Step, Function, or Type | This doc describes this entity |
+
+### Discussion
+
+| Edge | From | To | Meaning |
+|------|------|----|---------|
+| `has_comment` | Discussion | Comment | Discussion contains this comment |
+| `addresses` | Discussion | Discussion | One discussion references another (e.g., PR addresses a JIRA) |
+| `proposed_in` | Step | Discussion | This step was proposed/discussed here |
+| `modifies` | Discussion(pr) | Function or File | The PR modifies this code |
+
+#### `addresses` edge properties
+
+| Property | Values | Meaning |
+|----------|--------|---------|
+| `found_in` | `pr`, `diff`, `search`, `jira_body`, `devlist_body` | Where the link was discovered |
+| `found_via` | JIRA ID or URL | Which discussion contained the reference (for secondary links) |
+
diff --git a/.skills/tinker-review/scripts/discovery/discussions.js b/.skills/tinker-review/scripts/discovery/discussions.js
new file mode 100644
index 0000000000..ff05a3e87a
--- /dev/null
+++ b/.skills/tinker-review/scripts/discovery/discussions.js
@@ -0,0 +1,280 @@
+/*
+ * 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 { get } from "node:https";
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const JIRA_BASE = "https://issues.apache.org/jira";
+const DEV_LIST_API = "https://lists.apache.org/api/stats.lua";
+const TINKERPOP_JIRA_PATTERN = /TINKERPOP-(\d+)/g;
+const DEV_LIST_LINK_PATTERN = /https?:\/\/lists\.apache\.org\/[^\s)\]>"]*/g;
+const PROPOSAL_DIR = "docs/src/dev/future";
+
+function httpGet(url) {
+ return new Promise((resolve, reject) => {
+ get(url, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ return httpGet(res.headers.location).then(resolve, reject);
+ }
+ let data = "";
+ res.on("data", (chunk) => { data += chunk; });
+ res.on("end", () => {
+ if (res.statusCode >= 200 && res.statusCode < 300) {
+ resolve(data);
+ } else {
+ reject(new Error(`HTTP ${res.statusCode}: ${url}`));
+ }
+ });
+ }).on("error", reject);
+ });
+}
+
+async function fetchJira(ticketId) {
+ try {
+ const url = `${JIRA_BASE}/rest/api/2/issue/${ticketId}?fields=summary,description,status,issuetype,comment`;
+ const data = JSON.parse(await httpGet(url));
+ const comments = (data.fields?.comment?.comments || []).map((c) => ({
+ author: c.author?.displayName || c.author?.name || "unknown",
+ body: (c.body || "").slice(0, 1000),
+ timestamp: c.created,
+ }));
+ return {
+ id: ticketId,
+ url: `${JIRA_BASE}/browse/${ticketId}`,
+ source: "jira",
+ title: data.fields?.summary || ticketId,
+ body: (data.fields?.description || "").slice(0, 2000),
+ status: data.fields?.status?.name || "unknown",
+ type: data.fields?.issuetype?.name || "unknown",
+ comments,
+ };
+ } catch {
+ return null;
+ }
+}
+
+async function fetchPrComments(prNumber) {
+ try {
+ const url = `https://api.github.com/repos/apache/tinkerpop/issues/${prNumber}/comments?per_page=50`;
+ const data = JSON.parse(await httpGet(url));
+ if (!Array.isArray(data)) return [];
+ return data.map((c) => ({
+ author: c.user?.login || "unknown",
+ body: (c.body || "").slice(0, 1000),
+ timestamp: c.created_at,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+async function fetchPrReviewComments(prNumber) {
+ try {
+ const url = `https://api.github.com/repos/apache/tinkerpop/pulls/${prNumber}/comments?per_page=50`;
+ const data = JSON.parse(await httpGet(url));
+ if (!Array.isArray(data)) return [];
+ return data.map((c) => ({
+ author: c.user?.login || "unknown",
+ body: (c.body || "").slice(0, 1000),
+ path: c.path || "",
+ timestamp: c.created_at,
+ }));
+ } catch {
+ return [];
+ }
+}
+
+async function searchDevList(keywords) {
+ try {
+ const query = encodeURIComponent(keywords.join(" "));
+ const url = `${DEV_LIST_API}?list=dev&domain=tinkerpop.apache.org&q=${query}&d=lte=1y`;
+ const data = JSON.parse(await httpGet(url));
+ if (!data.emails || data.emails.length === 0) return [];
+
+ return data.emails
+ .filter((e) => e.subject && !e.subject.startsWith("Re:"))
+ .slice(0, 5)
+ .map((e) => ({
+ url: `https://lists.apache.org/thread/${e.mid}`,
+ source: "devlist",
+ title: e.subject,
+ body: (e.body || "").slice(0, 1000),
+ date: new Date(e.epoch * 1000).toISOString().slice(0, 10),
+ }));
+ } catch {
+ return [];
+ }
+}
+
+async function findMatchingProposals(repoPath, keywords) {
+ const proposalDir = join(repoPath, PROPOSAL_DIR);
+ try {
+ const files = await readdir(proposalDir);
+ const proposals = [];
+
+ for (const file of files) {
+ if (!file.endsWith(".asciidoc") && !file.endsWith(".adoc")) continue;
+ const content = await readFile(join(proposalDir, file), "utf-8");
+ const lowerContent = content.toLowerCase();
+ const matchedKeywords = keywords.filter((k) => lowerContent.includes(k.toLowerCase()));
+ if (matchedKeywords.length > 0) {
+ const titleMatch = content.match(/^=+\s*(.+)$/m);
+ proposals.push({
+ file,
+ path: `${PROPOSAL_DIR}/${file}`,
+ source: "proposal",
+ title: titleMatch ? titleMatch[1].trim() : file,
+ matchedKeywords,
+ snippet: content.slice(0, 500),
+ });
+ }
+ }
+
+ return proposals.sort((a, b) => b.matchedKeywords.length - a.matchedKeywords.length);
+ } catch {
+ return [];
+ }
+}
+
+function extractLinksFromText(text) {
+ const jiraRefs = [...new Set([...text.matchAll(TINKERPOP_JIRA_PATTERN)].map((m) => m[0]))];
+ const devListRefs = [...new Set([...text.matchAll(DEV_LIST_LINK_PATTERN)].map((m) => m[0]))];
+ return { jiraRefs, devListRefs };
+}
+
+async function followLinks(discussions) {
+ const secondary = [];
+
+ for (const disc of discussions) {
+ const commentBodies = (disc.comments || []).map((c) => c.body).join("\n");
+ const textToScan = [disc.body || "", disc.title || "", commentBodies].join("\n");
+ const { jiraRefs, devListRefs } = extractLinksFromText(textToScan);
+
+ for (const jiraId of jiraRefs) {
+ if (discussions.some((d) => d.id === jiraId)) continue;
+ const jira = await fetchJira(jiraId);
+ if (jira) {
+ secondary.push({
+ ...jira,
+ found_in: `${disc.source}_body`,
+ found_via: disc.id || disc.url,
+ });
+ }
+ }
+
+ for (const url of devListRefs) {
+ if (discussions.some((d) => d.url === url)) continue;
+ secondary.push({
+ url,
+ source: "devlist",
+ title: "(referenced thread)",
+ body: "",
+ found_in: `${disc.source}_body`,
+ found_via: disc.id || disc.url,
+ });
+ }
+ }
+
+ return secondary;
+}
+
+/**
+ * Discover discussions and proposals related to a PR.
+ *
+ * Strategy:
+ * - JIRA: scan all text for TINKERPOP-XXXX. Fetch if found. If absent, note missing.
+ * - Dev list: scan for explicit links. If none, search archives (max 1 year back).
+ * If search returns nothing, note missing.
+ * - Proposals: check docs/src/dev/future/ for documents matching PR keywords.
+ * If not referenced anywhere, still check by keyword match.
+ * - Cross-reference: follow one hop from each discovered discussion to find
+ * additional JIRAs and dev list threads referenced within them.
+ *
+ * @param {object} params
+ * @param {number} params.pr - PR number (for fetching comments from GitHub)
+ * @param {string} params.prTitle - PR title
+ * @param {string} params.prBody - PR body/description
+ * @param {string} params.diff - The full diff text
+ * @param {string[]} params.keywords - Keywords for search (class names, step names, etc.)
+ * @param {string} params.repoPath - Path to the repo (for proposal discovery)
+ * @returns {Promise<DiscoveryResult>}
+ */
+export async function discoverDiscussions(params) {
+ const { pr, prTitle = "", prBody = "", diff = "", keywords = [], repoPath = "" } = params;
+
+ // Fetch PR comments from GitHub (issue comments + inline review comments)
+ const issueComments = pr ? await fetchPrComments(pr) : [];
+ const reviewComments = pr ? await fetchPrReviewComments(pr) : [];
+ const prCommentBodies = [...issueComments, ...reviewComments].map((c) => c.body);
+
+ const allText = [prTitle, prBody, ...prCommentBodies, diff].join("\n");
+
+ // --- JIRA (direct) ---
+ const jiraMatches = [...new Set([...allText.matchAll(TINKERPOP_JIRA_PATTERN)].map((m) => m[0]))];
+ const jiras = (await Promise.all(jiraMatches.map(fetchJira))).filter(Boolean);
+ for (const j of jiras) { j.found_in = "pr"; }
+
+ // --- Dev list (direct) ---
+ const devListLinks = [...new Set([...allText.matchAll(DEV_LIST_LINK_PATTERN)].map((m) => m[0]))];
+ const explicitDevList = devListLinks.map((url) => ({
+ url,
+ source: "devlist",
+ title: "(linked thread)",
+ body: "",
+ found_in: "pr",
+ }));
+
+ let searchedDevList = [];
+ let devListSearchPerformed = false;
+ if (explicitDevList.length === 0 && keywords.length > 0) {
+ devListSearchPerformed = true;
+ searchedDevList = await searchDevList(keywords);
+ for (const d of searchedDevList) { d.found_in = "search"; }
+ }
+
+ // --- Cross-reference: follow one hop from direct discoveries ---
+ const directDiscussions = [...jiras, ...explicitDevList, ...searchedDevList];
+ const secondaryDiscussions = await followLinks(directDiscussions);
+
+ // --- Proposals ---
+ const proposalLinks = [...allText.matchAll(/docs\/src\/dev\/future\/[^\s)\]>"]+/g)].map((m) => m[0]);
+ let proposals = [];
+ if (repoPath) {
+ proposals = await findMatchingProposals(repoPath, keywords);
+ }
+
+ return {
+ jiras,
+ jiraMissing: jiraMatches.length === 0,
+
+ devList: [...explicitDevList, ...searchedDevList],
+ devListMissing: explicitDevList.length === 0 && searchedDevList.length === 0,
+ devListSearchPerformed,
+ devListSearchKeywords: devListSearchPerformed ? keywords : [],
+
+ secondary: secondaryDiscussions,
+
+ prComments: { issue: issueComments, review: reviewComments },
+
+ proposals,
+ proposalLinked: proposalLinks.length > 0,
+ proposalMissing: proposalLinks.length === 0 && proposals.length === 0,
+ };
+}
diff --git a/.skills/tinker-review/scripts/enrichment/api.js b/.skills/tinker-review/scripts/enrichment/api.js
new file mode 100644
index 0000000000..5e6b370429
--- /dev/null
+++ b/.skills/tinker-review/scripts/enrichment/api.js
@@ -0,0 +1,168 @@
+/*
+ * 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 { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import gremlin from "gremlin";
+
+const { process: { statics: __ } } = gremlin;
+
+let cachedSteps = null;
+
+// === Read operations ===
+
+export async function listFunctions(g, filter = {}) {
+ let t = g.V().hasLabel("Function");
+ if (filter.changed !== undefined) t = t.has("changed", filter.changed);
+ if (filter.visibility) t = t.has("visibility", filter.visibility);
+ if (filter.filePath) t = t.has("filePath", filter.filePath);
+ const results = await t.elementMap().toList();
+ return results.map(m => ({
+ name: m.get("name"),
+ signature: m.get("signature"),
+ filePath: m.get("filePath"),
+ visibility: m.get("visibility"),
+ changed: m.get("changed"),
+ linesStart: m.get("lines_start"),
+ linesEnd: m.get("lines_end"),
+ }));
+}
+
+export async function listTypes(g, filter = {}) {
+ let t = g.V().hasLabel("Type");
+ if (filter.kind) t = t.has("kind", filter.kind);
+ if (filter.filePath) t = t.has("filePath", filter.filePath);
+ const results = await t.elementMap().toList();
+ return results.map(m => ({
+ name: m.get("name"),
+ kind: m.get("kind"),
+ visibility: m.get("visibility"),
+ filePath: m.get("filePath"),
+ }));
+}
+
+export async function getCallsFrom(g, functionName, filePath) {
+ const results = await g.V().hasLabel("Function")
+ .has("name", functionName)
+ .has("filePath", filePath)
+ .out("calls")
+ .elementMap()
+ .toList();
+ return results.map(m => ({
+ calleeName: m.get("name"),
+ filePath: m.get("filePath"),
+ }));
+}
+
+export async function getCanonicalSteps(repoPath) {
+ if (cachedSteps) return cachedSteps;
+ const g4Path = join(repoPath, "gremlin-language/src/main/antlr4/Gremlin.g4");
+ const g4 = await readFile(g4Path, "utf-8");
+ cachedSteps = [...g4.matchAll(/traversalMethod_(\w+)/g)].map(m => m[1]);
+ cachedSteps = [...new Set(cachedSteps)].sort();
+ return cachedSteps;
+}
+
+// === Write operations ===
+
+export async function mapStep(g, functionName, filePath, canonicalStepName) {
+ const stepExists = await g.V().hasLabel("Step").has("name", canonicalStepName).hasNext();
+ if (!stepExists) {
+ await g.addV("Step")
+ .property("name", canonicalStepName)
+ .property("canonical_name", canonicalStepName)
+ .next();
+ }
+
+ await g.V().hasLabel("Function")
+ .has("name", functionName)
+ .has("filePath", filePath)
+ .addE("implements_step")
+ .to(__.V().hasLabel("Step").has("name", canonicalStepName))
+ .next();
+
+ return { mapped: `${functionName} -> ${canonicalStepName}` };
+}
+
+export async function linkDiscussion(g, url, source, title, body) {
+ await g.addV("Discussion")
+ .property("url", url)
+ .property("source", source)
+ .property("title", title)
+ .property("body", body || "")
+ .next();
+
+ const prDiscussion = await g.V().hasLabel("Discussion").has("source", "pr").hasNext();
+ if (prDiscussion) {
+ await g.V().hasLabel("Discussion").has("source", "pr")
+ .addE("addresses")
+ .to(__.V().hasLabel("Discussion").has("url", url))
+ .next();
+ }
+
+ return { linked: `${source}: ${title}` };
+}
+
+export async function annotate(g, label, name, key, value) {
+ await g.V().hasLabel(label).has("name", name)
+ .property(key, value)
+ .next();
+
+ return { annotated: `${label}:${name}.${key} = ${value}` };
+}
+
+export async function linkDoc(g, entityLabel, entityName, docPath, section) {
+ const docExists = await g.V().hasLabel("Doc").has("path", docPath).hasNext();
+ if (!docExists) {
+ await g.addV("Doc")
+ .property("path", docPath)
+ .property("section", section || "")
+ .next();
+ }
+
+ await g.V().hasLabel("Doc").has("path", docPath)
+ .addE("documents")
+ .to(__.V().hasLabel(entityLabel).has("name", entityName))
+ .next();
+
+ return { linked: `${docPath} documents ${entityLabel}:${entityName}` };
+}
+
+export async function addGrammarRule(g, name, production) {
+ await g.addV("GrammarRule")
+ .property("name", name)
+ .property("production", production || "")
+ .next();
+
+ return { added: `GrammarRule: ${name}` };
+}
+
+export async function createPrDiscussion(g, pr, title, body) {
+ const exists = await g.V().hasLabel("Discussion").has("source", "pr").hasNext();
+ if (exists) return { created: false };
+
+ await g.addV("Discussion")
+ .property("url", `https://github.com/apache/tinkerpop/pull/${pr}`)
+ .property("source", "pr")
+ .property("title", title || `PR #${pr}`)
+ .property("body", body || "")
+ .next();
+
+ return { created: true, pr };
+}
diff --git a/.skills/tinker-review/scripts/enrichment/cli.js b/.skills/tinker-review/scripts/enrichment/cli.js
new file mode 100644
index 0000000000..4cf70ec498
--- /dev/null
+++ b/.skills/tinker-review/scripts/enrichment/cli.js
@@ -0,0 +1,156 @@
+/*
+ * 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 { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import gremlin from "gremlin";
+import {
+ listFunctions, listTypes, getCallsFrom, getCanonicalSteps,
+ mapStep, linkDiscussion, linkDoc, addGrammarRule, annotate,
+ createPrDiscussion,
+} from "./api.js";
+
+const COMMANDS = {
+ listFunctions: { fn: listFunctions, needsG: true },
+ listTypes: { fn: listTypes, needsG: true },
+ getCallsFrom: { fn: getCallsFrom, needsG: true },
+ getCanonicalSteps: { fn: getCanonicalSteps, needsG: false },
+ mapStep: { fn: mapStep, needsG: true },
+ linkDiscussion: { fn: linkDiscussion, needsG: true },
+ linkDoc: { fn: linkDoc, needsG: true },
+ addGrammarRule: { fn: addGrammarRule, needsG: true },
+ annotate: { fn: annotate, needsG: true },
+ createPrDiscussion: { fn: createPrDiscussion, needsG: true },
+};
+
+function parseArgs(argv) {
+ const args = {};
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i].startsWith("--")) {
+ const key = argv[i].slice(2);
+ const val = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : "true";
+ if (val === "true") args[key] = true;
+ else if (val === "false") args[key] = false;
+ else if (!isNaN(val)) args[key] = Number(val);
+ else args[key] = val;
+ if (val !== "true") i++;
+ }
+ }
+ return args;
+}
+
+async function loadSession(workDir) {
+ const sessionPath = join(workDir, "session.json");
+ const raw = await readFile(sessionPath, "utf-8");
+ return JSON.parse(raw);
+}
+
+async function main() {
+ const [command, ...rest] = process.argv.slice(2);
+
+ if (!command || command === "--help") {
+ console.log("Usage: node cli.js <command> [--key value ...]");
+ console.log(" node cli.js <command> --workDir /tmp/pr-review-3448 [--key value ...]");
+ console.log("");
+ console.log("Commands:");
+ console.log(" listFunctions [--changed true] [--visibility public]");
+ console.log(" listTypes [--kind class]");
+ console.log(" getCallsFrom --function <name> --file <path>");
+ console.log(" getCanonicalSteps");
+ console.log(" mapStep --function <name> --file <path> --step <canonicalName>");
+ console.log(" linkDiscussion --url <url> --source <jira|devlist|proposal> --title <title> [--body <body>]");
+ console.log(" linkDoc --entity <label> --name <name> --doc <path> [--section <section>]");
+ console.log(" addGrammarRule --name <name> [--production <production>]");
+ console.log(" annotate --label <label> --name <name> --key <key> --value <value>");
+ console.log("");
+ console.log("Options:");
+ console.log(" --workDir Work directory (default: env PR_REVIEW_WORKDIR or /tmp/pr-review-*)");
+ process.exit(0);
+ }
+
+ if (!COMMANDS[command]) {
+ console.error(`Unknown command: ${command}`);
+ console.error(`Available: ${Object.keys(COMMANDS).join(", ")}`);
+ process.exit(1);
+ }
+
+ const args = parseArgs(rest);
+ const workDir = args.workDir || process.env.PR_REVIEW_WORKDIR;
+
+ if (!workDir) {
+ console.error("Error: --workDir or PR_REVIEW_WORKDIR env var required");
+ process.exit(1);
+ }
+
+ const session = await loadSession(workDir);
+ const { fn, needsG } = COMMANDS[command];
+
+ let connection;
+ let g;
+ let result;
+
+ try {
+ if (needsG) {
+ connection = new gremlin.driver.DriverRemoteConnection(session.url);
+ g = gremlin.process.AnonymousTraversalSource.traversal().withRemote(connection);
+ }
+
+ switch (command) {
+ case "listFunctions":
+ result = await fn(g, { changed: args.changed, visibility: args.visibility, filePath: args.file });
+ break;
+ case "listTypes":
+ result = await fn(g, { kind: args.kind, filePath: args.file });
+ break;
+ case "getCallsFrom":
+ result = await fn(g, args.function, args.file);
+ break;
+ case "getCanonicalSteps":
+ result = await fn(session.worktreePath || session.repoPath);
+ break;
+ case "mapStep":
+ result = await fn(g, args.function, args.file, args.step);
+ break;
+ case "linkDiscussion":
+ result = await fn(g, args.url, args.source, args.title, args.body);
+ break;
+ case "linkDoc":
+ result = await fn(g, args.entity, args.name, args.doc, args.section);
+ break;
+ case "addGrammarRule":
+ result = await fn(g, args.name, args.production);
+ break;
+ case "annotate":
+ result = await fn(g, args.label, args.name, args.key, args.value);
+ break;
+ case "createPrDiscussion":
+ result = await fn(g, args.pr, args.title, args.body);
+ break;
+ }
+
+ console.log(JSON.stringify(result, null, 2));
+ } finally {
+ if (connection) await connection.close().catch(() => {});
+ }
+}
+
+main().catch((err) => {
+ console.error(err.message || err);
+ process.exit(1);
+});
diff --git a/.skills/tinker-review/scripts/extraction/tree-sitter.js b/.skills/tinker-review/scripts/extraction/tree-sitter.js
new file mode 100644
index 0000000000..6b9a89833a
--- /dev/null
+++ b/.skills/tinker-review/scripts/extraction/tree-sitter.js
@@ -0,0 +1,592 @@
+/*
+ * 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 { createRequire } from "node:module";
+const require = createRequire(import.meta.url);
+const TreeSitter = require("web-tree-sitter");
+import { readdir, readFile } from "node:fs/promises";
+import { join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { dirname } from "node:path";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const LANGUAGE_EXTENSIONS = {
+ java: [".java"],
+ javascript: [".js", ".mjs", ".cjs"],
+ go: [".go"],
+ python: [".py"],
+ csharp: [".cs"],
+ dart: [".dart"],
+};
+
+const WASM_FILENAMES = {
+ java: "tree-sitter-java.wasm",
+ javascript: "tree-sitter-javascript.wasm",
+ go: "tree-sitter-go.wasm",
+ python: "tree-sitter-python.wasm",
+ csharp: "tree-sitter-c_sharp.wasm",
+ dart: "tree-sitter-dart.wasm",
+};
+
+const SKIP_DIRS = new Set([
+ "node_modules", "build", "target", ".git", ".gradle",
+ "dist", "out", "__pycache__", ".dart_tool", ".pub-cache",
+ "vendor", "bin", "obj",
+]);
+
+let initialized = false;
+
+async function ensureInit() {
+ if (!initialized) {
+ const wasmPath = join(__dirname, "..", "..", "node_modules", "web-tree-sitter", "tree-sitter.wasm");
+ await TreeSitter.init({ locateFile: () => wasmPath });
+ initialized = true;
+ }
+}
+
+async function loadLanguage(language) {
+ const wasmFile = WASM_FILENAMES[language];
+ if (!wasmFile) {
+ throw new Error(`Unsupported language: ${language}. Supported: ${Object.keys(WASM_FILENAMES).join(", ")}`);
+ }
+ const wasmPath = join(__dirname, "..", "..", "node_modules", "tree-sitter-wasms", "out", wasmFile);
+ return TreeSitter.Language.load(wasmPath);
+}
+
+async function walkDirectory(directory, extensions, changedSet) {
+ const files = [];
+
+ async function walk(dir) {
+ let entries;
+ try {
+ entries = await readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
+ continue;
+ }
+ const fullPath = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await walk(fullPath);
+ } else if (extensions.some((ext) => entry.name.endsWith(ext))) {
+ const relPath = relative(directory, fullPath);
+ files.push({
+ path: relPath,
+ fullPath,
+ changed: changedSet.has(relPath),
+ });
+ }
+ }
+ }
+
+ await walk(directory);
+ return files;
+}
+
+function isFunctionNode(node, language) {
+ const types = {
+ java: ["method_declaration", "constructor_declaration"],
+ go: ["function_declaration", "method_declaration"],
+ python: ["function_definition"],
+ javascript: ["function_declaration", "method_definition"],
+ csharp: ["method_declaration", "constructor_declaration"],
+ dart: ["method_signature", "function_signature"],
+ };
+ return (types[language] || []).includes(node.type);
+}
+
+function isTypeNode(node, language) {
+ const types = {
+ java: ["class_declaration", "interface_declaration", "enum_declaration"],
+ go: ["type_declaration"],
+ python: ["class_definition"],
+ javascript: ["class_declaration"],
+ csharp: ["class_declaration", "interface_declaration", "struct_declaration", "enum_declaration"],
+ dart: ["class_definition", "enum_declaration"],
+ };
+ return (types[language] || []).includes(node.type);
+}
+
+function isCallNode(node, language) {
+ const types = {
+ java: ["method_invocation"],
+ go: ["call_expression"],
+ python: ["call"],
+ javascript: ["call_expression"],
+ csharp: ["invocation_expression"],
+ dart: ["call_expression"],
+ };
+ return (types[language] || []).includes(node.type);
+}
+
+function isImportNode(node, language) {
+ const types = {
+ java: ["import_declaration"],
+ go: ["import_spec"],
+ python: ["import_statement", "import_from_statement"],
+ javascript: ["import_statement"],
+ csharp: ["using_directive"],
+ dart: ["import_or_export"],
+ };
+ return (types[language] || []).includes(node.type);
+}
+
+function childByType(node, type) {
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child.type === type) return child;
+ }
+ return null;
+}
+
+function childrenByType(node, type) {
+ const results = [];
+ for (let i = 0; i < node.childCount; i++) {
+ const child = node.child(i);
+ if (child.type === type) results.push(child);
+ }
+ return results;
+}
+
+function deepChildByType(node, type) {
+ if (node.type === type) return node;
+ for (let i = 0; i < node.childCount; i++) {
+ const result = deepChildByType(node.child(i), type);
+ if (result) return result;
+ }
+ return null;
+}
+
+function getVisibility(node, language) {
+ const modNode = childByType(node, "modifiers");
+ if (modNode) {
+ const text = modNode.text;
+ if (text.includes("private")) return "private";
+ if (text.includes("protected")) return "protected";
+ if (text.includes("internal")) return "internal";
+ return "public";
+ }
+
+ if (language === "go") {
+ const nameNode = childByType(node, "identifier") || childByType(node, "field_identifier") || childByType(node, "type_identifier");
+ if (nameNode) {
+ const name = nameNode.text;
+ return name[0] === name[0].toUpperCase() ? "public" : "private";
+ }
+ return "public";
+ }
+
+ if (language === "python") {
+ const nameNode = childByType(node, "identifier");
+ if (nameNode) {
+ const name = nameNode.text;
+ if (name.startsWith("__") && !name.endsWith("__")) return "private";
+ if (name.startsWith("_")) return "protected";
+ }
+ return "public";
+ }
+
+ return "public";
+}
+
+function extractFunctionName(node, language) {
+ if (language === "go" && node.type === "method_declaration") {
+ const fieldId = childByType(node, "field_identifier");
+ if (fieldId) return fieldId.text;
+ }
+
+ const nameNode = childByType(node, "identifier");
+ if (nameNode) return nameNode.text;
+
+ if (language === "javascript" && node.type === "method_definition") {
+ const propId = childByType(node, "property_identifier");
+ if (propId) return propId.text;
+ }
+
+ return null;
+}
+
+function extractSignature(node, language) {
+ const name = extractFunctionName(node, language);
+ if (!name) return null;
+
+ let params = "";
+ const paramNode = childByType(node, "formal_parameters")
+ || childByType(node, "parameter_list")
+ || childByType(node, "parameters")
+ || childByType(node, "formal_parameter_list");
+ if (paramNode) params = paramNode.text;
+
+ if (language === "go" && node.type === "method_declaration") {
+ const paramLists = childrenByType(node, "parameter_list");
+ const receiver = paramLists.length > 0 ? paramLists[0].text : "";
+ const funcParams = paramLists.length > 1 ? paramLists[1].text : "";
+ return `(${receiver}) ${name}${funcParams}`;
+ }
+
+ return `${name}${params}`;
+}
+
+function extractFunctionsFromTree(tree, filePath, language, fileChanged) {
+ const functions = [];
+
+ function visit(node) {
+ if (isFunctionNode(node, language)) {
+ const name = extractFunctionName(node, language);
+ if (name) {
+ functions.push({
+ name,
+ signature: extractSignature(node, language) || name,
+ visibility: getVisibility(node, language),
+ filePath,
+ linesStart: node.startPosition.row + 1,
+ linesEnd: node.endPosition.row + 1,
+ changed: fileChanged,
+ });
+ }
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ visit(node.child(i));
+ }
+ }
+
+ visit(tree.rootNode);
+ return functions;
+}
+
+function inferTypeKind(node, language) {
+ const t = node.type;
+ if (t.includes("interface")) return "interface";
+ if (t.includes("struct")) return "struct";
+ if (t.includes("enum")) return "enum";
+ if (language === "go" && t === "type_declaration") {
+ const spec = childByType(node, "type_spec");
+ if (spec) {
+ for (let i = 0; i < spec.childCount; i++) {
+ const child = spec.child(i);
+ if (child.type === "interface_type") return "interface";
+ if (child.type === "struct_type") return "struct";
+ }
+ }
+ }
+ return "class";
+}
+
+function extractTypeName(node, language) {
+ if (language === "go" && node.type === "type_declaration") {
+ const spec = childByType(node, "type_spec");
+ if (spec) {
+ const typeId = childByType(spec, "type_identifier");
+ if (typeId) return typeId.text;
+ }
+ return null;
+ }
+ const nameNode = childByType(node, "identifier");
+ return nameNode ? nameNode.text : null;
+}
+
+function extractTypesFromTree(tree, filePath, language) {
+ const types = [];
+
+ function visit(node) {
+ if (isTypeNode(node, language)) {
+ const name = extractTypeName(node, language);
+ if (name) {
+ types.push({
+ name,
+ kind: inferTypeKind(node, language),
+ visibility: getVisibility(node, language),
+ filePath,
+ });
+ }
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ visit(node.child(i));
+ }
+ }
+
+ visit(tree.rootNode);
+ return types;
+}
+
+function extractCalleeName(node, language) {
+ if (language === "java") {
+ const nameNode = childByType(node, "identifier");
+ return nameNode ? nameNode.text : null;
+ }
+
+ if (language === "go") {
+ const firstChild = node.child(0);
+ if (!firstChild) return null;
+ if (firstChild.type === "identifier") return firstChild.text;
+ if (firstChild.type === "selector_expression") {
+ const field = childByType(firstChild, "field_identifier");
+ return field ? field.text : null;
+ }
+ return null;
+ }
+
+ if (language === "python") {
+ const firstChild = node.child(0);
+ if (!firstChild) return null;
+ if (firstChild.type === "identifier") return firstChild.text;
+ if (firstChild.type === "attribute") {
+ const attr = childByType(firstChild, "identifier");
+ return attr ? attr.text : null;
+ }
+ return null;
+ }
+
+ if (language === "javascript" || language === "csharp" || language === "dart") {
+ const firstChild = node.child(0);
+ if (!firstChild) return null;
+ if (firstChild.type === "identifier") return firstChild.text;
+ if (firstChild.type === "member_expression" || firstChild.type === "member_access_expression") {
+ const prop = childByType(firstChild, "property_identifier") || childByType(firstChild, "identifier");
+ return prop ? prop.text : null;
+ }
+ const id = deepChildByType(firstChild, "identifier");
+ return id ? id.text : null;
+ }
+
+ return null;
+}
+
+function findEnclosingFunction(line, functionRanges) {
+ for (const fn of functionRanges) {
+ if (line >= fn.linesStart && line <= fn.linesEnd) {
+ return fn.name;
+ }
+ }
+ return null;
+}
+
+function extractCallsFromTree(tree, filePath, language, functionRanges) {
+ const calls = [];
+
+ function visit(node) {
+ if (isCallNode(node, language)) {
+ const calleeName = extractCalleeName(node, language);
+ if (calleeName) {
+ const line = node.startPosition.row + 1;
+ calls.push({
+ callerName: findEnclosingFunction(line, functionRanges) || "<module>",
+ callerFile: filePath,
+ calleeName,
+ line,
+ });
+ }
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ visit(node.child(i));
+ }
+ }
+
+ visit(tree.rootNode);
+ return calls;
+}
+
+function extractImportInfo(node, filePath, language) {
+ if (language === "java") {
+ const scopedId = childByType(node, "scoped_identifier");
+ if (!scopedId) return null;
+ const path = scopedId.text;
+ const parts = path.split(".");
+ return { filePath, importedPath: path, importedName: parts[parts.length - 1] };
+ }
+
+ if (language === "go") {
+ const pathNode = childByType(node, "interpreted_string_literal");
+ if (!pathNode) return null;
+ const path = pathNode.text.replace(/"/g, "");
+ const parts = path.split("/");
+ return { filePath, importedPath: path, importedName: parts[parts.length - 1] };
+ }
+
+ if (language === "python") {
+ if (node.type === "import_from_statement") {
+ const dotted = childByType(node, "dotted_name");
+ if (!dotted) return null;
+ return { filePath, importedPath: dotted.text, importedName: "*" };
+ }
+ const dotted = childByType(node, "dotted_name");
+ if (!dotted) return null;
+ return { filePath, importedPath: dotted.text, importedName: dotted.text };
+ }
+
+ if (language === "javascript") {
+ const srcNode = childByType(node, "string");
+ if (!srcNode) return null;
+ const path = srcNode.text.replace(/['"]/g, "");
+ return { filePath, importedPath: path, importedName: "*" };
+ }
+
+ if (language === "csharp") {
+ const qName = childByType(node, "qualified_name") || childByType(node, "identifier");
+ if (!qName) return null;
+ const path = qName.text;
+ const parts = path.split(".");
+ return { filePath, importedPath: path, importedName: parts[parts.length - 1] };
+ }
+
+ if (language === "dart") {
+ const strLit = deepChildByType(node, "string_literal");
+ if (!strLit) return null;
+ const path = strLit.text.replace(/['"]/g, "");
+ const parts = path.split("/");
+ const name = parts[parts.length - 1].replace(".dart", "");
+ return { filePath, importedPath: path, importedName: name };
+ }
+
+ return null;
+}
+
+function extractImportsFromTree(tree, filePath, language) {
+ const imports = [];
+
+ function visit(node) {
+ if (isImportNode(node, language)) {
+ const info = extractImportInfo(node, filePath, language);
+ if (info) imports.push(info);
+ }
+ for (let i = 0; i < node.childCount; i++) {
+ visit(node.child(i));
+ }
+ }
+
+ visit(tree.rootNode);
+ return imports;
+}
+
+function isTestFile(filePath, language) {
+ switch (language) {
+ case "java":
+ return filePath.includes("src/test/") || filePath.endsWith("Test.java");
+ case "go":
+ return filePath.endsWith("_test.go");
+ case "python":
+ return /(?:^|\/|\\)test_[^/\\]*\.py$/.test(filePath);
+ case "dart":
+ return filePath.includes("_test.dart") || filePath.includes("/test/");
+ case "javascript":
+ return filePath.includes(".test.") || filePath.includes(".spec.") || filePath.includes("/test/");
+ case "csharp":
+ return filePath.includes("Test") && filePath.includes("/Tests/");
+ default:
+ return false;
+ }
+}
+
+function classifyTestType(filePath, language) {
+ if (language === "java" && filePath.includes("src/test/")) {
+ if (filePath.toLowerCase().includes("integration")) return "integration";
+ return "unit";
+ }
+ return "unit";
+}
+
+/**
+ * Parse source files in a directory using Tree-sitter.
+ * Returns structured extraction data for graph population.
+ *
+ * @param {string} directory - Absolute path to the PR worktree
+ * @param {string} language - Primary language to parse (e.g., "dart")
+ * @param {object} options
+ * @param {string[]} [options.changedFiles] - List of files changed in PR (relative paths)
+ * @returns {Promise<ExtractionResult>}
+ */
+export async function extract(directory, language, options = {}) {
+ const changedFiles = options.changedFiles || [];
+ const changedSet = new Set(changedFiles);
+
+ const extensions = LANGUAGE_EXTENSIONS[language];
+ if (!extensions) {
+ throw new Error(`Unsupported language: ${language}. Supported: ${Object.keys(LANGUAGE_EXTENSIONS).join(", ")}`);
+ }
+
+ await ensureInit();
+ const lang = await loadLanguage(language);
+ const parser = new TreeSitter();
+ parser.setLanguage(lang);
+
+ let sourceFiles;
+ if (changedFiles.length > 0) {
+ sourceFiles = changedFiles
+ .filter(f => extensions.some(ext => f.endsWith(ext)))
+ .map(f => ({ path: f, fullPath: join(directory, f), changed: true }));
+ } else {
+ sourceFiles = await walkDirectory(directory, extensions, changedSet);
+ }
+
+ const result = {
+ language,
+ files: [],
+ functions: [],
+ types: [],
+ calls: [],
+ imports: [],
+ tests: [],
+ };
+
+ for (const file of sourceFiles) {
+ const content = await readFile(file.fullPath, "utf-8");
+ const tree = parser.parse(content);
+ if (!tree) continue;
+
+ result.files.push({
+ path: file.path,
+ language,
+ changed: file.changed,
+ });
+
+ const fileFunctions = extractFunctionsFromTree(tree, file.path, language, file.changed);
+ result.functions.push(...fileFunctions);
+
+ const fileTypes = extractTypesFromTree(tree, file.path, language);
+ result.types.push(...fileTypes);
+
+ const fileCalls = extractCallsFromTree(tree, file.path, language, fileFunctions);
+ result.calls.push(...fileCalls);
+
+ const fileImports = extractImportsFromTree(tree, file.path, language);
+ result.imports.push(...fileImports);
+
+ if (isTestFile(file.path, language)) {
+ for (const fn of fileFunctions) {
+ result.tests.push({
+ name: fn.name,
+ type: classifyTestType(file.path, language),
+ filePath: file.path,
+ calledFunctions: fileCalls
+ .filter((c) => c.callerName === fn.name)
+ .map((c) => c.calleeName),
+ });
+ }
+ }
+
+ tree.delete();
+ }
+
+ parser.delete();
+ return result;
+}
diff --git a/.skills/tinker-review/scripts/graph/populate-discussions.js b/.skills/tinker-review/scripts/graph/populate-discussions.js
new file mode 100644
index 0000000000..996a3d5364
--- /dev/null
+++ b/.skills/tinker-review/scripts/graph/populate-discussions.js
@@ -0,0 +1,279 @@
+/*
+ * 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 gremlin from "gremlin";
+
+const { process: { statics: __ } } = gremlin;
+
+const BATCH_SIZE = 50;
+
+async function submitBatch(batch) {
+ await Promise.allSettled(batch.map((t) => t.next()));
+}
+
+/**
+ * Populate TinkerGraph with discussion data from discoverDiscussions().
+ * Creates Discussion and Comment vertices plus has_comment, addresses,
+ * proposed_in, and modifies edges per the PR knowledge graph schema.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {object} discussions - Output from discoverDiscussions()
+ * @param {object} context
+ * @param {number} context.pr - PR number
+ * @param {string} context.prTitle - PR title
+ * @param {string[]} context.changedFiles - Files modified in the PR
+ * @returns {Promise<{vertices: number, edges: number, breakdown: object}>}
+ */
+export async function populateDiscussions(g, discussions, context) {
+ const counts = {
+ vertices: 0,
+ edges: 0,
+ breakdown: { discussions: 0, comments: 0, hasComment: 0, addresses: 0, proposedIn: 0, modifies: 0 },
+ };
+
+ const prUrl = `https://github.com/apache/tinkerpop/pull/${context.pr}`;
+
+ // Create the PR itself as a Discussion vertex
+ await g.addV("Discussion")
+ .property("url", prUrl)
+ .property("source", "pr")
+ .property("title", context.prTitle || `PR #${context.pr}`)
+ .property("body", "")
+ .next();
+ counts.vertices++;
+ counts.breakdown.discussions++;
+
+ // Create Discussion vertices for JIRAs
+ for (const jira of discussions.jiras) {
+ await g.addV("Discussion")
+ .property("url", jira.url)
+ .property("source", "jira")
+ .property("title", jira.title)
+ .property("body", (jira.body || "").slice(0, 2000))
+ .next();
+ counts.vertices++;
+ counts.breakdown.discussions++;
+
+ // Create Comment vertices for JIRA comments
+ for (const comment of (jira.comments || [])) {
+ await g.addV("Comment")
+ .property("author", comment.author)
+ .property("body", comment.body)
+ .property("timestamp", comment.timestamp || "")
+ .next();
+ counts.vertices++;
+ counts.breakdown.comments++;
+ }
+ }
+
+ // Create Discussion vertices for dev list threads
+ for (const thread of discussions.devList) {
+ await g.addV("Discussion")
+ .property("url", thread.url)
+ .property("source", "devlist")
+ .property("title", thread.title)
+ .property("body", (thread.body || "").slice(0, 2000))
+ .next();
+ counts.vertices++;
+ counts.breakdown.discussions++;
+ }
+
+ // Create Discussion vertices for secondary (cross-referenced) discussions
+ for (const sec of (discussions.secondary || [])) {
+ await g.addV("Discussion")
+ .property("url", sec.url)
+ .property("source", sec.source)
+ .property("title", sec.title || "")
+ .property("body", (sec.body || "").slice(0, 2000))
+ .next();
+ counts.vertices++;
+ counts.breakdown.discussions++;
+
+ for (const comment of (sec.comments || [])) {
+ await g.addV("Comment")
+ .property("author", comment.author)
+ .property("body", comment.body)
+ .property("timestamp", comment.timestamp || "")
+ .next();
+ counts.vertices++;
+ counts.breakdown.comments++;
+ }
+ }
+
+ // Create Comment vertices for PR issue comments
+ for (const comment of (discussions.prComments?.issue || [])) {
+ await g.addV("Comment")
+ .property("author", comment.author)
+ .property("body", comment.body)
+ .property("timestamp", comment.timestamp || "")
+ .next();
+ counts.vertices++;
+ counts.breakdown.comments++;
+ }
+
+ // Create Comment vertices for PR review comments
+ for (const comment of (discussions.prComments?.review || [])) {
+ await g.addV("Comment")
+ .property("author", comment.author)
+ .property("body", comment.body)
+ .property("timestamp", comment.timestamp || "")
+ .next();
+ counts.vertices++;
+ counts.breakdown.comments++;
+ }
+
+ // Create Discussion vertices for proposals
+ for (const proposal of (discussions.proposals || [])) {
+ await g.addV("Discussion")
+ .property("url", proposal.path || "")
+ .property("source", "proposal")
+ .property("title", proposal.title)
+ .property("body", (proposal.snippet || "").slice(0, 2000))
+ .next();
+ counts.vertices++;
+ counts.breakdown.discussions++;
+ }
+
+ // --- Edges (batched) ---
+ let batch = [];
+
+ // has_comment: PR Discussion -> PR comments
+ for (const comment of (discussions.prComments?.issue || [])) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", prUrl)
+ .addE("has_comment")
+ .to(__.V().hasLabel("Comment").has("author", comment.author).has("timestamp", comment.timestamp || ""))
+ );
+ counts.edges++;
+ counts.breakdown.hasComment++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ for (const comment of (discussions.prComments?.review || [])) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", prUrl)
+ .addE("has_comment")
+ .to(__.V().hasLabel("Comment").has("author", comment.author).has("timestamp", comment.timestamp || ""))
+ );
+ counts.edges++;
+ counts.breakdown.hasComment++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ // has_comment: JIRA Discussion -> JIRA comments
+ for (const jira of discussions.jiras) {
+ for (const comment of (jira.comments || [])) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", jira.url)
+ .addE("has_comment")
+ .to(__.V().hasLabel("Comment").has("author", comment.author).has("timestamp", comment.timestamp || ""))
+ );
+ counts.edges++;
+ counts.breakdown.hasComment++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+ }
+
+ // has_comment: Secondary Discussion -> their comments
+ for (const sec of (discussions.secondary || [])) {
+ for (const comment of (sec.comments || [])) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", sec.url)
+ .addE("has_comment")
+ .to(__.V().hasLabel("Comment").has("author", comment.author).has("timestamp", comment.timestamp || ""))
+ );
+ counts.edges++;
+ counts.breakdown.hasComment++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+ }
+
+ // addresses: PR Discussion -> JIRA discussions
+ for (const jira of discussions.jiras) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", prUrl)
+ .addE("addresses")
+ .property("found_in", jira.found_in || "pr")
+ .to(__.V().hasLabel("Discussion").has("url", jira.url))
+ );
+ counts.edges++;
+ counts.breakdown.addresses++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ // addresses: PR Discussion -> dev list threads
+ for (const thread of discussions.devList) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", prUrl)
+ .addE("addresses")
+ .property("found_in", thread.found_in || "pr")
+ .to(__.V().hasLabel("Discussion").has("url", thread.url))
+ );
+ counts.edges++;
+ counts.breakdown.addresses++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ // addresses: source Discussion -> secondary discussions (cross-references)
+ for (const sec of (discussions.secondary || [])) {
+ const sourceUrl = sec.found_via
+ ? (discussions.jiras.find((j) => j.id === sec.found_via)?.url || sec.found_via)
+ : prUrl;
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", sourceUrl)
+ .addE("addresses")
+ .property("found_in", sec.found_in || "")
+ .property("found_via", sec.found_via || "")
+ .to(__.V().hasLabel("Discussion").has("url", sec.url))
+ );
+ counts.edges++;
+ counts.breakdown.addresses++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ // proposed_in: link proposals to the PR Discussion
+ for (const proposal of (discussions.proposals || [])) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("source", "proposal").has("title", proposal.title)
+ .addE("proposed_in")
+ .to(__.V().hasLabel("Discussion").has("url", prUrl))
+ );
+ counts.edges++;
+ counts.breakdown.proposedIn++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ // modifies: PR Discussion -> changed Files
+ for (const filePath of (context.changedFiles || [])) {
+ batch.push(
+ g.V().hasLabel("Discussion").has("url", prUrl)
+ .addE("modifies")
+ .to(__.V().hasLabel("File").has("path", filePath))
+ );
+ counts.edges++;
+ counts.breakdown.modifies++;
+ if (batch.length >= BATCH_SIZE) { await submitBatch(batch); batch = []; }
+ }
+
+ if (batch.length > 0) {
+ await submitBatch(batch);
+ }
+
+ return counts;
+}
diff --git a/.skills/tinker-review/scripts/graph/populate.js b/.skills/tinker-review/scripts/graph/populate.js
new file mode 100644
index 0000000000..748dda561f
--- /dev/null
+++ b/.skills/tinker-review/scripts/graph/populate.js
@@ -0,0 +1,170 @@
+/*
+ * 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 gremlin from "gremlin";
+
+const { process: { statics: __ } } = gremlin;
+
+const BATCH_SIZE = 50;
+
+async function submitBatch(batch) {
+ const results = await Promise.allSettled(batch.map((t) => t.next()));
+ return results.filter((r) => r.status === "fulfilled").length;
+}
+
+/**
+ * Populate TinkerGraph with extraction data.
+ * Creates vertices and edges matching the PR knowledge graph schema.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {ExtractionResult} extraction - Output from tree-sitter module
+ * @returns {Promise<PopulationSummary>}
+ */
+export async function populate(g, extraction) {
+ const counts = {
+ vertices: 0,
+ edges: 0,
+ breakdown: { files: 0, functions: 0, types: 0, tests: 0, calls: 0, defines: 0, testsEdges: 0 },
+ };
+
+ for (const file of extraction.files) {
+ await g.addV("File")
+ .property("path", file.path)
+ .property("language", file.language)
+ .property("changed", file.changed)
+ .next();
+ counts.vertices++;
+ counts.breakdown.files++;
+ }
+
+ for (const fn of extraction.functions) {
+ await g.addV("Function")
+ .property("name", fn.name)
+ .property("signature", fn.signature)
+ .property("visibility", fn.visibility)
+ .property("filePath", fn.filePath)
+ .property("lines_start", fn.linesStart)
+ .property("lines_end", fn.linesEnd)
+ .property("changed", fn.changed)
+ .next();
+ counts.vertices++;
+ counts.breakdown.functions++;
+ }
+
+ for (const type of extraction.types) {
+ await g.addV("Type")
+ .property("name", type.name)
+ .property("kind", type.kind)
+ .property("visibility", type.visibility)
+ .property("filePath", type.filePath)
+ .next();
+ counts.vertices++;
+ counts.breakdown.types++;
+ }
+
+ const tests = extraction.tests || [];
+ for (const test of tests) {
+ await g.addV("Test")
+ .property("name", test.name)
+ .property("type", test.type)
+ .property("filePath", test.filePath)
+ .next();
+ counts.vertices++;
+ counts.breakdown.tests++;
+ }
+
+ let batch = [];
+
+ for (const fn of extraction.functions) {
+ batch.push(
+ g.V().hasLabel("File").has("path", fn.filePath)
+ .addE("defines")
+ .to(__.V().hasLabel("Function").has("name", fn.name).has("filePath", fn.filePath))
+ );
+ counts.edges++;
+ counts.breakdown.defines++;
+
+ if (batch.length >= BATCH_SIZE) {
+ await submitBatch(batch);
+ batch = [];
+ }
+ }
+
+ for (const type of extraction.types) {
+ batch.push(
+ g.V().hasLabel("File").has("path", type.filePath)
+ .addE("defines")
+ .to(__.V().hasLabel("Type").has("name", type.name).has("filePath", type.filePath))
+ );
+ counts.edges++;
+ counts.breakdown.defines++;
+
+ if (batch.length >= BATCH_SIZE) {
+ await submitBatch(batch);
+ batch = [];
+ }
+ }
+
+ for (const call of extraction.calls) {
+ batch.push(
+ g.V().hasLabel("Function")
+ .has("name", call.callerName)
+ .has("filePath", call.callerFile)
+ .addE("calls")
+ .to(__.V().hasLabel("Function").has("name", call.calleeName))
+ );
+ counts.edges++;
+ counts.breakdown.calls++;
+
+ if (batch.length >= BATCH_SIZE) {
+ await submitBatch(batch);
+ batch = [];
+ }
+ }
+
+ for (const test of tests) {
+ for (const calledFn of test.calledFunctions) {
+ batch.push(
+ g.V().hasLabel("Test").has("name", test.name).has("filePath", test.filePath)
+ .addE("tests")
+ .to(__.V().hasLabel("Function").has("name", calledFn))
+ );
+ counts.edges++;
+ counts.breakdown.testsEdges++;
+
+ if (batch.length >= BATCH_SIZE) {
+ await submitBatch(batch);
+ batch = [];
+ }
+ }
+ }
+
+ // Import resolution (depends_on edges) is intentionally not implemented.
+ // File-to-file connectivity is already captured through the calls/defines edges
+ // (File A defines Function X which calls Function Y defined in File B). The
+ // depends_on edge would only add value for type-only references (imports used
+ // for signatures/fields but not method calls) which are less relevant for
+ // review purposes. Revisit if cluster analysis produces false disconnections.
+
+ if (batch.length > 0) {
+ await submitBatch(batch);
+ }
+
+ return counts;
+}
diff --git a/.skills/tinker-review/scripts/infrastructure/docker.js b/.skills/tinker-review/scripts/infrastructure/docker.js
new file mode 100644
index 0000000000..085d9d4608
--- /dev/null
+++ b/.skills/tinker-review/scripts/infrastructure/docker.js
@@ -0,0 +1,128 @@
+/*
+ * 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 { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { createServer } from "node:net";
+import { get } from "node:http";
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+const exec = promisify(execFile);
+
+const DEFAULT_IMAGE = "tinkerpop/gremlin-server:3.8.1";
+const DEFAULT_TIMEOUT_MS = 30000;
+const POLL_INTERVAL_MS = 500;
+
+const INIT_GROOVY = `def globals = [:]
+globals << [g : traversal().withEmbedded(graph)]
+globals << [a : traversal().withEmbedded(graph).withComputer()]
+`;
+
+/**
+ * Start a Gremlin Server Docker container with TinkerGraph.
+ * Configures two traversal sources:
+ * 'g' — standard traversal
+ * 'a' — withComputer() for OLAP steps like connectedComponent()
+ *
+ * Uses the stock 3.8.1 image and config. Only overrides the init script
+ * to add the 'a' binding.
+ *
+ * @param {object} options
+ * @param {string} [options.image] - Docker image (default: "tinkerpop/gremlin-server:3.8.1")
+ * @param {number} [options.timeoutMs] - Max wait for readiness (default: 30000)
+ * @returns {Promise<ServerHandle>}
+ */
+export async function startServer(options = {}) {
+ const image = options.image || DEFAULT_IMAGE;
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT_MS;
+
+ const port = await findAvailablePort();
+ const configDir = `/tmp/gremlin-review-${port}`;
+
+ await mkdir(join(configDir, "scripts"), { recursive: true });
+ await writeFile(join(configDir, "scripts", "init.groovy"), INIT_GROOVY);
+
+ const { stdout } = await exec("docker", [
+ "run", "-d",
+ "--name", `pr-review-graph-${port}`,
+ "-p", `${port}:8182`,
+ "-v", `${configDir}/scripts/init.groovy:/opt/gremlin-server/scripts/empty-sample.groovy`,
+ image,
+ ]);
+
+ const containerId = stdout.trim();
+ const url = `ws://localhost:${port}/gremlin`;
+
+ const handle = { port, containerId, url, configDir };
+
+ try {
+ await waitForReady(port, timeoutMs);
+ } catch (err) {
+ await stopServer(handle).catch(() => {});
+ throw err;
+ }
+
+ return handle;
+}
+
+/**
+ * Stop and remove the Gremlin Server container.
+ *
+ * @param {ServerHandle} handle - Handle returned by startServer
+ * @returns {Promise<void>}
+ */
+export async function stopServer(handle) {
+ await exec("docker", ["stop", handle.containerId]).catch(() => {});
+ await exec("docker", ["rm", handle.containerId]).catch(() => {});
+}
+
+function findAvailablePort() {
+ return new Promise((resolve, reject) => {
+ const srv = createServer();
+ srv.listen(0, () => {
+ const { port } = srv.address();
+ srv.close(() => resolve(port));
+ });
+ srv.on("error", reject);
+ });
+}
+
+function waitForReady(port, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+
+ return new Promise((resolve, reject) => {
+ function poll() {
+ if (Date.now() > deadline) {
+ reject(new Error(`Gremlin Server did not become ready within ${timeoutMs}ms`));
+ return;
+ }
+
+ const req = get(`http://localhost:${port}/gremlin`, (res) => {
+ res.resume();
+ resolve();
+ });
+ req.on("error", () => {
+ setTimeout(poll, POLL_INTERVAL_MS);
+ });
+ }
+
+ poll();
+ });
+}
diff --git a/.skills/tinker-review/scripts/patterns/architecture.js b/.skills/tinker-review/scripts/patterns/architecture.js
new file mode 100644
index 0000000000..1a510ad414
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/architecture.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 gremlin from "gremlin";
+
+const { process: { t } } = gremlin;
+
+/**
+ * Generate architecture SVG input from the graph's cluster analysis.
+ *
+ * Queries File vertices and depends_on edges from the populated graph,
+ * groups files into clusters by directory path, and returns the node/edge
+ * structure expected by the renderer's generateClusterSvg().
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource (already connected)
+ * @param {object} params
+ * @param {object} [params.clusterResult] - Output from clusterAnalysis() (connectedComponent clusters)
+ * @param {boolean} [params.changedOnly] - Only include changed files (default: false)
+ * @param {number} [params.maxNodes] - Cap on nodes to render (default: 40)
+ * @returns {Promise<{nodes: Array, edges: Array}>}
+ */
+export async function architecture(g, params = {}) {
+ const { clusterResult, changedOnly = false, maxNodes = 40 } = params;
+
+ let traversal = g.V().hasLabel("File");
+ if (changedOnly) {
+ traversal = traversal.has("changed", true);
+ }
+
+ const fileVertices = await traversal.elementMap().toList();
+
+ if (fileVertices.length === 0) {
+ return { nodes: [], edges: [] };
+ }
+
+ const clusterAssignment = buildClusterAssignment(fileVertices, clusterResult);
+
+ let nodes = fileVertices.map((fileMap) => {
+ const path = fileMap.get("path");
+ const changed = fileMap.get("changed") || false;
+ const id = path;
+ const label = shortLabel(path);
+ const cluster = clusterAssignment.get(path) || dirCluster(path);
+
+ return { id, label, cluster, changed };
+ });
+
+ if (nodes.length > maxNodes) {
+ nodes = prioritizeNodes(nodes, maxNodes);
+ }
+
+ const nodeIds = new Set(nodes.map((n) => n.id));
+
+ // Find cross-file function calls: File A defines Function X which calls Function Y defined in File B
+ const edgeResults = await g.V().hasLabel("Function")
+ .as("caller")
+ .out("calls")
+ .as("callee")
+ .select("caller", "callee")
+ .by("filePath")
+ .toList();
+
+ const edgeSet = new Set();
+ const edges = [];
+ for (const row of edgeResults) {
+ const from = row.get("caller");
+ const to = row.get("callee");
+ if (from && to && from !== to && nodeIds.has(from) && nodeIds.has(to)) {
+ const key = `${from}|${to}`;
+ if (!edgeSet.has(key)) {
+ edgeSet.add(key);
+ edges.push({ from, to });
+ }
+ }
+ }
+
+ return { nodes, edges };
+}
+
+function buildClusterAssignment(fileVertices, clusterResult) {
+ const assignment = new Map();
+
+ if (clusterResult && clusterResult.clusters) {
+ for (const cluster of clusterResult.clusters) {
+ const clusterLabel = deriveClusterLabel(cluster.files);
+ for (const filePath of cluster.files) {
+ assignment.set(filePath, clusterLabel);
+ }
+ }
+ } else {
+ for (const fileMap of fileVertices) {
+ const path = fileMap.get("path");
+ assignment.set(path, dirCluster(path));
+ }
+ }
+
+ return assignment;
+}
+
+function deriveClusterLabel(files) {
+ if (files.length === 0) return "other";
+ if (files.length === 1) return dirCluster(files[0]);
+
+ const segments = files.map((f) => f.split("/").slice(0, -1));
+ const minLen = Math.min(...segments.map((s) => s.length));
+
+ let common = 0;
+ for (let i = 0; i < minLen; i++) {
+ const seg = segments[0][i];
+ if (segments.every((s) => s[i] === seg)) {
+ common = i + 1;
+ } else {
+ break;
+ }
+ }
+
+ if (common > 0) {
+ return segments[0].slice(0, common).join("/");
+ }
+
+ return dirCluster(files[0]);
+}
+
+function dirCluster(filePath) {
+ const parts = filePath.split("/");
+ if (parts.length <= 1) return "/";
+ if (parts.length <= 3) return parts.slice(0, -1).join("/");
+ return parts.slice(0, 2).join("/");
+}
+
+function shortLabel(filePath) {
+ const parts = filePath.split("/");
+ return parts[parts.length - 1];
+}
+
+function prioritizeNodes(nodes, maxNodes) {
+ const changed = nodes.filter((n) => n.changed);
+ const unchanged = nodes.filter((n) => !n.changed);
+
+ if (changed.length >= maxNodes) {
+ return changed.slice(0, maxNodes);
+ }
+
+ return [...changed, ...unchanged.slice(0, maxNodes - changed.length)];
+}
diff --git a/.skills/tinker-review/scripts/patterns/blast-radius.js b/.skills/tinker-review/scripts/patterns/blast-radius.js
new file mode 100644
index 0000000000..be96bda19e
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/blast-radius.js
@@ -0,0 +1,93 @@
+/*
+ * 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 gremlin from "gremlin";
+
+const INHERENTLY_CENTRAL = new Set([
+ "equals", "hashCode", "toString", "clone", "close", "compareTo",
+ "iterator", "hasNext", "next", "get", "set", "size", "isEmpty",
+ "contains", "add", "remove", "clear", "values", "entrySet", "keySet",
+ "finalize", "notify", "notifyAll", "wait",
+]);
+
+/**
+ * Calculate blast radius — how many functions are reachable downstream
+ * from changed functions via call edges. High blast radius means a change
+ * here affects many callers.
+ *
+ * Methods that are inherently central (equals, toString, etc.) are filtered
+ * out UNLESS they were modified in this PR.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {number} [params.depth] - Max hops to traverse (default: 3)
+ * @param {boolean} [params.changedOnly] - Start from changed functions only (default: true)
+ * @returns {Promise<BlastRadiusResult>}
+ */
+export async function blastRadius(g, params = {}) {
+ const depth = params.depth || 3;
+ const changedOnly = params.changedOnly !== false;
+
+ let traversal = g.V().hasLabel("Function");
+ if (changedOnly) {
+ traversal = traversal.has("changed", true);
+ }
+
+ const functions = await traversal.elementMap().toList();
+ const results = [];
+
+ for (const fnMap of functions) {
+ const vertexId = fnMap.get(gremlin.process.t.id);
+ const name = fnMap.get("name");
+ const changed = fnMap.get("changed");
+
+ if (INHERENTLY_CENTRAL.has(name) && !changed) continue;
+
+ const reachable = await g.V(vertexId)
+ .repeat(gremlin.process.statics.in_("calls"))
+ .times(depth)
+ .emit()
+ .dedup()
+ .count()
+ .next();
+
+ const count = reachable.value;
+ if (count > 0) {
+ results.push({
+ name,
+ filePath: fnMap.get("filePath"),
+ signature: fnMap.get("signature"),
+ linesStart: fnMap.get("lines_start"),
+ linesEnd: fnMap.get("lines_end"),
+ changed,
+ reachableCount: count,
+ depth,
+ });
+ }
+ }
+
+ results.sort((a, b) => b.reachableCount - a.reachableCount);
+
+ return {
+ functions: results,
+ maxReachable: results.length > 0 ? results[0].reachableCount : 0,
+ totalWithCallers: results.length,
+ depth,
+ };
+}
diff --git a/.skills/tinker-review/scripts/patterns/centrality.js b/.skills/tinker-review/scripts/patterns/centrality.js
new file mode 100644
index 0000000000..11aa0ca259
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/centrality.js
@@ -0,0 +1,100 @@
+/*
+ * 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 gremlin from "gremlin";
+
+const INHERENTLY_CENTRAL = new Set([
+ "equals", "hashCode", "toString", "clone", "close", "compareTo",
+ "iterator", "hasNext", "next", "get", "set", "size", "isEmpty",
+ "contains", "add", "remove", "clear", "values", "entrySet", "keySet",
+ "finalize", "notify", "notifyAll", "wait",
+]);
+
+/**
+ * Identify high-centrality functions — those with many incoming and outgoing
+ * call edges. These are structural hotspots where changes propagate widely.
+ *
+ * Methods that are inherently central by nature (equals, toString, hashCode, etc.)
+ * are filtered out UNLESS they were modified in this PR — in which case they're
+ * highly relevant because many callers depend on their behavior.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {boolean} [params.changedOnly] - Only check changed functions (default: true)
+ * @param {number} [params.topN] - Return top N results (default: 10)
+ * @param {number} [params.minDegree] - Minimum combined in+out degree to include (default: 3)
+ * @returns {Promise<CentralityResult>}
+ */
+export async function highCentrality(g, params = {}) {
+ const changedOnly = params.changedOnly !== false;
+ const topN = params.topN || 10;
+ const minDegree = params.minDegree || 3;
+
+ let traversal = g.V().hasLabel("Function");
+ if (changedOnly) {
+ traversal = traversal.has("changed", true);
+ }
+
+ const functions = await traversal.elementMap().toList();
+ const results = [];
+ const filtered = [];
+
+ for (const fnMap of functions) {
+ const vertexId = fnMap.get(gremlin.process.t.id);
+ const name = fnMap.get("name");
+ const changed = fnMap.get("changed");
+
+ const inDegree = await g.V(vertexId).inE("calls").count().next();
+ const outDegree = await g.V(vertexId).outE("calls").count().next();
+
+ const inCount = inDegree.value;
+ const outCount = outDegree.value;
+ const totalDegree = inCount + outCount;
+
+ if (totalDegree < minDegree) continue;
+
+ const entry = {
+ name,
+ filePath: fnMap.get("filePath"),
+ signature: fnMap.get("signature"),
+ linesStart: fnMap.get("lines_start"),
+ linesEnd: fnMap.get("lines_end"),
+ changed,
+ inDegree: inCount,
+ outDegree: outCount,
+ totalDegree,
+ inherentlyCentral: INHERENTLY_CENTRAL.has(name),
+ };
+
+ if (INHERENTLY_CENTRAL.has(name) && !changed) {
+ filtered.push(entry);
+ } else {
+ results.push(entry);
+ }
+ }
+
+ results.sort((a, b) => b.totalDegree - a.totalDegree);
+
+ return {
+ hotspots: results.slice(0, topN),
+ totalAnalyzed: functions.length,
+ aboveThreshold: results.length,
+ filteredAsBoilerplate: filtered.length,
+ };
+}
diff --git a/.skills/tinker-review/scripts/patterns/cluster-analysis.js b/.skills/tinker-review/scripts/patterns/cluster-analysis.js
new file mode 100644
index 0000000000..f446dc754e
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/cluster-analysis.js
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+/**
+ * Determine whether a PR's changed files form one coherent change or multiple
+ * disconnected clusters. Uses connectedComponent() via the OLAP traversal source.
+ *
+ * A single cluster means the PR is one logical change. Multiple clusters suggest
+ * the PR bundles unrelated changes or has hidden dependencies the graph didn't capture.
+ *
+ * @param {object} a - gremlin-js GraphTraversalSource bound with withComputer() (the 'a' source)
+ * @param {object} params
+ * @param {boolean} [params.changedOnly] - Only analyze changed files (default: true)
+ * @returns {Promise<ClusterResult>}
+ */
+export async function clusterAnalysis(a, params = {}) {
+ const changedOnly = params.changedOnly !== false;
+
+ let traversal = a.V().hasLabel("File");
+ if (changedOnly) {
+ traversal = traversal.has("changed", true);
+ }
+
+ const results = await traversal
+ .connectedComponent()
+ .project("path", "component")
+ .by("path")
+ .by("gremlin.connectedComponentVertexProgram.component")
+ .toList();
+
+ const clusterMap = new Map();
+ for (const r of results) {
+ const path = r.get("path");
+ const component = r.get("component");
+ if (!clusterMap.has(component)) {
+ clusterMap.set(component, []);
+ }
+ clusterMap.get(component).push(path);
+ }
+
+ const clusters = [...clusterMap.values()]
+ .sort((a, b) => b.length - a.length)
+ .map((files, i) => ({
+ id: i + 1,
+ files,
+ size: files.length,
+ }));
+
+ return {
+ clusterCount: clusters.length,
+ coherent: clusters.length <= 1,
+ clusters,
+ totalFiles: results.length,
+ };
+}
diff --git a/.skills/tinker-review/scripts/patterns/completeness.js b/.skills/tinker-review/scripts/patterns/completeness.js
new file mode 100644
index 0000000000..788d6faf7c
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/completeness.js
@@ -0,0 +1,93 @@
+/*
+ * 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 gremlin from "gremlin";
+
+/**
+ * Check that a vertex has all expected outgoing/incoming edge labels.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {string} params.vertexLabel - Label of vertex to check (e.g., "Step")
+ * @param {string} [params.vertexName] - Name property to filter by
+ * @param {string[]} params.expectedEdges - Edge labels that should exist
+ * Prefix with "in:" or "out:" for direction (default: "out:")
+ * e.g., ["out:has_rule", "in:implements_step", "in:covers", "in:documents"]
+ * @returns {Promise<CompletenessResult[]>}
+ */
+export async function completeness(g, params) {
+ const { vertexLabel, vertexName, expectedEdges } = params;
+
+ let traversal = g.V().hasLabel(vertexLabel);
+ if (vertexName) {
+ traversal = traversal.has("name", vertexName);
+ }
+
+ const vertices = await traversal.elementMap().toList();
+
+ const results = [];
+
+ for (const vertexMap of vertices) {
+ const vertexId = vertexMap.get(gremlin.process.t.id);
+ const name = vertexMap.get("name") || String(vertexId);
+
+ const present = [];
+ const missing = [];
+
+ for (const edgeSpec of expectedEdges) {
+ const { direction, label } = parseEdgeSpec(edgeSpec);
+ const exists = await edgeExists(g, vertexId, direction, label);
+
+ if (exists) {
+ present.push(edgeSpec);
+ } else {
+ missing.push(edgeSpec);
+ }
+ }
+
+ const total = present.length + missing.length;
+ const score = total > 0 ? present.length / total : 1;
+
+ results.push({ node: name, present, missing, score });
+ }
+
+ return results;
+}
+
+function parseEdgeSpec(spec) {
+ if (spec.startsWith("in:")) {
+ return { direction: "in", label: spec.slice(3) };
+ }
+ if (spec.startsWith("out:")) {
+ return { direction: "out", label: spec.slice(4) };
+ }
+ return { direction: "out", label: spec };
+}
+
+async function edgeExists(g, vertexId, direction, label) {
+ let traversal;
+ if (direction === "in") {
+ traversal = g.V(vertexId).inE(label);
+ } else {
+ traversal = g.V(vertexId).outE(label);
+ }
+
+ const edges = await traversal.limit(1).toList();
+ return edges.length > 0;
+}
diff --git a/.skills/tinker-review/scripts/patterns/coverage-gaps.js b/.skills/tinker-review/scripts/patterns/coverage-gaps.js
new file mode 100644
index 0000000000..b570fea2e7
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/coverage-gaps.js
@@ -0,0 +1,69 @@
+/*
+ * 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 gremlin from "gremlin";
+
+/**
+ * Find changed functions that have no incoming 'tests' edge.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {boolean} [params.changedOnly] - Only check functions with changed=true (default: true)
+ * @returns {Promise<CoverageGapResult>}
+ */
+export async function coverageGaps(g, params = {}) {
+ const changedOnly = params.changedOnly !== false;
+
+ let traversal = g.V().hasLabel("Function");
+ if (changedOnly) {
+ traversal = traversal.has("changed", true);
+ }
+
+ const functions = await traversal.elementMap().toList();
+
+ const testFilePaths = new Set(
+ (await g.V().hasLabel("Test").values("filePath").toList())
+ );
+
+ const nonTestFunctions = functions.filter(
+ (fnMap) => !testFilePaths.has(fnMap.get("filePath"))
+ );
+
+ const totalChanged = nonTestFunctions.length;
+ const uncovered = [];
+
+ for (const fnMap of nonTestFunctions) {
+ const vertexId = fnMap.get(gremlin.process.t.id);
+ const hasTest = await g.V(vertexId).inE("tests").limit(1).toList();
+
+ if (hasTest.length === 0) {
+ uncovered.push({
+ name: fnMap.get("name"),
+ signature: fnMap.get("signature"),
+ filePath: fnMap.get("filePath"),
+ linesStart: fnMap.get("lines_start"),
+ linesEnd: fnMap.get("lines_end"),
+ });
+ }
+ }
+
+ const totalCovered = totalChanged - uncovered.length;
+
+ return { uncovered, totalChanged, totalCovered };
+}
diff --git a/.skills/tinker-review/scripts/patterns/orphans.js b/.skills/tinker-review/scripts/patterns/orphans.js
new file mode 100644
index 0000000000..2cafaa7021
--- /dev/null
+++ b/.skills/tinker-review/scripts/patterns/orphans.js
@@ -0,0 +1,74 @@
+/*
+ * 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 gremlin from "gremlin";
+
+/**
+ * Find orphan vertices — nodes that are missing expected relationships.
+ * Useful for detecting undocumented functions, untested steps, or
+ * disconnected code.
+ *
+ * @param {object} g - gremlin-js GraphTraversalSource
+ * @param {object} params
+ * @param {string} params.vertexLabel - Label to check (e.g., "Step", "Function")
+ * @param {string} params.expectedEdge - Edge label that should exist
+ * @param {string} [params.direction] - "in" or "out" (default: "in")
+ * @param {boolean} [params.changedOnly] - Only check changed vertices (default: false)
+ * @returns {Promise<OrphanResult>}
+ */
+export async function orphans(g, params) {
+ const { vertexLabel, expectedEdge } = params;
+ const direction = params.direction || "in";
+ const changedOnly = params.changedOnly || false;
+
+ let traversal = g.V().hasLabel(vertexLabel);
+ if (changedOnly) {
+ traversal = traversal.has("changed", true);
+ }
+
+ const vertices = await traversal.elementMap().toList();
+ const orphaned = [];
+
+ for (const vMap of vertices) {
+ const vertexId = vMap.get(gremlin.process.t.id);
+
+ let edgeTraversal;
+ if (direction === "in") {
+ edgeTraversal = g.V(vertexId).inE(expectedEdge);
+ } else {
+ edgeTraversal = g.V(vertexId).outE(expectedEdge);
+ }
+
+ const edges = await edgeTraversal.limit(1).toList();
+ if (edges.length === 0) {
+ orphaned.push({
+ name: vMap.get("name") || vMap.get("path") || String(vertexId),
+ label: vertexLabel,
+ filePath: vMap.get("filePath") || vMap.get("path"),
+ missingEdge: `${direction}:${expectedEdge}`,
+ });
+ }
+ }
+
+ return {
+ orphaned,
+ totalChecked: vertices.length,
+ totalOrphaned: orphaned.length,
+ };
+}
diff --git a/.skills/tinker-review/scripts/renderer/render.js b/.skills/tinker-review/scripts/renderer/render.js
new file mode 100644
index 0000000000..ae18e32829
--- /dev/null
+++ b/.skills/tinker-review/scripts/renderer/render.js
@@ -0,0 +1,422 @@
+/*
+ * 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 { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const TEMPLATE = readFileSync(join(__dirname, "template.html"), "utf-8");
+
+function esc(str) {
+ return String(str || "")
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Render the full report from evidence data + agent narrative.
+ * Output matches the structure of reference-report.html exactly.
+ *
+ * Input contract — data fields (from review.js):
+ * meta: { pr, title, domains: [], language, changedFileCount, timestamp }
+ * graphStats: { vertices, edges, breakdown: { files, functions, types, tests, calls } }
+ * checks: { completeness, coverageGaps, centrality, blastRadius, clusters }
+ * discussions: { jiras, devList, secondary, prComments, devListSearchKeywords, ... }
+ * changedFiles: []
+ *
+ * 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" }]
+ * functionalTest: { plan: "HTML", results: [{name, pass, output}], observations: ["HTML"] }
+ * findings: [{ title, snippet: "code", body: "HTML" }]
+ * openQuestions: [{ title, body: "HTML", meta: "string" }]
+ * appendixFunctional: { environment: "HTML", testCode: "code", fullOutput: "code" }
+ */
+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) {
+ const { meta, graphStats, checks, discussions, summary, clusters,
+ guidedWalk, functionalTest, findings, openQuestions, appendixFunctional } = evidence;
+
+ const parts = [];
+ parts.push(renderHeader(meta));
+ parts.push(renderNav());
+ parts.push(summary ? renderSummary(summary) : notProvided("summary", "Summary"));
+ parts.push(discussions ? renderContext(discussions) : notProvided("context", "Discovered Context"));
+ parts.push(renderClusters(clusters, checks && checks.clusters, evidence.architecture));
+ parts.push(guidedWalk && guidedWalk.length > 0 ? renderGuidedWalk(guidedWalk) : 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"));
+ parts.push(renderAppendixStructural(checks, graphStats));
+ parts.push(appendixFunctional ? renderAppendixFunctional(appendixFunctional) : notProvided("appendix-functional", "Appendix: Functional Test Details"));
+ parts.push(`<footer>Graph Review — Apache TinkerPop | PR #${esc(String(meta.pr))} | Generated ${esc(meta.timestamp)}</footer>`);
+
+ return TEMPLATE.replace("{{content}}", parts.join("\n\n"));
+}
+
+function renderHeader(meta) {
+ const domains = (meta.domains || []).join(", ");
+ return `<header>
+ <h1>PR #${esc(String(meta.pr))} — ${esc(meta.title)}</h1>
+ <div class="meta">
+ <span class="badge badge-domain">${esc(domains)}</span>
+ <span>${meta.changedFileCount} files changed</span>
+ </div>
+</header>`;
+}
+
+function renderNav() {
+ return `<nav>
+ <h2>Contents</h2>
+ <ul>
+ <li><a href="#summary">Summary</a></li>
+ <li><a href="#context">Context</a></li>
+ <li><a href="#clusters">Clusters</a></li>
+ <li><a href="#guided-walk">Guided Walk</a></li>
+ <li><a href="#functional-test">Functional Test</a></li>
+ <li><a href="#findings">Findings</a></li>
+ <li><a href="#open-questions">Open Questions</a></li>
+ <li><a href="#appendix-structural">Appendix: Structural</a></li>
+ <li><a href="#appendix-functional">Appendix: Functional Test</a></li>
+ </ul>
+</nav>`;
+}
+
+function renderSummary(summary) {
+ return `<section id="summary">\n <h2>Summary</h2>\n ${summary}\n</section>`;
+}
+
+function renderContext(disc) {
+ const cards = [];
+
+ for (const jira of (disc.jiras || [])) {
+ let commentsHtml = "";
+ if (jira.comments && jira.comments.length > 0) {
+ const items = jira.comments.map(c =>
+ `<li><em>${esc(c.author)}:</em> "${esc((c.body || "").slice(0, 150))}"</li>`
+ ).join("\n ");
+ commentsHtml = `\n <p style="margin-top: 0.5rem;"><strong>JIRA comments:</strong></p>\n <ul style="margin: 0.25rem 0 0 1.5rem; font-size: 0.875rem;">\n ${items}\n </ul>`;
+ }
+ cards.push(`<div class="card card-context">
+ <h3><span class="badge badge-context">JIRA</span> <a href="${esc(jira.url)}">${esc(jira.id)}</a>: ${esc(jira.title)}</h3>
+ <p>Found in: ${esc(jira.found_in || "pr")}. Status: <strong>${esc(jira.status)}</strong>.</p>${commentsHtml}
+ </div>`);
+ }
+
+ for (const sec of (disc.secondary || [])) {
+ cards.push(`<div class="card card-context">
+ <h3><span class="badge badge-context">JIRA</span> <a href="${esc(sec.url)}">${esc(sec.id || "")}</a>: ${esc(sec.title)}</h3>
+ <p>Found via: ${esc(sec.found_via)} (${esc(sec.found_in)}).</p>
+ </div>`);
+ }
+
+ if (disc.devList && disc.devList.length > 0) {
+ for (const d of disc.devList) {
+ cards.push(`<div class="card card-context">\n <h3>Dev List: ${esc(d.title)}</h3>\n <p><a href="${esc(d.url)}">${esc(d.url)}</a></p>\n </div>`);
+ }
+ } else {
+ const kw = (disc.devListSearchKeywords || []).join(", ");
+ const meta = kw ? `\n <p class="discovery-meta">Searched dev@tinkerpop.apache.org (last 1 year) for: ${esc(kw)}</p>` : "";
+ cards.push(`<div class="card">\n <h3>Dev List</h3>\n <p>No discussion found.</p>${meta}\n </div>`);
+ }
+
+ if (disc.proposalMissing !== false) {
+ cards.push(`<div class="card">\n <h3>Proposals</h3>\n <p>No matching proposal in <code>docs/src/dev/future/</code>.</p>\n </div>`);
+ }
+
+ return `<section id="context">\n <h2>Discovered Context</h2>\n ${cards.join("\n ")}\n</section>`;
+}
+
+function generateClusterSvg(clusterData, architecture) {
+ if (!clusterData || !clusterData.clusters || clusterData.clusters.length === 0) return "";
+
+ const clusters = clusterData.clusters;
+ const primary = clusters[0];
+ const satellites = clusters.slice(1);
+ const hasSatellites = satellites.length > 0;
+
+ const svgWidth = hasSatellites ? 900 : 700;
+ const primaryWidth = hasSatellites ? 650 : 680;
+
+ // Group primary cluster files by directory
+ const groups = new Map();
+ for (const filePath of primary.files) {
+ const parts = filePath.split("/");
+ const filename = parts.pop();
+ const dir = parts.slice(-2).join("/") || "root";
+ if (!groups.has(dir)) groups.set(dir, []);
+ groups.get(dir).push(filename);
+ }
+
+ let svgContent = "";
+ const groupEntries = [...groups.entries()].slice(0, 6);
+
+ // Primary cluster background
+ const primaryHeight = Math.max(200, groupEntries.length * 70 + 60);
+ svgContent += `<rect x="10" y="10" width="${primaryWidth}" height="${primaryHeight}" rx="8" fill="#e8f5e9" stroke="#4caf50" stroke-width="1.5" stroke-dasharray="4"/>`;
+ svgContent += `<text x="25" y="32" font-size="11" font-weight="600" fill="#2e7d32">Cluster 1 — Primary (${primary.size} files)</text>`;
+
+ // Render groups inside primary
+ const colors = ["#1976d2", "#f57c00", "#7b1fa2", "#388e3c", "#455a64", "#546e7a"];
+ let y = 50;
+ for (let i = 0; i < groupEntries.length; i++) {
+ const [dir, files] = groupEntries[i];
+ const color = colors[i % colors.length];
+ const boxHeight = Math.min(files.length * 12 + 30, 90);
+ svgContent += `<rect x="30" y="${y}" width="280" height="${boxHeight}" rx="5" fill="#fff" stroke="${color}" stroke-width="1.5"/>`;
+ svgContent += `<text x="170" y="${y + 18}" font-size="9" font-weight="600" fill="${color}" text-anchor="middle">${esc(dir)}</text>`;
+ const shown = files.slice(0, 4);
+ for (let j = 0; j < shown.length; j++) {
+ svgContent += `<text x="170" y="${y + 33 + j * 12}" font-size="8" fill="#333" text-anchor="middle">${esc(shown[j])}</text>`;
+ }
+ if (files.length > 4) {
+ svgContent += `<text x="170" y="${y + 33 + 4 * 12}" font-size="8" fill="#666" text-anchor="middle">+${files.length - 4} more</text>`;
+ }
+ y += boxHeight + 10;
+ }
+
+ // Satellite clusters
+ if (hasSatellites) {
+ let satY = 10;
+ for (const sat of satellites.slice(0, 3)) {
+ const satHeight = Math.min(sat.files.length * 12 + 50, 100);
+ svgContent += `<rect x="680" y="${satY}" width="210" height="${satHeight}" rx="8" fill="#fff3e0" stroke="#ff9800" stroke-width="1.5" stroke-dasharray="4"/>`;
+ svgContent += `<text x="695" y="${satY + 20}" font-size="10" font-weight="600" fill="#e65100">Satellite (${sat.size} files)</text>`;
+ const shown = sat.files.slice(0, 4);
+ for (let j = 0; j < shown.length; j++) {
+ const fname = shown[j].split("/").pop();
+ svgContent += `<text x="785" y="${satY + 38 + j * 13}" font-size="8" fill="#333" text-anchor="middle">${esc(fname)}</text>`;
+ }
+ satY += satHeight + 15;
+ }
+ }
+
+ // Draw edges between directory groups based on cross-file function calls
+ const archEdges = (architecture && architecture.edges) || [];
+ if (archEdges.length > 0 && groupEntries.length > 1) {
+ const fileToGroup = new Map();
+ for (let i = 0; i < groupEntries.length; i++) {
+ const [dir, files] = groupEntries[i];
+ for (const f of files) {
+ fileToGroup.set(f, i);
+ }
+ }
+
+ // Also map full paths to group index
+ for (const filePath of primary.files) {
+ const fname = filePath.split("/").pop();
+ const parts = filePath.split("/");
+ const dir = parts.slice(-3, -1).join("/") || "root";
+ for (let i = 0; i < groupEntries.length; i++) {
+ if (groupEntries[i][0] === dir || groupEntries[i][1].includes(fname)) {
+ fileToGroup.set(filePath, i);
+ break;
+ }
+ }
+ }
+
+ const groupCenters = groupEntries.map((_, i) => ({ x: 170, y: 50 + i * 80 + 35 }));
+ const drawnEdges = new Set();
+
+ for (const edge of archEdges) {
+ const fromGroup = fileToGroup.get(edge.from) ?? fileToGroup.get(edge.from.split("/").pop());
+ const toGroup = fileToGroup.get(edge.to) ?? fileToGroup.get(edge.to.split("/").pop());
+ if (fromGroup != null && toGroup != null && fromGroup !== toGroup) {
+ const key = `${Math.min(fromGroup, toGroup)}-${Math.max(fromGroup, toGroup)}`;
+ if (!drawnEdges.has(key) && groupCenters[fromGroup] && groupCenters[toGroup]) {
+ drawnEdges.add(key);
+ const from = groupCenters[fromGroup];
+ const to = groupCenters[toGroup];
+ svgContent += `<line x1="${from.x + 140}" y1="${from.y}" x2="${to.x + 140}" y2="${to.y}" stroke="#999" stroke-width="1.5" stroke-dasharray="3" marker-end="url(#arrow)"/>`;
+ }
+ }
+ }
+
+ if (drawnEdges.size > 0) {
+ svgContent = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#999"/></marker></defs>` + svgContent;
+ }
+ }
+
+ const svgHeight = Math.max(primaryHeight + 20, 200);
+ return `<svg class="cluster-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" xmlns="http://www.w3.org/2000/svg">
+ ${svgContent}
+</svg>`;
+}
+
+function renderClusters(clusters, clusterData, architecture) {
+ const svg = generateClusterSvg(clusterData, architecture);
+ const assessment = (clusters && clusters.assessment) || "<p>No assessment provided.</p>";
+
+ return `<section id="clusters">
+ <h2>Change Coherence</h2>
+ <p class="section-intro">Connected component analysis of changed files. Files are connected when their functions call each other. A single cluster means one logical change; multiple clusters suggest bundled unrelated changes.</p>
+ ${svg}
+ <div class="card card-safe">
+ <h3>Assessment</h3>
+ ${assessment}
+ </div>
+</section>`;
+}
+
+function renderGuidedWalk(walk) {
+ 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>` : "";
+ return `<div class="card ${cls}">
+ <h3><span class="walk-number">${i + 1}</span> ${step.title}${badge}</h3>
+ ${step.body}
+ </div>`;
+ }).join("\n ");
+ return `<section id="guided-walk">\n <h2>Guided Walk</h2>\n ${steps}\n</section>`;
+}
+
+function renderFunctionalTest(ft) {
+ const plan = ft.plan ? `<div class="card card-test">\n <h3>Test Plan</h3>\n ${ft.plan}\n </div>` : "";
+
+ let results = "";
+ if (ft.results && ft.results.length > 0) {
+ const passed = ft.results.filter(r => r.pass).length;
+ const total = ft.results.length;
+ const badgeText = passed === total ? "All Pass" : `${passed}/${total}`;
+ const grid = ft.results.map(r =>
+ `<span class="${r.pass ? "test-pass" : "test-fail"}">${r.pass ? "PASS" : "FAIL"}</span><span>${esc(r.name)}</span><span><code>${esc(r.output || "")}</code></span>`
+ ).join("\n ");
+ results = `<div class="card card-safe">
+ <h3>Results: ${passed}/${total} passed <span class="badge badge-safe">${badgeText}</span></h3>
+ <div class="test-grid">\n ${grid}\n </div>
+ </div>`;
+ }
+
+ let obs = "";
+ if (ft.observations && ft.observations.length > 0) {
+ const items = ft.observations.map(o => `<li>${o}</li>`).join("\n ");
+ obs = `<div class="card">\n <h3>Observations</h3>\n <ul style="margin: 0.5rem 0 0 1.5rem; font-size: 0.9rem;">\n ${items}\n </ul>\n </div>`;
+ }
+
+ return `<section id="functional-test">
+ <h2>Functional Test</h2>
+ <p class="section-intro">Built from PR source and tested against a live Gremlin Server. Tests derived from documentation and Gherkin features only. <a href="#appendix-functional">Full execution details in appendix.</a></p>
+ ${plan}
+ ${results}
+ ${obs}
+</section>`;
+}
+
+function renderFindings(findings) {
+ const cards = findings.map((f, i) => {
+ const snippet = f.snippet ? `\n <pre><code>${esc(f.snippet)}</code></pre>` : "";
+ return `<div class="card finding">
+ <p class="finding-title">${i + 1}. ${esc(f.title)}</p>${snippet}
+ ${f.body}
+ </div>`;
+ }).join("\n ");
+ return `<section id="findings">\n <h2>Findings</h2>\n ${cards}\n</section>`;
+}
+
+function renderOpenQuestions(questions) {
+ const cards = questions.map(q => {
+ const meta = q.meta ? `\n <p class="discovery-meta">${esc(q.meta)}</p>` : "";
+ return `<div class="card">\n <h3>${esc(q.title)}</h3>\n ${q.body}${meta}\n </div>`;
+ }).join("\n ");
+ return `<section id="open-questions">\n <h2>Open Questions</h2>\n ${cards}\n</section>`;
+}
+
+function renderAppendixStructural(checks, graphStats) {
+ const hotspots = checks?.centrality?.hotspots || [];
+ const blast = checks?.blastRadius?.functions || [];
+ const stats = graphStats || {};
+ const bd = stats.breakdown || {};
+
+ const hotspotRows = hotspots.slice(0, 10).map(h => {
+ const badge = (h.inherentlyCentral && h.changed) ? `<span class="badge badge-modified">modified</span>` : "";
+ return `<tr><td class="fn-name">${esc(h.name)}</td><td>${esc((h.filePath || "").split("/").pop())}</td><td class="num">${h.inDegree}</td><td class="num">${h.outDegree}</td><td class="num"><strong>${h.totalDegree}</strong></td><td>${badge}</td></tr>`;
+ }).join("\n ");
+
+ const blastRows = blast.slice(0, 10).map(b => {
+ const badge = b.changed ? `<span class="badge badge-modified">modified</span>` : "";
+ return `<tr><td class="fn-name">${esc(b.name)}</td><td>${esc((b.filePath || "").split("/").pop())}</td><td class="num">${b.reachableCount}</td><td>${badge}</td></tr>`;
+ }).join("\n ");
+
+ return `<section id="appendix-structural">
+ <h2>Appendix: Structural Data</h2>
+
+ <h3>Structural Hotspots</h3>
+ <p class="section-intro">Functions with the most incoming/outgoing call edges among changed code. Inherently-central methods (equals, toString) included only when modified by this PR.</p>
+ <table class="gap-table">
+ <thead><tr><th>Function</th><th>File</th><th>In</th><th>Out</th><th>Total</th><th></th></tr></thead>
+ <tbody>\n ${hotspotRows}\n </tbody>
+ </table>
+
+ <h3>Blast Radius</h3>
+ <p class="section-intro">Reachable callers within 3 hops upstream — higher means more code affected by behavioral changes.</p>
+ <table class="gap-table">
+ <thead><tr><th>Function</th><th>File</th><th>Reachable</th><th></th></tr></thead>
+ <tbody>\n ${blastRows}\n </tbody>
+ </table>
+
+ <h3>Graph Statistics</h3>
+ <div class="stats-grid">
+ <div class="stat-box"><div class="value">${stats.vertices || 0}</div><div class="label">Vertices</div></div>
+ <div class="stat-box"><div class="value">${stats.edges || 0}</div><div class="label">Edges</div></div>
+ <div class="stat-box"><div class="value">${bd.files || 0}</div><div class="label">Files</div></div>
+ <div class="stat-box"><div class="value">${bd.functions || 0}</div><div class="label">Functions</div></div>
+ <div class="stat-box"><div class="value">${bd.types || 0}</div><div class="label">Types</div></div>
+ <div class="stat-box"><div class="value">${bd.tests || 0}</div><div class="label">Tests</div></div>
+ </div>
+</section>`;
+}
+
+function renderAppendixFunctional(af) {
+ return `<section id="appendix-functional">
+ <h2>Appendix: Functional Test Details</h2>
+
+ <h3>Execution Environment</h3>
+ <div class="card">\n ${af.environment}\n </div>
+
+ <h3>Test Code</h3>
+ <pre><code>${esc(af.testCode)}</code></pre>
+
+ <h3>Full Output</h3>
+ <pre><code>${esc(af.fullOutput)}</code></pre>
+</section>`;
+}
+
+// CLI: node render.js <input.json> [output.html]
+import { basename } from "node:path";
+if (process.argv[1] && basename(process.argv[1]) === "render.js") {
+ const inputPath = process.argv[2];
+ if (!inputPath) {
+ console.error("Usage: node render.js <evidence.json> [output.html]");
+ process.exit(1);
+ }
+ 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);
+ writeFileSync(outputPath, html, "utf-8");
+ console.log(`Report written: ${outputPath}`);
+}
diff --git a/.skills/tinker-review/scripts/renderer/template.html b/.skills/tinker-review/scripts/renderer/template.html
new file mode 100644
index 0000000000..42e12cd79d
--- /dev/null
+++ b/.skills/tinker-review/scripts/renderer/template.html
@@ -0,0 +1,73 @@
+<!--
+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.
+-->
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Graph Review</title>
+<style>
+:root { --bg: #ffffff; --bg-alt: #f8f9fa; --bg-card: #ffffff; --border: #dee2e6; --text: #212529; --text-muted: #6c757d; --primary: #0d6efd; --success: #198754; --warning: #ffc107; --danger: #dc3545; --radius: 6px; --shadow: 0 1px 3px rgba(0,0,0,0.08); }
+@media (prefers-color-scheme: dark) { :root { --bg: #1a1a2e; --bg-alt: #16213e; --bg-card: #0f3460; --border: #2a4a7f; --text: #e4e4e4; --text-muted: #a0a0a0; --primary: #4dabf7; --success: #51cf66; --warning: #fcc419; --danger: #ff6b6b; } }
+* { margin: 0; padding: 0; box-sizing: border-box; }
+body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem; max-width: 960px; margin: 0 auto; }
+a { color: var(--primary); text-decoration: none; } a:hover { text-decoration: underline; }
+header { border-bottom: 2px solid var(--border); padding-bottom: 1.5rem; margin-bottom: 2rem; }
+header h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.25rem; }
+header .meta { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.875rem; color: var(--text-muted); }
+.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-weight: 500; font-size: 0.75rem; text-transform: uppercase; }
+.badge-domain { background: var(--primary); color: #fff; } .badge-attention { background: var(--danger); color: #fff; }
+.badge-safe { background: var(--success); color: #fff; } .badge-context { background: #6f42c1; color: #fff; }
+.badge-test { background: #20c997; color: #fff; } .badge-modified { background: var(--warning); color: #000; }
+nav { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem; margin-bottom: 2rem; }
+nav h2 { font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 0.5rem; }
+nav ul { list-style: none; display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; } nav ul li a { font-size: 0.875rem; }
+section { margin-bottom: 2.5rem; }
+section h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
+section h3 { font-size: 1.05rem; font-weight: 600; margin: 1.25rem 0 0.5rem; }
+.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; margin-bottom: 1rem; box-shadow: var(--shadow); }
+.card h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; margin-top: 0; } .card p { margin-bottom: 0.5rem; }
+.card-attention { border-left: 4px solid var(--danger); } .card-safe { border-left: 4px solid var(--success); }
+.card-info { border-left: 4px solid var(--primary); } .card-context { border-left: 4px solid #6f42c1; }
+.card-test { border-left: 4px solid #20c997; }
+code { background: var(--bg-alt); padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.85rem; font-family: "SF Mono", Consolas, monospace; }
+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; }
+.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; }
+.section-intro { font-size: 0.9rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.5; }
+.cluster-svg { width: 100%; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-alt); margin: 1rem 0; }
+.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.75rem; }
+.stat-box { background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.75rem; text-align: center; }
+.stat-box .value { font-size: 1.25rem; font-weight: 700; color: var(--primary); } .stat-box .label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
+.gap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
+.gap-table th { text-align: left; padding: 0.4rem 0.6rem; background: var(--bg-alt); border-bottom: 2px solid var(--border); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; color: var(--text-muted); }
+.gap-table td { padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); } .gap-table .fn-name { font-family: monospace; font-weight: 500; } .gap-table .num { text-align: right; font-family: monospace; }
+.discovery-meta { font-size: 0.8rem; color: var(--text-muted); font-style: italic; margin-top: 0.5rem; }
+footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.75rem; color: var(--text-muted); text-align: center; }
+</style>
+</head>
+<body>
+
+{{content}}
+
+</body>
+</html>
diff --git a/.skills/tinker-review/scripts/review.js b/.skills/tinker-review/scripts/review.js
new file mode 100644
index 0000000000..ebe50ffe99
--- /dev/null
+++ b/.skills/tinker-review/scripts/review.js
@@ -0,0 +1,406 @@
+/*
+ * 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 { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { writeFile, mkdir, rm } from "node:fs/promises";
+import { join, basename, extname } from "node:path";
+import { existsSync } from "node:fs";
+import gremlin from "gremlin";
+
+import { startServer, stopServer } from "./infrastructure/docker.js";
+import { extract } from "./extraction/tree-sitter.js";
+import { populate } from "./graph/populate.js";
+import { populateDiscussions } from "./graph/populate-discussions.js";
+import { completeness } from "./patterns/completeness.js";
+import { coverageGaps } from "./patterns/coverage-gaps.js";
+import { highCentrality } from "./patterns/centrality.js";
+import { blastRadius } from "./patterns/blast-radius.js";
+import { clusterAnalysis } from "./patterns/cluster-analysis.js";
+import { architecture } from "./patterns/architecture.js";
+import { createPrDiscussion } from "./enrichment/api.js";
+import { discoverDiscussions } from "./discovery/discussions.js";
+
+const exec = promisify(execFile);
+
+const LANGUAGE_HINTS = {
+ dart: "dart",
+ java: "java",
+ go: "go",
+ py: "python",
+ js: "javascript",
+ mjs: "javascript",
+ cs: "csharp",
+};
+
+function log(msg) {
+ process.stdout.write(`[review] ${msg}\n`);
+}
+
+function detectLanguage(changedFiles) {
+ const extCounts = new Map();
+ for (const file of changedFiles) {
+ const ext = extname(file).slice(1);
+ if (LANGUAGE_HINTS[ext]) {
+ extCounts.set(LANGUAGE_HINTS[ext], (extCounts.get(LANGUAGE_HINTS[ext]) || 0) + 1);
+ }
+ }
+
+ let best = null;
+ let bestCount = 0;
+ for (const [lang, count] of extCounts) {
+ if (count > bestCount) {
+ best = lang;
+ bestCount = count;
+ }
+ }
+ return best || "java";
+}
+
+async function getChangedFiles(repoPath, prBranch, remote = "upstream", baseBranch = "master") {
+ const { stdout: baseCommit } = await exec(
+ "git", ["merge-base", prBranch, `${remote}/${baseBranch}`],
+ { cwd: repoPath }
+ );
+ const base = baseCommit.trim();
+
+ const { stdout: diffOutput } = await exec(
+ "git", ["diff", "--name-only", `${base}...${prBranch}`],
+ { cwd: repoPath }
+ );
+ return diffOutput.trim().split("\n").filter(Boolean);
+}
+
+function classifyDomains(changedFiles) {
+ const paths = changedFiles.join("\n").toLowerCase();
+ const domains = ["general"];
+
+ if (paths.includes("gremlin-dart") || paths.includes("gremlin-swift") || paths.includes("gremlin-rust") ||
+ paths.includes("gremlin-go/") || paths.includes("gremlin-python/") || paths.includes("gremlin-dotnet/") ||
+ paths.includes("gremlin-js/")) {
+ domains.push("glv");
+ }
+ if (paths.includes("gremlin-driver/") || paths.includes("gremlin-server/") || paths.includes("gremlin-util/") ||
+ paths.includes("serializ") || paths.includes("graphson") || paths.includes("graphbinary") || paths.includes("gryo")) {
+ domains.push("driver-server");
+ }
+ if (paths.includes("gremlin-language/") || paths.includes(".g4")) {
+ domains.push("grammar");
+ }
+ if (paths.includes("gremlin-core/") && (paths.includes("step/map/") || paths.includes("step/filter/") ||
+ paths.includes("step/sideeffect/") || paths.includes("step/branch/"))) {
+ domains.push("new-step");
+ }
+
+ return domains;
+}
+
+function extractKeywords(changedFiles, prTitle) {
+ const keywords = new Set();
+ for (const file of changedFiles) {
+ const parts = file.split("/");
+ const filename = parts[parts.length - 1].replace(/\.\w+$/, "");
+ if (filename.length > 3 && !["index", "package", "pom", "build"].includes(filename.toLowerCase())) {
+ keywords.add(filename);
+ }
+ }
+ const titleWords = prTitle.split(/[\s\-_:]+/).filter((w) => w.length > 3 && !/^\d+$/.test(w));
+ for (const w of titleWords.slice(0, 3)) {
+ keywords.add(w);
+ }
+ return [...keywords].slice(0, 6);
+}
+
+async function cleanupWorktree(repoPath, worktreePath, prBranch) {
+ if (existsSync(worktreePath)) {
+ await exec("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoPath }).catch(() => {});
+ }
+ await exec("git", ["worktree", "prune"], { cwd: repoPath }).catch(() => {});
+ await exec("git", ["branch", "-D", prBranch], { cwd: repoPath }).catch(() => {});
+}
+
+// ============================================================
+// SETUP — fetch PR, create worktree, start server
+// Returns a session object the agent uses for all subsequent phases.
+// ============================================================
+
+async function detectRemote(repoPath) {
+ const { stdout } = await exec("git", ["remote", "-v"], { cwd: repoPath });
+ const lines = stdout.split("\n");
+ for (const line of lines) {
+ if (line.includes("apache/tinkerpop") && line.includes("(fetch)")) {
+ return line.split(/\s+/)[0];
+ }
+ }
+ // Fall back to origin if no apache/tinkerpop remote found
+ return "origin";
+}
+
+// Detect the branch a PR targets (e.g. master, 3.8-dev, 3.7-dev) from the
+// GitHub API. Falls back to "master" if the API is unreachable.
+async function detectBaseBranch(pr) {
+ try {
+ const { get } = await import("node:https");
+ const prData = await new Promise((resolve, reject) => {
+ get(`https://api.github.com/repos/apache/tinkerpop/pulls/${pr}`, {
+ headers: { "User-Agent": "tinker-review", "Accept": "application/json" },
+ }, (res) => {
+ let data = "";
+ res.on("data", (chunk) => { data += chunk; });
+ res.on("end", () => resolve(JSON.parse(data)));
+ }).on("error", reject);
+ });
+ return prData.base?.ref || "master";
+ } catch {
+ return "master";
+ }
+}
+
+export async function setup(params) {
+ const { pr, repoPath, options = {} } = params;
+ const remote = options.remote || await detectRemote(repoPath);
+ const baseBranch = options.baseBranch || await detectBaseBranch(pr);
+ const prBranch = `pr-review/${pr}`;
+ const workDir = `/tmp/pr-review-${pr}`;
+ const worktreePath = `${workDir}/src`;
+
+ await mkdir(workDir, { recursive: true });
+ await cleanupWorktree(repoPath, worktreePath, prBranch);
+
+ log(`PR #${pr} — targets ${baseBranch}, fetching...`);
+ await exec("git", ["fetch", remote, baseBranch], { cwd: repoPath }).catch(() => {});
+ await exec("git", ["fetch", remote, `pull/${pr}/head:${prBranch}`], { cwd: repoPath });
+ await exec("git", ["worktree", "add", worktreePath, prBranch], { cwd: repoPath });
+
+ const changedFiles = await getChangedFiles(repoPath, prBranch, remote, baseBranch);
+ const language = detectLanguage(changedFiles);
+ const domains = classifyDomains(changedFiles);
+ log(`PR #${pr} — classified as: ${domains.join(", ")} (${language}, ${changedFiles.length} files changed)`);
+
+ log(`Starting Gremlin Server...`);
+ const handle = await startServer();
+ log(`Gremlin Server ready on port ${handle.port}`);
+
+ const connection = new gremlin.driver.DriverRemoteConnection(handle.url);
+ const g = gremlin.process.traversal().withRemote(connection);
+ const aConnection = new gremlin.driver.DriverRemoteConnection(handle.url, { traversalSource: "a" });
+ const a = gremlin.process.traversal().withRemote(aConnection);
+
+ await writeFile(join(workDir, "session.json"), JSON.stringify({
+ url: handle.url,
+ port: handle.port,
+ containerId: handle.containerId,
+ worktreePath,
+ workDir,
+ repoPath,
+ pr,
+ baseBranch,
+ }, null, 2));
+
+ return {
+ pr,
+ repoPath,
+ remote,
+ baseBranch,
+ prBranch,
+ workDir,
+ worktreePath,
+ changedFiles,
+ language,
+ domains,
+ handle,
+ connection,
+ aConnection,
+ g,
+ a,
+ };
+}
+
+// ============================================================
+// PHASE 1 — extract, populate, discover, run checks
+// Writes evidence JSON to workDir. Server stays alive.
+// ============================================================
+
+export async function phase1(session) {
+ const { pr, repoPath, remote, baseBranch = "master", prBranch, workDir, worktreePath, changedFiles, language, domains, g, a } = session;
+
+ log(`Phase 1: Extracting structure (${language})...`);
+ const extraction = await extract(worktreePath, language, { changedFiles });
+ log(`Phase 1 complete: ${extraction.files.length} files, ${extraction.functions.length} functions, ${extraction.types.length} types`);
+
+ log(`Populating graph...`);
+ const graphStats = await populate(g, extraction);
+ log(`Graph populated: ${graphStats.vertices} vertices, ${graphStats.edges} edges`);
+
+ let prTitle = `PR #${pr}`;
+ let prBody = "";
+ try {
+ const { get } = await import("node:https");
+ const prData = await new Promise((resolve, reject) => {
+ get(`https://api.github.com/repos/apache/tinkerpop/pulls/${pr}`, {
+ headers: { "User-Agent": "tinker-review", "Accept": "application/json" },
+ }, (res) => {
+ let data = "";
+ res.on("data", (chunk) => { data += chunk; });
+ res.on("end", () => resolve(JSON.parse(data)));
+ }).on("error", reject);
+ });
+ prTitle = prData.title || prTitle;
+ prBody = (prData.body || "").slice(0, 2000);
+ } catch {
+ const { stdout } = await exec("git", ["log", "-1", "--format=%s", prBranch], { cwd: repoPath }).catch(() => ({ stdout: prTitle }));
+ prTitle = stdout.trim() || prTitle;
+ }
+
+ await createPrDiscussion(g, pr, prTitle.trim(), "");
+
+ log(`Discovering discussions...`);
+ const { stdout: diffText } = await exec(
+ "git", ["diff", "--unified=0", `${(await exec("git", ["merge-base", prBranch, `${remote}/${baseBranch}`], { cwd: repoPath })).stdout.trim()}...${prBranch}`],
+ { cwd: repoPath }
+ ).catch(() => ({ stdout: "" }));
+
+ const prKeywords = extractKeywords(changedFiles, prTitle.trim());
+ const discussions = await discoverDiscussions({
+ pr,
+ prTitle: prTitle,
+ prBody,
+ diff: diffText,
+ keywords: prKeywords,
+ repoPath,
+ });
+ log(` jiras: ${discussions.jiras.length} found${discussions.jiraMissing ? " (none referenced)" : ""}`);
+ log(` pr comments: ${discussions.prComments.issue.length} issue + ${discussions.prComments.review.length} review`);
+ log(` dev list: ${discussions.devList.length} found${discussions.devListSearchPerformed ? ` (searched: ${prKeywords.join(", ")})` : ""}`);
+ log(` proposals: ${discussions.proposals.length} found`);
+
+ log(`Loading discussions into graph...`);
+ await populateDiscussions(g, discussions, { pr, prTitle: prTitle.trim(), changedFiles });
+
+ log(`Running checks...`);
+ const completenessResults = await completeness(g, {
+ vertexLabel: "File",
+ expectedEdges: ["out:defines"],
+ });
+
+ const coverageResult = await coverageGaps(g, { changedOnly: true });
+ const centralityResult = await highCentrality(g, { changedOnly: true, topN: 10, minDegree: 3 });
+ const blastResult = await blastRadius(g, { depth: 3, changedOnly: true });
+ const clusterResult = await clusterAnalysis(a, { changedOnly: true });
+ log(` completeness: ${completenessResults.filter(r => r.missing.length > 0).length} gaps found`);
+ log(` coverage_gaps: ${coverageResult.uncovered.length} functions without tests`);
+ log(` centrality: ${centralityResult.aboveThreshold} hotspots`);
+ log(` blast_radius: max ${blastResult.maxReachable} reachable`);
+ log(` clusters: ${clusterResult.clusterCount} (${clusterResult.coherent ? "coherent" : "fragmented"})`);
+
+ log(`Generating architecture map...`);
+ const architectureResult = await architecture(g, { clusterResult, changedOnly: true });
+ log(` architecture: ${architectureResult.nodes.length} nodes, ${architectureResult.edges.length} edges`);
+
+ const evidence = {
+ meta: {
+ pr,
+ title: prTitle.trim(),
+ domains,
+ language,
+ changedFileCount: changedFiles.length,
+ timestamp: new Date().toISOString(),
+ },
+ graphStats,
+ architecture: architectureResult,
+ checks: {
+ completeness: completenessResults,
+ coverageGaps: coverageResult,
+ centrality: centralityResult,
+ blastRadius: blastResult,
+ clusters: clusterResult,
+ },
+ discussions,
+ changedFiles,
+ };
+
+ const jsonPath = join(workDir, "evidence.json");
+ await writeFile(jsonPath, JSON.stringify(evidence, null, 2), "utf-8");
+ log(`Evidence written: ${jsonPath}`);
+
+ return { evidence, jsonPath };
+}
+
+// ============================================================
+// TEARDOWN — stop server, remove worktree
+// Call this ONLY when all phases are complete.
+// ============================================================
+
+export async function teardown(sessionOrWorkDir) {
+ let repoPath, worktreePath, prBranch, containerId;
+
+ if (typeof sessionOrWorkDir === "string") {
+ // Called with just a workDir path — read session.json
+ const { readFile: rf } = await import("node:fs/promises");
+ const sessionData = JSON.parse(await rf(join(sessionOrWorkDir, "session.json"), "utf-8"));
+ repoPath = sessionData.repoPath;
+ worktreePath = sessionData.worktreePath;
+ containerId = sessionData.containerId;
+ prBranch = `pr-review/${sessionData.pr}`;
+ } else {
+ // Called with a live session object
+ const session = sessionOrWorkDir;
+ repoPath = session.repoPath;
+ worktreePath = session.worktreePath;
+ prBranch = session.prBranch;
+ containerId = session.handle?.containerId;
+ if (session.aConnection) await session.aConnection.close().catch(() => {});
+ if (session.connection) await session.connection.close().catch(() => {});
+ }
+
+ if (containerId) {
+ await exec("docker", ["stop", containerId]).catch(() => {});
+ await exec("docker", ["rm", containerId]).catch(() => {});
+ }
+ await cleanupWorktree(repoPath, worktreePath, prBranch);
+ log(`Teardown complete.`);
+}
+
+// ============================================================
+// CLI entry point — runs Phase 1 only, leaves server running
+// for the agent to continue with enrichment/synthesis.
+// ============================================================
+
+if (process.argv[1] && basename(process.argv[1]) === "review.js") {
+ const pr = parseInt(process.argv[2], 10);
+ if (!pr || isNaN(pr)) {
+ console.error("Usage: node review.js <pr-number> [repo-path]");
+ console.error(" Runs Phase 1 and outputs evidence JSON.");
+ console.error(" Server stays running for enrichment. Call teardown when done.");
+ process.exit(1);
+ }
+ const repoPath = process.argv[3] || process.cwd();
+
+ const session = await setup({ pr, repoPath });
+ try {
+ const { jsonPath } = await phase1(session);
+ log(`Server running on port ${session.handle.port} — ready for enrichment.`);
+ log(`Work directory: ${session.workDir}`);
+ log(`Worktree: ${session.worktreePath}`);
+ log(`To teardown: call teardown(session) or stop container ${session.handle.containerId}`);
+ } catch (err) {
+ await teardown(session).catch(() => {});
+ throw err;
+ }
+}
diff --git a/bin/agent-setup.sh b/bin/agent-setup.sh
index 23097448e5..b7339c100d 100755
--- a/bin/agent-setup.sh
+++ b/bin/agent-setup.sh
@@ -20,9 +20,12 @@
# Set up AI coding agent integration with TinkerPop's Agent Skills.
#
-# TinkerPop maintains development guidance as an Agent Skill in .skills/tinkerpop-dev/.
+# TinkerPop maintains Agent Skills in .skills/:
+# tinker-dev - Development guidance (coding conventions, build recipes, etc.)
+# tinker-review - Graph-based PR review (knowledge graph analysis, playbooks, etc.)
+#
# Different AI coding tools discover skills in different directories. This script
-# creates the necessary symlinks or shims so your tool can find the skill.
+# creates the necessary symlinks or shims so your tool can find the skills.
#
# Usage:
# bin/agent-setup.sh <agent>
@@ -44,8 +47,7 @@
set -uo pipefail
-SKILL_DIR=".skills/tinkerpop-dev"
-SKILL_NAME="tinkerpop-dev"
+SKILLS=("tinker-dev" "tinker-review")
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -68,8 +70,8 @@ usage() {
}
# Verify we're in the repo root
-if [[ ! -d "$SKILL_DIR" ]]; then
- bad "Cannot find $SKILL_DIR — run this script from the TinkerPop repository root."
+if [[ ! -d ".skills/tinker-dev" ]]; then
+ bad "Cannot find .skills/tinker-dev — run this script from the TinkerPop repository root."
exit 1
fi
@@ -77,9 +79,11 @@ fi
setup_symlink() {
local tool_name="$1"
local target_dir="$2"
+ local skill_name="$3"
+ local skill_dir=".skills/$skill_name"
mkdir -p "$target_dir"
- local link_path="$target_dir/$SKILL_NAME"
+ local link_path="$target_dir/$skill_name"
if [[ -L "$link_path" ]]; then
skip "$tool_name: symlink already exists at $link_path"
@@ -91,12 +95,11 @@ setup_symlink() {
return 1
fi
- # Compute relative path from target_dir to SKILL_DIR
+ # Compute relative path from target_dir to skill_dir
local rel_path
- rel_path=$(python3 -c "import os.path; print(os.path.relpath('$SKILL_DIR', '$target_dir'))" 2>/dev/null)
+ rel_path=$(python3 -c "import os.path; print(os.path.relpath('$skill_dir', '$target_dir'))" 2>/dev/null)
if [[ -z "$rel_path" ]]; then
- # Fallback if python3 not available
- rel_path=$(perl -e 'use File::Spec; print File::Spec->abs2rel("'"$SKILL_DIR"'", "'"$target_dir"'")' 2>/dev/null)
+ rel_path=$(perl -e 'use File::Spec; print File::Spec->abs2rel("'"$skill_dir"'", "'"$target_dir"'")' 2>/dev/null)
fi
if [[ -z "$rel_path" ]]; then
bad "$tool_name: could not compute relative path (need python3 or perl)"
@@ -110,39 +113,48 @@ setup_symlink() {
# Kiro doesn't follow symlinks in .kiro/skills/, so we copy the skill directory
# instead. See: https://github.com/kirodotdev/Kiro/issues (symlink support).
setup_kiro() {
- local target_dir=".kiro/skills/$SKILL_NAME"
-
- if [[ -d "$target_dir" ]]; then
- rm -rf "$target_dir"
- skip "kiro: removed existing copy at $target_dir"
- fi
-
mkdir -p ".kiro/skills"
- cp -r "$SKILL_DIR" "$target_dir"
- ok "kiro: copied skill to $target_dir"
+ for skill_name in "${SKILLS[@]}"; do
+ local target_dir=".kiro/skills/$skill_name"
+ if [[ -d "$target_dir" ]]; then
+ rm -rf "$target_dir"
+ fi
+ cp -r ".skills/$skill_name" "$target_dir"
+ ok "kiro: copied $skill_name to $target_dir"
+ done
echo ""
- echo -e " ${YELLOW}NOTE:${NC} Kiro uses a copy, not a symlink. If you update the skill in"
- echo -e " $SKILL_DIR, re-run this script to sync the changes."
+ echo -e " ${YELLOW}NOTE:${NC} Kiro uses copies, not symlinks. If you update skills in"
+ echo -e " .skills/, re-run this script to sync the changes."
}
setup_agent() {
local agent="$1"
case "$agent" in
claude)
- setup_symlink "claude" ".claude/skills"
+ for skill in "${SKILLS[@]}"; do
+ setup_symlink "claude" ".claude/skills" "$skill"
+ done
;;
copilot)
- setup_symlink "copilot (.github)" ".github/skills"
- setup_symlink "copilot (.agents)" ".agents/skills"
+ for skill in "${SKILLS[@]}"; do
+ setup_symlink "copilot (.github)" ".github/skills" "$skill"
+ setup_symlink "copilot (.agents)" ".agents/skills" "$skill"
+ done
;;
cursor)
- setup_symlink "cursor" ".cursor/skills"
+ for skill in "${SKILLS[@]}"; do
+ setup_symlink "cursor" ".cursor/skills" "$skill"
+ done
;;
codex)
- setup_symlink "codex" ".codex/skills"
+ for skill in "${SKILLS[@]}"; do
+ setup_symlink "codex" ".codex/skills" "$skill"
+ done
;;
junie)
- setup_symlink "junie" ".junie/skills"
+ for skill in "${SKILLS[@]}"; do
+ setup_symlink "junie" ".junie/skills" "$skill"
+ done
;;
kiro)
setup_kiro
@@ -159,13 +171,15 @@ setup_agent() {
list_agents() {
echo "Supported agents and their skill discovery paths:"
echo ""
- echo " claude .claude/skills/$SKILL_NAME/ -> symlink to $SKILL_DIR"
- echo " copilot .github/skills/$SKILL_NAME/ -> symlink to $SKILL_DIR"
- echo " .agents/skills/$SKILL_NAME/ -> symlink to $SKILL_DIR"
- echo " cursor .cursor/skills/$SKILL_NAME/ -> symlink to $SKILL_DIR"
- echo " codex .codex/skills/$SKILL_NAME/ -> symlink to $SKILL_DIR"
- echo " junie .junie/skills/$SKILL_NAME/ -> symlink to $SKILL_DIR"
- echo " kiro .kiro/skills/$SKILL_NAME/ -> copy of $SKILL_DIR (re-run to sync)"
+ echo " Skills: ${SKILLS[*]}"
+ echo ""
+ echo " claude .claude/skills/<skill>/ -> symlink to .skills/<skill>"
+ echo " copilot .github/skills/<skill>/ -> symlink to .skills/<skill>"
+ echo " .agents/skills/<skill>/ -> symlink to .skills/<skill>"
+ echo " cursor .cursor/skills/<skill>/ -> symlink to .skills/<skill>"
+ echo " codex .codex/skills/<skill>/ -> symlink to .skills/<skill>"
+ echo " junie .junie/skills/<skill>/ -> symlink to .skills/<skill>"
+ echo " kiro .kiro/skills/<skill>/ -> copy of .skills/<skill> (re-run to sync)"
}
# --- Main ---