← back to Cli Printing Press
fix(regenmerge): correct owner rewrite + skip injection on preserved hosts (#477)
6d45d2287817140223dd9e851738886633197bfe · 2026-05-01 20:51:08 -0700 · Trevin Chow
Fix two regen-merge --apply bugs surfaced by the flightgoat sweep:
#471: Apply doesn't rewrite owner attribution from fresh→destination.
TC + NEW files were copied verbatim from fresh, so every file shipped
with whoever-ran-generate's git-config owner. Concretely: 100 flightgoat
files flipped from matt-van-horn to trevin-chow during sweep.
#472: Apply re-injects AddCommand calls into NOVEL host files. The
lost-registrations classifier flagged calls in NOVEL hosts as missing
from fresh (they trivially are — the whole file is). Injection then
duplicated AddCommand lines into preserved files. Same logic also broke
on PUBLISHED-ONLY-TEMPLATED hosts (file deleted before injection
target).
Fix #471:
- New pipeline.RewriteOwner walks rewriteExtensions files and replaces
the framework's "// Copyright YYYY <oldOwner>." header with newOwner.
Only matches the framework-emitted line; prose mentions of the old
owner are left untouched.
- New regenmerge.resolveOwnerForTree mirrors generator's manifest >
copyright-header precedence (intentionally duplicated — the regenmerge
package's parent pipeline package has test files that import generator,
blocking a shared helper without a broader test-package refactor).
- Apply calls both, rewrites only when both owners are determinable and
differ.
Fix #472:
- extractLostRegistrations now skips host files absent from fresh.
Apply only overwrites hosts that exist in fresh (TC + NEW); NOVEL hosts
are preserved verbatim (calls already in place), PUBLISHED-ONLY-TEMPLATED
hosts are deleted (no target).
/simplify pass applied:
- Aligned readManifestOwner trim behavior between generator and
regenmerge (generator was missing the TrimSpace; quality reviewer
flagged the drift).
- Simplified RewriteOwner: bake oldOwner into the regex via QuoteMeta +
use $1/$2 backreferences, dropping the manual byte-slice splice and
the unreachable len(match) defensive check.
Tests:
- pipeline.RewriteOwner: happy path, prose-not-touched, idempotent,
empty/equal no-ops, unrelated-owner skip
- regenmerge: synthetic NOVEL-host-skip integration, end-to-end Apply
with diverging pub/fresh owners
Closes #471
Closes #472
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/plan_generate.goA internal/pipeline/owner_rewrite.goA internal/pipeline/owner_rewrite_test.goM internal/pipeline/regenmerge/apply.goA internal/pipeline/regenmerge/correctness_fixes_test.goA internal/pipeline/regenmerge/owner.goM internal/pipeline/regenmerge/registrations.go
Diff
commit 6d45d2287817140223dd9e851738886633197bfe
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 1 20:51:08 2026 -0700
fix(regenmerge): correct owner rewrite + skip injection on preserved hosts (#477)
Fix two regen-merge --apply bugs surfaced by the flightgoat sweep:
#471: Apply doesn't rewrite owner attribution from fresh→destination.
TC + NEW files were copied verbatim from fresh, so every file shipped
with whoever-ran-generate's git-config owner. Concretely: 100 flightgoat
files flipped from matt-van-horn to trevin-chow during sweep.
#472: Apply re-injects AddCommand calls into NOVEL host files. The
lost-registrations classifier flagged calls in NOVEL hosts as missing
from fresh (they trivially are — the whole file is). Injection then
duplicated AddCommand lines into preserved files. Same logic also broke
on PUBLISHED-ONLY-TEMPLATED hosts (file deleted before injection
target).
Fix #471:
- New pipeline.RewriteOwner walks rewriteExtensions files and replaces
the framework's "// Copyright YYYY <oldOwner>." header with newOwner.
Only matches the framework-emitted line; prose mentions of the old
owner are left untouched.
- New regenmerge.resolveOwnerForTree mirrors generator's manifest >
copyright-header precedence (intentionally duplicated — the regenmerge
package's parent pipeline package has test files that import generator,
blocking a shared helper without a broader test-package refactor).
- Apply calls both, rewrites only when both owners are determinable and
differ.
Fix #472:
- extractLostRegistrations now skips host files absent from fresh.
Apply only overwrites hosts that exist in fresh (TC + NEW); NOVEL hosts
are preserved verbatim (calls already in place), PUBLISHED-ONLY-TEMPLATED
hosts are deleted (no target).
/simplify pass applied:
- Aligned readManifestOwner trim behavior between generator and
regenmerge (generator was missing the TrimSpace; quality reviewer
flagged the drift).
- Simplified RewriteOwner: bake oldOwner into the regex via QuoteMeta +
use $1/$2 backreferences, dropping the manual byte-slice splice and
the unreachable len(match) defensive check.
Tests:
- pipeline.RewriteOwner: happy path, prose-not-touched, idempotent,
empty/equal no-ops, unrelated-owner skip
- regenmerge: synthetic NOVEL-host-skip integration, end-to-end Apply
with diverging pub/fresh owners
Closes #471
Closes #472
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/generator/plan_generate.go | 4 +-
internal/pipeline/owner_rewrite.go | 54 ++++++
internal/pipeline/owner_rewrite_test.go | 117 +++++++++++++
internal/pipeline/regenmerge/apply.go | 17 ++
.../pipeline/regenmerge/correctness_fixes_test.go | 181 +++++++++++++++++++++
internal/pipeline/regenmerge/owner.go | 66 ++++++++
internal/pipeline/regenmerge/registrations.go | 15 ++
7 files changed, 452 insertions(+), 2 deletions(-)
diff --git a/internal/generator/plan_generate.go b/internal/generator/plan_generate.go
index 4f48dd26..c7e4915f 100644
--- a/internal/generator/plan_generate.go
+++ b/internal/generator/plan_generate.go
@@ -309,7 +309,7 @@ func resolveOwnerForExisting(outputDir string) string {
// readManifestOwner returns the `owner` field from
// outputDir/.printing-press.json, or "" if the file is absent, malformed,
-// or the field is empty.
+// or the field is empty/whitespace.
func readManifestOwner(outputDir string) string {
data, err := os.ReadFile(filepath.Join(outputDir, ".printing-press.json"))
if err != nil {
@@ -321,7 +321,7 @@ func readManifestOwner(outputDir string) string {
if err := json.Unmarshal(data, &m); err != nil {
return ""
}
- return m.Owner
+ return strings.TrimSpace(m.Owner)
}
// resolveOwnerForNew returns the owner attribution for a brand-new project
diff --git a/internal/pipeline/owner_rewrite.go b/internal/pipeline/owner_rewrite.go
new file mode 100644
index 00000000..b5b908c4
--- /dev/null
+++ b/internal/pipeline/owner_rewrite.go
@@ -0,0 +1,54 @@
+package pipeline
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+)
+
+// RewriteOwner replaces oldOwner with newOwner in copyright headers across
+// all rewriteExtensions files under dir. No-op when the owners are equal or
+// either is empty.
+//
+// This exists for regen-merge --apply: fresh-generated trees carry whatever
+// owner the runner's git config produced, but the destination tree may have
+// a different attribution (e.g. flightgoat is matt-van-horn while a sweep
+// run by trevin-chow would otherwise rewrite all 100 copied files to the
+// wrong author). Mirrors RewriteModulePath: same dir-walk, same extension
+// list, same idempotent semantics.
+//
+// Only the owner token in the framework-emitted copyright header is replaced
+// (anchored on `// Copyright YYYY ` prefix and a trailing literal `.`); other
+// prose mentions of the old owner are intentionally left alone to avoid
+// corrupting hand-written content (issue trackers, attribution lists, etc.).
+func RewriteOwner(dir, oldOwner, newOwner string) error {
+ if oldOwner == "" || newOwner == "" || oldOwner == newOwner {
+ return nil
+ }
+
+ // Bake the literal oldOwner into the pattern so the match itself enforces
+ // the equality guard — no separate capture-and-compare step needed. The
+ // $1/$2 backreferences preserve the prefix and trailing period verbatim.
+ re := regexp.MustCompile(`(?m)^(//\s*Copyright\s+\d+\s+)` + regexp.QuoteMeta(oldOwner) + `(\.)`)
+ replacement := []byte("${1}" + newOwner + "${2}")
+
+ return filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() || !hasRewriteExtension(path) {
+ return nil
+ }
+
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("reading %s: %w", path, err)
+ }
+ updated := re.ReplaceAll(content, replacement)
+ if len(updated) == len(content) && string(updated) == string(content) {
+ return nil
+ }
+ return os.WriteFile(path, updated, 0o644)
+ })
+}
diff --git a/internal/pipeline/owner_rewrite_test.go b/internal/pipeline/owner_rewrite_test.go
new file mode 100644
index 00000000..3d6d9eea
--- /dev/null
+++ b/internal/pipeline/owner_rewrite_test.go
@@ -0,0 +1,117 @@
+package pipeline
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestRewriteOwnerHappyPath pins the core flightgoat case: fresh files
+// carry the wrong owner; rewrite swaps them to the destination's owner.
+func TestRewriteOwnerHappyPath(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "internal", "cli", "root.go"),
+ []byte("// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.\npackage cli\n"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "internal", "cli", "auth.go"),
+ []byte("// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.\npackage cli\n"), 0o644))
+
+ require.NoError(t, RewriteOwner(dir, "trevin-chow", "matt-van-horn"))
+
+ for _, p := range []string{"internal/cli/root.go", "internal/cli/auth.go"} {
+ data, err := os.ReadFile(filepath.Join(dir, p))
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "Copyright 2026 matt-van-horn.")
+ assert.NotContains(t, string(data), "trevin-chow")
+ }
+}
+
+// TestRewriteOwnerLeavesProseAlone verifies we only touch the framework's
+// emitted copyright header, not arbitrary mentions of the old owner. Without
+// the literal-period anchor in the regex this would corrupt hand-written
+// content like CHANGELOG mentions.
+func TestRewriteOwnerLeavesProseAlone(t *testing.T) {
+ dir := t.TempDir()
+ body := []byte(`// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+package cli
+
+// Originally written by trevin-chow as part of the X experiment.
+// Maintained at https://github.com/trevin-chow/something.
+const owner = "trevin-chow"
+`)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "x.go"), body, 0o644))
+
+ require.NoError(t, RewriteOwner(dir, "trevin-chow", "matt-van-horn"))
+
+ out, err := os.ReadFile(filepath.Join(dir, "x.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(out), "Copyright 2026 matt-van-horn.")
+ // Hand-written prose mentions stay put.
+ assert.Contains(t, string(out), "Originally written by trevin-chow")
+ assert.Contains(t, string(out), "github.com/trevin-chow/something")
+ assert.Contains(t, string(out), `const owner = "trevin-chow"`)
+}
+
+// TestRewriteOwnerIdempotent confirms a second pass is a no-op once the
+// owner has been swapped — needed because Apply may run the rewrite step on
+// a tempdir that already has the destination's owner if the operator's
+// git config already matches.
+func TestRewriteOwnerIdempotent(t *testing.T) {
+ dir := t.TempDir()
+ body := []byte("// Copyright 2026 matt-van-horn. Licensed under Apache-2.0.\npackage cli\n")
+ path := filepath.Join(dir, "x.go")
+ require.NoError(t, os.WriteFile(path, body, 0o644))
+
+ require.NoError(t, RewriteOwner(dir, "trevin-chow", "matt-van-horn"))
+ require.NoError(t, RewriteOwner(dir, "trevin-chow", "matt-van-horn"))
+
+ out, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, string(body), string(out))
+}
+
+// TestRewriteOwnerNoOps confirms early-exit cases: empty owners, equal
+// owners, missing dir.
+func TestRewriteOwnerNoOps(t *testing.T) {
+ tests := []struct {
+ name string
+ oldOwner string
+ newOwner string
+ }{
+ {"empty old", "", "matt-van-horn"},
+ {"empty new", "matt-van-horn", ""},
+ {"equal", "matt-van-horn", "matt-van-horn"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ body := []byte("// Copyright 2026 matt-van-horn. Licensed.\npackage cli\n")
+ path := filepath.Join(dir, "x.go")
+ require.NoError(t, os.WriteFile(path, body, 0o644))
+ require.NoError(t, RewriteOwner(dir, tt.oldOwner, tt.newOwner))
+ out, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, string(body), string(out))
+ })
+ }
+}
+
+// TestRewriteOwnerSkipsUnrelatedOwner ensures we don't rewrite a file whose
+// owner is something other than the explicit oldOwner. This matters when a
+// CLI tree contains files from multiple authors (e.g., novel files
+// hand-attributed to a different person).
+func TestRewriteOwnerSkipsUnrelatedOwner(t *testing.T) {
+ dir := t.TempDir()
+ body := []byte("// Copyright 2026 someone-else. Licensed.\npackage cli\n")
+ path := filepath.Join(dir, "x.go")
+ require.NoError(t, os.WriteFile(path, body, 0o644))
+
+ require.NoError(t, RewriteOwner(dir, "trevin-chow", "matt-van-horn"))
+
+ out, err := os.ReadFile(path)
+ require.NoError(t, err)
+ assert.Equal(t, string(body), string(out))
+}
diff --git a/internal/pipeline/regenmerge/apply.go b/internal/pipeline/regenmerge/apply.go
index d6eadd10..c2fb4910 100644
--- a/internal/pipeline/regenmerge/apply.go
+++ b/internal/pipeline/regenmerge/apply.go
@@ -123,6 +123,23 @@ func Apply(report *MergeReport, opts Options) error {
}
}
+ // Owner-attribution rewrite: fresh files carry whatever owner the runner's
+ // generator produced (their git config, typically). Without this step,
+ // every TC + NEW file copied from fresh ships with the wrong copyright
+ // when a different operator runs the sweep — observed concretely on the
+ // flightgoat sweep where 100 files flipped to trevin-chow despite the
+ // CLI being matt-van-horn. Resolve both trees' owners via the same
+ // precedence chain the generator uses (manifest > copyright > empty)
+ // and rewrite only when both are determinable and they differ.
+ pubOwner := resolveOwnerForTree(cliDir)
+ freshOwner := resolveOwnerForTree(freshDir)
+ if pubOwner != "" && freshOwner != "" && pubOwner != freshOwner {
+ if err := pipeline.RewriteOwner(tempDir, freshOwner, pubOwner); err != nil {
+ cleanup()
+ return fmt.Errorf("rewriting owner: %w", err)
+ }
+ }
+
// Overwrite go.mod with the final merged form. If either tree lacks a
// go.mod, renderMergedGoMod returns os.ErrNotExist and we skip the
// merge — there's nothing to merge. Any other error surfaces as a hard
diff --git a/internal/pipeline/regenmerge/correctness_fixes_test.go b/internal/pipeline/regenmerge/correctness_fixes_test.go
new file mode 100644
index 00000000..fbd41b3f
--- /dev/null
+++ b/internal/pipeline/regenmerge/correctness_fixes_test.go
@@ -0,0 +1,181 @@
+package regenmerge
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestExtractLostRegistrationsSkipsHostsAbsentFromFresh pins the fix for
+// issue #472: re-injection should target only host files that exist in fresh
+// (i.e., TEMPLATED-CLEAN files Apply will overwrite). NOVEL hosts (preserved
+// verbatim) and PUBLISHED-ONLY-TEMPLATED hosts (deleted) must be skipped —
+// the former because their AddCommand calls are already in place, the latter
+// because the host file no longer exists in the staging dir at injection
+// time.
+func TestExtractLostRegistrationsSkipsHostsAbsentFromFresh(t *testing.T) {
+ t.Parallel()
+
+ // pub has TWO host files with AddCommand calls:
+ // - root.go (also in fresh, so injection makes sense for what's "lost")
+ // - novel.go (NOT in fresh — this is a NOVEL file). Its calls are
+ // trivially "missing" from fresh because the whole file is missing.
+ // Fresh has root.go only. Without the fix, novel.go's calls get queued
+ // for re-injection — but novel.go is preserved verbatim, so injection
+ // would create duplicate AddCommand lines.
+ pubRoot := `package cli
+
+import "github.com/spf13/cobra"
+
+func Execute() {
+ rootCmd := &cobra.Command{Use: "x"}
+ rootCmd.AddCommand(newSharedCmd(nil))
+ _ = rootCmd.Execute()
+}
+
+func newSharedCmd(*rootFlags) *cobra.Command { return nil }
+type rootFlags struct{}
+`
+ pubNovel := `package cli
+
+import "github.com/spf13/cobra"
+
+func registerNovels(rootCmd *cobra.Command) {
+ rootCmd.AddCommand(newNovelACmd(nil))
+ rootCmd.AddCommand(newNovelBCmd(nil))
+}
+
+func newNovelACmd(*rootFlags) *cobra.Command { return nil }
+func newNovelBCmd(*rootFlags) *cobra.Command { return nil }
+`
+ freshRoot := `package cli
+
+import "github.com/spf13/cobra"
+
+func Execute() {
+ rootCmd := &cobra.Command{Use: "x"}
+ rootCmd.AddCommand(newSharedCmd(nil))
+ _ = rootCmd.Execute()
+}
+
+func newSharedCmd(*rootFlags) *cobra.Command { return nil }
+type rootFlags struct{}
+`
+ pubDir, freshDir := buildSyntheticFixture(t,
+ map[string]string{
+ "internal/cli/root.go": pubRoot,
+ "internal/cli/novel.go": pubNovel,
+ },
+ map[string]string{
+ "internal/cli/root.go": freshRoot,
+ })
+
+ regs, err := extractLostRegistrations(pubDir, freshDir)
+ require.NoError(t, err)
+
+ // novel.go must NOT appear in lost-registrations output, even though pub
+ // has AddCommand calls there that fresh doesn't. The fix anchors injection
+ // targets to "exists in fresh" only.
+ for _, r := range regs {
+ assert.NotEqual(t, "internal/cli/novel.go", r.HostFile,
+ "NOVEL host should be skipped — its calls are preserved with the file")
+ }
+}
+
+// TestApplyRewritesOwnerAttribution pins issue #471: Apply must rewrite the
+// owner string in TC + NEW files copied from fresh so the merged tree carries
+// the destination's attribution, not the runner-of-generate's.
+func TestApplyRewritesOwnerAttribution(t *testing.T) {
+ t.Parallel()
+
+ // Pub tree: matt-van-horn owner via .printing-press.json manifest.
+ pubManifest := `{"schema_version":1,"owner":"matt-van-horn","cli_name":"x-pp-cli","api_name":"x"}`
+ pubRoot := `// Copyright 2026 matt-van-horn. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press. DO NOT EDIT.
+package cli
+
+import "github.com/spf13/cobra"
+
+func Execute() {
+ rootCmd := &cobra.Command{Use: "x"}
+ _ = rootCmd.Execute()
+}
+`
+ // Fresh tree: trevin-chow owner (simulates a different operator running
+ // generate with their git config).
+ freshManifest := `{"schema_version":1,"owner":"trevin-chow","cli_name":"x-pp-cli","api_name":"x"}`
+ freshRoot := `// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press. DO NOT EDIT.
+package cli
+
+import "github.com/spf13/cobra"
+
+func Execute() {
+ rootCmd := &cobra.Command{Use: "x"}
+ _ = rootCmd.Execute()
+}
+`
+ pubGoMod := "module x.test\n\ngo 1.22\n"
+ freshGoMod := "module x.test\n\ngo 1.22\n"
+
+ stagedPubDir, freshDir := stagedSyntheticPair(t,
+ map[string]string{
+ ".printing-press.json": pubManifest,
+ "go.mod": pubGoMod,
+ "internal/cli/root.go": pubRoot,
+ },
+ map[string]string{
+ ".printing-press.json": freshManifest,
+ "go.mod": freshGoMod,
+ "internal/cli/root.go": freshRoot,
+ })
+
+ report, err := Classify(stagedPubDir, freshDir, Options{Force: true})
+ require.NoError(t, err)
+ require.NoError(t, Apply(report, Options{Force: true}))
+
+ merged, err := os.ReadFile(filepath.Join(stagedPubDir, "internal", "cli", "root.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(merged), "Copyright 2026 matt-van-horn.",
+ "merged root.go should carry the destination's owner")
+ assert.NotContains(t, string(merged), "trevin-chow",
+ "runner's owner must be rewritten away from copied fresh files")
+}
+
+// stagedSyntheticPair builds a synthetic pub/fresh pair under the test's
+// CWD prefix (so validatePathAgainstCWD passes without --force) and returns
+// the staged directory paths. Equivalent to stageFixture but writes file
+// contents directly rather than copying from a testdata/ fixture.
+func stagedSyntheticPair(t *testing.T, pubFiles, freshFiles map[string]string) (string, string) {
+ t.Helper()
+ cwd, err := os.Getwd()
+ require.NoError(t, err)
+ root := filepath.Join(cwd, "_staged", t.Name())
+ require.NoError(t, os.MkdirAll(root, 0o755))
+ t.Cleanup(func() { _ = os.RemoveAll(root) })
+
+ pubDir := filepath.Join(root, "published")
+ freshDir := filepath.Join(root, "fresh")
+ for _, layout := range []struct {
+ dir string
+ files map[string]string
+ }{
+ {pubDir, pubFiles},
+ {freshDir, freshFiles},
+ } {
+ for rel, content := range layout.files {
+ full := filepath.Join(layout.dir, rel)
+ require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
+ require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
+ }
+ }
+ // CopyDir-style assertion via path inspection — the apply uses both
+ // trees, so ensure they have unique paths under the staged root for the
+ // rename-swap to land in the right place.
+ require.True(t, strings.HasPrefix(pubDir, cwd))
+ return pubDir, freshDir
+}
diff --git a/internal/pipeline/regenmerge/owner.go b/internal/pipeline/regenmerge/owner.go
new file mode 100644
index 00000000..3bf8ded9
--- /dev/null
+++ b/internal/pipeline/regenmerge/owner.go
@@ -0,0 +1,66 @@
+package regenmerge
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// Owner resolution mirrors generator/plan_generate.go's
+// resolveOwnerForExisting precedence (manifest > copyright header > empty),
+// but is duplicated here because the test files in package pipeline import
+// generator (internal/pipeline/contracts_test.go); having the production
+// generator package also import pipeline would create a build cycle when
+// compiling pipeline's test binary. The two implementations should stay
+// aligned; if test deps are reshaped to break the cycle, replace this with
+// a shared helper.
+//
+// Used during --apply to discover (a) the destination tree's owner and (b)
+// the fresh tree's owner, so RewriteOwner can replace fresh's attribution
+// with the destination's across every file copied from fresh.
+
+var copyrightOwnerRe = regexp.MustCompile(`(?m)^//\s*Copyright\s+\d+\s+([A-Za-z0-9_-]+)\.`)
+
+// resolveOwnerForTree returns the owner attribution for a CLI tree at dir.
+// Tiers (highest first):
+//
+// 1. .printing-press.json's `owner` field, if present and non-empty
+// 2. parsed `// Copyright YYYY <owner>` line in internal/cli/root.go
+// 3. "" (empty) — caller decides whether to skip rewrite or fall back to
+// a runner-controlled value
+//
+// Returns "" when no owner can be determined. Callers in --apply skip the
+// rewrite step on empty so a missing-manifest tree doesn't corrupt headers.
+func resolveOwnerForTree(dir string) string {
+ if owner := readManifestOwner(dir); owner != "" {
+ return owner
+ }
+ return parseCopyrightOwner(dir)
+}
+
+func readManifestOwner(dir string) string {
+ data, err := os.ReadFile(filepath.Join(dir, ".printing-press.json"))
+ if err != nil {
+ return ""
+ }
+ var m struct {
+ Owner string `json:"owner"`
+ }
+ if err := json.Unmarshal(data, &m); err != nil {
+ return ""
+ }
+ return strings.TrimSpace(m.Owner)
+}
+
+func parseCopyrightOwner(dir string) string {
+ data, err := os.ReadFile(filepath.Join(dir, "internal", "cli", "root.go"))
+ if err != nil {
+ return ""
+ }
+ if m := copyrightOwnerRe.FindSubmatch(data); m != nil {
+ return string(m[1])
+ }
+ return ""
+}
diff --git a/internal/pipeline/regenmerge/registrations.go b/internal/pipeline/regenmerge/registrations.go
index f1f05021..b55ae211 100644
--- a/internal/pipeline/regenmerge/registrations.go
+++ b/internal/pipeline/regenmerge/registrations.go
@@ -38,6 +38,18 @@ func extractLostRegistrations(publishedDir, freshDir string) ([]LostRegistration
return nil, fmt.Errorf("scanning fresh internal/cli: %w", err)
}
+ // Apply only overwrites host files that exist in fresh (TEMPLATED-CLEAN +
+ // NEW-TEMPLATE-EMISSION). Hosts that don't exist in fresh are either NOVEL
+ // (preserved verbatim — pub's calls are already in place) or
+ // PUBLISHED-ONLY-TEMPLATED (deleted by Apply — no target to inject into).
+ // Either way, re-injection here would be wrong: NOVEL gets duplicate
+ // AddCommand lines, PUBLISHED-ONLY fails at apply time when the host file
+ // doesn't exist in the staging dir.
+ freshHostBasenames := map[string]struct{}{}
+ for path := range freshCalls {
+ freshHostBasenames[filepath.Base(path)] = struct{}{}
+ }
+
// Merged-tree decl-set for referent-existence checks: fresh's
// internal/cli/ ∪ published novel files (those Apply preserves into
// the merged tree). Without the union, novel-command constructors get
@@ -66,6 +78,9 @@ func extractLostRegistrations(publishedDir, freshDir string) ([]LostRegistration
var out []LostRegistration
sort.Strings(hostFiles)
for _, host := range hostFiles {
+ if _, existsInFresh := freshHostBasenames[filepath.Base(host)]; !existsInFresh {
+ continue
+ }
var lost, skipped []string
for _, call := range pubCalls[host] {
if _, present := freshCallSet[call.normalized]; present {
← 20ffaaa4 chore(skills): re-add context: fork to polish and output-rev
·
back to Cli Printing Press
·
fix(browsersniff): accept v2-shape traffic-analysis.json on 63c9618c →