Skip to content

Commit 7eb4382

Browse files
aayushprsinghAayush Pratap Singh
andauthored
fix: make generated outputs deterministic and normalize paths
* fix: normalize paths and ignore generated outputs * fix: make generated outputs deterministic --------- Co-authored-by: Aayush Pratap Singh <aayush@bhooyam.com>
1 parent eaaaa20 commit 7eb4382

10 files changed

Lines changed: 126 additions & 18 deletions

File tree

internal/cli/diff.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77

8+
"github.com/glincker/stacklit/internal/config"
89
"github.com/glincker/stacklit/internal/git"
910
"github.com/glincker/stacklit/internal/schema"
1011
"github.com/glincker/stacklit/internal/walker"
@@ -32,8 +33,9 @@ func newDiffCmd() *cobra.Command {
3233
return fmt.Errorf("stacklit.json has no merkle_hash; run 'stacklit generate' to rebuild")
3334
}
3435

35-
// 2. Walk current source files
36-
files, err := walker.Walk(".", nil)
36+
// 2. Walk current source files, excluding Stacklit's own generated outputs.
37+
cfg := config.Load(".")
38+
files, err := walker.Walk(".", cfg.ScanIgnore())
3739
if err != nil {
3840
return fmt.Errorf("failed to walk source files: %w", err)
3941
}

internal/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,16 @@ func Load(root string) *Config {
7272

7373
return cfg
7474
}
75+
76+
// ScanIgnore returns ignore patterns plus Stacklit output files so generated artifacts
77+
// never feed back into the next scan.
78+
func (c *Config) ScanIgnore() []string {
79+
ignore := append([]string{}, c.Ignore...)
80+
for _, out := range []string{c.Output.JSON, c.Output.Mermaid, c.Output.HTML} {
81+
if out == "" {
82+
continue
83+
}
84+
ignore = append(ignore, filepath.ToSlash(out))
85+
}
86+
return ignore
87+
}

internal/config/config_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,22 @@ func TestLoadMalformed(t *testing.T) {
5454
t.Errorf("expected default max_depth=4 after malformed file, got %d", cfg.MaxDepth)
5555
}
5656
}
57+
58+
func TestScanIgnoreIncludesOutputs(t *testing.T) {
59+
cfg := DefaultConfig()
60+
cfg.Ignore = []string{"custom/"}
61+
cfg.Output.JSON = "out\\stacklit.json"
62+
cfg.Output.Mermaid = "docs\\DEPENDENCIES.md"
63+
cfg.Output.HTML = "stacklit.html"
64+
65+
got := cfg.ScanIgnore()
66+
want := []string{"custom/", "out/stacklit.json", "docs/DEPENDENCIES.md", "stacklit.html"}
67+
if len(got) != len(want) {
68+
t.Fatalf("expected %d ignore patterns, got %d: %v", len(want), len(got), got)
69+
}
70+
for i := range want {
71+
if got[i] != want[i] {
72+
t.Fatalf("expected ignore[%d]=%q, got %q (all=%v)", i, want[i], got[i], got)
73+
}
74+
}
75+
}

internal/engine/engine.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func Run(opts Options) (*Result, error) {
166166
}
167167

168168
// 3. Walk the filesystem, honouring extra ignore patterns from config.
169-
files, err := walker.Walk(root, cfg.Ignore)
169+
files, err := walker.Walk(root, cfg.ScanIgnore())
170170
if err != nil {
171171
return nil, fmt.Errorf("walking %s: %w", root, err)
172172
}
@@ -452,17 +452,20 @@ func assembleIndex(
452452
}
453453
}
454454

455-
// Cap type defs to maxExports per module.
455+
// Cap type defs to maxExports per module in a deterministic order.
456456
typeDefs := mod.TypeDefs
457457
if maxExports > 0 && len(typeDefs) > maxExports {
458+
keys := make([]string, 0, len(typeDefs))
459+
for k := range typeDefs {
460+
keys = append(keys, k)
461+
}
462+
sort.Strings(keys)
458463
trimmed := make(map[string]string, maxExports)
459-
i := 0
460-
for k, v := range typeDefs {
461-
trimmed[k] = v
462-
i++
464+
for i, k := range keys {
463465
if i >= maxExports {
464466
break
465467
}
468+
trimmed[k] = typeDefs[k]
466469
}
467470
typeDefs = trimmed
468471
}

internal/renderer/html.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package renderer
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"os"
67
"strings"
@@ -18,5 +19,9 @@ func WriteHTML(idx *schema.Index, path string) error {
1819
}
1920
html := strings.Replace(assets.TemplateHTML, "{{STACKLIT_DATA}}", string(dataJSON), 1)
2021
html = strings.Replace(html, "{{LANG_ICONS_JS}}", assets.LangIconsJS, 1)
21-
return os.WriteFile(path, []byte(html), 0644)
22+
data := []byte(html)
23+
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) {
24+
return nil
25+
}
26+
return os.WriteFile(path, data, 0644)
2227
}

internal/renderer/json.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package renderer
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"os"
67
"time"
@@ -17,11 +18,16 @@ func WriteJSON(idx *schema.Index, path string) error {
1718
idx.Schema = schemaURL
1819
idx.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
1920
idx.StacklitVersion = version
21+
preserveGeneratedAtIfUnchanged(idx, path)
2022

2123
data, err := json.MarshalIndent(idx, "", " ")
2224
if err != nil {
2325
return err
2426
}
2527

28+
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) {
29+
return nil
30+
}
31+
2632
return os.WriteFile(path, data, 0644)
2733
}

internal/renderer/mermaid.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package renderer
22

33
import (
4+
"bytes"
45
"fmt"
56
"os"
7+
"sort"
68
"strings"
79
"time"
810
"unicode"
@@ -82,8 +84,13 @@ func WriteMermaid(idx *schema.Index, path string) error {
8284
}
8385
}
8486

85-
// Write classDef entries for used languages.
87+
// Write classDef entries for used languages in deterministic order.
88+
langs := make([]string, 0, len(usedLangs))
8689
for lang := range usedLangs {
90+
langs = append(langs, lang)
91+
}
92+
sort.Strings(langs)
93+
for _, lang := range langs {
8794
colors := langColors[lang]
8895
sb.WriteString(fmt.Sprintf(" classDef %s fill:%s,color:%s,stroke:%s\n",
8996
lang, colors[0], colors[1], colors[0]))
@@ -92,8 +99,14 @@ func WriteMermaid(idx *schema.Index, path string) error {
9299
// Determine primary language for node class assignment.
93100
primaryLang := strings.ToLower(idx.Tech.PrimaryLanguage)
94101

95-
// Write node definitions.
96-
for name, mod := range idx.Modules {
102+
// Write node definitions in deterministic order.
103+
moduleNames := make([]string, 0, len(idx.Modules))
104+
for name := range idx.Modules {
105+
moduleNames = append(moduleNames, name)
106+
}
107+
sort.Strings(moduleNames)
108+
for _, name := range moduleNames {
109+
mod := idx.Modules[name]
97110
id := sanitizeMermaidID(name)
98111
label := truncate(mod.Purpose, 40)
99112
nodeClass := primaryLang
@@ -115,5 +128,9 @@ func WriteMermaid(idx *schema.Index, path string) error {
115128
}
116129

117130
sb.WriteString("```\n")
118-
return os.WriteFile(path, []byte(sb.String()), 0644)
131+
data := []byte(sb.String())
132+
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, data) {
133+
return nil
134+
}
135+
return os.WriteFile(path, data, 0644)
119136
}

internal/renderer/stable.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package renderer
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
7+
"github.com/glincker/stacklit/internal/schema"
8+
)
9+
10+
func normalizedIndexJSON(idx *schema.Index) ([]byte, error) {
11+
clone := *idx
12+
clone.GeneratedAt = ""
13+
return json.Marshal(clone)
14+
}
15+
16+
// preserveGeneratedAtIfUnchanged keeps the previous GeneratedAt value when the
17+
// semantic index content is unchanged. This avoids needless output churn across
18+
// repeated runs, especially on Windows where unchanged locked files should not
19+
// trigger rewrite attempts.
20+
func preserveGeneratedAtIfUnchanged(idx *schema.Index, path string) {
21+
existingBytes, err := os.ReadFile(path)
22+
if err != nil {
23+
return
24+
}
25+
var existing schema.Index
26+
if err := json.Unmarshal(existingBytes, &existing); err != nil {
27+
return
28+
}
29+
currentNorm, err := normalizedIndexJSON(idx)
30+
if err != nil {
31+
return
32+
}
33+
existingNorm, err := normalizedIndexJSON(&existing)
34+
if err != nil {
35+
return
36+
}
37+
if string(currentNorm) == string(existingNorm) {
38+
idx.GeneratedAt = existing.GeneratedAt
39+
}
40+
}

internal/walker/walker.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ func Walk(root string, extraIgnore []string) ([]string, error) {
9999
return relErr
100100
}
101101

102+
// Normalize path separators so generated output and ignore matching stay stable across OSes.
103+
rel = filepath.ToSlash(rel)
104+
102105
// Skip the root itself.
103106
if rel == "." {
104107
return nil

internal/walker/walker_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ func TestWalkSourceFiles(t *testing.T) {
3535
// Must include Go source files.
3636
wantIncluded := []string{
3737
"main.go",
38-
filepath.Join("internal", "handler.go"),
39-
filepath.Join("internal", "handler_test.go"),
38+
filepath.ToSlash(filepath.Join("internal", "handler.go")),
39+
filepath.ToSlash(filepath.Join("internal", "handler_test.go")),
4040
}
4141
for _, want := range wantIncluded {
4242
if !fileSet[want] {
@@ -46,8 +46,8 @@ func TestWalkSourceFiles(t *testing.T) {
4646

4747
// Must exclude gitignored directories.
4848
wantExcluded := []string{
49-
filepath.Join("vendor", "lib.go"),
50-
filepath.Join("node_modules", "pkg.js"),
49+
filepath.ToSlash(filepath.Join("vendor", "lib.go")),
50+
filepath.ToSlash(filepath.Join("node_modules", "pkg.js")),
5151
}
5252
for _, exclude := range wantExcluded {
5353
if fileSet[exclude] {
@@ -124,7 +124,7 @@ func TestWalkAlwaysIgnoresDirs(t *testing.T) {
124124
}
125125

126126
for _, d := range ignoredDirs {
127-
p := filepath.Join(d, "file.go")
127+
p := filepath.ToSlash(filepath.Join(d, "file.go"))
128128
if fileSet[p] {
129129
t.Errorf("expected %q inside always-ignore dir to be excluded", p)
130130
}

0 commit comments

Comments
 (0)