@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 +20 -5
- package/dist/bin/args.d.ts +15 -2
- package/dist/bin/args.js +31 -3
- package/dist/bin/platformos-mcp-supervisor.js +6 -2
- package/dist/context.d.ts +16 -8
- package/dist/context.js +7 -6
- package/dist/cost-model.d.ts +60 -0
- package/dist/cost-model.js +60 -0
- package/dist/enrich/enrich.d.ts +18 -0
- package/dist/enrich/enrich.js +39 -0
- package/dist/impact/dependants.d.ts +75 -0
- package/dist/impact/dependants.js +104 -0
- package/dist/impact/diff.d.ts +27 -0
- package/dist/impact/diff.js +33 -0
- package/dist/impact/impact.d.ts +17 -5
- package/dist/impact/impact.js +135 -164
- package/dist/impact/project-scan.d.ts +12 -10
- package/dist/impact/project-scan.js +12 -10
- package/dist/result/assemble.d.ts +1 -2
- package/dist/result/assemble.js +1 -2
- package/dist/result/blocking.d.ts +10 -4
- package/dist/result/blocking.js +23 -3
- package/dist/result/impact-states.d.ts +12 -9
- package/dist/result/impact-states.js +9 -17
- package/dist/result/response-budget.js +5 -3
- package/dist/result/types.d.ts +42 -40
- package/dist/transport/instructions.js +24 -6
- package/dist/transport/server.d.ts +5 -0
- package/dist/transport/server.js +8 -4
- package/dist/transport/validate-code.d.ts +17 -0
- package/dist/transport/validate-code.js +56 -18
- package/dist/validate/batch-bounds.js +5 -0
- package/dist/validate/validate-buffers.js +81 -27
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -51,11 +51,26 @@ input { file_path, content } | { files: [...] }
|
|
|
51
51
|
→ ValidateCodeResult
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
package/dist/bin/args.d.ts
CHANGED
|
@@ -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({
|
|
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").
|
|
10
|
-
*
|
|
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
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
package/dist/cost-model.d.ts
CHANGED
|
@@ -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;
|
package/dist/cost-model.js
CHANGED
|
@@ -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
|
package/dist/enrich/enrich.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/enrich/enrich.js
CHANGED
|
@@ -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[];
|