@@ -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.
4949var 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.
348356func 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."
375386func 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.\n Use `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 \n The scope.\n \n See 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.\n Use `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 \n The scope.\n \n See 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