Skip to content

Commit ece2f4d

Browse files
committed
no-mistakes(review): Harden prerelease-link unlink and simplify report @via walk
1 parent 3a43b74 commit ece2f4d

6 files changed

Lines changed: 84 additions & 66 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,16 @@ database does not have.
9797
repins.** A `link` / `unlink` round trip added a final newline to a `pom.xml` that had none,
9898
so `git status` showed it modified, and both commands said "repinned ... in `app/pom.xml`"
9999
for a module pinned only through its parent's `${metaobjects.version}`. The rewrites keep a
100-
file's final-newline state, and a repin is reported only for a file whose text changed.
100+
file's final-newline state (`pom.xml`, and the blocks appended to `pyproject.toml` and
101+
`.npmrc`), and a repin is reported only for a file whose text changed.
101102
Found by an adopter on 1.1.0-rc.4.
102103
- **`prerelease-link unlink` removes the git exclude lines `link` added.** `link` appended its
103104
ignore rules as bare lines to the clone's shared `info/exclude`, and `unlink` left them there.
104105
They now sit in one managed block, which `unlink` removes without touching a line the user
105106
wrote, and a repeated `link` does not add them twice. The file is shared by every worktree
106-
of the clone, so an entry another linked worktree still needs stays until that worktree is
107-
unlinked.
107+
of the clone, so an entry that another linked worktree, or another project linked in the same
108+
worktree, still needs stays until that one is unlinked. A `NuGet.config` that `link` wrote is
109+
removed on `unlink` rather than left as a bare XML declaration.
108110
- `verify --codegen` (Python, C#) says when the committed output was generated with another
109111
column rule, another table rule, or both, and names the `naming` setting that reproduces it,
110112
instead of advising a regeneration that would commit the wrong column or table names.

‎docs/features/prerelease.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ worktree this is the *shared* exclude file in the clone's common dir (git has no
172172
per-worktree exclude), so the paths `link` adds there live inside their own managed block
173173
too: `unlink` removes exactly that block, never a line a user added to the file by hand,
174174
and a repeated `link` does not duplicate it. An entry that another worktree of the same
175-
clone still needs, because its own managed file is still linked, stays until that worktree
176-
is unlinked too. Files that *are* tracked by definition
175+
clone, or another project linked in the same worktree, still needs stays until that one is
176+
unlinked too. Files that *are* tracked by definition
177177
(`pyproject.toml`, `pom.xml`) get a loud warning instead — there is no way to make an edit
178178
to a tracked file uncommittable, which is precisely why the detector in §5 exists.
179179

‎server/typescript/packages/codegen-ts/test/projection/extract-report-spec.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,7 @@ describe("extractReportSpec: refusals that name what is wrong", () => {
591591
value: () => root.objects().map((c) => (c.resolutionKey() === fromKey ? fromStub : c)),
592592
});
593593
expect(() => extractReportSpec(titles, rootStub, CTX)).toThrow(
594-
/report 'ProgramTitles': dimension 'programTitle' @via 'Purchase.program.nowhere' cannot be joined at hop 'nowhere' on 'acme::shop::Program': it names no relationship or identity.reference/,
594+
"report 'ProgramTitles': dimension 'programTitle' @via 'Purchase.program.nowhere' does not resolve.",
595595
);
596596
});
597597

‎server/typescript/packages/metadata/src/core/reporting/report-shape.ts‎

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -158,34 +158,15 @@ function reportingViaTerminal(
158158
from: MetaObject,
159159
root: MetaRoot,
160160
): MetaObject | undefined {
161-
const walk = walkReportingVia(via, declaring, from, root);
162-
return walk !== undefined && "terminal" in walk ? walk.terminal : undefined;
163-
}
164-
165-
/**
166-
* {@link reportingViaTerminal}, saying where a walk stopped: `undefined` when the owner does
167-
* not resolve, else the terminal entity or the hop that failed, the entity it was looked up
168-
* on and why.
169-
*/
170-
function walkReportingVia(
171-
via: string,
172-
declaring: MetaData,
173-
from: MetaObject,
174-
root: MetaRoot,
175-
): { terminal: MetaObject } | { hop: string; at: MetaObject; reason: string } | undefined {
176161
const hops = reportingViaHops(via, declaring, from, root);
177162
if (hops === undefined) return undefined;
178163
let current: MetaObject = from;
179164
for (const hop of hops) {
180-
const step = resolveViaHop(current, hop, root);
181-
if (step === undefined) return { hop, at: current, reason: "it names no relationship or identity.reference of that entity" };
182-
if (step.target === undefined) {
183-
const ref = step.hop.attr(step.isReference ? IDENTITY_REFERENCE_ATTR_REFERENCES : RELATIONSHIP_ATTR_OBJECT_REF);
184-
return { hop, at: current, reason: `its target '${String(ref ?? "")}' does not resolve to an object` };
185-
}
186-
current = step.target;
165+
const target = resolveViaHop(current, hop, root)?.target;
166+
if (target === undefined) return undefined;
167+
current = target;
187168
}
188-
return { terminal: current };
169+
return current;
189170
}
190171

191172
/**
@@ -402,15 +383,8 @@ function dimensionField(
402383
if (dim === undefined) throw unresolved(reportName, `dimension '${item.name}' on '${from.name}'`);
403384
const via = dim.via();
404385
// A @via that does not walk is the @via's error, not the @of's (the @of resolves at its end).
405-
if (via !== undefined) {
406-
const walk = walkReportingVia(via, reportingMemberOwner(dim, from), from, root);
407-
if (walk === undefined) throw unresolved(reportName, `dimension '${dim.name}' @via '${via}'`);
408-
if (!("terminal" in walk)) {
409-
throw new Error(
410-
`report '${reportName}': dimension '${dim.name}' @via '${via}' cannot be joined at hop '${walk.hop}' ` +
411-
`on '${walk.at.resolutionKey()}': ${walk.reason}.`,
412-
);
413-
}
386+
if (via !== undefined && reportingViaTerminal(via, reportingMemberOwner(dim, from), from, root) === undefined) {
387+
throw unresolved(reportName, `dimension '${dim.name}' @via '${via}'`);
414388
}
415389
const ref = resolveDimensionOf(dim, from, root);
416390
if (ref === undefined) throw unresolved(reportName, `dimension '${item.name}' @of`);

‎tools/prerelease/prerelease-link.sh‎

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ finish_rewrite() { # finish_rewrite <file> <tmpfile> <had 0|1>
197197
return 0
198198
}
199199

200+
# The END marker of a block appended to a file, ending in a newline only when the file did
201+
# before the block was added (<had>, from `had_newline`). The newline that separates the
202+
# file's last line from the block is the one the block's END would otherwise carry, so
203+
# `strip_block` hands the file back with its own final-newline state.
204+
end_block() { # end_block <had 0|1>
205+
if [ "$1" = 1 ]; then echo "$END"; else printf '%s' "$END"; fi
206+
}
207+
200208
# Remove a managed block from a file, leaving the rest byte-identical.
201209
strip_block() { # strip_block <file> <begin> <end>
202210
local f="$1" b="$2" e="$3" had
@@ -260,12 +268,10 @@ set_exclude_block() { # set_exclude_block <exclude-file> <lines>
260268
} > "$ex.tmp" && mv "$ex.tmp" "$ex"
261269
}
262270

263-
# The OTHER work trees of this clone, one path per line. They read the same exclude file, so
264-
# an entry one of them still needs must outlive this work tree's `unlink`.
265-
other_worktrees() {
266-
local self
267-
self="$(git -C "$PROJECT" rev-parse --show-toplevel 2>/dev/null)" || return 0
268-
git -C "$PROJECT" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' | grep -vxF -- "$self" || true
271+
# Every work tree of this clone, this one included, one path per line. They all read the same
272+
# exclude file, so an entry any of them still needs must outlive an `unlink`.
273+
worktrees() {
274+
git -C "$PROJECT" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p' || true
269275
}
270276

271277
# Whether a file is one `link` wrote and has not removed: it carries a managed marker, or, for
@@ -656,18 +662,19 @@ link_npm() {
656662
# pre-release pin and no registry to resolve it from — `link` reported success and the
657663
# breakage surfaced later as `notarget` inside that sub-project. `check` already walked
658664
# nested roots; that asymmetry was the bug.
659-
local wrote=0 root rel f written=()
665+
local wrote=0 root rel f had written=()
660666
while IFS= read -r root; do
661667
f="$root/.npmrc"
662668
strip_block "$f" "$BEGIN" "$END"
663669
# A pre-existing .npmrc without its own trailing newline would otherwise run its last
664670
# line straight into the BEGIN marker text (`cat` and `echo "$BEGIN"` land on the same
665671
# line) — not just a cosmetic newline difference but real content corruption.
666-
{ if [ -f "$f" ]; then cat "$f"; ends_in_newline "$f" || echo; fi
672+
had="$(had_newline "$f")"
673+
{ if [ -f "$f" ]; then cat "$f"; [ "$had" = 1 ] || echo; fi
667674
echo "$BEGIN"
668675
echo "$NPM_SCOPE:registry=$BASE/api/packages/$OWNER/npm/"
669676
[ -n "$auth" ] && echo "$auth"
670-
echo "$END"; } > "$f.tmp" && mv "$f.tmp" "$f"
677+
end_block "$had"; } > "$f.tmp" && mv "$f.tmp" "$f"
671678
rel="${f#"$PROJECT"/}"; [ "$rel" = "$f" ] && rel=".npmrc"
672679
if tracked "$rel"; then
673680
warn "$rel is TRACKED in this project — the managed block is committable. Remove it with 'unlink' before pushing."
@@ -697,22 +704,23 @@ link_npm() {
697704
}
698705

699706
link_py() {
700-
local root f n=0
707+
local root f had n=0
701708
while IFS= read -r root; do
702709
f="$root/pyproject.toml"; n=$((n + 1))
703710
strip_block "$f" "$BEGIN" "$END"
711+
had="$(had_newline "$f")"
704712
# `explicit = true` + [tool.uv.sources] is per-package index pinning: nothing resolves
705713
# from this index unless a source names it, so the rest of PyPI is untouched. pip's
706714
# --extra-index-url has no equivalent, which is why uv is the documented path.
707-
{ cat "$f"; echo; echo "$BEGIN"
715+
{ cat "$f"; [ "$had" = 1 ] || echo; echo "$BEGIN"
708716
echo '[[tool.uv.index]]'
709717
echo 'name = "metaobjects-prerelease"'
710718
echo "url = \"$BASE/api/packages/$OWNER/pypi/simple\""
711719
echo 'explicit = true'
712720
echo
713721
echo '[tool.uv.sources]'
714722
echo "$PY_DIST = { index = \"metaobjects-prerelease\" }"
715-
echo "$END"; } > "$f.tmp" && mv "$f.tmp" "$f"
723+
end_block "$had"; } > "$f.tmp" && mv "$f.tmp" "$f"
716724
say "linked ${f#"$PROJECT"/}"
717725
done < <(py_roots)
718726
if [ "$n" -eq 0 ]; then warn "no pyproject.toml — configure pip manually, or add one"; return 0; fi
@@ -851,27 +859,18 @@ run_detector() {
851859

852860
# ── unlink ────────────────────────────────────────────────────────────────────────────
853861
unlink_all() {
854-
# The git-exclude block link() wrote — removed by identity, same as every other
855-
# managed block, so a line a user added to info/exclude by hand survives untouched. The
856-
# file is shared by every work tree of the clone, so an entry another work tree's managed
857-
# file still needs stays: dropping it would let `git add -A` there stage an .npmrc that can
858-
# hold a registry credential.
859-
local ex line wt keep=""
860-
ex="$(exclude_file)"
861-
if [ -n "$ex" ]; then
862-
while IFS= read -r line; do
863-
while IFS= read -r wt; do
864-
if is_managed_file "$wt/$line"; then keep+="$line"$'\n'; break; fi
865-
done < <(other_worktrees)
866-
done < <(exclude_block_lines "$ex")
867-
set_exclude_block "$ex" "${keep%$'\n'}"
868-
fi
869862
# Every root `link` could have written to, or a sub-project keeps a managed block
870863
# pointing at a registry that will not serve it once the pre-release is gone.
871864
local root
872865
while IFS= read -r root; do strip_block "$root/.npmrc" "$BEGIN" "$END"; done < <(npm_install_roots)
873866
while IFS= read -r root; do strip_block "$root/pyproject.toml" "$BEGIN" "$END"; done < <(py_roots)
874867
strip_block "$PROJECT/NuGet.config" "$XBEGIN" "$XEND"
868+
# A NuGet.config `link` wrote is the XML declaration and the managed block, nothing else;
869+
# what the strip leaves of it is not a valid config, so it goes. A user's own file, with a
870+
# block added by hand, keeps everything outside the block.
871+
if [ -f "$PROJECT/NuGet.config" ] && ! grep -v '^<?xml [^>]*?>$' "$PROJECT/NuGet.config" | grep -q '[^[:space:]]'; then
872+
rm -f "$PROJECT/NuGet.config"
873+
fi
875874
while IFS= read -r root; do
876875
strip_block "$root/pom.xml" "$XBEGIN" "$XEND"
877876
# The http-blocker workaround is a whole file we authored, not a block inside someone
@@ -887,6 +886,22 @@ unlink_all() {
887886
say "removed the .mvn http-blocker workaround in ${root#"$PROJECT"}"
888887
fi
889888
done < <(mvn_roots)
889+
# The git-exclude block link() wrote — removed by identity, same as every other
890+
# managed block, so a line a user added to info/exclude by hand survives untouched. The
891+
# file is shared by every work tree of the clone and names paths from the repository root,
892+
# so an entry a managed file still needs stays: one another work tree holds, or another
893+
# project linked in this one. Dropping it would let `git add -A` stage an .npmrc that can
894+
# hold a registry credential. This runs last, once this project's own files are gone.
895+
local ex line wt keep=""
896+
ex="$(exclude_file)"
897+
if [ -n "$ex" ]; then
898+
while IFS= read -r line; do
899+
while IFS= read -r wt; do
900+
if is_managed_file "$wt/$line"; then keep+="$line"$'\n'; break; fi
901+
done < <(worktrees)
902+
done < <(exclude_block_lines "$ex")
903+
set_exclude_block "$ex" "${keep%$'\n'}"
904+
fi
890905
say "removed managed registry config"
891906
}
892907

‎tools/prerelease/test-prerelease-link.sh‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# .npmrc (which can hold a registry credential) is not ignored. The exclude entries `link`
1111
# adds live in one managed block, so `unlink` removes exactly that block (and nothing a
1212
# user put in the file by hand), and a repeated `link` does not duplicate it. Unlinking one
13-
# worktree keeps an entry another linked worktree of the same clone still needs.
13+
# worktree or project keeps an entry another linked worktree or project still needs.
1414
# R3 a link/unlink round trip leaves a pom.xml that lacked a trailing newline byte-identical
1515
# (the previous awk-based rewrites unconditionally added one), and `link`/`unlink` report
1616
# "repinned ... in <file>" only for a pom whose own text actually changed — not a module
@@ -133,6 +133,27 @@ check "unlink removes the managed block from the shared exclude file" '! grep -q
133133
check "unlink removes the .npmrc entry it added" '! grep -qxF .npmrc "$common/info/exclude"'
134134
check "unlink leaves a user's own exclude rule alone" 'grep -qxF a-users-own-rule "$common/info/exclude"'
135135

136+
# D11: two projects linked in ONE work tree share the block too, each entry named from the
137+
# repository root. Unlinking one keeps the entry the other's .npmrc still needs.
138+
multi="$WORK/multi"
139+
git init -q "$multi"
140+
mkdir -p "$multi/apps/web" "$multi/apps/api"
141+
for app in web api; do
142+
printf '{ "name": "%s", "version": "1.0.0", "dependencies": { "@metaobjectsdev/metadata": "^1.0.0" } }\n' "$app" \
143+
> "$multi/apps/$app/package.json"
144+
done
145+
git -C "$multi" add -A && git -C "$multi" commit -q -m init
146+
mcommon="$(git -C "$multi" rev-parse --git-common-dir)"
147+
case "$mcommon" in /*) ;; *) mcommon="$multi/$mcommon" ;; esac
148+
if link "$multi/apps/web" && link "$multi/apps/api"; then pass "link succeeds for two projects of one worktree"; else fail "link of two subprojects failed: $(tail -5 "$WORK/out")"; fi
149+
unlink_project "$multi/apps/web"
150+
check "unlinking one subproject removes its own .npmrc" '[ ! -f "$multi/apps/web/.npmrc" ]'
151+
check "unlinking one subproject drops its own exclude entry" '! grep -qxF apps/web/.npmrc "$mcommon/info/exclude"'
152+
check "the other subproject's .npmrc is still ignored by git" 'git -C "$multi" check-ignore -q -- apps/api/.npmrc'
153+
check "git status shows no .npmrc after one subproject is unlinked" '! git -C "$multi" status --porcelain | grep -q "\.npmrc"'
154+
unlink_project "$multi/apps/api"
155+
check "unlinking the last subproject removes the managed block" '! grep -q "metaobjects prerelease (managed)" "$mcommon/info/exclude"'
156+
136157
# The assertion: an .npmrc git does not ignore makes link fail.
137158
git -C "$WORK/main" worktree add -q "$WORK/wt2" -b linked2
138159
# A global excludes file is not what we control, so defeat the ignore by negating it in .gitignore.
@@ -247,9 +268,11 @@ cat > "$poly/ports/csharp/.config/dotnet-tools.json" <<'JSON'
247268
{ "version": 1, "isRoot": true, "tools": { "metaobjects.cli": { "commands": ["dotnet-meta"] } } }
248269
JSON
249270
git -C "$poly" add -A && git -C "$poly" commit -q -m init
271+
cp "$poly/ports/python/pyproject.toml" "$WORK/poly-pyproject.toml.orig"
250272
if link "$poly"; then pass "link succeeds in a polyglot repository"; else fail "polyglot link failed: $(tail -5 "$WORK/out")"; fi
251273
check "a manifest with no tool version reports that nothing was pinned" 'grep -q "ports/csharp/.config/dotnet-tools.json names MetaObjects.Cli but no version for it was found: nothing pinned" "$WORK/out" && ! grep -q "repinned MetaObjects.Cli to 1.1.0-rc.9 in ports/csharp/" "$WORK/out"'
252274
check "a repinned manifest is reported as repinned" 'grep -q "repinned MetaObjects.Cli to 1.1.0-rc.9 in .config/dotnet-tools.json" "$WORK/out"'
275+
check "link writes a NuGet.config at the root" 'grep -qF "metaobjects-prerelease" "$poly/NuGet.config"'
253276
check "the nested python project is linked" 'grep -q "metaobjects-prerelease" "$poly/ports/python/pyproject.toml"'
254277
check "the nested python project is repinned" 'grep -q "metaobjects==1.1.0rc9" "$poly/ports/python/pyproject.toml"'
255278
check "each independent pom gets the repositories" 'grep -q "<pluginRepositories>" "$poly/ports/java/pom.xml" && grep -q "<pluginRepositories>" "$poly/ports/kotlin/pom.xml"'
@@ -273,6 +296,10 @@ MO_REGISTRY_BASE="http://127.0.0.1:$PORT" MO_REGISTRY_OWNER=owner MO_REGISTRY_TO
273296
bash "$LINK" unlink --project "$poly" --to 1.0.13 >"$WORK/out" 2>&1 || true
274297
check "an inherited version property survives link and unlink" 'grep -q "<version>\${metaobjects.version}</version>" "$poly/ports/java/inherits/pom.xml" && ! grep -q "<version>8" "$poly/ports/java/inherits/pom.xml"'
275298
check "unlink removes every managed block" '! grep -rq "metaobjects prerelease (managed)" "$poly/ports"'
299+
check "the round trip leaves the pyproject.toml byte-identical (repin aside)" \
300+
'sed "s/metaobjects==1.0.13/metaobjects==1.0.9/" "$poly/ports/python/pyproject.toml" | cmp -s - "$WORK/poly-pyproject.toml.orig"'
301+
check "unlink removes the NuGet.config link wrote" '[ ! -e "$poly/NuGet.config" ]'
302+
check "git status shows no NuGet.config after the round trip" '! git -C "$poly" status --porcelain | grep -q "NuGet.config"'
276303
check "unlink repins the nested python project" 'grep -q "metaobjects==1.0.13" "$poly/ports/python/pyproject.toml"'
277304
check "unlink repins every pom" 'grep -q "8.0.13" "$poly/ports/java/pom.xml" && grep -q "8.0.13" "$poly/ports/java/module/pom.xml" && [ "$(grep -c "<version>8.0.13</version>" "$poly/ports/kotlin/pom.xml")" = 3 ]'
278305
check "unlink repins the local tool" 'grep -q "\"metaobjects.cli\": { \"version\": \"1.0.13\"" "$poly/.config/dotnet-tools.json"'

0 commit comments

Comments
 (0)