@maias/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/graph.js ADDED
@@ -0,0 +1,93 @@
1
+ /** All screen-to-screen references declared on a screen (nav items, elements, state elements). */
2
+ export function outboundRefs(screen, screenIndex) {
3
+ const refs = [];
4
+ const base = ['screens', screenIndex];
5
+ if (typeof screen.back?.target === 'string') {
6
+ refs.push({
7
+ target: screen.back.target,
8
+ kind: 'back',
9
+ label: screen.back.label,
10
+ path: [...base, 'back', 'target'],
11
+ });
12
+ }
13
+ for (const group of ['primary', 'secondary']) {
14
+ (screen.navigation?.[group] ?? []).forEach((item, i) => {
15
+ if (typeof item?.target === 'string') {
16
+ refs.push({
17
+ target: item.target,
18
+ kind: `navigation.${group}`,
19
+ label: item.label,
20
+ path: [...base, 'navigation', group, i, 'target'],
21
+ });
22
+ }
23
+ });
24
+ }
25
+ const collectElements = (elements, path, kind) => {
26
+ (elements ?? []).forEach((el, i) => {
27
+ if (typeof el?.target === 'string') {
28
+ refs.push({ target: el.target, kind, label: el.label, path: [...path, i, 'target'] });
29
+ }
30
+ });
31
+ };
32
+ collectElements(screen.elements, [...base, 'elements'], 'element');
33
+ for (const state of ['empty', 'loading', 'error']) {
34
+ collectElements(screen.states?.[state]?.elements, [...base, 'states', state, 'elements'], 'state_element');
35
+ }
36
+ return refs;
37
+ }
38
+ /** Index of screens by ID. First occurrence wins when IDs are duplicated (lint reports the duplicate). */
39
+ export function screenIndex(doc) {
40
+ const map = new Map();
41
+ for (const screen of doc.screens ?? []) {
42
+ if (screen?.id && !map.has(screen.id))
43
+ map.set(screen.id, screen);
44
+ }
45
+ return map;
46
+ }
47
+ export function getScreen(doc, id) {
48
+ return screenIndex(doc).get(id);
49
+ }
50
+ /**
51
+ * Screens reachable from the app's structure (spec §3.3): primary navigation,
52
+ * flow entry screens, then transitively via outbound references.
53
+ */
54
+ export function reachableScreens(doc) {
55
+ const index = screenIndex(doc);
56
+ const queue = [
57
+ ...(doc.app?.navigation?.primary?.screens ?? []),
58
+ ...(doc.app?.flows ?? []).map((f) => f?.entry_screen),
59
+ ].filter((id) => typeof id === 'string' && index.has(id));
60
+ const reachable = new Set();
61
+ while (queue.length > 0) {
62
+ const id = queue.pop();
63
+ if (reachable.has(id))
64
+ continue;
65
+ reachable.add(id);
66
+ const screen = index.get(id);
67
+ const idx = (doc.screens ?? []).indexOf(screen);
68
+ for (const ref of outboundRefs(screen, idx)) {
69
+ if (index.has(ref.target) && !reachable.has(ref.target))
70
+ queue.push(ref.target);
71
+ }
72
+ }
73
+ return reachable;
74
+ }
75
+ /** Screens in no flow AND unreachable (spec §3.2). */
76
+ export function orphanScreens(doc) {
77
+ const inFlows = new Set((doc.app?.flows ?? []).flatMap((f) => f?.screens ?? []));
78
+ const reachable = reachableScreens(doc);
79
+ return (doc.screens ?? [])
80
+ .map((s) => s?.id)
81
+ .filter((id) => typeof id === 'string' && !inFlows.has(id) && !reachable.has(id));
82
+ }
83
+ /** All references across the document pointing at `id`. */
84
+ export function whatLinksHere(doc, id) {
85
+ const refs = [];
86
+ (doc.screens ?? []).forEach((screen, i) => {
87
+ for (const ref of outboundRefs(screen, i)) {
88
+ if (ref.target === id)
89
+ refs.push({ ...ref, from: screen.id });
90
+ }
91
+ });
92
+ return refs;
93
+ }
@@ -0,0 +1,9 @@
1
+ export * from './model.js';
2
+ export * from './diagnostics.js';
3
+ export { parse, ParsedDocument, type ParseResult, type SourceFormat } from './parse.js';
4
+ export { validate, type ValidationResult } from './validate.js';
5
+ export { lint } from './lint.js';
6
+ export { schemaValidate } from './schema-validate.js';
7
+ export { getScreen, orphanScreens, outboundRefs, reachableScreens, screenIndex, whatLinksHere, type InboundRef, type OutboundRef, } from './graph.js';
8
+ export { addScreen, editElements, removeScreen, renameScreen, type AddScreenOptions, type EditResult, type ElementGuard, type ElementOp, type ElementPatch, type ElementStateKey, type RemoveScreenOptions, type RemoveScreenResult, } from './edit.js';
9
+ export { canonicalize, format } from './fmt.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * from './model.js';
2
+ export * from './diagnostics.js';
3
+ export { parse, ParsedDocument } from './parse.js';
4
+ export { validate } from './validate.js';
5
+ export { lint } from './lint.js';
6
+ export { schemaValidate } from './schema-validate.js';
7
+ export { getScreen, orphanScreens, outboundRefs, reachableScreens, screenIndex, whatLinksHere, } from './graph.js';
8
+ export { addScreen, editElements, removeScreen, renameScreen, } from './edit.js';
9
+ export { canonicalize, format } from './fmt.js';
package/dist/lint.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { type Diagnostic } from './diagnostics.js';
2
+ import type { ParsedDocument } from './parse.js';
3
+ /**
4
+ * Semantic lint (spec rules marked [lint]). Runs on the plain-data view and is
5
+ * defensive about shape: it assumes nothing the schema hasn't guaranteed, so it
6
+ * can produce useful findings even alongside schema errors.
7
+ */
8
+ export declare function lint(parsed: ParsedDocument): Diagnostic[];
package/dist/lint.js ADDED
@@ -0,0 +1,176 @@
1
+ import { CORE_ELEMENT_TYPES } from './model.js';
2
+ import { CODES } from './diagnostics.js';
3
+ import { orphanScreens, outboundRefs, reachableScreens } from './graph.js';
4
+ /**
5
+ * Semantic lint (spec rules marked [lint]). Runs on the plain-data view and is
6
+ * defensive about shape: it assumes nothing the schema hasn't guaranteed, so it
7
+ * can produce useful findings even alongside schema errors.
8
+ */
9
+ export function lint(parsed) {
10
+ const doc = parsed.data;
11
+ const out = [];
12
+ const at = (path, code, severity, message) => out.push({ code, severity, message, path, ...parsed.position(path) });
13
+ const screens = Array.isArray(doc?.screens) ? doc.screens : [];
14
+ const flows = Array.isArray(doc?.app?.flows) ? doc.app.flows : [];
15
+ const ids = new Set(screens.map((s) => s?.id).filter((id) => typeof id === 'string'));
16
+ // E001 duplicate screen IDs
17
+ const seenIds = new Map();
18
+ screens.forEach((screen, i) => {
19
+ if (typeof screen?.id !== 'string')
20
+ return;
21
+ if (seenIds.has(screen.id)) {
22
+ at(['screens', i, 'id'], CODES.DUPLICATE_ID, 'error', `Duplicate screen id '${screen.id}' (first defined at screens[${seenIds.get(screen.id)}])`);
23
+ }
24
+ else {
25
+ seenIds.set(screen.id, i);
26
+ }
27
+ });
28
+ // E002 duplicate / colliding paths (parameter segments collide regardless of name)
29
+ const seenPaths = new Map();
30
+ screens.forEach((screen, i) => {
31
+ if (typeof screen?.path !== 'string')
32
+ return;
33
+ const shape = screen.path.replace(/:[a-z][a-z0-9_]*/g, ':param');
34
+ const prev = seenPaths.get(shape);
35
+ if (prev) {
36
+ at(['screens', i, 'path'], CODES.DUPLICATE_PATH, 'error', `Path '${screen.path}' collides with '${prev.id}' — two screens would match the same URL`);
37
+ }
38
+ else {
39
+ seenPaths.set(shape, { id: screen.id, index: i });
40
+ }
41
+ });
42
+ // E003 dangling targets + W004 self-targets — the register-apple class of bug
43
+ screens.forEach((screen, i) => {
44
+ for (const ref of outboundRefs(screen, i)) {
45
+ if (!ids.has(ref.target)) {
46
+ at(ref.path, CODES.DANGLING_TARGET, 'error', `Dangling target '${ref.target}' on screen '${screen.id}' (${ref.kind}${ref.label ? ` '${ref.label}'` : ''}) — no such screen`);
47
+ }
48
+ else if (ref.target === screen.id) {
49
+ at(ref.path, CODES.SELF_TARGET, 'warning', `Screen '${screen.id}' targets itself (${ref.kind}${ref.label ? ` '${ref.label}'` : ''})`);
50
+ }
51
+ }
52
+ });
53
+ // E004/E005/E009 flow integrity
54
+ const seenFlows = new Set();
55
+ flows.forEach((flow, i) => {
56
+ if (typeof flow?.name === 'string') {
57
+ if (seenFlows.has(flow.name))
58
+ at(['app', 'flows', i, 'name'], CODES.DUPLICATE_FLOW, 'error', `Duplicate flow name '${flow.name}'`);
59
+ seenFlows.add(flow.name);
60
+ }
61
+ const flowScreens = Array.isArray(flow?.screens) ? flow.screens : [];
62
+ if (typeof flow?.entry_screen === 'string') {
63
+ if (!ids.has(flow.entry_screen)) {
64
+ at(['app', 'flows', i, 'entry_screen'], CODES.FLOW_ENTRY, 'error', `Flow '${flow.name}' entry_screen '${flow.entry_screen}' does not exist`);
65
+ }
66
+ else if (!flowScreens.includes(flow.entry_screen)) {
67
+ at(['app', 'flows', i, 'entry_screen'], CODES.FLOW_ENTRY, 'error', `Flow '${flow.name}' entry_screen '${flow.entry_screen}' is not listed in its screens`);
68
+ }
69
+ }
70
+ flowScreens.forEach((id, j) => {
71
+ if (typeof id === 'string' && !ids.has(id)) {
72
+ at(['app', 'flows', i, 'screens', j], CODES.FLOW_UNKNOWN_SCREEN, 'error', `Flow '${flow.name}' lists unknown screen '${id}'`);
73
+ }
74
+ });
75
+ });
76
+ // E006/E007/E008 navigation registry membership
77
+ const primary = Array.isArray(doc?.app?.navigation?.primary?.screens) ? doc.app.navigation.primary.screens : [];
78
+ const secondary = Array.isArray(doc?.app?.navigation?.secondary?.screens) ? doc.app.navigation.secondary.screens : [];
79
+ const primarySet = new Set(primary);
80
+ const secondarySet = new Set(secondary);
81
+ ['primary', 'secondary'].forEach((group) => {
82
+ (group === 'primary' ? primary : secondary).forEach((id, j) => {
83
+ if (typeof id === 'string' && !ids.has(id)) {
84
+ at(['app', 'navigation', group, 'screens', j], CODES.REGISTRY_UNKNOWN_SCREEN, 'error', `Navigation registry (${group}) lists unknown screen '${id}'`);
85
+ }
86
+ });
87
+ });
88
+ screens.forEach((screen, i) => {
89
+ if (typeof screen?.id !== 'string')
90
+ return;
91
+ const inPrimary = primarySet.has(screen.id);
92
+ const inSecondary = secondarySet.has(screen.id);
93
+ if (!inPrimary && !inSecondary) {
94
+ at(['screens', i, 'id'], CODES.UNREGISTERED_SCREEN, 'error', `Screen '${screen.id}' is missing from the navigation registry — add it to app.navigation primary.screens or secondary.screens`);
95
+ }
96
+ else if (inPrimary && inSecondary) {
97
+ at(['screens', i, 'id'], CODES.DOUBLY_REGISTERED_SCREEN, 'error', `Screen '${screen.id}' is in both primary.screens and secondary.screens — it must be in exactly one`);
98
+ }
99
+ });
100
+ // W001 missing descriptions
101
+ screens.forEach((screen, i) => {
102
+ if (typeof screen?.id === 'string' && !screen.description) {
103
+ at(['screens', i], CODES.MISSING_DESCRIPTION, 'warning', `Screen '${screen.id}' has no description — intent should travel with structure (spec §1.5)`);
104
+ }
105
+ });
106
+ // W002/W003 reachability & orphans
107
+ const reachable = reachableScreens(doc);
108
+ const orphans = new Set(orphanScreens(doc));
109
+ screens.forEach((screen, i) => {
110
+ if (typeof screen?.id !== 'string' || reachable.has(screen.id))
111
+ return;
112
+ if (orphans.has(screen.id)) {
113
+ at(['screens', i], CODES.ORPHAN_SCREEN, 'warning', `Screen '${screen.id}' is an orphan — in no flow and unreachable from any entry point or primary navigation`);
114
+ }
115
+ else {
116
+ at(['screens', i], CODES.UNREACHABLE_SCREEN, 'warning', `Screen '${screen.id}' is unreachable — no navigation path leads to it`);
117
+ }
118
+ });
119
+ // W005 unknown element types without x_ prefix (probable typo; never an error — spec §10.1)
120
+ const coreTypes = new Set(CORE_ELEMENT_TYPES);
121
+ const checkElements = (elements, base, screenId) => {
122
+ (elements ?? []).forEach((el, j) => {
123
+ if (typeof el?.type === 'string' && !coreTypes.has(el.type) && !el.type.startsWith('x_')) {
124
+ at([...base, j, 'type'], CODES.UNKNOWN_ELEMENT_TYPE, 'warning', `Unknown element type '${el.type}' on screen '${screenId}' — not in the core taxonomy and not x_-prefixed (typo? custom types should be x_<vendor>_<name>)`);
125
+ }
126
+ });
127
+ };
128
+ screens.forEach((screen, i) => {
129
+ if (typeof screen?.id !== 'string')
130
+ return;
131
+ checkElements(screen.elements, ['screens', i, 'elements'], screen.id);
132
+ for (const state of ['empty', 'loading', 'error']) {
133
+ checkElements(screen.states?.[state]?.elements, ['screens', i, 'states', state, 'elements'], screen.id);
134
+ }
135
+ });
136
+ // W006 deep_link without an app scheme
137
+ if (!doc?.app?.links?.scheme) {
138
+ screens.forEach((screen, i) => {
139
+ if (screen?.deep_link === true) {
140
+ at(['screens', i, 'deep_link'], CODES.DEEP_LINK_NO_SCHEME, 'warning', `Screen '${screen.id}' declares deep_link but app.links.scheme is not set`);
141
+ }
142
+ });
143
+ }
144
+ // W007 navigation.primary on non-primary screens / inconsistent with the registry
145
+ screens.forEach((screen, i) => {
146
+ const items = screen?.navigation?.primary;
147
+ if (!Array.isArray(items) || items.length === 0 || typeof screen?.id !== 'string')
148
+ return;
149
+ if (!primarySet.has(screen.id)) {
150
+ at(['screens', i, 'navigation', 'primary'], CODES.PRIMARY_NAV_MISUSE, 'warning', `Screen '${screen.id}' declares navigation.primary but is not a primary screen — use navigation.secondary for contextual links`);
151
+ return;
152
+ }
153
+ const declared = items.map((it) => it?.target).filter((t) => typeof t === 'string');
154
+ const mismatch = declared.filter((t) => !primarySet.has(t));
155
+ if (mismatch.length > 0) {
156
+ at(['screens', i, 'navigation', 'primary'], CODES.PRIMARY_NAV_MISUSE, 'warning', `Screen '${screen.id}' navigation.primary targets [${mismatch.join(', ')}] not in app.navigation.primary.screens`);
157
+ }
158
+ });
159
+ // W008 action_sheet screens should present as sheet
160
+ screens.forEach((screen, i) => {
161
+ if (screen?.type === 'action_sheet' && !screen.presentation) {
162
+ at(['screens', i], CODES.SHEET_PRESENTATION, 'warning', `Screen '${screen.id}' is an action_sheet but declares no presentation — add 'presentation: sheet' (spec §4.4)`);
163
+ }
164
+ });
165
+ // W009 1.1-only features under a 1.0 version header (D28). The version
166
+ // header is a declaration of the feature set in use — minor versions are
167
+ // additive, so the fix is always "declare the higher version".
168
+ if (doc?.maias === '1.0') {
169
+ screens.forEach((screen, i) => {
170
+ if (screen?.back !== undefined) {
171
+ at(['screens', i, 'back'], CODES.VERSION_FEATURE_MISMATCH, 'warning', `Screen '${screen.id}' uses 'back' — a 1.1 feature — but the document declares maias: "1.0"; declare maias: "1.1"`);
172
+ }
173
+ });
174
+ }
175
+ return out;
176
+ }
@@ -0,0 +1 @@
1
+ export declare const MAIAS_SCHEMA: Record<string, unknown>;