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
10 changes: 10 additions & 0 deletions internal/scaffold/assets/dcp.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/Opencode-DCP/opencode-dynamic-context-pruning/master/dcp.schema.json",
// Enable <protect> tag preservation during DCP compression.
// Slash command files in .opencode/commands/ use <protect> tags
// to mark execution-critical sections (guardrails, checklists,
// mandatory gates) that must survive context pruning.
"compress": {
"protectTags": true
}
}
51 changes: 23 additions & 28 deletions internal/scaffold/scaffold.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,16 @@ func isToolOwned(relPath string) bool {
switch relPath {
case "commands/speckit.testreview.md",
"agents/gaze-test-generator.md",
"commands/gaze-fix.md":
"commands/gaze-fix.md",
"dcp.jsonc":
return true
}
return false
}

// versionMarker returns the version marker comment to embed in
// each scaffolded file.
// scaffolded Markdown files. Non-Markdown files skip the marker
// (see processAssetFile).
func versionMarker(version string) string {
if version == "" {
version = "dev"
Expand Down Expand Up @@ -134,25 +136,6 @@ func insertMarkerAfterFrontmatter(content []byte, marker string) []byte {
return out
}

// Run scaffolds OpenCode agent, command, and reference files into
// the target directory. It creates .opencode/agents/,
// .opencode/commands/, and .opencode/references/ subdirectories
// and writes the embedded quality-reporting files.
//
// Each file is prepended with a version marker comment:
//
// <!-- scaffolded by gaze vX.Y.Z -->
//
// Files are classified as user-owned or tool-owned via
// isToolOwned(). If a user-owned file already exists
// and opts.Force is false, the file is skipped. Tool-owned files
// use overwrite-on-diff: they are replaced when their content
// differs from the embedded version, even without --force. If
// opts.Force is true, all files are overwritten regardless of
// ownership.
//
// Run returns a Result summarizing what was created, skipped,
// overwritten, or updated.
// applyDefaults sets zero-valued Options fields to their defaults.
func applyDefaults(opts *Options) error {
if opts.TargetDir == "" {
Expand Down Expand Up @@ -209,8 +192,9 @@ func writeNewFile(outPath string, content []byte, exists bool, displayPath strin
}

// processAssetFile handles a single embedded asset: checks existence,
// reads content, inserts the version marker, and writes or skips
// based on force/tool-ownership semantics. Returns the action taken
// reads content, inserts the version marker for Markdown files (non-
// Markdown files skip the marker), and writes or skips based on
// force/tool-ownership semantics. Returns the action taken
// ("created", "overwritten", "updated", "skipped") or an error.
func processAssetFile(embeddedPath, relPath string, opts Options, marker string) (string, error) {
outPath := filepath.Join(opts.TargetDir, ".opencode", relPath)
Expand All @@ -227,7 +211,15 @@ func processAssetFile(embeddedPath, relPath string, opts Options, marker string)
return "", fmt.Errorf("reading embedded asset %s: %w", embeddedPath, err)
}

out := insertMarkerAfterFrontmatter(content, marker)
// Skip the HTML version marker for non-Markdown files.
Comment thread
jflowers marked this conversation as resolved.
// The marker (<!-- scaffolded by gaze vX.Y.Z -->) is an HTML
// comment that is invalid in formats like JSONC.
var out []byte
if strings.HasSuffix(relPath, ".md") {
out = insertMarkerAfterFrontmatter(content, marker)
} else {
out = content
}

if exists && !opts.Force {
if isToolOwned(relPath) {
Expand All @@ -239,15 +231,18 @@ func processAssetFile(embeddedPath, relPath string, opts Options, marker string)
return writeNewFile(outPath, out, exists, displayPath)
}

// Run scaffolds OpenCode agent, command, and reference files into
// the target directory. It creates .opencode/agents/,
// Run scaffolds OpenCode agent, command, reference, and configuration
// files into the target directory. It creates .opencode/agents/,
// .opencode/commands/, and .opencode/references/ subdirectories
// and writes the embedded quality-reporting files.
// and writes the embedded quality-reporting files and DCP config.
//
// Each file is prepended with a version marker comment:
// Markdown files are prepended with a version marker comment:
//
// <!-- scaffolded by gaze vX.Y.Z -->
//
// Non-Markdown files (e.g., dcp.jsonc) skip the marker because the
// HTML comment syntax is invalid in those formats.
//
// Files are classified as user-owned or tool-owned via
// isToolOwned(). If a user-owned file already exists
// and opts.Force is false, the file is skipped. Tool-owned files
Expand Down
88 changes: 55 additions & 33 deletions internal/scaffold/scaffold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import (
"testing"
)

// TestRun_CreatesFiles verifies that gaze init creates exactly 8
// files (agents, commands, and reference files) in the correct
// directories when run in an empty project.
// TestRun_CreatesFiles verifies that gaze init creates exactly 9
// files (agents, commands, reference files, and DCP config) in the
// correct directories when run in an empty project.
func TestRun_CreatesFiles(t *testing.T) {
dir := t.TempDir()

Expand All @@ -31,8 +31,8 @@ func TestRun_CreatesFiles(t *testing.T) {
t.Fatalf("Run() returned error: %v", err)
}

if len(result.Created) != 8 {
t.Errorf("expected 8 created files, got %d: %v", len(result.Created), result.Created)
if len(result.Created) != 9 {
t.Errorf("expected 9 created files, got %d: %v", len(result.Created), result.Created)
}
if len(result.Skipped) != 0 {
t.Errorf("expected 0 skipped files, got %d: %v", len(result.Skipped), result.Skipped)
Expand All @@ -44,14 +44,15 @@ func TestRun_CreatesFiles(t *testing.T) {
t.Errorf("expected 0 updated files, got %d: %v", len(result.Updated), result.Updated)
}

// Verify all 6 expected files exist on disk.
// Verify all expected files exist on disk.
expected := []string{
".opencode/agents/gaze-reporter.md",
".opencode/agents/reviewer-testing.md",
".opencode/commands/gaze.md",
".opencode/commands/speckit.testreview.md",
".opencode/references/doc-scoring-model.md",
".opencode/references/example-report.md",
".opencode/dcp.jsonc",
}
for _, rel := range expected {
path := filepath.Join(dir, rel)
Expand Down Expand Up @@ -92,8 +93,8 @@ func TestRun_SkipsExisting(t *testing.T) {
}

// Second run without --force: tool-owned files have identical
// content, so all 6 files land in Skipped (3 user-owned +
// 3 tool-owned identical).
// content, so all 9 files land in Skipped (3 user-owned +
// 6 tool-owned identical).
var buf2 bytes.Buffer
result, err := Run(Options{
TargetDir: dir,
Expand All @@ -107,8 +108,8 @@ func TestRun_SkipsExisting(t *testing.T) {
if len(result.Created) != 0 {
t.Errorf("expected 0 created, got %d: %v", len(result.Created), result.Created)
}
if len(result.Skipped) != 8 {
t.Errorf("expected 8 skipped, got %d: %v", len(result.Skipped), result.Skipped)
if len(result.Skipped) != 9 {
t.Errorf("expected 9 skipped, got %d: %v", len(result.Skipped), result.Skipped)
}
if len(result.Overwritten) != 0 {
t.Errorf("expected 0 overwritten, got %d: %v", len(result.Overwritten), result.Overwritten)
Expand Down Expand Up @@ -165,8 +166,8 @@ func TestRun_ForceOverwrites(t *testing.T) {
if len(result.Skipped) != 0 {
t.Errorf("expected 0 skipped, got %d: %v", len(result.Skipped), result.Skipped)
}
if len(result.Overwritten) != 8 {
t.Errorf("expected 8 overwritten, got %d: %v", len(result.Overwritten), result.Overwritten)
if len(result.Overwritten) != 9 {
t.Errorf("expected 9 overwritten, got %d: %v", len(result.Overwritten), result.Overwritten)
}
if len(result.Updated) != 0 {
t.Errorf("expected 0 updated, got %d: %v", len(result.Updated), result.Updated)
Expand Down Expand Up @@ -214,7 +215,16 @@ func TestRun_VersionMarker(t *testing.T) {

s := string(content)

// Marker must be present in the file.
// Non-Markdown files skip the HTML marker (it would be
// invalid syntax in formats like JSONC).
if !strings.HasSuffix(relPath, ".md") {
if strings.Contains(s, expected) {
t.Errorf("file %s: non-Markdown file should NOT contain HTML marker", relPath)
}
continue
}

// Marker must be present in Markdown files.
if !strings.Contains(s, expected) {
t.Errorf("file %s: marker %q not found in content", relPath, expected)
}
Expand All @@ -236,7 +246,7 @@ func TestRun_VersionMarker(t *testing.T) {
relPath, markerIdx, frontmatterEnd)
}
}
// Non-frontmatter files: marker presence already verified above.
// Non-frontmatter Markdown files: marker presence already verified above.
}
}

Expand Down Expand Up @@ -273,6 +283,15 @@ func TestRun_VersionMarker_Dev(t *testing.T) {
}

s := string(content)

// Non-Markdown files skip the HTML marker.
if !strings.HasSuffix(relPath, ".md") {
if strings.Contains(s, expected) {
t.Errorf("file %s: non-Markdown file should NOT contain HTML marker", relPath)
}
continue
}

if !strings.Contains(s, expected) {
t.Errorf("file %s: marker %q not found in content", relPath, expected)
}
Expand Down Expand Up @@ -305,8 +324,8 @@ func TestRun_NoGoMod_PrintsWarning(t *testing.T) {
}

// Files should still be created.
if len(result.Created) != 8 {
t.Errorf("expected 8 created files, got %d", len(result.Created))
if len(result.Created) != 9 {
t.Errorf("expected 9 created files, got %d", len(result.Created))
}

// Warning should be printed.
Expand All @@ -333,8 +352,8 @@ func TestEmbeddedAssetsMatchSource(t *testing.T) {
t.Fatalf("assetPaths() returned error: %v", err)
}

if len(paths) != 8 {
t.Fatalf("expected 8 embedded assets, got %d: %v", len(paths), paths)
if len(paths) != 9 {
t.Fatalf("expected 9 embedded assets, got %d: %v", len(paths), paths)
}

for _, relPath := range paths {
Expand All @@ -357,9 +376,9 @@ func TestEmbeddedAssetsMatchSource(t *testing.T) {
}
}

// TestAssetPaths_Returns8Files verifies the embedded asset manifest
// contains exactly 8 files.
func TestAssetPaths_Returns8Files(t *testing.T) {
// TestAssetPaths_Returns9Files verifies the embedded asset manifest
// contains exactly 9 files.
func TestAssetPaths_Returns9Files(t *testing.T) {
paths, err := assetPaths()
if err != nil {
t.Fatalf("assetPaths() returned error: %v", err)
Expand All @@ -372,6 +391,7 @@ func TestAssetPaths_Returns8Files(t *testing.T) {
"commands/gaze-fix.md": true,
"commands/gaze.md": true,
"commands/speckit.testreview.md": true,
"dcp.jsonc": true,
"references/doc-scoring-model.md": true,
"references/example-report.md": true,
}
Expand All @@ -398,7 +418,7 @@ func TestRun_OverwriteOnDiff_ToolOwned(t *testing.T) {
t.Fatalf("creating go.mod: %v", err)
}

// First run: create all 8 files.
// First run: create all 9 files.
var buf1 bytes.Buffer
result1, err := Run(Options{
TargetDir: dir,
Expand All @@ -408,8 +428,8 @@ func TestRun_OverwriteOnDiff_ToolOwned(t *testing.T) {
if err != nil {
t.Fatalf("first Run() returned error: %v", err)
}
if len(result1.Created) != 8 {
t.Fatalf("expected 8 created files, got %d: %v", len(result1.Created), result1.Created)
if len(result1.Created) != 9 {
t.Fatalf("expected 9 created files, got %d: %v", len(result1.Created), result1.Created)
}

// Second run without --force: all files should be skipped
Expand All @@ -423,8 +443,8 @@ func TestRun_OverwriteOnDiff_ToolOwned(t *testing.T) {
if err != nil {
t.Fatalf("second Run() returned error: %v", err)
}
if len(result2.Skipped) != 8 {
t.Errorf("expected 8 skipped, got %d: %v", len(result2.Skipped), result2.Skipped)
if len(result2.Skipped) != 9 {
t.Errorf("expected 9 skipped, got %d: %v", len(result2.Skipped), result2.Skipped)
}
if len(result2.Updated) != 0 {
t.Errorf("expected 0 updated, got %d: %v", len(result2.Updated), result2.Updated)
Expand All @@ -441,7 +461,7 @@ func TestRun_OverwriteOnDiff_ToolOwned(t *testing.T) {
}

// Third run without --force: the 2 modified tool-owned files
// should be overwritten (Updated), the other 3 tool-owned files
// should be overwritten (Updated), the other 4 tool-owned files
// skipped (identical), and 3 user-owned files skipped.
var buf3 bytes.Buffer
result3, err := Run(Options{
Expand All @@ -456,9 +476,9 @@ func TestRun_OverwriteOnDiff_ToolOwned(t *testing.T) {
if len(result3.Updated) != 2 {
t.Errorf("expected 2 updated, got %d: %v", len(result3.Updated), result3.Updated)
}
// 3 user-owned + 3 identical tool-owned = 6 skipped.
if len(result3.Skipped) != 6 {
t.Errorf("expected 6 skipped, got %d: %v", len(result3.Skipped), result3.Skipped)
// 3 user-owned + 4 identical tool-owned = 7 skipped.
if len(result3.Skipped) != 7 {
t.Errorf("expected 7 skipped, got %d: %v", len(result3.Skipped), result3.Skipped)
}
if len(result3.Created) != 0 {
t.Errorf("expected 0 created, got %d: %v", len(result3.Created), result3.Created)
Expand Down Expand Up @@ -544,9 +564,9 @@ func TestRun_OverwriteOnDiff_SkipsIdentical(t *testing.T) {
t.Errorf("expected 0 updated (identical content), got %d: %v", len(result.Updated), result.Updated)
}

// All 8 files should be skipped.
if len(result.Skipped) != 8 {
t.Errorf("expected 8 skipped, got %d: %v", len(result.Skipped), result.Skipped)
// All 9 files should be skipped.
if len(result.Skipped) != 9 {
t.Errorf("expected 9 skipped, got %d: %v", len(result.Skipped), result.Skipped)
}

// Verify tool-owned files are specifically in the skipped list.
Expand Down Expand Up @@ -579,6 +599,8 @@ func TestIsToolOwned(t *testing.T) {
// Tool-owned: explicit command files.
{"commands/speckit.testreview.md", true},
{"commands/gaze-fix.md", true},
// Tool-owned: DCP configuration.
{"dcp.jsonc", true},
// User-owned: agents.
{"agents/gaze-reporter.md", false},
{"agents/reviewer-testing.md", false},
Expand Down
Loading