@platformos/platformos-mcp-supervisor 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.
@@ -17,9 +17,11 @@
17
17
  * Within a file and bucket, entries are taken from the FRONT, so what survives is the head
18
18
  * of a list already ordered by line and column — where the root cause of a cascade is.
19
19
  *
20
- * A STREAM THAT CANNOT FIT IS CLOSED, not skipped: once a bucket's next entry does not fit
21
- * it takes nothing further, so the returned list stays a contiguous head rather than a
22
- * scattered sample.
20
+ * A STREAM THAT CANNOT FIT TAKES NOTHING FURTHER, so the returned list stays a contiguous
21
+ * head rather than a scattered sample. That comes from `taken` not advancing — a retried
22
+ * entry costs the same and `spent` only grows, so it can never fit later. Closing the
23
+ * stream only saves the retry, and earns its keep there: 1.9 ms against 61 ms on a batch of
24
+ * blocked files, same output.
23
25
  *
24
26
  * ONE ERROR PER FILE IS GUARANTEED, budget or not — a blocked write with an empty `errors`
25
27
  * list names a problem and then declines to say what it is. Bounded: one entry per file,
@@ -134,53 +134,56 @@ export interface ValidateCodeDiagnostic {
134
134
  see_also?: string;
135
135
  }
136
136
  /**
137
- * Applicability of the blast-radius answer. Distinguishes a real "nothing depends on this"
138
- * (`computed`, total 0 — safe to change) from "we could not find out" (`unavailable`) and
139
- * from "this file type has no dependency graph" (`not_applicable`), so a failed lookup can
140
- * never be misread as a green light.
137
+ * Whether the cross-file comparison ran. `computed` means this file's dependants were linted
138
+ * with the change applied and without it; `unavailable` means that could not be done (a
139
+ * failure, or the deadline); `not_applicable` means the file has no dependants this stage
140
+ * can find — the graph holds no edges pointing at it, or it has no logical name.
141
141
  *
142
- * `not_applicable` covers files that are not graph-trackable edge targets (schema /
143
- * custom-model-type / translation YAML): they are wired by model/table NAME, not by file
144
- * reference (see ADR 004), so `total: 0` would falsely read as "safe to change". It also
145
- * covers a source in no platformOS directory, which has no logical name to reference.
142
+ * `disabled` means this SERVER was started with cross-file impact turned off (`--no-impact`),
143
+ * so nothing was attempted and retrying will not change that — which is the whole reason it
144
+ * is not folded into `unavailable`.
145
+ *
146
+ * NONE OF THE FOUR IS A CLEARANCE, including `computed`. See {@link ValidateCodeImpact}.
146
147
  */
147
- export type ValidateCodeImpactStatus = 'computed' | 'unavailable' | 'not_applicable';
148
+ export type ValidateCodeImpactStatus = 'computed' | 'unavailable' | 'not_applicable' | 'disabled';
148
149
  /**
149
- * Cross-file "blast radius" of editing the file: who DEPENDS ON it, which lint cannot see
150
- * (lint is per-file, forward-looking). Derived per request from the project's own text
151
- * with the changeset's buffers overlaid.
150
+ * What this change BREAKS in files the request is not editing — the question lint cannot
151
+ * answer, being per-file and forward-looking. Derived per request by linting the edited
152
+ * file's dependants twice, with the changeset applied and without it, and reporting only the
153
+ * difference. The findings are the check engine's own, so they carry its message, severity
154
+ * and documentation link rather than a second opinion invented here.
152
155
  *
153
- * `scope` is always `direct` (immediate callers only — transitive closure is noise).
154
- * `dependents` is meaningful ONLY when `status` is `computed`; otherwise it is zeroed and
155
- * the status says why.
156
+ * IT DOES NOT ANSWER "WHO DEPENDS ON THIS FILE", and never will: `{% render var %}` names its
157
+ * target at runtime, so no static analysis can enumerate a file's dependants, and a count
158
+ * that omits the ones it cannot see reads as a licence to delete. Only findings are
159
+ * published; their absence claims nothing.
156
160
  */
157
161
  export interface ValidateCodeImpact {
158
- scope: 'direct';
159
162
  status: ValidateCodeImpactStatus;
160
- dependents: {
161
- /** Number of distinct files that reference the edited file. */
162
- total: number;
163
- /** Distinct referencing files per edge kind (render/include/function/…); a file using two kinds counts in both. */
164
- by_kind: Record<string, number>;
165
- /** Up to 10 distinct referencing files, project-relative, sorted. */
166
- sample: string[];
167
- };
168
163
  /**
169
- * Dependent callers whose arguments do NOT match the edited file's `{% doc %}`
170
- * signature — the cross-file counterpart to the `PartialCallArguments` lint check.
171
- * Present ONLY when the edited buffer declares a `{% doc %}` block: an empty array means
172
- * "checked, every caller matches", absent means "no contract to check against".
164
+ * Files the change broke, each with the findings it GAINED. A finding already present
165
+ * before the change is not reported, however severe: it is not this edit's doing.
166
+ *
167
+ * PRESENT ONLY WHEN NON-EMPTY, and absence is not a clearance — it means no break was
168
+ * found among the dependants that are visible, which is not the same as none existing.
169
+ */
170
+ breaks?: ValidateCodeImpactBreak[];
171
+ /**
172
+ * Present only when more dependants were found than the bound allows: `returned` were
173
+ * linted, `total` were found.
174
+ *
175
+ * NOT the same kind of truncation as the result-level `truncated`, which withholds
176
+ * findings that WERE computed. The difference here was never checked at all, so an
177
+ * otherwise-clean answer carrying this field is a PARTIAL one.
173
178
  */
174
- signature_risk?: ValidateCodeSignatureRisk[];
179
+ unchecked_dependants?: ValidateCodeBucketTruncation;
175
180
  }
176
- /** One dependent caller at risk from the edited file's current `{% doc %}` signature. */
177
- export interface ValidateCodeSignatureRisk {
178
- /** The referencing file, project-relative. */
179
- caller: string;
180
- /** Required `@param`s the caller does not pass. */
181
- missing_required: string[];
182
- /** Arguments the caller passes that the `{% doc %}` block does not declare. */
183
- unexpected_args: string[];
181
+ /** One file the change broke, and what it broke in it. */
182
+ export interface ValidateCodeImpactBreak {
183
+ /** The broken file, project-relative. NOT a file the request asked about. */
184
+ file: string;
185
+ /** The findings this change introduced there, in reading order. */
186
+ diagnostics: ValidateCodeDiagnostic[];
184
187
  }
185
188
  /** How many entries of one bucket were returned, against how many were found. */
186
189
  export interface ValidateCodeBucketTruncation {
@@ -215,9 +218,8 @@ export interface ValidateCodeResult {
215
218
  warnings: ValidateCodeDiagnostic[];
216
219
  infos: ValidateCodeDiagnostic[];
217
220
  /**
218
- * Cross-file blast radius: who depends on the edited file. Always present; `status`
219
- * distinguishes a real "nothing depends on this" from "not computed".
220
- * See {@link ValidateCodeImpact}.
221
+ * Cross-file consequence of the edit: what it breaks in files you are not editing.
222
+ * Always present, and never a clearance. See {@link ValidateCodeImpact}.
221
223
  */
222
224
  impact: ValidateCodeImpact;
223
225
  /** Deterministic prose telling the agent what to do next. */
@@ -25,6 +25,7 @@
25
25
  * every session.
26
26
  */
27
27
  import { allChecks } from '@platformos/platformos-check-common';
28
+ import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from '../validate/batch-bounds.js';
28
29
  /**
29
30
  * The coverage line, DERIVED so it cannot describe a build that no longer exists.
30
31
  *
@@ -35,98 +36,64 @@ import { allChecks } from '@platformos/platformos-check-common';
35
36
  function coverage() {
36
37
  return `${allChecks.length} checks`;
37
38
  }
38
- export const SERVER_INSTRUCTIONS = `platformOS code validator.
39
-
40
- WHEN TO USE
41
- Call validate_code BEFORE writing or editing any platformOS Liquid, GraphQL or
42
- YAML file — it validates an in-memory buffer, so call it with the content you are
43
- about to write, not after writing. This is the primary quality gate. If you are changing several files as one coherent change,
44
- send them together in a single call (see the tool's \`files\` parameter): files in
45
- one call can reference each other, so a partial you are creating alongside its
46
- caller resolves correctly. Sent one at a time, that same edit is reported broken.
47
- List each file at most once — a changeset cannot hold two versions of one file, so a
48
- request that names the same file twice is refused rather than guessed at.
49
- Skipping this tool is the #1 cause of broken platformOS code.
50
-
51
- HOW TO READ THE RESULT
52
-
53
- must_fix_before_write
54
- true -> Do NOT write the file. It will not parse, it will raise at runtime, or
55
- the deploy converter will reject it — and a converter rejection fails the
56
- WHOLE changeset, not just this file.
57
- false -> Nothing BLOCKING was found. This is not a statement that the code is
58
- correct, only that no known-fatal problem was detected. Keep your own
59
- judgement.
60
-
61
- status
62
- ok | warning | error -> the file WAS checked; these describe what was found.
63
- not_applicable -> the file was NOT checked at all. This is neither
64
- approval nor refusal — it carries no opinion about the
65
- file. Read not_applicable_reason before deciding:
66
- outside_project - not inside the project this server serves
67
- unsupported_type - not a file type platformOS lints
68
- misplaced_source - a platformOS source outside every deployed subtree; nothing
69
- checked it and nothing will load it either
70
- ignored - excluded by the project's .platformos-check.yml
71
- too_large - one buffer, or the request as a whole, is above its size
72
- limit. Split the file, or send fewer files per call — the
73
- reason text says which bound was hit
74
- timed_out - validation was abandoned; retrying may work
75
- internal_error - your REQUEST was malformed, or the validator hit a bug; the
76
- reason text says which. A malformed request — both input
77
- forms at once, neither, or one file listed twice — is yours
78
- to fix and worth retrying once fixed.
39
+ /**
40
+ * The request caps, DERIVED for the same reason as the count above.
41
+ *
42
+ * `MAX_BATCH_FILES` already reaches the model as `maxItems` on the tool's schema, but
43
+ * `MAX_BATCH_BYTES` is computed from the cost model and appears in no schema and no prose —
44
+ * so a caller could only learn it by being refused. Stated together because a batch is
45
+ * planned against both at once, and derived so that moving the cost model moves this too.
46
+ */
47
+ function bounds() {
48
+ return `${MAX_BATCH_FILES} files and ${Math.floor(MAX_BATCH_BYTES / 1024)} KiB`;
49
+ }
50
+ export const SERVER_INSTRUCTIONS = `Tool Purpose: Validates platformOS Liquid, GraphQL and YAML files in-memory to prevent broken deployments.
79
51
 
80
- errors / warnings / infos
81
- Each list is ordered by line then column WITHIN ITSELF; the three are not one
82
- ordered sequence, so concatenating them does not walk the file in order. Columns
83
- count UTF-16 code units, so an emoji advances the column by 2.
84
- Note that errors[] can be non-empty while must_fix_before_write is false: some
85
- errors are real problems that do not stop the file working (an argument a partial
86
- ignores, a missing asset, a missing image dimension). Fix them when you can; they
87
- do not block the write.
88
- Each finding names the check that produced it, and where see_also is present it links
89
- that check's documentation — read it there rather than guessing at the rule.
90
- A finding may carry fix (edits that are safe to apply as given) or suggestions
91
- (alternatives to choose between). Their start_index / end_index are 0-based offsets
92
- into the buffer you sent — NOT the 1-based line/column on the same finding. Apply
93
- several edits from the end backwards, or account for the drift.
52
+ CRITICAL USAGE RULES
53
+ - Validate BEFORE writing: always pass the proposed in-memory buffer. NEVER write to disk first. This is the primary quality gate. Skipping this tool is the #1 cause of broken platformOS code.
54
+ - Audit existing code with the same call: pass a file's current contents to learn whether what is on disk is already broken. Its own findings are real, but impact is not — impact lints the dependants with your buffer and again without it and reports the difference, so an unchanged buffer yields an empty impact by construction, not by safety.
55
+ - Batch interdependent files: if one change touches several referencing files, send them together in the files array so cross-references resolve. Sent one at a time, a partial you are creating alongside its caller is reported missing. Up to ${bounds()} per request.
56
+ - No duplicates: List each file at most once per request. A changeset cannot hold two versions of one file, so a request naming one twice is refused.
94
57
 
95
- truncated
96
- Present ONLY when a file had so many findings that the lists were shortened to
97
- keep the answer a reasonable size; absent means the lists are complete. It gives
98
- the true total per affected list, so "returned: 40, total: 900" means 860 more
99
- exist. The lists keep the TOP of the file, where a cascade's root cause usually
100
- is. status and must_fix_before_write are always computed from ALL findings, never
101
- from the shortened list, so a truncated answer is never a softer verdict — fix
102
- what is listed and validate again to see the rest.
58
+ RESPONSE SCHEMA & ACTION DIRECTIVES
103
59
 
104
- impact
105
- Which other files depend on this one, worked out from the project as it stands at the
106
- moment of the call. \`status: unavailable\` means it could not be worked out — a failure,
107
- or it ran out of time — and its zeroed counts are NOT a claim that nothing depends on
108
- the file. Only \`status: computed\` with \`total: 0\` says that.
60
+ must_fix_before_write (Boolean)
61
+ - true: FATAL. Do NOT write the file. The deploy converter refuses the WHOLE changeset, not just this file.
62
+ - false: nothing blocking was found. This is NOT a statement that the code is correct, only that no known-fatal problem was detected. Keep your own judgement.
109
63
 
110
- WHAT IS NOT CHECKED
111
- ${coverage()} run against your buffer. Where a finding exists it explains itself and links
112
- its documentation, so what follows is only the SILENCES — the places where finding nothing is not the same
113
- as finding it correct, and where no diagnostic can tell you so.
64
+ status (String)
65
+ - ok | warning | error: the file WAS checked; these describe what was found.
66
+ - not_applicable: the file was NOT checked. Neither approval nor refusal — it carries no opinion about the file.
67
+ - not_applicable_reason: outside_project, unsupported_type, misplaced_source, ignored, too_large, timed_out, internal_error.
68
+ - next_step: the one to act on. It names the path, limit or deadline actually hit, and what to do about it.
69
+ - too_large: one buffer, or the request as a whole, is above its size limit. next_step says which, so shrink a file or send fewer per call accordingly.
70
+ - Retry: timed_out is worth another call. internal_error is two cases — a malformed request (both input forms at once, neither, or one file listed twice) is yours to fix and worth retrying once fixed; a validator bug is not, and no retry will change it.
114
71
 
115
- Argument types are checked only where the platformOS documentation publishes a type.
116
- An argument the documentation leaves untyped accepts more than one type and is never
117
- reported — which is every argument of every core Liquid filter, because those coerce
118
- rather than refuse. {{ 5 | upcase }} renders and is not reported. That silence is the
119
- documentation's answer, not a guarantee the value is right.
72
+ errors, warnings, infos (Arrays)
73
+ - Each array is ordered by line then column WITHIN ITSELF. The three are not one sequence, so concatenating them does not walk the file in order. Columns count UTF-16 code units, so an emoji advances the column by 2.
74
+ - errors[] can be non-empty while must_fix_before_write is false: an argument a partial ignores, a missing asset, a missing image dimension are real but do not stop the file working.
75
+ - Fixes and suggestions carry start_index / end_index: 0-based offsets into the buffer you sent, NOT the 1-based line/column on the same finding.
76
+ - Action: to avoid offset drift, apply multiple edits from the bottom of the file upwards.
77
+ - Action: where see_also is present, read that check's documentation rather than guessing at the rule.
120
78
 
121
- The SHAPE of a model schema is not checked. An unknown property is not reported,
122
- because the platform accepts it.
79
+ truncated (Object)
80
+ - Present only when a file produced too many findings to return in full. The lists keep the TOP of the file, where a cascade's root cause usually is.
81
+ - Constraint: status and must_fix_before_write are ALWAYS computed from the true total of findings, never from the shortened list. Fix what is listed and re-validate to see the rest.
123
82
 
124
- Duplicate YAML keys are compared the way the platform's own parser resolves them, so
125
- yes: and true: in one mapping are reported as the one key they become. That comparison
126
- is NOT exhaustive — a few spellings collide on the platform and are not reported — so
127
- silence there does not prove two keys are distinct.
83
+ impact (Object)
84
+ - What this change BREAKS in files you are NOT editing — the one thing a per-file lint cannot see, since it only looks at the buffers you sent.
85
+ - It reports only what your change INTRODUCED. A problem the file already had is never listed, however severe.
86
+ - Constraint: a dependant break does NOT set must_fix_before_write. Your buffer may be perfectly correct; fix the callers or decide not to.
87
+ - impact.status:
88
+ - computed: the dependants were linted with your change and without it.
89
+ - not_applicable: no dependant the graph can find names this file. That is not a clearance.
90
+ - unavailable | disabled: the comparison never ran. An empty impact under either is NOT a finding of safety.
91
+ - unchecked_dependants: present when a file has more dependants than one request will lint, so an otherwise-clean answer carrying it is a PARTIAL one.
92
+ - NOTHING HERE IS A CLEARANCE. A dependant can be invisible: {% render partial_name %} picks its target at runtime, so no analysis can resolve it. This server never tells you nothing depends on a file — before renaming or deleting one, search the project yourself.
128
93
 
129
- Coverage is per project: checks can be enabled, disabled or ignored in the project's
130
- .platformos-check.yml, so a clean result reflects that project's configuration rather
131
- than a fixed universal standard.`;
94
+ LIMITATIONS (what a clean result does NOT prove)
95
+ ${coverage()} run against your buffer. Coverage is per project: checks can be enabled, disabled or ignored in .platformos-check.yml, so a clean result reflects that configuration rather than a fixed universal standard. Where a finding exists it explains itself and links its documentation, so what follows is only the SILENCES.
96
+ - An argument the documentation leaves untyped accepts more than one type and is never reported — which is every argument of every core Liquid filter, because those coerce rather than refuse. {{ 5 | upcase }} renders and is not reported.
97
+ - A model schema's property TYPE is checked and an unknown one is reported. The rest of the shape is not: an unrecognised top-level key is rejected on deploy and nothing reports it.
98
+ - Duplicate YAML keys are compared the way the platform's own parser resolves them, but NOT exhaustively — a few spellings collide on the platform and are not reported, so silence there does not prove two keys are distinct.`;
132
99
  //# sourceMappingURL=instructions.js.map
@@ -16,6 +16,11 @@ export interface ServerOptions {
16
16
  log?: Logger;
17
17
  /** Advertised server version. */
18
18
  version?: string;
19
+ /**
20
+ * Whether cross-file impact runs. Defaults to ON — only an explicit `false`, from
21
+ * `--no-impact` or `POS_SUPERVISOR_NO_IMPACT`, turns it off.
22
+ */
23
+ impactEnabled?: boolean;
19
24
  }
20
25
  export interface ServerHandle {
21
26
  server: McpServer;
@@ -17,10 +17,14 @@ const DEFAULT_VERSION = '0.0.1';
17
17
  export async function startServer(opts) {
18
18
  const log = opts.log ?? createLogger(SERVER_NAME);
19
19
  // NOTHING IS BUILT OR WARMED HERE, by design. check-node owns one lazy `App` per project
20
- // at process level and reconciles it per call, and the blast radius is derived per
21
- // request from the project's text (`impact/project-scan.ts`) — so there is no graph to
22
- // build at boot, nothing to keep fresh, and no "still computing" answer.
23
- const context = { projectDir: opts.projectDir, log };
20
+ // at process level and reconciles it per call, and impact is derived per request from
21
+ // the project's text (`impact/project-scan.ts`) — so there is no graph to build at boot,
22
+ // nothing to keep fresh, and no "still computing" answer.
23
+ const context = {
24
+ projectDir: opts.projectDir,
25
+ log,
26
+ impactEnabled: opts.impactEnabled,
27
+ };
24
28
  // `instructions` reaches the model with the tool list; without them an agent has only the
25
29
  // tool description to go on and invents a reading of the result (see instructions.ts).
26
30
  const server = new McpServer({ name: SERVER_NAME, version: opts.version ?? DEFAULT_VERSION }, { instructions: SERVER_INSTRUCTIONS });
@@ -16,13 +16,34 @@
16
16
  * This module owns only the MCP surface: schema, description, registration, and mapping
17
17
  * input shape to output shape. All logic lives in `validate/`.
18
18
  */
19
- import { type ZodRawShape } from 'zod';
19
+ import { z } from 'zod';
20
20
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
21
21
  import type { SupervisorContext } from '../context.js';
22
22
  import type { ValidateCodeParams, ValidateCodeResult, ValidateFilesResult } from '../result/types.js';
23
23
  import { type ValidateAdapters } from '../validate/validate-buffers.js';
24
24
  import { MAX_BATCH_BYTES, MAX_BATCH_FILES } from '../validate/batch-bounds.js';
25
- export declare const VALIDATE_CODE_INPUT: ZodRawShape;
25
+ /**
26
+ * zod raw shape for the tool input (validated by the MCP SDK before dispatch).
27
+ *
28
+ * Both forms are optional at the schema level and the handler requires exactly one,
29
+ * because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
30
+ * — it emits `anyOf`, which several MCP clients do not surface to the model at all.
31
+ * Two optional fields with a documented rule converts cleanly and reads clearly.
32
+ *
33
+ * WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
34
+ * the arguments arrives as the JSON Schema this shape converts to, so a constraint
35
+ * that does not convert may as well not be advertised at all. Prefer a zod builtin
36
+ * over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
37
+ * pins the emitted document end to end.
38
+ */
39
+ export declare const VALIDATE_CODE_INPUT: {
40
+ file_path: z.ZodOptional<z.ZodString>;
41
+ content: z.ZodOptional<z.ZodString>;
42
+ files: z.ZodOptional<z.ZodArray<z.ZodObject<{
43
+ file_path: z.ZodString;
44
+ content: z.ZodString;
45
+ }, z.core.$strip>>>;
46
+ };
26
47
  /**
27
48
  * The agent-facing prose, exported so tests can pin claims that must not rot. The
28
49
  * description is the agent's whole understanding of this tool; a stale sentence
@@ -22,22 +22,29 @@ import { validateBuffers, } from '../validate/validate-buffers.js';
22
22
  import { MAX_BATCH_BYTES, MAX_BATCH_FILES, batchTooLarge } from '../validate/batch-bounds.js';
23
23
  import { collidingBufferPaths } from '../validate/batch-coherence.js';
24
24
  import { assembleNotApplicableResult } from '../result/assemble.js';
25
- /** A non-blank path. Blank is malformed input, refused at the protocol boundary. */
26
- const filePath = z
27
- .string()
28
- .refine((value) => value.trim().length > 0, { message: 'file_path must not be empty' });
29
- const content = z.string();
30
25
  /**
31
- * zod raw shape for the tool input (validated by the MCP SDK before dispatch).
26
+ * A non-blank path. Blank is malformed input, refused at the protocol boundary.
32
27
  *
33
- * Both forms are optional at the schema level and the handler requires exactly one,
34
- * because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
35
- * — it emits `anyOf`, which several MCP clients do not surface to the model at all.
36
- * Two optional fields with a documented rule converts cleanly and reads clearly.
28
+ * BOTH halves are load-bearing and neither is redundant. The refinement is what
29
+ * refuses a whitespace-only path; `.min(1)` is the half that SURVIVES conversion to
30
+ * JSON Schema, as `minLength`. A refinement converts to nothing at all, so with the
31
+ * refinement alone the rule exists only on this side of the wire — the model is
32
+ * never told, sends `""`, and learns the rule from a rejection. Drop either half and
33
+ * a test fails: the emitted schema loses `minLength`, or `' '` starts being accepted.
37
34
  *
38
- * Typed as `ZodRawShape` (not the inferred literal shape) so `registerTool` does not
39
- * instantiate excessively deeply over the schema (TS2589 under zod 3.25).
35
+ * A FUNCTION, not a shared constant, for the same wire-level reason: the SDK's
36
+ * converter dedupes by schema IDENTITY, so one instance reused across both input
37
+ * forms emits `{ $ref: '#/properties/file_path' }` for the second use. That drags the
38
+ * single-file description into the batch entries, where "Pair with `content`" is
39
+ * wrong, and puts a `$ref` in a tool inputSchema — which not every MCP client
40
+ * resolves. A fresh instance per use costs nothing and emits in full.
40
41
  */
42
+ const filePath = () => z
43
+ .string()
44
+ .min(1)
45
+ .refine((value) => value.trim().length > 0, { message: 'file_path must not be empty' });
46
+ /** File contents. Empty is legal — an empty file is a file. Fresh per use, as above. */
47
+ const content = () => z.string();
41
48
  /**
42
49
  * An example path of each kind, for the agent-facing prose below.
43
50
  *
@@ -52,16 +59,33 @@ const exampleOf = (type, name) => `${getAppPaths(type)[0]}/${name}.liquid`;
52
59
  const EXAMPLE_PARTIAL = exampleOf(PlatformOSFileType.Partial, 'card');
53
60
  const EXAMPLE_PAGE = exampleOf(PlatformOSFileType.Page, 'home');
54
61
  const EXAMPLE_PROMO = exampleOf(PlatformOSFileType.Partial, 'promo');
62
+ /**
63
+ * zod raw shape for the tool input (validated by the MCP SDK before dispatch).
64
+ *
65
+ * Both forms are optional at the schema level and the handler requires exactly one,
66
+ * because a zod union of two object shapes defeats the SDK's JSON-Schema conversion
67
+ * — it emits `anyOf`, which several MCP clients do not surface to the model at all.
68
+ * Two optional fields with a documented rule converts cleanly and reads clearly.
69
+ *
70
+ * WHAT THE MODEL SEES IS THE CONVERSION, NOT THIS. Everything an agent knows about
71
+ * the arguments arrives as the JSON Schema this shape converts to, so a constraint
72
+ * that does not convert may as well not be advertised at all. Prefer a zod builtin
73
+ * over a `.refine` wherever one expresses the rule; `transport/validate-code.spec.ts`
74
+ * pins the emitted document end to end.
75
+ */
55
76
  export const VALIDATE_CODE_INPUT = {
56
- file_path: filePath
77
+ file_path: filePath()
57
78
  .optional()
58
79
  .describe('Path of the file to validate, absolute or relative to the project root ' +
59
80
  `(e.g. "${EXAMPLE_PARTIAL}"). Pair with \`content\`.`),
60
- content: content
81
+ content: content()
61
82
  .optional()
62
83
  .describe('The exact file contents you are about to write. Pair with `file_path`.'),
63
84
  files: z
64
- .array(z.object({ file_path: filePath, content }))
85
+ .array(z.object({
86
+ file_path: filePath().describe('Path of THIS file, absolute or relative to the project root.'),
87
+ content: content().describe('The exact contents you are about to write to THIS file.'),
88
+ }))
65
89
  .min(1)
66
90
  .max(MAX_BATCH_FILES)
67
91
  .optional()
@@ -138,9 +162,20 @@ export async function runValidateCode(ctx, params, overrides = {}) {
138
162
  }
139
163
  const { buffers, isBatch } = requested;
140
164
  ctx.log(isBatch ? `validate_code: ${buffers.length} file(s)` : `validate_code: ${buffers[0].filePath}`);
141
- // Both refusals are request-level and impossible with a single buffer, so they are
142
- // decided here, before any work starts. First refusal wins; the cheaper check runs first.
143
- const refusal = isBatch
165
+ // Request-level refusals, decided before any work starts. First refusal wins; the cheaper
166
+ // check runs first.
167
+ //
168
+ // GATED ON MORE THAN ONE BUFFER, not on which form carried it. One file is one file, and
169
+ // the two forms must answer identically — the shape an agent happened to reach for cannot
170
+ // change a verdict. Both refusals ask a question a lone buffer cannot answer YES to:
171
+ // collision needs two entries naming one file, and the caps exist because a batch can hand
172
+ // the server work no single buffer could. Collision was already a no-op at one buffer; the
173
+ // total-byte cap was NOT, and it fired FIRST — so a 300 KiB file sent as a one-file batch
174
+ // was refused with "split it into smaller batches", advice its caller cannot follow, and
175
+ // was never told MAX_BUFFER_BYTES, the bound it actually had to get under. Whether ONE
176
+ // buffer is admissible is `bufferTooLarge`'s question, asked per buffer inside
177
+ // `validateBuffers` for both forms alike.
178
+ const refusal = buffers.length > 1
144
179
  ? (batchTooLarge(buffers) ?? collidingBufferPaths(ctx.projectDir, buffers))
145
180
  : undefined;
146
181
  if (refusal) {
@@ -39,6 +39,11 @@ export const MAX_BATCH_BYTES = maxBytesWithin(MAX_LINT_DEADLINE_MS);
39
39
  * validating a subset would report a changeset as checked when it was not.
40
40
  */
41
41
  export function batchTooLarge(buffers) {
42
+ // UNREACHABLE OVER MCP — `VALIDATE_CODE_INPUT.files` caps the array with
43
+ // `.max(MAX_BATCH_FILES)` first, and `runValidateCode` is not on the package's public
44
+ // surface — so zero coverage here is the schema working, not a gap. Kept as defence-in-depth
45
+ // because only this bound lives inside the function that promises it, and asked FIRST
46
+ // because splitting a request by bytes can still leave too many files.
42
47
  if (buffers.length > MAX_BATCH_FILES) {
43
48
  return {
44
49
  code: 'too_large',