diff --git a/audits/README.md b/audits/README.md new file mode 100644 index 00000000..fbd4d729 --- /dev/null +++ b/audits/README.md @@ -0,0 +1,25 @@ +# Canonical module audits + +이 폴더는 공개 제품 문서가 아니라 repository 책임 감사를 위한 machine-readable +ledger를 소유합니다. 사이트는 결과를 표시할 수 있지만 감사 계약과 판정은 +site-owned가 아닙니다. + +`document-types.json`의 후보 분모는 `site/site-routes.json`의 `Document Types` +서브 메뉴입니다. 모든 후보는 `candidateProfiles`에 필요성, 역할, 현재 관찰된 +schema와 근거 source symbol을 기록합니다. 이 schema는 감사 입력이며 정본 계약을 +미리 확정하지 않습니다. 후보를 감사할 때는 다음 순서로 닫습니다. + +1. package public entrypoint, Usage source registry와 live demo registry에서 runtime + closure를 정한다. +2. 독립 knowledge, decision, change reason 또는 lifecycle마다 responsibility + occurrence를 하나 만든다. 한 파일에 여러 책임이 있으면 passport를 나눈다. +3. 모든 occurrence에 `$canonical-module-audit`의 MECE disposition을 정확히 하나 + 기록한다. +4. `sourcePath`와 `symbol`을 실제 구현에 연결하고 `denominator`를 occurrence 수와 + 일치시킨다. +5. nonconforming occurrence가 남아 있으면 후보 상태를 `audited-tbd`로 유지한다. + +Ledger 추가는 `npm run check:canonical-modules -w +@interactive-os/json-document-site`로 검증합니다. Guard는 내비게이션 분모 불일치, +누락된 source·symbol·passport field, 중복 occurrence, 잘못된 disposition과 성급한 +TBD 종료를 실패시킵니다. diff --git a/audits/document-types.json b/audits/document-types.json new file mode 100644 index 00000000..9e08c16c --- /dev/null +++ b/audits/document-types.json @@ -0,0 +1,314 @@ +{ + "version": 1, + "invariant": "same role + same responsibility = same canonical module", + "candidates": ["rich-text", "order", "object", "tree", "database", "calendar", "sheet", "kanban", "annotation"], + "candidateProfiles": { + "rich-text": { + "why": "paragraph, list, mark와 extension node의 의미와 유효성을 일반 JSON 편집 규칙만으로 보존할 수 없기 때문입니다.", + "does": "Rich Text node vocabulary, block/inline 관계, mark와 extension 계약을 정의하고 구조화된 문서를 text·HTML 같은 표현으로 투영합니다.", + "schema": "interface RichTextDocument {\n readonly id: string;\n readonly profile: string;\n readonly type: \"doc\";\n readonly content: ReadonlyArray;\n}\n\ntype RichTextBlockNode =\n | RichTextParagraph\n | RichTextHeading\n | RichTextBlockquote\n | RichTextCodeBlock\n | RichTextBulletList\n | RichTextOrderedList;\n\ninterface RichTextText { readonly id: string; readonly type: \"text\"; readonly text: string; readonly marks: ReadonlyArray }\ninterface RichTextHardBreak { readonly id: string; readonly type: \"hardBreak\" }\ninterface RichTextParagraph { readonly id: string; readonly type: \"paragraph\"; readonly content: ReadonlyArray }\ninterface RichTextHeading { readonly id: string; readonly type: \"heading\"; readonly attrs: { readonly level: 1 | 2 | 3 | 4 | 5 | 6 }; readonly content: ReadonlyArray }\ninterface RichTextBlockquote { readonly id: string; readonly type: \"blockquote\"; readonly content: ReadonlyArray }\ninterface RichTextCodeBlock { readonly id: string; readonly type: \"codeBlock\"; readonly attrs: { readonly language: string | null }; readonly content: readonly [] | readonly [RichTextText] }\ninterface RichTextListItem { readonly id: string; readonly type: \"listItem\"; readonly content: ReadonlyArray }\ninterface RichTextBulletList { readonly id: string; readonly type: \"bulletList\"; readonly content: ReadonlyArray }\ninterface RichTextOrderedList { readonly id: string; readonly type: \"orderedList\"; readonly attrs: { readonly start: number }; readonly content: ReadonlyArray }\ntype RichTextExtensionNode = { readonly id: string; readonly type: `${string}/${string}`; readonly attrs?: Readonly>; readonly content?: ReadonlyArray };", + "fields": [ + { "name": "id", "description": "문서와 각 node를 편집·selection·extension에서 안정적으로 참조하는 identity입니다." }, + { "name": "profile", "description": "문서가 따르는 Rich Text vocabulary와 호환 계약을 식별합니다." }, + { "name": "type", "description": "document와 node variant를 판별합니다. extension은 namespace/name 형태를 사용합니다." }, + { "name": "content", "description": "block·inline·list의 허용된 계층을 보존하는 ordered child sequence입니다." }, + { "name": "attrs", "description": "heading level, code language, ordered-list start와 extension별 구조화 속성입니다." }, + { "name": "text / marks", "description": "text node의 문자열과 해당 문자열 전체에 적용되는 strong, link 같은 의미 표식입니다." } + ], + "sourcePath": "packages/json-document-rich-text/src/model.ts", + "symbol": "RichTextDocument" + }, + "order": { + "why": "순서가 의미인 item 집합은 배열 위치뿐 아니라 안정된 item identity와 rename 규칙을 보존해야 하기 때문입니다.", + "does": "식별된 item의 선형 순서와 label을 정의하고 reorder·rename 같은 의미 연산의 기반을 제공합니다.", + "schema": "interface OrderItem {\n readonly id: string;\n readonly label: string;\n}\n\ninterface OrderDocument {\n readonly items: ReadonlyArray;\n}", + "fields": [ + { "name": "items", "description": "문서가 의미하는 선형 순서입니다. 배열 순서가 곧 item 순서입니다." }, + { "name": "items[].id", "description": "rename, selection과 paste target이 배열 위치와 무관하게 item을 찾는 identity입니다." }, + { "name": "items[].label", "description": "item이 사용자에게 나타내는 편집 가능한 이름입니다." } + ], + "sourcePath": "packages/json-document-editing/src/order.ts", + "symbol": "OrderDocument" + }, + "object": { + "why": "캔버스 object는 identity, geometry와 presentation 값을 하나의 의미 단위로 유지해야 하기 때문입니다.", + "does": "object의 위치·크기·색상 모델을 정의하고 translate·resize·fill 같은 공간 연산의 기반을 제공합니다.", + "schema": "interface DocumentObject {\n readonly id: string;\n readonly label: string;\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n readonly color: string;\n}\n\ninterface ObjectDocument {\n readonly objects: ReadonlyArray;\n}", + "fields": [ + { "name": "objects", "description": "같은 공간에서 배치·선택되는 object의 ordered collection입니다." }, + { "name": "objects[].id", "description": "selection, transform과 clipboard가 object를 안정적으로 참조하는 identity입니다." }, + { "name": "objects[].label", "description": "object의 사용자 가시 이름입니다." }, + { "name": "x / y", "description": "문서 좌표계에서 object 좌상단의 위치입니다." }, + { "name": "width / height", "description": "resize 이후에도 유효해야 하는 object의 공간 크기입니다." }, + { "name": "color", "description": "object가 소유하는 현재 presentation 값입니다." } + ], + "sourcePath": "packages/json-document-editing/src/object.ts", + "symbol": "ObjectDocument" + }, + "tree": { + "why": "계층 문서는 node identity와 parent reference의 무결성, 순서와 가시성 투영을 함께 보존해야 하기 때문입니다.", + "does": "parent-linked node 모델을 정의하고 visible tree와 구조 이동을 위한 의미 기반을 제공합니다.", + "schema": "interface TreeNode {\n readonly id: string;\n readonly parentId: string | null;\n readonly label: string;\n}\n\ninterface TreeDocument {\n readonly nodes: ReadonlyArray;\n}", + "fields": [ + { "name": "nodes", "description": "tree node의 저장 순서입니다. 같은 parent 아래 sibling order의 입력이 됩니다." }, + { "name": "nodes[].id", "description": "parent reference, selection과 구조 연산이 사용하는 고유 identity입니다." }, + { "name": "nodes[].parentId", "description": "부모 node를 참조하며 null이면 root입니다. 존재하는 id만 참조해야 합니다." }, + { "name": "nodes[].label", "description": "node가 나타내는 사용자 가시 이름입니다." } + ], + "sourcePath": "packages/json-document-editing/src/tree.ts", + "symbol": "TreeDocument" + }, + "database": { + "why": "record 값은 property schema와 view가 참조하는 identity·type 규칙을 함께 만족해야 하기 때문입니다.", + "does": "property schema, record 값과 저장된 table view를 정의하고 typed value·sort·filter projection의 기반을 제공합니다.", + "schema": "type DatabasePropertyType = \"title\" | \"text\" | \"number\" | \"select\" | \"checkbox\";\ninterface DatabaseSelectOption { readonly id: string; readonly name: string }\ninterface DatabaseProperty { readonly id: string; readonly name: string; readonly type: DatabasePropertyType; readonly options: ReadonlyArray }\ninterface DatabaseRecord { readonly id: string; readonly values: Readonly> }\ninterface DatabaseSort { readonly propertyId: string; readonly direction: \"ascending\" | \"descending\" }\ninterface DatabaseFilter { readonly propertyId: string; readonly operator: \"equals\"; readonly value: JSONValue }\ninterface DatabaseTableView {\n readonly id: string; readonly name: string; readonly type: \"table\";\n readonly propertyOrder: ReadonlyArray;\n readonly propertyVisibility: Readonly>;\n readonly propertyWidths: Readonly>;\n readonly sort: DatabaseSort | null; readonly filter: DatabaseFilter | null;\n}\ninterface DatabaseDocument {\n readonly schema: { readonly properties: ReadonlyArray };\n readonly records: ReadonlyArray;\n readonly views: ReadonlyArray;\n}", + "fields": [ + { "name": "schema.properties", "description": "record value의 key, type과 표시 이름을 정의하는 canonical property 목록입니다." }, + { "name": "properties[].options", "description": "select property가 허용하는 안정된 option identity와 이름입니다. 다른 type에서는 빈 목록입니다." }, + { "name": "records[].id", "description": "view, selection과 CRUD가 record를 참조하는 고유 identity입니다." }, + { "name": "records[].values", "description": "property id를 key로 갖는 값 map이며 각 값은 property type을 만족해야 합니다." }, + { "name": "views", "description": "같은 records를 표시하는 저장된 table projection 설정입니다." }, + { "name": "propertyOrder / propertyVisibility / propertyWidths", "description": "view별 열 순서·가시성·너비이며 존재하는 property id만 참조해야 합니다." }, + { "name": "sort / filter", "description": "view 결과를 파생하는 property 참조와 정렬·동등 비교 조건입니다." } + ], + "sourcePath": "packages/json-document-editing/src/database.ts", + "symbol": "DatabaseDocument" + }, + "calendar": { + "why": "event interval, calendar reference와 recurrence가 시간 의미와 참조 무결성을 함께 보존해야 하기 때문입니다.", + "does": "calendar와 event·recurrence 모델을 정의하고 occurrence, busy date와 기간별 event projection의 기반을 제공합니다.", + "schema": "interface CalendarCalendar { readonly id: string; readonly title: string; readonly hidden: boolean; readonly color: string }\ninterface CalendarRecurrence { readonly freq: \"daily\" | \"weekly\" | \"monthly\" | \"yearly\"; readonly interval: number; readonly until: string }\ninterface CalendarEvent {\n readonly id: string; readonly title: string; readonly start: string; readonly end: string;\n readonly allDay: boolean; readonly calendarId: string;\n readonly recurrence: CalendarRecurrence | null; readonly excludeDates: ReadonlyArray;\n}\ninterface CalendarDocument {\n readonly calendars: ReadonlyArray;\n readonly events: ReadonlyArray;\n}", + "fields": [ + { "name": "calendars", "description": "event를 분류하는 calendar의 고유 목록입니다." }, + { "name": "calendars[].hidden / color", "description": "calendar별 가시성 상태와 event 표현에 쓰는 비어 있지 않은 색상 값입니다." }, + { "name": "events[].id / calendarId", "description": "event identity와 소속 calendar reference입니다. calendarId는 존재하는 calendar를 가리켜야 합니다." }, + { "name": "start / end / allDay", "description": "interval을 정의합니다. allDay이면 date, 아니면 datetime-local 형식을 사용합니다." }, + { "name": "recurrence.freq / interval / until", "description": "반복 주기, 양의 간격과 반복 종료 경계를 정의합니다." }, + { "name": "excludeDates", "description": "반복 projection에서 제외할 occurrence date 목록입니다." } + ], + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "CalendarDocument" + }, + "sheet": { + "why": "grid cell 값은 안정된 row·column identity와 좌표 관계를 유지해야 배열 index 변화에도 의미가 보존되기 때문입니다.", + "does": "column, row와 column-keyed cell 모델을 정의하고 grid topology와 range projection의 기반을 제공합니다.", + "schema": "interface SheetColumn { readonly id: string; readonly label: string }\ninterface SheetRow { readonly id: string; readonly cells: Readonly> }\ninterface SheetDocument {\n readonly columns: ReadonlyArray;\n readonly rows: ReadonlyArray;\n}", + "fields": [ + { "name": "columns", "description": "grid의 열 순서와 column identity를 정의합니다." }, + { "name": "columns[].id / label", "description": "cell key로 쓰는 안정된 identity와 사용자 가시 열 이름입니다." }, + { "name": "rows", "description": "grid의 행 순서를 정의하는 row 목록입니다." }, + { "name": "rows[].id", "description": "selection과 range가 배열 위치와 무관하게 row를 참조하는 identity입니다." }, + { "name": "rows[].cells", "description": "column id를 key로 JSON cell value를 저장합니다. 알려진 column만 key로 사용해야 합니다." } + ], + "sourcePath": "packages/json-document-editing/src/sheet.ts", + "symbol": "SheetDocument" + }, + "kanban": { + "why": "card identity와 column의 card reference·순서가 보드 상태의 핵심 불변식이기 때문입니다.", + "does": "column과 card 모델, column별 card 순서를 정의하고 card 이동 연산의 기반을 제공합니다.", + "schema": "interface KanbanCard { readonly id: string; readonly title: string }\ninterface KanbanColumn { readonly id: string; readonly title: string; readonly cardIds: ReadonlyArray }\ninterface KanbanDocument {\n readonly columns: ReadonlyArray;\n readonly cards: ReadonlyArray;\n}", + "fields": [ + { "name": "columns", "description": "보드의 열 순서를 정의하는 고유 column 목록입니다." }, + { "name": "columns[].id / title", "description": "drop target이 참조하는 identity와 사용자 가시 열 이름입니다." }, + { "name": "columns[].cardIds", "description": "column 안의 card 순서입니다. 존재하는 card id만 중복 없이 참조해야 합니다." }, + { "name": "cards", "description": "보드가 소유하는 card의 canonical 목록입니다." }, + { "name": "cards[].id / title", "description": "이동·selection의 안정된 identity와 사용자 가시 card 제목입니다." } + ], + "sourcePath": "packages/json-document-editing/src/kanban.ts", + "symbol": "KanbanDocument" + }, + "annotation": { + "why": "annotation은 source identity, selector geometry, instruction body와 presentation 의미를 일관되게 연결해야 하기 때문입니다.", + "does": "annotation source·selector·body·presentation 모델과 참조 무결성을 정의하고 target별 annotation projection의 기반을 제공합니다.", + "schema": "type AnnotationSelector =\n | { readonly type: \"point\"; readonly x: number; readonly y: number }\n | { readonly type: \"rectangle\"; readonly x: number; readonly y: number; readonly width: number; readonly height: number }\n | { readonly type: \"path\"; readonly points: ReadonlyArray<{ x: number; y: number }> }\n | { readonly type: \"arrow\"; readonly from: { x: number; y: number }; readonly to: { x: number; y: number } };\ntype AnnotationPresentation =\n | { readonly type: \"marker\" } | { readonly type: \"reaction\"; readonly reaction: \"like\" | \"dislike\" }\n | { readonly type: \"outline\" } | { readonly type: \"stroke\" } | { readonly type: \"arrow\" };\ninterface AnnotationSource { readonly id: string; readonly src: string; readonly width: number; readonly height: number }\ninterface Annotation { readonly id: string; readonly body: { readonly instruction: string }; readonly target: { readonly sourceId: string; readonly selector: AnnotationSelector }; readonly presentation: AnnotationPresentation }\ninterface AnnotationDocument {\n readonly profile: \"urn:interactive-os:json-document:annotation:1\"; readonly id: string;\n readonly sources: ReadonlyArray; readonly annotations: ReadonlyArray;\n}", + "fields": [ + { "name": "profile / id", "description": "Annotation v1 계약과 문서 identity를 식별합니다." }, + { "name": "sources", "description": "annotation target이 참조할 이미지·문서 source와 양의 원본 크기를 정의합니다." }, + { "name": "annotations[].body.instruction", "description": "선택된 위치에 연결된 사용자 또는 agent 지시 내용입니다." }, + { "name": "target.sourceId", "description": "존재하는 source를 가리키는 참조입니다." }, + { "name": "target.selector", "description": "point, rectangle, path 또는 arrow로 target geometry를 표현합니다." }, + { "name": "presentation", "description": "selector와 호환되는 marker, reaction, outline, stroke 또는 arrow 표현 의미입니다." } + ], + "sourcePath": "packages/json-document-editing/src/annotation.ts", + "symbol": "AnnotationDocument" + } + }, + "audits": { + "calendar": { + "status": "audited-tbd", + "denominator": 10, + "horizon": { + "enumerators": [ + "packages/json-document-editing/src/index.ts", + "packages/json-document-calendar/src/index.ts", + "site/src/shared/demo-workbench/demo-sources.ts", + "site/src/app/live-demo-registry.tsx" + ], + "predicate": "Calendar의 model, invariant, semantic operation, projection, Editing lifecycle, platform translation, affordance, Hand와 reusable UI 책임", + "excluded": "fixtures, copy, layout-only CSS, tests and generated route files" + }, + "occurrences": [ + { + "id": "calendar-model", + "role": "Document Model", + "knowledge": "calendar, event, recurrence and calendar membership vocabulary", + "decision": "the canonical JSON shape of a Calendar document", + "changeReason": "Calendar document vocabulary changes", + "stateLifecycle": "immutable document value", + "inputsOutputs": "CalendarDocument and nested JSON values", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "Document Type boundary: model survives without Editing and UI", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "CalendarDocument", + "disposition": "mislocated module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "admit and name the Calendar Document Type owner before moving the public type" + }, + { + "id": "calendar-invariant", + "role": "Validation", + "knowledge": "Calendar document reference and interval invariants", + "decision": "whether a Calendar document is valid", + "changeReason": "Calendar validity rules change", + "stateLifecycle": "stateless validation", + "inputsOutputs": "CalendarDocument to validation failure or success", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "Document Type boundary owns schema and invariants", + "sourcePath": "packages/json-document-editing/src/calendar-validation.ts", + "symbol": "assertCalendarDocument", + "disposition": "mislocated module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "define a stable validation contract at the admitted owner" + }, + { + "id": "calendar-occurrence-projection", + "role": "Projection", + "knowledge": "recurrence expansion and excluded occurrence dates", + "decision": "which occurrences a recurring event produces in a range", + "changeReason": "Calendar recurrence semantics change", + "stateLifecycle": "stateless projection", + "inputsOutputs": "CalendarEvent and range to CalendarOccurrence list", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "Document Type boundary owns projections independent of Editing", + "sourcePath": "packages/json-document-editing/src/calendar-occurrence.ts", + "symbol": "projectCalendarOccurrences", + "disposition": "mislocated module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "separate recurrence projection from occurrence editing transitions" + }, + { + "id": "calendar-semantic-operation", + "role": "Document Operation", + "knowledge": "Calendar event and recurrence mutation meaning", + "decision": "the semantic event update represented by a Calendar operation", + "changeReason": "Calendar mutation semantics change", + "stateLifecycle": "operation value interpreted by Editing", + "inputsOutputs": "CalendarIntent event cases to document patch", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "CalendarIntent currently mixes document operations with selection and clipboard intents", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "CalendarIntent", + "disposition": "missing canonical module", + "intendedOwner": "Calendar Document Type canonical module (missing)", + "nextCheck": "split input-independent Calendar operations from Editing-only intents" + }, + { + "id": "calendar-editor", + "role": "Editing lifecycle", + "knowledge": "selection, clipboard, history and Intent execution", + "decision": "how Calendar editing state transitions are committed", + "changeReason": "Calendar editing workflow changes", + "stateLifecycle": "stateful Editing session", + "inputsOutputs": "EditingDocumentSource and Calendar Intent to EditingResult", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "repository naming standard assigns Intent, Selection, Clipboard and History to Editing", + "sourcePath": "packages/json-document-editing/src/calendar.ts", + "symbol": "createCalendarEditor", + "disposition": "canonical API gap", + "intendedOwner": "@interactive-os/json-document-editing", + "nextCheck": "consume the future Calendar Document Type API instead of locally owning its model and rules" + }, + { + "id": "calendar-pointer-interpretation", + "role": "Affordance", + "knowledge": "input-independent pointer release gesture meaning", + "decision": "which Calendar resize or move intent a gesture means", + "changeReason": "Calendar gesture grammar changes", + "stateLifecycle": "pointer gesture release", + "inputsOutputs": "pointer release value to Calendar intent", + "currentOwner": "@interactive-os/json-document-editing", + "canonicalEvidence": "repository naming standard assigns human manipulation grammar to Affordance", + "sourcePath": "packages/json-document-editing/src/calendar-time-grid-pointer.ts", + "symbol": "interpretCalendarTimeGridPointer", + "disposition": "mislocated module", + "intendedOwner": "@interactive-os/json-document-affordance", + "nextCheck": "compare all-day and month pointer interpreters before choosing one Calendar affordance module" + }, + { + "id": "calendar-web-input", + "role": "Web Adapter", + "knowledge": "DOM keyboard and grid geometry contracts", + "decision": "how Web input becomes Calendar-neutral input values", + "changeReason": "browser input contracts change", + "stateLifecycle": "platform event translation", + "inputsOutputs": "Web event and element geometry to public input values", + "currentOwner": "@interactive-os/json-document-web", + "canonicalEvidence": "repository naming standard assigns platform translation to Adapter", + "sourcePath": "packages/json-document-web/src/calendar-input.ts", + "symbol": "calendarCommandFromWebKeyboardEvent", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-web", + "nextCheck": "preserve Web-only knowledge while Calendar operations are separated" + }, + { + "id": "calendar-hand", + "role": "Hand composition", + "knowledge": "Calendar editor observation and occurrence interaction composition", + "decision": "how Editing, Adapter, Affordance and UI close into a usable Calendar Hand", + "changeReason": "Calendar Hand interaction composition changes", + "stateLifecycle": "React Hand lifecycle", + "inputsOutputs": "CalendarEditor to CalendarHand", + "currentOwner": "@interactive-os/json-document-calendar", + "canonicalEvidence": "package metadata and implementation-shape standard classify this package as Product-facing Hand", + "sourcePath": "packages/json-document-calendar/src/use-calendar-hand.ts", + "symbol": "useCalendarHand", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar", + "nextCheck": "keep composition only after Document Type capabilities move behind a public API" + }, + { + "id": "calendar-month-grid", + "role": "Reusable UI behavior", + "knowledge": "Calendar month-grid rendering and interaction surface", + "decision": "accessible month-grid projection and interaction contract", + "changeReason": "Calendar Hand UI behavior changes", + "stateLifecycle": "React component lifecycle", + "inputsOutputs": "Calendar events, Hand and policies to React UI", + "currentOwner": "@interactive-os/json-document-calendar", + "canonicalEvidence": "public Calendar Hand package owns reusable Calendar UI behavior", + "sourcePath": "packages/json-document-calendar/src/calendar-month-grid.tsx", + "symbol": "CalendarMonthGrid", + "disposition": "canonical consumer", + "intendedOwner": "@interactive-os/json-document-calendar", + "nextCheck": "ensure projection inputs come from the future Document Type API" + }, + { + "id": "calendar-host", + "role": "Host composition", + "knowledge": "demo fixtures, product copy, layout and concrete dependency injection", + "decision": "which Calendar capabilities and policies the demo composes", + "changeReason": "Calendar demo product composition changes", + "stateLifecycle": "route lifecycle", + "inputsOutputs": "fixtures and concrete instances to rendered product page", + "currentOwner": "site Calendar route", + "canonicalEvidence": "live demo registry is the canonical Host enumerator", + "sourcePath": "site/src/routes/calendar-demo/CalendarDemoRoute.tsx", + "symbol": "CalendarDemoRoute", + "disposition": "Host composition", + "intendedOwner": "site Calendar route", + "nextCheck": "audit the runtime import closure for local model, schema, operation or projection implementations" + } + ] + } + } +} diff --git a/docs/evaluate.mjs b/docs/evaluate.mjs index 948bbf9f..50f6b490 100644 --- a/docs/evaluate.mjs +++ b/docs/evaluate.mjs @@ -48,7 +48,10 @@ function fail(message) { const publicDocs = { overview: read("docs/public/overview.md"), + applications: read("docs/public/applications.md"), concepts: read("docs/public/concepts.md"), + foundation: read("docs/public/foundation.md"), + howWeBuild: read("docs/public/how-we-build.md"), documentTypes: read("docs/public/document-types.md"), selection: read("docs/public/selection.md"), history: read("docs/public/history.md"), @@ -199,6 +202,7 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ "affordance.md", "animation.md", "api.md", + "applications.md", "clipboard.md", "collaboration-history.md", "collaboration-lease.md", @@ -217,8 +221,10 @@ if (JSON.stringify(fileNames("docs/public")) !== JSON.stringify([ "connectors.md", "database.md", "document-types.md", + "foundation.md", "hands.md", "history.md", + "how-we-build.md", "intent-guide.md", "intent.md", "llms.txt", diff --git a/docs/public/applications.md b/docs/public/applications.md new file mode 100644 index 00000000..bee9d199 --- /dev/null +++ b/docs/public/applications.md @@ -0,0 +1,38 @@ +# Applications + +Application은 navigation, workflow, runtime과 제품 정책을 소유하는 완성된 제품 +표면입니다. Artifact는 Application과 같은 앱이 아니라, 그 안에서 사람이 만들고 +수정하며 agent와 주고받는 콘텐츠입니다. + +## Calendar + +[Calendar Application](/applications/calendar)은 Calendar Document Type, Editing, +Calendar Hand와 UI primitives를 day·week·month·year 제품 경험으로 조합합니다. + +```text +Calendar Application +├─ Calendar Document Type · event, recurrence, interval +├─ Calendar Hand · selection, create, move, resize, history +├─ Calendar UI · grids, inspector, date controls +└─ App-owned · navigation, URL state, copy, fixture, layout +``` + +Calendar라는 이름 아래의 모든 코드를 App이 소유하지 않습니다. 재사용 책임은 +각 canonical package에 남고 Application은 제품 조합과 정책만 소유합니다. + +## AI Agent + +[AI Agent Application](/applications/ai-agent)은 session runtime에 Composer, +Markdown, AG-UI와 A2UI projection을 조합합니다. + +```text +AI Agent Application +├─ Composer · Mention Hands +├─ Markdown · Rich Text projection +├─ AG-UI → A2UI integration +└─ App-owned · session navigation, runtime connection, shell, policy +``` + +두 Application은 showcase가 아니라 책임을 발견하고 canonical API가 실제 제품에서 +다시 소비되는지 검증하는 production composition root입니다. 개발 순환은 +[How We Build](/docs/how-we-build)에서 설명합니다. diff --git a/docs/public/concepts.md b/docs/public/concepts.md index 8966ac70..ac0a41c6 100644 --- a/docs/public/concepts.md +++ b/docs/public/concepts.md @@ -17,7 +17,9 @@ Affordance ─ input grammar ─┐ UI Primitives ─ standard UI ├─ Host가 장르별 Hands를 조합 Rich Text 등 domain ────────┘ -Hands를 제품 surface에 조합한 결과가 사람이 다루는 Artifact가 됩니다. +Hands를 surface에 조합한 결과가 사람이 다루는 Artifact가 됩니다. Artifact는 +navigation이나 workflow를 소유하지 않는 콘텐츠입니다. Application은 Artifact와 +다른 콘텐츠를 runtime과 제품 정책에 놓아 실제 제품 경험으로 제공합니다. ``` 이 그림의 선은 허용된 의존·조합 방향입니다. 모든 노드를 순서대로 설치하라는 @@ -25,10 +27,22 @@ Hands를 제품 surface에 조합한 결과가 사람이 다루는 Artifact가 외부 라이브러리가 필요할 때 고릅니다. Collaboration은 다음 계층이 아니라 같은 `JSONDocument` 계약의 다른 구현입니다. -권장 읽기 순서는 `JSON Document → Document Types → Editing → Adapter → Connector → Affordance -→ UI Primitives → Hands → Artifact`입니다. 이 순서는 학습을 위한 서사일 뿐 -package dependency를 주장하지 않습니다. Collaboration은 Core의 대체 구현과 -profile 포함 관계를 따로 보기 위해 별도 묶음에서 읽습니다. +권장 읽기 순서는 `Foundation → Building Blocks → Hands → Artifact → Application`입니다. +Foundation 안에서는 JSON Document, Document Types, Editing과 Collaboration을, +Building Blocks에서는 Adapter, Connector, Affordance와 UI Primitives를 읽습니다. +이 순서는 학습을 위한 서사일 뿐 package dependency를 주장하지 않습니다. +Collaboration은 Core의 대체 구현과 profile 포함 관계로 Foundation 안에서 읽습니다. + +프로젝트가 책임을 발견하는 방향은 이 읽기·구현 방향과 반대입니다. + +```text +구현 의존: Foundation → Building Blocks → Hands → Artifact → Application +책임 발견: Application → 책임 발견 → Canonical Module → Application +``` + +먼저 제품을 만들고 실제 사용 흐름에서 반복되는 책임을 찾습니다. 추출된 책임은 +canonical owner와 public API를 얻고, Application은 임시 구현 대신 그 API를 다시 +소비합니다. 자세한 순환은 [How We Build](how-we-build.md)에서 설명합니다. ## JSON Document @@ -107,8 +121,8 @@ Host 조합에서 함께 동작해야 닫힙니다. 재사용 책임은 owner pa ## Artifact -Artifact는 다음 책임 계층이 아니라 앞의 책임을 조합해 사람이 보고 고칠 수 -있게 만든 결과입니다. +Artifact는 독립 App이 아니라 앞의 책임을 조합해 사람이 보고 고칠 수 있게 만든 +Application 내부 콘텐츠입니다. navigation, workflow와 제품 정책은 소유하지 않습니다. MD, PPT, Sheet는 서로 다른 화면과 Hands를 사용해도 같은 문서와 편집 계약을 공유할 수 있습니다. @@ -117,13 +131,25 @@ MD, PPT, Sheet는 서로 다른 화면과 Hands를 사용해도 같은 문서와 놓는 정보 구조와 시각 가설만 확인하며, 실제 계약 증거는 각 Hands Live Demo와 package test에서 봅니다. +## Application + +Application은 Artifact와 Hands를 실제 제품 경험으로 제공하는 최종 composition +root입니다. 주요 화면 영역과 실행 순서, URL과 navigation, 제품 copy와 fixture, +concrete runtime 연결은 Application에 남습니다. 문서의 의미, editing lifecycle, +platform translation과 반복 UI처럼 같은 역할과 책임을 갖는 코드는 canonical +module로 추출됩니다. + +[Calendar와 AI Agent](/applications)는 제품에서 발견한 책임과 App에 남은 정책을 +함께 보여 줍니다. Calendar Document Type, Calendar Hand와 Calendar Application은 +같은 이름을 공유하지만 서로 다른 owner입니다. + ## Collaboration Collaboration은 JSON Document 계약을 여러 참여자의 인과 변경으로 구현합니다. 로컬 구현과 마찬가지로 값을 읽고, 변경을 적용하고, 결과를 구독하지만 내부 기록은 참여자의 변경 순서와 수렴을 다룹니다. -위치가 Artifact 다음인 것은 의존 방향이 아니라 문서 분류를 나타냅니다. +Collaboration은 Foundation 안에서 JSON Document와 같은 계약의 대체 구현으로 읽습니다. 협업 document를 Editing에 주입할 수 있지만 History command는 editor-local History 대신 actor-local `runtime.history`로 연결해야 합니다. base → History → Text profile의 포함 관계는 [Collaboration](collaboration.md)에 있습니다. diff --git a/docs/public/foundation.md b/docs/public/foundation.md new file mode 100644 index 00000000..f53683ca --- /dev/null +++ b/docs/public/foundation.md @@ -0,0 +1,32 @@ +# Foundation + +Foundation은 Application, Artifact와 Hands가 공유하는 기반 계약입니다. 화면이나 +제품 장르보다 먼저 값의 의미, 변경, 편집 상태와 협업 방식을 정의합니다. + +## JSON Document + +표의 셀과 문서의 블록은 생김새가 달라도 JSON 안에서 주소를 가집니다. 한 위치는 +JSON Pointer로 가리키고 여러 위치는 JSONPath로 찾으며, 변경은 JSON Patch로 +표현합니다. `JSONDocument`는 현재 값을 읽고, 찾고, 검증하고, 원자적으로 적용하고, +실제로 달라진 결과를 구독자에게 전달하는 공통 계약입니다. + +## Document Types + +Rich Text, Calendar, Database 같은 Document Type은 Foundation 위에서 데이터의 +의미와 유효한 구조를 정의합니다. 같은 이름을 쓰는 Hand나 Application과는 별도 +책임이며, 제품 화면이나 navigation을 소유하지 않습니다. + +## Editing + +선택, 보이는 순서, clipboard와 history처럼 편집하는 동안만 필요한 상태는 문서 값 +옆에 둡니다. 화면 사건은 Intent가 되고 Editing은 현재 문서와 편집 상태를 읽어 +처리합니다. + +## Collaboration + +협업은 다음 UI 계층이 아니라 같은 `JSONDocument` 계약의 다른 구현입니다. 여러 +참여자의 변경을 인과 순서로 수렴시키면서도 Foundation의 읽기·변경·구독 진입점을 +유지합니다. + +다음으로 플랫폼과 생태계 연결을 고르려면 [Building Blocks](adapters.md)를, +전체 개념 관계를 먼저 보려면 [Concept Map](concepts.md)을 읽습니다. diff --git a/docs/public/how-we-build.md b/docs/public/how-we-build.md new file mode 100644 index 00000000..61852375 --- /dev/null +++ b/docs/public/how-we-build.md @@ -0,0 +1,46 @@ +# 제품에서 정본 모듈을 발견하는 방법 + +json-document는 추상 계층을 먼저 완성한 뒤 제품에 적용하지 않습니다. Calendar나 +AI Agent 같은 Application을 먼저 만들고, 실제 사용 흐름에서 반복되는 책임을 +발견해 canonical module로 추출합니다. 제품은 추출된 공개 API를 다시 소비하며 +경계를 검증합니다. + +```text +Application을 만든다 + ↓ +실제 제품 사건과 반복 책임을 관찰한다 + ↓ +Document Type · Editing · Adapter · UI 책임을 분리한다 + ↓ +canonical module과 public API로 정본화한다 + ↓ +Application이 정본 API를 다시 소비한다 +``` + +구현 의존 방향과 책임을 발견하는 방향은 서로 반대입니다. + +```text +구현 의존: Foundation → Building Blocks → Hands → Artifact → Application +책임 발견: Application → 책임 발견 → Canonical Module → Application +``` + +여기서 Artifact는 독립 App이 아니라 Application이 만들고 편집하는 콘텐츠입니다. +Navigation, workflow, runtime과 제품 정책은 Application에 남습니다. + +## Application에 남는 것 + +Application은 화면의 주요 영역과 실행 순서, URL과 navigation, 제품 copy, +permission, fixture, concrete runtime 연결을 소유합니다. 제품 전체를 제거했을 때 +함께 사라지는 정책입니다. + +## 모듈로 추출하는 것 + +문서의 의미와 유효성, selection과 history, 입력 번역, 반복되는 UI 동작처럼 +제품 밖에서도 같은 역할과 책임을 갖는 코드는 canonical owner로 이동합니다. +한 Application에서만 발견됐더라도 독립적인 책임이면 이름과 경계를 갖습니다. + +## 다시 제품으로 돌아오기 + +추출은 복사본을 하나 더 만드는 일이 아닙니다. Application의 임시 구현을 제거하고 +canonical public API를 소비해야 순환이 닫힙니다. [Applications](/applications)는 +각 제품에 남은 정책과 추출된 책임을 함께 보여 줍니다. diff --git a/site/package.json b/site/package.json index 1aa80209..8274078e 100644 --- a/site/package.json +++ b/site/package.json @@ -14,7 +14,7 @@ "check:primitives": "node scripts/check-live-demo-primitives.mjs", "check:ui-roles": "node scripts/check-ui-role-primitives.mjs", "check:choice-id": "node scripts/check-choice-id.mjs", - "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs", + "check:canonical-modules": "node scripts/check-canonical-module-closure.mjs && node scripts/check-document-type-audits.mjs && node scripts/check-documentation-page.mjs", "check:interaction-handles": "node scripts/check-interaction-handles.mjs", "check:contextual-affordance": "node scripts/check-contextual-affordance.mjs", "check:product-shell-toolbar": "node scripts/check-product-shell-toolbar.mjs", diff --git a/site/scripts/check-document-type-audits.mjs b/site/scripts/check-document-type-audits.mjs new file mode 100644 index 00000000..5cd7b6d0 --- /dev/null +++ b/site/scripts/check-document-type-audits.mjs @@ -0,0 +1,58 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("../..", import.meta.url).pathname; +const ledger = JSON.parse(readFileSync(join(root, "audits/document-types.json"), "utf8")); +const siteRoutes = JSON.parse(readFileSync(join(root, "site/site-routes.json"), "utf8")); +const allowed = new Set(["canonical consumer", "Host composition", "duplicate implementation", "canonical API gap", "missing canonical module", "mislocated module", "out of scope", "unverified"]); +const expectedCandidates = siteRoutes + .filter((route) => route.navigationGroup === "Document Types" && route.path !== "/docs/document-types") + .map((route) => route.path.slice("/docs/document-types/".length)); + +if (JSON.stringify(ledger.candidates) !== JSON.stringify(expectedCandidates)) throw new Error("Document Type audit candidates do not match the TBD navigation denominator"); + +for (const candidate of ledger.candidates) { + const profile = ledger.candidateProfiles[candidate]; + if (profile === undefined) throw new Error(`${candidate} is missing its candidate profile`); + for (const field of ["why", "does", "schema", "sourcePath", "symbol"]) { + if (typeof profile[field] !== "string" || profile[field].trim() === "") throw new Error(`${candidate} candidate profile is missing ${field}`); + } + if (!Array.isArray(profile.fields) || profile.fields.length === 0) throw new Error(`${candidate} candidate profile is missing field descriptions`); + for (const field of profile.fields) { + if (typeof field.name !== "string" || field.name.trim() === "" || typeof field.description !== "string" || field.description.trim() === "") { + throw new Error(`${candidate} candidate profile has an incomplete field description`); + } + } + const sourceFile = join(root, profile.sourcePath); + if (!existsSync(sourceFile)) throw new Error(`${candidate} candidate profile source does not exist: ${profile.sourcePath}`); + if (!new RegExp(`\\b${profile.symbol}\\b`).test(readFileSync(sourceFile, "utf8"))) throw new Error(`${candidate} candidate profile symbol does not exist: ${profile.symbol}`); +} + +for (const [candidate, audit] of Object.entries(ledger.audits)) { + if (!ledger.candidates.includes(candidate)) throw new Error(`unknown Document Type audit: ${candidate}`); + if (audit.denominator !== audit.occurrences.length) throw new Error(`${candidate} audit denominator mismatch: expected ${audit.denominator}, found ${audit.occurrences.length}`); + const ids = new Set(); + for (const occurrence of audit.occurrences) { + if (ids.has(occurrence.id)) throw new Error(`${candidate} duplicate occurrence id: ${occurrence.id}`); + ids.add(occurrence.id); + if (!allowed.has(occurrence.disposition)) throw new Error(`${candidate}/${occurrence.id} has invalid disposition: ${occurrence.disposition}`); + for (const field of ["role", "knowledge", "decision", "changeReason", "stateLifecycle", "inputsOutputs", "currentOwner", "canonicalEvidence", "sourcePath", "intendedOwner", "nextCheck"]) { + if (typeof occurrence[field] !== "string" || occurrence[field].trim() === "") throw new Error(`${candidate}/${occurrence.id} is missing ${field}`); + } + const sourceFile = join(root, occurrence.sourcePath); + if (!existsSync(sourceFile)) throw new Error(`${candidate}/${occurrence.id} source does not exist: ${occurrence.sourcePath}`); + if (occurrence.symbol && !new RegExp(`\\b${occurrence.symbol}\\b`).test(readFileSync(sourceFile, "utf8"))) { + throw new Error(`${candidate}/${occurrence.id} symbol does not exist: ${occurrence.symbol}`); + } + } +} + +const calendar = ledger.audits.calendar; +for (const role of ["Document Model", "Validation", "Projection", "Document Operation", "Editing lifecycle", "Affordance", "Web Adapter", "Hand composition", "Reusable UI behavior", "Host composition"]) { + if (!calendar.occurrences.some((occurrence) => occurrence.role === role)) throw new Error(`Calendar audit is missing role: ${role}`); +} +if (calendar.status !== "audited-tbd" || !calendar.occurrences.some((occurrence) => !["canonical consumer", "Host composition"].includes(occurrence.disposition))) { + throw new Error("Calendar must remain audited-tbd while nonconforming occurrences remain"); +} + +console.log(`Document Type audits ok; candidates=${ledger.candidates.length}; candidate profiles=${Object.keys(ledger.candidateProfiles).length}; audited=${Object.keys(ledger.audits).length}; Calendar occurrences=${calendar.occurrences.length}.`); diff --git a/site/scripts/check-documentation-page.mjs b/site/scripts/check-documentation-page.mjs new file mode 100644 index 00000000..2d3c047f --- /dev/null +++ b/site/scripts/check-documentation-page.mjs @@ -0,0 +1,27 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("../..", import.meta.url).pathname; +const docsRoot = join(root, "site/src/routes/docs"); +const canonicalOwner = "DocumentationPage.tsx"; +const consumers = ["DocsRoute.tsx", "ConceptsRoute.tsx", "DocumentTypeCandidateRoute.tsx"]; + +for (const name of readdirSync(docsRoot).filter((entry) => entry.endsWith(".tsx"))) { + const source = readFileSync(join(docsRoot, name), "utf8"); + if (name !== canonicalOwner && name !== "MarkdownViewer.tsx" && /(?:MarkdownViewer|markdownHeadings)/.test(source)) { + throw new Error(`${name} bypasses the canonical DocumentationPage composition`); + } +} + +const owner = readFileSync(join(docsRoot, canonicalOwner), "utf8"); +for (const contract of ["PageFrame", "PageHeader", "MarkdownViewer", "markdownHeadings", "Documentation sections", "On this page", "max-w-3xl"]) { + if (!owner.includes(contract)) throw new Error(`DocumentationPage is missing canonical contract: ${contract}`); +} +for (const consumer of consumers) { + const source = readFileSync(join(docsRoot, consumer), "utf8"); + if (!source.includes('from "./DocumentationPage"') || !source.includes(" = Object.fromEntries( - siteLayers.map((layer) => [layer.group, { path: layer.path, label: layer.label }]), -) as Record; +const groupLandings: Record = { + Introduction: { path: "/docs", label: "Introduce" }, + "JSON Document": { path: "/docs/foundation", label: "JSON Document" }, + "Document Types": { path: "/docs/document-types", label: "Document Types" }, + Editing: { path: "/docs/intent-guide", label: "Editing" }, + Collaboration: { path: "/docs/collaboration", label: "Collaboration" }, + Adapter: { path: "/docs/adapters", label: "Platform Adapters" }, + Connector: { path: "/docs/connectors", label: "Ecosystem Connectors" }, + Affordance: { path: "/docs/affordance", label: "Affordances" }, + "UI Primitives": { path: "/docs/ui-primitives", label: "UI Primitives" }, + Hands: { path: "/editors", label: "Hands" }, + Artifact: { path: "/viewer", label: "Artifact" }, + Applications: { path: "/applications", label: "Applications" }, +}; export function breadcrumbTrail( route: SiteRoute, @@ -31,17 +42,31 @@ export function breadcrumbTrail( : undefined; } + const directSection = siteSections.find((section) => section.path === route.path && section.groups.length === 0); const group = routeGroup(route, routes); - if (group) { + if (directSection) { + stack[0] = { path: directSection.path, label: directSection.label }; + } else if (group) { + const section = sectionForGroup(group); const landing = groupLandings[group]; - if (stack[0]?.path === landing.path && stack[0]?.label === "Overview") stack[0] = landing; - else if (stack[0]?.label !== group) stack.unshift(landing); + if (stack[0]?.path === section.path) stack[0] = { path: section.path, label: section.label }; + else { + if (landing.path !== section.path && stack[0]?.path !== landing.path) stack.unshift(landing); + stack.unshift({ path: section.path, label: section.label }); + } } if (stack[0]?.path !== overview.path) stack.unshift(overview); return stack; } +export function routeSection(route: SiteRoute, routes: ReadonlyArray): SiteSection | undefined { + const direct = siteSections.find((section) => section.path === route.path); + if (direct) return direct; + const group = routeGroup(route, routes); + return group ? sectionForGroup(group) : undefined; +} + function crumbLabel(route: SiteRoute): string { return route.label; } @@ -94,6 +119,7 @@ export function rootNavRoutes(routes: ReadonlyArray): ReadonlyArray> = { - "JSON Document": { icon: Braces, size: 19 }, - "Document Types": { icon: FileType2, size: 19 }, - Editing: { icon: PencilLine, size: 18 }, - Adapter: { icon: Cable, size: 19 }, - Connector: { icon: Link2, size: 21 }, - Affordance: { icon: MousePointer2, size: 20 }, - "UI Primitives": { icon: Blocks, size: 18 }, - Hands: { icon: Hand, size: 19 }, - Artifact: { icon: Files, size: 18 }, - Collaboration: { icon: UsersRound, size: 19 }, +const layerIcons: Readonly> = { + introduce: { icon: BookOpen, size: 18 }, + foundation: { icon: Braces, size: 19 }, + "building-blocks": { icon: Blocks, size: 18 }, + hands: { icon: Hand, size: 19 }, + artifact: { icon: Files, size: 18 }, + applications: { icon: PanelsTopLeft, size: 19 }, + reference: { icon: Library, size: 18 }, }; export function NavigationLayerIcon(props: { - readonly group: SiteNavigationGroup; + readonly section: SiteSectionId; readonly className?: string; }) { - const layerIcon = layerIcons[props.group]; + const layerIcon = layerIcons[props.section]; const Icon = layerIcon.icon; return