Skip to content
Open
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
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ canopy search refs ParseConfig .
# Check code quality (CI gate)
canopy analyze check --max-cyclomatic 30

# Inspect parser gaps and fail on incomplete or unknown receipts
canopy index coverage . --strict

# Full executive report
canopy analyze report --format markdown

Expand All @@ -37,7 +40,9 @@ canopy mcp --root .

## Current Main

Current main uses gotreesitter `v0.45.0` and keeps large-repository indexing bounded by default: the CLI uses a 1 GiB Go soft memory limit when the caller has not supplied one, while index builds use at most two concurrent parse workers and a garbage collection cadence of 32 parsed files. Index walks also prune ignored directories before descent and skip unsupported or tagless grammars before parsing.
Current main uses gotreesitter `v0.49.0` and keeps large-repository indexing bounded by default: the CLI uses a 1 GiB Go soft memory limit when the caller has not supplied one, while index builds use at most two concurrent parse workers and a garbage collection cadence of 32 parsed files. Index walks also prune ignored directories before descent and skip unsupported or tagless grammars before parsing.

Index schema `0.3.0` stores parser-health receipts for each file. Receipts preserve top-most `ERROR` and `MISSING` regions, parser stop reasons, recovered-region decisions, and generated-file fast paths. A `clean` receipt means no actionable parser gap was detected; it is not a proof that a grammar or tags query models every construct.

Diff-aware checks and reviews use a valid repository index when one exists.
Without one, they build and report a changed-only snapshot.
Expand All @@ -64,6 +69,7 @@ Call graph roots can be narrowed with `--file` or `path/to/file.go:Name` when mu
| `canopy index stats` | Codebase metrics: symbol counts, language breakdown |
| `canopy index diff` | Compare structural changes between two snapshots |
| `canopy index errors` | Show parse errors from indexing |
| `canopy index coverage` | Report parser gaps and missing parse receipts. `--strict` provides a CI gate |
| `canopy index validate` | Validate index integrity |
| `canopy index export` | Export index to portable `.canopyindex` file for federation |
| `canopy index import` | Load and summarize exported indexes |
Expand All @@ -76,7 +82,7 @@ Call graph roots can be narrowed with `--file` or `path/to/file.go:Name` when mu
| `canopy search refs` | Find references by symbol name or regex |
| `canopy search query` | Raw tree-sitter S-expression queries |
| `canopy search scope` | Resolve symbols in scope at file + line |
| `canopy search context` | Pack focused context for agent token budgets. `--concept` for concept-aware packing |
| `canopy search context` | Pack focused context for agent token budgets. Bundle mode reports parser health; `--concept` enables concept-aware packing |
| `canopy search symbols` | Search symbols by pattern |
| `canopy search imports` | Analyze import patterns |

Expand Down Expand Up @@ -109,8 +115,8 @@ Call graph roots can be narrowed with `--file` or `path/to/file.go:Name` when mu
| `canopy analyze licenses` | Dependency license detection with SPDX matching and deny rules |
| `canopy analyze similarity` | Find similar functions between codebases |
| `canopy analyze duplication` | Detect code duplication |
| `canopy analyze report` | Executive summary: complexity, architecture, security, dead code, hotspots. `--by-team` for CODEOWNERS breakdown |
| `canopy analyze review` | Aggregated PR review: complexity delta, boundary violations, new capabilities, blast radius |
| `canopy analyze report` | Confidence-aware summary: parser health, complexity, architecture, security, dead code, hotspots. `--by-team` for CODEOWNERS breakdown |
| `canopy analyze review` | Confidence-aware PR review: parser health, complexity delta, boundary violations, new capabilities, blast radius |
| `canopy analyze trends` | Track quality metrics over time (`record` / `show`) |

### Transform — Code transformations and output generation
Expand Down Expand Up @@ -257,8 +263,9 @@ canopy mcp --root /path/to/repo --allow-writes # enable refactoring tools
| `gts_callgraph` | Call graph traversal |
| `gts_dead` | Dead code detection |
| `gts_impact` | Blast radius computation |
| `gts_context` | Token-budgeted context packing |
| `gts_context` | Token-budgeted context packing with parser health on task-conditioned bundles |
| `gts_grep` | Structural selector search |
| `gts_coverage` | Parser gaps, stopped parses, recovery receipts, and unknown coverage |

## Selector Syntax

Expand Down
24 changes: 21 additions & 3 deletions cmd/canopy/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"

"m31labs.dev/canopy/internal/contextpack"
"m31labs.dev/canopy/internal/indexcoverage"
"m31labs.dev/canopy/pkg/contextbundle"
"m31labs.dev/canopy/pkg/model"
"m31labs.dev/canopy/pkg/xref"
Expand Down Expand Up @@ -77,6 +78,10 @@ func newContextCmd() *cobra.Command {
if err != nil {
return err
}
parserHealth, err := indexcoverage.BuildHealthForPaths(idx, contextManifestPaths(result.Manifest), 5)
if err != nil {
return err
}

if bundleManifestPath != "" {
if err := writeJSONFile(bundleManifestPath, result.Manifest); err != nil {
Expand All @@ -90,9 +95,14 @@ func newContextCmd() *cobra.Command {
}
if jsonOutput {
return emitJSON(struct {
Receipt contextbundle.Receipt `json:"receipt"`
Manifest contextbundle.Manifest `json:"manifest"`
}{Receipt: result.Receipt, Manifest: result.Manifest})
Receipt contextbundle.Receipt `json:"receipt"`
Manifest contextbundle.Manifest `json:"manifest"`
ParserHealth indexcoverage.Health `json:"parser_health"`
}{Receipt: result.Receipt, Manifest: result.Manifest, ParserHealth: parserHealth})
}
if parserHealth.Status != model.ParseCoverageClean && parserHealth.Status != model.ParseCoverageGenerated {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: selected context parser health is %s (%d untrusted files)\n",
parserHealth.Status, parserHealth.Summary.Untrusted)
}
fmt.Print(string(result.Content))
return nil
Expand Down Expand Up @@ -201,6 +211,14 @@ func newContextCmd() *cobra.Command {
return cmd
}

func contextManifestPaths(manifest contextbundle.Manifest) []string {
paths := make([]string, 0, len(manifest.Items))
for _, item := range manifest.Items {
paths = append(paths, item.Path)
}
return paths
}

// conceptReport holds the result of concept-aware context packing.
type conceptReport struct {
Concept string `json:"concept"`
Expand Down
1 change: 1 addition & 0 deletions cmd/canopy/group_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func newIndexGroup() *cobra.Command {
newStatsCmd(),
newDiffCmd(),
newErrorsCmd(),
newIndexCoverageCmd(),
newValidateCmd(),
newExportCmd(),
newImportCmd(),
Expand Down
12 changes: 9 additions & 3 deletions cmd/canopy/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ func loadBaselineIndex(outPath string) (*model.Index, bool, error) {
if strings.TrimSpace(outPath) == "" {
return nil, false, nil
}
cached, err := index.Load(outPath)
// A build can migrate an older cache by treating it as a comparison
// baseline while the builder reparses entries from the current schema.
cached, err := index.LoadLenient(outPath)
switch {
case err == nil:
return cached, true, nil
Expand Down Expand Up @@ -201,7 +203,9 @@ func compareBaseline(previous, idx *model.Index, hasBaseline bool) (structdiff.R
changed := true
if hasBaseline {
report = structdiff.Compare(previous, idx)
changed = report.Stats.ChangedFiles > 0 || !parseErrorsEqual(previous.Errors, idx.Errors)
changed = report.Stats.ChangedFiles > 0 ||
!parseErrorsEqual(previous.Errors, idx.Errors) ||
!parseCoverageEqual(previous.Files, idx.Files)
}
return report, changed
}
Expand Down Expand Up @@ -239,7 +243,9 @@ func runIndexWatch(ctx context.Context, target string, builder *index.Builder, c
}

watchReport := structdiff.Compare(current, next)
watchChanged := watchReport.Stats.ChangedFiles > 0 || !parseErrorsEqual(current.Errors, next.Errors)
watchChanged := watchReport.Stats.ChangedFiles > 0 ||
!parseErrorsEqual(current.Errors, next.Errors) ||
!parseCoverageEqual(current.Files, next.Files)
if !watchChanged {
return
}
Expand Down
149 changes: 149 additions & 0 deletions cmd/canopy/index_coverage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package main

import (
"errors"
"fmt"

"github.com/spf13/cobra"

"m31labs.dev/canopy/internal/indexcoverage"
"m31labs.dev/canopy/pkg/model"
)

func newIndexCoverageCmd() *cobra.Command {
var cachePath string
var noCache bool
var jsonOutput bool
var includeAll bool
var strict bool
var limit int

cmd := &cobra.Command{
Use: "coverage [path]",
Short: "Report parser coverage gaps and missing parse receipts",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if limit <= 0 {
return fmt.Errorf("limit must be > 0")
}
target := "."
if len(args) == 1 {
target = args[0]
}

idx, err := loadOrBuild(cmd, cachePath, target, noCache)
if err != nil {
return err
}
if generator, _ := cmd.Flags().GetString("generator"); generator != "" {
idx = idx.FilterByGenerator(generator)
}
includeGenerated, _ := cmd.Flags().GetBool("include-generated")
report, err := indexcoverage.Build(idx, indexcoverage.Options{
IncludeClean: includeAll,
IncludeGenerated: includeAll || includeGenerated,
MaxFiles: limit,
})
if err != nil {
return err
}

if jsonOutput {
if err := emitJSON(report); err != nil {
return err
}
} else {
printCoverageReport(report)
}
if strict && report.StrictFailure() {
return exitCodeError{code: 2, err: errors.New("index contains incomplete or unknown parser coverage")}
}
return nil
},
}

cmd.Flags().StringVar(&cachePath, "cache", "", "load index from cache instead of parsing")
cmd.Flags().BoolVar(&noCache, "no-cache", false, "skip auto-discovery of cached index")
cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output")
cmd.Flags().BoolVar(&includeAll, "all", false, "include clean and generated-file receipts")
cmd.Flags().BoolVar(&strict, "strict", false, "exit with code 2 for partial, stopped, unknown, or failed parses")
cmd.Flags().IntVar(&limit, "limit", 100, "maximum number of file receipts to emit")
return cmd
}

func printCoverageReport(report indexcoverage.Report) {
summary := report.Summary
fmt.Printf(
"coverage: files=%d clean=%d partial=%d stopped=%d generated=%d unknown=%d parse_errors=%d gaps=%d root=%s\n",
report.TotalFiles,
summary.Clean,
summary.Partial,
summary.Stopped,
summary.Generated,
summary.Unknown,
summary.ParseErrors,
summary.Gaps,
report.Root,
)
if summary.Recovered > 0 || summary.IgnoredEOF > 0 || summary.Truncated > 0 {
fmt.Printf(
"receipts: recovered_regions=%d ignored_eof_missing=%d truncated_files=%d\n",
summary.Recovered,
summary.IgnoredEOF,
summary.Truncated,
)
}
for _, file := range report.Files {
coverage := file.Coverage
fmt.Printf(
" %s status=%s language=%s gaps=%d errors=%d missing=%d",
file.Path,
coverage.Status,
file.Language,
len(coverage.Gaps),
coverage.ErrorNodes,
coverage.MissingNodes,
)
if coverage.StopReason != "" {
fmt.Printf(" reason=%s", coverage.StopReason)
}
fmt.Println()
for _, gap := range coverage.Gaps {
printCoverageGap(gap)
}
}
if report.DetailsTruncated {
fmt.Println(" ... file receipts truncated; raise --limit to inspect more")
}
if len(report.Errors) > 0 {
fmt.Println("parse errors:")
for _, parseErr := range report.Errors {
fmt.Printf(" %s: %s\n", parseErr.Path, parseErr.Error)
}
}
}

func printCoverageGap(gap model.ParseGap) {
fmt.Printf(
" %s %d:%d-%d:%d bytes=%d-%d",
gap.Kind,
gap.StartLine,
gap.StartColumn,
gap.EndLine,
gap.EndColumn,
gap.StartByte,
gap.EndByte,
)
if gap.NodeType != "" {
fmt.Printf(" node=%s", gap.NodeType)
}
fmt.Println()
}

func runIndexCoverage(args []string) error {
cmd := newIndexCoverageCmd()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(args)
return cmd.Execute()
}
82 changes: 82 additions & 0 deletions cmd/canopy/index_coverage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package main

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"

"m31labs.dev/canopy/internal/indexcoverage"
"m31labs.dev/canopy/pkg/model"
)

func TestRunIndexCoverageJSON(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package sample\n\nfunc Work() {}\n"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}

output, runErr := captureIndexCoverageOutput(t, []string{tmpDir, "--no-cache", "--json", "--all"})
if runErr != nil {
t.Fatalf("runIndexCoverage returned error: %v", runErr)
}
var report indexcoverage.Report
if err := json.Unmarshal(output, &report); err != nil {
t.Fatalf("decode coverage report: %v\n%s", err, output)
}
if report.TotalFiles != 1 || report.Summary.Clean != 1 || len(report.Files) != 1 {
t.Fatalf("unexpected report: %+v", report)
}
}

func TestRunIndexCoverageStrictRejectsMalformedSource(t *testing.T) {
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "broken.go"), []byte("package sample\n\nfunc Broken( {\n"), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}

_, runErr := captureIndexCoverageOutput(t, []string{tmpDir, "--no-cache", "--strict"})
if runErr == nil {
t.Fatal("expected strict coverage to fail")
}
assertExitCode(t, runErr, 2)
}

func TestParseCoverageEqualDetectsReceiptChanges(t *testing.T) {
left := []model.FileSummary{{
Path: "main.go",
ParseCoverage: &model.ParseCoverage{Status: model.ParseCoverageClean},
}}
right := []model.FileSummary{{
Path: "main.go",
ParseCoverage: &model.ParseCoverage{Status: model.ParseCoveragePartial},
}}
if parseCoverageEqual(left, right) {
t.Fatal("expected different receipts to compare unequal")
}
right[0].ParseCoverage.Status = model.ParseCoverageClean
if !parseCoverageEqual(left, right) {
t.Fatal("expected identical receipts to compare equal")
}
}

func captureIndexCoverageOutput(t *testing.T, args []string) ([]byte, error) {
t.Helper()
originalStdout := os.Stdout
readPipe, writePipe, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe failed: %v", err)
}
os.Stdout = writePipe
defer func() { os.Stdout = originalStdout }()

runErr := runIndexCoverage(args)
_ = writePipe.Close()
var output bytes.Buffer
if _, err := output.ReadFrom(readPipe); err != nil {
t.Fatalf("ReadFrom failed: %v", err)
}
_ = readPipe.Close()
return output.Bytes(), runErr
}
Loading
Loading