Skip to content

Commit 4018869

Browse files
dmealingclaude
andcommitted
fix(cli): a dangling nested requirement member names the hop that broke
de8837a stopped telling the author to qualify an object that resolved, but reported the whole member tail against the object: a live claim on `acme::shop::Order.reference.display`, with `reference` still present, read "'acme::shop::Order' has no member 'reference.display'". It now walks the path and names the first segment that does not resolve and the node it was looked for under: "'acme::shop::Order.reference' has no member 'display'". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFkf38KwqbtbAeVtizYBC7
1 parent de8837a commit 4018869

3 files changed

Lines changed: 32 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
5252
Rename `dueDate` in the model and a live claim on `app::Task.dueDate` failed with
5353
`ERR_REQUIREMENT_DANGLING_REF`, then added *"An object named "Task" exists in: app::Task.
5454
Qualify it with its package (FQN)."* The ref was already qualified and the object resolved;
55-
only the member was gone. The error now ends *"'app::Task' has no member 'dueDate'."* The
55+
only the member was gone. The error now ends *"'app::Task' has no member 'dueDate'."*, and a
56+
nested ref names the hop that broke (*"'app::Task.title' has no member 'display'."*). The
5657
did-you-mean hint still appears when the object itself does not resolve.
5758

5859
## [1.0.4] — 2026-09-14

‎server/typescript/packages/cli/src/lib/requirement-check.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ export function splitMemberRef(ref: string): { owner: string; path: string[] } {
131131
return { owner: ref.slice(0, dot), path: ref.slice(dot + 1).split(".") };
132132
}
133133

134+
/** Names the FIRST segment of a non-empty, unresolvable `path` under `obj`, and the node it
135+
* was looked for under: `acme::shop::Order.reference.display` with `reference` still present
136+
* reads "'acme::shop::Order.reference' has no member 'display'", not the whole tail. */
137+
function missingMemberHint(obj: MetaData, path: string[]): string {
138+
let found = 0;
139+
while (found < path.length - 1 && resolveMember(obj, path.slice(0, found + 1)) !== undefined) found++;
140+
const parent = [obj.resolutionKey(), ...path.slice(0, found)].join(".");
141+
return ` '${parent}' has no member '${path[found]}'.`;
142+
}
143+
134144
/** Resolution keys of every root-level object whose `extends` chain reaches
135145
* `ancestor`. Walks the RESOLVED super pointer rather than the raw string, so a
136146
* cross-package or dotted reference resolves the same way the loader resolved
@@ -501,9 +511,7 @@ export function checkRequirements(root: MetaData, scan: RequirementScan = scanRe
501511
// The did-you-mean hint answers an OBJECT that failed to resolve. When the object
502512
// resolved and only the member is gone, it listed that same object back and said
503513
// to qualify it: name the member instead.
504-
const hint = node === undefined
505-
? didYouMeanHint(root, owner)
506-
: ` '${node.resolutionKey()}' has no member '${path.join(".")}'.`;
514+
const hint = node === undefined ? didYouMeanHint(root, owner) : missingMemberHint(node, path);
507515
out.push({
508516
severity: "error", code: ERR_REQUIREMENT_DANGLING_REF, path: reqPath,
509517
message: `'${ref}' does not resolve in the loaded model (status '${String(req.status())}' — ` +

‎server/typescript/packages/cli/test/unit/requirement-check.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,25 @@ describe("requirement.* — reference resolution", () => {
475475
expect(dangling[0]!.message).toContain("'acme::shop::Order' has no member 'referenceCode'");
476476
});
477477

478+
test("a gone member under a member that DOES resolve names the first segment that failed", async () => {
479+
// `reference` still exists; only `display` under it does not. Reporting the whole
480+
// tail against the object ("'Order' has no member 'reference.display'") would not
481+
// say which hop broke.
482+
const r = await run(caps(COVER + `
483+
- requirement.functional:
484+
name: ReadableReference
485+
level: 5
486+
status: live
487+
statement: "An order reference renders for a human"
488+
counterexample: "A reference shown as a raw string"
489+
implementedBy: ["acme::shop::Order.reference.display"]
490+
`), OTHER);
491+
expect(r.loadError).toBeUndefined();
492+
const dangling = r.diags.filter((x) => x.code === ERR_REQUIREMENT_DANGLING_REF);
493+
expect(dangling.length).toBe(1);
494+
expect(dangling[0]!.message).toContain("'acme::shop::Order.reference' has no member 'display'");
495+
});
496+
478497
test("splitMemberRef splits at the first dot after the package", () => {
479498
expect(splitMemberRef("acme::shop::Order")).toEqual({ owner: "acme::shop::Order", path: [] });
480499
expect(splitMemberRef("acme::shop::Order.total")).toEqual({

0 commit comments

Comments
 (0)