appilot-mcp 0.1.0 → 0.2.1

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.
@@ -52,6 +52,11 @@ export function snapshotFromBundle(entities, expectedLocales) {
52
52
  };
53
53
  });
54
54
  const zones = entities.zones.map(z => ({ semantic_id: z.semantic_id }));
55
+ const tools = entities.tools.map(t => ({
56
+ tool_name: t.tool_name,
57
+ kind: String(t.runtime_spec?.kind ?? ''),
58
+ view_path: t.view?.path ?? null,
59
+ }));
55
60
  const knowledge = entities.knowledge_content.map(k => ({
56
61
  id: k.group,
57
62
  scope: k.scope,
@@ -61,5 +66,7 @@ export function snapshotFromBundle(entities, expectedLocales) {
61
66
  title: b.title,
62
67
  })),
63
68
  }));
64
- return { expectedLocales, views, controls, forms, zones, actionPlans, knowledge };
69
+ // A bundle is complete by construction (the envelope validator refuses a
70
+ // partial one), so there is never a gap on this path.
71
+ return { expectedLocales, views, controls, forms, tools, zones, actionPlans, knowledge };
65
72
  }
@@ -16,4 +16,4 @@ import type { ConfigSnapshot, HealthReport } from './types.js';
16
16
  * Run the full config health contract over a snapshot. Findings are returned
17
17
  * most-severe first, so the report reads like the manual audit.
18
18
  */
19
- export declare function runHealthContract(snap: ConfigSnapshot): HealthReport;
19
+ export declare function runHealthContract(input: ConfigSnapshot): HealthReport;
@@ -13,7 +13,8 @@
13
13
  */
14
14
  import { validateActionPlanMarkers } from 'appilot-shared/utils';
15
15
  import { IDENTIFIER_KINDS } from 'appilot-shared/validation';
16
- const VALID_KB_SCOPES = new Set(['app_global', 'domain_global', 'page']);
16
+ import { KNOWLEDGE_SCOPES } from 'appilot-shared/types';
17
+ const VALID_KB_SCOPES = new Set(KNOWLEDGE_SCOPES);
17
18
  /** Extract every `{{kind:id}}` marker (with or without a `[label](...)` wrapper). */
18
19
  function extractMarkers(step) {
19
20
  const rx = /\{\{([a-z_]+):([^}]+)\}\}/g;
@@ -87,13 +88,30 @@ function lintActionability(snap) {
87
88
  const entersValue = hasFormStep || hasSetValue || hasFormValues;
88
89
  const looksLikeCreate = localizedValues({ ...plan.name, ...plan.description }).some(v => CREATE_INTENT.test(v))
89
90
  || CREATE_INTENT.test(plan.semantic_id);
90
- if (!entersValue) {
91
+ // A plan that enters no value is not automatically inert. The defect
92
+ // this lint exists for is a plan that OPENS or FOCUSES something and
93
+ // stops, which is what "click the input, then nothing" looks like. A
94
+ // plan whose task is a decision, approve, reject, publish, archive,
95
+ // completes that task with the click itself, and there is no value to
96
+ // enter anywhere in it.
97
+ //
98
+ // So the test is what the clicks target. A click on a control that is
99
+ // not one of a form's own fields commits something. Only a plan with no
100
+ // commit at all, or one whose wording promises a create or an edit, is
101
+ // the defect.
102
+ const formFieldIds = new Set(snap.forms.flatMap(f => f.field_ids));
103
+ const commits = markers.some(m => m.kind === 'click' && !formFieldIds.has(m.id));
104
+ if (!entersValue && (looksLikeCreate || !commits)) {
91
105
  findings.push({
92
106
  severity: looksLikeCreate ? 'critical' : 'high',
93
107
  category: 'actionability',
94
108
  entity: `action_plan:${plan.semantic_id}`,
95
- message: 'Plan enters no value: it has no {{form:…}} step, no set_value step, and empty form_values, so it only opens/focuses an element and never completes the task.',
96
- recommendation: 'Add a {{form:…}} step (or a set_value step) to enter the value, then a click step on the submit control to confirm.',
109
+ message: looksLikeCreate
110
+ ? 'Plan enters no value: it has no {{form:…}} step, no set_value step, and empty form_values, so it opens an element and never completes the create or edit its description promises.'
111
+ : 'Plan neither enters a value nor clicks anything outside a form\'s own fields, so it only opens or focuses an element and never completes the task.',
112
+ recommendation: looksLikeCreate
113
+ ? 'Add a {{form:…}} step (or a set_value step) to enter the value, then a click step on the submit control to confirm.'
114
+ : 'Add the step that commits the task: a click on the control that confirms it, or a {{form:…}} step followed by a submit.',
97
115
  });
98
116
  }
99
117
  // Submit-without-fill: clicks a form's submit but never fills that form
@@ -209,7 +227,7 @@ function lintKnowledge(snap) {
209
227
  findings.push({
210
228
  severity: 'high', category: 'kb-scope', entity,
211
229
  message: `Invalid knowledge scope "${kb.scope}".`,
212
- recommendation: 'Use one of app_global, domain_global, page.',
230
+ recommendation: `Use one of ${KNOWLEDGE_SCOPES.join(', ')}.`,
213
231
  });
214
232
  }
215
233
  const languages = new Set(kb.bodies.map(b => b.language));
@@ -274,14 +292,58 @@ function lintIdentifiers(snap) {
274
292
  lintIdentifier(z.semantic_id, `zone:${z.semantic_id}`, findings);
275
293
  for (const p of snap.actionPlans)
276
294
  lintIdentifier(p.semantic_id, `action_plan:${p.semantic_id}`, findings);
295
+ // A tool name is its own identifier kind: it reaches the model as a callable
296
+ // name, so it follows the tool_name rule rather than semantic_id. Medium, not
297
+ // high: an off-shape name still resolves at runtime, and a high finding would
298
+ // turn importing a working configuration that predates the rule into a 422.
299
+ for (const t of snap.tools) {
300
+ if (!IDENTIFIER_KINDS.tool_name.isValid(t.tool_name)) {
301
+ findings.push({
302
+ severity: 'medium', category: 'identifier', entity: `tool:${t.tool_name}`,
303
+ message: `Tool name "${t.tool_name}" is not a valid tool_name (${IDENTIFIER_KINDS.tool_name.rule}).`,
304
+ recommendation: `Rename it, for example ${IDENTIFIER_KINDS.tool_name.example}. The model calls the tool by this name.`,
305
+ });
306
+ }
307
+ }
277
308
  return findings;
278
309
  }
310
+ // ---------------------------------------------------------------------------
311
+ // Lint 7 — snapshot completeness.
312
+ //
313
+ // Reported as findings rather than thrown, because a partial snapshot is still
314
+ // worth auditing. What must not happen is a partial snapshot reading as a clean
315
+ // bill of health: every lint below an unreadable entity silently passes.
316
+ // ---------------------------------------------------------------------------
317
+ function lintGaps(snap) {
318
+ return (snap.gaps ?? []).map(g => ({
319
+ severity: 'high',
320
+ category: 'coverage',
321
+ entity: `snapshot:${g.entity}`,
322
+ message: `${g.entity} could not be read, so no ${g.entity} lint ran: ${g.reason}`,
323
+ recommendation: 'Fix the access problem and re-run. Treat every finding in this report as incomplete until this line is gone.',
324
+ }));
325
+ }
279
326
  const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 };
280
327
  /**
281
328
  * Run the full config health contract over a snapshot. Findings are returned
282
329
  * most-severe first, so the report reads like the manual audit.
283
330
  */
284
- export function runHealthContract(snap) {
331
+ export function runHealthContract(input) {
332
+ // Normalise once. A snapshot reaches this function from three producers (the
333
+ // live-API client, the bundle adapter, the backend import path) and every lint
334
+ // below iterates these arrays without a guard; one producer omitting one of
335
+ // them would otherwise crash the audit instead of reporting on it.
336
+ const snap = {
337
+ ...input,
338
+ expectedLocales: input.expectedLocales ?? [],
339
+ views: input.views ?? [],
340
+ controls: input.controls ?? [],
341
+ forms: input.forms ?? [],
342
+ tools: input.tools ?? [],
343
+ zones: input.zones ?? [],
344
+ actionPlans: input.actionPlans ?? [],
345
+ knowledge: input.knowledge ?? [],
346
+ };
285
347
  const findings = [
286
348
  ...lintMarkers(snap),
287
349
  ...lintActionability(snap),
@@ -289,6 +351,7 @@ export function runHealthContract(snap) {
289
351
  ...lintI18n(snap),
290
352
  ...lintKnowledge(snap),
291
353
  ...lintIdentifiers(snap),
354
+ ...lintGaps(snap),
292
355
  ].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
293
356
  const counts = { critical: 0, high: 0, medium: 0, low: 0 };
294
357
  for (const f of findings)
@@ -7,7 +7,7 @@
7
7
  * same validator works online, offline (air-gapped), and in unit fixtures.
8
8
  */
9
9
  export type Severity = 'critical' | 'high' | 'medium' | 'low';
10
- export type FindingCategory = 'markers' | 'actionability' | 'selector-stability' | 'i18n-coverage' | 'kb-scope' | 'kb-procedure' | 'kb-hygiene' | 'identifier';
10
+ export type FindingCategory = 'markers' | 'actionability' | 'selector-stability' | 'i18n-coverage' | 'kb-scope' | 'kb-procedure' | 'kb-hygiene' | 'identifier' | 'coverage';
11
11
  export interface Finding {
12
12
  severity: Severity;
13
13
  category: FindingCategory;
@@ -21,6 +21,9 @@ export interface Finding {
21
21
  /** A per-locale text map, e.g. `{ de: 'Neuer Ordner', en: 'New folder' }`. */
22
22
  export type LocalizedText = Record<string, string | undefined>;
23
23
  export interface ControlSnapshot {
24
+ /** Row id, when the snapshot came from the live API. Absent from a bundle,
25
+ * which is keyed semantically. `update_control` needs it. */
26
+ id?: string;
24
27
  semantic_id: string;
25
28
  locator_type: string;
26
29
  locator: string;
@@ -34,6 +37,8 @@ export interface FormSnapshot {
34
37
  entry_control_id?: string | null;
35
38
  }
36
39
  export interface ActionPlanSnapshot {
40
+ /** Row id, when the snapshot came from the live API. See ControlSnapshot.id. */
41
+ id?: string;
37
42
  semantic_id: string;
38
43
  sections: Array<{
39
44
  view_path?: string;
@@ -52,9 +57,25 @@ export interface ActionPlanSnapshot {
52
57
  is_active?: boolean;
53
58
  }
54
59
  export interface ZoneSnapshot {
60
+ id?: string;
55
61
  semantic_id: string;
56
62
  }
63
+ /**
64
+ * A tool as the agent needs to reason about it: what it is called, what kind of
65
+ * runtime backs it, and whether it is bound to a view. Not the full definition;
66
+ * `export_config` carries that.
67
+ */
68
+ export interface ToolSnapshot {
69
+ id?: string;
70
+ tool_name: string;
71
+ /** `http_proxy`, `client_action`, … Empty when the instance does not say. */
72
+ kind: string;
73
+ view_path?: string | null;
74
+ is_active?: boolean;
75
+ }
57
76
  export interface KnowledgeSnapshot {
77
+ /** Row id on the live-API path (what `update_knowledge` takes), the
78
+ * translation group on the bundle path. */
58
79
  id: string | number;
59
80
  scope: string;
60
81
  /** Body text per language row (knowledge_content is one row per language). */
@@ -69,15 +90,30 @@ export interface ViewSnapshot {
69
90
  path: string;
70
91
  name: LocalizedText;
71
92
  }
93
+ /**
94
+ * An entity the snapshot could not read, and why.
95
+ *
96
+ * A snapshot with an empty `knowledge` array and a snapshot whose knowledge read
97
+ * was refused look identical, and the second one silently turns every knowledge
98
+ * lint into a pass. Recording the failure is what lets `read_config` and
99
+ * `validate_config` say "I could not see this" instead of "this is fine".
100
+ */
101
+ export interface SnapshotGap {
102
+ entity: 'views' | 'controls' | 'forms' | 'tools' | 'zones' | 'actionPlans' | 'knowledge';
103
+ reason: string;
104
+ }
72
105
  export interface ConfigSnapshot {
73
106
  /** Locales the config is expected to cover, e.g. ['de','en','es']. */
74
107
  expectedLocales: string[];
75
108
  views: ViewSnapshot[];
76
109
  controls: ControlSnapshot[];
77
110
  forms: FormSnapshot[];
111
+ tools: ToolSnapshot[];
78
112
  actionPlans: ActionPlanSnapshot[];
79
113
  zones: ZoneSnapshot[];
80
114
  knowledge: KnowledgeSnapshot[];
115
+ /** Entities that could not be read. Empty on a complete snapshot. */
116
+ gaps?: SnapshotGap[];
81
117
  }
82
118
  export interface HealthReport {
83
119
  ok: boolean;