@llui/vite-plugin 0.0.30 → 0.0.33

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.
@@ -1,8 +1,39 @@
1
+ export type DispatchMode = 'shared' | 'human-only' | 'agent-only';
1
2
  export type MessageAnnotations = {
2
3
  intent: string | null;
3
4
  alwaysAffordable: boolean;
4
5
  requiresConfirm: boolean;
5
- humanOnly: boolean;
6
+ dispatchMode: DispatchMode;
7
+ /**
8
+ * Concrete example dispatches the LLM can copy from. Populated by
9
+ * `@example("text")` JSDoc tags. Each tag becomes one entry, in
10
+ * source order, so authors can mix scenarios ("typical case",
11
+ * "edge case with auth", etc.) without nesting them in a single
12
+ * string.
13
+ */
14
+ examples: string[];
15
+ /**
16
+ * Non-blocking caution. Surfaced verbatim to the agent at affordance
17
+ * time so the LLM can weigh the consequence ("this overwrites the
18
+ * cloud version", "fires analytics that can't be retracted") before
19
+ * dispatching. Distinct from `requiresConfirm`, which is a runtime
20
+ * gate the user must acknowledge.
21
+ */
22
+ warning: string | null;
23
+ /**
24
+ * Effect kinds this variant emits when dispatched, declared by the
25
+ * author via `@emits("kind1", "kind2")`. Lets the agent reason
26
+ * about side effects ("this dispatch hits the cloud, so I should
27
+ * batch") without the compiler having to walk update.ts. Authored
28
+ * rather than auto-extracted because real apps emit effects
29
+ * through helpers (`track('foo')`, `saveDelta(d)`) — auto-detecting
30
+ * those would require helper-return-shape analysis with
31
+ * ergonomically-painful failure modes; the declarative form trades
32
+ * automatic discovery for accuracy and simplicity.
33
+ *
34
+ * Empty when no `@emits` tag is present.
35
+ */
36
+ emits: string[];
6
37
  };
7
38
  /**
8
39
  * Walk a Msg-like discriminated-union type alias and extract JSDoc
@@ -13,11 +44,22 @@ export type MessageAnnotations = {
13
44
  * @intent("human readable")
14
45
  * @alwaysAffordable
15
46
  * @requiresConfirm
16
- * @humanOnly
47
+ * @humanOnly — sugar for dispatchMode: 'human-only'
48
+ * @agentOnly — sugar for dispatchMode: 'agent-only'
17
49
  *
18
50
  * Unknown tags are ignored; malformed @intent (no quoted string) is
19
- * treated as "no intent". The four flags are booleans; any occurrence
20
- * of the tag sets it true.
51
+ * treated as "no intent". `@humanOnly` and `@agentOnly` are mutually
52
+ * exclusive if both are present (which the ESLint rule
53
+ * `agent-exclusive-annotations` reports as an error), the parser
54
+ * falls back to `'shared'` so a misconfigured Msg variant doesn't
55
+ * silently lock out one audience.
21
56
  */
22
- export declare function extractMsgAnnotations(source: string): Record<string, MessageAnnotations> | null;
57
+ export declare function extractMsgAnnotations(source: string,
58
+ /**
59
+ * Name of the type alias to extract from. Defaults to `'Msg'` for
60
+ * convention. Passed by the cross-file resolver when the alias has
61
+ * been renamed through imports/re-exports — its local name in the
62
+ * declaring file may differ from `'Msg'`.
63
+ */
64
+ typeName?: string): Record<string, MessageAnnotations> | null;
23
65
  //# sourceMappingURL=msg-annotations.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"msg-annotations.d.ts","sourceRoot":"","sources":["../src/msg-annotations.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,gBAAgB,EAAE,OAAO,CAAA;IACzB,eAAe,EAAE,OAAO,CAAA;IACxB,SAAS,EAAE,OAAO,CAAA;CACnB,CAAA;AASD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,GAAG,IAAI,CA2B/F"}
1
+ {"version":3,"file":"msg-annotations.d.ts","sourceRoot":"","sources":["../src/msg-annotations.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,YAAY,GAAG,YAAY,CAAA;AAEjE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,gBAAgB,EAAE,OAAO,CAAA;IACzB,eAAe,EAAE,OAAO,CAAA;IACxB,YAAY,EAAE,YAAY,CAAA;IAC1B;;;;;;OAMG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB;;;;;;OAMG;IACH,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB;;;;;;;;;;;;OAYG;IACH,KAAK,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAYD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM;AACd;;;;;GAKG;AACH,QAAQ,GAAE,MAAc,GACvB,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,GAAG,IAAI,CAgC3C"}
@@ -3,7 +3,10 @@ const DEFAULT = {
3
3
  intent: null,
4
4
  alwaysAffordable: false,
5
5
  requiresConfirm: false,
6
- humanOnly: false,
6
+ dispatchMode: 'shared',
7
+ examples: [],
8
+ warning: null,
9
+ emits: [],
7
10
  };
8
11
  /**
9
12
  * Walk a Msg-like discriminated-union type alias and extract JSDoc
@@ -14,21 +17,36 @@ const DEFAULT = {
14
17
  * @intent("human readable")
15
18
  * @alwaysAffordable
16
19
  * @requiresConfirm
17
- * @humanOnly
20
+ * @humanOnly — sugar for dispatchMode: 'human-only'
21
+ * @agentOnly — sugar for dispatchMode: 'agent-only'
18
22
  *
19
23
  * Unknown tags are ignored; malformed @intent (no quoted string) is
20
- * treated as "no intent". The four flags are booleans; any occurrence
21
- * of the tag sets it true.
24
+ * treated as "no intent". `@humanOnly` and `@agentOnly` are mutually
25
+ * exclusive if both are present (which the ESLint rule
26
+ * `agent-exclusive-annotations` reports as an error), the parser
27
+ * falls back to `'shared'` so a misconfigured Msg variant doesn't
28
+ * silently lock out one audience.
22
29
  */
23
- export function extractMsgAnnotations(source) {
30
+ export function extractMsgAnnotations(source,
31
+ /**
32
+ * Name of the type alias to extract from. Defaults to `'Msg'` for
33
+ * convention. Passed by the cross-file resolver when the alias has
34
+ * been renamed through imports/re-exports — its local name in the
35
+ * declaring file may differ from `'Msg'`.
36
+ */
37
+ typeName = 'Msg') {
24
38
  const sf = ts.createSourceFile('msg.ts', source, ts.ScriptTarget.Latest, true);
25
39
  const aliases = [];
26
40
  sf.forEachChild((n) => {
27
41
  if (ts.isTypeAliasDeclaration(n))
28
42
  aliases.push(n);
29
43
  });
30
- const named = aliases.find((a) => a.name.text === 'Msg');
31
- const alias = named ?? aliases.find((a) => ts.isUnionTypeNode(a.type));
44
+ const named = aliases.find((a) => a.name.text === typeName);
45
+ // Fallback: only when looking for the conventional 'Msg' name AND the
46
+ // file has no `type Msg = …`; pick any union type alias. With an
47
+ // explicit `typeName` from the resolver, we don't fall back — that
48
+ // would silently match the wrong alias.
49
+ const alias = named ?? (typeName === 'Msg' ? aliases.find((a) => ts.isUnionTypeNode(a.type)) : undefined);
32
50
  if (!alias || !ts.isUnionTypeNode(alias.type))
33
51
  return null;
34
52
  const result = {};
@@ -75,17 +93,75 @@ function readLeadingJSDoc(source, scanPos) {
75
93
  }
76
94
  function parseAnnotations(comment) {
77
95
  if (!comment)
78
- return { ...DEFAULT };
96
+ return { ...DEFAULT, examples: [] };
79
97
  const intent = readIntent(comment);
98
+ const human = /@humanOnly\b/.test(comment);
99
+ const agent = /@agentOnly\b/.test(comment);
100
+ // Mutual-exclusion fallback: both tags present means a config bug;
101
+ // the ESLint rule reports it. At parse time, default to 'shared' so
102
+ // we don't silently lock out one audience based on tag order.
103
+ const dispatchMode = human && !agent ? 'human-only' : agent && !human ? 'agent-only' : 'shared';
80
104
  return {
81
105
  intent,
82
106
  alwaysAffordable: /@alwaysAffordable\b/.test(comment),
83
107
  requiresConfirm: /@requiresConfirm\b/.test(comment),
84
- humanOnly: /@humanOnly\b/.test(comment),
108
+ dispatchMode,
109
+ examples: readExamples(comment),
110
+ warning: readWarning(comment),
111
+ emits: readEmits(comment),
85
112
  };
86
113
  }
114
+ /**
115
+ * Match `@emits("k1", "k2", ...)` — comma-separated list of effect
116
+ * kind strings. Each entry can use straight or curly quotes; the
117
+ * separator is `,` with arbitrary whitespace. Returns the kinds in
118
+ * source order (deduped). Empty when the tag is absent or has no
119
+ * quoted strings.
120
+ */
121
+ function readEmits(comment) {
122
+ // Match the whole `@emits(...)` parenthesized group so we can
123
+ // re-parse the inner content for individual quoted strings. The
124
+ // outer match is non-greedy on the closing paren to avoid eating
125
+ // through later JSDoc.
126
+ const outer = comment.match(/@emits\s*\(([^)]*)\)/);
127
+ if (!outer || outer[1] === undefined)
128
+ return [];
129
+ const inner = outer[1];
130
+ const seen = new Set();
131
+ const out = [];
132
+ const re = /["“]([^"”]*)["”]/g;
133
+ let m;
134
+ while ((m = re.exec(inner)) !== null) {
135
+ const v = m[1];
136
+ if (v === undefined || seen.has(v))
137
+ continue;
138
+ seen.add(v);
139
+ out.push(v);
140
+ }
141
+ return out;
142
+ }
87
143
  function readIntent(comment) {
88
144
  const match = comment.match(/@intent\s*\(\s*["\u201c]([^"\u201d]*)["\u201d]\s*\)/);
89
145
  return match?.[1] ?? null;
90
146
  }
147
+ /**
148
+ * Match every `@example("\u2026")` (and curly-quote variant) in source
149
+ * order. Multiple tags on one variant are common \u2014 typical-case,
150
+ * edge-case-with-auth, etc. \u2014 so the parser collects all of them
151
+ * rather than picking the first.
152
+ */
153
+ function readExamples(comment) {
154
+ const out = [];
155
+ const re = /@example\s*\(\s*["\u201c]([^"\u201d]*)["\u201d]\s*\)/g;
156
+ let m;
157
+ while ((m = re.exec(comment)) !== null) {
158
+ if (m[1] !== undefined)
159
+ out.push(m[1]);
160
+ }
161
+ return out;
162
+ }
163
+ function readWarning(comment) {
164
+ const match = comment.match(/@warning\s*\(\s*["\u201c]([^"\u201d]*)["\u201d]\s*\)/);
165
+ return match?.[1] ?? null;
166
+ }
91
167
  //# sourceMappingURL=msg-annotations.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"msg-annotations.js","sourceRoot":"","sources":["../src/msg-annotations.ts"],"names":[],"mappings":"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","sourcesContent":["import ts from 'typescript'\n\nexport type MessageAnnotations = {\n intent: string | null\n alwaysAffordable: boolean\n requiresConfirm: boolean\n humanOnly: boolean\n}\n\nconst DEFAULT: MessageAnnotations = {\n intent: null,\n alwaysAffordable: false,\n requiresConfirm: false,\n humanOnly: false,\n}\n\n/**\n * Walk a Msg-like discriminated-union type alias and extract JSDoc\n * annotations attached to each union member. Returns null if no\n * recognizable union is found so callers can skip emission cleanly.\n *\n * Expected JSDoc grammar (order-independent):\n * @intent(\"human readable\")\n * @alwaysAffordable\n * @requiresConfirm\n * @humanOnly\n *\n * Unknown tags are ignored; malformed @intent (no quoted string) is\n * treated as \"no intent\". The four flags are booleans; any occurrence\n * of the tag sets it true.\n */\nexport function extractMsgAnnotations(source: string): Record<string, MessageAnnotations> | null {\n const sf = ts.createSourceFile('msg.ts', source, ts.ScriptTarget.Latest, true)\n const aliases: ts.TypeAliasDeclaration[] = []\n sf.forEachChild((n) => {\n if (ts.isTypeAliasDeclaration(n)) aliases.push(n)\n })\n const named = aliases.find((a) => a.name.text === 'Msg')\n const alias = named ?? aliases.find((a) => ts.isUnionTypeNode(a.type))\n if (!alias || !ts.isUnionTypeNode(alias.type)) return null\n\n const result: Record<string, MessageAnnotations> = {}\n const types = alias.type.types\n for (let i = 0; i < types.length; i++) {\n const member = types[i]\n if (member === undefined || !ts.isTypeLiteralNode(member)) continue\n const variant = readDiscriminantLiteral(member)\n if (!variant) continue\n // Leading JSDoc for union member i is scanned from the end of the\n // previous element (or union.pos for the first member), because\n // TypeScript's parser places comment ranges relative to the token\n // that follows them — and the | bar is not part of the TypeLiteralNode.\n const prev = types[i - 1]\n const scanPos = i === 0 || prev === undefined ? alias.type.pos : prev.end\n const comment = readLeadingJSDoc(source, scanPos)\n result[variant] = parseAnnotations(comment)\n }\n return Object.keys(result).length === 0 ? null : result\n}\n\nfunction readDiscriminantLiteral(lit: ts.TypeLiteralNode): string | null {\n for (const m of lit.members) {\n if (!ts.isPropertySignature(m)) continue\n if (!m.name || !ts.isIdentifier(m.name) || m.name.text !== 'type') continue\n if (!m.type || !ts.isLiteralTypeNode(m.type)) continue\n const literal = m.type.literal\n if (ts.isStringLiteral(literal)) return literal.text\n }\n return null\n}\n\nfunction readLeadingJSDoc(source: string, scanPos: number): string {\n const ranges = ts.getLeadingCommentRanges(source, scanPos) ?? []\n const docs = ranges\n .filter((r) => r.kind === ts.SyntaxKind.MultiLineCommentTrivia)\n .map((r) => source.slice(r.pos, r.end))\n .filter((txt) => txt.startsWith('/**'))\n return docs.join('\\n')\n}\n\nfunction parseAnnotations(comment: string): MessageAnnotations {\n if (!comment) return { ...DEFAULT }\n const intent = readIntent(comment)\n return {\n intent,\n alwaysAffordable: /@alwaysAffordable\\b/.test(comment),\n requiresConfirm: /@requiresConfirm\\b/.test(comment),\n humanOnly: /@humanOnly\\b/.test(comment),\n }\n}\n\nfunction readIntent(comment: string): string | null {\n const match = comment.match(/@intent\\s*\\(\\s*[\"\\u201c]([^\"\\u201d]*)[\"\\u201d]\\s*\\)/)\n return match?.[1] ?? null\n}\n"]}
1
+ {"version":3,"file":"msg-annotations.js","sourceRoot":"","sources":["../src/msg-annotations.ts"],"names":[],"mappings":"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","sourcesContent":["import ts from 'typescript'\n\nexport type DispatchMode = 'shared' | 'human-only' | 'agent-only'\n\nexport type MessageAnnotations = {\n intent: string | null\n alwaysAffordable: boolean\n requiresConfirm: boolean\n dispatchMode: DispatchMode\n /**\n * Concrete example dispatches the LLM can copy from. Populated by\n * `@example(\"text\")` JSDoc tags. Each tag becomes one entry, in\n * source order, so authors can mix scenarios (\"typical case\",\n * \"edge case with auth\", etc.) without nesting them in a single\n * string.\n */\n examples: string[]\n /**\n * Non-blocking caution. Surfaced verbatim to the agent at affordance\n * time so the LLM can weigh the consequence (\"this overwrites the\n * cloud version\", \"fires analytics that can't be retracted\") before\n * dispatching. Distinct from `requiresConfirm`, which is a runtime\n * gate the user must acknowledge.\n */\n warning: string | null\n /**\n * Effect kinds this variant emits when dispatched, declared by the\n * author via `@emits(\"kind1\", \"kind2\")`. Lets the agent reason\n * about side effects (\"this dispatch hits the cloud, so I should\n * batch\") without the compiler having to walk update.ts. Authored\n * rather than auto-extracted because real apps emit effects\n * through helpers (`track('foo')`, `saveDelta(d)`) — auto-detecting\n * those would require helper-return-shape analysis with\n * ergonomically-painful failure modes; the declarative form trades\n * automatic discovery for accuracy and simplicity.\n *\n * Empty when no `@emits` tag is present.\n */\n emits: string[]\n}\n\nconst DEFAULT: MessageAnnotations = {\n intent: null,\n alwaysAffordable: false,\n requiresConfirm: false,\n dispatchMode: 'shared',\n examples: [],\n warning: null,\n emits: [],\n}\n\n/**\n * Walk a Msg-like discriminated-union type alias and extract JSDoc\n * annotations attached to each union member. Returns null if no\n * recognizable union is found so callers can skip emission cleanly.\n *\n * Expected JSDoc grammar (order-independent):\n * @intent(\"human readable\")\n * @alwaysAffordable\n * @requiresConfirm\n * @humanOnly — sugar for dispatchMode: 'human-only'\n * @agentOnly — sugar for dispatchMode: 'agent-only'\n *\n * Unknown tags are ignored; malformed @intent (no quoted string) is\n * treated as \"no intent\". `@humanOnly` and `@agentOnly` are mutually\n * exclusive — if both are present (which the ESLint rule\n * `agent-exclusive-annotations` reports as an error), the parser\n * falls back to `'shared'` so a misconfigured Msg variant doesn't\n * silently lock out one audience.\n */\nexport function extractMsgAnnotations(\n source: string,\n /**\n * Name of the type alias to extract from. Defaults to `'Msg'` for\n * convention. Passed by the cross-file resolver when the alias has\n * been renamed through imports/re-exports — its local name in the\n * declaring file may differ from `'Msg'`.\n */\n typeName: string = 'Msg',\n): Record<string, MessageAnnotations> | null {\n const sf = ts.createSourceFile('msg.ts', source, ts.ScriptTarget.Latest, true)\n const aliases: ts.TypeAliasDeclaration[] = []\n sf.forEachChild((n) => {\n if (ts.isTypeAliasDeclaration(n)) aliases.push(n)\n })\n const named = aliases.find((a) => a.name.text === typeName)\n // Fallback: only when looking for the conventional 'Msg' name AND the\n // file has no `type Msg = …`; pick any union type alias. With an\n // explicit `typeName` from the resolver, we don't fall back — that\n // would silently match the wrong alias.\n const alias =\n named ?? (typeName === 'Msg' ? aliases.find((a) => ts.isUnionTypeNode(a.type)) : undefined)\n if (!alias || !ts.isUnionTypeNode(alias.type)) return null\n\n const result: Record<string, MessageAnnotations> = {}\n const types = alias.type.types\n for (let i = 0; i < types.length; i++) {\n const member = types[i]\n if (member === undefined || !ts.isTypeLiteralNode(member)) continue\n const variant = readDiscriminantLiteral(member)\n if (!variant) continue\n // Leading JSDoc for union member i is scanned from the end of the\n // previous element (or union.pos for the first member), because\n // TypeScript's parser places comment ranges relative to the token\n // that follows them — and the | bar is not part of the TypeLiteralNode.\n const prev = types[i - 1]\n const scanPos = i === 0 || prev === undefined ? alias.type.pos : prev.end\n const comment = readLeadingJSDoc(source, scanPos)\n result[variant] = parseAnnotations(comment)\n }\n return Object.keys(result).length === 0 ? null : result\n}\n\nfunction readDiscriminantLiteral(lit: ts.TypeLiteralNode): string | null {\n for (const m of lit.members) {\n if (!ts.isPropertySignature(m)) continue\n if (!m.name || !ts.isIdentifier(m.name) || m.name.text !== 'type') continue\n if (!m.type || !ts.isLiteralTypeNode(m.type)) continue\n const literal = m.type.literal\n if (ts.isStringLiteral(literal)) return literal.text\n }\n return null\n}\n\nfunction readLeadingJSDoc(source: string, scanPos: number): string {\n const ranges = ts.getLeadingCommentRanges(source, scanPos) ?? []\n const docs = ranges\n .filter((r) => r.kind === ts.SyntaxKind.MultiLineCommentTrivia)\n .map((r) => source.slice(r.pos, r.end))\n .filter((txt) => txt.startsWith('/**'))\n return docs.join('\\n')\n}\n\nfunction parseAnnotations(comment: string): MessageAnnotations {\n if (!comment) return { ...DEFAULT, examples: [] }\n const intent = readIntent(comment)\n const human = /@humanOnly\\b/.test(comment)\n const agent = /@agentOnly\\b/.test(comment)\n // Mutual-exclusion fallback: both tags present means a config bug;\n // the ESLint rule reports it. At parse time, default to 'shared' so\n // we don't silently lock out one audience based on tag order.\n const dispatchMode: DispatchMode =\n human && !agent ? 'human-only' : agent && !human ? 'agent-only' : 'shared'\n return {\n intent,\n alwaysAffordable: /@alwaysAffordable\\b/.test(comment),\n requiresConfirm: /@requiresConfirm\\b/.test(comment),\n dispatchMode,\n examples: readExamples(comment),\n warning: readWarning(comment),\n emits: readEmits(comment),\n }\n}\n\n/**\n * Match `@emits(\"k1\", \"k2\", ...)` — comma-separated list of effect\n * kind strings. Each entry can use straight or curly quotes; the\n * separator is `,` with arbitrary whitespace. Returns the kinds in\n * source order (deduped). Empty when the tag is absent or has no\n * quoted strings.\n */\nfunction readEmits(comment: string): string[] {\n // Match the whole `@emits(...)` parenthesized group so we can\n // re-parse the inner content for individual quoted strings. The\n // outer match is non-greedy on the closing paren to avoid eating\n // through later JSDoc.\n const outer = comment.match(/@emits\\s*\\(([^)]*)\\)/)\n if (!outer || outer[1] === undefined) return []\n const inner = outer[1]\n const seen = new Set<string>()\n const out: string[] = []\n const re = /[\"“]([^\"”]*)[\"”]/g\n let m: RegExpExecArray | null\n while ((m = re.exec(inner)) !== null) {\n const v = m[1]\n if (v === undefined || seen.has(v)) continue\n seen.add(v)\n out.push(v)\n }\n return out\n}\n\nfunction readIntent(comment: string): string | null {\n const match = comment.match(/@intent\\s*\\(\\s*[\"\\u201c]([^\"\\u201d]*)[\"\\u201d]\\s*\\)/)\n return match?.[1] ?? null\n}\n\n/**\n * Match every `@example(\"\\u2026\")` (and curly-quote variant) in source\n * order. Multiple tags on one variant are common \\u2014 typical-case,\n * edge-case-with-auth, etc. \\u2014 so the parser collects all of them\n * rather than picking the first.\n */\nfunction readExamples(comment: string): string[] {\n const out: string[] = []\n const re = /@example\\s*\\(\\s*[\"\\u201c]([^\"\\u201d]*)[\"\\u201d]\\s*\\)/g\n let m: RegExpExecArray | null\n while ((m = re.exec(comment)) !== null) {\n if (m[1] !== undefined) out.push(m[1])\n }\n return out\n}\n\nfunction readWarning(comment: string): string | null {\n const match = comment.match(/@warning\\s*\\(\\s*[\"\\u201c]([^\"\\u201d]*)[\"\\u201d]\\s*\\)/)\n return match?.[1] ?? null\n}\n"]}
@@ -1,9 +1,85 @@
1
+ import ts from 'typescript';
2
+ /**
3
+ * The "bare type" of a field. Covers four cases:
4
+ * - primitive keyword as a string: `'string'`, `'number'`, `'boolean'`, `'unknown'`
5
+ * - string-literal union: `{enum: ['a', 'b']}`
6
+ * - nested object shape: `{kind: 'object', shape: {...}}` — emitted when
7
+ * a field's type is a local interface/type alias the extractor could
8
+ * follow (depth-limited; cross-file references stay `'unknown'`).
9
+ * - array of element type: `{kind: 'array', element: <bare type>}`.
10
+ *
11
+ * The synthesizer in `@llui/agent`'s `list_actions` walks these to build
12
+ * copy-paste-ready payload examples; the validator in `send_message`
13
+ * walks them too (treating object/array as "any" since deep validation
14
+ * is the reducer's job).
15
+ */
16
+ export type MsgFieldType = string | {
17
+ enum: string[];
18
+ } | {
19
+ kind: 'object';
20
+ shape: Record<string, MsgField>;
21
+ } | {
22
+ kind: 'array';
23
+ element: MsgFieldType;
24
+ };
25
+ /**
26
+ * Rich per-field descriptor. Emitted only when there's something
27
+ * beyond the bare type to communicate — optionality, an explicit
28
+ * priority hint, or a freeform agent hint. When everything but `type`
29
+ * is unset, the producer emits the bare `MsgFieldType` instead so
30
+ * variants without annotations stay byte-cheap in the bundle.
31
+ */
32
+ export interface MsgFieldRich {
33
+ type: MsgFieldType;
34
+ /** Mirrors TypeScript's `?:` optional marker. Required fields omit this. */
35
+ optional?: boolean;
36
+ /**
37
+ * Strength signal for optional fields. Borrows RFC 2119's `SHOULD`:
38
+ * the LLM ought to fill it in unless it has a specific reason not
39
+ * to. Required fields don't carry a priority — TS already conveys
40
+ * "must" via the type system. Currently the only level; future
41
+ * extensions could add `'recommended'` or similar.
42
+ */
43
+ priority?: 'should';
44
+ /** Freeform consequence-shaped explanation. Surfaced verbatim to
45
+ * the LLM at affordance time. */
46
+ hint?: string;
47
+ }
48
+ export type MsgField = MsgFieldType | MsgFieldRich;
1
49
  export interface MsgSchema {
2
50
  discriminant: string;
3
- variants: Record<string, Record<string, string | {
4
- enum: string[];
5
- }>>;
51
+ variants: Record<string, Record<string, MsgField>>;
6
52
  }
7
- export declare function extractMsgSchema(source: string): MsgSchema | null;
8
- export declare function extractEffectSchema(source: string): MsgSchema | null;
53
+ /** True when `f` is a rich descriptor (object with `type` key). */
54
+ export declare function isRichField(f: MsgField): f is MsgFieldRich;
55
+ /** Extracts the bare type from either descriptor form. */
56
+ export declare function fieldType(f: MsgField): MsgFieldType;
57
+ export declare function extractMsgSchema(source: string, typeName?: string): MsgSchema | null;
58
+ export declare function extractEffectSchema(source: string, typeName?: string): MsgSchema | null;
59
+ /**
60
+ * Index of type aliases and interfaces visible from a source file,
61
+ * keyed by name. Lets the field-type resolver follow `Criterion[]` →
62
+ * `interface Criterion { … }` and emit a nested object shape rather
63
+ * than `'unknown'`.
64
+ *
65
+ * The cross-file resolver pipeline (`cross-file-resolver.ts`) builds
66
+ * an enriched index that includes types imported from sibling files —
67
+ * follow `GridSorting` → `'rank' | 'crit-X' | 'crit-Y'` → `{enum: […]}`
68
+ * even when the alias lives in `./state.ts` not the Msg-defining file.
69
+ */
70
+ export type TypeIndex = Map<string, ts.TypeNode | ts.InterfaceDeclaration>;
71
+ /**
72
+ * Build a single field descriptor from a property signature: type,
73
+ * optionality, and any `@should("…")` JSDoc hint. Emits the compact
74
+ * bare form when there's nothing extra to communicate; otherwise the
75
+ * rich `{type, optional?, priority?, hint?}` shape.
76
+ *
77
+ * Exported so the cross-file resolver (which walks the same property
78
+ * signatures when the Msg type lives in a different file from the
79
+ * `component()` call) can produce identical descriptors. Without
80
+ * sharing this helper, JSDoc hints would silently disappear whenever
81
+ * a Msg union got resolved across module boundaries.
82
+ */
83
+ export declare function buildFieldDescriptor(member: ts.PropertySignature, source: string, typeIndex?: TypeIndex): MsgField;
84
+ export declare function resolveFieldType(type: ts.TypeNode, typeIndex?: TypeIndex, depth?: number): MsgFieldType;
9
85
  //# sourceMappingURL=msg-schema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"msg-schema.d.ts","sourceRoot":"","sources":["../src/msg-schema.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,SAAS;IACxB,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG;QAAE,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC,CAAA;CACtE;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAEjE;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAEpE"}
1
+ {"version":3,"file":"msg-schema.d.ts","sourceRoot":"","sources":["../src/msg-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAA;AAE3B;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,YAAY,GACpB,MAAM,GACN;IAAE,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,YAAY,CAAA;CAAE,CAAA;AAE5C;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,YAAY,CAAA;IAClB,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAA;IACnB;sCACkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,MAAM,QAAQ,GAAG,YAAY,GAAG,YAAY,CAAA;AAElD,MAAM,WAAW,SAAS;IACxB,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC,CAAA;CACnD;AAED,mEAAmE;AACnE,wBAAgB,WAAW,CAAC,CAAC,EAAE,QAAQ,GAAG,CAAC,IAAI,YAAY,CAE1D;AAED,0DAA0D;AAC1D,wBAAgB,SAAS,CAAC,CAAC,EAAE,QAAQ,GAAG,YAAY,CAEnD;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAc,GAAG,SAAS,GAAG,IAAI,CAE3F;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAiB,GAAG,SAAS,GAAG,IAAI,CAEjG;AAqBD;;;;;;;;;;GAUG;AACH,MAAM,MAAM,SAAS,GAAG,GAAG,CAAC,MAAM,EAAE,EAAE,CAAC,QAAQ,GAAG,EAAE,CAAC,oBAAoB,CAAC,CAAA;AAsD1E;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,EAAE,CAAC,iBAAiB,EAC5B,MAAM,EAAE,MAAM,EACd,SAAS,GAAE,SAAqB,GAC/B,QAAQ,CAkBV;AAeD,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,EAAE,CAAC,QAAQ,EACjB,SAAS,GAAE,SAAqB,EAChC,KAAK,SAAkB,GACtB,YAAY,CA0Fd"}
@@ -1,29 +1,50 @@
1
1
  import ts from 'typescript';
2
- export function extractMsgSchema(source) {
3
- return extractDiscriminatedUnionSchema(source, 'Msg');
2
+ /** True when `f` is a rich descriptor (object with `type` key). */
3
+ export function isRichField(f) {
4
+ return typeof f === 'object' && f !== null && !Array.isArray(f) && 'type' in f;
4
5
  }
5
- export function extractEffectSchema(source) {
6
- return extractDiscriminatedUnionSchema(source, 'Effect');
6
+ /** Extracts the bare type from either descriptor form. */
7
+ export function fieldType(f) {
8
+ return isRichField(f) ? f.type : f;
9
+ }
10
+ export function extractMsgSchema(source, typeName = 'Msg') {
11
+ return extractDiscriminatedUnionSchema(source, typeName);
12
+ }
13
+ export function extractEffectSchema(source, typeName = 'Effect') {
14
+ return extractDiscriminatedUnionSchema(source, typeName);
7
15
  }
8
16
  function extractDiscriminatedUnionSchema(source, typeName) {
9
17
  const sf = ts.createSourceFile('input.ts', source, ts.ScriptTarget.Latest, true);
18
+ const typeIndex = buildTypeIndex(sf);
10
19
  for (const stmt of sf.statements) {
11
20
  if (!ts.isTypeAliasDeclaration(stmt))
12
21
  continue;
13
22
  if (stmt.name.text !== typeName)
14
23
  continue;
15
24
  const variants = {};
16
- collectVariants(stmt.type, variants);
25
+ collectVariants(stmt.type, variants, source, typeIndex);
17
26
  if (Object.keys(variants).length === 0)
18
27
  return null;
19
28
  return { discriminant: 'type', variants };
20
29
  }
21
30
  return null;
22
31
  }
23
- function collectVariants(type, variants) {
32
+ function buildTypeIndex(sf) {
33
+ const index = new Map();
34
+ for (const stmt of sf.statements) {
35
+ if (ts.isTypeAliasDeclaration(stmt)) {
36
+ index.set(stmt.name.text, stmt.type);
37
+ }
38
+ else if (ts.isInterfaceDeclaration(stmt)) {
39
+ index.set(stmt.name.text, stmt);
40
+ }
41
+ }
42
+ return index;
43
+ }
44
+ function collectVariants(type, variants, source, typeIndex) {
24
45
  if (ts.isUnionTypeNode(type)) {
25
46
  for (const member of type.types) {
26
- collectVariants(member, variants);
47
+ collectVariants(member, variants, source, typeIndex);
27
48
  }
28
49
  return;
29
50
  }
@@ -42,18 +63,57 @@ function collectVariants(type, variants) {
42
63
  }
43
64
  continue;
44
65
  }
45
- if (!memberType) {
46
- fields[name] = 'unknown';
47
- continue;
48
- }
49
- fields[name] = resolveFieldType(memberType);
66
+ fields[name] = buildFieldDescriptor(member, source, typeIndex);
50
67
  }
51
68
  if (discriminantValue) {
52
69
  variants[discriminantValue] = fields;
53
70
  }
54
71
  }
55
72
  }
56
- function resolveFieldType(type) {
73
+ /**
74
+ * Build a single field descriptor from a property signature: type,
75
+ * optionality, and any `@should("…")` JSDoc hint. Emits the compact
76
+ * bare form when there's nothing extra to communicate; otherwise the
77
+ * rich `{type, optional?, priority?, hint?}` shape.
78
+ *
79
+ * Exported so the cross-file resolver (which walks the same property
80
+ * signatures when the Msg type lives in a different file from the
81
+ * `component()` call) can produce identical descriptors. Without
82
+ * sharing this helper, JSDoc hints would silently disappear whenever
83
+ * a Msg union got resolved across module boundaries.
84
+ */
85
+ export function buildFieldDescriptor(member, source, typeIndex = new Map()) {
86
+ const baseType = member.type
87
+ ? resolveFieldType(member.type, typeIndex, MAX_FIELD_DEPTH)
88
+ : 'unknown';
89
+ const optional = member.questionToken !== undefined;
90
+ const jsdoc = readMemberJSDoc(source, member);
91
+ const hint = readShouldHint(jsdoc);
92
+ if (!optional && hint === null) {
93
+ return baseType;
94
+ }
95
+ const rich = { type: baseType };
96
+ if (optional)
97
+ rich.optional = true;
98
+ if (hint !== null) {
99
+ rich.priority = 'should';
100
+ rich.hint = hint;
101
+ }
102
+ return rich;
103
+ }
104
+ /**
105
+ * Recursion bound for nested type resolution. Stops the extractor
106
+ * before it spirals on self-referential or mutually-recursive types
107
+ * (`type Tree = { children: Tree[] }`). At depth 0 every reference
108
+ * collapses to `'unknown'`; the synthesizer emits `null` and the
109
+ * agent falls back to free-form filling.
110
+ *
111
+ * 3 covers the common cases (Msg payload → Criterion → ValueMeta),
112
+ * keeps the bundle bounded, and is well under the Tarjan-style
113
+ * depths needed for actual recursive types.
114
+ */
115
+ const MAX_FIELD_DEPTH = 3;
116
+ export function resolveFieldType(type, typeIndex = new Map(), depth = MAX_FIELD_DEPTH) {
57
117
  // Primitive keywords
58
118
  if (type.kind === ts.SyntaxKind.StringKeyword)
59
119
  return 'string';
@@ -78,6 +138,134 @@ function resolveFieldType(type) {
78
138
  return { enum: literals };
79
139
  }
80
140
  }
141
+ // Below this point, all branches need depth budget. Bail out cheaply.
142
+ if (depth <= 0)
143
+ return 'unknown';
144
+ // Inline object literal — `{a: number; b: string}` directly.
145
+ if (ts.isTypeLiteralNode(type)) {
146
+ return { kind: 'object', shape: collectInlineShape(type, typeIndex, depth - 1) };
147
+ }
148
+ // Array type — `T[]` and `readonly T[]`.
149
+ if (ts.isArrayTypeNode(type)) {
150
+ return { kind: 'array', element: resolveFieldType(type.elementType, typeIndex, depth - 1) };
151
+ }
152
+ // Generic Array<T> (less common in app code but compiler may produce it).
153
+ if (ts.isTypeReferenceNode(type) &&
154
+ ts.isIdentifier(type.typeName) &&
155
+ type.typeName.text === 'Array' &&
156
+ type.typeArguments?.length === 1 &&
157
+ type.typeArguments[0]) {
158
+ return {
159
+ kind: 'array',
160
+ element: resolveFieldType(type.typeArguments[0], typeIndex, depth - 1),
161
+ };
162
+ }
163
+ // ReadonlyArray<T> → same shape; the readonly modifier is purely a
164
+ // TypeScript-side concern that the agent never observes at runtime.
165
+ if (ts.isTypeReferenceNode(type) &&
166
+ ts.isIdentifier(type.typeName) &&
167
+ type.typeName.text === 'ReadonlyArray' &&
168
+ type.typeArguments?.length === 1 &&
169
+ type.typeArguments[0]) {
170
+ return {
171
+ kind: 'array',
172
+ element: resolveFieldType(type.typeArguments[0], typeIndex, depth - 1),
173
+ };
174
+ }
175
+ // `readonly T[]` parses as TypeOperator(readonly) wrapping ArrayType.
176
+ if (ts.isTypeOperatorNode(type) && type.operator === ts.SyntaxKind.ReadonlyKeyword) {
177
+ return resolveFieldType(type.type, typeIndex, depth);
178
+ }
179
+ // Named type reference — chase it through the local index.
180
+ if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) {
181
+ const target = typeIndex.get(type.typeName.text);
182
+ if (target) {
183
+ if (ts.isInterfaceDeclaration(target)) {
184
+ return {
185
+ kind: 'object',
186
+ shape: collectInterfaceShape(target, typeIndex, depth - 1),
187
+ };
188
+ }
189
+ // Type alias — recurse on its body. `type Foo = …` could resolve
190
+ // to anything (object literal, array, union, primitive); each
191
+ // already has its own branch above.
192
+ return resolveFieldType(target, typeIndex, depth - 1);
193
+ }
194
+ // Reference to a type the index doesn't know about — typically
195
+ // imported from another module. Cross-file resolution is the
196
+ // separate cross-file-resolver pipeline's job; leave this as
197
+ // unknown rather than fabricating a misleading shape.
198
+ return 'unknown';
199
+ }
81
200
  return 'unknown';
82
201
  }
202
+ function collectInlineShape(lit, typeIndex, depth) {
203
+ const shape = {};
204
+ for (const member of lit.members) {
205
+ if (!ts.isPropertySignature(member) || !member.name || !ts.isIdentifier(member.name))
206
+ continue;
207
+ const name = member.name.text;
208
+ const baseType = member.type
209
+ ? resolveFieldType(member.type, typeIndex, depth)
210
+ : 'unknown';
211
+ const optional = member.questionToken !== undefined;
212
+ if (!optional) {
213
+ shape[name] = baseType;
214
+ }
215
+ else {
216
+ shape[name] = { type: baseType, optional: true };
217
+ }
218
+ }
219
+ return shape;
220
+ }
221
+ function collectInterfaceShape(iface, typeIndex, depth) {
222
+ const shape = {};
223
+ for (const member of iface.members) {
224
+ if (!ts.isPropertySignature(member) || !member.name || !ts.isIdentifier(member.name))
225
+ continue;
226
+ const name = member.name.text;
227
+ const baseType = member.type
228
+ ? resolveFieldType(member.type, typeIndex, depth)
229
+ : 'unknown';
230
+ const optional = member.questionToken !== undefined;
231
+ if (!optional) {
232
+ shape[name] = baseType;
233
+ }
234
+ else {
235
+ shape[name] = { type: baseType, optional: true };
236
+ }
237
+ }
238
+ return shape;
239
+ }
240
+ /**
241
+ * Read the leading JSDoc block immediately above `member`. The
242
+ * TypeScript parser doesn't attach JSDoc to interior property
243
+ * signatures, so we re-scan the source between the previous member's
244
+ * end (or the type-literal's `{`) and this member's start, and return
245
+ * the last `/** … *\/` block found there. Returns `''` when none.
246
+ */
247
+ function readMemberJSDoc(source, member) {
248
+ const ranges = ts.getLeadingCommentRanges(source, member.pos) ?? [];
249
+ // Walk in order, keeping only `/** */` blocks. Multiple back-to-back
250
+ // JSDocs concatenate (matches msg-annotations.ts's existing behavior).
251
+ const docs = ranges
252
+ .filter((r) => r.kind === ts.SyntaxKind.MultiLineCommentTrivia)
253
+ .map((r) => source.slice(r.pos, r.end))
254
+ .filter((txt) => txt.startsWith('/**'));
255
+ return docs.join('\n');
256
+ }
257
+ /**
258
+ * Match `@should("…")` (and curly-quote variant) anywhere in the
259
+ * JSDoc. Mirrors msg-annotations.ts's `@intent` parser — same grammar,
260
+ * same tolerance for either ASCII or curly quotes.
261
+ *
262
+ * Returns the unescaped string content, or null when the tag is
263
+ * absent or malformed.
264
+ */
265
+ function readShouldHint(comment) {
266
+ if (!comment)
267
+ return null;
268
+ const match = comment.match(/@should\s*\(\s*["“]([^"”]*)["”]\s*\)/);
269
+ return match?.[1] ?? null;
270
+ }
83
271
  //# sourceMappingURL=msg-schema.js.map