@platformos/platformos-mcp-supervisor 0.1.0 → 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.
@@ -0,0 +1,33 @@
1
+ /** The comparison key: what makes two findings the same finding. */
2
+ function identityOf(diagnostic) {
3
+ return `${diagnostic.check}\u0000${diagnostic.line}\u0000${diagnostic.column}`;
4
+ }
5
+ /**
6
+ * The diagnostics in `after` that are not in `before`, preserving `after`'s order.
7
+ *
8
+ * `before` is the file as it stands with NONE of the changeset overlaid; `after` is the same
9
+ * file with the changeset applied. Everything returned is therefore attributable to the
10
+ * change.
11
+ */
12
+ export function introducedDiagnostics(after, before) {
13
+ if (after.length === 0)
14
+ return [];
15
+ const remaining = new Map();
16
+ for (const diagnostic of before) {
17
+ const key = identityOf(diagnostic);
18
+ remaining.set(key, (remaining.get(key) ?? 0) + 1);
19
+ }
20
+ const introduced = [];
21
+ for (const diagnostic of after) {
22
+ const key = identityOf(diagnostic);
23
+ const unmatched = remaining.get(key) ?? 0;
24
+ // Each `before` occurrence cancels exactly one `after` occurrence, so a code that
25
+ // legitimately fires twice does not swallow a third.
26
+ if (unmatched > 0)
27
+ remaining.set(key, unmatched - 1);
28
+ else
29
+ introduced.push(diagnostic);
30
+ }
31
+ return introduced;
32
+ }
33
+ //# sourceMappingURL=diff.js.map
@@ -1,9 +1,21 @@
1
- import { type AdapterInput } from '../adapter-input.js';
1
+ import { type DocsetVocabulary } from '../enrich/enrich.js';
2
+ import type { BatchBuffer, runBatchLint } from '../lint/lint-batch.js';
2
3
  import type { ValidateCodeImpact } from '../result/types.js';
3
4
  import type { ProjectScan } from './project-scan.js';
5
+ /** Everything one impact pass needs. The lint seam is injected so the contract is testable. */
6
+ export interface ImpactInput {
7
+ projectDir: string;
8
+ /** The changeset, exactly as the primary lint received it. */
9
+ buffers: readonly BatchBuffer[];
10
+ scan: ProjectScan;
11
+ lint: typeof runBatchLint;
12
+ docset: () => Promise<DocsetVocabulary>;
13
+ log: (message: string) => void;
14
+ }
4
15
  /**
5
- * Compute the edited file's blast radius from the project as it is right now.
6
- * Reports `not_applicable` when nothing could reference the file by name, and
7
- * throws only on real I/O failure — the caller degrades that to `unavailable`.
16
+ * The impact of the whole changeset, per requested buffer key.
17
+ *
18
+ * Throws only on real I/O failure — the caller degrades that to `unavailable` for every
19
+ * buffer, because a failure here says nothing about any of them.
8
20
  */
9
- export declare function runImpact(params: AdapterInput, scan: ProjectScan): Promise<ValidateCodeImpact>;
21
+ export declare function runImpact(input: ImpactInput): Promise<Map<string, ValidateCodeImpact>>;
@@ -1,189 +1,160 @@
1
1
  /**
2
- * Impact (blast-radius) adapter — an I/O boundary on the request path (sibling
3
- * to `lint/`).
2
+ * Impact — what this change breaks in files the agent is NOT editing.
4
3
  *
5
- * Answers the one question lint structurally cannot: "who DEPENDS ON the file
6
- * being edited?" — its incoming references across the project.
4
+ * Lint is per-file and forward-looking: it visits only the buffers it was sent, so a page
5
+ * that the edited partial has just broken is never even looked at. This stage looks at it,
6
+ * and it does so by ASKING THE CHECK ENGINE rather than by re-deriving the answer:
7
7
  *
8
- * HOW, AND WHY IT IS NOT A GRAPH. Every edge platformos-graph records names its target with
9
- * a STATIC STRING LITERAL: the operand of `render`/`include`/`function`/`background`/
10
- * `graphql`, the operand of `asset_url`, or an explicit frontmatter `layout:` (an omitted
11
- * `layout:` synthesizes no edge — see `traverse.ts`). So a file's dependents can only be
12
- * among the edge sources whose TEXT contains its logical name, and filtering on that name is
13
- * a sound over-approximation leaving few survivors (measured p50 1, p90 6 candidates on a
14
- * 2,615-file project). Those are parsed and resolved with {@link extractFileReferences}, the
15
- * SAME resolver `buildAppGraph` runs, so this answer cannot drift from the graph's — 80
16
- * targets sampled across two real projects produced 0 mismatches on `(source, kind, args)`.
8
+ * pass A the dependants, linted with the changeset overlaid
9
+ * pass B the same dependants, linted with nothing overlaid
10
+ * report A \ B — exactly the findings this change introduced
17
11
  *
18
- * THERE IS NO `computing` STATE. The answer is derived from the project as it is at request
19
- * time, so it is fresh by construction. A failure or the deadline yields `unavailable`; a
20
- * file the graph cannot model yields `not_applicable`; everything else is `computed`.
12
+ * WHY A DIFF AND NOT A CHECK-CODE ALLOWLIST. An earlier version compared `{% doc %}`
13
+ * parameters by hand, which reimplemented `MissingRenderPartialArguments` with no message,
14
+ * no documentation link and an invented severity. The diff needs no list of "cross-file
15
+ * relevant" codes, because relevance is CAUSAL: a `DeprecatedFrontmatterField` already in a
16
+ * dependant is irrelevant for having been there BEFORE, not for its code. A check added
17
+ * upstream is covered on the day it ships, with no edit here.
21
18
  *
22
- * The dependents list reads the CHANGESET, not just disk: `ProjectScan` overlays the buffers
23
- * under validation, so a caller a buffer has just added or removed is counted.
19
+ * SERIALIZED, BY NECESSITY. `lintBuffers` overlays buffers into check-node's process-shared
20
+ * `App` and reverts them on the way out, and that App has no lock. Two lint passes therefore
21
+ * cannot overlap — with each other, or with the request's primary lint. The orchestrator
22
+ * runs the primary lint concurrently with the project SCAN (pure fs reads, no App) and only
23
+ * then runs these two passes, so the write gate is never delayed by, or lost to, impact.
24
+ *
25
+ * BOUNDED BY INPUT, NOT BY THE DEADLINE. `IMPACT_DEADLINE_MS` cannot bound this: a lint is
26
+ * synchronous CPU work and no timer preempts it. `MAX_CANDIDATE_BYTES` caps the text
27
+ * discovery will parse and `MAX_DEPENDANTS_LINTED` caps how many dependants are linted, both
28
+ * derived in `cost-model.ts` to fit inside that deadline. Hitting either is REPORTED —
29
+ * `status: unavailable` or `unchecked_dependants` — never silently absorbed.
30
+ *
31
+ * NOTHING HERE IS A CLEARANCE. A dependant that names its target at runtime, or that does
32
+ * not parse, is invisible to discovery — so an empty answer means no break was FOUND among
33
+ * the dependants that are visible, never that none exists.
24
34
  */
25
- import { extractDocDefinition, path, SourceCodeType, } from '@platformos/platformos-check-common';
26
- import { sourceCodeTypeOf, uriToName } from '@platformos/platformos-common';
27
- import { extractFileReferences, toSourceCode, } from '@platformos/platformos-graph';
35
+ import { path } from '@platformos/platformos-check-common';
36
+ import { uriToName } from '@platformos/platformos-common';
28
37
  import { toAbsoluteFilePath } from '../adapter-input.js';
29
- /** Number of referencing files listed in `sample`/`signature_risk` before truncating. */
30
- const SAMPLE_LIMIT = 10;
38
+ import { MAX_DEPENDANTS_LINTED } from '../cost-model.js';
39
+ import { enrichBatch } from '../enrich/enrich.js';
40
+ import { NOT_APPLICABLE_IMPACT, UNAVAILABLE_IMPACT } from '../result/impact-states.js';
41
+ import { canHaveDependants, dependantsOf, toDependantBuffers } from './dependants.js';
42
+ import { introducedDiagnostics } from './diff.js';
31
43
  /**
32
- * The edge kinds whose call-site arguments are validated against a partial's
33
- * `{% doc %}` `@param` contract — EXACTLY the kinds the `PartialCallArguments`
34
- * lint check validates (`render`/`include` via its `RenderMarkup` handler,
35
- * `function` via `FunctionMarkup`). `background`/`graphql`/`layout`/`asset` edges
36
- * carry scheduling/operation arguments that are NOT `@param`s, so signature-impact
37
- * must ignore them or it would flag correct calls (a false positive that the
38
- * forward `PartialCallArguments` check never produces).
39
- */
40
- const SIGNATURE_EDGE_KINDS = new Set(['render', 'include', 'function']);
41
- /**
42
- * Whether the graph can model incoming references to `uri` — i.e. `uri` can be a
43
- * resolvable edge TARGET (a Liquid page/layout/partial, or a GraphQL operation).
44
- * Reuses check-common's canonical classifiers so this cannot drift from the
45
- * graph's own edge resolution.
44
+ * The impact of the whole changeset, per requested buffer key.
46
45
  *
47
- * Files that are NOT edge targets — schema / custom-model-type / translation YAML,
48
- * or any unclassified file — are wired by model/table NAME, not by file reference
49
- * (ADR 004), so the graph has no dependents for them and `total: 0` would be a
50
- * false "safe to change". Those get `status: 'not_applicable'` instead.
46
+ * Throws only on real I/O failure — the caller degrades that to `unavailable` for every
47
+ * buffer, because a failure here says nothing about any of them.
51
48
  */
52
- function isGraphTrackable(uri) {
53
- // The graph's edge-target types, asked by extension. Naming the two explicitly keeps the
54
- // YAML case visibly excluded, which is the distinction the docblock above turns on.
55
- const type = sourceCodeTypeOf(uri);
56
- return type === SourceCodeType.LiquidHtml || type === SourceCodeType.GraphQL;
57
- }
58
- /** A fresh zeroed dependents shape for every non-`computed` status. */
59
- const noDependents = () => ({
60
- total: 0,
61
- by_kind: {},
62
- sample: [],
63
- });
64
- /**
65
- * Compute the edited file's blast radius from the project as it is right now.
66
- * Reports `not_applicable` when nothing could reference the file by name, and
67
- * throws only on real I/O failure — the caller degrades that to `unavailable`.
68
- */
69
- export async function runImpact(params, scan) {
70
- const { projectDir, filePath, content } = params;
49
+ export async function runImpact(input) {
50
+ const { projectDir, buffers, scan } = input;
71
51
  const rootUri = path.normalize(path.URI.file(projectDir));
72
- const fileUri = path.normalize(path.URI.file(toAbsoluteFilePath(projectDir, filePath)));
73
- // Applicability is a property of the FILE, independent of any scan: a non-trackable file
74
- // has no dependency edges, and a file in no platformOS directory has no logical NAME for
75
- // a reference to spell. `total: 0` for either would be a false "safe to change".
76
- const name = uriToName(fileUri, rootUri)?.name;
77
- if (!isGraphTrackable(fileUri) || name === undefined) {
78
- return { scope: 'direct', status: 'not_applicable', dependents: noDependents() };
52
+ const uriOf = (buffer) => path.normalize(path.URI.file(toAbsoluteFilePath(projectDir, buffer.filePath)));
53
+ // A file in the changeset is reported on its own terms. Excluding the whole changeset from
54
+ // every dependant set is also what makes the scan's text the DISK text for each dependant,
55
+ // which is exactly what pass B's baseline needs.
56
+ const changeset = new Set(buffers.map(uriOf));
57
+ const byBuffer = new Map();
58
+ const dependantsPerBuffer = new Map();
59
+ for (const buffer of buffers) {
60
+ const uri = uriOf(buffer);
61
+ // Applicability is a property of the FILE and needs no scan. The name is read separately
62
+ // because discovery needs the STRING while `canHaveDependants` answers a boolean; the
63
+ // second condition below narrows that string and is not a second rule.
64
+ const name = uriToName(uri, rootUri)?.name;
65
+ if (!canHaveDependants(uri, rootUri) || name === undefined) {
66
+ byBuffer.set(buffer.filePath, NOT_APPLICABLE_IMPACT());
67
+ continue;
68
+ }
69
+ const dependants = await dependantsOf(scan, uri, name, changeset);
70
+ // `null` is "too much candidate text to examine", NOT "no dependants". Reported as
71
+ // `unavailable`, which is what it is: the comparison did not run.
72
+ if (dependants === null) {
73
+ byBuffer.set(buffer.filePath, UNAVAILABLE_IMPACT());
74
+ continue;
75
+ }
76
+ dependantsPerBuffer.set(buffer.filePath, dependants);
77
+ byBuffer.set(buffer.filePath, { status: 'computed' });
79
78
  }
80
- const dependents = await incomingReferences(scan, fileUri, name);
81
- const signature = await docSignature(fileUri, content);
82
- const signature_risk = signature && computeSignatureRisk(dependents, rootUri, signature);
83
- return {
84
- scope: 'direct',
85
- status: 'computed',
86
- dependents: summarizeDependents(dependents, rootUri),
87
- ...(signature_risk ? { signature_risk } : {}),
88
- };
79
+ const found = [...new Set([...dependantsPerBuffer.values()].flat())].sort((a, b) => a.localeCompare(b));
80
+ if (found.length === 0)
81
+ return byBuffer;
82
+ // BOUNDED INPUT, not a deadline: a lint is synchronous CPU work and no timer preempts it
83
+ // (see `deadline.ts`), so the only defence is refusing to start more of it than fits.
84
+ // Sorted first, so which dependants survive the bound is deterministic rather than
85
+ // whichever order a directory walk happened to produce.
86
+ const linted = new Set(found.slice(0, MAX_DEPENDANTS_LINTED));
87
+ const introduced = await introducedByDependant(input, [...linted]);
88
+ for (const [key, dependants] of dependantsPerBuffer) {
89
+ const checked = dependants.filter((uri) => linted.has(uri));
90
+ const breaks = checked
91
+ .map((uri) => ({ uri, diagnostics: introduced.get(uri) ?? [] }))
92
+ .filter(({ diagnostics }) => diagnostics.length > 0)
93
+ .map(({ uri, diagnostics }) => ({ file: path.relative(uri, rootUri), diagnostics }));
94
+ const impact = { status: 'computed' };
95
+ // Omitted when empty. An empty list reads as "checked, nothing depends on this that
96
+ // could break", which a scan of the dependants that happen to be VISIBLE cannot earn.
97
+ if (breaks.length > 0)
98
+ impact.breaks = breaks;
99
+ // Reported per buffer rather than per request: the bound is global, but whether it cost
100
+ // THIS buffer anything depends on how many of its own dependants fell past it.
101
+ if (checked.length < dependants.length) {
102
+ impact.unchecked_dependants = { returned: checked.length, total: dependants.length };
103
+ }
104
+ byBuffer.set(key, impact);
105
+ }
106
+ return byBuffer;
89
107
  }
90
108
  /**
91
- * Every reference in the project that resolves to `fileUri`.
109
+ * The findings each dependant gained because of the changeset, keyed by its URI.
92
110
  *
93
- * The name filter is what keeps this cheap, and it is sound rather than heuristic: an edge
94
- * exists only where a static literal spells the target's logical name. Everything past the
95
- * filter is exact — the survivors are resolved by the graph's own resolver, so a candidate
96
- * that merely MENTIONS the name contributes no reference.
111
+ * The two passes are AWAITED IN SEQUENCE, never raced: they share check-node's process-wide
112
+ * `App`, and an overlay installed by one while the other is reverting would corrupt both.
97
113
  */
98
- async function incomingReferences(scan, fileUri, name) {
114
+ async function introducedByDependant(input, dependants) {
115
+ const { projectDir, buffers, scan, lint, docset, log } = input;
99
116
  const sources = await scan.sources();
100
- const candidates = [...sources].filter(([, source]) => source.includes(name));
101
- const perCandidate = await Promise.all(candidates.map(async ([uri, source]) => {
102
- const sourceCode = await toSourceCode(uri, source);
103
- return extractFileReferences(scan.rootUri, uri, sourceCode, { fs: scan.fs });
104
- }));
105
- return perCandidate.flat().filter((reference) => reference.target.uri === fileUri);
106
- }
107
- /**
108
- * Reduce the incoming reference edges of the edited file to the agent-facing
109
- * summary: distinct referencing FILES (`total`), distinct files per edge kind
110
- * (`by_kind`), and a capped, sorted `sample` of project-relative caller paths.
111
- */
112
- function summarizeDependents(references, rootUri) {
113
- // caller path (project-relative) → the edge kinds by which it references the file
114
- const callers = new Map();
115
- for (const ref of references) {
116
- if (!ref.kind)
117
- continue; // every graph edge carries a kind; defensive only
118
- const caller = path.relative(ref.source.uri, rootUri);
119
- const kinds = callers.get(caller) ?? new Set();
120
- kinds.add(ref.kind);
121
- callers.set(caller, kinds);
122
- }
123
- const by_kind = {};
124
- for (const kinds of callers.values()) {
125
- for (const kind of kinds)
126
- by_kind[kind] = (by_kind[kind] ?? 0) + 1;
117
+ const dependantBuffers = toDependantBuffers(dependants, sources);
118
+ const keyOf = new Map(dependantBuffers.map((buffer) => [buffer.filePath, buffer.uri]));
119
+ // WITH the changeset overlaid. The changeset's own buffers are included because a
120
+ // dependant must see the edit to be broken by it; their results are discarded here, since
121
+ // the primary lint already reported them.
122
+ const after = await lint({ projectDir, buffers: [...buffers, ...dependantBuffers] });
123
+ // Only a dependant that found something in pass A needs a baseline: the diff is A \ B, so
124
+ // an empty A is empty whatever B holds. Worth less than it looks — measured, a page
125
+ // reports `DeprecatedFrontmatterField` whatever it contains, so only partial dependants
126
+ // are ever clean enough to skip.
127
+ const withFindings = dependantBuffers.filter((buffer) => (after.diagnostics.get(buffer.filePath)?.length ?? 0) > 0);
128
+ const before = withFindings.length
129
+ ? await lint({ projectDir, buffers: withFindings })
130
+ : undefined;
131
+ const enriched = await enrichBatch(onlyDependants(after, keyOf), docset, log);
132
+ const introduced = new Map();
133
+ for (const [filePath, uri] of keyOf) {
134
+ const gained = introducedDiagnostics(enriched.get(filePath) ?? [], before?.diagnostics.get(filePath) ?? []);
135
+ if (gained.length > 0)
136
+ introduced.set(uri, gained);
127
137
  }
128
- const sample = [...callers.keys()].sort((a, b) => a.localeCompare(b)).slice(0, SAMPLE_LIMIT);
129
- return { total: callers.size, by_kind, sample };
138
+ return introduced;
130
139
  }
131
140
  /**
132
- * The edited buffer's `{% doc %}` parameter contract (required + all declared
133
- * names), or `null` when the buffer is non-Liquid, unparseable, or declares no
134
- * `{% doc %}` block. Reuses check-common's `extractDocDefinition` — the same
135
- * primitive the `PartialCallArguments` check reads for the doc case — so the
136
- * two never diverge. `null` deliberately disables signature-impact: without an
137
- * explicit contract we do NOT guess a signature (no false positives).
141
+ * Pass A narrowed to the dependants, so enrichment is not paid a second time for the
142
+ * changeset's own findings — the primary lint has already enriched those.
138
143
  */
139
- async function docSignature(fileUri, content) {
140
- const sourceCode = await toSourceCode(fileUri, content);
141
- if (sourceCode.type !== SourceCodeType.LiquidHtml || sourceCode.ast instanceof Error)
142
- return null;
143
- const definition = await extractDocDefinition(fileUri, sourceCode.ast);
144
- const parameters = definition.liquidDoc?.parameters;
145
- if (!parameters || parameters.length === 0)
146
- return null;
147
- return {
148
- required: parameters.filter((p) => p.required).map((p) => p.name),
149
- allowed: parameters.map((p) => p.name),
150
- };
151
- }
152
- /**
153
- * The dependent callers whose passed arguments violate `signature` — missing a
154
- * required `@param`, or passing one the `{% doc %}` block does not declare. The
155
- * cross-file inverse of `PartialCallArguments`: it checks the edited file's
156
- * contract against every existing caller at once. Deduplicated per caller,
157
- * sorted, and capped.
158
- */
159
- function computeSignatureRisk(references, rootUri, signature) {
160
- const byCaller = new Map();
161
- for (const ref of references) {
162
- // Only the kinds whose args ARE `@param`s (see {@link SIGNATURE_EDGE_KINDS}) —
163
- // a `{% background %}`/`{% graphql %}`/layout edge's args are not, and flagging
164
- // them would be a false positive the forward check never makes.
165
- if (!ref.kind || !SIGNATURE_EDGE_KINDS.has(ref.kind))
166
- continue;
167
- const args = ref.args ?? [];
168
- const missing = signature.required.filter((param) => !args.includes(param));
169
- const unexpected = args.filter((arg) => !signature.allowed.includes(arg));
170
- if (missing.length === 0 && unexpected.length === 0)
144
+ function onlyDependants(pass, keyOf) {
145
+ const diagnostics = new Map();
146
+ const sources = new Map();
147
+ for (const key of keyOf.keys()) {
148
+ const found = pass.diagnostics.get(key);
149
+ // A dependant the lint DECLINED (excluded by config, say) has no diagnostics entry.
150
+ // Skipped rather than defaulted to `[]`, which would report it as checked and clean.
151
+ if (found === undefined)
171
152
  continue;
172
- const caller = path.relative(ref.source.uri, rootUri);
173
- const entry = byCaller.get(caller) ?? { missing: new Set(), unexpected: new Set() };
174
- for (const m of missing)
175
- entry.missing.add(m);
176
- for (const u of unexpected)
177
- entry.unexpected.add(u);
178
- byCaller.set(caller, entry);
153
+ diagnostics.set(key, found);
154
+ const source = pass.sources?.get(key);
155
+ if (source)
156
+ sources.set(key, source);
179
157
  }
180
- return [...byCaller.entries()]
181
- .sort(([a], [b]) => a.localeCompare(b))
182
- .slice(0, SAMPLE_LIMIT)
183
- .map(([caller, { missing, unexpected }]) => ({
184
- caller,
185
- missing_required: [...missing].sort((a, b) => a.localeCompare(b)),
186
- unexpected_args: [...unexpected].sort((a, b) => a.localeCompare(b)),
187
- }));
158
+ return { diagnostics, sources, notChecked: pass.notChecked };
188
159
  }
189
160
  //# sourceMappingURL=impact.js.map
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * The project's edge-source files as TEXT, read ONCE per request.
3
3
  *
4
- * This is the whole of the blast radius's project state. The trade is deliberate: pay a
5
- * project READ per request — I/O, on the threadpool, overlapping the lint's CPU — instead
6
- * of a whole-project PARSE once and a lifetime of keeping it honest. Measured on a real
7
- * project (2,615 edge sources, 3.0 MB): 235 ms. Being derived per request, it cannot be stale,
8
- * which is why `impact` has no `computing` state.
4
+ * This is the whole of impact's project state, and it is read only when some buffer in the
5
+ * request could HAVE dependants — decidable from the paths alone, before any I/O. The trade
6
+ * is deliberate:
7
+ * pay a project READ per request — I/O, on the threadpool, overlapping the lint's CPU —
8
+ * instead of a whole-project PARSE once and a lifetime of keeping it honest. Measured on a
9
+ * real project (2,615 edge sources, 3.0 MB): 235 ms. Being derived per request, it cannot be
10
+ * stale, which is why `impact` has no `computing` state.
9
11
  *
10
12
  * SCOPE. Only EDGE SOURCES are read (`enumerateEdgeSources` — page/layout/partial), because
11
13
  * only their content can declare an edge; `.graphql`/`.yml`/asset files are leaves and can
12
14
  * never be the SOURCE of a reference. That is the graph's own definition, imported rather
13
15
  * than restated.
14
16
  *
15
- * BUFFERS WIN OVER DISK. The changeset under validation is overlaid, so a partial a buffer
16
- * has only just started rendering counts as a dependent, and one whose call a buffer has
17
- * just deleted stops counting — otherwise this stage would answer from disk while the lint
18
- * beside it answers from the buffer. A buffer for a file not yet on disk is ADDED when it is
19
- * an edge source, so a brand-new page's `{% render %}` is seen.
17
+ * BUFFERS WIN OVER DISK. The changeset under validation is overlaid, so a call a buffer has
18
+ * only just added counts, and one it has just deleted stops counting — otherwise this stage
19
+ * would answer from disk while the lint beside it answers from the buffer. A buffer for a
20
+ * file not yet on disk is ADDED when it is an edge source, so a brand-new page's
21
+ * `{% render %}` is seen.
20
22
  */
21
23
  import { type UriString } from '@platformos/platformos-check-common';
22
24
  import type { AbstractFileSystem } from '@platformos/platformos-common';
@@ -1,22 +1,24 @@
1
1
  /**
2
2
  * The project's edge-source files as TEXT, read ONCE per request.
3
3
  *
4
- * This is the whole of the blast radius's project state. The trade is deliberate: pay a
5
- * project READ per request — I/O, on the threadpool, overlapping the lint's CPU — instead
6
- * of a whole-project PARSE once and a lifetime of keeping it honest. Measured on a real
7
- * project (2,615 edge sources, 3.0 MB): 235 ms. Being derived per request, it cannot be stale,
8
- * which is why `impact` has no `computing` state.
4
+ * This is the whole of impact's project state, and it is read only when some buffer in the
5
+ * request could HAVE dependants — decidable from the paths alone, before any I/O. The trade
6
+ * is deliberate:
7
+ * pay a project READ per request — I/O, on the threadpool, overlapping the lint's CPU —
8
+ * instead of a whole-project PARSE once and a lifetime of keeping it honest. Measured on a
9
+ * real project (2,615 edge sources, 3.0 MB): 235 ms. Being derived per request, it cannot be
10
+ * stale, which is why `impact` has no `computing` state.
9
11
  *
10
12
  * SCOPE. Only EDGE SOURCES are read (`enumerateEdgeSources` — page/layout/partial), because
11
13
  * only their content can declare an edge; `.graphql`/`.yml`/asset files are leaves and can
12
14
  * never be the SOURCE of a reference. That is the graph's own definition, imported rather
13
15
  * than restated.
14
16
  *
15
- * BUFFERS WIN OVER DISK. The changeset under validation is overlaid, so a partial a buffer
16
- * has only just started rendering counts as a dependent, and one whose call a buffer has
17
- * just deleted stops counting — otherwise this stage would answer from disk while the lint
18
- * beside it answers from the buffer. A buffer for a file not yet on disk is ADDED when it is
19
- * an edge source, so a brand-new page's `{% render %}` is seen.
17
+ * BUFFERS WIN OVER DISK. The changeset under validation is overlaid, so a call a buffer has
18
+ * only just added counts, and one it has just deleted stops counting — otherwise this stage
19
+ * would answer from disk while the lint beside it answers from the buffer. A buffer for a
20
+ * file not yet on disk is ADDED when it is an edge source, so a brand-new page's
21
+ * `{% render %}` is seen.
20
22
  */
21
23
  import { path } from '@platformos/platformos-check-common';
22
24
  import { enumerateEdgeSources, isEdgeSource } from '@platformos/platformos-graph';
@@ -10,7 +10,6 @@ export declare function assembleResult(diagnostics: ValidateCodeDiagnostic[], im
10
10
  * own validation failed would make the tool a liability. `reason` lands in `next_step`;
11
11
  * `code` lands in `not_applicable_reason` for an agent to branch on.
12
12
  *
13
- * `impact` is `not_applicable`, and its zeroed `dependents` must never be read as "nothing
14
- * depends on this".
13
+ * `impact` is `not_applicable`: nothing cross-file was compared either.
15
14
  */
16
15
  export declare function assembleNotApplicableResult(declined: Declined): ValidateCodeResult;
@@ -55,8 +55,7 @@ export function assembleResult(diagnostics, impact) {
55
55
  * own validation failed would make the tool a liability. `reason` lands in `next_step`;
56
56
  * `code` lands in `not_applicable_reason` for an agent to branch on.
57
57
  *
58
- * `impact` is `not_applicable`, and its zeroed `dependents` must never be read as "nothing
59
- * depends on this".
58
+ * `impact` is `not_applicable`: nothing cross-file was compared either.
60
59
  */
61
60
  export function assembleNotApplicableResult(declined) {
62
61
  return {
@@ -65,10 +65,16 @@ export declare const BLOCKING_CHECKS: ReadonlySet<string>;
65
65
  * - `UniqueDocParamNames`, `ValidDocParamTypes` — doc hygiene with no runtime effect, since
66
66
  * `{% doc %}` is inert at runtime.
67
67
  * - `ImgWidthAndHeight`, `ParserBlockingScript` — performance advice.
68
- * - `ValidFrontmatter` — two of its three findings (unknown key, missing layout) ARE hard
69
- * deploy rejections, so this absence is a known gap rather than a judgement. All three
70
- * share one code with no discriminator, so adding it would fix two false approvals and
71
- * create one false block. Blocked on a discriminator — TASK-26.
68
+ * - `DeprecatedFrontmatterField` — measured: a deprecated key (`layout_name` naming a layout
69
+ * that exists, `redirect_url`) and a deprecated `home.liquid` filename are all ACCEPTED by
70
+ * the converter. Superseded, not broken.
71
+ *
72
+ * `ValidFrontmatter` used to be listed here, recorded as blocked on a discriminator
73
+ * (TASK-26) because "two of its three findings" were fatal and blocking the code would have
74
+ * created one false block. Both halves were wrong. It had seven reachable shapes, not three,
75
+ * and `layout: false` — the finding named as the harmless one — is itself a converter
76
+ * rejection. It has since been split into per-shape codes, which is the discriminator the
77
+ * task was waiting for: three of them are in the set above, two are named here.
72
78
  */
73
79
  /** A diagnostic, narrowed to what the gate reads. */
74
80
  interface GateInput {
@@ -41,10 +41,15 @@ export const BLOCKING_CHECKS = new Set([
41
41
  // Does not parse. Nothing downstream can be trusted about the file at all.
42
42
  'LiquidHTMLSyntaxError',
43
43
  // Unparseable YAML. Measured: `pos-cli deploy --dry-run` REJECTS the file ("Body
44
- // contains invalid YAML"), failing the WHOLE changeset. Scoped to SYNTAX on purpose —
45
- // the converter accepts unknown property types and duplicate property names, so
46
- // schema-shape validation would block nothing real.
44
+ // contains invalid YAML"), failing the WHOLE changeset. Scoped to SYNTAX, which is a
45
+ // narrower claim than it used to make here: a REAL deploy rejects an unknown property
46
+ // TYPE (now `InvalidSchemaPropertyType`) and an unknown top-level key, both of which
47
+ // `--dry-run` misses by returning before the nested converter. Duplicate property names
48
+ // ARE accepted — measured.
47
49
  'YAMLSyntaxError',
50
+ // `Attribute type `x` is not allowed`, from the nested `CustomAttributeConverter`.
51
+ // Measured by a REAL deploy; `--dry-run` accepts it.
52
+ 'InvalidSchemaPropertyType',
48
53
  // Broken reference: the target does not exist, so the render fails at runtime.
49
54
  'MissingPartial',
50
55
  // platformOS raises on an unknown filter rather than ignoring it.
@@ -82,6 +87,21 @@ export const BLOCKING_CHECKS = new Set([
82
87
  // content, so one that cannot is a defeated contract — and a silently blank page
83
88
  // is harder to diagnose than an error.
84
89
  'MissingContentForLayout',
90
+ // --- Frontmatter, split out of `ValidFrontmatter`. Each is a measured converter
91
+ // rejection, which fails the WHOLE changeset. ---
92
+ // `Body contains invalid YAML: <parser error>`. `YAMLSyntaxError` cannot cover this —
93
+ // it is YAML-typed and never sees a `.liquid` file.
94
+ 'InvalidFrontmatterSyntax',
95
+ // `Unknown properties: <key>.`
96
+ 'UnknownFrontmatterField',
97
+ // `Request method 'POST' is not allowed`, and `layout: false` → `undefined method 'sub'
98
+ // for false` (YAML reads it as a boolean; `set_layout` guards `nil`, not `false`).
99
+ 'InvalidFrontmatterValue',
100
+ // `Could not find Layout with layout: <name>`.
101
+ 'MissingLayout',
102
+ // `<page> tries to assign authorization_policies which do not exist: <name>`. Measured by
103
+ // a REAL deploy — `--dry-run` accepts it, returning before the association write.
104
+ 'MissingFrontmatterAssociation',
85
105
  ]);
86
106
  /**
87
107
  * Whether this set of diagnostics should stop the agent writing the file.
@@ -1,16 +1,19 @@
1
1
  /**
2
- * The non-`computed` blast-radius states.
2
+ * The non-`computed` impact states.
3
3
  *
4
- * Each is a FACTORY, not a shared constant: the zeroed `dependents` object is mutable and
5
- * ends up inside a returned result, so one shared instance would let a mutation reach every
6
- * past and future response.
4
+ * Each is a FACTORY, not a shared constant: the object ends up inside a returned result, so
5
+ * one shared instance would let a mutation reach every past and future response.
7
6
  *
8
- * IMPORTANT: `dependents` is meaningful ONLY when `status` is `computed`. Every state here
9
- * zeroes it, and a zero must NEVER be read as "nothing depends on this, safe to change" —
10
- * that is what `status` is for.
7
+ * NEITHER IS A CLEARANCE, and neither is `computed` — impact publishes findings only, so
8
+ * nothing here or anywhere else says "nothing depends on this". See `ValidateCodeImpact`.
11
9
  */
12
10
  import type { ValidateCodeImpact } from './types.js';
13
- /** The blast radius could not be computed at all — a failure, or a deadline. */
11
+ /** The cross-file comparison could not be run at all — a failure, or a deadline. */
14
12
  export declare const UNAVAILABLE_IMPACT: () => ValidateCodeImpact;
15
- /** This file has no dependency edges to speak of — see `ValidateCodeImpactStatus`. */
13
+ /**
14
+ * The server was started with `--no-impact`, so nothing cross-file was attempted. Distinct
15
+ * from `unavailable` on purpose: a retry cannot change this one.
16
+ */
17
+ export declare const DISABLED_IMPACT: () => ValidateCodeImpact;
18
+ /** This file has no dependants the graph can find — see `ValidateCodeImpactStatus`. */
16
19
  export declare const NOT_APPLICABLE_IMPACT: () => ValidateCodeImpact;
@@ -1,18 +1,10 @@
1
- const noDependents = () => ({
2
- total: 0,
3
- by_kind: {},
4
- sample: [],
5
- });
6
- /** The blast radius could not be computed at all — a failure, or a deadline. */
7
- export const UNAVAILABLE_IMPACT = () => ({
8
- scope: 'direct',
9
- status: 'unavailable',
10
- dependents: noDependents(),
11
- });
12
- /** This file has no dependency edges to speak of — see `ValidateCodeImpactStatus`. */
13
- export const NOT_APPLICABLE_IMPACT = () => ({
14
- scope: 'direct',
15
- status: 'not_applicable',
16
- dependents: noDependents(),
17
- });
1
+ /** The cross-file comparison could not be run at all — a failure, or a deadline. */
2
+ export const UNAVAILABLE_IMPACT = () => ({ status: 'unavailable' });
3
+ /**
4
+ * The server was started with `--no-impact`, so nothing cross-file was attempted. Distinct
5
+ * from `unavailable` on purpose: a retry cannot change this one.
6
+ */
7
+ export const DISABLED_IMPACT = () => ({ status: 'disabled' });
8
+ /** This file has no dependants the graph can find — see `ValidateCodeImpactStatus`. */
9
+ export const NOT_APPLICABLE_IMPACT = () => ({ status: 'not_applicable' });
18
10
  //# sourceMappingURL=impact-states.js.map