@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.
- package/README.md +105 -0
- package/dist/adapter-input.d.ts +113 -0
- package/dist/adapter-input.js +210 -0
- package/dist/adapter-input.spec.d.ts +1 -0
- package/dist/adapter-input.spec.js +275 -0
- package/dist/bin/args.d.ts +15 -2
- package/dist/bin/args.js +31 -3
- package/dist/bin/args.spec.d.ts +1 -0
- package/dist/bin/args.spec.js +35 -0
- package/dist/bin/platformos-mcp-supervisor.js +6 -2
- package/dist/check-docs.d.ts +21 -0
- package/dist/check-docs.js +38 -0
- package/dist/check-docs.spec.d.ts +1 -0
- package/dist/check-docs.spec.js +87 -0
- package/dist/context.d.ts +34 -0
- package/dist/context.js +12 -0
- package/dist/cost-model.d.ts +188 -0
- package/dist/cost-model.js +197 -0
- package/dist/cost-model.spec.d.ts +1 -0
- package/dist/cost-model.spec.js +91 -0
- package/dist/deadline.d.ts +27 -0
- package/dist/deadline.js +40 -0
- package/dist/deadline.spec.d.ts +1 -0
- package/dist/deadline.spec.js +83 -0
- package/dist/enrich/enrich.d.ts +105 -0
- package/dist/enrich/enrich.js +136 -0
- package/dist/enrich/enrich.spec.d.ts +1 -0
- package/dist/enrich/enrich.spec.js +184 -0
- package/dist/enrich/symbol.d.ts +16 -0
- package/dist/enrich/symbol.js +58 -0
- package/dist/enrich/symbol.spec.d.ts +1 -0
- package/dist/enrich/symbol.spec.js +89 -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 +21 -0
- package/dist/impact/impact.js +160 -0
- package/dist/impact/impact.spec.d.ts +1 -0
- package/dist/impact/impact.spec.js +357 -0
- package/dist/impact/project-scan.d.ts +39 -0
- package/dist/impact/project-scan.js +62 -0
- package/dist/impact/project-scan.spec.d.ts +1 -0
- package/dist/impact/project-scan.spec.js +121 -0
- package/dist/index.d.ts +1 -1
- package/dist/lint/fixes.d.ts +28 -0
- package/dist/lint/fixes.js +30 -0
- package/dist/lint/lint-batch.d.ts +100 -0
- package/dist/lint/lint-batch.js +98 -0
- package/dist/lint/lint-batch.spec.d.ts +1 -0
- package/dist/lint/lint-batch.spec.js +503 -0
- package/dist/lint/lint.d.ts +11 -1
- package/dist/lint/lint.js +9 -3
- package/dist/result/assemble.d.ts +11 -9
- package/dist/result/assemble.js +63 -16
- package/dist/result/assemble.spec.d.ts +1 -0
- package/dist/result/assemble.spec.js +319 -0
- package/dist/result/blocking.d.ts +92 -0
- package/dist/result/blocking.js +116 -0
- package/dist/result/blocking.spec.d.ts +1 -0
- package/dist/result/blocking.spec.js +201 -0
- package/dist/result/impact-states.d.ts +19 -0
- package/dist/result/impact-states.js +10 -0
- package/dist/result/response-budget.d.ts +9 -0
- package/dist/result/response-budget.js +133 -0
- package/dist/result/response-budget.spec.d.ts +1 -0
- package/dist/result/response-budget.spec.js +222 -0
- package/dist/result/types.d.ts +212 -104
- package/dist/result/types.js +4 -13
- package/dist/transport/instructions-coverage.spec.d.ts +1 -0
- package/dist/transport/instructions-coverage.spec.js +176 -0
- package/dist/transport/instructions.d.ts +1 -0
- package/dist/transport/instructions.js +150 -0
- package/dist/transport/process-guards.d.ts +43 -0
- package/dist/transport/process-guards.js +63 -0
- package/dist/transport/process-guards.spec.d.ts +1 -0
- package/dist/transport/process-guards.spec.js +200 -0
- package/dist/transport/server.d.ts +6 -1
- package/dist/transport/server.js +21 -10
- package/dist/transport/validate-code.d.ts +50 -15
- package/dist/transport/validate-code.js +238 -33
- package/dist/transport/validate-code.spec.d.ts +1 -0
- package/dist/transport/validate-code.spec.js +2303 -0
- package/dist/validate/batch-bounds.d.ts +34 -0
- package/dist/validate/batch-bounds.js +65 -0
- package/dist/validate/batch-coherence.d.ts +21 -0
- package/dist/validate/batch-coherence.js +54 -0
- package/dist/validate/batch-coherence.spec.d.ts +1 -0
- package/dist/validate/batch-coherence.spec.js +111 -0
- package/dist/validate/validate-buffers.d.ts +27 -0
- package/dist/validate/validate-buffers.js +319 -0
- package/package.json +8 -7
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { NodeFileSystem, path as pathUtils } from '@platformos/platformos-check-node';
|
|
6
|
+
import { runImpact } from './impact.js';
|
|
7
|
+
import { createProjectScan } from './project-scan.js';
|
|
8
|
+
/**
|
|
9
|
+
* The blast radius is an I/O adapter over the project's own text, so these run against a
|
|
10
|
+
* REAL project on disk rather than a stubbed graph.
|
|
11
|
+
*
|
|
12
|
+
* A stubbed `AppGraph` can only prove the summarizing arithmetic; every claim that matters
|
|
13
|
+
* — that a `{% render %}` in another file is FOUND, that a name-shaped substring in prose
|
|
14
|
+
* is not, that a buffer's edit is what counts — lives below the stub. Here the walk, the
|
|
15
|
+
* read, the name filter, the parse and the resolver all run.
|
|
16
|
+
*/
|
|
17
|
+
describe('runImpact', () => {
|
|
18
|
+
let projectDir;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
projectDir = mkdtempSync(join(tmpdir(), 'pos-impact-'));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
const write = (files) => {
|
|
26
|
+
for (const [relativePath, source] of Object.entries(files)) {
|
|
27
|
+
const absolute = join(projectDir, relativePath);
|
|
28
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
29
|
+
writeFileSync(absolute, source, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const scanOf = (buffers = {}) => createProjectScan(pathUtils.normalize(pathUtils.toUri(projectDir)), NodeFileSystem, new Map(Object.entries(buffers).map(([relativePath, content]) => [
|
|
33
|
+
pathUtils.normalize(pathUtils.toUri(join(projectDir, relativePath))),
|
|
34
|
+
content,
|
|
35
|
+
])));
|
|
36
|
+
const run = (filePath, content = '', scan = scanOf()) => runImpact({ projectDir, filePath, content }, scan);
|
|
37
|
+
const card = 'app/views/partials/card.liquid';
|
|
38
|
+
describe('dependents', () => {
|
|
39
|
+
it('summarizes distinct dependent files, per-kind counts, and a sorted sample', async () => {
|
|
40
|
+
write({
|
|
41
|
+
[card]: '<div></div>',
|
|
42
|
+
'app/views/pages/index.liquid': "{% render 'card' %}",
|
|
43
|
+
'app/views/pages/about.liquid': "{% render 'card' %}",
|
|
44
|
+
'app/views/partials/wrapper.liquid': "{% include 'card' %}",
|
|
45
|
+
});
|
|
46
|
+
expect(await run(card)).toEqual({
|
|
47
|
+
scope: 'direct',
|
|
48
|
+
status: 'computed',
|
|
49
|
+
dependents: {
|
|
50
|
+
total: 3,
|
|
51
|
+
by_kind: { render: 2, include: 1 },
|
|
52
|
+
sample: [
|
|
53
|
+
'app/views/pages/about.liquid',
|
|
54
|
+
'app/views/pages/index.liquid',
|
|
55
|
+
'app/views/partials/wrapper.liquid',
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
it('reports computed with zero dependents (safe to change), distinct from "not computed"', async () => {
|
|
61
|
+
write({ [card]: '<div></div>', 'app/views/pages/index.liquid': '<p>nothing here</p>' });
|
|
62
|
+
expect(await run(card)).toEqual({
|
|
63
|
+
scope: 'direct',
|
|
64
|
+
status: 'computed',
|
|
65
|
+
dependents: { total: 0, by_kind: {}, sample: [] },
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
it('counts a file once in total but under each kind it references by', async () => {
|
|
69
|
+
write({
|
|
70
|
+
[card]: '<div></div>',
|
|
71
|
+
// same caller, two render edges → one distinct file
|
|
72
|
+
'app/views/pages/index.liquid': "{% render 'card' %}{% render 'card' %}",
|
|
73
|
+
// a caller that both renders and includes → one file, counted in both kinds
|
|
74
|
+
'app/views/partials/dual.liquid': "{% render 'card' %}{% include 'card' %}",
|
|
75
|
+
});
|
|
76
|
+
expect(await run(card)).toEqual({
|
|
77
|
+
scope: 'direct',
|
|
78
|
+
status: 'computed',
|
|
79
|
+
dependents: {
|
|
80
|
+
total: 2,
|
|
81
|
+
by_kind: { render: 2, include: 1 },
|
|
82
|
+
sample: ['app/views/pages/index.liquid', 'app/views/partials/dual.liquid'],
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
it('caps the sample at 10 files while keeping the true total', async () => {
|
|
87
|
+
write({
|
|
88
|
+
[card]: '<div></div>',
|
|
89
|
+
...Object.fromEntries(Array.from({ length: 15 }, (_, i) => [
|
|
90
|
+
`app/views/pages/p${String(i).padStart(2, '0')}.liquid`,
|
|
91
|
+
"{% render 'card' %}",
|
|
92
|
+
])),
|
|
93
|
+
});
|
|
94
|
+
expect(await run(card)).toEqual({
|
|
95
|
+
scope: 'direct',
|
|
96
|
+
status: 'computed',
|
|
97
|
+
dependents: {
|
|
98
|
+
total: 15,
|
|
99
|
+
by_kind: { render: 15 },
|
|
100
|
+
sample: [
|
|
101
|
+
'app/views/pages/p00.liquid',
|
|
102
|
+
'app/views/pages/p01.liquid',
|
|
103
|
+
'app/views/pages/p02.liquid',
|
|
104
|
+
'app/views/pages/p03.liquid',
|
|
105
|
+
'app/views/pages/p04.liquid',
|
|
106
|
+
'app/views/pages/p05.liquid',
|
|
107
|
+
'app/views/pages/p06.liquid',
|
|
108
|
+
'app/views/pages/p07.liquid',
|
|
109
|
+
'app/views/pages/p08.liquid',
|
|
110
|
+
'app/views/pages/p09.liquid',
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* The name filter is an over-approximation, so everything past it must be
|
|
117
|
+
* EXACT. A file that merely spells the name — in prose, or as the prefix of a
|
|
118
|
+
* different partial's name — survives the filter and must contribute nothing.
|
|
119
|
+
*
|
|
120
|
+
* The control is the third file: the same fixture proves a real `{% render %}`
|
|
121
|
+
* IS found, so "zero" here cannot be the scan quietly finding nothing at all.
|
|
122
|
+
*/
|
|
123
|
+
it('does not count a file that only mentions the name without referencing it', async () => {
|
|
124
|
+
write({
|
|
125
|
+
[card]: '<div></div>',
|
|
126
|
+
'app/views/pages/prose.liquid': '<p>the card component is documented elsewhere</p>',
|
|
127
|
+
'app/views/pages/prefix.liquid': "{% render 'card_footer' %}",
|
|
128
|
+
'app/views/partials/card_footer.liquid': '<footer></footer>',
|
|
129
|
+
'app/views/pages/real.liquid': "{% render 'card' %}",
|
|
130
|
+
});
|
|
131
|
+
expect(await run(card)).toEqual({
|
|
132
|
+
scope: 'direct',
|
|
133
|
+
status: 'computed',
|
|
134
|
+
dependents: {
|
|
135
|
+
total: 1,
|
|
136
|
+
by_kind: { render: 1 },
|
|
137
|
+
sample: ['app/views/pages/real.liquid'],
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
it('finds callers through every edge kind, not just render/include', async () => {
|
|
142
|
+
write({
|
|
143
|
+
'app/graphql/get_posts.graphql': 'query { posts { id } }',
|
|
144
|
+
'app/views/pages/index.liquid': "{% graphql posts = 'get_posts' %}",
|
|
145
|
+
});
|
|
146
|
+
expect(await run('app/graphql/get_posts.graphql')).toEqual({
|
|
147
|
+
scope: 'direct',
|
|
148
|
+
status: 'computed',
|
|
149
|
+
dependents: {
|
|
150
|
+
total: 1,
|
|
151
|
+
by_kind: { graphql: 1 },
|
|
152
|
+
sample: ['app/views/pages/index.liquid'],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
it('finds a caller in a module, whose reference spells the modules/<name>/ prefix', async () => {
|
|
157
|
+
write({
|
|
158
|
+
'modules/core/public/views/partials/badge.liquid': '<span></span>',
|
|
159
|
+
'app/views/pages/index.liquid': "{% render 'modules/core/badge' %}",
|
|
160
|
+
});
|
|
161
|
+
expect(await run('modules/core/public/views/partials/badge.liquid')).toEqual({
|
|
162
|
+
scope: 'direct',
|
|
163
|
+
status: 'computed',
|
|
164
|
+
dependents: {
|
|
165
|
+
total: 1,
|
|
166
|
+
by_kind: { render: 1 },
|
|
167
|
+
sample: ['app/views/pages/index.liquid'],
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('the changeset is what counts, not just disk', () => {
|
|
173
|
+
it('counts a caller the buffer has just added', async () => {
|
|
174
|
+
write({ [card]: '<div></div>', 'app/views/pages/index.liquid': '<p>nothing yet</p>' });
|
|
175
|
+
const scan = scanOf({ 'app/views/pages/index.liquid': "{% render 'card' %}" });
|
|
176
|
+
expect(await run(card, '', scan)).toEqual({
|
|
177
|
+
scope: 'direct',
|
|
178
|
+
status: 'computed',
|
|
179
|
+
dependents: {
|
|
180
|
+
total: 1,
|
|
181
|
+
by_kind: { render: 1 },
|
|
182
|
+
sample: ['app/views/pages/index.liquid'],
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
it('stops counting a caller whose reference the buffer has just deleted', async () => {
|
|
187
|
+
write({ [card]: '<div></div>', 'app/views/pages/index.liquid': "{% render 'card' %}" });
|
|
188
|
+
const scan = scanOf({ 'app/views/pages/index.liquid': '<p>not any more</p>' });
|
|
189
|
+
expect(await run(card, '', scan)).toEqual({
|
|
190
|
+
scope: 'direct',
|
|
191
|
+
status: 'computed',
|
|
192
|
+
dependents: { total: 0, by_kind: {}, sample: [] },
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
it('counts a caller that does not exist on disk at all yet', async () => {
|
|
196
|
+
write({ [card]: '<div></div>' });
|
|
197
|
+
const scan = scanOf({ 'app/views/pages/brand-new.liquid': "{% render 'card' %}" });
|
|
198
|
+
expect(await run(card, '', scan)).toEqual({
|
|
199
|
+
scope: 'direct',
|
|
200
|
+
status: 'computed',
|
|
201
|
+
dependents: {
|
|
202
|
+
total: 1,
|
|
203
|
+
by_kind: { render: 1 },
|
|
204
|
+
sample: ['app/views/pages/brand-new.liquid'],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe('applicability (files nothing can reference by name)', () => {
|
|
210
|
+
/**
|
|
211
|
+
* A scan that throws if it is consulted — applicability is a property of the
|
|
212
|
+
* FILE, decided before any project text is read.
|
|
213
|
+
*/
|
|
214
|
+
const throwingScan = () => ({
|
|
215
|
+
rootUri: pathUtils.normalize(pathUtils.toUri(projectDir)),
|
|
216
|
+
fs: NodeFileSystem,
|
|
217
|
+
sources: () => {
|
|
218
|
+
throw new Error('the project must not be scanned for a non-trackable file');
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
it('reports not_applicable for a custom-model-type/schema YAML (wired by table name, not by edge)', async () => {
|
|
222
|
+
expect(await run('app/custom_model_types/blog_post.yml', '', throwingScan())).toEqual({
|
|
223
|
+
scope: 'direct',
|
|
224
|
+
status: 'not_applicable',
|
|
225
|
+
dependents: { total: 0, by_kind: {}, sample: [] },
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
it('reports not_applicable for a translation YAML', async () => {
|
|
229
|
+
expect(await run('app/translations/en.yml', '', throwingScan())).toEqual({
|
|
230
|
+
scope: 'direct',
|
|
231
|
+
status: 'not_applicable',
|
|
232
|
+
dependents: { total: 0, by_kind: {}, sample: [] },
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
it('reports not_applicable for a Liquid file in no platformOS directory — it has no name to reference', async () => {
|
|
236
|
+
expect(await run('scripts/generate.liquid', '', throwingScan())).toEqual({
|
|
237
|
+
scope: 'direct',
|
|
238
|
+
status: 'not_applicable',
|
|
239
|
+
dependents: { total: 0, by_kind: {}, sample: [] },
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('signature-impact (callers vs the edited buffer {% doc %})', () => {
|
|
244
|
+
// A buffer declaring `title` (required) and `count` (optional).
|
|
245
|
+
const docBuffer = `{% doc %}
|
|
246
|
+
@param {String} title - required title
|
|
247
|
+
@param {Number} [count] - optional count
|
|
248
|
+
{% enddoc %}
|
|
249
|
+
<div>{{ title }}</div>`;
|
|
250
|
+
it('flags dependent callers that omit a required @param or pass an undeclared one', async () => {
|
|
251
|
+
write({
|
|
252
|
+
[card]: '<div></div>',
|
|
253
|
+
// ok: passes the required title
|
|
254
|
+
'app/views/pages/ok.liquid': "{% render 'card', title: 'a', count: 1 %}",
|
|
255
|
+
// missing required `title`
|
|
256
|
+
'app/views/pages/missing.liquid': "{% render 'card', count: 1 %}",
|
|
257
|
+
// passes an argument the doc does not declare
|
|
258
|
+
'app/views/pages/extra.liquid': "{% render 'card', title: 'a', colour: 'red' %}",
|
|
259
|
+
// passes nothing at all → missing required `title`
|
|
260
|
+
'app/views/pages/bare.liquid': "{% render 'card' %}",
|
|
261
|
+
});
|
|
262
|
+
expect(await run(card, docBuffer)).toEqual({
|
|
263
|
+
scope: 'direct',
|
|
264
|
+
status: 'computed',
|
|
265
|
+
dependents: {
|
|
266
|
+
total: 4,
|
|
267
|
+
by_kind: { render: 4 },
|
|
268
|
+
sample: [
|
|
269
|
+
'app/views/pages/bare.liquid',
|
|
270
|
+
'app/views/pages/extra.liquid',
|
|
271
|
+
'app/views/pages/missing.liquid',
|
|
272
|
+
'app/views/pages/ok.liquid',
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
signature_risk: [
|
|
276
|
+
{
|
|
277
|
+
caller: 'app/views/pages/bare.liquid',
|
|
278
|
+
missing_required: ['title'],
|
|
279
|
+
unexpected_args: [],
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
caller: 'app/views/pages/extra.liquid',
|
|
283
|
+
missing_required: [],
|
|
284
|
+
unexpected_args: ['colour'],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
caller: 'app/views/pages/missing.liquid',
|
|
288
|
+
missing_required: ['title'],
|
|
289
|
+
unexpected_args: [],
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
it('returns an empty signature_risk (checked, all match) when every caller satisfies the doc', async () => {
|
|
295
|
+
write({
|
|
296
|
+
[card]: '<div></div>',
|
|
297
|
+
'app/views/pages/a.liquid': "{% render 'card', title: 'a' %}",
|
|
298
|
+
'app/views/pages/b.liquid': "{% render 'card', title: 'b', count: 2 %}",
|
|
299
|
+
});
|
|
300
|
+
expect((await run(card, docBuffer)).signature_risk).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
it('omits signature_risk entirely when the buffer declares no {% doc %} contract', async () => {
|
|
303
|
+
write({
|
|
304
|
+
[card]: '<div></div>',
|
|
305
|
+
'app/views/pages/x.liquid': "{% render 'card', whatever: 1 %}",
|
|
306
|
+
});
|
|
307
|
+
const result = await run(card, '<div>{{ title }}</div>');
|
|
308
|
+
expect(result.signature_risk).toBeUndefined();
|
|
309
|
+
expect(result.status).toEqual('computed');
|
|
310
|
+
});
|
|
311
|
+
it('ignores non-@param edges (background/graphql) — only render/include/function args are @params', async () => {
|
|
312
|
+
write({
|
|
313
|
+
[card]: '<div></div>',
|
|
314
|
+
// render caller passing an undeclared arg → flagged
|
|
315
|
+
'app/views/pages/render.liquid': "{% render 'card', title: 'a', colour: 'red' %}",
|
|
316
|
+
// background caller: `delay` is a scheduling arg, NOT a @param → must NOT be flagged
|
|
317
|
+
'app/views/pages/bg.liquid': "{% background job = 'card', delay: 5 %}",
|
|
318
|
+
// graphql caller: `per_page` is an operation arg, NOT a @param → must NOT be flagged
|
|
319
|
+
'app/views/pages/gql.liquid': "{% graphql g = 'card', per_page: 10 %}",
|
|
320
|
+
});
|
|
321
|
+
// background/graphql callers ARE still counted as dependents — they are only
|
|
322
|
+
// excluded from the @param signature check.
|
|
323
|
+
const result = await run(card, docBuffer);
|
|
324
|
+
expect(result.dependents.by_kind).toEqual({ render: 1, background: 1 });
|
|
325
|
+
expect(result.signature_risk).toEqual([
|
|
326
|
+
{
|
|
327
|
+
caller: 'app/views/pages/render.liquid',
|
|
328
|
+
missing_required: [],
|
|
329
|
+
unexpected_args: ['colour'],
|
|
330
|
+
},
|
|
331
|
+
]);
|
|
332
|
+
});
|
|
333
|
+
it('includes function call sites (their args ARE @params, mirroring PartialCallArguments)', async () => {
|
|
334
|
+
write({
|
|
335
|
+
[card]: '<div></div>',
|
|
336
|
+
'app/lib/commands/run.liquid': "{% function r = 'card', count: 1 %}",
|
|
337
|
+
});
|
|
338
|
+
expect(await run(card, docBuffer)).toEqual({
|
|
339
|
+
scope: 'direct',
|
|
340
|
+
status: 'computed',
|
|
341
|
+
dependents: {
|
|
342
|
+
total: 1,
|
|
343
|
+
by_kind: { function: 1 },
|
|
344
|
+
sample: ['app/lib/commands/run.liquid'],
|
|
345
|
+
},
|
|
346
|
+
signature_risk: [
|
|
347
|
+
{
|
|
348
|
+
caller: 'app/lib/commands/run.liquid',
|
|
349
|
+
missing_required: ['title'],
|
|
350
|
+
unexpected_args: [],
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
//# sourceMappingURL=impact.spec.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's edge-source files as TEXT, read ONCE per request.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole of impact's project state, and it is read only when some buffer in the
|
|
5
|
+
* request could HAVE dependants — decidable from the paths alone, before any I/O. The trade
|
|
6
|
+
* is deliberate:
|
|
7
|
+
* pay a project READ per request — I/O, on the threadpool, overlapping the lint's CPU —
|
|
8
|
+
* instead of a whole-project PARSE once and a lifetime of keeping it honest. Measured on a
|
|
9
|
+
* real project (2,615 edge sources, 3.0 MB): 235 ms. Being derived per request, it cannot be
|
|
10
|
+
* stale, which is why `impact` has no `computing` state.
|
|
11
|
+
*
|
|
12
|
+
* SCOPE. Only EDGE SOURCES are read (`enumerateEdgeSources` — page/layout/partial), because
|
|
13
|
+
* only their content can declare an edge; `.graphql`/`.yml`/asset files are leaves and can
|
|
14
|
+
* never be the SOURCE of a reference. That is the graph's own definition, imported rather
|
|
15
|
+
* than restated.
|
|
16
|
+
*
|
|
17
|
+
* BUFFERS WIN OVER DISK. The changeset under validation is overlaid, so a call a buffer has
|
|
18
|
+
* only just added counts, and one it has just deleted stops counting — otherwise this stage
|
|
19
|
+
* would answer from disk while the lint beside it answers from the buffer. A buffer for a
|
|
20
|
+
* file not yet on disk is ADDED when it is an edge source, so a brand-new page's
|
|
21
|
+
* `{% render %}` is seen.
|
|
22
|
+
*/
|
|
23
|
+
import { type UriString } from '@platformos/platformos-check-common';
|
|
24
|
+
import type { AbstractFileSystem } from '@platformos/platformos-common';
|
|
25
|
+
/** The project text one request works from, plus the filesystem name resolution reads through. */
|
|
26
|
+
export interface ProjectScan {
|
|
27
|
+
readonly rootUri: UriString;
|
|
28
|
+
readonly fs: AbstractFileSystem;
|
|
29
|
+
/** Every edge source's text, keyed by normalized URI. Memoized: read once per scan. */
|
|
30
|
+
sources(): Promise<ReadonlyMap<UriString, string>>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A scan of `rootUri`, with `buffers` (normalized URI → in-flight content) overlaid.
|
|
34
|
+
*
|
|
35
|
+
* Nothing is read until {@link ProjectScan.sources} is first called, and it is read exactly
|
|
36
|
+
* once however many buffers a request validates — the reason the scan is a request-scoped
|
|
37
|
+
* object rather than a free function.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createProjectScan(rootUri: UriString, fs: AbstractFileSystem, buffers?: ReadonlyMap<UriString, string>): ProjectScan;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's edge-source files as TEXT, read ONCE per request.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole of impact's project state, and it is read only when some buffer in the
|
|
5
|
+
* request could HAVE dependants — decidable from the paths alone, before any I/O. The trade
|
|
6
|
+
* is deliberate:
|
|
7
|
+
* pay a project READ per request — I/O, on the threadpool, overlapping the lint's CPU —
|
|
8
|
+
* instead of a whole-project PARSE once and a lifetime of keeping it honest. Measured on a
|
|
9
|
+
* real project (2,615 edge sources, 3.0 MB): 235 ms. Being derived per request, it cannot be
|
|
10
|
+
* stale, which is why `impact` has no `computing` state.
|
|
11
|
+
*
|
|
12
|
+
* SCOPE. Only EDGE SOURCES are read (`enumerateEdgeSources` — page/layout/partial), because
|
|
13
|
+
* only their content can declare an edge; `.graphql`/`.yml`/asset files are leaves and can
|
|
14
|
+
* never be the SOURCE of a reference. That is the graph's own definition, imported rather
|
|
15
|
+
* than restated.
|
|
16
|
+
*
|
|
17
|
+
* BUFFERS WIN OVER DISK. The changeset under validation is overlaid, so a call a buffer has
|
|
18
|
+
* only just added counts, and one it has just deleted stops counting — otherwise this stage
|
|
19
|
+
* would answer from disk while the lint beside it answers from the buffer. A buffer for a
|
|
20
|
+
* file not yet on disk is ADDED when it is an edge source, so a brand-new page's
|
|
21
|
+
* `{% render %}` is seen.
|
|
22
|
+
*/
|
|
23
|
+
import { path } from '@platformos/platformos-check-common';
|
|
24
|
+
import { enumerateEdgeSources, isEdgeSource } from '@platformos/platformos-graph';
|
|
25
|
+
/**
|
|
26
|
+
* A scan of `rootUri`, with `buffers` (normalized URI → in-flight content) overlaid.
|
|
27
|
+
*
|
|
28
|
+
* Nothing is read until {@link ProjectScan.sources} is first called, and it is read exactly
|
|
29
|
+
* once however many buffers a request validates — the reason the scan is a request-scoped
|
|
30
|
+
* object rather than a free function.
|
|
31
|
+
*/
|
|
32
|
+
export function createProjectScan(rootUri, fs, buffers = new Map()) {
|
|
33
|
+
let pending;
|
|
34
|
+
return {
|
|
35
|
+
rootUri,
|
|
36
|
+
fs,
|
|
37
|
+
sources: () => (pending ??= readEdgeSources(rootUri, fs, buffers)),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async function readEdgeSources(rootUri, fs, buffers) {
|
|
41
|
+
const uris = await enumerateEdgeSources(fs, rootUri);
|
|
42
|
+
const sources = new Map();
|
|
43
|
+
await Promise.all(uris.map(async (uri) => {
|
|
44
|
+
const key = path.normalize(uri);
|
|
45
|
+
// The buffer is the truth for this file; reading it would race the overlay below.
|
|
46
|
+
if (buffers.has(key))
|
|
47
|
+
return;
|
|
48
|
+
// A file that vanished between the walk and the read cannot be a dependent, and
|
|
49
|
+
// failing the whole scan over it would cost the answer for every other file.
|
|
50
|
+
const source = await fs.readFile(uri).catch(() => undefined);
|
|
51
|
+
if (source !== undefined)
|
|
52
|
+
sources.set(key, source);
|
|
53
|
+
}));
|
|
54
|
+
for (const [uri, source] of buffers) {
|
|
55
|
+
// A buffer that is not an edge source (a `.graphql` operation, a schema `.yml`)
|
|
56
|
+
// declares no outgoing edges, so it can only be a target — nothing to scan.
|
|
57
|
+
if (isEdgeSource(uri, rootUri))
|
|
58
|
+
sources.set(uri, source);
|
|
59
|
+
}
|
|
60
|
+
return sources;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=project-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FileType } from '@platformos/platformos-common';
|
|
3
|
+
import { createProjectScan } from './project-scan.js';
|
|
4
|
+
const ROOT = 'file:///project';
|
|
5
|
+
/**
|
|
6
|
+
* A filesystem over a `relative path → contents` tree that COUNTS its reads, which
|
|
7
|
+
* is the whole point: the scan's contract is what it costs, not just what it
|
|
8
|
+
* returns.
|
|
9
|
+
*/
|
|
10
|
+
function countingFs(tree) {
|
|
11
|
+
const reads = [];
|
|
12
|
+
const paths = Object.keys(tree);
|
|
13
|
+
const fs = {
|
|
14
|
+
async readFile(uri) {
|
|
15
|
+
reads.push(uri);
|
|
16
|
+
const contents = tree[uri.slice(`${ROOT}/`.length)];
|
|
17
|
+
if (contents === undefined)
|
|
18
|
+
throw new Error(`no such file: ${uri}`);
|
|
19
|
+
return contents;
|
|
20
|
+
},
|
|
21
|
+
async readDirectory(uri) {
|
|
22
|
+
const prefix = uri === ROOT ? '' : `${uri.slice(`${ROOT}/`.length)}/`;
|
|
23
|
+
const entries = new Map();
|
|
24
|
+
for (const filePath of paths) {
|
|
25
|
+
if (!filePath.startsWith(prefix))
|
|
26
|
+
continue;
|
|
27
|
+
const rest = filePath.slice(prefix.length);
|
|
28
|
+
const cut = rest.indexOf('/');
|
|
29
|
+
entries.set(cut === -1 ? rest : rest.slice(0, cut), cut === -1 ? FileType.File : FileType.Directory);
|
|
30
|
+
}
|
|
31
|
+
if (entries.size === 0)
|
|
32
|
+
throw new Error(`no such directory: ${uri}`);
|
|
33
|
+
return [...entries].map(([name, type]) => [`${uri}/${name}`, type]);
|
|
34
|
+
},
|
|
35
|
+
async stat(uri) {
|
|
36
|
+
const contents = tree[uri.slice(`${ROOT}/`.length)];
|
|
37
|
+
if (contents === undefined)
|
|
38
|
+
throw new Error(`no such file: ${uri}`);
|
|
39
|
+
return { type: FileType.File, size: contents.length };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return { fs, reads };
|
|
43
|
+
}
|
|
44
|
+
const PROJECT = {
|
|
45
|
+
'app/views/pages/index.liquid': "{% render 'card' %}",
|
|
46
|
+
'app/views/partials/card.liquid': '<div></div>',
|
|
47
|
+
'app/graphql/get_posts.graphql': 'query { posts { id } }',
|
|
48
|
+
};
|
|
49
|
+
describe('createProjectScan', () => {
|
|
50
|
+
it('reads every edge source, and nothing that cannot declare an edge', async () => {
|
|
51
|
+
const { fs } = countingFs(PROJECT);
|
|
52
|
+
const sources = await createProjectScan(ROOT, fs).sources();
|
|
53
|
+
// The `.graphql` operation is a LEAF: it can be referenced but can never
|
|
54
|
+
// reference, so reading it would be pure cost.
|
|
55
|
+
expect([...sources.keys()].sort()).toEqual([
|
|
56
|
+
'file:///project/app/views/pages/index.liquid',
|
|
57
|
+
'file:///project/app/views/partials/card.liquid',
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
it('reads the project ONCE however many times it is asked', async () => {
|
|
61
|
+
const { fs, reads } = countingFs(PROJECT);
|
|
62
|
+
const scan = createProjectScan(ROOT, fs);
|
|
63
|
+
const [first, second, third] = await Promise.all([
|
|
64
|
+
scan.sources(),
|
|
65
|
+
scan.sources(),
|
|
66
|
+
scan.sources(),
|
|
67
|
+
]);
|
|
68
|
+
// Same object back, and two liquid files read in total — not six.
|
|
69
|
+
expect([first === second, second === third]).toEqual([true, true]);
|
|
70
|
+
expect(reads.sort()).toEqual([
|
|
71
|
+
'file:///project/app/views/pages/index.liquid',
|
|
72
|
+
'file:///project/app/views/partials/card.liquid',
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
it('reads nothing at all until it is asked', async () => {
|
|
76
|
+
const { fs, reads } = countingFs(PROJECT);
|
|
77
|
+
createProjectScan(ROOT, fs);
|
|
78
|
+
expect(reads).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
it('serves a buffer instead of the file on disk, without reading that file', async () => {
|
|
81
|
+
const { fs, reads } = countingFs(PROJECT);
|
|
82
|
+
const page = 'file:///project/app/views/pages/index.liquid';
|
|
83
|
+
const sources = await createProjectScan(ROOT, fs, new Map([[page, "{% render 'other' %}"]])).sources();
|
|
84
|
+
expect(sources.get(page)).toEqual("{% render 'other' %}");
|
|
85
|
+
expect(reads).toEqual(['file:///project/app/views/partials/card.liquid']);
|
|
86
|
+
});
|
|
87
|
+
it('includes a buffer for a file that is not on disk yet', async () => {
|
|
88
|
+
const { fs } = countingFs(PROJECT);
|
|
89
|
+
const fresh = 'file:///project/app/views/pages/brand-new.liquid';
|
|
90
|
+
const sources = await createProjectScan(ROOT, fs, new Map([[fresh, "{% render 'card' %}"]]))
|
|
91
|
+
.sources()
|
|
92
|
+
.then((all) => all);
|
|
93
|
+
expect(sources.get(fresh)).toEqual("{% render 'card' %}");
|
|
94
|
+
});
|
|
95
|
+
it('drops a buffer that could never declare an edge, rather than scanning it', async () => {
|
|
96
|
+
const { fs } = countingFs(PROJECT);
|
|
97
|
+
const operation = 'file:///project/app/graphql/get_posts.graphql';
|
|
98
|
+
const sources = await createProjectScan(ROOT, fs, new Map([[operation, 'query { posts { id title } }']])).sources();
|
|
99
|
+
expect(sources.has(operation)).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* A file the walk saw and the read could not open is the ordinary race (it was
|
|
103
|
+
* deleted in between), not a reason to lose the whole answer.
|
|
104
|
+
*
|
|
105
|
+
* The control is `index.liquid`: the same scan still returns it, so "card is
|
|
106
|
+
* missing" cannot be the scan having failed wholesale.
|
|
107
|
+
*/
|
|
108
|
+
it('skips a file that vanished between the walk and the read, keeping the rest', async () => {
|
|
109
|
+
const { fs } = countingFs(PROJECT);
|
|
110
|
+
const vanishing = 'file:///project/app/views/partials/card.liquid';
|
|
111
|
+
const readFile = fs.readFile.bind(fs);
|
|
112
|
+
fs.readFile = async (uri) => {
|
|
113
|
+
if (uri === vanishing)
|
|
114
|
+
throw new Error('ENOENT');
|
|
115
|
+
return readFile(uri);
|
|
116
|
+
};
|
|
117
|
+
const sources = await createProjectScan(ROOT, fs).sources();
|
|
118
|
+
expect([...sources.keys()]).toEqual(['file:///project/app/views/pages/index.liquid']);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
//# sourceMappingURL=project-scan.spec.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export { startServer } from './transport/server.js';
|
|
3
3
|
export type { ServerOptions, ServerHandle } from './transport/server.js';
|
|
4
4
|
export { registerValidateCode, VALIDATE_CODE_INPUT } from './transport/validate-code.js';
|
|
5
|
-
export type { SupervisorContext } from './
|
|
5
|
+
export type { SupervisorContext } from './context.js';
|
|
6
6
|
export { createLogger } from './logger.js';
|
|
7
7
|
export type { Logger } from './logger.js';
|
|
8
8
|
export * from './result/types.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's concrete edits, in the shape an agent applies them in.
|
|
3
|
+
*
|
|
4
|
+
* A PURE SHAPE MAPPING and nothing more. `check-node` runs the `Fixer` functions — beside
|
|
5
|
+
* the lint, while the `App` still holds the buffer — and hands back `FixDescription[]`; all
|
|
6
|
+
* that is left here is renaming three fields.
|
|
7
|
+
*
|
|
8
|
+
* RUNNING A FIXER HERE WOULD BE WRONG: a fixer may read back through the `App`
|
|
9
|
+
* (`missing-doc-param`'s calls `indentationOfLineAt`, which reads `file.source`), and after
|
|
10
|
+
* `lintBuffers` has reverted its overlay that either throws or computes against DISK text.
|
|
11
|
+
*
|
|
12
|
+
* NOTHING HERE AUTHORS EDIT TEXT. Every byte came out of a corrector a check drove.
|
|
13
|
+
*/
|
|
14
|
+
import type { MaterialisedFix } from '@platformos/platformos-check-node';
|
|
15
|
+
import type { AgentFix } from '../result/types.js';
|
|
16
|
+
/** The agent-facing fix fields for one offense; keys are absent when the engine offered none. */
|
|
17
|
+
export interface OffenseFixes {
|
|
18
|
+
fix?: AgentFix;
|
|
19
|
+
suggestions?: AgentFix[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Map one offense's materialised fixes onto the diagnostic shape.
|
|
23
|
+
*
|
|
24
|
+
* `fix` and `suggestions` stay separate because the engine draws that distinction and an
|
|
25
|
+
* agent must act on it differently: a fix is the answer and is safe to apply unread, a
|
|
26
|
+
* suggestion is one of several choices and carries the engine's own wording.
|
|
27
|
+
*/
|
|
28
|
+
export declare function toAgentFixes(materialised: MaterialisedFix | undefined): OffenseFixes;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** check-common's `FixDescription` → the agent's `AgentEdit`. Field names, nothing else. */
|
|
2
|
+
function toEdits(edits) {
|
|
3
|
+
return edits.map((edit) => ({
|
|
4
|
+
start_index: edit.startIndex,
|
|
5
|
+
end_index: edit.endIndex,
|
|
6
|
+
new_text: edit.insert,
|
|
7
|
+
}));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Map one offense's materialised fixes onto the diagnostic shape.
|
|
11
|
+
*
|
|
12
|
+
* `fix` and `suggestions` stay separate because the engine draws that distinction and an
|
|
13
|
+
* agent must act on it differently: a fix is the answer and is safe to apply unread, a
|
|
14
|
+
* suggestion is one of several choices and carries the engine's own wording.
|
|
15
|
+
*/
|
|
16
|
+
export function toAgentFixes(materialised) {
|
|
17
|
+
if (!materialised)
|
|
18
|
+
return {};
|
|
19
|
+
const result = {};
|
|
20
|
+
if (materialised.fix)
|
|
21
|
+
result.fix = { edits: toEdits(materialised.fix) };
|
|
22
|
+
if (materialised.suggestions) {
|
|
23
|
+
result.suggestions = materialised.suggestions.map((suggestion) => ({
|
|
24
|
+
description: suggestion.message,
|
|
25
|
+
edits: toEdits(suggestion.edits),
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=fixes.js.map
|