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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ Follows the first real-agent A/B (bench/agent-ab, pull request #77), which found
- `path` adds advice after the weakest hop score: sure (0.9 or more) needs no confirming grep, otherwise grep the weak hop.
- A pre-registered second A/B round in `bench/agent-ab/tasks-round2/`, with a `stacklit-e` arm in the harness. It has not been run.

### Fixed
- `.cc` and `.cxx` sources were never indexed because the walker did not list those extensions, and `.hh` and `.hxx` were missing too. A C++ project whose sources use `.cc` was reported as C.
- A `.h` header was always labelled C. It is now C++ when its own text uses C++ constructs or a C++ translation unit sits in the same directory, so a header dominated project no longer reports the wrong primary language. Cached parse results are rebuilt once for this.

### Changed
- The MCP handshake instructions and `stacklit guide` use the routed wording instead of "source of truth, do not crawl" (about 100 tokens, was about 75). `STACKLIT_STEERING=d` keeps the earlier text for comparison.

Expand Down
6 changes: 4 additions & 2 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import (
"github.com/glincker/stacklit/internal/parser"
)

// formatVersion is bumped when the on-disk layout changes.
const formatVersion = 1
// formatVersion is bumped when the on-disk layout changes, or when a stored
// parse result would be wrong under the current classification. Version 2
// changed how .h headers and .cc files are labelled.
const formatVersion = 2

type fileFormat struct {
Format int `json:"format"`
Expand Down
51 changes: 51 additions & 0 deletions internal/graph/cpp_lang_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package graph

import (
"os"
"path/filepath"
"testing"
)

func TestExtractPathClassifiesHeaders(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}

// Plain C header with no C++ neighbour.
write("plain.h", "int process(const char *msg);\n")
if fs := ExtractPath(dir, "plain.h", 0); fs == nil || fs.Lang != "c" {
t.Fatalf("plain.h = %+v, want Lang c", fs)
}

// Same header once a C++ translation unit sits beside it.
write("plain.cc", "int process(const char *msg) { return 0; }\n")
if fs := ExtractPath(dir, "plain.h", 0); fs == nil || fs.Lang != "cpp" {
t.Fatalf("plain.h beside .cc = %+v, want Lang cpp", fs)
}

// A header that is C++ on its own text.
write("acquirer.h", "class Acquirer {\npublic:\n void run();\n};\n")
if fs := ExtractPath(dir, "acquirer.h", 0); fs == nil || fs.Lang != "cpp" {
t.Fatalf("acquirer.h = %+v, want Lang cpp", fs)
}

// A .cc file itself is C++.
if fs := ExtractPath(dir, "plain.cc", 0); fs == nil || fs.Lang != "cpp" {
t.Fatalf("plain.cc = %+v, want Lang cpp", fs)
}
}

func TestExtractFileWithoutDiskPathUsesContent(t *testing.T) {
fs := extractFileWith("hdr.h", []byte("namespace n { struct S; }\n"), ExtractOptions{}, nil)
if fs == nil || fs.Lang != "cpp" {
t.Fatalf("header with C++ text = %+v, want Lang cpp", fs)
}
fs = extractFileWith("hdr.h", []byte("int f(void);\n"), ExtractOptions{}, nil)
if fs == nil || fs.Lang != "c" {
t.Fatalf("header with C text = %+v, want Lang c", fs)
}
}
6 changes: 4 additions & 2 deletions internal/graph/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ func isTestFile(path string) bool {
// ExtractFile parses one file's content into symbols and references using the
// default size limits.
func ExtractFile(path string, src []byte) *FileSyms {
return extractFileWith(path, src, ExtractOptions{}, nil)
// No on-disk path, so a header is classified from its own text only and
// the result does not depend on the process working directory.
return extractFileAt(path, "", src, ExtractOptions{}, nil)
}

// Extract reads and parses the given root-relative files in parallel.
Expand Down Expand Up @@ -169,7 +171,7 @@ func extractPath(root, rel string, o ExtractOptions, gate *parseGate) *FileSyms
if err != nil {
return nil
}
return extractFileWith(filepath.ToSlash(rel), src, o, gate)
return extractFileAt(filepath.ToSlash(rel), filepath.Join(root, filepath.FromSlash(rel)), src, o, gate)
}

// stripNoise blanks comments and string contents with spaces, keeping newlines
Expand Down
21 changes: 14 additions & 7 deletions internal/graph/ts_dispatch.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package graph

import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"

"github.com/glincker/stacklit/internal/parser"
)

// Parse cost model, matching the main scanner: the GLR parser holds roughly
Expand Down Expand Up @@ -133,21 +134,27 @@ func grammarFor(lang, path string, src []byte) string {
}
case "csharp":
return "c_sharp"
case "c":
if ext == ".h" && (bytes.Contains(src, []byte("class ")) || bytes.Contains(src, []byte("namespace ")) || bytes.Contains(src, []byte("template<")) || bytes.Contains(src, []byte("template <"))) {
return "cpp"
}
}
return lang
}

// extractFileWith dispatches on language. Tree-sitter handles everything except
// Go; the line scanners stay as the fallback for oversize or unparsable files.
// extractFileWith dispatches on language when only the source is available.
// The neighbour check that can mark a header as C++ is skipped.
func extractFileWith(path string, src []byte, o ExtractOptions, gate *parseGate) *FileSyms {
return extractFileAt(path, "", src, o, gate)
}

// extractFileAt is extractFileWith with the header's on-disk path, so a .h
// header can be classified from the translation units next to it.
func extractFileAt(path, diskPath string, src []byte, o ExtractOptions, gate *parseGate) *FileSyms {
lang := LangOf(path)
if lang == "" {
return nil
}
// A .h header is C++ when its own text or its neighbours say so.
if lang == "c" && strings.EqualFold(filepath.Ext(path), ".h") {
lang = parser.HeaderLanguage(diskPath, src)
}
if lang == "go" {
return extractGo(path, src)
}
Expand Down
13 changes: 9 additions & 4 deletions internal/parser/generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ var extensionLanguageMap = map[string]string{
".cc": "cpp",
".cxx": "cpp",
".hpp": "cpp",
".hh": "cpp",
".hxx": "cpp",
".cs": "csharp",
".php": "php",
".sh": "shell",
Expand Down Expand Up @@ -111,14 +113,14 @@ func (g *GenericParser) CanParse(_ string) bool {
func (g *GenericParser) Parse(path string, content []byte) (*FileInfo, error) {
return &FileInfo{
Path: path,
Language: detectLanguage(path),
Language: detectLanguage(path, content),
LineCount: countLines(content),
}, nil
}

// detectLanguage maps the file extension to a language name.
// Returns "unknown" for unrecognized extensions.
func detectLanguage(filename string) string {
// detectLanguage maps the file extension to a language name. A .h header is
// classified from its content. Returns "unknown" for unrecognized extensions.
func detectLanguage(filename string, content []byte) string {
lower := strings.ToLower(filename)
base := lower
if i := strings.LastIndexAny(base, "/\\"); i >= 0 {
Expand All @@ -139,6 +141,9 @@ func detectLanguage(filename string) string {
}

ext := lower[idx:]
if ext == ".h" && HeaderLooksCpp(content) {
return "cpp"
}
if lang, ok := extensionLanguageMap[ext]; ok {
return lang
}
Expand Down
95 changes: 95 additions & 0 deletions internal/parser/header_lang.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package parser

import (
"bytes"
"os"
"path/filepath"
"strings"
)

// cppMarkers are constructs that do not compile as C. They are matched as
// plain byte sequences on the whole header, because a header can hold several
// preprocessor branches and any C++ branch makes the file C++.
var cppMarkers = [][]byte{
[]byte("class "),
[]byte("namespace "),
[]byte("template<"),
[]byte("template <"),
[]byte("public:"),
[]byte("private:"),
[]byte("protected:"),
[]byte("std::"),
[]byte("virtual "),
[]byte("constexpr "),
[]byte("nullptr"),
[]byte("using namespace "),
[]byte("extern \"C++\""),
}

// cppUnitExts are translation unit extensions that mark a directory as C++.
var cppUnitExts = []string{".cc", ".cpp", ".cxx"}

// HeaderLanguage decides whether a .h header belongs to C or C++. It returns
// "cpp" when the source shows a C++ construct, or when a C++ translation unit
// shares the header's directory, and "c" otherwise. fullPath may be empty or
// unresolvable; the neighbour check is then skipped. src may be nil.
func HeaderLanguage(fullPath string, src []byte) string {
if HeaderLooksCpp(src) {
return "cpp"
}
if SiblingIsCpp(fullPath) {
return "cpp"
}
return "c"
}

// HeaderLooksCpp reports whether the header source uses C++ only constructs.
func HeaderLooksCpp(src []byte) bool {
for _, m := range cppMarkers {
if bytes.Contains(src, m) {
return true
}
}
return false
}

// SiblingIsCpp reports whether a C++ translation unit sits next to fullPath.
// A unit with the same base name wins over an unrelated one in the same
// directory. An empty or unreadable path reports false.
func SiblingIsCpp(fullPath string) bool {
if fullPath == "" {
return false
}
dir := filepath.Dir(fullPath)
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
base := strings.ToLower(strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath)))
sameName, anyName := false, false
for _, e := range entries {
if e.IsDir() {
continue
}
name := strings.ToLower(e.Name())
ext := filepath.Ext(name)
if !containsString(cppUnitExts, ext) {
continue
}
anyName = true
if strings.TrimSuffix(name, ext) == base {
sameName = true
break
}
}
return sameName || anyName
}

func containsString(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
108 changes: 108 additions & 0 deletions internal/parser/header_lang_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package parser

import (
"os"
"path/filepath"
"testing"
)

func TestHeaderLooksCpp(t *testing.T) {
cppHeaders := [][]byte{
[]byte("class Acquirer {\npublic:\n void run();\n};\n"),
[]byte("namespace iso { struct Msg; }\n"),
[]byte("template <typename T>\nT clamp(T v);\n"),
[]byte("std::string name;\n"),
[]byte("virtual void process() = 0;\n"),
}
for _, src := range cppHeaders {
if !HeaderLooksCpp(src) {
t.Errorf("HeaderLooksCpp(%q) = false, want true", src)
}
}

cHeaders := [][]byte{
[]byte("#ifndef ACQUIRER_H\n#define ACQUIRER_H\nint process(const char *msg);\n#endif\n"),
[]byte("typedef struct { int id; } acquirer;\n"),
[]byte(""),
}
for _, src := range cHeaders {
if HeaderLooksCpp(src) {
t.Errorf("HeaderLooksCpp(%q) = true, want false", src)
}
}
}

func TestHeaderLanguageContentOnly(t *testing.T) {
if got := HeaderLanguage("", []byte("class A {};\n")); got != "cpp" {
t.Errorf("C++ header = %q, want cpp", got)
}
if got := HeaderLanguage("", []byte("int f(void);\n")); got != "c" {
t.Errorf("C header = %q, want c", got)
}
}

func TestHeaderLanguageNeighbour(t *testing.T) {
dir := t.TempDir()
header := filepath.Join(dir, "acquirer.h")
if err := os.WriteFile(header, []byte("int process(const char *msg);\n"), 0o644); err != nil {
t.Fatal(err)
}

// A plain C header with no C++ neighbour stays C.
if got := HeaderLanguage(header, []byte("int process(const char *msg);\n")); got != "c" {
t.Errorf("header without neighbours = %q, want c", got)
}

// Any translation unit in the directory marks the header as C++.
if err := os.WriteFile(filepath.Join(dir, "acquirer.cc"), []byte("int process(const char *msg);\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := HeaderLanguage(header, []byte("int process(const char *msg);\n")); got != "cpp" {
t.Errorf("header beside .cc = %q, want cpp", got)
}
}

func TestSiblingIsCppPrefersSameBaseName(t *testing.T) {
dir := t.TempDir()
header := filepath.Join(dir, "acquirer.h")
if err := os.WriteFile(header, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "unrelated.c"), nil, 0o644); err != nil {
t.Fatal(err)
}
if SiblingIsCpp(header) {
t.Error("SiblingIsCpp = true with only a .c neighbour, want false")
}
if err := os.WriteFile(filepath.Join(dir, "acquirer.cxx"), nil, 0o644); err != nil {
t.Fatal(err)
}
if !SiblingIsCpp(header) {
t.Error("SiblingIsCpp = false with acquirer.cxx present, want true")
}
if SiblingIsCpp("") {
t.Error("SiblingIsCpp(\"\") = true, want false")
}
}

func TestCLanguageLabelsCCAndHeaders(t *testing.T) {
cases := map[string]string{
"a.cc": "cpp",
"a.cxx": "cpp",
"a.hh": "cpp",
"a.hxx": "cpp",
"a.cpp": "cpp",
"a.c": "c",
}
for path, want := range cases {
if got := cLanguage(path, nil); got != want {
t.Errorf("cLanguage(%q) = %q, want %q", path, got, want)
}
}
if got := cLanguage("a.h", []byte("class A {};\n")); got != "cpp" {
t.Errorf("cLanguage(cpp header) = %q, want cpp", got)
}
if got := cLanguage("a.h", []byte("int f(void);\n")); got != "c" {
t.Errorf("cLanguage(c header) = %q, want c", got)
}
}
2 changes: 1 addition & 1 deletion internal/parser/langs_wave2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func writeTemp(t *testing.T, name, content string) string {

func TestGenericDetectsBuildFilesByName(t *testing.T) {
for path, want := range map[string]string{"Makefile": "make", "sub/Dockerfile": "dockerfile", "CMakeLists.txt": "cmake", "Dockerfile.prod": "dockerfile", "x.fs": "fsharp"} {
if got := detectLanguage(path); got != want {
if got := detectLanguage(path, nil); got != want {
t.Errorf("detectLanguage(%q) = %q, want %q", path, got, want)
}
}
Expand Down
Loading