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 ef7ec42aaa tinker-review: implement functional testing for PR review
ef7ec42aaa is described below
commit ef7ec42aaab69348651de9af135bb71693632a06
Author: Stephen Mallette <[email protected]>
AuthorDate: Thu Jul 9 17:08:59 2026 +0000
tinker-review: implement functional testing for PR review
Adds the deferred functional-test component: a blind subagent exercises the
built PR as a minimally-experienced user against a Gremlin Server compiled
from
source, and its findings feed the report.
- functional/{setup,cli}.js build the PR and launch a server from its own
artifacts; net.js shares port/readiness helpers with docker.js
- a sixth 'Verify' playbook section carries the gate and per-change battery
- the blind agent labels every scenario in code; the report surfaces themes
and
cites those labels, with full code in the appendix
- renderer strips stray code wrappers so raw-text fields render cleanly
Assisted-by: Claude Code:claude-opus-4-8
---
.skills/tinker-review/DESIGN.md | 24 ++-
.skills/tinker-review/SKILL.md | 101 +++++++---
.skills/tinker-review/playbooks/bug-fix.md | 11 ++
.skills/tinker-review/playbooks/driver-server.md | 13 ++
.skills/tinker-review/playbooks/general.md | 30 +++
.skills/tinker-review/playbooks/glv.md | 10 +
.skills/tinker-review/playbooks/grammar.md | 11 ++
.skills/tinker-review/playbooks/new-step.md | 11 ++
.skills/tinker-review/playbooks/removal.md | 11 ++
.../tinker-review/references/functional-testing.md | 96 +++++++++
.skills/tinker-review/references/interfaces.md | 4 +
.skills/tinker-review/scripts/functional/cli.js | 92 +++++++++
.skills/tinker-review/scripts/functional/setup.js | 218 +++++++++++++++++++++
.../tinker-review/scripts/infrastructure/docker.js | 41 +---
.../tinker-review/scripts/infrastructure/net.js | 78 ++++++++
.skills/tinker-review/scripts/renderer/render.js | 34 +++-
.skills/tinker-review/scripts/review.js | 17 +-
.../tinker-review/test/playbook-sections.test.js | 9 +-
18 files changed, 736 insertions(+), 75 deletions(-)
diff --git a/.skills/tinker-review/DESIGN.md b/.skills/tinker-review/DESIGN.md
index 72b345596a..130a3c655c 100644
--- a/.skills/tinker-review/DESIGN.md
+++ b/.skills/tinker-review/DESIGN.md
@@ -16,7 +16,10 @@ Gremlin Server container stays up.
**Phase 2 — agent-driven** does everything that needs judgment: enrich the
graph
via the enrichment CLI, an optional functional test, then the report. It ends
by
-tearing down the container and worktree.
+tearing down the container and worktree. The functional test itself splits
along
+the same mechanical/judgment line: `functional/setup.js` builds the PR and
starts
+a server from its artifacts (mechanical), while a blind subagent designs and
runs
+the test battery per the playbooks' Verify sections (judgment).
Data flows one direction through three artifacts:
@@ -38,6 +41,8 @@ has already exited.
| `scripts/graph/*.js` | populate the graph; `confidence.js` / `externals.js`
/ `references.js` hold the data-model vocabularies and shared edge helpers |
| `scripts/patterns/*.js` | one structural check per file; each defines its
own result `@typedef` |
| `scripts/enrichment/{api,cli}.js` | Phase 2 read/write commands over the
live graph |
+| `scripts/functional/{setup,cli}.js` | Phase 2 functional test — build the
PR, launch a server from its artifacts (mechanical half of step 4) |
+| `scripts/infrastructure/{docker,net}.js` | Phase 1 stock-image server
(`docker.js`); shared port/readiness helpers (`net.js`) |
| `scripts/renderer/{render.js,template.html}` | `report.json` → HTML |
| `playbooks/*.md` | domain review guidance — prompt scaffolds, not code |
| `references/{schema,interfaces}.md` | the graph schema and the evidence
composite |
@@ -77,11 +82,11 @@ has already exited.
- **Add an enrichment command** — a function in `scripts/enrichment/api.js`
(or a
pattern module), wired into `cli.js` (COMMANDS + help + switch), documented
in
`SKILL.md`. Edge-creating commands take a `confidence`, default `INFERRED`.
-- **Add a playbook** — five sections. **Context** is prose stating when the
+- **Add a playbook** — six sections. **Context** is prose stating when the
playbook applies (an applicability gate, read while choosing playbooks). The
- three working sections — **Enrich**, **Inspect**, **Interpret** — are bullet
- checklists, one item per action so the agent runs them dependably rather than
- parsing prose. They split by data flow:
+ four working sections — **Enrich**, **Inspect**, **Verify**, **Interpret** —
+ are bullet checklists, one item per action so the agent runs them dependably
+ rather than parsing prose. They split by data flow:
- **Enrich** — each bullet names a registered enrichment command and mutates
the graph only (name at least one command, or state that none applies).
- **Inspect** — each bullet names a source-read concern to record as a
@@ -92,6 +97,13 @@ has already exited.
API-design concerns mined from TinkerPop reviewer patterns`) when that
framing
guides the read. If a playbook has no source-read work (its judgment is
structural), say so in one line rather than inventing items.
+ - **Verify** — each bullet shapes the optional functional test's battery for
+ this class of change: which GLVs/layers to exercise and what adversarial
cases
+ matter. The gate (whether to test at all) and the shared battery framework
+ live once in `general.md`'s Verify and must not be repeated per playbook; a
+ domain playbook adds only its specialization. If a change class rarely has
a
+ black-box surface, say when to skip in one line rather than inventing
items.
+ The mechanical build+start is `functional/setup.js`, not a playbook
concern.
- **Interpret** — each bullet weighs an `evidence.json` field or an Inspect
candidate into `findings` or `openQuestions`. List only the weighing
bullets;
the "weigh the evidence.json signals and Inspect candidates into
`findings` /
@@ -101,7 +113,7 @@ has already exited.
ordering, not a per-finding field.
**Escape** sets stop/escalate gates. `test/playbook-sections.test.js`
enforces
- that every playbook carries the five sections in order and that Enrich names
a
+ that every playbook carries the six sections in order and that Enrich names a
command. Add an orient rule in `SKILL.md`.
- **Add an edge or vertex type** — document it in `references/schema.md`; tag
new
edges with `confidence`; use find-or-create for cross-boundary endpoints.
diff --git a/.skills/tinker-review/SKILL.md b/.skills/tinker-review/SKILL.md
index 6eaf5f5e6c..a1287a16ab 100644
--- a/.skills/tinker-review/SKILL.md
+++ b/.skills/tinker-review/SKILL.md
@@ -26,6 +26,7 @@ metadata:
- 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
- Read [references/enrichment-cli.md](references/enrichment-cli.md) when you
need to know what an enrichment CLI command *does* and when to reach for it
(the command names below are terse; this is where their meaning lives)
+- Read [references/functional-testing.md](references/functional-testing.md)
when you run the optional functional test (step 4) — what `functional/cli.js`
does, how the built server is configured, and how to drive it from the subagent
## Execution Sequence
@@ -83,12 +84,14 @@ checklists — one item per action.
| **Context** | choosing playbooks (above) | Confirm the path-matched playbook
fits this PR; set aside the ones that don't. |
| **Enrich** | improving the graph (step 3) | Run each command bullet to add
or re-grade semantic edges. Graph mutation only. |
| **Inspect** | reading the changed source (step 3) | Check each bullet
against the source; record what you find as a *candidate finding* for
Interpret. |
-| **Interpret** | writing the report (step 5) | Weigh the named
`evidence.json` fields together with the Inspect candidates into `findings` /
`openQuestions`. |
+| **Verify** | functional testing (step 4) | Apply the gate, then shape the
blind subagent's test battery. `general.md` holds the shared gate/framework;
domains specialize it. |
+| **Interpret** | writing the report (step 5) | Weigh the named
`evidence.json` fields together with the Inspect and Verify candidates into
`findings` / `openQuestions`. |
| **Escape** | any time | Honor its stop/escalate gates; halt or flag when one
holds. |
-The three working sections split by data flow: **Enrich** writes to the graph,
-**Inspect** reads the changed source into candidate findings, **Interpret**
reads
-the computed `evidence.json` checks and weighs the Inspect candidates into the
+The working sections split by data flow: **Enrich** writes to the graph,
+**Inspect** reads the changed source into candidate findings, **Verify**
exercises
+the built feature as a user would, and **Interpret** reads the computed
+`evidence.json` checks and weighs the Inspect and Verify candidates into the
report. `findings` is an ordered list — Interpret ranks it most-severe-first,
grading each entry blocking / high / low.
@@ -143,38 +146,75 @@ 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.
+Gremlin Server built from the PR and submit traversals. Try to use the feature
+based only on what the documentation says. Try to break it with adversarial
+inputs.
+
+**Gate — decide whether to run it.** Consult the applicable playbooks'
**Verify**
+sections. `general.md`'s Verify holds the shared gate: skip functional testing
+when the change has no user-facing runtime surface (tests-only, docs-only, a
pure
+internal refactor, build/CI plumbing) and either omit `functionalTest` or
record
+the skip reason. Otherwise continue.
+
+**Build the PR and start the server (mechanical).** The `functional/cli.js`
+command builds the full reactor, locates the assembly, and launches a Gremlin
+Server from the built artifacts on a fresh port (distinct from the knowledge
+graph server). It reads `pr`/`repoPath` from `session.json` and writes the
+handle to `functional.json` so teardown can find it:
-**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
+node .skills/tinker-review/scripts/functional/cli.js start --workDir
/tmp/pr-review-<pr>
```
-**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/`
+This prints `{ url, port, pid, assemblyDir, logFile, ... }`. The build is slow
+(full reactor); on failure, the error names the server log to inspect. See
+[references/functional-testing.md](references/functional-testing.md) for what
the
+command does and how to drive it.
**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 Gremlin Server URL (the `url` from the command above)
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.
+or access to the analysis worktree. Brief it as a **minimally experienced
+TinkerPop user** who has only the docs — it tests blind, and its stumbles are
+signal about how usable the feature is.
**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.
+and attempts adversarial edge cases. The **shape** of the battery comes from
the
+applicable playbooks' Verify sections — which languages/layers to exercise and
+what adversarial cases matter for this class of change.
-**Layer decision:**
+**Layer decision** (the Verify sections say which applies to this PR):
- **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.
+**Label every scenario in the code (required).** Instruct the subagent to keep
+the test code as the single source of truth for *what* was tested, and to label
+each scenario with a comment carrying a stable id and a one-line intent, e.g.:
+
+```groovy
+// Scenario A1: group().by(T.id) — validates the id key survives when its
reduction is productive
+g.V().group().by(id).by(out().count())
+```
+
+The subagent returns:
+- **Complete, unabbreviated test code**, every scenario labeled as above —
this is
+ `appendixFunctional.testCode` (raw text; the renderer wraps it — do NOT add
+ `<pre>`/`<code>`). No `...`, no "(abbrev)"; if the battery is large, that is
+ what the appendix is for.
+- **Themes, not a scenario dump** — group the scenarios into a handful of
themes,
+ each citing the labels it spans (e.g. "Barrier family (A4–A12): …").
+- Results, adversarial findings, and observations keyed to those labels.
+
+Fold these into the `functionalTest` / `appendixFunctional` report fields
(step 5)
+and weigh them in Interpret. The count and granularity of
`functionalTest.results`
+rows must match the themes, not inflate to imply more tests than the appendix
+actually lists.
### 5. Phase 2 — Produce Report
@@ -200,11 +240,24 @@ produce a complete evidence-with-narrative JSON file.
Write it to
- `guidedWalk` — array of `{ title, badge, badgeText, body }` objects
- `findings` — array of `{ title, snippet, body }` objects, ordered
most-severe-first (Interpret grades each blocking / high / low)
- `openQuestions` — array of `{ title, body, meta }` objects
-- `functionalTest` — `{ plan, results: [{name, pass, output}], observations }`
(if testing was done)
-- `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.
+- `functionalTest` — `{ plan, results: [{name, pass, output}], observations }`
(if testing was done).
+ `plan` and `observations` are HTML and surface **themes and insights** — what
+ families of behavior were exercised and what was learned — not a per-scenario
+ list. `results` rows are **theme-level**: each `name` names a theme and the
+ scenario labels it spans (e.g. `"Barrier family (A4–A12)"`), so the PASS/FAIL
+ grid stays scannable and every row is backed by labeled code in the appendix.
+ Do not enumerate every scenario here and do not let the row count imply more
+ tests than the appendix lists.
+- `appendixFunctional` — `{ environment, testCode, fullOutput }` (if testing
was done).
+ `environment` is HTML. `testCode` and `fullOutput` are **raw text** — the
+ renderer wraps them in `<pre><code>`, so do NOT add `<pre>`/`<code>`/`<p>`
+ yourself. `testCode` is the **complete, unabbreviated** labeled battery from
the
+ subagent (see step 4); it is the source of truth the Functional Test section
+ summarizes by label.
+
+All `body` fields (and `functionalTest.plan`/`observations`,
`appendixFunctional.environment`)
+are HTML — use `<code>`, `<strong>`, `<ul>`, `<p>` as needed. The raw-text
fields
+noted above are the exception. The renderer handles all layout, CSS, and
structure.
**Step B:** Render the report:
@@ -231,7 +284,9 @@ 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.
+If a functional test ran (step 4), teardown also stops that server (via its
+`pid` in `functional.json`) and removes the `build/` worktree. Call this ONLY
+after all phases are complete.
## Important Notes
diff --git a/.skills/tinker-review/playbooks/bug-fix.md
b/.skills/tinker-review/playbooks/bug-fix.md
index 74290dfb81..8c95e63eb4 100644
--- a/.skills/tinker-review/playbooks/bug-fix.md
+++ b/.skills/tinker-review/playbooks/bug-fix.md
@@ -20,6 +20,17 @@ address the root cause, not just the symptom.
- Resource cleanup on error paths — if the bug involves connection/channel
handling, no leak when the fix triggers.
+## Verify
+- Reproduce the reported symptom first, then confirm the fix resolves it:
derive
+ the failing scenario from the linked issue and run it against the built
server.
+- Pick the layer by where the bug lives — an embedded Console/TinkerGraph
+ exercise for core logic; the affected GLV's native client for a driver/wire
bug.
+- Adversarial: nearby inputs the fix might have missed (the boundary just past
+ the reported case, the empty/null variant) — a fix that only patches the
exact
+ reported value is a finding.
+- If the bug has no black-box surface (e.g. an internal-only refactor of the
+ fix), state that; rely on the author's regression test instead.
+
## Interpret
- `checks.blastRadius` — high on a bug fix is a warning: verify the fix doesn't
subtly change behavior for existing callers.
diff --git a/.skills/tinker-review/playbooks/driver-server.md
b/.skills/tinker-review/playbooks/driver-server.md
index cdb92df3eb..55fba6045c 100644
--- a/.skills/tinker-review/playbooks/driver-server.md
+++ b/.skills/tinker-review/playbooks/driver-server.md
@@ -39,6 +39,19 @@ serialization / server init / auth) first, then check the
matching group.
- Server configuration uses gremlin-lang expressions, not Groovy scripts.
- No commented-out old code left behind — remove it cleanly.
+## Verify
+- Focus on the wire: connect a GLV client to the built server and round-trip
the
+ serializers or protocol paths the PR touches. Confirm the bytes survive both
+ directions (GraphBinary and GraphSON if both are affected).
+- If a type code was added or removed, confirm the new code path works and the
+ removed one fails cleanly rather than mis-deserializing.
+- Adversarial: a payload larger than a batch (exercise result streaming), a
+ server-side error (confirm it comes back as a usable error, per the
GraphBinary
+ → JSON error fallback), and a malformed request.
+- Pure connection-lifecycle or concurrency changes have no single-query
surface —
+ say so; the confidence here comes from the author's concurrency tests, not
this
+ black-box pass.
+
## Interpret
- `checks.blastRadius` — inherently high (shared infrastructure); don't flag
the
reach itself, name the specific callers most affected.
diff --git a/.skills/tinker-review/playbooks/general.md
b/.skills/tinker-review/playbooks/general.md
index 2f91d976a0..ce4f197ea5 100644
--- a/.skills/tinker-review/playbooks/general.md
+++ b/.skills/tinker-review/playbooks/general.md
@@ -41,9 +41,39 @@ inherits it. Run in order:
- Concurrency-implicated data structures (`CopyOnWriteArraySet`, synchronized
collections) introduced without profiling justification
+## Verify
+Context: this is the shared gate and battery-design framework for the optional
+functional test (SKILL.md step 4). Domain playbooks add their own Verify
bullets;
+they do not repeat this framework.
+
+**Gate — does functional testing run at all?** Run it only when the change has
a
+user-facing runtime surface. Skip (state why in `functionalTest`, or omit the
+field) when the change is tests-only, docs-only, a pure internal refactor with
no
+observable behavior change, or build/CI plumbing.
+
+**Design the battery to match the change** — exercise what changed, then try
the
+mistakes a real user would make:
+- New or changed **step / API surface** → submit native queries against the
built
+ server in every affected GLV. A step that spans grammar + core + all GLVs is
+ tested per language.
+- **Semantics changed, API stable** → a small embedded exercise (Layer 1:
Gremlin
+ Console / TinkerGraph, or a short Java snippet) that drives the feature is
+ enough; per-GLV wire tests add nothing.
+- **Serialization / type / protocol** → round-trip the affected types over the
+ wire from at least one GLV (Layer 2).
+- Always include adversarial cases: wrong argument types, empty/null inputs,
+ boundary values, and the feature used against the grain of the docs.
+
+The blind subagent designs and runs this battery from the docs alone — see
+SKILL.md step 4 for how it is briefed and isolated.
+
## Interpret
- `checks.coverageGaps` / `checks.orphans` — missing tests on changed code; a
test-quality concern, weighed alongside the Inspect smells.
+- `functionalTest` observations (if testing ran) — a failing or surprising
result
+ is a finding graded by severity; a documented-but-unusable feature is
blocking.
+ Adversarial gaps the subagent found (unclear errors, silent wrong answers)
are
+ high. If testing was skipped, the gate reason is not itself a finding.
- Safety concerns (resource leaks, concurrency risks, missing error handling)
and test-quality issues — high; make these the focus.
- Style nits and unused variables — low; note them, don't let them dominate.
diff --git a/.skills/tinker-review/playbooks/glv.md
b/.skills/tinker-review/playbooks/glv.md
index 34f6c991c4..03f9ee5cec 100644
--- a/.skills/tinker-review/playbooks/glv.md
+++ b/.skills/tinker-review/playbooks/glv.md
@@ -26,6 +26,16 @@ recent accepted GLV).
error paths; the common GLV bug is leaking a connection when a traversal
fails
mid-execution.
+## Verify
+- Test from the GLV under review by connecting its native client to the built
+ server — this is the language whose wire behavior the PR changes.
+- Round-trip the value types the GLV serializes (numbers, lists, maps,
vertices,
+ the language's date/UUID types) and confirm they survive the trip unchanged.
+- Adversarial: submit a traversal that errors server-side and confirm the GLV
+ surfaces a usable error rather than hanging or leaking the connection.
+- If the change is idiomatic-only (no wire/serialization impact), an embedded
+ Java exercise is not required — state that in `functionalTest`.
+
## Interpret
- `checks.completeness` — distinguish genuinely missing steps from steps
present
under a language-specific name (Python `addV` vs Go `AddV` — same step).
diff --git a/.skills/tinker-review/playbooks/grammar.md
b/.skills/tinker-review/playbooks/grammar.md
index f402817cc0..03b476ca43 100644
--- a/.skills/tinker-review/playbooks/grammar.md
+++ b/.skills/tinker-review/playbooks/grammar.md
@@ -20,6 +20,17 @@ downstream tooling. Backwards compatibility is critical.
- New keywords — TinkerPop has special handling for keywords as map keys
(#3091);
a new keyword can break queries that use it as an identifier.
+## Verify
+- Submit a query using the new syntax against the built server and confirm it
+ parses and returns the intended result.
+- Backwards-compat is the priority: run a handful of pre-existing query forms
and
+ confirm the grammar change did not break them — especially if a rule was
+ modified rather than added.
+- If a new keyword was introduced, submit a query using that word as an
+ identifier / map key (the #3091 hazard) and confirm it still parses.
+- Test across the ANTLR targets the PR updates (Java, Python, Go) — the same
+ query should parse the same in each.
+
## Interpret
- `checks.blastRadius` / `checks.centrality` — grammar touches everything;
don't
flag the reach, focus on backwards compatibility.
diff --git a/.skills/tinker-review/playbooks/new-step.md
b/.skills/tinker-review/playbooks/new-step.md
index 6db570c2d7..fecbb64876 100644
--- a/.skills/tinker-review/playbooks/new-step.md
+++ b/.skills/tinker-review/playbooks/new-step.md
@@ -25,6 +25,17 @@ semantics.
- Cross-GLV signatures — parameter count should match; types differ by
language.
Judge semantic equivalence, not syntactic identity.
+## Verify
+- Test the step in every active GLV, not just Java — a new step must work
+ end-to-end per language. Submit native queries against the built server from
+ each GLV client.
+- Drive the step's documented signature: required args, optional args, and the
+ no-arg form if the docs describe one.
+- Adversarial: wrong argument types, the step in an illegal position (start vs
+ mid-traversal), and composition with a common neighbor step (`by`, `as`).
+- Cross-check observed behavior against the step's Gherkin feature — the
results
+ should agree with what the feature asserts.
+
## Interpret
- `checks.completeness` over `implements_step` / `has_rule` / `covers` /
`documents` / `proposed_in` — what's missing. Missing from some GLVs is
diff --git a/.skills/tinker-review/playbooks/removal.md
b/.skills/tinker-review/playbooks/removal.md
index d834f16e71..73ba2a6296 100644
--- a/.skills/tinker-review/playbooks/removal.md
+++ b/.skills/tinker-review/playbooks/removal.md
@@ -29,6 +29,17 @@ None specific to removal — the review judgment here is
structural (classifying
the `references` edges recorded in Enrich) and is handled in Interpret rather
than by reading changed source.
+## Verify
+- The build worktree compiling at all is the first signal — a removal that
left a
+ dangling reference fails `mvn install`, and `buildAndStart` reports it.
+- Confirm the removed surface is actually gone: submit a query using the
removed
+ step / feature against the built server and confirm it now errors rather than
+ silently working.
+- Confirm what remains still works: exercise a neighboring feature that shared
+ code with the removed one, to catch an over-broad deletion.
+- A removal with no user-facing runtime surface (internal class, build-only
+ dependency) needs no query test — say so; the reference analysis carries it.
+
## Interpret
The `references` edges in `checks.removalRefs` (plus any you added) and
`checks.coverageGaps` on surviving code are the primary outputs.
diff --git a/.skills/tinker-review/references/functional-testing.md
b/.skills/tinker-review/references/functional-testing.md
new file mode 100644
index 0000000000..9455146193
--- /dev/null
+++ b/.skills/tinker-review/references/functional-testing.md
@@ -0,0 +1,96 @@
+<!--
+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.
+-->
+
+# Functional Testing — Reference
+
+The mechanics behind SKILL.md step 4. *Whether* to run a functional test and
+*what* to test are judgment calls that live in the playbooks' **Verify**
sections
+(`general.md` holds the shared gate and battery framework; domain playbooks
+specialize). This file covers only the reproducible half: building the PR and
+standing up a server to test against.
+
+## Why a second server
+
+Phase 1's knowledge-graph server (`infrastructure/docker.js`) runs the **stock
+published image** — it holds the review graph, not the PR's code. Functional
+testing must exercise the PR's **own compiled artifacts**, so it builds from
+source and launches natively. The two servers run on different ports and never
+share state.
+
+## `functional/cli.js`
+
+| Command | What it does |
+|---------|--------------|
+| `start --workDir <dir> [--port <n>]` | Reads `pr`/`repoPath` from
`session.json`; adds a `build/` worktree on `pr-review/<pr>`; runs `mvn clean
install -DskipTests` over the full reactor; locates the `*-standalone`
assembly; writes a TinkerGraph config, an init script (binding `g` and `a`),
and a server yaml; launches `bin/gremlin-server.sh` on a free port; polls until
ready. Prints the handle as JSON and persists it to `functional.json`. |
+| `stop --workDir <dir>` | Reads `functional.json`, kills the server JVM, and
removes the `build/` worktree. Also invoked automatically by `review.js`
teardown. |
+
+The handle: `{ port, url, pid, buildWorktree, assemblyDir, logFile }`. `url` is
+the HTTP endpoint (`http://localhost:<port>/gremlin`) to hand the subagent.
+
+The build is the slow step (a full reactor build, minutes). If readiness times
+out, the error names `functional-server.log` in the work dir — inspect it for
the
+JVM's own startup errors.
+
+## What the built server exposes
+
+Mirrors the Phase-1 server so a reviewer's queries look the same against
either:
+
+- `g` — a standard traversal source over an empty TinkerGraph
+- `a` — the same graph `withComputer()`, for OLAP steps
(`connectedComponent()`, …)
+
+Serializers are the project defaults (GraphSON V4 + GraphBinary V4) over the
+`HttpChannelizer`, so any current GLV client connects normally.
+
+## Driving it from the subagent
+
+The subagent connects to `url` and submits traversals — via a GLV client or the
+Gremlin Console — exactly as a user reading the docs would. It gets the server
+URL, the PR title/description, the relevant docs, and the relevant Gherkin
+features; it does **not** get source, the graph, or the review findings. See
+SKILL.md step 4 for the full briefing contract and the Verify sections for the
+per-change battery.
+
+## What the subagent must return (report inputs)
+
+The test code is the source of truth for *what* was tested, so the subagent
+returns it **complete and unabbreviated**, with every scenario labeled in a
+comment carrying a stable id and one-line intent:
+
+```groovy
+// Scenario A1: group().by(T.id) — id key survives when its reduction is
productive
+g.V().group().by(id).by(out().count())
+```
+
+Those labels are the anchor between the report's two functional sections:
+
+- `appendixFunctional.testCode` — the full labeled battery (raw text; the
+ renderer wraps it — the subagent must not pre-wrap in `<pre>`/`<code>`).
+- `functionalTest` — **themes, not a scenario dump**. `results` rows are
+ theme-level, each citing the labels it spans (e.g. "Barrier family
(A4–A12)");
+ `plan`/`observations` surface what was exercised and what was learned. The
row
+ count must not imply more tests than the appendix lists.
+
+The renderer defensively strips a stray wrapper from the raw-text fields, but
the
+contract is raw text — relying on the guard is a slip, not a plan.
+
+## Isolation notes
+
+- The `build/` worktree is separate from the enrichment worktree (`src/`) so
+ Maven's `target/` output never pollutes the tree the agent reads.
+- Teardown removes the `build/` worktree and stops the JVM. If a run is
+ interrupted, `functional/cli.js stop` (or the next `start`, which prunes a
+ stale worktree first) cleans up.
diff --git a/.skills/tinker-review/references/interfaces.md
b/.skills/tinker-review/references/interfaces.md
index 8d0fa4239d..93f290c542 100644
--- a/.skills/tinker-review/references/interfaces.md
+++ b/.skills/tinker-review/references/interfaces.md
@@ -136,7 +136,11 @@ interface ReportPackage extends Evidence {
findings: { title; snippet; body }[];
openQuestions: { title; body; meta }[];
functionalTest?: { plan; results: { name; pass; output }[]; observations };
+ // plan/observations: HTML, theme-level. results rows are THEMES, each
`name`
+ // naming the scenario labels it spans — not one row per scenario.
appendixFunctional?: { environment; testCode; fullOutput };
+ // environment: HTML. testCode/fullOutput: RAW TEXT (renderer wraps in
+ // <pre><code>; do not pre-wrap). testCode is the COMPLETE labeled battery.
}
```
diff --git a/.skills/tinker-review/scripts/functional/cli.js
b/.skills/tinker-review/scripts/functional/cli.js
new file mode 100644
index 0000000000..7d8006418c
--- /dev/null
+++ b/.skills/tinker-review/scripts/functional/cli.js
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+// Thin CLI over functional/setup.js so SKILL.md step 4 can drive the
mechanical
+// build+start from one command, the way enrichment/cli.js drives the graph.
+// Reads session.json from --workDir for the pr/repo, prints the server handle
as
+// JSON, and persists it to functional.json so `stop` can find it later.
+//
+// node scripts/functional/cli.js start --workDir /tmp/pr-review-<pr>
+// node scripts/functional/cli.js stop --workDir /tmp/pr-review-<pr>
+
+import { readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { buildAndStart, stop } from "./setup.js";
+
+function parseArgs(argv) {
+ // A leading `--flag` means no subcommand was given (e.g. `--help`).
+ const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : undefined;
+ const opts = {};
+ for (let i = command ? 1 : 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] :
true;
+ opts[key] = val;
+ }
+ }
+ return { command, opts };
+}
+
+async function readSession(workDir) {
+ return JSON.parse(await readFile(join(workDir, "session.json"), "utf-8"));
+}
+
+async function main() {
+ const { command, opts } = parseArgs(process.argv.slice(2));
+ const workDir = opts.workDir;
+
+ if (!command || opts.help) {
+ process.stdout.write(
+ "Usage:\n" +
+ " cli.js start --workDir <dir> [--port <n>] build the PR and start
the functional server\n" +
+ " cli.js stop --workDir <dir> stop it and remove the
build worktree\n",
+ );
+ return;
+ }
+ if (!workDir) throw new Error("--workDir is required");
+
+ const session = await readSession(workDir);
+ const handlePath = join(workDir, "functional.json");
+
+ if (command === "start") {
+ const handle = await buildAndStart(workDir, {
+ pr: session.pr,
+ repoPath: session.repoPath,
+ port: opts.port ? Number(opts.port) : undefined,
+ });
+ await writeFile(handlePath, JSON.stringify(handle, null, 2));
+ process.stdout.write(JSON.stringify(handle, null, 2) + "\n");
+ return;
+ }
+
+ if (command === "stop") {
+ const handle = JSON.parse(await readFile(handlePath, "utf-8").catch(() =>
"null"));
+ if (handle) await stop(handle, { repoPath: session.repoPath });
+ process.stdout.write(JSON.stringify({ stopped: Boolean(handle) }) + "\n");
+ return;
+ }
+
+ throw new Error(`Unknown command: ${command}`);
+}
+
+main().catch((err) => {
+ process.stderr.write(`${err.message}\n`);
+ process.exit(1);
+});
diff --git a/.skills/tinker-review/scripts/functional/setup.js
b/.skills/tinker-review/scripts/functional/setup.js
new file mode 100644
index 0000000000..6a07592879
--- /dev/null
+++ b/.skills/tinker-review/scripts/functional/setup.js
@@ -0,0 +1,218 @@
+/*
+ * 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.
+ */
+
+// Mechanical setup for Phase-2 functional testing: build the PR from source
and
+// stand up a Gremlin Server from the freshly built assembly, so a blind
subagent
+// can exercise the change as a user would. This is the reproducible half of
+// step 4 — the judgment (what to test) lives in the playbooks' Verify
sections.
+//
+// Distinct from infrastructure/docker.js, which runs the STOCK published image
+// for the Phase-1 knowledge graph. Functional testing must run the PR's OWN
+// compiled code, so it builds and launches natively from `target/`.
+//
+// The build worktree is separate from the enrichment worktree
(`<workDir>/src`)
+// so Maven's `target/` output never pollutes the tree the agent reads during
+// enrichment.
+
+import { execFile, spawn } from "node:child_process";
+import { promisify } from "node:util";
+import { mkdir, writeFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
+import { existsSync } from "node:fs";
+import { createWriteStream } from "node:fs";
+
+import { findAvailablePort, waitForHttp } from "../infrastructure/net.js";
+
+const exec = promisify(execFile);
+
+const DEFAULT_BUILD_TIMEOUT_MS = 30 * 60 * 1000; // full reactor build is slow
+const DEFAULT_READY_TIMEOUT_MS = 60 * 1000; // native JVM start + graph
load
+
+// TinkerGraph config for the functional server. Mirrors the stock empty graph.
+const GRAPH_PROPERTIES =
`gremlin.graph=org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph
+gremlin.tinkergraph.vertexIdManager=LONG
+`;
+
+// Init script binding the two traversal sources the reviewer may need — kept
in
+// lockstep with docker.js's INIT_GROOVY so both servers expose the same
surface:
+// 'g' — standard traversal
+// 'a' — withComputer() for OLAP steps like connectedComponent()
+const INIT_GROOVY = `def globals = [:]
+globals << [g : traversal().withEmbedded(graph)]
+globals << [a : traversal().withEmbedded(graph).withComputer()]
+`;
+
+/**
+ * Build a self-contained server yaml for the functional test. Based on the
+ * project's shipped default (HttpChannelizer + GraphSON V4 / GraphBinary V4)
+ * with host/port pinned and the g/a bindings wired via an init script. Written
+ * fresh rather than parsed from the assembly so there is no YAML dependency
and
+ * no textual patching of the shipped file.
+ */
+function serverYaml(port) {
+ return `host: localhost
+port: ${port}
+evaluationTimeout: 30000
+channelizer: org.apache.tinkerpop.gremlin.server.channel.HttpChannelizer
+graphs: {
+ graph: conf/tinkergraph-review.properties}
+scriptEngines: {
+ gremlin-lang: {},
+ gremlin-groovy: {
+ plugins: {
org.apache.tinkerpop.gremlin.server.jsr223.GremlinServerGremlinPlugin: {},
+ org.apache.tinkerpop.gremlin.jsr223.ImportGremlinPlugin:
{classImports: [java.lang.Math], methodImports: [java.lang.Math#*]},
+ org.apache.tinkerpop.gremlin.jsr223.ScriptFileGremlinPlugin:
{files: [scripts/review-init.groovy]}}}}
+serializers:
+ - { className:
org.apache.tinkerpop.gremlin.util.ser.GraphSONMessageSerializerV4, config: {
ioRegistries:
[org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerIoRegistryV3] }}
+ - { className:
org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV4 }
+ - { className:
org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV4, config: {
serializeResultToString: true }}
+metrics: {
+ slf4jReporter: {enabled: true, interval: 180000}}
+strictTransactionManagement: false
+`;
+}
+
+/**
+ * Locate the built standalone server assembly under the build worktree. The
+ * assembly directory carries the project version in its name, so it is globbed
+ * rather than hard-coded.
+ *
+ * @param {string} buildWorktree
+ * @returns {Promise<string>} absolute path to the *-standalone directory
+ */
+async function findAssembly(buildWorktree) {
+ const targetDir = join(buildWorktree, "gremlin-server", "target");
+ if (!existsSync(targetDir)) {
+ throw new Error(`No gremlin-server/target under ${buildWorktree} — did the
build succeed?`);
+ }
+ const entries = await readdir(targetDir);
+ const match = entries.find(
+ (e) => e.startsWith("apache-tinkerpop-gremlin-server-") &&
e.endsWith("-standalone"),
+ );
+ if (!match) {
+ throw new Error(`No *-standalone assembly in ${targetDir} (found:
${entries.join(", ") || "nothing"})`);
+ }
+ return join(targetDir, match);
+}
+
+/**
+ * @typedef {object} FunctionalHandle
+ * @property {number} port - localhost port the server listens on
+ * @property {string} url - base HTTP endpoint
(`http://localhost:<port>/gremlin`)
+ * @property {number} pid - PID of the server JVM (for teardown)
+ * @property {string} buildWorktree - the `<workDir>/build` worktree to remove
on teardown
+ * @property {string} assemblyDir - the built `*-standalone` directory
+ * @property {string} logFile - server stdout/stderr log for diagnosis
+ */
+
+/**
+ * Build the PR and start a Gremlin Server from the built artifacts.
+ *
+ * Steps: add a `build` worktree on the PR branch → `mvn clean install
+ * -DskipTests` over the full reactor → locate the server assembly → write the
+ * TinkerGraph config + init script + yaml → launch `bin/gremlin-server.sh`
+ * natively on a free port → poll until ready.
+ *
+ * The caller (SKILL.md step 4) decides whether to run this at all, per the
+ * playbooks' Verify gate. Skipped changes never pay the build cost.
+ *
+ * @param {string} workDir - the review work dir, e.g. `/tmp/pr-review-<pr>`
+ * @param {object} opts
+ * @param {number} opts.pr - PR number (the branch is `pr-review/<pr>`)
+ * @param {string} opts.repoPath - the git repo the worktree is added against
+ * @param {number} [opts.port] - fixed port (default: an OS-assigned free port)
+ * @param {number} [opts.buildTimeoutMs]
+ * @param {number} [opts.readyTimeoutMs]
+ * @returns {Promise<FunctionalHandle>}
+ */
+export async function buildAndStart(workDir, opts) {
+ const { pr, repoPath } = opts;
+ if (!pr) throw new Error("buildAndStart requires opts.pr");
+ if (!repoPath) throw new Error("buildAndStart requires opts.repoPath");
+
+ const prBranch = `pr-review/${pr}`;
+ const buildWorktree = join(workDir, "build");
+
+ // Add the build worktree (idempotent — remove a stale one first).
+ if (existsSync(buildWorktree)) {
+ await exec("git", ["worktree", "remove", "--force", buildWorktree], { cwd:
repoPath }).catch(() => {});
+ }
+ await exec("git", ["worktree", "prune"], { cwd: repoPath }).catch(() => {});
+ await exec("git", ["worktree", "add", buildWorktree, prBranch], { cwd:
repoPath });
+
+ // Full-reactor build without tests, so every module reflects the PR.
+ await exec(
+ "mvn",
+ ["-f", join(buildWorktree, "pom.xml"), "clean", "install", "-DskipTests"],
+ { cwd: buildWorktree, timeout: opts.buildTimeoutMs ||
DEFAULT_BUILD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
+ );
+
+ const assemblyDir = await findAssembly(buildWorktree);
+ const port = opts.port || await findAvailablePort();
+
+ // Write config into the assembly's conf/scripts dirs.
+ await mkdir(join(assemblyDir, "conf"), { recursive: true });
+ await mkdir(join(assemblyDir, "scripts"), { recursive: true });
+ await writeFile(join(assemblyDir, "conf", "tinkergraph-review.properties"),
GRAPH_PROPERTIES);
+ await writeFile(join(assemblyDir, "scripts", "review-init.groovy"),
INIT_GROOVY);
+ const yamlPath = join(assemblyDir, "conf", "gremlin-server-review.yaml");
+ await writeFile(yamlPath, serverYaml(port));
+
+ // Launch natively in the foreground ("console" mode reads the yaml arg via
the
+ // catch-all case) and detach, capturing output to a log for diagnosis.
+ const logFile = join(workDir, "functional-server.log");
+ const out = createWriteStream(logFile);
+ await new Promise((resolve) => out.once("open", resolve));
+
+ const child = spawn(
+ join(assemblyDir, "bin", "gremlin-server.sh"),
+ [yamlPath],
+ { cwd: assemblyDir, stdio: ["ignore", out, out], detached: true },
+ );
+ child.unref();
+
+ const handle = { port, url: `http://localhost:${port}/gremlin`, pid:
child.pid, buildWorktree, assemblyDir, logFile };
+
+ try {
+ await waitForHttp(port, opts.readyTimeoutMs || DEFAULT_READY_TIMEOUT_MS);
+ } catch (err) {
+ await stop(handle).catch(() => {});
+ throw new Error(`${err.message} — see ${logFile}`);
+ }
+
+ return handle;
+}
+
+/**
+ * Stop the functional server and remove its build worktree.
+ *
+ * @param {FunctionalHandle} handle
+ * @param {object} [opts]
+ * @param {string} [opts.repoPath] - repo to prune the worktree against
(defaults to none)
+ * @returns {Promise<void>}
+ */
+export async function stop(handle, opts = {}) {
+ if (handle?.pid) {
+ try { process.kill(handle.pid, "SIGTERM"); } catch { /* already gone */ }
+ }
+ if (handle?.buildWorktree && opts.repoPath) {
+ await exec("git", ["worktree", "remove", "--force", handle.buildWorktree],
{ cwd: opts.repoPath }).catch(() => {});
+ await exec("git", ["worktree", "prune"], { cwd: opts.repoPath }).catch(()
=> {});
+ }
+}
diff --git a/.skills/tinker-review/scripts/infrastructure/docker.js
b/.skills/tinker-review/scripts/infrastructure/docker.js
index 372072332b..a1e6023a18 100644
--- a/.skills/tinker-review/scripts/infrastructure/docker.js
+++ b/.skills/tinker-review/scripts/infrastructure/docker.js
@@ -19,16 +19,15 @@
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";
+import { findAvailablePort, waitForHttp } from "./net.js";
+
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)]
@@ -75,7 +74,7 @@ export async function startServer(options = {}) {
const handle = { port, containerId, url, configDir };
try {
- await waitForReady(port, timeoutMs);
+ await waitForHttp(port, timeoutMs);
} catch (err) {
await stopServer(handle).catch(() => {});
throw err;
@@ -94,37 +93,3 @@ 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/infrastructure/net.js
b/.skills/tinker-review/scripts/infrastructure/net.js
new file mode 100644
index 0000000000..ead214e4a2
--- /dev/null
+++ b/.skills/tinker-review/scripts/infrastructure/net.js
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+// Shared networking helpers for the two Gremlin Servers a review stands up:
the
+// Phase-1 knowledge-graph server (docker.js) and the Phase-2 functional-test
+// server built from PR source (functional/setup.js). Both pick a free port and
+// poll the same HTTP readiness endpoint, so that logic lives here once.
+
+import { createServer } from "node:net";
+import { get } from "node:http";
+
+const POLL_INTERVAL_MS = 500;
+
+/**
+ * Ask the OS for a free TCP port by binding to 0 and reading it back.
+ * @returns {Promise<number>}
+ */
+export 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);
+ });
+}
+
+/**
+ * Poll an HTTP endpoint until it responds or the timeout elapses. Gremlin
+ * Server answers a plain GET on `/gremlin` once its WebSocket listener is up,
+ * so a successful response of any status means "ready".
+ *
+ * @param {number} port - localhost port to poll
+ * @param {number} timeoutMs - max wait before rejecting
+ * @param {object} [options]
+ * @param {string} [options.path] - request path (default "/gremlin")
+ * @returns {Promise<void>}
+ */
+export function waitForHttp(port, timeoutMs, options = {}) {
+ const path = options.path || "/gremlin";
+ const deadline = Date.now() + timeoutMs;
+
+ return new Promise((resolve, reject) => {
+ function poll() {
+ if (Date.now() > deadline) {
+ reject(new Error(`Server on port ${port} did not become ready within
${timeoutMs}ms`));
+ return;
+ }
+
+ const req = get(`http://localhost:${port}${path}`, (res) => {
+ res.resume();
+ resolve();
+ });
+ req.on("error", () => {
+ setTimeout(poll, POLL_INTERVAL_MS);
+ });
+ }
+
+ poll();
+ });
+}
diff --git a/.skills/tinker-review/scripts/renderer/render.js
b/.skills/tinker-review/scripts/renderer/render.js
index 58935effda..3fadcb096e 100644
--- a/.skills/tinker-review/scripts/renderer/render.js
+++ b/.skills/tinker-review/scripts/renderer/render.js
@@ -32,6 +32,31 @@ function esc(str) {
.replace(/"/g, """);
}
+/**
+ * Fields typed as raw *code* (appendixFunctional.testCode / .fullOutput) are
+ * wrapped by the renderer in its own `<pre><code>` and escaped. Agents
+ * sometimes pre-wrap the value in `<pre>`, `<code>`, or `<p>` anyway; escaping
+ * that renders the tags literally. Strip a single leading/trailing wrapper of
+ * those kinds so a slip degrades to clean text instead of visible markup. This
+ * is a lenient guard, not an HTML sanitizer — the field contract is raw text.
+ */
+function stripCodeWrapper(str) {
+ let s = String(str || "").trim();
+ const wrapper = /^<(pre|code|p)(?:\s[^>]*)?>([\s\S]*)<\/\1>$/i;
+ // Peel nested wrappers (e.g. `<pre><code>…</code></pre>`), up to two layers.
+ // Only peel an *unambiguous* single wrapper: if the inner content still
holds
+ // the same tag, the string is not one wrapper (e.g. two sibling `<code>`
+ // blocks), so leave it alone rather than over-strip.
+ for (let i = 0; i < 2; i++) {
+ const m = s.match(wrapper);
+ if (!m) break;
+ const inner = m[2].trim();
+ if (new RegExp(`</${m[1]}>`, "i").test(inner)) break;
+ s = inner;
+ }
+ return s;
+}
+
/**
* Render the full report from evidence data + agent narrative.
* Output matches the structure of reference-report.html exactly.
@@ -48,9 +73,11 @@ function esc(str) {
* 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"] }
+ * (results rows are THEME-level, each naming the scenario labels it
covers)
* findings: [{ title, snippet: "code", body: "HTML" }]
* openQuestions: [{ title, body: "HTML", meta: "string" }]
- * appendixFunctional: { environment: "HTML", testCode: "code", fullOutput:
"code" }
+ * appendixFunctional: { environment: "HTML", testCode: "raw text",
fullOutput: "raw text" }
+ * (testCode = the COMPLETE labeled battery; renderer wraps it in
<pre><code>, do NOT pre-wrap)
*/
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>`;
@@ -548,10 +575,11 @@ function renderAppendixFunctional(af) {
<div class="card">\n ${af.environment}\n </div>
<h3>Test Code</h3>
- <pre><code>${esc(af.testCode)}</code></pre>
+ <p class="section-intro">Complete, unabbreviated test battery. Each scenario
is labeled in a comment; the Functional Test section refers to these labels.</p>
+ <pre><code>${esc(stripCodeWrapper(af.testCode))}</code></pre>
<h3>Full Output</h3>
- <pre><code>${esc(af.fullOutput)}</code></pre>
+ <pre><code>${esc(stripCodeWrapper(af.fullOutput))}</code></pre>
</section>`;
}
diff --git a/.skills/tinker-review/scripts/review.js
b/.skills/tinker-review/scripts/review.js
index 21df3054d3..badcf72939 100644
--- a/.skills/tinker-review/scripts/review.js
+++ b/.skills/tinker-review/scripts/review.js
@@ -455,11 +455,12 @@ export async function phase1(session) {
// ============================================================
export async function teardown(sessionOrWorkDir) {
- let repoPath, worktreePath, prBranch, containerId;
+ let repoPath, worktreePath, prBranch, containerId, workDir;
if (typeof sessionOrWorkDir === "string") {
// Called with just a workDir path — read session.json
const { readFile: rf } = await import("node:fs/promises");
+ workDir = sessionOrWorkDir;
const sessionData = JSON.parse(await rf(join(sessionOrWorkDir,
"session.json"), "utf-8"));
repoPath = sessionData.repoPath;
worktreePath = sessionData.worktreePath;
@@ -468,6 +469,7 @@ export async function teardown(sessionOrWorkDir) {
} else {
// Called with a live session object
const session = sessionOrWorkDir;
+ workDir = session.workDir;
repoPath = session.repoPath;
worktreePath = session.worktreePath;
prBranch = session.prBranch;
@@ -476,6 +478,19 @@ export async function teardown(sessionOrWorkDir) {
if (session.connection) await session.connection.close().catch(() => {});
}
+ // Stop the functional-test server and remove its build worktree, if one was
+ // stood up (step 4 persists its handle to functional.json).
+ if (workDir) {
+ const { readFile: rf } = await import("node:fs/promises");
+ const handle = await rf(join(workDir, "functional.json"), "utf-8")
+ .then((s) => JSON.parse(s))
+ .catch(() => null);
+ if (handle) {
+ const { stop: stopFunctional } = await import("./functional/setup.js");
+ await stopFunctional(handle, { repoPath }).catch(() => {});
+ }
+ }
+
if (containerId) {
await exec("docker", ["stop", containerId]).catch(() => {});
await exec("docker", ["rm", containerId]).catch(() => {});
diff --git a/.skills/tinker-review/test/playbook-sections.test.js
b/.skills/tinker-review/test/playbook-sections.test.js
index 32a1f2976b..8f89cd345e 100644
--- a/.skills/tinker-review/test/playbook-sections.test.js
+++ b/.skills/tinker-review/test/playbook-sections.test.js
@@ -18,12 +18,13 @@
*/
// Structural conformance guard for the playbooks. Every playbook must carry
the
-// same five sections in the same order, split by data flow:
+// same six sections in the same order, split by data flow:
//
// Context — applicability gate
// Enrich — graph mutation only; names real enrichment CLI commands
// Inspect — reads changed source into findings (no graph representation)
-// Interpret — weighs evidence.json checks (+ Inspect notes) into the report
+// Verify — functional-test battery + the gate deciding whether to run it
+// Interpret — weighs evidence.json checks (+ Inspect/Verify notes) into the
report
// Escape — stop/escalate gates
//
// It also fails if an Enrich section names no registered command (and doesn't
@@ -41,7 +42,7 @@ import { COMMANDS } from "../scripts/enrichment/cli.js";
const here = dirname(fileURLToPath(import.meta.url));
const playbooksDir = join(here, "..", "playbooks");
-const CANONICAL = ["Context", "Enrich", "Inspect", "Interpret", "Escape"];
+const CANONICAL = ["Context", "Enrich", "Inspect", "Verify", "Interpret",
"Escape"];
// An Enrich section that legitimately has no domain-specific graph write must
// say so with this phrase rather than silently naming nothing.
@@ -71,7 +72,7 @@ function sectionBody(md, name) {
return md.slice(start, end);
}
-test("every playbook carries the five canonical sections in order", async ()
=> {
+test("every playbook carries the six canonical sections in order", async () =>
{
for (const file of await playbookFiles()) {
const md = await readFile(join(playbooksDir, file), "utf-8");
const headers = sectionHeaders(md);