Artifact 3ae8a10a861e12607ec80dcc9a33d513b47c6f46d3d5f0f66eaedcb3983c5352:
- File
internal/deck/legality.go
— part of check-in
[7b8f655da6]
at
2026-10-12 17:52:27
on branch main
— fix(deck): enforce card legality, required cards and per-type semantics (#273) (#339)
deck.Parse determined an artifact's type but then checked nothing against it, so a card a type disallows, a required card left out, or a tag that breaks a per-type semantic rule all parsed.
Add deck/legality.go with fossil's manifestCardTypes table (allowed and required cards per type) and the §5.4 semantic checks: a control artifact's tags may not target itself, a technote's tags are all self-referential singletons, and a forum post carries at most one of an H or I card and at most one parent. Parse now determines the type (inferType gains the B, J and Q type-setters it was missing and falls back to a control artifact, not an empty check-in, when nothing sets a type and there is no comment or parent), then applies legality and the semantic checks. A missing Z card is now caught by the universal required-card rule.
Several internal/manifest and internal/deck tests built incomplete artifacts fossil also rejects -- a cluster with a D card, a forum post with both an H and an I card, control-shaped D/U/Z artifacts with no T card, and single-card fixtures missing their type's required cards. Frame each as a valid artifact of its type; the behavior each test pins is unchanged.
This also makes two repostate conformance cases conform: the technote with a non-self tag is now rejected at parse (not by a later isArtifact gate), and the opaque-control rebuild case. Their marks are removed in conformance/repostate_gaps_test.go. (user: [email protected], size: 3921) [annotate] [blame] [check-ins using]
package deck import "fmt" // Card legality and per-type semantic checks, §5.2 and §5.4. The allowed and // required card sets are fossil's manifestCardTypes table (src/manifest.c), // one row per artifact type; they match the table the conformance suite reads // from the draft. type cardTable struct { allowed uint32 required uint32 } // cardTables is indexed by ArtifactType. Each entry's allowed and required // masks are built from the §5.2 Table 4 letter sets. var cardTables = func() map[ArtifactType]cardTable { rows := map[ArtifactType][2]string{ Checkin: {"BCDFNPQRTUZ", "DZ"}, Wiki: {"CDLNPUWZ", "DLUWZ"}, Ticket: {"DJKUZ", "DJKUZ"}, Event: {"CDENPTUWZ", "DEWZ"}, Cluster: {"MZ", "MZ"}, ForumPost: {"DGHINPUWZ", "DUWZ"}, Attachment: {"ACDNUZ", "ADZ"}, Control: {"DTUZ", "DTUZ"}, } tables := make(map[ArtifactType]cardTable, len(rows)) for t, r := range rows { tables[t] = cardTable{allowed: letterMask(r[0]), required: letterMask(r[1])} } return tables }() // letterMask turns a set of upper-case card letters into a bitmask, bit // (letter-'A') per letter. func letterMask(letters string) uint32 { var m uint32 for i := 0; i < len(letters); i++ { m |= 1 << (letters[i] - 'A') } return m } // checkType applies §5.2 legality (allowed and required cards for the deck's // type) and the §5.4 per-type semantic checks. seenMask has a bit set per card // type present in the artifact. func checkType(d *Deck, seenMask uint32) error { table, ok := cardTables[d.Type] if !ok { panic("deck.checkType: no card table for the artifact type") } // §5.2: no card outside the type's allowed set, and every required card // present. fossil reports the illegal card before the missing one. if extra := seenMask &^ table.allowed; extra != 0 { return fmt.Errorf("%c-card not allowed in %s", lowestLetter(extra), typeName(d.Type)) } if missing := table.required &^ seenMask; missing != 0 { return fmt.Errorf("%c-card missing in %s", lowestLetter(missing), typeName(d.Type)) } return checkSemantics(d) } // lowestLetter returns the card letter of the lowest set bit of mask. func lowestLetter(mask uint32) byte { for i := byte(0); i < 26; i++ { if mask&(1<<i) != 0 { return 'A' + i } } return '?' } // checkSemantics applies the §5.4 per-type rules that the card table cannot // express, ported from the switch at the end of fossil's manifest_parse. func checkSemantics(d *Deck) error { var selfTags, singletonTags int for _, t := range d.T { if t.UUID == "*" { selfTags++ } if t.Type == TagSingleton { singletonTags++ } } switch d.Type { case Control: // §5.4: a control artifact's tags target other artifacts, never itself. if selfTags > 0 { return fmt.Errorf("self-referential T-card in control artifact") } case Event: // §5.4: every tag on a technote is a self-referential singleton. if len(d.T) != selfTags { return fmt.Errorf("non-self-referential T-card in technote") } if len(d.T) != singletonTags { return fmt.Errorf("T-card with '*' or '-' in technote") } case ForumPost: // §5.4: a post is either a new thread (H) or a reply (I), not both, and // a forum post has at most one parent. if d.H != "" && d.I != "" { return fmt.Errorf("a forum post cannot carry both an H-card and an I-card") } if len(d.P) > 1 { return fmt.Errorf("a forum post has at most one parent") } } return nil } // typeName is the artifact type's name for an error message, matching fossil's // azNameOfMType. func typeName(t ArtifactType) string { switch t { case Checkin: return "manifest" case Wiki: return "wiki" case Ticket: return "ticket" case Event: return "technote" case Cluster: return "cluster" case ForumPost: return "forum post" case Attachment: return "attachment" case Control: return "control artifact" default: return "artifact" } }