@platformos/platformos-mcp-supervisor 0.0.2 → 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.
Files changed (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -0,0 +1,34 @@
1
+ import type { Declined } from '../result/types.js';
2
+ import type { BufferToValidate } from './validate-buffers.js';
3
+ /**
4
+ * Most files in one request.
5
+ *
6
+ * Far above any plausible single coherent edit. In practice the byte cap below binds
7
+ * first for large files; this one exists so a request of thousands of tiny buffers
8
+ * cannot turn into an unbounded number of per-file graph lookups.
9
+ */
10
+ export declare const MAX_BATCH_FILES = 50;
11
+ /**
12
+ * Most total bytes in one request.
13
+ *
14
+ * DERIVED, not chosen: the largest request whose scaled deadline still fits inside the
15
+ * ceiling the server is willing to wait (see `cost-model.ts`). Without any cap, 50 files
16
+ * just under `MAX_BUFFER_BYTES` would be ~6 MiB — minutes of parsing.
17
+ *
18
+ * A round multiple of `MAX_BUFFER_BYTES` inherits none of that reasoning and drifts the
19
+ * moment throughput moves, admitting a batch that cannot finish inside its deadline — and
20
+ * every file in such a request comes back `timed_out`, unchecked and silently. Deriving it
21
+ * means a change to the cost model moves this too; `cost-model.spec.ts` fails if the
22
+ * arithmetic stops fitting. The worst legal batch measures ~10 s against the deadline it
23
+ * earns; re-run `scripts/measure-lint-cost.mjs` to check the margin.
24
+ */
25
+ export declare const MAX_BATCH_BYTES: number;
26
+ /**
27
+ * A refusal when the request exceeds its file-count or total-byte cap, else
28
+ * `undefined`.
29
+ *
30
+ * Refuses the WHOLE request rather than trimming it: the cap is a property of the
31
+ * request, so choosing which files to drop would be arbitrary, and silently
32
+ * validating a subset would report a changeset as checked when it was not.
33
+ */
34
+ export declare function batchTooLarge(buffers: readonly BufferToValidate[]): Declined | undefined;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Caps on a multi-file request.
3
+ *
4
+ * The per-buffer bound (`MAX_BUFFER_BYTES`) is not enough on its own: a batch is a
5
+ * new way to hand the server unbounded work, and N individually-legal buffers add
6
+ * up. At the measured `LINT_MS_PER_KIB`, a request has to be bounded in total, not
7
+ * just per file.
8
+ */
9
+ import { LINT_MS_PER_KIB, MAX_LINT_DEADLINE_MS, maxBytesWithin } from '../cost-model.js';
10
+ /**
11
+ * Most files in one request.
12
+ *
13
+ * Far above any plausible single coherent edit. In practice the byte cap below binds
14
+ * first for large files; this one exists so a request of thousands of tiny buffers
15
+ * cannot turn into an unbounded number of per-file graph lookups.
16
+ */
17
+ export const MAX_BATCH_FILES = 50;
18
+ /**
19
+ * Most total bytes in one request.
20
+ *
21
+ * DERIVED, not chosen: the largest request whose scaled deadline still fits inside the
22
+ * ceiling the server is willing to wait (see `cost-model.ts`). Without any cap, 50 files
23
+ * just under `MAX_BUFFER_BYTES` would be ~6 MiB — minutes of parsing.
24
+ *
25
+ * A round multiple of `MAX_BUFFER_BYTES` inherits none of that reasoning and drifts the
26
+ * moment throughput moves, admitting a batch that cannot finish inside its deadline — and
27
+ * every file in such a request comes back `timed_out`, unchecked and silently. Deriving it
28
+ * means a change to the cost model moves this too; `cost-model.spec.ts` fails if the
29
+ * arithmetic stops fitting. The worst legal batch measures ~10 s against the deadline it
30
+ * earns; re-run `scripts/measure-lint-cost.mjs` to check the margin.
31
+ */
32
+ export const MAX_BATCH_BYTES = maxBytesWithin(MAX_LINT_DEADLINE_MS);
33
+ /**
34
+ * A refusal when the request exceeds its file-count or total-byte cap, else
35
+ * `undefined`.
36
+ *
37
+ * Refuses the WHOLE request rather than trimming it: the cap is a property of the
38
+ * request, so choosing which files to drop would be arbitrary, and silently
39
+ * validating a subset would report a changeset as checked when it was not.
40
+ */
41
+ export function batchTooLarge(buffers) {
42
+ // UNREACHABLE OVER MCP — `VALIDATE_CODE_INPUT.files` caps the array with
43
+ // `.max(MAX_BATCH_FILES)` first, and `runValidateCode` is not on the package's public
44
+ // surface — so zero coverage here is the schema working, not a gap. Kept as defence-in-depth
45
+ // because only this bound lives inside the function that promises it, and asked FIRST
46
+ // because splitting a request by bytes can still leave too many files.
47
+ if (buffers.length > MAX_BATCH_FILES) {
48
+ return {
49
+ code: 'too_large',
50
+ reason: `The request has ${buffers.length} files, above the ${MAX_BATCH_FILES} file limit. ` +
51
+ `Nothing was checked — split it into smaller batches.`,
52
+ };
53
+ }
54
+ const bytes = buffers.reduce((total, buffer) => total + Buffer.byteLength(buffer.content, 'utf8'), 0);
55
+ if (bytes > MAX_BATCH_BYTES) {
56
+ return {
57
+ code: 'too_large',
58
+ reason: `The request totals ${Math.round(bytes / 1024)} KiB, above the ${Math.round(MAX_BATCH_BYTES / 1024)} KiB limit. Validation costs about ${LINT_MS_PER_KIB} ms per KiB, so checking this ` +
59
+ `would take longer than this server will wait. Nothing was checked — split it into ` +
60
+ `smaller batches.`,
61
+ };
62
+ }
63
+ return undefined;
64
+ }
65
+ //# sourceMappingURL=batch-bounds.js.map
@@ -0,0 +1,21 @@
1
+ import type { Declined } from '../result/types.js';
2
+ import type { BufferToValidate } from './validate-buffers.js';
3
+ /**
4
+ * A refusal when two or more entries resolve to the same file, else `undefined`.
5
+ *
6
+ * A REFUSAL AND NOT A MERGE. Results are keyed by the caller's own `filePath` STRING, but
7
+ * buffers are overlaid and deduplicated by normalized URI, last entry winning — so when
8
+ * several caller keys resolve to ONE uri they all read back that uri's offenses, and the
9
+ * losing buffer is never linted while its entry carries the WINNER's verdict. Measured
10
+ * before this guard existed: a broken buffer and a clean one under the same path returned
11
+ * `ok` for both, and reversing the argument order flipped both to `error`.
12
+ *
13
+ * The whole request is refused rather than a winner picked, following `batchTooLarge`: the
14
+ * incoherence is a property of the REQUEST. IDENTICAL CONTENT IS REFUSED TOO — merging
15
+ * would be safe, but a rule with a content-equality carve-out is one more branch that can
16
+ * be wrong.
17
+ *
18
+ * Collision is decided on the same normalized URI the overlay uses, so this guard cannot
19
+ * disagree with the mechanism it protects.
20
+ */
21
+ export declare function collidingBufferPaths(projectDir: string, buffers: readonly BufferToValidate[]): Declined | undefined;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Whether a multi-file request CONTRADICTS ITSELF, as opposed to merely being too
3
+ * large.
4
+ *
5
+ * Separate from `batch-bounds.ts` because it asks a different question and needs a
6
+ * different input: a cap is a pure function of the buffers' content, while deciding
7
+ * whether two entries name the same file requires the project root to resolve
8
+ * relative paths against.
9
+ */
10
+ import { path as pathUtils } from '@platformos/platformos-check-common';
11
+ import { toAbsoluteFilePath } from '../adapter-input.js';
12
+ /**
13
+ * A refusal when two or more entries resolve to the same file, else `undefined`.
14
+ *
15
+ * A REFUSAL AND NOT A MERGE. Results are keyed by the caller's own `filePath` STRING, but
16
+ * buffers are overlaid and deduplicated by normalized URI, last entry winning — so when
17
+ * several caller keys resolve to ONE uri they all read back that uri's offenses, and the
18
+ * losing buffer is never linted while its entry carries the WINNER's verdict. Measured
19
+ * before this guard existed: a broken buffer and a clean one under the same path returned
20
+ * `ok` for both, and reversing the argument order flipped both to `error`.
21
+ *
22
+ * The whole request is refused rather than a winner picked, following `batchTooLarge`: the
23
+ * incoherence is a property of the REQUEST. IDENTICAL CONTENT IS REFUSED TOO — merging
24
+ * would be safe, but a rule with a content-equality carve-out is one more branch that can
25
+ * be wrong.
26
+ *
27
+ * Collision is decided on the same normalized URI the overlay uses, so this guard cannot
28
+ * disagree with the mechanism it protects.
29
+ */
30
+ export function collidingBufferPaths(projectDir, buffers) {
31
+ const spellingsByFile = new Map();
32
+ for (const buffer of buffers) {
33
+ const uri = pathUtils.normalize(pathUtils.URI.file(toAbsoluteFilePath(projectDir, buffer.filePath)));
34
+ const spellings = spellingsByFile.get(uri);
35
+ if (spellings)
36
+ spellings.push(buffer.filePath);
37
+ else
38
+ spellingsByFile.set(uri, [buffer.filePath]);
39
+ }
40
+ const collisions = [...spellingsByFile.values()].filter((spellings) => spellings.length > 1);
41
+ if (collisions.length === 0)
42
+ return undefined;
43
+ const described = collisions
44
+ .map((spellings) => spellings.map((spelling) => `\`${spelling}\``).join(' and '))
45
+ .join('; ');
46
+ return {
47
+ code: 'internal_error',
48
+ reason: `The same file appears more than once in this request: ${described}. A changeset cannot ` +
49
+ `contain two versions of one file — validating it would mean checking one buffer and ` +
50
+ `reporting its verdict for the other. Nothing was checked; send each file exactly once, ` +
51
+ `with the content you intend to write.`,
52
+ };
53
+ }
54
+ //# sourceMappingURL=batch-coherence.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import path from 'node:path';
3
+ import { collidingBufferPaths } from './batch-coherence.js';
4
+ /**
5
+ * TASK-22. Two entries resolving to one file used to be linted as one buffer (last
6
+ * one wins) while BOTH entries were reported with the winner's verdict — so a
7
+ * buffer that was never validated came back clean, and reversing the argument order
8
+ * flipped which one was lied about.
9
+ *
10
+ * The guard is decided on the same normalized URI the overlay deduplicates on, so
11
+ * every spelling that collapses there must be caught here.
12
+ */
13
+ const ROOT = path.resolve('/srv/app');
14
+ const buffer = (filePath, content = 'x') => ({ filePath, content });
15
+ describe('Unit: collidingBufferPaths', () => {
16
+ describe('refuses every spelling that resolves to one file', () => {
17
+ it.each([
18
+ [
19
+ 'the identical relative path twice',
20
+ 'app/views/partials/x.liquid',
21
+ 'app/views/partials/x.liquid',
22
+ ],
23
+ [
24
+ 'absolute and relative spellings of one file',
25
+ path.join(ROOT, 'app/views/partials/x.liquid'),
26
+ 'app/views/partials/x.liquid',
27
+ ],
28
+ [
29
+ 'a redundant current-directory segment',
30
+ 'app/views/partials/x.liquid',
31
+ 'app/views/partials/./x.liquid',
32
+ ],
33
+ [
34
+ 'a path that climbs out and back in',
35
+ 'app/views/partials/x.liquid',
36
+ 'app/views/partials/../partials/x.liquid',
37
+ ],
38
+ ])('%s', (_why, first, second) => {
39
+ const refusal = collidingBufferPaths(ROOT, [
40
+ buffer(first, 'broken'),
41
+ buffer(second, 'clean'),
42
+ ]);
43
+ expect(refusal?.code).toEqual('internal_error');
44
+ // Both spellings are named, so the caller can find the entries to remove.
45
+ expect(refusal?.reason).toContain(first);
46
+ expect(refusal?.reason).toContain(second);
47
+ });
48
+ });
49
+ it('refuses regardless of argument order', () => {
50
+ const one = 'app/views/partials/x.liquid';
51
+ const two = path.join(ROOT, 'app/views/partials/x.liquid');
52
+ expect(collidingBufferPaths(ROOT, [buffer(one), buffer(two)])?.code).toEqual('internal_error');
53
+ expect(collidingBufferPaths(ROOT, [buffer(two), buffer(one)])?.code).toEqual('internal_error');
54
+ });
55
+ it('refuses even when the colliding buffers carry identical content', () => {
56
+ // Safe to merge in principle, but "same path twice" is a caller bug either way
57
+ // and a content-equality carve-out is one more branch that can be wrong.
58
+ const refusal = collidingBufferPaths(ROOT, [
59
+ buffer('app/views/pages/a.liquid', 'same'),
60
+ buffer('app/views/pages/a.liquid', 'same'),
61
+ ]);
62
+ expect(refusal?.code).toEqual('internal_error');
63
+ });
64
+ it('names every colliding group, not just the first', () => {
65
+ const refusal = collidingBufferPaths(ROOT, [
66
+ buffer('app/views/pages/a.liquid'),
67
+ buffer('app/views/pages/b.liquid'),
68
+ buffer('app/views/pages/a.liquid'),
69
+ buffer('app/views/pages/b.liquid'),
70
+ ]);
71
+ expect(refusal?.reason).toContain('a.liquid');
72
+ expect(refusal?.reason).toContain('b.liquid');
73
+ });
74
+ it('reports a file repeated three times as one group', () => {
75
+ const refusal = collidingBufferPaths(ROOT, [
76
+ buffer('app/views/pages/a.liquid'),
77
+ buffer('app/views/pages/a.liquid'),
78
+ buffer('app/views/pages/a.liquid'),
79
+ ]);
80
+ expect(refusal?.code).toEqual('internal_error');
81
+ });
82
+ describe('allows requests that name each file once', () => {
83
+ it('accepts distinct files', () => {
84
+ expect(collidingBufferPaths(ROOT, [
85
+ buffer('app/views/pages/a.liquid'),
86
+ buffer('app/views/partials/b.liquid'),
87
+ buffer('app/graphql/c.graphql'),
88
+ ])).toBeUndefined();
89
+ });
90
+ it('accepts a mix of absolute and relative spellings of DIFFERENT files', () => {
91
+ // The keying this guard protects exists so exactly this request keeps working.
92
+ expect(collidingBufferPaths(ROOT, [
93
+ buffer(path.join(ROOT, 'app/views/pages/a.liquid')),
94
+ buffer('app/views/partials/b.liquid'),
95
+ ])).toBeUndefined();
96
+ });
97
+ it('accepts a single buffer', () => {
98
+ expect(collidingBufferPaths(ROOT, [buffer('app/views/pages/a.liquid')])).toBeUndefined();
99
+ });
100
+ it('accepts an empty request', () => {
101
+ expect(collidingBufferPaths(ROOT, [])).toBeUndefined();
102
+ });
103
+ it('does not treat two different files in one directory as a collision', () => {
104
+ expect(collidingBufferPaths(ROOT, [
105
+ buffer('app/views/pages/index.liquid'),
106
+ buffer('app/views/pages/index2.liquid'),
107
+ ])).toBeUndefined();
108
+ });
109
+ });
110
+ });
111
+ //# sourceMappingURL=batch-coherence.spec.js.map
@@ -0,0 +1,27 @@
1
+ import { type SupervisorContext } from '../context.js';
2
+ import { type DocsetVocabulary } from '../enrich/enrich.js';
3
+ import { runImpact } from '../impact/impact.js';
4
+ import { runBatchLint } from '../lint/lint-batch.js';
5
+ import type { ValidateCodeResult } from '../result/types.js';
6
+ /** One buffer to validate, keyed by the caller's own path string. */
7
+ export interface BufferToValidate {
8
+ /** File under edit — absolute, or relative to the project root, exactly as given. */
9
+ filePath: string;
10
+ content: string;
11
+ }
12
+ /** The I/O seams the orchestrator drives. Injectable so the contract is unit-testable. */
13
+ export interface ValidateAdapters {
14
+ lint: typeof runBatchLint;
15
+ impact: typeof runImpact;
16
+ /** Resolve the docset enrichment reads — the one piece of I/O the pure stage cannot do. */
17
+ docset: () => Promise<DocsetVocabulary>;
18
+ }
19
+ /**
20
+ * Validate every buffer, returning one result per buffer keyed by the caller's own
21
+ * `filePath` string.
22
+ *
23
+ * Keyed by the caller's key rather than a normalized URI so a caller that mixes
24
+ * relative and absolute paths can find its own results without reproducing our
25
+ * normalization.
26
+ */
27
+ export declare function validateBuffers(ctx: SupervisorContext, buffers: readonly BufferToValidate[], overrides?: Partial<ValidateAdapters>): Promise<Map<string, ValidateCodeResult>>;
@@ -0,0 +1,319 @@
1
+ /**
2
+ * The ONE validation orchestrator. Everything the tool does happens here, and it always
3
+ * works on a LIST of buffers — one file is simply a list of length one. The tool surface
4
+ * adapts shapes; the logic exists once.
5
+ *
6
+ * THE BATCH IS NOT ATOMIC. Every requested buffer gets a result. A refusal declines only
7
+ * ITS buffer, and a merely-unchecked buffer never gates the others.
8
+ *
9
+ * The list is the primitive because validating several buffers together is also more
10
+ * CORRECT: with every buffer overlaid at once, a partial introduced in one resolves for a
11
+ * `render` in another.
12
+ */
13
+ import { getPlatformOSDocset, NodeFileSystem, path as pathUtils, } from '@platformos/platformos-check-node';
14
+ import { assetNotLinted, bufferTooLarge, fileApplicability, ignoredByProjectConfig, misplacedSource, notPlatformOSFile, toAbsoluteFilePath, } from '../adapter-input.js';
15
+ import { IMPACT_DEADLINE_MS } from '../context.js';
16
+ import { enrichDiagnostics } from '../enrich/enrich.js';
17
+ import { lintDeadlineMs } from '../cost-model.js';
18
+ import { TIMED_OUT, withDeadline } from '../deadline.js';
19
+ import { runImpact } from '../impact/impact.js';
20
+ import { canHaveDependants } from '../impact/dependants.js';
21
+ import { createProjectScan } from '../impact/project-scan.js';
22
+ import { runBatchLint, } from '../lint/lint-batch.js';
23
+ import { assembleNotApplicableResult, assembleResult } from '../result/assemble.js';
24
+ import { capToBudget } from '../result/response-budget.js';
25
+ import { DISABLED_IMPACT, UNAVAILABLE_IMPACT } from '../result/impact-states.js';
26
+ /** Read the process-wide docset and flatten it to the arrays enrichment takes. */
27
+ async function resolveDocset() {
28
+ const docset = getPlatformOSDocset();
29
+ const [filters, tags, objects] = await Promise.all([
30
+ docset.filters(),
31
+ docset.tags(),
32
+ docset.objects(),
33
+ ]);
34
+ return { filters, tags, objects };
35
+ }
36
+ const DEFAULT_ADAPTERS = {
37
+ lint: runBatchLint,
38
+ impact: runImpact,
39
+ docset: resolveDocset,
40
+ };
41
+ /**
42
+ * check-node's "did not check it" status → the refusal the agent reads, by
43
+ * project-relative path.
44
+ *
45
+ * TOTAL OVER {@link LintNotCheckedStatus} BY TYPE, with no `default` arm: every entry hands
46
+ * back different ADVICE, so a status added upstream must fail the build here rather than
47
+ * fall through to someone else's remedy.
48
+ */
49
+ const DECLINE = {
50
+ 'excluded-by-config': ignoredByProjectConfig,
51
+ 'misplaced-source': misplacedSource,
52
+ 'not-a-platformos-file': notPlatformOSFile,
53
+ 'not-a-source-file': assetNotLinted,
54
+ };
55
+ /**
56
+ * Validate every buffer, returning one result per buffer keyed by the caller's own
57
+ * `filePath` string.
58
+ *
59
+ * Keyed by the caller's key rather than a normalized URI so a caller that mixes
60
+ * relative and absolute paths can find its own results without reproducing our
61
+ * normalization.
62
+ */
63
+ export async function validateBuffers(ctx, buffers, overrides = {}) {
64
+ const adapters = { ...DEFAULT_ADAPTERS, ...overrides };
65
+ const { declined, lintable } = partition(ctx.projectDir, buffers);
66
+ // Computed ONCE and threaded to both the timer and the message that reports it, so the
67
+ // two cannot disagree about the deadline a caller was held to.
68
+ const lintDeadline = lintDeadlineMs(admittedBytes(lintable));
69
+ // The project SCAN overlaps the primary lint; the impact LINT PASSES cannot.
70
+ //
71
+ // `lintBuffers` overlays buffers into check-node's process-shared `App` and reverts them
72
+ // on the way out, and that App has no lock — so two lint passes running at once would
73
+ // corrupt each other. The scan is pure filesystem I/O and touches no App, which is what
74
+ // lets the expensive half of impact hide behind the lint anyway.
75
+ const scan = projectScan(ctx, lintable);
76
+ const [lint] = await Promise.all([
77
+ lintWithDeadline(ctx, adapters, lintable, lintDeadline),
78
+ scan.warm(),
79
+ ]);
80
+ // AFTER the primary lint, and never allowed to break it: the write gate is already
81
+ // decided by this point, so impact can only add to the answer or fail quietly.
82
+ //
83
+ // Turned off for this SERVER (`--no-impact`) it costs NOTHING: `projectScan` declines to
84
+ // read, and the two extra lint passes never start. `disabled` is its own status precisely
85
+ // so an agent does not read it as a failure worth retrying.
86
+ const impacts = ctx.impactEnabled === false
87
+ ? new Map(lintable.map((buffer) => [buffer.filePath, DISABLED_IMPACT()]))
88
+ : await impactWithDeadline(ctx, adapters, lintable, scan.scan);
89
+ // The lint pass is what knows which buffers were not checked and why — it holds the
90
+ // config AND the classifier. Folding its answer in here keeps ONE source of truth for
91
+ // "is this file part of the app".
92
+ if (lint !== TIMED_OUT) {
93
+ const rootUri = pathUtils.toUri(ctx.projectDir);
94
+ for (const [key, status] of lint.notChecked) {
95
+ const absolute = pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, key));
96
+ declined.set(key, DECLINE[status](pathUtils.relative(absolute, rootUri)));
97
+ }
98
+ }
99
+ const diagnostics = lint === TIMED_OUT ? TIMED_OUT : await enrich(ctx, lint, adapters.docset);
100
+ // The response bound is applied LAST, to finished results: `resultFor` has already
101
+ // computed `status` and `must_fix_before_write` from the complete diagnostic set, so the
102
+ // cap can only shorten lists — it has no way to soften a verdict.
103
+ return capToBudget(new Map(buffers.map((buffer) => [
104
+ buffer.filePath,
105
+ resultFor(buffer.filePath, declined, diagnostics, impacts, lintDeadline),
106
+ ])));
107
+ }
108
+ /**
109
+ * Split the requested buffers into those this server declines outright and those it will
110
+ * send to the lint.
111
+ *
112
+ * PURE and synchronous. Every refusal decidable without I/O happens here — outside the
113
+ * project root, an unsupported file type, over the size bound — so a declined buffer costs
114
+ * nothing and never reaches the engine.
115
+ *
116
+ * Config-level exclusion and telling a MISPLACED source from an unsupported one both need
117
+ * the project, so they are decided by the lint pass instead and folded in by
118
+ * {@link validateBuffers} through {@link DECLINE}.
119
+ */
120
+ function partition(projectDir, buffers) {
121
+ const declined = new Map();
122
+ const lintable = [];
123
+ for (const buffer of buffers) {
124
+ const refusal = refuse(projectDir, buffer);
125
+ if (refusal) {
126
+ declined.set(buffer.filePath, refusal);
127
+ continue;
128
+ }
129
+ lintable.push({ filePath: buffer.filePath, content: buffer.content });
130
+ }
131
+ return { declined, lintable };
132
+ }
133
+ /** The pure, per-buffer refusal for this buffer, if any. */
134
+ function refuse(projectDir, buffer) {
135
+ const applicability = fileApplicability(projectDir, buffer.filePath);
136
+ if (!applicability.applicable)
137
+ return applicability;
138
+ return bufferTooLarge(buffer.content);
139
+ }
140
+ /**
141
+ * Lint every lintable buffer in ONE project pass, or {@link TIMED_OUT}.
142
+ *
143
+ * The deadline is a backstop against an async stall, NOT cancellation: a synchronous parse
144
+ * blocks the event loop and the timer cannot even fire during it (see `deadline.ts`). What
145
+ * bounds CPU-bound work is `MAX_BUFFER_BYTES` and `MAX_BATCH_BYTES`.
146
+ *
147
+ * It is sized from the bytes ACTUALLY ADMITTED, since those are the only ones the lint
148
+ * will spend time on.
149
+ */
150
+ async function lintWithDeadline(ctx, adapters, lintable, deadline) {
151
+ if (lintable.length === 0)
152
+ return { diagnostics: new Map(), sources: new Map(), notChecked: new Map() };
153
+ const work = adapters.lint({ projectDir: ctx.projectDir, buffers: lintable });
154
+ const outcome = await withDeadline(work, deadline);
155
+ if (outcome !== TIMED_OUT)
156
+ return outcome;
157
+ observeAbandoned(ctx, work, 'lint');
158
+ ctx.log(`validate_code: lint exceeded ${deadline} ms`);
159
+ return TIMED_OUT;
160
+ }
161
+ /**
162
+ * Attach the check's documentation URL and the docset entry for the symbol each diagnostic
163
+ * is about.
164
+ *
165
+ * THE ONLY I/O HERE IS RESOLVING THE DOCSET, at this edge rather than inside `enrich/`,
166
+ * which is a pure function of data.
167
+ *
168
+ * FAILING TO ENRICH MUST NOT FAIL THE CALL: a throw degrades to the un-enriched
169
+ * diagnostics and is logged, rather than reporting a broken file as unchecked.
170
+ *
171
+ * NOTHING TO ENRICH MEANS NO I/O AT ALL — resolving the docset would pay
172
+ * `PlatformOSLiquidDocsManager.setup()`'s network revision check for nothing.
173
+ */
174
+ async function enrich(ctx, lint, resolve) {
175
+ let found = 0;
176
+ for (const diagnostics of lint.diagnostics.values())
177
+ found += diagnostics.length;
178
+ if (found === 0)
179
+ return lint.diagnostics;
180
+ try {
181
+ // ONCE per request, not once per file: the vocabulary is the same for every buffer.
182
+ const vocabulary = await resolve();
183
+ return new Map([...lint.diagnostics].map(([key, diagnostics]) => {
184
+ const source = lint.sources?.get(key);
185
+ // `startIndexes` is index-aligned with `diagnostics` by construction in
186
+ // `runBatchLint`; a missing entry can only mean the two got out of step, so the
187
+ // offset is passed as ABSENT rather than as an out-of-range sentinel that would
188
+ // resolve to some unrelated symbol.
189
+ const inputs = diagnostics.map((diagnostic, index) => ({
190
+ diagnostic,
191
+ startIndex: source?.startIndexes[index],
192
+ }));
193
+ return [key, enrichDiagnostics(inputs, { ast: source?.ast, vocabulary })];
194
+ }));
195
+ }
196
+ catch (error) {
197
+ ctx.log(`validate_code: enrichment failed, returning findings unenriched: ${describe(error)}`);
198
+ return lint.diagnostics;
199
+ }
200
+ }
201
+ /** Total UTF-8 bytes handed to the lint — what the deadline is sized against. */
202
+ function admittedBytes(lintable) {
203
+ return lintable.reduce((total, buffer) => total + Buffer.byteLength(buffer.content, 'utf8'), 0);
204
+ }
205
+ /**
206
+ * Impact per lintable buffer.
207
+ *
208
+ * ONE scan serves the whole batch: `createProjectScan` reads the project's edge sources once,
209
+ * LAZILY and memoized, so a batch whose buffers can have no dependants never reads the project
210
+ * at all, and one where some can pays a single read. The batch's own buffers are overlaid into
211
+ * it, so a call a buffer has just added counts.
212
+ *
213
+ * The set shares one tight deadline because impact is discardable enrichment —
214
+ * `unavailable` already means "we don't know".
215
+ */
216
+ async function impactWithDeadline(ctx, adapters, lintable, scan) {
217
+ const byFile = new Map();
218
+ if (lintable.length === 0)
219
+ return byFile;
220
+ const work = adapters
221
+ .impact({
222
+ projectDir: ctx.projectDir,
223
+ buffers: lintable,
224
+ scan,
225
+ lint: adapters.lint,
226
+ docset: adapters.docset,
227
+ log: (message) => ctx.log(`validate_code: ${message}`),
228
+ })
229
+ .catch((error) => {
230
+ // ONE failure covers the whole changeset: the passes are shared, so a failure says
231
+ // nothing about any individual buffer.
232
+ ctx.log(`validate_code: impact failed: ${describe(error)}`);
233
+ return new Map();
234
+ });
235
+ const outcome = await withDeadline(work, IMPACT_DEADLINE_MS);
236
+ if (outcome === TIMED_OUT) {
237
+ observeAbandoned(ctx, work, 'impact');
238
+ ctx.log(`validate_code: impact exceeded ${IMPACT_DEADLINE_MS} ms, continuing without it`);
239
+ for (const buffer of lintable)
240
+ byFile.set(buffer.filePath, UNAVAILABLE_IMPACT());
241
+ return byFile;
242
+ }
243
+ // A buffer the pass did not answer for gets `unavailable` rather than a silent absence:
244
+ // `resultFor` would otherwise default it, and "we did not look" must not read as "we
245
+ // looked and found nothing".
246
+ for (const buffer of lintable) {
247
+ byFile.set(buffer.filePath, outcome.get(buffer.filePath) ?? UNAVAILABLE_IMPACT());
248
+ }
249
+ return byFile;
250
+ }
251
+ /**
252
+ * The request's project scan, plus a handle to start reading it.
253
+ *
254
+ * Split so the read can be STARTED alongside the primary lint and awaited later: it is the
255
+ * expensive half of impact (~235 ms on a 2,615-file project) and pure filesystem I/O, so
256
+ * overlapping it with the lint's CPU costs nothing and races nothing.
257
+ */
258
+ function projectScan(ctx, lintable) {
259
+ const rootUri = pathUtils.normalize(pathUtils.toUri(ctx.projectDir));
260
+ const scan = createProjectScan(rootUri, NodeFileSystem, new Map(lintable.map((buffer) => [
261
+ pathUtils.normalize(pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, buffer.filePath))),
262
+ buffer.content,
263
+ ])));
264
+ // Nothing in this changeset can HAVE dependants — every buffer is a YAML file, or sits in
265
+ // no platformOS directory — so impact will never consult the scan and reading the project
266
+ // would be pure waste. Decidable from the paths alone, before any I/O.
267
+ //
268
+ // A PERFORMANCE GUARD, NOT A CORRECTNESS ONE, which is why six mutants survive on it.
269
+ // `warm()` only PRE-warms: `scan.sources()` memoizes its promise and impact awaits that
270
+ // same promise itself, so inverting this buys a wasted read or a colder path, never a
271
+ // different answer. Nothing pins it — nor the `--no-impact` claim above, where what is
272
+ // tested is that the impact ADAPTER goes uncalled, not that the project read is skipped.
273
+ const worthReading = ctx.impactEnabled !== false &&
274
+ lintable.some((buffer) => canHaveDependants(pathUtils.normalize(pathUtils.toUri(toAbsoluteFilePath(ctx.projectDir, buffer.filePath))), rootUri));
275
+ return {
276
+ scan,
277
+ // A failed read is impact's problem, not the lint's: swallowed here so it cannot reject
278
+ // the `Promise.all` that the primary lint is riding in, and surfaced when impact awaits
279
+ // the same memoized promise itself.
280
+ warm: () => (worthReading ? scan.sources().catch(() => undefined) : Promise.resolve()),
281
+ };
282
+ }
283
+ /**
284
+ * Attach a handler to work the deadline abandoned.
285
+ *
286
+ * The promise is STILL RUNNING and now unobserved, so a later rejection would surface as
287
+ * an unhandled rejection — fatal under Node's default. Logged rather than swallowed.
288
+ */
289
+ function observeAbandoned(ctx, work, what) {
290
+ work.catch((error) => {
291
+ ctx.log(`validate_code: abandoned ${what} later failed: ${describe(error)}`);
292
+ });
293
+ }
294
+ const describe = (error) => (error instanceof Error ? error.message : String(error));
295
+ function resultFor(filePath, declined, diagnostics, impacts, lintDeadline) {
296
+ const refusal = declined.get(filePath);
297
+ if (refusal)
298
+ return assembleNotApplicableResult(refusal);
299
+ if (diagnostics === TIMED_OUT) {
300
+ return assembleNotApplicableResult({
301
+ code: 'timed_out',
302
+ reason: `Validation exceeded ${Math.round(lintDeadline / 1000)} s and was abandoned, so ` +
303
+ `nothing conclusive was checked for \`${filePath}\`. Treat this as "unknown", not as a ` +
304
+ `verdict on the file. Retrying — with fewer files, if this was a batch — may succeed.`,
305
+ });
306
+ }
307
+ const forFile = diagnostics.get(filePath);
308
+ if (forFile === undefined) {
309
+ // FAIL SAFE. Unreachable: `runBatchLint` seeds an entry for every requested key. But
310
+ // defaulting to `[]` would report a file CLEAN that was never linted.
311
+ return assembleNotApplicableResult({
312
+ code: 'internal_error',
313
+ reason: `No result was produced for \`${filePath}\`, so nothing was checked. This is a bug in ` +
314
+ `the validator, not a verdict on the file — treat it as "unknown" and please report it.`,
315
+ });
316
+ }
317
+ return assembleResult(forFile, impacts.get(filePath) ?? UNAVAILABLE_IMPACT());
318
+ }
319
+ //# sourceMappingURL=validate-buffers.js.map