@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.
package/README.md CHANGED
@@ -51,11 +51,26 @@ input { file_path, content } | { files: [...] }
51
51
  → ValidateCodeResult
52
52
  ```
53
53
 
54
- The lint and the blast radius run concurrently; the lint is the long pole and the blast
55
- radius hides behind it. Nothing is cached between calls on the blast-radius side: it reads
56
- the project's edge sources, keeps the few that could name the edited file, and resolves
57
- their references through `platformos-graph` — so the answer is fresh by construction rather
58
- than by revalidating a stored graph.
54
+ Impact reports one thing: **what your change breaks in files you are not editing**. It finds
55
+ the edited file's dependants through `platformos-graph`, lints them twice — once with your
56
+ changeset applied, once without — and reports only the findings the change INTRODUCED. Those
57
+ findings are the check engine's own, so they arrive with the same message, severity,
58
+ documentation link and fixes as any other diagnostic; nothing about "what is broken" is
59
+ re-derived here.
60
+
61
+ The project READ overlaps the primary lint, but the two impact lint passes cannot: they share
62
+ check-node's process-wide `App`, which has no lock. So the primary lint runs first and is
63
+ never delayed by, or lost to, impact.
64
+
65
+ Two bounds keep it affordable, both measured and both reported when hit — `IMPACT_DEADLINE_MS`
66
+ cannot bound this work, since a lint is synchronous CPU and no timer preempts it:
67
+ `MAX_CANDIDATE_BYTES` caps the text discovery will parse, and `MAX_DEPENDANTS_LINTED` caps how
68
+ many dependants get linted. Start the server with `--no-impact` (or
69
+ `POS_SUPERVISOR_NO_IMPACT=1`) to switch the whole stage off; results then carry
70
+ `impact.status: "disabled"`.
71
+
72
+ It never answers "who depends on this file": `{% render var %}` names its target at runtime,
73
+ so that question has no sound static answer and no count is published in place of one.
59
74
 
60
75
  ## What this package is not
61
76
 
@@ -6,17 +6,30 @@
6
6
  */
7
7
  export interface ParsedArgs {
8
8
  projectDir?: string;
9
+ /** `false` only when `--no-impact` was passed. Cross-file impact is ON by default. */
10
+ impact: boolean;
9
11
  help: boolean;
10
12
  }
11
13
  /**
12
- * Parse `--project <dir>` / `--project=<dir>` / `--help` / `-h` from a
14
+ * Parse `--project <dir>` / `--project=<dir>` / `--no-impact` / `--help` / `-h` from a
13
15
  * `process.argv.slice(2)`-style array. Unknown flags are tolerated so a
14
16
  * forward-compatible client can pass extras without breaking the bin.
15
17
  */
16
18
  export declare function parseArgs(argv: ReadonlyArray<string>): ParsedArgs;
19
+ /**
20
+ * Whether cross-file impact runs, by precedence: `--no-impact` wins, then
21
+ * `POS_SUPERVISOR_NO_IMPACT` (any non-empty value other than `0`/`false`), then ON.
22
+ *
23
+ * A SERVER setting rather than a tool parameter, deliberately. The per-call knob would put
24
+ * the choice with the agent, and an agent that does not know it is editing a shared partial
25
+ * is exactly the one that would not ask for the check — this server exists for what the
26
+ * agent does not know. It also keeps the tool surface unchanged: `mode` was a real parameter
27
+ * here once and was retired, and `stdio-smoke.spec.ts` still asserts a stale one is ignored.
28
+ */
29
+ export declare function resolveImpactEnabled(args: ParsedArgs, env: NodeJS.ProcessEnv): boolean;
17
30
  /**
18
31
  * Resolve the project directory by precedence:
19
32
  * 1. `--project` argument, 2. `POS_SUPERVISOR_PROJECT_DIR`, 3. cwd.
20
33
  */
21
34
  export declare function resolveProjectDir(args: ParsedArgs, env: NodeJS.ProcessEnv, cwd: string): string;
22
- export declare const HELP = "platformos-mcp-supervisor \u2014 MCP stdio server exposing validate_code\n\nUsage:\n platformos-mcp-supervisor [--project <dir>]\n\nOptions:\n --project <dir> Project root to validate against\n (env: POS_SUPERVISOR_PROJECT_DIR; default: current directory)\n -h, --help Show this help\n";
35
+ export declare const HELP = "platformos-mcp-supervisor \u2014 MCP stdio server exposing validate_code\n\nUsage:\n platformos-mcp-supervisor [--project <dir>] [--no-impact]\n\nOptions:\n --project <dir> Project root to validate against\n (env: POS_SUPERVISOR_PROJECT_DIR; default: current directory)\n --no-impact Do not compute cross-file impact. Each result still reports\n every finding in the buffers you send; it simply stops\n reporting what your change breaks in files you are NOT\n editing. Costs ~240 ms per request on a 2,600-file project.\n (env: POS_SUPERVISOR_NO_IMPACT=1)\n -h, --help Show this help\n";
package/dist/bin/args.js CHANGED
@@ -1,16 +1,20 @@
1
1
  /**
2
- * Parse `--project <dir>` / `--project=<dir>` / `--help` / `-h` from a
2
+ * Parse `--project <dir>` / `--project=<dir>` / `--no-impact` / `--help` / `-h` from a
3
3
  * `process.argv.slice(2)`-style array. Unknown flags are tolerated so a
4
4
  * forward-compatible client can pass extras without breaking the bin.
5
5
  */
6
6
  export function parseArgs(argv) {
7
7
  let projectDir;
8
+ let impact = true;
8
9
  let help = false;
9
10
  for (let i = 0; i < argv.length; i++) {
10
11
  const arg = argv[i];
11
12
  if (arg === '--help' || arg === '-h') {
12
13
  help = true;
13
14
  }
15
+ else if (arg === '--no-impact') {
16
+ impact = false;
17
+ }
14
18
  else if (arg === '--project') {
15
19
  projectDir = argv[i + 1];
16
20
  i++;
@@ -19,7 +23,26 @@ export function parseArgs(argv) {
19
23
  projectDir = arg.slice('--project='.length);
20
24
  }
21
25
  }
22
- return { projectDir, help };
26
+ return { projectDir, impact, help };
27
+ }
28
+ /**
29
+ * Whether cross-file impact runs, by precedence: `--no-impact` wins, then
30
+ * `POS_SUPERVISOR_NO_IMPACT` (any non-empty value other than `0`/`false`), then ON.
31
+ *
32
+ * A SERVER setting rather than a tool parameter, deliberately. The per-call knob would put
33
+ * the choice with the agent, and an agent that does not know it is editing a shared partial
34
+ * is exactly the one that would not ask for the check — this server exists for what the
35
+ * agent does not know. It also keeps the tool surface unchanged: `mode` was a real parameter
36
+ * here once and was retired, and `stdio-smoke.spec.ts` still asserts a stale one is ignored.
37
+ */
38
+ export function resolveImpactEnabled(args, env) {
39
+ if (!args.impact)
40
+ return false;
41
+ const disabled = env.POS_SUPERVISOR_NO_IMPACT;
42
+ if (disabled === undefined || disabled === '' || disabled === '0' || disabled === 'false') {
43
+ return true;
44
+ }
45
+ return false;
23
46
  }
24
47
  /**
25
48
  * Resolve the project directory by precedence:
@@ -31,11 +54,16 @@ export function resolveProjectDir(args, env, cwd) {
31
54
  export const HELP = `platformos-mcp-supervisor — MCP stdio server exposing validate_code
32
55
 
33
56
  Usage:
34
- platformos-mcp-supervisor [--project <dir>]
57
+ platformos-mcp-supervisor [--project <dir>] [--no-impact]
35
58
 
36
59
  Options:
37
60
  --project <dir> Project root to validate against
38
61
  (env: POS_SUPERVISOR_PROJECT_DIR; default: current directory)
62
+ --no-impact Do not compute cross-file impact. Each result still reports
63
+ every finding in the buffers you send; it simply stops
64
+ reporting what your change breaks in files you are NOT
65
+ editing. Costs ~240 ms per request on a 2,600-file project.
66
+ (env: POS_SUPERVISOR_NO_IMPACT=1)
39
67
  -h, --help Show this help
40
68
  `;
41
69
  //# sourceMappingURL=args.js.map
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { createLogger } from '../logger.js';
22
22
  import { startServer } from '../transport/server.js';
23
- import { HELP, parseArgs, resolveProjectDir } from './args.js';
23
+ import { HELP, parseArgs, resolveImpactEnabled, resolveProjectDir } from './args.js';
24
24
  async function main() {
25
25
  const args = parseArgs(process.argv.slice(2));
26
26
  if (args.help) {
@@ -29,7 +29,11 @@ async function main() {
29
29
  }
30
30
  const log = createLogger('platformos-mcp-supervisor');
31
31
  const projectDir = resolveProjectDir(args, process.env, process.cwd());
32
- await startServer({ projectDir, log });
32
+ await startServer({
33
+ projectDir,
34
+ log,
35
+ impactEnabled: resolveImpactEnabled(args, process.env),
36
+ });
33
37
  }
34
38
  main().catch((err) => {
35
39
  process.stderr.write(`fatal: ${err?.stack ?? String(err)}\n`);
package/dist/context.d.ts CHANGED
@@ -6,21 +6,29 @@ import type { Logger } from './logger.js';
6
6
  * NO CACHE HANDLES HERE, and their absence is the point. check-node keeps ONE lazy `App`
7
7
  * per project at process level and reconciles it per call, so a cache nobody has to
8
8
  * remember to pass is a cache no call path can forget — do not reintroduce a handle here
9
- * (see check-node's "Process-level state"). The blast radius is likewise DERIVED per
10
- * request from the project's text (`impact/project-scan.ts`), so nothing can be stale.
9
+ * (see check-node's "Process-level state"). Impact is likewise DERIVED per request from
10
+ * the project's text (`impact/project-scan.ts`), so nothing can be stale.
11
11
  */
12
12
  export interface SupervisorContext {
13
13
  /** Absolute project root that buffers are validated against. */
14
14
  projectDir: string;
15
15
  log: Logger;
16
+ /**
17
+ * Whether cross-file impact runs. OPTIONAL AND DEFAULTING TO ON: impact is a safety
18
+ * feature, so a context that forgets to mention it must get the safe behaviour rather than
19
+ * silently lose the check. Only an explicit `false` — from `--no-impact` or
20
+ * `POS_SUPERVISOR_NO_IMPACT` — turns it off.
21
+ */
22
+ impactEnabled?: boolean;
16
23
  }
17
24
  /**
18
- * Deadline for the blast radius. Much tighter than the lint's, which is a function of
19
- * admitted bytes and lives in `cost-model.ts` — deliberately not re-exported from here.
25
+ * Deadline for impact. Much tighter than the lint's, which is a function of admitted bytes
26
+ * and lives in `cost-model.ts` — deliberately not re-exported from here.
20
27
  *
21
- * Tight because the work is small — one project read (235 ms measured on a 2,615-file
22
- * project) plus a name filter and a handful of parses, p50 9 ms / p90 63 ms per file — and
23
- * because exceeding it costs only the answer, which has an `unavailable` state. The tail it
24
- * exists for is real but rare: the most-referenced partial measured took ~1 s.
28
+ * A BACKSTOP, NOT THE BOUND. A lint is synchronous CPU work and no timer preempts it (see
29
+ * `deadline.ts`), so what actually limits impact is bounded INPUT — `MAX_CANDIDATE_BYTES` and
30
+ * `MAX_DEPENDANTS_LINTED` in `cost-model.ts`, both derived to fit inside this number. This
31
+ * catches what those cannot: a stalled read, or a project large enough that the read alone
32
+ * runs long. Exceeding it costs only the answer, which has an `unavailable` state.
25
33
  */
26
34
  export declare const IMPACT_DEADLINE_MS = 2000;
package/dist/context.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Deadline for the blast radius. Much tighter than the lint's, which is a function of
3
- * admitted bytes and lives in `cost-model.ts` — deliberately not re-exported from here.
2
+ * Deadline for impact. Much tighter than the lint's, which is a function of admitted bytes
3
+ * and lives in `cost-model.ts` — deliberately not re-exported from here.
4
4
  *
5
- * Tight because the work is small — one project read (235 ms measured on a 2,615-file
6
- * project) plus a name filter and a handful of parses, p50 9 ms / p90 63 ms per file — and
7
- * because exceeding it costs only the answer, which has an `unavailable` state. The tail it
8
- * exists for is real but rare: the most-referenced partial measured took ~1 s.
5
+ * A BACKSTOP, NOT THE BOUND. A lint is synchronous CPU work and no timer preempts it (see
6
+ * `deadline.ts`), so what actually limits impact is bounded INPUT — `MAX_CANDIDATE_BYTES` and
7
+ * `MAX_DEPENDANTS_LINTED` in `cost-model.ts`, both derived to fit inside this number. This
8
+ * catches what those cannot: a stalled read, or a project large enough that the read alone
9
+ * runs long. Exceeding it costs only the answer, which has an `unavailable` state.
9
10
  */
10
11
  export const IMPACT_DEADLINE_MS = 2_000;
11
12
  //# sourceMappingURL=context.js.map
@@ -126,3 +126,63 @@ export declare function lintDeadlineMs(bytes: number): number;
126
126
  * cap from the deadline ceiling instead of from a round multiple of the per-buffer bound.
127
127
  */
128
128
  export declare function maxBytesWithin(deadlineMs: number): number;
129
+ /**
130
+ * How many DEPENDANTS one request will lint when computing impact.
131
+ *
132
+ * THE REAL BOUND ON IMPACT, and not a nicety. `IMPACT_DEADLINE_MS` cannot bound this work:
133
+ * a lint is synchronous CPU on the one event loop, so no timer preempts it (see
134
+ * `deadline.ts`). Bounding the INPUT is the only defence, exactly as `MAX_BUFFER_BYTES` is
135
+ * for the primary lint.
136
+ *
137
+ * DERIVED from the deadline it has to fit inside, on the worst project scale:
138
+ *
139
+ * impact deadline 2 000 ms
140
+ * less the project read at 10k files ~ 850 ms
141
+ * leaves for both lint passes ~1 150 ms
142
+ *
143
+ * Measured twice-lint cost, and the same {@link LOAD_FACTOR} the lint deadline uses:
144
+ *
145
+ * 100 dependants 258 ms idle 774 ms loaded -> 1 624 ms total, FITS
146
+ * 200 dependants 487 ms idle 1 460 ms loaded -> over
147
+ * 311 dependants 716 ms idle 2 147 ms loaded -> over on its own
148
+ *
149
+ * 311 is not hypothetical: it is the most-depended-on file measured on a real 2 615-file
150
+ * application (`lib/current_profile`). The same measurement puts 99.37% of targets at 100
151
+ * dependants or fewer — p50 1, p90 4, p99 59 — so the bound is hit rarely and, when it is,
152
+ * IT IS REPORTED (`unchecked_dependants`) rather than silently shortening the analysis.
153
+ */
154
+ export declare const MAX_DEPENDANTS_LINTED = 100;
155
+ /**
156
+ * Cost of DISCOVERING dependants, per KiB of candidate text.
157
+ *
158
+ * Discovery parses every file whose text contains the target's name and resolves its
159
+ * references through the graph, so it scales with the BYTES it must parse rather than with
160
+ * the file count. Measured on a real 2 615-file application across four very differently
161
+ * shaped targets: 12.82, 12.52, 8.59 and 14.60 ms/KiB. Held above the slowest observed, for
162
+ * the same reason {@link LINT_MS_PER_KIB} is.
163
+ */
164
+ export declare const DISCOVERY_MS_PER_KIB = 15;
165
+ /**
166
+ * How much candidate text one target's discovery will parse before giving up.
167
+ *
168
+ * WHY A BOUND AT ALL, and why it is not {@link MAX_DEPENDANTS_LINTED}: that one bounds the
169
+ * two lint passes, which happen AFTER discovery. Discovery itself was unbounded, and the
170
+ * most-referenced file on a real application (`lib/current_profile`, 312 candidates) cost
171
+ * 4.7 s and then returned `unavailable` anyway — seconds spent to reach "I do not know".
172
+ * The candidate byte total is known from a substring scan, before a single parse, so the
173
+ * same answer costs milliseconds instead.
174
+ *
175
+ * DERIVED from what has to fit inside `IMPACT_DEADLINE_MS`:
176
+ *
177
+ * impact deadline 2 000 ms
178
+ * less the project read ~ 235 ms
179
+ * less both lint passes at the dependant bound ~ 258 ms
180
+ * leaves for discovery ~1 507 ms
181
+ * at {@link DISCOVERY_MS_PER_KIB} ~100 KiB
182
+ *
183
+ * Held at 64 KiB rather than 100, because 100 spends the entire remaining budget and leaves
184
+ * nothing for a larger project's read. Measured against the same application: candidate text
185
+ * is p50 1 KiB, p90 13, p95 23, p99 143 — so 64 KiB is nearly 3x the p95 and turns away
186
+ * 2.51% of targets, each in a few milliseconds instead of several seconds.
187
+ */
188
+ export declare const MAX_CANDIDATE_BYTES: number;
@@ -134,4 +134,64 @@ export function lintDeadlineMs(bytes) {
134
134
  export function maxBytesWithin(deadlineMs) {
135
135
  return Math.floor(deadlineMs / DEADLINE_MS_PER_KIB) * 1024;
136
136
  }
137
+ /**
138
+ * How many DEPENDANTS one request will lint when computing impact.
139
+ *
140
+ * THE REAL BOUND ON IMPACT, and not a nicety. `IMPACT_DEADLINE_MS` cannot bound this work:
141
+ * a lint is synchronous CPU on the one event loop, so no timer preempts it (see
142
+ * `deadline.ts`). Bounding the INPUT is the only defence, exactly as `MAX_BUFFER_BYTES` is
143
+ * for the primary lint.
144
+ *
145
+ * DERIVED from the deadline it has to fit inside, on the worst project scale:
146
+ *
147
+ * impact deadline 2 000 ms
148
+ * less the project read at 10k files ~ 850 ms
149
+ * leaves for both lint passes ~1 150 ms
150
+ *
151
+ * Measured twice-lint cost, and the same {@link LOAD_FACTOR} the lint deadline uses:
152
+ *
153
+ * 100 dependants 258 ms idle 774 ms loaded -> 1 624 ms total, FITS
154
+ * 200 dependants 487 ms idle 1 460 ms loaded -> over
155
+ * 311 dependants 716 ms idle 2 147 ms loaded -> over on its own
156
+ *
157
+ * 311 is not hypothetical: it is the most-depended-on file measured on a real 2 615-file
158
+ * application (`lib/current_profile`). The same measurement puts 99.37% of targets at 100
159
+ * dependants or fewer — p50 1, p90 4, p99 59 — so the bound is hit rarely and, when it is,
160
+ * IT IS REPORTED (`unchecked_dependants`) rather than silently shortening the analysis.
161
+ */
162
+ export const MAX_DEPENDANTS_LINTED = 100;
163
+ /**
164
+ * Cost of DISCOVERING dependants, per KiB of candidate text.
165
+ *
166
+ * Discovery parses every file whose text contains the target's name and resolves its
167
+ * references through the graph, so it scales with the BYTES it must parse rather than with
168
+ * the file count. Measured on a real 2 615-file application across four very differently
169
+ * shaped targets: 12.82, 12.52, 8.59 and 14.60 ms/KiB. Held above the slowest observed, for
170
+ * the same reason {@link LINT_MS_PER_KIB} is.
171
+ */
172
+ export const DISCOVERY_MS_PER_KIB = 15;
173
+ /**
174
+ * How much candidate text one target's discovery will parse before giving up.
175
+ *
176
+ * WHY A BOUND AT ALL, and why it is not {@link MAX_DEPENDANTS_LINTED}: that one bounds the
177
+ * two lint passes, which happen AFTER discovery. Discovery itself was unbounded, and the
178
+ * most-referenced file on a real application (`lib/current_profile`, 312 candidates) cost
179
+ * 4.7 s and then returned `unavailable` anyway — seconds spent to reach "I do not know".
180
+ * The candidate byte total is known from a substring scan, before a single parse, so the
181
+ * same answer costs milliseconds instead.
182
+ *
183
+ * DERIVED from what has to fit inside `IMPACT_DEADLINE_MS`:
184
+ *
185
+ * impact deadline 2 000 ms
186
+ * less the project read ~ 235 ms
187
+ * less both lint passes at the dependant bound ~ 258 ms
188
+ * leaves for discovery ~1 507 ms
189
+ * at {@link DISCOVERY_MS_PER_KIB} ~100 KiB
190
+ *
191
+ * Held at 64 KiB rather than 100, because 100 spends the entire remaining budget and leaves
192
+ * nothing for a larger project's read. Measured against the same application: candidate text
193
+ * is p50 1 KiB, p90 13, p95 23, p99 143 — so 64 KiB is nearly 3x the p95 and turns away
194
+ * 2.51% of targets, each in a few milliseconds instead of several seconds.
195
+ */
196
+ export const MAX_CANDIDATE_BYTES = 64 * 1024;
137
197
  //# sourceMappingURL=cost-model.js.map
@@ -85,3 +85,21 @@ export interface EnrichContext {
85
85
  * which is also the one truncation keeps: the cap retains the top of the file.
86
86
  */
87
87
  export declare function enrichDiagnostics(inputs: readonly EnrichInput[], context: EnrichContext): ValidateCodeDiagnostic[];
88
+ /**
89
+ * Enrich a whole lint pass, reusing ONE docset resolution for every file in it.
90
+ *
91
+ * Shared by the primary lint and by the impact diff, so a finding reported in a file the
92
+ * agent is not editing carries the same documentation URL and the same hint as one in a file
93
+ * it is. A failure here degrades to unenriched findings rather than losing them: enrichment
94
+ * is additive, and dropping a diagnostic because its docs could not be read would trade a
95
+ * real finding for a cosmetic one.
96
+ */
97
+ export declare function enrichBatch(lint: EnrichableBatch, resolve: () => Promise<DocsetVocabulary>, log: (message: string) => void): Promise<Map<string, ValidateCodeDiagnostic[]>>;
98
+ /** The part of a lint pass {@link enrichBatch} reads — structural, so impact can pass its own. */
99
+ export interface EnrichableBatch {
100
+ diagnostics: Map<string, ValidateCodeDiagnostic[]>;
101
+ sources?: Map<string, {
102
+ ast?: LiquidHtmlNode;
103
+ startIndexes: number[];
104
+ }>;
105
+ }
@@ -4,6 +4,10 @@
4
4
  import { render } from '@platformos/platformos-language-server-common/dist/docset/index.js';
5
5
  import { checkDocs } from '../check-docs.js';
6
6
  import { documentedSymbolAt } from './symbol.js';
7
+ // Local, matching `validate-buffers.ts` and `transport/process-guards.ts`, which each carry
8
+ // their own. Three copies is a smell worth collapsing into one shared helper — separately,
9
+ // not in the middle of a feature.
10
+ const describe = (error) => (error instanceof Error ? error.message : String(error));
7
11
  /**
8
12
  * The checks for which a published SIGNATURE is the answer.
9
13
  *
@@ -94,4 +98,39 @@ function entryFor(symbol, vocabulary) {
94
98
  : vocabulary.objects;
95
99
  return entries.find((entry) => entry.name === symbol.name);
96
100
  }
101
+ /**
102
+ * Enrich a whole lint pass, reusing ONE docset resolution for every file in it.
103
+ *
104
+ * Shared by the primary lint and by the impact diff, so a finding reported in a file the
105
+ * agent is not editing carries the same documentation URL and the same hint as one in a file
106
+ * it is. A failure here degrades to unenriched findings rather than losing them: enrichment
107
+ * is additive, and dropping a diagnostic because its docs could not be read would trade a
108
+ * real finding for a cosmetic one.
109
+ */
110
+ export async function enrichBatch(lint, resolve, log) {
111
+ let found = 0;
112
+ for (const diagnostics of lint.diagnostics.values())
113
+ found += diagnostics.length;
114
+ if (found === 0)
115
+ return lint.diagnostics;
116
+ try {
117
+ const vocabulary = await resolve();
118
+ return new Map([...lint.diagnostics].map(([key, diagnostics]) => {
119
+ const source = lint.sources?.get(key);
120
+ // `startIndexes` is index-aligned with `diagnostics` by construction in
121
+ // `runBatchLint`; a missing entry can only mean the two got out of step, so the
122
+ // offset is passed as ABSENT rather than as an out-of-range sentinel that would
123
+ // resolve to some unrelated symbol.
124
+ const inputs = diagnostics.map((diagnostic, index) => ({
125
+ diagnostic,
126
+ startIndex: source?.startIndexes[index],
127
+ }));
128
+ return [key, enrichDiagnostics(inputs, { ast: source?.ast, vocabulary })];
129
+ }));
130
+ }
131
+ catch (error) {
132
+ log(`enrichment failed, returning findings unenriched: ${describe(error)}`);
133
+ return lint.diagnostics;
134
+ }
135
+ }
97
136
  //# sourceMappingURL=enrich.js.map
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Which files depend on the one being edited — the input to the lint diff, and nothing more.
3
+ *
4
+ * This module answers only "which files should I lint to find out what this change broke".
5
+ * It never decides whether something IS broken: that is the check engine's job, and the
6
+ * whole point of the diff is to stop reimplementing it here.
7
+ *
8
+ * NO EDGE-KIND FILTER, deliberately. An earlier version kept only `render`/`include`/
9
+ * `function` because it compared arguments by hand and any other kind produced false
10
+ * positives. The engine has no such problem — a `{% background %}` caller, a layout edge or
11
+ * an asset reference can each be broken by an edit — so every kind is a dependant here.
12
+ *
13
+ * `null` means NOT LOOKED AT: too much candidate text to examine inside the deadline. It is
14
+ * not "no dependants" and must never be reported as one.
15
+ *
16
+ * SOUND, NOT COMPLETE, and the gap is not closable. Every edge platformos-graph records
17
+ * names its target with a STATIC STRING LITERAL, so a dependant's text must contain the
18
+ * target's logical name; filtering on that is a sound over-approximation leaving few
19
+ * survivors (measured p50 1, p90 6 candidates on a 2,615-file project), and 0 of 8,464 real
20
+ * edges were dropped by it. What it cannot see is a caller that names its target at runtime
21
+ * or does not parse — see `frontier.ts`, which reports exactly that gap rather than hiding
22
+ * it.
23
+ */
24
+ import { type UriString } from '@platformos/platformos-check-common';
25
+ import type { ProjectScan } from './project-scan.js';
26
+ /**
27
+ * Whether this file could have dependants AT ALL — decidable from its path alone, with no
28
+ * project read and no parse.
29
+ *
30
+ * TWO CONDITIONS, and both are load-bearing:
31
+ *
32
+ * - it must be a resolvable edge TARGET, which means Liquid or GraphQL. Schema,
33
+ * custom-model-type and translation YAML are deliberately excluded: they are wired by
34
+ * model, table or translation-key NAME rather than by file reference (ADR 004), so the
35
+ * graph holds no edge to them and asking would silently answer "nothing depends on this".
36
+ * Giving those files real dependants is TASK-95, in the graph rather than here.
37
+ * - it must have a logical NAME, since a reference spells one. A source in no platformOS
38
+ * directory has none and so cannot be referenced.
39
+ *
40
+ * Asked twice on purpose: by the orchestrator, to skip reading the project for a changeset
41
+ * that cannot use it, and by impact itself. One predicate, so the two cannot disagree about
42
+ * whether that read was needed.
43
+ */
44
+ export declare function canHaveDependants(uri: UriString, rootUri: UriString): boolean;
45
+ /**
46
+ * The distinct files that reference `fileUri`, excluding anything in `exclude`.
47
+ *
48
+ * `exclude` carries the changeset: a file being edited is already reported on its own terms,
49
+ * and linting it again as somebody's dependant would report the same finding twice. It also
50
+ * makes the scan's text the DISK text for every dependant returned, which is exactly what
51
+ * the diff's baseline pass needs — the scan overlays buffers, so excluding them is what
52
+ * keeps the baseline honest without a second read.
53
+ */
54
+ export declare function dependantsOf(scan: ProjectScan, fileUri: UriString, name: string, exclude: ReadonlySet<UriString>): Promise<UriString[] | null>;
55
+ /**
56
+ * A dependant as the lint takes it: an absolute path and the text on disk.
57
+ *
58
+ * Carries its own `uri` rather than relying on position, because {@link toDependantBuffers}
59
+ * can DROP an entry — zipping the result back against the input by index would then pair
60
+ * every later buffer with the wrong file, silently.
61
+ */
62
+ export interface DependantBuffer {
63
+ uri: UriString;
64
+ filePath: string;
65
+ content: string;
66
+ }
67
+ /**
68
+ * Turn dependant URIs into lint buffers, reading their text from the scan the discovery
69
+ * already paid for rather than going back to disk.
70
+ *
71
+ * `path.fsPath` is check-common's own URI-to-path conversion, not a local spelling of it: on
72
+ * Windows a URI and a filesystem path differ in separator AND in how the drive letter is
73
+ * encoded, and a hand-rolled conversion is the mistake that only the Windows CI job catches.
74
+ */
75
+ export declare function toDependantBuffers(uris: readonly UriString[], sources: ReadonlyMap<UriString, string>): DependantBuffer[];
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Which files depend on the one being edited — the input to the lint diff, and nothing more.
3
+ *
4
+ * This module answers only "which files should I lint to find out what this change broke".
5
+ * It never decides whether something IS broken: that is the check engine's job, and the
6
+ * whole point of the diff is to stop reimplementing it here.
7
+ *
8
+ * NO EDGE-KIND FILTER, deliberately. An earlier version kept only `render`/`include`/
9
+ * `function` because it compared arguments by hand and any other kind produced false
10
+ * positives. The engine has no such problem — a `{% background %}` caller, a layout edge or
11
+ * an asset reference can each be broken by an edit — so every kind is a dependant here.
12
+ *
13
+ * `null` means NOT LOOKED AT: too much candidate text to examine inside the deadline. It is
14
+ * not "no dependants" and must never be reported as one.
15
+ *
16
+ * SOUND, NOT COMPLETE, and the gap is not closable. Every edge platformos-graph records
17
+ * names its target with a STATIC STRING LITERAL, so a dependant's text must contain the
18
+ * target's logical name; filtering on that is a sound over-approximation leaving few
19
+ * survivors (measured p50 1, p90 6 candidates on a 2,615-file project), and 0 of 8,464 real
20
+ * edges were dropped by it. What it cannot see is a caller that names its target at runtime
21
+ * or does not parse — see `frontier.ts`, which reports exactly that gap rather than hiding
22
+ * it.
23
+ */
24
+ import { path, SourceCodeType } from '@platformos/platformos-check-common';
25
+ import { MAX_CANDIDATE_BYTES } from '../cost-model.js';
26
+ import { sourceCodeTypeOf, uriToName } from '@platformos/platformos-common';
27
+ import { extractFileReferences, toSourceCode } from '@platformos/platformos-graph';
28
+ /**
29
+ * Whether this file could have dependants AT ALL — decidable from its path alone, with no
30
+ * project read and no parse.
31
+ *
32
+ * TWO CONDITIONS, and both are load-bearing:
33
+ *
34
+ * - it must be a resolvable edge TARGET, which means Liquid or GraphQL. Schema,
35
+ * custom-model-type and translation YAML are deliberately excluded: they are wired by
36
+ * model, table or translation-key NAME rather than by file reference (ADR 004), so the
37
+ * graph holds no edge to them and asking would silently answer "nothing depends on this".
38
+ * Giving those files real dependants is TASK-95, in the graph rather than here.
39
+ * - it must have a logical NAME, since a reference spells one. A source in no platformOS
40
+ * directory has none and so cannot be referenced.
41
+ *
42
+ * Asked twice on purpose: by the orchestrator, to skip reading the project for a changeset
43
+ * that cannot use it, and by impact itself. One predicate, so the two cannot disagree about
44
+ * whether that read was needed.
45
+ */
46
+ export function canHaveDependants(uri, rootUri) {
47
+ const type = sourceCodeTypeOf(uri);
48
+ const isEdgeTarget = type === SourceCodeType.LiquidHtml || type === SourceCodeType.GraphQL;
49
+ return isEdgeTarget && uriToName(uri, rootUri)?.name !== undefined;
50
+ }
51
+ /**
52
+ * The distinct files that reference `fileUri`, excluding anything in `exclude`.
53
+ *
54
+ * `exclude` carries the changeset: a file being edited is already reported on its own terms,
55
+ * and linting it again as somebody's dependant would report the same finding twice. It also
56
+ * makes the scan's text the DISK text for every dependant returned, which is exactly what
57
+ * the diff's baseline pass needs — the scan overlays buffers, so excluding them is what
58
+ * keeps the baseline honest without a second read.
59
+ */
60
+ export async function dependantsOf(scan, fileUri, name, exclude) {
61
+ const sources = await scan.sources();
62
+ const candidates = [...sources].filter(([uri, source]) => !exclude.has(uri) && uri !== fileUri && source.includes(name));
63
+ // BOUNDED BEFORE THE EXPENSIVE PART. Parsing is what discovery costs, and the byte total
64
+ // is known from the substring scan already done — so a file referenced from too much text
65
+ // to examine says so in milliseconds instead of spending seconds to reach the same answer.
66
+ const bytes = candidates.reduce((total, [, source]) => total + Buffer.byteLength(source), 0);
67
+ if (bytes > MAX_CANDIDATE_BYTES)
68
+ return null;
69
+ const perCandidate = await Promise.all(candidates.map(async ([uri, source]) => {
70
+ const sourceCode = await toSourceCode(uri, source);
71
+ const references = await extractFileReferences(scan.rootUri, uri, sourceCode, {
72
+ fs: scan.fs,
73
+ });
74
+ // The name filter over-approximates; resolving through the graph's OWN resolver is
75
+ // what makes the answer exact. A file that merely mentions the name contributes
76
+ // nothing.
77
+ return references.some((reference) => reference.target.uri === fileUri) ? uri : undefined;
78
+ }));
79
+ return perCandidate
80
+ .filter((uri) => uri !== undefined)
81
+ .sort((a, b) => a.localeCompare(b));
82
+ }
83
+ /**
84
+ * Turn dependant URIs into lint buffers, reading their text from the scan the discovery
85
+ * already paid for rather than going back to disk.
86
+ *
87
+ * `path.fsPath` is check-common's own URI-to-path conversion, not a local spelling of it: on
88
+ * Windows a URI and a filesystem path differ in separator AND in how the drive letter is
89
+ * encoded, and a hand-rolled conversion is the mistake that only the Windows CI job catches.
90
+ */
91
+ export function toDependantBuffers(uris, sources) {
92
+ const buffers = [];
93
+ for (const uri of uris) {
94
+ const content = sources.get(uri);
95
+ // A URI the scan does not hold cannot be linted from it. Unreachable — every dependant
96
+ // came OUT of this map — but defaulting to empty text would lint a file as blank and
97
+ // report every one of its findings as newly caused.
98
+ if (content === undefined)
99
+ continue;
100
+ buffers.push({ uri, filePath: path.fsPath(uri), content });
101
+ }
102
+ return buffers;
103
+ }
104
+ //# sourceMappingURL=dependants.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What a change INTRODUCED in a file it did not edit.
3
+ *
4
+ * PURE, and deliberately the whole definition of "relevant". The alternative — an allowlist
5
+ * of check codes that count as cross-file — rots the first time a check is added, and it
6
+ * asks the wrong question anyway: a `DeprecatedFrontmatterField` already in a dependant is
7
+ * irrelevant because it was there BEFORE, not because of its code. Relevance is CAUSAL, so
8
+ * it is computed by comparing the same file linted twice rather than by classifying codes.
9
+ *
10
+ * IDENTITY IS (check, line, column). The dependant's own text is byte-identical across the
11
+ * two passes — only the files it depends on changed — so positions are stable and cannot
12
+ * drift under the comparison. Matching on the code alone would let a pre-existing
13
+ * `UnknownFilter` on line 90 mask a new one on line 3.
14
+ *
15
+ * A MULTISET, not a set: two offenses can legitimately share a code and a position is the
16
+ * only thing that separates them, so the count at each identity is what carries over. Three
17
+ * `before` and four `after` at one identity means one new, not none.
18
+ */
19
+ import type { ValidateCodeDiagnostic } from '../result/types.js';
20
+ /**
21
+ * The diagnostics in `after` that are not in `before`, preserving `after`'s order.
22
+ *
23
+ * `before` is the file as it stands with NONE of the changeset overlaid; `after` is the same
24
+ * file with the changeset applied. Everything returned is therefore attributable to the
25
+ * change.
26
+ */
27
+ export declare function introducedDiagnostics(after: readonly ValidateCodeDiagnostic[], before: readonly ValidateCodeDiagnostic[]): ValidateCodeDiagnostic[];