From 6511ac78f9b097f063363311e40479030a022c0a Mon Sep 17 00:00:00 2001 From: GDS K S Date: Tue, 5 May 2026 19:33:37 -0500 Subject: [PATCH 1/3] feat: add stacklit export command for markdown overview Closes #28. Reading stacklit.json directly is fine for AI agents, but it's hard to skim in a PR description, GitHub issue, or chat message. The new export command renders the same data as readable markdown: stacklit export # markdown to stdout stacklit export -o stacklit.md # markdown to a file The output includes a project header with file/line totals, a language and framework summary, entrypoints, a modules table with direct dependencies, per-module collapsible
blocks listing exports and types, the dependency edge list, hot files from the last 90 days, and the existing hints (test command, env vars, where to add features). Rendering is deterministic. Modules, dependencies, and hot files are sorted, so the same stacklit.json produces byte-identical markdown across runs and machines, which makes it safe to commit. Pipe characters in user-supplied strings (module purposes, etc.) are escaped so they don't break markdown table rows. --- README.md | 2 + USAGE.md | 26 +++ internal/cli/export.go | 60 ++++++ internal/cli/root.go | 1 + internal/renderer/markdown.go | 322 +++++++++++++++++++++++++++++ internal/renderer/markdown_test.go | 214 +++++++++++++++++++ 6 files changed, 625 insertions(+) create mode 100644 internal/cli/export.go create mode 100644 internal/renderer/markdown.go create mode 100644 internal/renderer/markdown_test.go diff --git a/README.md b/README.md index d0f759d..128a1b7 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,8 @@ stacklit diff # check if index is stale stacklit serve # start MCP server stacklit derive # print compact nav map (~250 tokens) stacklit derive --inject claude # inject map into CLAUDE.md +stacklit export # print readable markdown overview +stacklit export -o stacklit.md # write markdown overview to a file stacklit setup # auto-configure all detected AI tools stacklit setup claude # configure Claude Code + MCP stacklit setup cursor # configure Cursor + MCP diff --git a/USAGE.md b/USAGE.md index 762cde8..5f1a3e6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -118,6 +118,8 @@ Regenerates `stacklit.html` and opens it in your browser. | `stacklit derive` | Print compact navigation map (~250 tokens) to stdout | | `stacklit derive --inject claude` | Inject map into CLAUDE.md | | `stacklit derive --inject cursor` | Inject map into .cursorrules | +| `stacklit export` | Print a readable markdown overview to stdout | +| `stacklit export -o stacklit.md` | Write the markdown overview to a file | | `stacklit setup` | Auto-detect and configure all AI tools | | `stacklit setup claude` | Configure Claude Code (CLAUDE.md + MCP) | | `stacklit setup cursor` | Configure Cursor (.cursorrules + MCP) | @@ -150,6 +152,30 @@ The server auto-reloads when `stacklit.json` changes on disk. --- +## Exporting to markdown + +`stacklit.json` is built for AI agents and tooling. When you want something a person can skim — a PR description, a GitHub issue, a Slack message — use the markdown export: + +```bash +stacklit export # print to stdout +stacklit export -o stacklit.md # write to a file +``` + +The markdown contains: + +- A title with the project name, language, file count, and total lines +- A language and framework summary table +- Entrypoints and key directories (when present) +- A modules table with purpose, size, and direct dependencies +- Per-module collapsible `
` blocks listing exports and types +- A dependency edge list, plus most-depended-on and isolated modules +- Hot files from the last 90 days (when git data is present) +- Hints: how to add a feature, the test command, env vars + +Output is deterministic: the same `stacklit.json` produces byte-identical markdown across runs and machines, so it's safe to commit. + +--- + ## Configuration Create `.stacklitrc.json` in your project root (optional): diff --git a/internal/cli/export.go b/internal/cli/export.go new file mode 100644 index 0000000..3c6a54d --- /dev/null +++ b/internal/cli/export.go @@ -0,0 +1,60 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/glincker/stacklit/internal/renderer" + "github.com/glincker/stacklit/internal/schema" + "github.com/spf13/cobra" +) + +func newExportCmd() *cobra.Command { + var format, source, output string + + cmd := &cobra.Command{ + Use: "export", + Short: "Export the index in a human-readable format", + Long: `Renders stacklit.json in a different format for sharing. + +Currently supports markdown, which produces a readable project overview +suitable for pasting into PR descriptions, GitHub issues, or chat. + +Examples: + stacklit export Print markdown to stdout + stacklit export --format md Same as above + stacklit export -o stacklit.md Write markdown to a file + stacklit export --source ./other.json Read from a non-default index`, + RunE: func(cmd *cobra.Command, args []string) error { + data, err := os.ReadFile(source) + if err != nil { + return fmt.Errorf("could not read %s: %w (run 'stacklit init' first)", source, err) + } + var idx schema.Index + if err := json.Unmarshal(data, &idx); err != nil { + return fmt.Errorf("could not parse %s: %w", source, err) + } + + switch format { + case "md", "markdown": + if output == "" { + fmt.Print(renderer.RenderMarkdown(&idx)) + return nil + } + if err := renderer.WriteMarkdown(&idx, output); err != nil { + return fmt.Errorf("could not write %s: %w", output, err) + } + fmt.Fprintf(cmd.OutOrStderr(), "Wrote %s\n", output) + return nil + default: + return fmt.Errorf("unknown format %q (supported: md)", format) + } + }, + } + + cmd.Flags().StringVar(&format, "format", "md", "Output format (md)") + cmd.Flags().StringVar(&source, "source", "stacklit.json", "Path to the index JSON") + cmd.Flags().StringVarP(&output, "output", "o", "", "Write to file instead of stdout") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index c291c5c..86bdd6c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -26,4 +26,5 @@ func init() { rootCmd.AddCommand(newServeCmd()) rootCmd.AddCommand(newDeriveCmd()) rootCmd.AddCommand(newSetupCmd()) + rootCmd.AddCommand(newExportCmd()) } diff --git a/internal/renderer/markdown.go b/internal/renderer/markdown.go new file mode 100644 index 0000000..3d98927 --- /dev/null +++ b/internal/renderer/markdown.go @@ -0,0 +1,322 @@ +package renderer + +import ( + "bytes" + "fmt" + "os" + "sort" + "strings" + + "github.com/glincker/stacklit/internal/schema" +) + +// RenderMarkdown returns a human-readable markdown overview of idx, designed +// for pasting into PR descriptions, GitHub issues, or chat. It's deterministic: +// modules and dependencies are sorted, so the output diffs cleanly between +// runs and between machines. +func RenderMarkdown(idx *schema.Index) string { + var sb strings.Builder + + writeHeader(&sb, idx) + writeOverview(&sb, idx) + writeEntrypoints(&sb, idx) + writeKeyDirectories(&sb, idx) + writeModulesTable(&sb, idx) + writeModuleDetails(&sb, idx) + writeDependencies(&sb, idx) + writeHotFiles(&sb, idx) + writeHints(&sb, idx) + writeFooter(&sb, idx) + + return sb.String() +} + +// WriteMarkdown writes the rendered markdown to path. If the file already +// matches the new content byte-for-byte, the file is left untouched so commit +// hooks don't see spurious changes. +func WriteMarkdown(idx *schema.Index, path string) error { + data := []byte(RenderMarkdown(idx)) + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) { + return nil + } + return os.WriteFile(path, data, 0644) +} + +func writeHeader(sb *strings.Builder, idx *schema.Index) { + name := idx.Project.Name + if name == "" { + name = "Project" + } + fmt.Fprintf(sb, "# %s\n\n", name) + + parts := []string{} + if idx.Tech.PrimaryLanguage != "" { + parts = append(parts, idx.Tech.PrimaryLanguage) + } + if idx.Project.Type != "" { + parts = append(parts, idx.Project.Type) + } + if idx.Structure.TotalFiles > 0 { + parts = append(parts, fmt.Sprintf("%d files", idx.Structure.TotalFiles)) + } + if idx.Structure.TotalLines > 0 { + parts = append(parts, fmt.Sprintf("%s lines", formatThousands(idx.Structure.TotalLines))) + } + if len(parts) > 0 { + sb.WriteString(strings.Join(parts, " · ")) + sb.WriteString("\n\n") + } +} + +func writeOverview(sb *strings.Builder, idx *schema.Index) { + hasLangs := len(idx.Tech.Languages) > 0 + hasFrameworks := len(idx.Tech.Frameworks) > 0 + if !hasLangs && !hasFrameworks && idx.Architecture.Pattern == "" { + return + } + + sb.WriteString("## Overview\n\n") + + if idx.Architecture.Pattern != "" { + fmt.Fprintf(sb, "Pattern: %s\n\n", idx.Architecture.Pattern) + } + + if hasLangs { + sb.WriteString("| Language | Files | Lines |\n") + sb.WriteString("|----------|-------|-------|\n") + langs := sortedKeys(idx.Tech.Languages) + for _, lang := range langs { + s := idx.Tech.Languages[lang] + fmt.Fprintf(sb, "| %s | %d | %s |\n", lang, s.Files, formatThousands(s.Lines)) + } + sb.WriteString("\n") + } + + if hasFrameworks { + sb.WriteString("Frameworks: ") + sb.WriteString(strings.Join(idx.Tech.Frameworks, ", ")) + sb.WriteString("\n\n") + } +} + +func writeEntrypoints(sb *strings.Builder, idx *schema.Index) { + if len(idx.Structure.Entrypoints) == 0 { + return + } + sb.WriteString("## Entrypoints\n\n") + entries := append([]string(nil), idx.Structure.Entrypoints...) + sort.Strings(entries) + for _, e := range entries { + fmt.Fprintf(sb, "- `%s`\n", e) + } + sb.WriteString("\n") +} + +func writeKeyDirectories(sb *strings.Builder, idx *schema.Index) { + if len(idx.Structure.KeyDirectories) == 0 { + return + } + sb.WriteString("## Key directories\n\n") + keys := sortedKeys(idx.Structure.KeyDirectories) + for _, k := range keys { + fmt.Fprintf(sb, "- `%s` — %s\n", k, idx.Structure.KeyDirectories[k]) + } + sb.WriteString("\n") +} + +func writeModulesTable(sb *strings.Builder, idx *schema.Index) { + if len(idx.Modules) == 0 { + return + } + sb.WriteString("## Modules\n\n") + sb.WriteString("| Module | Purpose | Files | Lines | Depends on |\n") + sb.WriteString("|--------|---------|-------|-------|------------|\n") + + names := sortedKeys(idx.Modules) + for _, n := range names { + m := idx.Modules[n] + deps := "—" + if len(m.DependsOn) > 0 { + d := append([]string(nil), m.DependsOn...) + sort.Strings(d) + deps = "`" + strings.Join(d, "`, `") + "`" + } + fmt.Fprintf(sb, "| `%s` | %s | %d | %s | %s |\n", + n, + escapePipe(m.Purpose), + m.Files, + formatThousands(m.Lines), + deps, + ) + } + sb.WriteString("\n") +} + +// writeModuleDetails emits a collapsible
block per module that has +// exports or type definitions. Keeps the top-level overview short while +// still letting readers drill in. +func writeModuleDetails(sb *strings.Builder, idx *schema.Index) { + names := sortedKeys(idx.Modules) + hasAny := false + for _, n := range names { + m := idx.Modules[n] + if len(m.Exports) == 0 && len(m.TypeDefs) == 0 { + continue + } + hasAny = true + break + } + if !hasAny { + return + } + + sb.WriteString("## Module details\n\n") + + for _, n := range names { + m := idx.Modules[n] + if len(m.Exports) == 0 && len(m.TypeDefs) == 0 { + continue + } + fmt.Fprintf(sb, "
%s\n\n", n) + if m.Purpose != "" { + fmt.Fprintf(sb, "%s\n\n", m.Purpose) + } + if len(m.Exports) > 0 { + sb.WriteString("**Exports**\n\n") + exports := append([]string(nil), m.Exports...) + sort.Strings(exports) + for _, e := range exports { + fmt.Fprintf(sb, "- `%s`\n", e) + } + sb.WriteString("\n") + } + if len(m.TypeDefs) > 0 { + sb.WriteString("**Types**\n\n") + tnames := sortedKeys(m.TypeDefs) + for _, t := range tnames { + fmt.Fprintf(sb, "- `%s` — %s\n", t, m.TypeDefs[t]) + } + sb.WriteString("\n") + } + sb.WriteString("
\n\n") + } +} + +func writeDependencies(sb *strings.Builder, idx *schema.Index) { + if len(idx.Dependencies.Edges) == 0 && + len(idx.Dependencies.MostDepended) == 0 && + len(idx.Dependencies.Isolated) == 0 { + return + } + sb.WriteString("## Dependencies\n\n") + + if len(idx.Dependencies.MostDepended) > 0 { + sb.WriteString("Most depended on: ") + md := append([]string(nil), idx.Dependencies.MostDepended...) + sort.Strings(md) + sb.WriteString("`" + strings.Join(md, "`, `") + "`") + sb.WriteString("\n\n") + } + + if len(idx.Dependencies.Isolated) > 0 { + sb.WriteString("Isolated: ") + iso := append([]string(nil), idx.Dependencies.Isolated...) + sort.Strings(iso) + sb.WriteString("`" + strings.Join(iso, "`, `") + "`") + sb.WriteString("\n\n") + } + + if len(idx.Dependencies.Edges) > 0 { + sb.WriteString("Edges:\n\n") + edges := append([][2]string(nil), idx.Dependencies.Edges...) + sort.Slice(edges, func(i, j int) bool { + if edges[i][0] != edges[j][0] { + return edges[i][0] < edges[j][0] + } + return edges[i][1] < edges[j][1] + }) + for _, e := range edges { + fmt.Fprintf(sb, "- `%s` → `%s`\n", e[0], e[1]) + } + sb.WriteString("\n") + } +} + +func writeHotFiles(sb *strings.Builder, idx *schema.Index) { + if len(idx.Git.HotFiles) == 0 { + return + } + sb.WriteString("## Hot files (last 90 days)\n\n") + sb.WriteString("| File | Commits |\n") + sb.WriteString("|------|---------|\n") + for _, h := range idx.Git.HotFiles { + fmt.Fprintf(sb, "| `%s` | %d |\n", h.Path, h.Commits90d) + } + sb.WriteString("\n") +} + +func writeHints(sb *strings.Builder, idx *schema.Index) { + h := idx.Hints + if h.AddFeature == "" && h.TestCmd == "" && len(h.EnvVars) == 0 && len(h.DoNotTouch) == 0 { + return + } + sb.WriteString("## Hints\n\n") + if h.AddFeature != "" { + fmt.Fprintf(sb, "- Add a feature: %s\n", h.AddFeature) + } + if h.TestCmd != "" { + fmt.Fprintf(sb, "- Run tests: `%s`\n", h.TestCmd) + } + if len(h.EnvVars) > 0 { + env := append([]string(nil), h.EnvVars...) + sort.Strings(env) + fmt.Fprintf(sb, "- Env vars: `%s`\n", strings.Join(env, "`, `")) + } + if len(h.DoNotTouch) > 0 { + dnt := append([]string(nil), h.DoNotTouch...) + sort.Strings(dnt) + fmt.Fprintf(sb, "- Do not touch: `%s`\n", strings.Join(dnt, "`, `")) + } + sb.WriteString("\n") +} + +func writeFooter(sb *strings.Builder, idx *schema.Index) { + sb.WriteString("---\n") + sb.WriteString("Generated by [stacklit](https://github.com/glincker/stacklit).") + if idx.GeneratedAt != "" { + fmt.Fprintf(sb, " Source: `stacklit.json` (%s).", idx.GeneratedAt) + } + sb.WriteString("\n") +} + +// sortedKeys returns the keys of m sorted lexicographically. Used to keep the +// rendered markdown deterministic regardless of Go's map iteration order. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func formatThousands(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var out []byte + for i, c := range []byte(s) { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, c) + } + return string(out) +} + +// escapePipe replaces "|" with "\|" so user-supplied strings can be embedded +// in markdown table cells without breaking the row. +func escapePipe(s string) string { + return strings.ReplaceAll(s, "|", `\|`) +} diff --git a/internal/renderer/markdown_test.go b/internal/renderer/markdown_test.go new file mode 100644 index 0000000..1012037 --- /dev/null +++ b/internal/renderer/markdown_test.go @@ -0,0 +1,214 @@ +package renderer + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/glincker/stacklit/internal/schema" +) + +func makeRichTestIndex() *schema.Index { + return &schema.Index{ + GeneratedAt: "2026-05-05T00:00:00Z", + Project: schema.Project{ + Name: "myapp", + Type: "monorepo", + }, + Tech: schema.Tech{ + PrimaryLanguage: "go", + Languages: map[string]schema.LangStats{ + "go": {Files: 10, Lines: 5000}, + "typescript": {Files: 4, Lines: 1234}, + }, + Frameworks: []string{"Gin", "React"}, + }, + Structure: schema.Structure{ + Entrypoints: []string{"cmd/api/main.go", "cmd/worker/main.go"}, + TotalFiles: 14, + TotalLines: 6234, + KeyDirectories: map[string]string{ + "cmd/": "binaries", + "internal/": "private packages", + }, + }, + Modules: map[string]schema.ModuleInfo{ + "src/api": { + Purpose: "API handlers (with | pipe in purpose)", + Files: 5, + Lines: 1200, + Exports: []string{"NewServer()", "RegisterRoutes()"}, + DependsOn: []string{"src/auth", "src/db"}, + TypeDefs: map[string]string{ + "Server": "router *gin.Engine, port int", + }, + }, + "src/auth": { + Purpose: "Authentication", + Files: 3, + Lines: 500, + Exports: []string{"Authenticate(token string) (User, error)"}, + DependsOn: []string{"src/db"}, + }, + "src/db": { + Purpose: "Database access layer", + Files: 4, + Lines: 800, + }, + }, + Dependencies: schema.Dependencies{ + Edges: [][2]string{ + {"src/api", "src/auth"}, + {"src/api", "src/db"}, + {"src/auth", "src/db"}, + }, + MostDepended: []string{"src/db"}, + Isolated: []string{"scripts"}, + }, + Git: schema.GitInfo{ + HotFiles: []schema.HotFile{ + {Path: "src/api/server.go", Commits90d: 12}, + {Path: "src/auth/jwt.go", Commits90d: 7}, + }, + }, + Hints: schema.Hints{ + AddFeature: "Create handler in src/api/, add route in src/api/routes.go", + TestCmd: "go test ./...", + EnvVars: []string{"DATABASE_URL", "JWT_SECRET"}, + }, + } +} + +func TestRenderMarkdownContainsExpectedSections(t *testing.T) { + idx := makeRichTestIndex() + out := RenderMarkdown(idx) + + wantSubstrings := []string{ + "# myapp", + "go · monorepo · 14 files · 6,234 lines", + "## Overview", + "| Language | Files | Lines |", + "| go | 10 | 5,000 |", + "Frameworks: Gin, React", + "## Entrypoints", + "- `cmd/api/main.go`", + "- `cmd/worker/main.go`", + "## Key directories", + "- `cmd/` — binaries", + "## Modules", + "| `src/api` |", + "## Module details", + "
src/api", + "`NewServer()`", + "## Dependencies", + "Most depended on: `src/db`", + "Isolated: `scripts`", + "- `src/api` → `src/auth`", + "## Hot files (last 90 days)", + "`src/api/server.go`", + "## Hints", + "- Run tests: `go test ./...`", + "- Env vars: `DATABASE_URL`, `JWT_SECRET`", + "Generated by [stacklit]", + } + + for _, w := range wantSubstrings { + if !strings.Contains(out, w) { + t.Errorf("expected output to contain %q\n--- output ---\n%s", w, out) + } + } +} + +func TestRenderMarkdownEscapesPipeInTableCell(t *testing.T) { + idx := makeRichTestIndex() + out := RenderMarkdown(idx) + + // The purpose string for src/api contains a pipe; it must be escaped so + // it doesn't break the table row. + if !strings.Contains(out, `with \| pipe`) { + t.Errorf("expected pipe in module purpose to be escaped to '\\|', got:\n%s", out) + } +} + +func TestRenderMarkdownIsDeterministic(t *testing.T) { + idx := makeRichTestIndex() + first := RenderMarkdown(idx) + for i := 0; i < 5; i++ { + again := RenderMarkdown(idx) + if again != first { + t.Fatalf("RenderMarkdown is not deterministic on run %d", i+2) + } + } +} + +func TestRenderMarkdownEmptyIndex(t *testing.T) { + idx := &schema.Index{ + Project: schema.Project{Name: ""}, + Tech: schema.Tech{Languages: map[string]schema.LangStats{}}, + Modules: map[string]schema.ModuleInfo{}, + Dependencies: schema.Dependencies{}, + } + out := RenderMarkdown(idx) + + if !strings.Contains(out, "# Project") { + t.Errorf("expected fallback title '# Project' for empty index, got:\n%s", out) + } + // Sections that depend on data should be absent. + for _, section := range []string{"## Modules", "## Dependencies", "## Hot files", "## Hints"} { + if strings.Contains(out, section) { + t.Errorf("did not expect %q section in empty index, got:\n%s", section, out) + } + } +} + +func TestWriteMarkdownIsIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "stacklit.md") + idx := makeRichTestIndex() + + if err := WriteMarkdown(idx, path); err != nil { + t.Fatalf("first WriteMarkdown failed: %v", err) + } + first, err := os.Stat(path) + if err != nil { + t.Fatalf("stat after first write: %v", err) + } + + // Sleep-free: check that a second identical write doesn't change the file. + // We compare contents directly since modtime can be unreliable on fast filesystems. + firstBytes, _ := os.ReadFile(path) + + if err := WriteMarkdown(idx, path); err != nil { + t.Fatalf("second WriteMarkdown failed: %v", err) + } + secondBytes, _ := os.ReadFile(path) + + if string(firstBytes) != string(secondBytes) { + t.Errorf("WriteMarkdown produced different content on second run") + } + + // Sanity: the file is non-trivial in size. + if first.Size() < 100 { + t.Errorf("expected markdown output to be > 100 bytes, got %d", first.Size()) + } +} + +func TestFormatThousands(t *testing.T) { + cases := []struct { + in int + want string + }{ + {0, "0"}, + {42, "42"}, + {999, "999"}, + {1000, "1,000"}, + {6234, "6,234"}, + {1234567, "1,234,567"}, + } + for _, tc := range cases { + if got := formatThousands(tc.in); got != tc.want { + t.Errorf("formatThousands(%d) = %q, want %q", tc.in, got, tc.want) + } + } +} From 77a3899ebb1a32612d3fc7b714570315a8d5e030 Mon Sep 17 00:00:00 2001 From: GDS K S Date: Tue, 5 May 2026 19:33:56 -0500 Subject: [PATCH 2/3] ci: matrix on linux, macos, windows + race detector + export smoke Three improvements: - Run the test suite on ubuntu-latest, macos-latest, and windows-latest. The Windows postinstall bug fixed in #35 went unnoticed because CI only built on Linux. Adding macOS and Windows runners catches platform-specific regressions before users do. - Run go test with -race so data races in the parser, walker, and engine fail the build instead of slipping through. - Add a go vet step (cheap signal, no extra deps). - Replace the 'init' smoke test with a 'generate --quiet' + 'export' smoke test. Generate exercises the same code path without trying to open a browser, and the export step verifies the new markdown command produces a non-empty file with a top-level heading on every OS. Uses go run instead of a pre-built binary so the smoke step works on Windows without needing to know about the .exe extension. --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d147be..7f0ca4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,35 @@ on: jobs: test: - runs-on: ubuntu-latest + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.25' - - run: go test ./... -v -count=1 - - run: go build -o stacklit ./cmd/stacklit - - run: ./stacklit init + cache: true + + - name: go vet + run: go vet ./... + + - name: go test + run: go test ./... -race -count=1 + + - name: build + run: go build ./cmd/stacklit + + - name: smoke test (generate) + run: go run ./cmd/stacklit generate --quiet + shell: bash + + - name: smoke test (export markdown) + run: | + go run ./cmd/stacklit export -o stacklit-ci.md + test -s stacklit-ci.md + head -1 stacklit-ci.md | grep -q '^# ' + shell: bash From cd250ea7b2dae7b793e1ea0181d9f8aed8eef268 Mon Sep 17 00:00:00 2001 From: GDS K S Date: Tue, 5 May 2026 19:59:35 -0500 Subject: [PATCH 3/3] fix: address review feedback on export PR Six issues from Copilot review: - Escape pipe characters in every table cell that interpolates a user-supplied string, not just module purpose. Module names, dependency names, language names, hot-file paths, and dependency edge endpoints all go through escapePipe now. Backticks don't protect pipes in GitHub markdown tables. - Drop the hard-coded 'Source: stacklit.json' from the footer. The --source flag can point at any JSON path, so naming a fixed file was misleading. The footer now just prints 'Generated at '. - Update the read-error hint to suggest 'stacklit generate' first (the non-browser entry point that CI uses) and mention 'stacklit init' as an alternative. - Update the unknown-format error to list 'md, markdown' since both spellings are accepted by the switch. Adds TestRenderMarkdownEscapesPipeEverywhere to cover module name, dep name, language name, and hot-file path escaping with adversarial inputs. --- internal/cli/export.go | 4 +- internal/renderer/markdown.go | 31 +++++++++++---- internal/renderer/markdown_test.go | 63 ++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/internal/cli/export.go b/internal/cli/export.go index 3c6a54d..4661be7 100644 --- a/internal/cli/export.go +++ b/internal/cli/export.go @@ -29,7 +29,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { data, err := os.ReadFile(source) if err != nil { - return fmt.Errorf("could not read %s: %w (run 'stacklit init' first)", source, err) + return fmt.Errorf("could not read %s: %w (run 'stacklit generate' first, or 'stacklit init')", source, err) } var idx schema.Index if err := json.Unmarshal(data, &idx); err != nil { @@ -48,7 +48,7 @@ Examples: fmt.Fprintf(cmd.OutOrStderr(), "Wrote %s\n", output) return nil default: - return fmt.Errorf("unknown format %q (supported: md)", format) + return fmt.Errorf("unknown format %q (supported: md, markdown)", format) } }, } diff --git a/internal/renderer/markdown.go b/internal/renderer/markdown.go index 3d98927..173863d 100644 --- a/internal/renderer/markdown.go +++ b/internal/renderer/markdown.go @@ -87,7 +87,7 @@ func writeOverview(sb *strings.Builder, idx *schema.Index) { langs := sortedKeys(idx.Tech.Languages) for _, lang := range langs { s := idx.Tech.Languages[lang] - fmt.Fprintf(sb, "| %s | %d | %s |\n", lang, s.Files, formatThousands(s.Lines)) + fmt.Fprintf(sb, "| %s | %d | %s |\n", escapePipe(lang), s.Files, formatThousands(s.Lines)) } sb.WriteString("\n") } @@ -137,12 +137,17 @@ func writeModulesTable(sb *strings.Builder, idx *schema.Index) { m := idx.Modules[n] deps := "—" if len(m.DependsOn) > 0 { - d := append([]string(nil), m.DependsOn...) + d := make([]string, 0, len(m.DependsOn)) + d = append(d, m.DependsOn...) sort.Strings(d) + // Escape pipes per-element so `foo|bar` deps don't break the row. + for i, s := range d { + d[i] = escapePipe(s) + } deps = "`" + strings.Join(d, "`, `") + "`" } fmt.Fprintf(sb, "| `%s` | %s | %d | %s | %s |\n", - n, + escapePipe(n), escapePipe(m.Purpose), m.Files, formatThousands(m.Lines), @@ -212,16 +217,24 @@ func writeDependencies(sb *strings.Builder, idx *schema.Index) { if len(idx.Dependencies.MostDepended) > 0 { sb.WriteString("Most depended on: ") - md := append([]string(nil), idx.Dependencies.MostDepended...) + md := make([]string, 0, len(idx.Dependencies.MostDepended)) + md = append(md, idx.Dependencies.MostDepended...) sort.Strings(md) + for i, s := range md { + md[i] = escapePipe(s) + } sb.WriteString("`" + strings.Join(md, "`, `") + "`") sb.WriteString("\n\n") } if len(idx.Dependencies.Isolated) > 0 { sb.WriteString("Isolated: ") - iso := append([]string(nil), idx.Dependencies.Isolated...) + iso := make([]string, 0, len(idx.Dependencies.Isolated)) + iso = append(iso, idx.Dependencies.Isolated...) sort.Strings(iso) + for i, s := range iso { + iso[i] = escapePipe(s) + } sb.WriteString("`" + strings.Join(iso, "`, `") + "`") sb.WriteString("\n\n") } @@ -236,7 +249,7 @@ func writeDependencies(sb *strings.Builder, idx *schema.Index) { return edges[i][1] < edges[j][1] }) for _, e := range edges { - fmt.Fprintf(sb, "- `%s` → `%s`\n", e[0], e[1]) + fmt.Fprintf(sb, "- `%s` → `%s`\n", escapePipe(e[0]), escapePipe(e[1])) } sb.WriteString("\n") } @@ -250,7 +263,7 @@ func writeHotFiles(sb *strings.Builder, idx *schema.Index) { sb.WriteString("| File | Commits |\n") sb.WriteString("|------|---------|\n") for _, h := range idx.Git.HotFiles { - fmt.Fprintf(sb, "| `%s` | %d |\n", h.Path, h.Commits90d) + fmt.Fprintf(sb, "| `%s` | %d |\n", escapePipe(h.Path), h.Commits90d) } sb.WriteString("\n") } @@ -283,8 +296,10 @@ func writeHints(sb *strings.Builder, idx *schema.Index) { func writeFooter(sb *strings.Builder, idx *schema.Index) { sb.WriteString("---\n") sb.WriteString("Generated by [stacklit](https://github.com/glincker/stacklit).") + // Don't hard-code "stacklit.json" here: --source can point at any file. + // The timestamp is what's load-bearing. if idx.GeneratedAt != "" { - fmt.Fprintf(sb, " Source: `stacklit.json` (%s).", idx.GeneratedAt) + fmt.Fprintf(sb, " Generated at %s.", idx.GeneratedAt) } sb.WriteString("\n") } diff --git a/internal/renderer/markdown_test.go b/internal/renderer/markdown_test.go index 1012037..44f9529 100644 --- a/internal/renderer/markdown_test.go +++ b/internal/renderer/markdown_test.go @@ -111,6 +111,7 @@ func TestRenderMarkdownContainsExpectedSections(t *testing.T) { "- Run tests: `go test ./...`", "- Env vars: `DATABASE_URL`, `JWT_SECRET`", "Generated by [stacklit]", + "Generated at 2026-05-05T00:00:00Z", } for _, w := range wantSubstrings { @@ -118,6 +119,12 @@ func TestRenderMarkdownContainsExpectedSections(t *testing.T) { t.Errorf("expected output to contain %q\n--- output ---\n%s", w, out) } } + + // Footer should no longer hard-code the source filename, since --source + // can point at any path. + if strings.Contains(out, "Source: `stacklit.json`") { + t.Errorf("footer should not hard-code 'stacklit.json' as the source") + } } func TestRenderMarkdownEscapesPipeInTableCell(t *testing.T) { @@ -131,6 +138,62 @@ func TestRenderMarkdownEscapesPipeInTableCell(t *testing.T) { } } +func TestRenderMarkdownEscapesPipeEverywhere(t *testing.T) { + // Build an adversarial index with pipes in module name, dep name, language + // name, and hot-file path. Every cell that interpolates a user-supplied + // string must escape pipes, otherwise GitHub markdown breaks the row. + idx := &schema.Index{ + Project: schema.Project{Name: "edge"}, + Tech: schema.Tech{ + PrimaryLanguage: "go", + Languages: map[string]schema.LangStats{ + "weird|lang": {Files: 1, Lines: 1}, + }, + }, + Modules: map[string]schema.ModuleInfo{ + "weird|module": { + Purpose: "ok", + Files: 1, + Lines: 1, + DependsOn: []string{"other|dep"}, + }, + }, + Dependencies: schema.Dependencies{ + Edges: [][2]string{{"weird|module", "other|dep"}}, + }, + Git: schema.GitInfo{ + HotFiles: []schema.HotFile{ + {Path: "src/odd|name.go", Commits90d: 3}, + }, + }, + } + out := RenderMarkdown(idx) + + for _, want := range []string{ + `weird\|lang`, + `weird\|module`, + `other\|dep`, + `src/odd\|name.go`, + } { + if !strings.Contains(out, want) { + t.Errorf("expected escaped %q in output\n--- output ---\n%s", want, out) + } + } + // Raw, unescaped pipe inside a table cell is the failure mode we want to + // avoid. We don't grep for `|` literally (the table dividers use it), + // but we can confirm none of the original strings appear unescaped. + for _, bad := range []string{ + "| weird|lang ", + "| `weird|module` ", + "`other|dep`", + "| `src/odd|name.go`", + } { + if strings.Contains(out, bad) { + t.Errorf("found unescaped pipe in cell: %q", bad) + } + } +} + func TestRenderMarkdownIsDeterministic(t *testing.T) { idx := makeRichTestIndex() first := RenderMarkdown(idx)