Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions frontend/src/atlas/atlasNounDeclarationFields.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
[
{
"field": "id",
"legalValues": "one of the ids declared in shared/atlasToolIdentity.ts's ATLAS_TOOL_IDENTITIES array",
"meaning": "the noun's stable identity. registerNoun() throws at module-eval time on a duplicate; assertRegistryAgreesWithIdentity() fails the build if an identity has no matching descriptor, or a descriptor has no matching identity."
},
{
"field": "icon",
"legalValues": "any icon component from @primer/octicons-react, typed as Icon",
"meaning": "the glyph rendered on this noun's tray/palette button."
},
{
"field": "label",
"legalValues": "a string",
"meaning": "the button/command text. By convention every in-tree noun sources this from identityOf(id).commandLabel rather than restating it, but the field itself accepts any string."
},
{
"field": "shortcutKey",
"legalValues": "a single-character string, or null",
"meaning": "the bare keypress that arms this tool from the board. null for a tool with no bare-key shortcut."
},
{
"field": "tray",
"legalValues": "'quick' or 'palette'",
"meaning": "which tray surface renders this tool's button."
},
{
"field": "interaction",
"legalValues": "'arm-then-click', 'pick-then-place', 'drag-to-draw', 'drag-to-erase', 'ephemeral-drag', or 'paste-or-drop'",
"meaning": "the authoring gesture that places this noun. Must equal the same field on this id's own shared/atlasToolIdentity.ts entry -- the registry's own agreement check cross-validates the two so they can never silently drift apart."
},
{
"field": "styleDefaults",
"legalValues": "a record of style-field key to value, or omitted entirely",
"meaning": "session-only seed values for a freshly placed instance's style state (colour, size, ...). Never persisted document data -- omit this field for a noun with no style surface rather than declaring an empty object."
},
{
"field": "styleFields",
"legalValues": "a readonly array of AtlasStyleField entries (atlasStyleVocabulary.ts's closed union: color, color-or-none, stroke-width, or shape-kind) -- REQUIRED, never optional",
"meaning": "this noun's own declared styleable properties. An empty array is the honest answer for a noun with no style surface at all. A non-empty array makes AtlasStylePanel.tsx render this tool's style picker automatically -- no other file needs to name this noun's id."
},
{
"field": "lockable",
"legalValues": "boolean -- REQUIRED, never optional",
"meaning": "does re-clicking this tool's own already-armed tray button lock it for repeated placement, instead of disarming on the second click? Only meaningful for an arm-then-click tool; every other tool still declares it, always false."
},
{
"field": "resizable",
"legalValues": "boolean -- REQUIRED, never optional",
"meaning": "can a placed instance be dragged to a new size via the shared NodeResizer? A container that auto-fits its own children, or a tool that never persists a placed instance, both legitimately declare false. The conformance suite checks that a true answer is backed by a real `<NodeResizer>` in the renderer boardNodeType names."
},
{
"field": "boardNodeType",
"legalValues": "'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null",
"meaning": "which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser)."
},
{
"field": "dragBand",
"legalValues": "boolean -- REQUIRED, never optional",
"meaning": "only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted."
},
{
"field": "commit",
"legalValues": "a function -- see 'How it loads' for why this is the one member that is runtime code, not declaration",
"meaning": "shapes this noun's own placement input into the artifact CreateBoardObject/CreateCard persists. Each noun's own signature differs; the registry's element type only has to accept every one of them, never call through it generically."
}
]
52 changes: 52 additions & 0 deletions frontend/src/atlas/atlasNounDeclarationFields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import declarationFields from './atlasNounDeclarationFields.json'
import type { AtlasToolShape } from './atlasNounRegistry'

// This IS goal 0211's chosen freshness mechanism for
// userdocs/reference/extending-the-canvas.md's "What is required"
// table: internal/docsgen has no TypeScript parser, so the table is
// generated (by internal/docsgen's own Go test/gen step) from the
// small committed JSON this file imports -- and THIS test is the other
// half of that freshness chain, verifying the JSON stays exhaustive
// against the real registry type rather than drifting into a stale,
// separately hand-maintained field list.
//
// EXHAUSTIVE_FIELD_KEYS is a literal object, `satisfies`-checked
// against Record<keyof AtlasToolShape, true> -- TypeScript's own
// excess-property + missing-property checks (both only apply to a
// fresh object literal, never to an imported binding) make this
// object fail to COMPILE the moment AtlasToolShape gains, loses, or
// renames a member. `keyof AtlasToolShape` intersects the keys of
// every union member (they all share the same base fields plus id/
// interaction), so this needs no exported base interface to reach
// into.
const EXHAUSTIVE_FIELD_KEYS = {
id: true,
icon: true,
label: true,
shortcutKey: true,
tray: true,
interaction: true,
styleDefaults: true,
styleFields: true,
lockable: true,
resizable: true,
boardNodeType: true,
dragBand: true,
commit: true,
} satisfies Record<keyof AtlasToolShape, true>

describe('atlasNounDeclarationFields.json (goal 0211: the extension contract page)', () => {
it('documents exactly the fields AtlasToolShape actually has -- no more, no fewer', () => {
const jsonFields = (declarationFields as { field: string }[]).map((f) => f.field).sort()
const typeFields = Object.keys(EXHAUSTIVE_FIELD_KEYS).sort()
expect(jsonFields).toEqual(typeFields)
})

it('gives every field a non-empty legalValues and meaning', () => {
for (const entry of declarationFields as { field: string; legalValues: string; meaning: string }[]) {
expect(entry.legalValues.length, `${entry.field}.legalValues`).toBeGreaterThan(0)
expect(entry.meaning.length, `${entry.field}.meaning`).toBeGreaterThan(0)
}
})
})
1 change: 1 addition & 0 deletions internal/docsgen/docsgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ func PageIndex() []DocPage {
{"concepts/runs-and-review.md", "Runs, review, and debugging", "durable runs, the review queue, breakpoints"},
{"reference/steps.md", "Step reference", "every step's contract, generated from the registry"},
{"reference/settings.md", "Settings", "app preferences: appearance, hotkeys, shortcuts, MCP access, remote access, backups, updates"},
{"reference/extending-the-canvas.md", "Extending the canvas", "how a canvas noun loads, what its declaration requires, and what platform APIs it may and may not reach"},
{"agents/connect-mcp.md", "Automate with agents", "connecting over MCP and what agents can do"},
{"trust/data-and-safety.md", "Trust, data, and safety", "no phone-home, local data, honest limits"},
}
Expand Down
85 changes: 85 additions & 0 deletions internal/docsgen/docsgen_nouns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package docsgen

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)

// declarationField mirrors one entry of
// frontend/src/atlas/atlasNounDeclarationFields.json -- the noun
// registry contract table's own source data (goal 0211). Go has no
// TypeScript parser, so extracting the table straight from
// AtlasToolShape's own .ts declaration was disproportionate; this JSON
// is the generation source instead, and
// atlasNounDeclarationFields.test.ts is the other half of the
// freshness chain, `satisfies`-checking a literal field-key list
// against the real type at TS compile time and comparing this JSON's
// own field set against that list at test time. Neither half alone
// would catch drift on its own language's side.
type declarationField struct {
Field string `json:"field"`
LegalValues string `json:"legalValues"`
Meaning string `json:"meaning"`
}

// Markers bounding the one generated region inside the otherwise
// hand-authored userdocs/reference/extending-the-canvas.md -- naming
// the exact source keeps the page honest about what freshness is (and
// is not) enforced: only the region between these two lines is
// regenerated by `go generate ./internal/docsgen` and diff-checked by
// TestExtendingCanvasPage_NounFieldTableMatchesCommitted; the prose
// around it carries no such promise.
const (
NounFieldTableBeginMarker = "<!-- BEGIN GENERATED: noun declaration fields (source: frontend/src/atlas/atlasNounDeclarationFields.json) -->"
NounFieldTableEndMarker = "<!-- END GENERATED -->"
)

// GenerateNounFieldTable renders every AtlasToolShape declaration
// field as a markdown table, read from the committed JSON at
// <frontendAtlasDir>/atlasNounDeclarationFields.json. `commit` is
// skipped -- it is the one AtlasToolShape member that is runtime code,
// not inert declaration, and the page's "How it loads" section
// documents it separately under the declaration-vs-code split.
func GenerateNounFieldTable(frontendAtlasDir string) (string, error) {
raw, err := os.ReadFile(filepath.Join(frontendAtlasDir, "atlasNounDeclarationFields.json")) // #nosec G304 -- caller-controlled fixed path, never external input
if err != nil {
return "", fmt.Errorf("read atlasNounDeclarationFields.json: %w", err)
}
var fields []declarationField
if err := json.Unmarshal(raw, &fields); err != nil {
return "", fmt.Errorf("parse atlasNounDeclarationFields.json: %w", err)
}
var b strings.Builder
b.WriteString("| Field | Legal values | Meaning |\n")
b.WriteString("|---|---|---|\n")
for _, f := range fields {
if f.Field == "commit" {
continue
}
fmt.Fprintf(&b, "| `%s` | %s | %s |\n", f.Field, f.LegalValues, f.Meaning)
}
return b.String(), nil
}

// ReplaceMarkedRegion swaps the text strictly between beginMarker and
// endMarker (both kept, byte-identical, in doc) for replacement --
// gen/main.go uses this to regenerate extending-the-canvas.md's one
// generated table in place without touching the hand-authored prose
// around it; the freshness test below does the same splice to compare
// against the committed file.
func ReplaceMarkedRegion(doc, beginMarker, endMarker, replacement string) (string, error) {
start := strings.Index(doc, beginMarker)
if start == -1 {
return "", fmt.Errorf("begin marker %q not found", beginMarker)
}
contentStart := start + len(beginMarker)
end := strings.Index(doc[contentStart:], endMarker)
if end == -1 {
return "", fmt.Errorf("end marker %q not found after begin marker", endMarker)
}
end += contentStart
return doc[:contentStart] + "\n\n" + replacement + "\n" + doc[end:], nil
}
33 changes: 33 additions & 0 deletions internal/docsgen/docsgen_nouns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package docsgen

import (
"os"
"path/filepath"
"testing"
)

// Same tfplugindocs shape as TestUserDocs_MatchCommitted, scoped to
// the one generated region inside an otherwise hand-authored page
// (goal 0211's "Extending the canvas" contract page mixes reviewed
// prose with a generated table, unlike steps.md/llms.txt which are
// generated wholesale). Fix a failure with
// `go generate ./internal/docsgen`.
func TestExtendingCanvasPage_NounFieldTableMatchesCommitted(t *testing.T) {
pagePath := filepath.Join("..", "..", "userdocs", "reference", "extending-the-canvas.md")
committed, err := os.ReadFile(pagePath) // #nosec G304 -- fixed path under this repo's own userdocs tree
if err != nil {
t.Fatalf("read extending-the-canvas.md: %v", err)
}
frontendAtlasDir := filepath.Join("..", "..", "frontend", "src", "atlas")
wantTable, err := GenerateNounFieldTable(frontendAtlasDir)
if err != nil {
t.Fatalf("generate noun field table: %v", err)
}
want, err := ReplaceMarkedRegion(string(committed), NounFieldTableBeginMarker, NounFieldTableEndMarker, wantTable)
if err != nil {
t.Fatalf("splice generated table into committed page: %v", err)
}
if string(committed) != want {
t.Errorf("extending-the-canvas.md's generated table is stale -- run `go generate ./internal/docsgen` and commit the result")
}
}
28 changes: 28 additions & 0 deletions internal/docsgen/gen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := regenerateNounFieldTable(root); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for name, gen := range map[string]func(string) (string, error){
"llms.txt": docsgen.GenerateLLMSTxt,
"llms-full.txt": docsgen.GenerateLLMSFullTxt,
Expand All @@ -33,3 +37,27 @@ func main() {
}
}
}

// regenerateNounFieldTable splices the freshly generated noun
// declaration-field table into extending-the-canvas.md's one marked
// region, leaving the rest of the hand-authored page untouched.
func regenerateNounFieldTable(docsRoot string) error {
pagePath := filepath.Join(docsRoot, "reference", "extending-the-canvas.md")
existing, err := os.ReadFile(pagePath) // #nosec G304 -- fixed path under this repo's own userdocs tree
if err != nil {
return fmt.Errorf("read extending-the-canvas.md: %w", err)
}
frontendAtlasDir := filepath.Join("..", "..", "frontend", "src", "atlas")
table, err := docsgen.GenerateNounFieldTable(frontendAtlasDir)
if err != nil {
return fmt.Errorf("generate noun field table: %w", err)
}
updated, err := docsgen.ReplaceMarkedRegion(string(existing), docsgen.NounFieldTableBeginMarker, docsgen.NounFieldTableEndMarker, table)
if err != nil {
return fmt.Errorf("splice noun field table into extending-the-canvas.md: %w", err)
}
if err := os.WriteFile(pagePath, []byte(updated), 0o600); err != nil { // #nosec G703 -- same fixed path as the read above
return fmt.Errorf("write extending-the-canvas.md: %w", err)
}
return nil
}
Loading
Loading