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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,46 @@ Manages `.graph.*` sidecar files written next to each source file. Agents read t

| Command | Description |
|---|---|
| `analyze [path]` | Upload repo, run full analysis, write `.graph.*` files (use `--no-files` to skip) |
| `analyze [path]` | Upload repo, run full analysis, write graph files (use `--three-file` for best results, `--no-shards` to skip) |
| `skill` | Print agent awareness prompt — pipe to `CLAUDE.md` or `AGENTS.md` |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the skill command is implemented in the codebase

rg -n "Use.*skill" --type=go
rg -n "\"skill\"" cmd/ --type=go -A5 -B5

Repository: supermodeltools/cli

Length of output: 45


🏁 Script executed:

# Search for skill across all files
rg -i "skill" --max-count=20

# Read the README around the mentioned lines
head -140 README.md | tail -40

Repository: supermodeltools/cli

Length of output: 2139


🏁 Script executed:

# Find main.go or command setup
fd -type f -name "main.go"

# Look for command registration patterns
rg -i "command.*skill|skill.*command" --type=go

# Look for cobra/cli command definitions
rg "cmd\." --type=go -B2 -A2 | head -50

# List the cmd/ directory structure
ls -la cmd/ 2>/dev/null || echo "cmd/ directory not found"
git ls-files | grep -E "^cmd/|main\.go"

Repository: supermodeltools/cli

Length of output: 4084


🏁 Script executed:

# Check archdocs.go for skill-related code
cat cmd/archdocs.go

# Double-check: search for "skill" in all cmd files
rg "skill" cmd/

Repository: supermodeltools/cli

Length of output: 2369


Remove the skill command from README or implement it.

The README documents a skill command (lines 106, 132) with usage examples, but it doesn't exist in the codebase. There's no cmd/skill.go file like the other documented commands (analyze, clean, hook, etc. all have their implementation files).

Either remove these lines from the README, or implement the command. Right now users following the docs will hit a "command not found" error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 106, The README currently documents a non-existent CLI
command "skill"; either remove the two README lines (the table row at line ~106
and the usage example at ~132) or add a real command implementation named
"skill" under cmd (e.g., create cmd/skill.go), export a Cobra/CLI subcommand
registered with the root command, and implement its Run/Execute function to
print the agent awareness prompt (same content referenced in
CLAUDE.md/AGENTS.md) and exit; reference symbols to update are the README
entries for "skill" and the new command file cmd/skill.go and its command
registration (root.AddCommand(skillCmd) or equivalent) so users no longer get
"command not found".

| `watch [path]` | Generate graph files on startup, then keep them updated incrementally |
| `clean [path]` | Remove all `.graph.*` files from the repository |
| `hook` | Claude Code `PostToolUse` hook — forward file-change events to the `watch` daemon |

### Three-file shard format (recommended)

For best results, use the `--three-file` flag to generate separate `.calls`, `.deps`, and `.impact` files instead of a single `.graph` file:

```bash
supermodel analyze --three-file
```

This produces three files per source file:

```
src/cache.go → src/cache.calls.go # who calls what, with file:line
→ src/cache.deps.go # imports and imported-by
→ src/cache.impact.go # risk level, domains, blast radius
Comment on lines +122 to +124
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the inconsistent file extension notation.

Your example at lines 122-124 shows specific filenames like cache.calls.go (with .go), but then lines 139-141 say "Files ending in .calls.*" using a wildcard pattern. This is confusing—pick one notation and stick with it.

If the actual generated files are cache.calls.go, cache.deps.go, etc., then use that concrete pattern throughout. If the * represents different language extensions (like .calls.go for Go, .calls.py for Python), make that explicit.

✏️ Suggested fix for consistency

Option 1: If files always include the source language extension, update the manual text to match the example:

-Files ending in .calls.* contain function call relationships.
-Files ending in .deps.* contain dependency relationships.
-Files ending in .impact.* contain blast radius data.
+Files ending in .calls.go (or .calls.py, etc.) contain function call relationships.
+Files ending in .deps.go (or .deps.py, etc.) contain dependency relationships.  
+Files ending in .impact.go (or .impact.py, etc.) contain blast radius data.

Option 2: If you want to use wildcards for brevity, update the example to match:

-src/cache.go          → src/cache.calls.go    # who calls what, with file:line
-                      → src/cache.deps.go     # imports and imported-by
-                      → src/cache.impact.go   # risk level, domains, blast radius
+src/cache.go          → src/cache.calls.*    # who calls what, with file:line
+                      → src/cache.deps.*     # imports and imported-by
+                      → src/cache.impact.*   # risk level, domains, blast radius

Also applies to: 139-141

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 122 - 124, The README shows inconsistent filename
notation between concrete filenames (e.g., cache.calls.go, cache.deps.go,
cache.impact.go) and a wildcard pattern ("Files ending in .calls.*"); choose and
apply one consistent convention across the document: either replace the wildcard
references with concrete examples matching the generated files (cache.calls.go,
cache.deps.go, cache.impact.go) wherever mentioned (including the earlier
example at lines 139-141) or change the concrete example to a wildcard form and
add a brief clarifying note explaining that the wildcard represents language
extensions (e.g., .calls.go, .calls.py) so readers understand whether the suffix
includes the source-language extension; update all occurrences to the chosen
style.

```
Comment on lines +121 to +125
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language specifiers to code blocks.

The markdown linter is flagging these code blocks because they're missing language identifiers. Add text or bash to keep the linter happy.

🔧 Quick fix

For the file listing at line 121:

-```
+```text
 src/cache.go          → src/cache.calls.go    # who calls what, with file:line

For the manual instruction at line 137:

-```
+```text
 This repository has Supermodel graph shard files next to source files.

Also applies to: 137-143

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 121-121: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 121 - 125, The markdown code fences for the file
listing line containing "src/cache.go          → src/cache.calls.go    # who
calls what, with file:line" and the manual instruction block starting with "This
repository has Supermodel graph shard files next to source files." need language
specifiers; update both triple-backtick fences to use ```text (or ```bash) so
the linter stops flagging them, i.e., add "text" after the opening ``` for the
block that contains the "src/cache.go → src/cache.calls.go" snippet and for the
block that contains the "This repository has Supermodel graph shard files next
to source files." paragraph.


The three-file format is **68% faster** in benchmarks because grep hits are more targeted — searching for a function name hits only the `.calls` file with caller/callee data, not a combined blob.

**Tell your agent about the files** by adding this to `CLAUDE.md` or `AGENTS.md`:

```bash
supermodel skill >> CLAUDE.md
```

Or manually add:

```
This repository has Supermodel graph shard files next to source files.
Files ending in .calls.* contain function call relationships.
Files ending in .deps.* contain dependency relationships.
Files ending in .impact.* contain blast radius data.
Read these files to understand relationships between modules before making changes.
```

### On-demand analysis

| Command | Description |
Expand Down
9 changes: 8 additions & 1 deletion cmd/analyze.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/supermodeltools/cli/internal/analyze"
Expand All @@ -11,6 +13,7 @@ import (
func init() {
var opts analyze.Options
var noShards bool
var threeFile bool

c := &cobra.Command{
Use: "analyze [path]",
Expand All @@ -32,6 +35,9 @@ Use --no-shards to skip writing graph files.`,
if err := cfg.RequireAPIKey(); err != nil {
return err
}
if noShards && threeFile {
return fmt.Errorf("--three-file cannot be used with --no-shards")
}
dir := "."
if len(args) > 0 {
dir = args[0]
Expand All @@ -40,7 +46,7 @@ Use --no-shards to skip writing graph files.`,
// Shard mode: Generate handles the full pipeline (API call +
// cache + shards) in a single upload. Running analyze.Run
// first would duplicate the API call.
return shards.Generate(cmd.Context(), cfg, dir, shards.GenerateOptions{Force: opts.Force})
return shards.Generate(cmd.Context(), cfg, dir, shards.GenerateOptions{Force: opts.Force, ThreeFile: threeFile})
}
return analyze.Run(cmd.Context(), cfg, dir, opts)
},
Expand All @@ -49,6 +55,7 @@ Use --no-shards to skip writing graph files.`,
c.Flags().BoolVar(&opts.Force, "force", false, "re-analyze even if a cached result exists")
c.Flags().StringVarP(&opts.Output, "output", "o", "", "output format: human|json")
c.Flags().BoolVar(&noShards, "no-shards", false, "skip writing .graph.* shard files")
c.Flags().BoolVar(&threeFile, "three-file", false, "generate .calls/.deps/.impact files instead of single .graph")

rootCmd.AddCommand(c)
}
2 changes: 1 addition & 1 deletion internal/shards/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func isShardPath(name string) bool {
return false
}
tag := strings.TrimPrefix(stemExt, ".")
return tag == ShardExt
return shardTags[tag]
}

// firstString returns the first non-empty string value from props found under
Expand Down
15 changes: 12 additions & 3 deletions internal/shards/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ type GenerateOptions struct {
Force bool
DryRun bool
CacheFile string
ThreeFile bool // generate .calls/.deps/.impact instead of single .graph
}

func renderShards(repoDir string, cache *Cache, files []string, dryRun, threeFile bool) (int, error) {
if threeFile {
return RenderAllThreeFile(repoDir, cache, files, dryRun)
}
return RenderAll(repoDir, cache, files, dryRun)
}

// WatchOptions configures the watch command.
Expand All @@ -45,6 +53,7 @@ type WatchOptions struct {
type RenderOptions struct {
CacheFile string
DryRun bool
ThreeFile bool
}

// Generate uploads a zip, builds the graph cache, and renders all shards.
Expand All @@ -69,7 +78,7 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate
cache.Build(&ir)
files := cache.SourceFiles()
spin := ui.Start("Rendering shards…")
written, err := RenderAll(repoDir, cache, files, opts.DryRun)
written, err := renderShards(repoDir, cache, files, opts.DryRun, opts.ThreeFile)
spin.Stop()
if err != nil {
return err
Expand Down Expand Up @@ -139,7 +148,7 @@ func Generate(ctx context.Context, cfg *config.Config, dir string, opts Generate
files := cache.SourceFiles()

spin = ui.Start("Rendering shards…")
written, err := RenderAll(repoDir, cache, files, opts.DryRun)
written, err := renderShards(repoDir, cache, files, opts.DryRun, opts.ThreeFile)
spin.Stop()
if err != nil {
return err
Expand Down Expand Up @@ -369,7 +378,7 @@ func Render(dir string, opts RenderOptions) error {
cache.Build(&ir)
files := cache.SourceFiles()

written, err := RenderAll(repoDir, cache, files, opts.DryRun)
written, err := renderShards(repoDir, cache, files, opts.DryRun, opts.ThreeFile)
if err != nil {
return err
}
Expand Down
89 changes: 88 additions & 1 deletion internal/shards/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ func ShardFilename(sourcePath string) string {
return stem + ".graph" + ext
}

// ThreeFileShardNames generates the .calls, .deps, .impact shard paths.
func ThreeFileShardNames(sourcePath string) (calls, deps, impact string) {
ext := filepath.Ext(sourcePath)
stem := strings.TrimSuffix(sourcePath, ext)
return stem + ".calls" + ext, stem + ".deps" + ext, stem + ".impact" + ext
}

// Header returns the @generated header line.
func Header(prefix string) string {
return prefix + " @generated supermodel-shard — do not edit\n"
Expand Down Expand Up @@ -236,20 +243,51 @@ func WriteShard(repoDir, shardPath, content string, dryRun bool) error {
return nil
}

// safeRemove removes a file only if it resolves inside repoDir (traversal guard).
func safeRemove(repoDir, relPath string) {
full, err := filepath.Abs(filepath.Join(repoDir, relPath))
if err != nil {
return
}
repoAbs, err := filepath.Abs(repoDir)
if err != nil {
return
}
if !strings.HasPrefix(full, repoAbs+string(filepath.Separator)) && full != repoAbs {
return
}
_ = os.Remove(full)
}

// removeStaleThreeFile removes .calls/.deps/.impact files for a source file.
func removeStaleThreeFile(repoDir, srcFile string) {
c, d, i := ThreeFileShardNames(srcFile)
for _, p := range []string{c, d, i} {
safeRemove(repoDir, p)
}
}

// removeStaleGraph removes the single .graph file for a source file.
func removeStaleGraph(repoDir, srcFile string) {
safeRemove(repoDir, ShardFilename(srcFile))
}

// RenderAll generates and writes .graph shards for the given source files.
// Returns the count of shards written.
func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int, error) {
sort.Strings(files)
written := 0

for _, srcFile := range files {
// Clean up stale three-file shards from a previous --three-file run.
removeStaleThreeFile(repoDir, srcFile)

ext := filepath.Ext(srcFile)
prefix := CommentPrefix(ext)
header := Header(prefix)

content := RenderGraph(srcFile, cache, prefix)
if content == "" {
// Remove any stale shard left from a previous run.
full := filepath.Join(repoDir, ShardFilename(srcFile))
_ = os.Remove(full)
continue
Expand All @@ -273,6 +311,55 @@ func RenderAll(repoDir string, cache *Cache, files []string, dryRun bool) (int,
return written, nil
}

// RenderAllThreeFile generates .calls, .deps, and .impact files per source file.
func RenderAllThreeFile(repoDir string, cache *Cache, files []string, dryRun bool) (int, error) {
sort.Strings(files)
written := 0

for _, srcFile := range files {
// Clean up stale single .graph file from a previous non-three-file run.
removeStaleGraph(repoDir, srcFile)

ext := filepath.Ext(srcFile)
prefix := CommentPrefix(ext)
header := Header(prefix)
goPrefix := ""
if ext == ".go" {
goPrefix = "//go:build ignore\n\npackage ignore\n"
}

callsPath, depsPath, impactPath := ThreeFileShardNames(srcFile)

deps := renderDepsSection(srcFile, cache, prefix)
calls := renderCallsSection(srcFile, cache, prefix)
impact := renderImpactSection(srcFile, cache, prefix)

for _, item := range []struct {
path string
content string
}{
{depsPath, deps},
{callsPath, calls},
{impactPath, impact},
} {
if item.content == "" {
safeRemove(repoDir, item.path)
continue
Comment on lines +345 to +347
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Protect stale-file deletes (dry-run + path safety).

This branch deletes files even in dry-run, and it bypasses the path traversal guard used by WriteShard. Please route deletes through a safe helper and honor dryRun.

Safer pattern
 		for _, item := range []struct {
 			path    string
 			content string
 		}{
 			{depsPath, deps},
 			{callsPath, calls},
 			{impactPath, impact},
 		} {
 			if item.content == "" {
-				full := filepath.Join(repoDir, item.path)
-				_ = os.Remove(full)
+				if err := RemoveShard(repoDir, item.path, dryRun); err != nil {
+					return written, err
+				}
 				continue
 			}
func RemoveShard(repoDir, shardPath string, dryRun bool) error {
	full, err := filepath.Abs(filepath.Join(repoDir, shardPath))
	if err != nil {
		return err
	}
	repoAbs, err := filepath.Abs(repoDir)
	if err != nil {
		return err
	}
	if !strings.HasPrefix(full, repoAbs+string(filepath.Separator)) && full != repoAbs {
		return fmt.Errorf("path traversal blocked: %s", shardPath)
	}
	if dryRun {
		fmt.Printf("  [dry-run] would remove %s\n", full)
		return nil
	}
	if err := os.Remove(full); err != nil && !os.IsNotExist(err) {
		return err
	}
	return nil
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/shards/render.go` around lines 311 - 314, The current branch deletes
stale files directly (os.Remove(full)) and ignores dryRun and the path-safety
checks used by WriteShard; replace the direct delete with a safe helper (e.g.,
RemoveShard) that resolves absolute paths, enforces the repoDir prefix check to
prevent path traversal, honors the dryRun flag by logging the intended delete
instead of performing it, and returns errors except for IsNotExist; update the
code path that handles item.content == "" to call that helper with repoDir,
item.path and dryRun instead of calling os.Remove directly.

}
fullContent := goPrefix + header + item.content + "\n"
if err := WriteShard(repoDir, item.path, fullContent, dryRun); err != nil {
if strings.Contains(err.Error(), "path traversal") {
continue
}
return written, err
}
written++
}
}

return written, nil
}

func formatLoc(file string, line int) string {
if file != "" && line > 0 {
return fmt.Sprintf("%s:%d", file, line)
Expand Down
Loading
Loading