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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ gitlab-mcp-server/
├── cmd/
│ ├── server/ # MCP server entry point and --shutdown support
│ ├── audit_1to1/ # Consolidated 1:1 SDK↔API parity audit (R-INPUT/R-OUTPUT/R-ACTION/R-META/R-ENUM + merge; R-PATH's `shapes.sent` and `shapes.typed.unsurfaced` list the fields GitLab sends that we do not publish, each with the entity condition and license tier read from `gitlab-api-live.json`, the list the field-by-field review reads, and `sent_declarations.go` answers the ones the record lists and the endpoint does not send; R-PAGE's `pagination.unpaginated` lists the actions that hand a model a list and no way to ask for the rest of it, judged against the 308 routes the same record says GitLab pages, and `pagination_declarations.go` answers the ones whose own route declares no `per_page`; -scope=sdk gates the service universe, the raw-GraphQL exemptions and the enum values; -scope=paths is R-PATH, the only rule that reads the request a handler builds rather than the surface it publishes — see Request paths below)
│ ├── audit_action_ids/ # Holds every canonical action ID the server publishes to a model against the IDs the catalog builds: the `RelatedActions` of an `ActionSpec`, the first argument of every `toolutil.HintAction` call, and a dotted ID spelled inside a `Usage` line or an individual tool's `Description`. Loaded through `cmd/internal/goprogram` and folded by the type checker rather than matched as text, because the IDs are package-local constants, fifty packages keep them in a metadata table with a lowercase `related` field, one keeps that table as a map to an anonymous struct, several hand the list in as a parameter, and two build an ID by concatenating a domain constant onto a name; a value it cannot fold is named rather than passed over. The oracle is the catalog built at Ultimate twice, self-managed and GitLab.com, since Orbit's group is contributed only for GitLab.com and one build alone reports its six IDs as dead. It answers whether an ID resolves and never whether it is the right one, and a registered alias is reported apart rather than counted a finding, since `gitlab_execute_action` resolves one and `gitlab_find_action` publishes canonical IDs. It reports and does not gate (make audit-action-ids)
│ ├── audit_action_ids/ # Holds every canonical action ID the server publishes to a model against the IDs the catalog builds: the `RelatedActions` of an `ActionSpec`, the first argument of every `toolutil.HintAction` call, and a dotted ID spelled inside a `Usage` line or an individual tool's `Description`. Loaded through `cmd/internal/goprogram` and folded by the type checker rather than matched as text, because the IDs are package-local constants, fifty packages keep them in a metadata table with a lowercase `related` field, one keeps that table as a map to an anonymous struct, several hand the list in as a parameter, and two build an ID by concatenating a domain constant onto a name; a value it cannot fold is named rather than passed over. The oracle is the catalog built at Ultimate twice, self-managed and GitLab.com, since Orbit's group is contributed only for GitLab.com and one build alone reports its six IDs as dead. It answers whether an ID resolves and never whether it is the right one, which is a permanent limit: being the right ID is a claim about the object an action reaches, and no set of names carries it. A registered alias is judged by where it is written, since both facts about one are true: `gitlab_execute_action` resolves it and `gitlab_find_action` publishes canonical IDs. In a `RelatedActions` entry or a `HintAction` argument it is a finding, with the canonical ID named beside it, because those are handed to a model as the ID to call next and an alias there can be followed once and never looked up; in a `Usage` line or a description it is reported apart and not counted, because naming an alias can be the substance of the sentence (`issue.update`'s usage says dynamic execute also accepts `issue.close` and `issue.reopen`). It reports and does not gate (make audit-action-ids)
│ ├── audit_catalog_first/ # Enforces catalog-first registration invariants (ADR-0004)
│ ├── audit_dead_consts/ # The half of `unused` staticcheck cannot reach: it judges a const group as one unit, so a member sharing a declaration with a member that is read is never looked at, and no `unused` setting changes it under golangci-lint v2. That group is the prevailing shape here (the action-ID block every domain keeps, the assertion-message block beside it in the tests), and the first run found twenty-five constants nothing reads. It loads `./internal/...` and `./cmd/...` through `cmd/internal/goprogram` with the test variants, since a constant a test reads is read and forty-odd packages hand their whole ID block to the catalog test through an `export_test.go`, which is also its one blind spot, a block exported wholesale being read wholesale. Exported constants are out of scope, because one may be read from a package behind a build tag this load leaves out; the packages carrying a GOOS- or GOARCH-constrained file are read again under each operating system and architecture pair the release builds, because a constant only the Windows half reads is read and so is one only the arm64 half reads, and setting the operating system alone would keep the host's architecture and leave an `_arm64.go` file out on amd64. A constant kept although nothing reads it is declared in `declarations.go` with its reason, keyed `package:name` at package scope and `package:Func.name` or `package:Type.Method.name` inside a function, so an entry for the one never excuses a local constant sharing its name; the stale judgement is scoped to the packages the run loaded (make check-dead-consts, step 4 of `make analyze`)
│ ├── audit_discovery_completeness/ # Audits discovery metadata (aliases/usage/related/param-guidance/sibling-cluster; input-enum candidates) — META-001
Expand Down Expand Up @@ -832,6 +832,8 @@ Developers add normal GitLab actions through domain-local `ActionSpecs` and the

Find combines canonical `domain.action` IDs, domain/action names, aliases, natural-language stopword filtering (removing frequent non-informative words), synonyms, fuzzy matching, and segmented matching for multi-intent prompts. Models should use `gitlab_find_action` to retrieve exact schemas, then execute the canonical action ID returned by find. See `docs/concepts/dynamic-tools.md` and ADR-0011.

**The `related_actions` a result carries are narrowed to the session that reads them.** The curated lists in the `ActionSpec`s are written against the whole catalog and every session is served a narrower one, so a Free instance used to be handed cross-links to Premium actions and an `--exclude-tools` deployment links to actions it had removed. `Registry.publishedRelatedActions` decides by what following the link would answer: one that resolves here is published under its canonical ID whatever spelling it was written in, one a filter withheld is kept because asking for it produces the narrowing rather than a typo correction (`withheldActionMessage`), and one neither can explain is dropped, since `unknown action` plus near-miss suggestions reads as a spelling correction and teaches a model the capability is absent. Measured at the Free tier the dropped set is 4 links in 3 packages, all of them Free actions naming Premium siblings.

**What one find call may cost is bounded, in three places that belong together.** The query is capped at `dynamic.MaxSearchQueryLength` (256 characters, published as the schema's `maxLength` and refused, never truncated), because a search costs the word count times the catalog three times over (lexical, fuzzy, and one pass per segment window) and a request body may be 4 MiB. The scoring passes check the context, so an abandoned POST stops costing and a deadline can end one; a cancelled search returns the error rather than an empty result. And the handler takes `toolutil.WithActionDeadline` itself, because it is registered directly rather than through one of the `WrapAction` functions and so passed through no deadline at all. `BenchmarkFind_PathologicalQuery` is what keeps a scorer change from quietly raising the ceiling.

### Enterprise tool gating
Expand Down
28 changes: 21 additions & 7 deletions cmd/audit_action_ids/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,27 @@
// cross-linked to the first is silent here, because the first resolves. What
// this narrows is the field to the IDs that cannot work at all.
//
// An ID that is a registered alias rather than a catalog ID is reported apart,
// under "alias", and is not counted as a finding. gitlab_execute_action
// resolves an alias, so a hint naming one works today; gitlab_find_action
// publishes canonical IDs, so a model that looks the name up in a listing does
// not find it. Which of those two facts should decide is a question for the
// layer that fixes the cross-links, and this command's job is to put both sets
// in front of it rather than to settle it.
// That limit is permanent, and the reason is worth stating so nobody tries to
// close it here: the oracle is the set of IDs, and being the right ID is a
// claim about the object an action reaches, which no set of names carries.
// Reading each list against the parameters its own action requires is what
// answers it, and that is a review rather than a rule. A membership check is
// also blind to a cross-link that resolves for this tree and not for the
// session reading it, which is why the projection filters what it publishes
// (Registry.publishedRelatedActions in internal/tools/dynamic) instead of
// leaving that to a gate here.
//
// An alias is judged by where it is written, which is the one thing this used
// to leave open. Both facts about an alias are true: gitlab_execute_action
// resolves one, and gitlab_find_action publishes canonical IDs and so lists it
// under no name. In a RelatedActions entry or a HintAction argument the second
// decides, because those are handed to a model as the ID to call next and an
// alias there can be followed once and never looked up, so it is a finding
// with the canonical ID named beside it as the fix. In a Usage line or a
// description the first decides, because naming an alias can be the substance
// of the sentence: issue.update's usage says that dynamic execute also accepts
// issue.close and issue.reopen. Those stay reported apart, under "alias", and
// are not counted as findings.
//
// # It reports and does not gate
//
Expand Down
65 changes: 52 additions & 13 deletions cmd/audit_action_ids/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import (
// schemaVersion is the shape of the work list this writes. A later layer reads
// the file to know which cross-links to fix, so a change to the shape has to
// be visible to it.
const schemaVersion = 1
//
// Version 2 moved an alias written into a related entry or a hint argument out
// of alias_references and into findings, carrying its canonical target in the
// same `canonical` field. The field set is unchanged and the counts are not:
// a reader that compares two runs across this line is comparing two rules.
const schemaVersion = 2

// dottedToken matches an action-ID-shaped token inside prose. The shape alone
// is far too generous, which is why every match is also held to a domain the
Expand Down Expand Up @@ -75,10 +80,21 @@ type Report struct {

// classify holds every site against the oracle and builds the report.
//
// The three outcomes are deliberately kept apart. A canonical ID is silent, an
// alias is reported without being counted a finding (gitlab_execute_action
// resolves it, gitlab_find_action does not publish it, and which of those
// decides is not this command's question), and anything else is a finding.
// The three outcomes are deliberately kept apart. A canonical ID is silent,
// anything the catalog has never heard of is a finding, and an alias is judged
// by where it was written.
//
// That last rule is the one this command deferred while there was nothing to
// settle it with. Both halves of the old reasoning are true at once:
// gitlab_execute_action resolves an alias, and gitlab_find_action publishes
// canonical IDs and so lists it under no name. What decides between them is
// the site. A related entry and a hint argument are structured fields that the
// discovery tools hand a model as the ID to call next, and an alias there is a
// cross-link a model can follow once and can never look up, so it is a
// finding. A Usage line or a description is prose, where naming an alias can
// be the whole point of the sentence: issue.update's usage says that dynamic
// execute also accepts issue.close and issue.reopen, which is true, useful,
// and would be a defect under one rule for both. Those stay reported apart.
func classify(sites []site, ids *oracle) Report {
report := Report{
SchemaVersion: schemaVersion,
Expand Down Expand Up @@ -119,7 +135,12 @@ func (r *Report) judge(at site, candidate string, ids *oracle) {
finding := Finding{Package: at.Package, File: at.File, Line: at.Line, Kind: at.Kind, ID: candidate}
if canonical, isAlias := ids.alias(candidate); isAlias {
finding.Canonical = canonical
r.AliasRefs = append(r.AliasRefs, finding)
if isProseKind(at.Kind) {
r.AliasRefs = append(r.AliasRefs, finding)
return
}
r.Findings = append(r.Findings, finding)
r.Summary.ByKind[at.Kind]++
return
}
finding.Closest = closestID(candidate, ids.sorted)
Expand Down Expand Up @@ -269,10 +290,10 @@ func editDistance(left, right string) int {
// to act on them, then the two buckets that are not findings, then what the
// run saw.
func writeReport(out io.Writer, report Report, verbose bool) {
writeGroups(out, report.Findings, "resolves to no action")
writeGroups(out, report.Findings, findingVerb)
if verbose {
fmt.Fprintln(out, "=== registered aliases, not catalog IDs ===")
writeGroups(out, report.AliasRefs, "alias of")
fmt.Fprintln(out, "=== aliases named in prose, not catalog IDs ===")
writeGroups(out, report.AliasRefs, aliasVerb)
writeUnresolved(out, report.Unresolved)
}
writeStale(out, report.StaleExemptions)
Expand All @@ -292,19 +313,37 @@ func writeStale(out io.Writer, stale []string) {
}
}

// writeGroups prints findings under one `=== package ===` heading each.
func writeGroups(out io.Writer, findings []Finding, verb string) {
// writeGroups prints findings under one `=== package ===` heading each, each
// row read with the verb its own outcome deserves.
//
// The verb is per row rather than per list because the findings list now holds
// two outcomes: an ID nothing resolves, and an alias written where a catalog
// ID belongs. Printing the second under the first's verb would say a string
// resolves to nothing while naming what it resolves to.
func writeGroups(out io.Writer, findings []Finding, verb func(Finding) string) {
current := ""
for _, finding := range findings {
if finding.Package != current {
current = finding.Package
fmt.Fprintf(out, "=== %s ===\n", current)
}
fmt.Fprintf(out, " %s:%d %s %q %s%s\n",
finding.File, finding.Line, finding.Kind, finding.ID, verb, trailer(finding))
finding.File, finding.Line, finding.Kind, finding.ID, verb(finding), trailer(finding))
}
}

// findingVerb reads one finding: an alias standing in for the canonical ID, or
// a string the catalog has never heard of.
func findingVerb(finding Finding) string {
if finding.Canonical != "" {
return "is an alias, not the catalog ID"
}
return "resolves to no action"
}

// aliasVerb reads a row of the prose bucket, where naming an alias is allowed.
func aliasVerb(Finding) string { return "alias of" }

// trailer renders whatever a finding knows beyond the ID itself.
func trailer(finding Finding) string {
if finding.Canonical != "" {
Expand All @@ -330,7 +369,7 @@ func writeUnresolved(out io.Writer, unresolved []Unresolved) {

// writeSummary prints what the run saw.
func writeSummary(out io.Writer, summary Summary, verbose bool) {
fmt.Fprintf(out, "%s: %d published ID(s) resolve to nothing in %d package(s); %d alias reference(s); %d site(s) not folded; %d stale exemption(s)\n",
fmt.Fprintf(out, "%s: %d published ID(s) to fix in %d package(s); %d alias(es) named in prose; %d site(s) not folded; %d stale exemption(s)\n",
toolName, summary.Findings, summary.Packages, summary.AliasHits, summary.Unresolved, summary.Stale)
fmt.Fprintf(out, " judged %d published ID(s) against %d catalog ID(s) and %d alias(es)\n",
summary.Judged, summary.CatalogIDs, summary.Aliases)
Expand Down
Loading
Loading