Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/plans/coding-command-group.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ subcommands take `-m/--memory` like every other group.
| `label-present` | error | the empty label (Decision 2) |
| `label-is-condition` | error | `child-of`, `applies-when`, `related`, bare `Applies when` |
| `check-node-resolves` | warning | dangling / unreadable check node — the edge's `source` (Decision 3) |
| `description-has-trigger` | warning | second blind spot in `hadron_find_nodes` output |
| `description-present` | warning | second blind spot in `hadron_find_nodes` output (deviation 2) |
| `duplicate-trigger` | warning | cloned check never re-pointed |
| `seq-unique` | warning | non-deterministic sibling ordering |
| `foreign-toolchain` | warning | the misfiled `format-sources` |
Expand Down Expand Up @@ -391,6 +391,28 @@ possible and the visibility gap is real — but it is now a rule with no known
live instance rather than one with a motivating example. Nothing in either
memory currently trips it.

### 8. Label findings quote the body's trigger paragraph (#331)

Added after `hadron coding review lint` found three bare-`Applies when` stems in
`hadronmemory.com::hadron-server` (hadron-server#845) and `--fix` could repair
none of them: the trigger text existed, but in the node body rather than the
description.

Measuring all 90 checks across the four memories that have them: 55 state their
scope as `> **Scope.** …`, 2 as a line-initial `**Applies when** …`, and 33
neither. So a body-derived trigger is findable for ~63% of checks.

**It is quoted, not promoted.** Those paragraphs run a median of 238 characters
(max 567) against a median healthy edge label of 84 (p90 142), so feeding one to
`--fix` would mint a label 3x too long — and an enormous slugified edge loc with
it. `label-present` / `label-is-condition` findings therefore append the
paragraph, truncated to 160 characters, and `--fix` is untouched. `--suggest`
prints it in full. When a body states no scope the finding gains nothing, since
that is a third of checks and a "none found" line on each would be noise.

Presentation only: a unit test asserts the hint changes neither which rules fire,
their severities, nor the `--fix` plan.

## Verification (as built)

`go test ./...` (16 packages) and `make lint` (0 issues) are green. Read-only
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/agentic/agentic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ hadron search <query> [-m <memory>]... [--mode hybrid|keyword|vector|regex] [--p
hadron replace text <old> <new> --field <f> (--node <urn> | -m <memory>) [--prefix <loc>] [--regex] [-i] [--dry-run] [--yes] [--max-nodes N]
hadron edge list <node-urn> | add | update <edge-id> | rm <edge-id>
hadron spec list [-m <memory>] | get <citation>|--prefix <prefix> | describe | use [<memory>] | register [--check] | find <query> [--match-exactly] | grep <pattern> [--regex] [-i] [--field content|abstract] [--prefix <loc>] | replace <pattern> <replacement> [--regex] [--word-boundary=false] [--field content|abstract] [--dry-run] [--yes] [--max-specs N] | new ... | edit <citation> | extract <citation> --to-feature <fff> | link <from> <to> | lint [<citation>] | check-tools [--prefix <loc>] | supersede <citation> | import spec-kit|code
hadron coding review lint -m <memory> [--root <loc>] [--toolchain <t>|-] [--strict] [--fix [--yes]] [--json] | preflight lint -m <memory> [--root <loc>] [--strict] [--json]
hadron coding review lint -m <memory> [--root <loc>] [--toolchain <t>|-] [--strict] [--suggest] [--fix [--yes]] [--json] | preflight lint -m <memory> [--root <loc>] [--strict] [--json]
hadron app list --org <org> | install (--org <id> | --owner-me) --agent <ref> --name <n> [--type <t>] [--urn <slug>] [--description <d>] | uninstall <id> | use <urn>
hadron ai-config list [--app <id>] [--agent <id>] | create (--app|--agent|--org <id>) --name <n> --provider <p> --model <m> [--api-key -] [--file <path>] | update <id> ... | rm <id>
hadron org list [--mine] | create --name <n> --urn <urn> | get <id> | public <org-ref> | update <id> | rm <id> | member list|add|set-role|rm <org-id> --user <id> [--role <r>] | invite create <email> --org <id> --role <r> | invite accept <slug> | invite show <slug>
Expand Down
134 changes: 134 additions & 0 deletions internal/cmd/coding/bodytrigger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package coding

import (
"regexp"
"strconv"
"strings"
)

// Quoting the body's trigger paragraph in a label finding (#331).
//
// When a check's edge label is empty or isn't a condition, the text that
// *should* be in the label is usually sitting a few lines away in the node
// body. Measured across the 90 review checks in the four memories that have
// them, 57 state their scope in the body under one of two conventions:
//
// > **Scope.** Adding or renaming a GraphQL argument that identifies …
// **Applies when** a parent component reads a child's `bind:`-bound value …
//
// The finding quotes that text so whoever fixes it has the source material
// without opening each node.
//
// It is deliberately NOT fed to --fix. Those paragraphs run a median of 238
// characters (max 567) against a median healthy edge label of 84, so promoting
// one verbatim would produce a label 3x too long — and, since the edge loc is
// slugified from the name, an enormous derived loc with it. Condensing a scope
// paragraph into a trigger is a judgement call the linter hands over rather
// than makes.
var (
reScopeMarker = regexp.MustCompile(`(?i)^\s*>?\s*\*\*Scope\.?\*\*\s*(.*)$`)
reAppliesMarker = regexp.MustCompile(`(?i)^\s*\*\*Applies when\*\*\s*(.*)$`)
)

// triggerQuoteLimit is how much of the paragraph a finding shows by default.
// A little above the p90 healthy label (142 chars), so what's displayed is
// roughly the size of the label being asked for. --suggest prints it whole.
const triggerQuoteLimit = 160

// bodyTrigger returns the scope/trigger paragraph a check's body states, and
// whether one was found. The paragraph is flattened to a single line.
//
// Both conventions wrap across lines: the blockquote form continues on
// following `>` lines, the inline form on following non-blank lines. Either
// ends at a blank line.
func bodyTrigger(content string) (string, bool) {
lines := strings.Split(content, "\n")
for i, ln := range lines {
if m := reScopeMarker.FindStringSubmatch(ln); m != nil {
// The marker's `>` is optional, so how the paragraph continues has
// to follow the line actually matched — assuming blockquote would
// silently drop the continuation of a plain `**Scope.**` paragraph.
// Every Scope marker in the live memories is blockquoted today, so
// this is a latent case rather than an observed one.
quoted := strings.HasPrefix(strings.TrimSpace(ln), ">")
return joinParagraph(m[1], lines[i+1:], quoted)
}
if m := reAppliesMarker.FindStringSubmatch(ln); m != nil {
// Keep the marker: "Applies when X" already reads as the trigger,
// whereas a Scope paragraph describes it.
text, ok := joinParagraph(m[1], lines[i+1:], false)
if !ok {
return "", false
}
return "Applies when " + text, true
}
}
return "", false
}

// joinParagraph flattens a marker line's remainder plus its continuation lines
// into one whitespace-normalised string. quoted selects blockquote
// continuation (lines starting `>`) over plain continuation (any non-blank).
func joinParagraph(first string, rest []string, quoted bool) (string, bool) {
parts := []string{first}
for _, ln := range rest {
t := strings.TrimSpace(ln)
if t == "" {
break
}
if quoted {
if !strings.HasPrefix(t, ">") {
break
}
t = strings.TrimSpace(strings.TrimPrefix(t, ">"))
if t == "" {
break
}
} else if strings.HasPrefix(t, "#") || strings.HasPrefix(t, ">") {
break // a new heading or blockquote ends the paragraph
}
parts = append(parts, t)
}
out := strings.Join(strings.Fields(strings.Join(parts, " ")), " ")
if out == "" {
return "", false
}
return out, true
}

// truncateRunes shortens s to at most n runes, backing off to a word boundary
// when one is near the limit so the quote doesn't end mid-word.
//
// Both the search and the threshold are in RUNES. strings.LastIndexAny would
// return a byte offset, which over-truncates multi-byte text: a CJK trigger
// whose only space sits at rune 50 has byte offset ~150, which passes a
// "3/4 of 160" test and cuts 160 runes down to 50.
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
cut := r[:n]
for i := len(cut) - 1; i > n*3/4; i-- {
if cut[i] == ' ' || cut[i] == '\t' {
cut = cut[:i]
break
}
}
return strings.TrimRight(string(cut), " \t,;:.") + "…"
}

// triggerHint is the sentence appended to a label finding when the body states
// a scope. Returns "" when it doesn't, so a finding gains nothing rather than
// carrying a "couldn't find one" line — a third of checks have no such
// paragraph, and that noise would land on every one of them.
func triggerHint(content string, full bool) string {
text, ok := bodyTrigger(content)
if !ok {
return ""
}
if !full {
text = truncateRunes(text, triggerQuoteLimit)
}
return " — the body states its scope, condense it into the label: " + strconv.Quote(text)
}
176 changes: 176 additions & 0 deletions internal/cmd/coding/bodytrigger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package coding

import (
"strings"
"testing"
)

// The two conventions, in the shapes they actually appear in: hadron-server /
// hadron-cli / mmdata use the blockquote Scope form, hadron-portal the inline
// bold form.
func TestBodyTrigger(t *testing.T) {
cases := []struct {
name string
content string
want string
}{
{
"blockquote scope, single line",
"# Review: whatever\n\n> **Scope.** Adding or renaming a GraphQL argument that identifies an entity.\n\nBody follows.",
"Adding or renaming a GraphQL argument that identifies an entity.",
},
{
"blockquote scope, wrapped",
"> **Scope.** Run this when authorization compares two `organizationId`s,\n> or branches on org membership, for a user-owned entity.\n\nRest.",
"Run this when authorization compares two `organizationId`s, or branches on org membership, for a user-owned entity.",
},
{
"inline applies-when keeps its marker",
"# Title\n\n**Applies when** a parent component reads a child's bound value.\n\nCheck 1 — ...",
"Applies when a parent component reads a child's bound value.",
},
{
"inline applies-when, wrapped",
"**Applies when** a route form\nreturns a typed message.\n\nNext para.",
"Applies when a route form returns a typed message.",
},
{
"scope without the trailing period in the marker",
"> **Scope** Something happens.\n",
"Something happens.",
},
// A third of checks state no scope at all; those must yield nothing so
// the finding stays quiet rather than carrying a "none found" line.
{"no marker", "# Review: x\n\nJust prose about the rule.\n", ""},
{"empty", "", ""},
{"marker with no text", "> **Scope.**\n\nBody.", ""},
{"marker with only whitespace", "> **Scope.** \n", ""},
}
for _, tc := range cases {
got, ok := bodyTrigger(tc.content)
if tc.want == "" {
if ok || got != "" {
t.Errorf("%s: expected no trigger, got %q", tc.name, got)
}
continue
}
if !ok {
t.Errorf("%s: expected a trigger, found none", tc.name)
continue
}
if got != tc.want {
t.Errorf("%s:\n got %q\n want %q", tc.name, got, tc.want)
}
}
}

// The blockquote form stops at the end of the blockquote, not at the end of the
// document — otherwise the whole body would be swallowed into the quote.
func TestBodyTriggerStopsAtParagraphEnd(t *testing.T) {
content := "> **Scope.** First para.\n> Still first.\n\n> A later blockquote that is not the scope.\n\n## Heading\n"
got, ok := bodyTrigger(content)
if !ok {
t.Fatal("expected a trigger")
}
if strings.Contains(got, "later blockquote") || strings.Contains(got, "Heading") {
t.Errorf("paragraph over-ran its end: %q", got)
}
if got != "First para. Still first." {
t.Errorf("got %q", got)
}
}

func TestTruncateRunes(t *testing.T) {
// Multi-byte input must not be cut mid-rune.
s := strings.Repeat("é", 50)
got := truncateRunes(s, 10)
if len([]rune(got)) > 11 { // 10 + the ellipsis
t.Errorf("truncated to %d runes: %q", len([]rune(got)), got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("expected an ellipsis, got %q", got)
}
// Short input is returned untouched.
if got := truncateRunes("short", 160); got != "short" {
t.Errorf("short input altered: %q", got)
}
// Cuts on a word boundary when one is near the limit.
got = truncateRunes("alpha beta gamma delta epsilon", 20)
if strings.Contains(got, "delt…") {
t.Errorf("cut mid-word: %q", got)
}
}

func TestTriggerHint(t *testing.T) {
long := "> **Scope.** " + strings.Repeat("word ", 100)

// Default truncates to roughly label size.
h := triggerHint(long, false)
if h == "" {
t.Fatal("expected a hint")
}
if len([]rune(h)) > triggerQuoteLimit+80 {
t.Errorf("default hint is not truncated: %d runes", len([]rune(h)))
}
if !strings.Contains(h, "…") {
t.Error("expected the truncation ellipsis")
}

// --suggest prints it whole.
full := triggerHint(long, true)
if len([]rune(full)) <= len([]rune(h)) {
t.Error("--suggest should produce a longer quote than the default")
}
if strings.Contains(full, "…") {
t.Error("--suggest should not truncate")
}

// No scope paragraph → no hint at all, in either mode.
if got := triggerHint("just prose", false); got != "" {
t.Errorf("expected no hint, got %q", got)
}
if got := triggerHint("just prose", true); got != "" {
t.Errorf("expected no hint with --suggest either, got %q", got)
}
}

// The Scope marker's `>` is optional, so continuation handling must follow the
// line actually matched. Assuming blockquote silently dropped the rest of a
// plain `**Scope.**` paragraph. No live memory uses this form today — all 57
// Scope markers are blockquoted — so this guards a latent case.
func TestBodyTriggerPlainScopeWraps(t *testing.T) {
got, ok := bodyTrigger("**Scope.** Adding an argument that identifies\nan existing entity.\n\nRest.")
if !ok {
t.Fatal("expected a trigger")
}
want := "Adding an argument that identifies an existing entity."
if got != want {
t.Errorf("plain Scope paragraph lost its continuation:\n got %q\n want %q", got, want)
}

// The blockquoted form still stops at the end of the blockquote.
got, _ = bodyTrigger("> **Scope.** First line.\nplain continuation must not be absorbed.\n")
if strings.Contains(got, "plain continuation") {
t.Errorf("blockquoted paragraph absorbed a non-quoted line: %q", got)
}
}

// truncateRunes searches and thresholds in runes. strings.LastIndexAny returns
// a BYTE offset, which over-truncated multi-byte text: a CJK trigger whose only
// space sits at rune 50 has byte offset ~150, which passed a "3/4 of 160" test
// and cut 160 runes down to 50.
func TestTruncateRunesNonASCIIKeepsContext(t *testing.T) {
s := strings.Repeat("経", 50) + " " + strings.Repeat("路", 200)
got := []rune(truncateRunes(s, 160))
if len(got) < 150 {
t.Errorf("over-truncated multi-byte text to %d runes; want ~160", len(got))
}
// Still a valid string, still ends with the ellipsis.
if !strings.HasSuffix(string(got), "…") {
t.Errorf("expected an ellipsis, got %q", string(got))
}
// ASCII behaviour is unchanged: back off to the word boundary.
if got := truncateRunes("alpha beta gamma delta epsilon zeta", 30); strings.Contains(got, "zet") {
t.Errorf("expected a word-boundary cut, got %q", got)
}
}
18 changes: 14 additions & 4 deletions internal/cmd/coding/coding.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ type checkNode struct {
Loc string
Name string
Description string
Tags []string
Seq *int
IsRunnable bool
// Content is the node body. The shared NodeBatch operation already selects
// it, so carrying it costs no extra round trip — it is what lets a label
// finding quote the trigger paragraph the body already states (#331).
Content string
Tags []string
Seq *int
IsRunnable bool
}

// graphEdge is one edge incident to a lint root. Other* describe the far
Expand Down Expand Up @@ -211,7 +215,10 @@ func fetchRootEdges(ctx context.Context, client graphql.Client, mem codingMemory
// an edge may legitimately cross into another memory, and rebuilding the ref
// would then look up the wrong memory — reporting a live node as unresolvable,
// or silently linting a same-loc node from the home memory instead.
func fetchNodes(ctx context.Context, client graphql.Client, byID map[string]string) (map[string]checkNode, []string, error) {
// wantContent retains node bodies. Only `review lint` reads them (to quote a
// check's scope paragraph, #331); `preflight lint` never does, so it opts out
// rather than holding every route target's body for the run.
func fetchNodes(ctx context.Context, client graphql.Client, byID map[string]string, wantContent bool) (map[string]checkNode, []string, error) {
if len(byID) == 0 {
return map[string]checkNode{}, nil, nil
}
Expand Down Expand Up @@ -243,6 +250,9 @@ func fetchNodes(ctx context.Context, client graphql.Client, byID map[string]stri
if n.Description != nil {
cn.Description = *n.Description
}
if wantContent && n.Content != nil {
cn.Content = *n.Content
}
if n.IsRunnable != nil {
cn.IsRunnable = *n.IsRunnable
}
Expand Down
Loading