Skip to content

Commit 972e2fb

Browse files
clkaoSpike Testclaude
authored
contractlint: resolve Skill() cross-file prose pointers in dangler oracle (#459)
Teach referenceProsePointerDanglers a third resolution path alongside intra-file heading and references/*.md token: a watched-section prose pointer resolves when the same line names the section's owning skill via the Skill(skill="spacedock:<owner>") idiom nt introduced. Replace watchedSectionNames()->sectionOwners() map[string]string (keys = watched set) as the single source of truth; resolution is additive and owner-specific, so a wrong-owner token does not suppress a genuine dangler. Extend the control into a 5-row table-driven battery adding the Skill-form owner-matched (resolves) and wrong-owner (still dangles) rows. Co-authored-by: Spike Test <spike@example.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3841618 commit 972e2fb

1 file changed

Lines changed: 98 additions & 55 deletions

File tree

internal/contractlint/boot_resident_closure_test.go

Lines changed: 98 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ var foReferenceCores = map[string][]string{
4444
// core names as non-user-invocable skills (via `spacedock:<name>`), NOT as references/*.md
4545
// read-paths: the status-viewer surface and the write/id-style surface. Each must exist
4646
// on disk AND carry its section anchors. Keyed on the skill path so a newly-registered
47-
// anchor is both watched (watchedSectionNames) and stat-checked (the skill-anchor test)
48-
// from one place.
47+
// anchor is both watched + owner-resolved (sectionOwners) and stat-checked (the skill-anchor
48+
// test) from one place.
4949
var deferredSkillCores = map[string][]string{
5050
filepath.Join("skills", "fo-status-viewer", "SKILL.md"): {
5151
"## Status Viewer", "### Captain-Facing State Display", "## Issue Filing",
@@ -239,45 +239,45 @@ func TestHostNeutralCoresResolveAndCarryCeremony(t *testing.T) {
239239
}
240240
}
241241

242-
// watchedSectionNames are the section names a prose pointer inside a deferred skill
243-
// body may name: the deferredSkillCores anchors with their `## `/`### ` heading markers
244-
// stripped ("Status Viewer", "Captain-Facing State Display", "Issue Filing", "FO Write
245-
// Scope", "ID Styles"). Derived from deferredSkillCores so a newly-registered anchor is
246-
// watched without a second edit here.
247-
func watchedSectionNames() []string {
248-
var names []string
249-
seen := map[string]bool{}
250-
for _, anchors := range deferredSkillCores {
242+
// sectionOwners maps each watched section name (the deferredSkillCores anchors with their
243+
// `## `/`### ` heading markers stripped — "Status Viewer", "Captain-Facing State Display",
244+
// "Issue Filing", "FO Write Scope", "ID Styles") to the skill that owns it
245+
// (skills/<owner>/SKILL.md, via filepath.Base(filepath.Dir(skillPath))). Derived from
246+
// deferredSkillCores so a newly-registered anchor is both watched AND owner-resolved from
247+
// one place. The section-name set the prose-pointer scanner watches is exactly this map's
248+
// keys; the owner drives the Skill(skill="spacedock:<owner>") cross-file resolution.
249+
func sectionOwners() map[string]string {
250+
owners := map[string]string{}
251+
for skillPath, anchors := range deferredSkillCores {
252+
owner := filepath.Base(filepath.Dir(skillPath)) // skills/<owner>/SKILL.md -> <owner>
251253
for _, anchor := range anchors {
252-
name := strings.TrimLeft(anchor, "# ")
253-
if !seen[name] {
254-
seen[name] = true
255-
names = append(names, name)
256-
}
254+
owners[strings.TrimLeft(anchor, "# ")] = owner
257255
}
258256
}
259-
return names
257+
return owners
260258
}
261259

262260
// referenceProsePointerDanglers scans one deferred module's body for non-heading lines that
263-
// NAME a watched section yet resolve NEITHER intra-file (the body carries that section's
264-
// heading at any level) NOR via a references/*.md path token on the same line. Each such
265-
// line is a dangling prose pointer — the M5 shape, where a bare "(see FO Write Scope)"
266-
// survives a move into a file that no longer carries that section. It returns one
267-
// description per dangling line. The real guard and its control both drive this single
268-
// scanner, so defeating it reds both.
269-
func referenceProsePointerDanglers(body string, watched []string) []string {
261+
// NAME a watched section yet resolve by NONE of the three honored paths: intra-file (the body
262+
// carries that section's heading at any level), a references/*.md path token on the same line,
263+
// or the cross-file Skill(skill="spacedock:<owner>") idiom — the same line names the section's
264+
// owning skill via the spacedock:<owner> token (captured by bodySkillRe). Each unresolved line
265+
// is a dangling prose pointer — the M5 shape, where a bare "(see FO Write Scope)" survives a
266+
// move into a file that no longer carries that section. The Skill-form resolution is
267+
// owner-specific: a spacedock: token for a DIFFERENT skill does NOT suppress the dangler, so
268+
// the scanner cannot degrade into silencing on any spacedock: token. It returns one description
269+
// per dangling line. The real guard and its control both drive this single scanner, so defeating
270+
// it reds both.
271+
func referenceProsePointerDanglers(body string, owners map[string]string) []string {
270272
lines := strings.Split(body, "\n")
271273
defined := map[string]bool{}
272274
for _, line := range lines {
273275
if !strings.HasPrefix(line, "#") {
274276
continue
275277
}
276278
heading := strings.TrimLeft(line, "# ")
277-
for _, name := range watched {
278-
if heading == name {
279-
defined[name] = true
280-
}
279+
if _, watched := owners[heading]; watched {
280+
defined[heading] = true
281281
}
282282
}
283283
var danglers []string
@@ -286,11 +286,19 @@ func referenceProsePointerDanglers(body string, watched []string) []string {
286286
continue // a heading DEFINES a section, it does not point at one
287287
}
288288
hasPath := bodyReferenceRe.MatchString(line)
289-
for _, name := range watched {
289+
// skills named on this line via the spacedock:<name> token (the Skill(skill="…") idiom)
290+
skillsNamed := map[string]bool{}
291+
for _, m := range bodySkillRe.FindAllStringSubmatch(line, -1) {
292+
skillsNamed[m[1]] = true
293+
}
294+
for name, owner := range owners {
290295
if !strings.Contains(line, name) || defined[name] || hasPath {
291296
continue
292297
}
293-
danglers = append(danglers, fmt.Sprintf("line %d names section %q but resolves neither intra-file (no `## %s` heading here) nor via a references/*.md token: %q", i+1, name, name, strings.TrimSpace(line)))
298+
if skillsNamed[owner] {
299+
continue // resolves cross-file via Skill(skill="spacedock:<owner>")
300+
}
301+
danglers = append(danglers, fmt.Sprintf("line %d names section %q but resolves neither intra-file (no `## %s` heading here), via a references/*.md token, nor via Skill(skill=\"spacedock:%s\"): %q", i+1, name, name, owner, strings.TrimSpace(line)))
294302
}
295303
}
296304
return danglers
@@ -347,9 +355,9 @@ func TestDeferredSkillCoresResolveAndCarryCeremony(t *testing.T) {
347355
// fail; the empty-walk guards keep this non-vacuous.
348356
func TestDeferredSkillProsePointersResolve(t *testing.T) {
349357
root := repoRoot(t)
350-
watched := watchedSectionNames()
351-
if len(watched) == 0 {
352-
t.Fatal("no watched section names derived from deferredSkillCores — the prose-pointer check would pass vacuously")
358+
owners := sectionOwners()
359+
if len(owners) == 0 {
360+
t.Fatal("no section owners derived from deferredSkillCores — the prose-pointer check would pass vacuously")
353361
}
354362
walked := 0
355363
for ref := range deferredSkillCores {
@@ -359,7 +367,7 @@ func TestDeferredSkillProsePointersResolve(t *testing.T) {
359367
continue
360368
}
361369
walked++
362-
for _, d := range referenceProsePointerDanglers(string(data), watched) {
370+
for _, d := range referenceProsePointerDanglers(string(data), owners) {
363371
t.Errorf("%s: %s", ref, d)
364372
}
365373
}
@@ -368,30 +376,65 @@ func TestDeferredSkillProsePointersResolve(t *testing.T) {
368376
}
369377
}
370378

371-
// TestDeferredSkillProsePointerGuardFailsOnDanglingTarget is the AC-3c control: it drives
372-
// referenceProsePointerDanglers against planted bodies so the gate is shown able to fail (RED
373-
// on the M5 bare-name shape) without false-positiving the two legitimate resolutions — an
374-
// intra-file heading and a references/*.md path token.
379+
// TestDeferredSkillProsePointerGuardFailsOnDanglingTarget is the AC-3c control battery: it
380+
// drives referenceProsePointerDanglers against planted bodies so the gate is shown able to
381+
// fail (RED on the M5 bare-name shape) without false-positiving the three legitimate
382+
// resolutions — an intra-file heading, a references/*.md path token, and the cross-file
383+
// Skill(skill="spacedock:<owner>") idiom. The wrong-owner row is the non-weakening guard:
384+
// a Skill token for a DIFFERENT skill must NOT suppress the dangler, so the resolver cannot
385+
// degrade into "any spacedock: token silences danglers."
375386
func TestDeferredSkillProsePointerGuardFailsOnDanglingTarget(t *testing.T) {
376-
watched := watchedSectionNames()
377-
if len(watched) == 0 {
378-
t.Fatal("no watched section names derived from deferredSkillCores — the control has nothing to test")
379-
}
380-
// RED — the exact M5 shape: a bare prose pointer to a section the body neither defines
381-
// nor reaches by path. It MUST dangle.
382-
dangling := "Some prose.\nUse `spacedock new` (see FO Write Scope), which mints the id.\n"
383-
if got := referenceProsePointerDanglers(dangling, watched); len(got) == 0 {
384-
t.Fatal("control: a bare prose pointer to FO Write Scope (no intra-file heading, no references/*.md token) did not dangle — the guard cannot fail")
387+
owners := sectionOwners()
388+
if len(owners) == 0 {
389+
t.Fatal("no section owners derived from deferredSkillCores — the control has nothing to test")
385390
}
386-
// GREEN — intra-file heading present: the pointer resolves, must NOT dangle.
387-
intraFile := "## FO Write Scope\n\nThe scope.\n\nSee FO Write Scope above for the full contract.\n"
388-
if got := referenceProsePointerDanglers(intraFile, watched); len(got) != 0 {
389-
t.Fatalf("control: a prose pointer resolved intra-file (## FO Write Scope present) was wrongly flagged as dangling: %v", got)
391+
cases := []struct {
392+
name string
393+
body string
394+
wantDangler bool // true: the pointer must dangle (>=1); false: it must resolve (0)
395+
}{
396+
{
397+
// The exact M5 shape: a bare prose pointer to a section the body neither
398+
// defines nor reaches by path. It MUST dangle.
399+
name: "bare-name pointer dangles",
400+
body: "Some prose.\nUse `spacedock new` (see FO Write Scope), which mints the id.\n",
401+
wantDangler: true,
402+
},
403+
{
404+
// Intra-file heading present: the pointer resolves, must NOT dangle.
405+
name: "intra-file heading resolves",
406+
body: "## FO Write Scope\n\nThe scope.\n\nSee FO Write Scope above for the full contract.\n",
407+
wantDangler: false,
408+
},
409+
{
410+
// A references/*.md path token present: the pointer resolves, must NOT dangle.
411+
name: "references/*.md path token resolves",
412+
body: "Use `spacedock new` (see `references/fo-dispatch-core.md`), which mints the id.\n",
413+
wantDangler: false,
414+
},
415+
{
416+
// The Skill(skill="spacedock:<owner>") idiom nt introduced: the line names the
417+
// watched section AND its owning skill, so the pointer resolves cross-file.
418+
name: "Skill-form owner-matched pointer resolves",
419+
body: "Use `spacedock new` (see FO Write Scope via `Skill(skill=\"spacedock:fo-write-core\")`), which mints the id.\n",
420+
wantDangler: false,
421+
},
422+
{
423+
// Wrong-owner Skill token: fo-status-viewer does not own FO Write Scope, so the
424+
// pointer still dangles. This is the non-weakening property.
425+
name: "wrong-owner Skill token still dangles",
426+
body: "Use `spacedock new` (see FO Write Scope via `Skill(skill=\"spacedock:fo-status-viewer\")`), which mints the id.\n",
427+
wantDangler: true,
428+
},
390429
}
391-
// GREEN — a references/*.md path token present: the pointer resolves, must NOT dangle.
392-
pathForm := "Use `spacedock new` (see `references/fo-dispatch-core.md`), which mints the id.\n"
393-
if got := referenceProsePointerDanglers(pathForm, watched); len(got) != 0 {
394-
t.Fatalf("control: a prose pointer carrying a references/*.md path token was wrongly flagged as dangling: %v", got)
430+
for _, tc := range cases {
431+
got := referenceProsePointerDanglers(tc.body, owners)
432+
if tc.wantDangler && len(got) == 0 {
433+
t.Errorf("%s: expected >=1 dangler, got 0: body=%q", tc.name, tc.body)
434+
}
435+
if !tc.wantDangler && len(got) != 0 {
436+
t.Errorf("%s: expected 0 danglers, got %d: %v", tc.name, len(got), got)
437+
}
395438
}
396439
}
397440

0 commit comments

Comments
 (0)