[object Object]

← 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

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 →