@platformos/platformos-mcp-supervisor 0.0.2 → 0.1.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 +90 -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.spec.d.ts +1 -0
- package/dist/bin/args.spec.js +35 -0
- 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 +26 -0
- package/dist/context.js +11 -0
- package/dist/cost-model.d.ts +128 -0
- package/dist/cost-model.js +137 -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 +87 -0
- package/dist/enrich/enrich.js +97 -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/impact.d.ts +9 -0
- package/dist/impact/impact.js +189 -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 +37 -0
- package/dist/impact/project-scan.js +60 -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 +12 -9
- package/dist/result/assemble.js +64 -16
- package/dist/result/assemble.spec.d.ts +1 -0
- package/dist/result/assemble.spec.js +319 -0
- package/dist/result/blocking.d.ts +86 -0
- package/dist/result/blocking.js +96 -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 +16 -0
- package/dist/result/impact-states.js +18 -0
- package/dist/result/response-budget.d.ts +9 -0
- package/dist/result/response-budget.js +131 -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 +210 -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 +132 -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 +1 -1
- package/dist/transport/server.js +16 -9
- package/dist/transport/validate-code.d.ts +36 -18
- package/dist/transport/validate-code.js +200 -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 +60 -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 +265 -0
- package/package.json +8 -7
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrichment is a pure function, so these run without a project, a server, or a lint.
|
|
3
|
+
*
|
|
4
|
+
* The AST is produced by parsing a snippet directly — the same parser the engine uses — so
|
|
5
|
+
* the fixtures state exactly what an agent sent, and the offsets are the ones an offense
|
|
6
|
+
* would carry.
|
|
7
|
+
*/
|
|
8
|
+
import { toLiquidHtmlAST } from '@platformos/liquid-html-parser';
|
|
9
|
+
import { allChecks } from '@platformos/platformos-check-common';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
import { checkDocs } from '../check-docs.js';
|
|
12
|
+
import { enrichDiagnostics, SIGNATURE_HINT_CHECKS } from './enrich.js';
|
|
13
|
+
/**
|
|
14
|
+
* A docset stand-in, deliberately tiny and DECLARED HERE.
|
|
15
|
+
*
|
|
16
|
+
* This is the sanctioned exception to "measure against the real documents": the subject is
|
|
17
|
+
* the enrichment code path, and these entries are INPUT to it, not a claim about what
|
|
18
|
+
* platformOS publishes. Asserting against the shipped docset would pin this file's
|
|
19
|
+
* expectations to a rendering of real documentation, which a docs release would then break
|
|
20
|
+
* without anything here being wrong.
|
|
21
|
+
*/
|
|
22
|
+
const VOCABULARY = {
|
|
23
|
+
filters: [
|
|
24
|
+
{ name: 'hash_merge', description: 'Merges two hashes.', summary: 'Merge hashes.' },
|
|
25
|
+
],
|
|
26
|
+
tags: [{ name: 'for', description: 'Iterates over an array.' }],
|
|
27
|
+
objects: [
|
|
28
|
+
{ name: 'context', description: 'The request context.' },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* What the shared renderer makes of each fixture entry, written out rather than derived.
|
|
33
|
+
*
|
|
34
|
+
* These are what an AGENT receives, so they are pinned whole: a derived expectation
|
|
35
|
+
* (`toEqual(render(entry, …))`) would restate the call under test and pass however the
|
|
36
|
+
* output changed. The renderer is the language server's and is tested there — what this
|
|
37
|
+
* file owns is that the right ENTRY reaches it, under the right KIND, and that the result
|
|
38
|
+
* is passed on unaltered.
|
|
39
|
+
*/
|
|
40
|
+
const REFERENCE = 'https://documentation.platformos.com/api-reference/liquid';
|
|
41
|
+
const RENDERED = {
|
|
42
|
+
hash_merge: `### hash_merge\nMerge hashes.\n\nMerges two hashes.\n\n---\n\n[platformOS Reference](${REFERENCE}/filters#hash_merge)`,
|
|
43
|
+
for: `### for\nIterates over an array.\n\n---\n\n[platformOS Reference](${REFERENCE}/loops#for)`,
|
|
44
|
+
context: `### context\nThe request context.`,
|
|
45
|
+
};
|
|
46
|
+
const diagnostic = (check) => ({
|
|
47
|
+
check,
|
|
48
|
+
severity: 'error',
|
|
49
|
+
message: `${check} fired`,
|
|
50
|
+
line: 1,
|
|
51
|
+
column: 1,
|
|
52
|
+
});
|
|
53
|
+
const enrichOne = (check, source, startIndex) => enrichDiagnostics([{ diagnostic: diagnostic(check), startIndex }], {
|
|
54
|
+
ast: toLiquidHtmlAST(source),
|
|
55
|
+
vocabulary: VOCABULARY,
|
|
56
|
+
})[0];
|
|
57
|
+
describe('Unit: enrichDiagnostics', () => {
|
|
58
|
+
it('attaches the check documentation URL, taken from the registry', () => {
|
|
59
|
+
// Derived, not pasted: the expectation moves with the registry.
|
|
60
|
+
const expected = checkDocs('FilterArity')?.url;
|
|
61
|
+
expect(enrichOne('FilterArity', '{{ null | hash_merge }}', 8)?.see_also).toEqual(expected);
|
|
62
|
+
});
|
|
63
|
+
it('renders the published signature for a filter used wrongly', () => {
|
|
64
|
+
const result = enrichOne('FilterArity', '{{ null | hash_merge }}', 8);
|
|
65
|
+
expect(result?.hint).toEqual(RENDERED.hash_merge);
|
|
66
|
+
});
|
|
67
|
+
it('reaches an enclosing TAG when the offense is reported on its argument', () => {
|
|
68
|
+
// `{% for x in (1..3) limit: 'ten' %}` — the offense lands on the string, three
|
|
69
|
+
// levels below the tag whose entry answers it.
|
|
70
|
+
const source = "{% for x in (1..3) limit: 'ten' %}{% endfor %}";
|
|
71
|
+
const result = enrichOne('ValidTagArgumentTypes', source, source.indexOf("'ten'"));
|
|
72
|
+
expect(result?.hint).toEqual(RENDERED.for);
|
|
73
|
+
});
|
|
74
|
+
it('renders an OBJECT entry, under the object kind', () => {
|
|
75
|
+
// The third of the three kinds, and the one whose rendering differs visibly: an
|
|
76
|
+
// object publishes no reference URL, so the entry ends at its description. Rendering
|
|
77
|
+
// it as a filter would append a `/filters#context` link that goes nowhere.
|
|
78
|
+
const result = enrichOne('ValidFilterArgumentTypes', '{{ context | hash_merge }}', 4);
|
|
79
|
+
expect(result?.hint).toEqual(RENDERED.context);
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* THE case that decided enrichment is opt-in per check. `{% render 'ghost' %}` resolves
|
|
83
|
+
* to the `render` TAG, so rendering "whatever symbol is here" answered a missing-partial
|
|
84
|
+
* error with ~500 bytes of general documentation about how `render` works, on a blocking
|
|
85
|
+
* check that fires constantly, about a partial that is not a docset symbol at all.
|
|
86
|
+
*/
|
|
87
|
+
it('adds NO signature for a check a signature does not answer', () => {
|
|
88
|
+
const result = enrichOne('MissingPartial', "{% render 'ghost' %}", 10);
|
|
89
|
+
expect(result?.hint).toBeUndefined();
|
|
90
|
+
// ...but the documentation URL still attaches: that part is about the CHECK, and is
|
|
91
|
+
// useful whatever the finding.
|
|
92
|
+
expect(result?.see_also).toEqual(checkDocs('MissingPartial')?.url);
|
|
93
|
+
});
|
|
94
|
+
it('adds no signature for a symbol the docset does not publish', () => {
|
|
95
|
+
// An unknown filter has no entry by definition — the engine's suggestions are the
|
|
96
|
+
// answer there, and this must not invent a substitute.
|
|
97
|
+
const result = enrichOne('ValidFilterArgumentTypes', '{{ x | no_such_filter_xyz: 1 }}', 6);
|
|
98
|
+
expect(result?.hint).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
it('leaves a diagnostic untouched when there is nothing to add', () => {
|
|
101
|
+
// `CheckError` is check-common's code for "a check crashed on this file". It reaches an
|
|
102
|
+
// agent as an ordinary offense and is NOT a registered check, so there is no
|
|
103
|
+
// documentation URL for it — and with no tree there is no signature either. Every
|
|
104
|
+
// optional field must be ABSENT rather than empty: an agent reads absence as "nothing
|
|
105
|
+
// more is known".
|
|
106
|
+
const [result] = enrichDiagnostics([{ diagnostic: diagnostic('CheckError'), startIndex: 0 }], {
|
|
107
|
+
ast: undefined,
|
|
108
|
+
vocabulary: VOCABULARY,
|
|
109
|
+
});
|
|
110
|
+
expect(result).toEqual({
|
|
111
|
+
check: 'CheckError',
|
|
112
|
+
severity: 'error',
|
|
113
|
+
message: 'CheckError fired',
|
|
114
|
+
line: 1,
|
|
115
|
+
column: 1,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* A repeated mistake must not buy a repeated paragraph. The signature is ~880 bytes and
|
|
120
|
+
* identical every time, against a 32 KiB diagnostic budget: twenty of them once cost
|
|
121
|
+
* 26 KiB and truncated the findings an agent actually needed. The FIRST occurrence keeps
|
|
122
|
+
* the hint, which is also the one truncation keeps.
|
|
123
|
+
*/
|
|
124
|
+
it('renders a symbol ONCE per file, however many findings name it', () => {
|
|
125
|
+
const source = '{{ null | hash_merge }}{{ null | hash_merge }}{{ null | hash_merge }}';
|
|
126
|
+
const results = enrichDiagnostics([8, 31, 54].map((startIndex) => ({ diagnostic: diagnostic('FilterArity'), startIndex })), { ast: toLiquidHtmlAST(source), vocabulary: VOCABULARY });
|
|
127
|
+
// The later two keep their `see_also` — deduplication is about the bulk, not about
|
|
128
|
+
// withholding the answer.
|
|
129
|
+
expect(results.map((result) => [result.hint, result.see_also !== undefined])).toEqual([
|
|
130
|
+
[RENDERED.hash_merge, true],
|
|
131
|
+
[undefined, true],
|
|
132
|
+
[undefined, true],
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
it('renders each DISTINCT symbol, so deduplication cannot silence a second one', () => {
|
|
136
|
+
// The control for the test above: a suppression keyed on anything coarser than the
|
|
137
|
+
// symbol — "one hint per file", "one per check" — passes that one and fails this.
|
|
138
|
+
const source = "{{ null | hash_merge }}{% for x in (1..3) limit: 'ten' %}{% endfor %}";
|
|
139
|
+
const results = enrichDiagnostics([
|
|
140
|
+
{ diagnostic: diagnostic('FilterArity'), startIndex: 8 },
|
|
141
|
+
{ diagnostic: diagnostic('ValidTagArgumentTypes'), startIndex: source.indexOf("'ten'") },
|
|
142
|
+
], { ast: toLiquidHtmlAST(source), vocabulary: VOCABULARY });
|
|
143
|
+
expect(results.map((result) => result.hint)).toEqual([RENDERED.hash_merge, RENDERED.for]);
|
|
144
|
+
});
|
|
145
|
+
/**
|
|
146
|
+
* No offset means no symbol — never a symbol resolved at a guessed one.
|
|
147
|
+
*
|
|
148
|
+
* The offset is absent only when `startIndexes` got out of step with the diagnostics it
|
|
149
|
+
* is index-aligned with, which is a bug rather than a state; the answer to it is the
|
|
150
|
+
* finding with no hint, not the finding with a plausible wrong one.
|
|
151
|
+
*/
|
|
152
|
+
it('adds no signature when the offense carries no offset', () => {
|
|
153
|
+
const withoutOffset = enrichOne('FilterArity', '{{ null | hash_merge }}');
|
|
154
|
+
// The control is the first test in this group: the same call WITH the offset renders.
|
|
155
|
+
expect({ hint: withoutOffset?.hint, see_also: withoutOffset?.see_also !== undefined }).toEqual({
|
|
156
|
+
hint: undefined,
|
|
157
|
+
see_also: true,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
it('does not mutate its input', () => {
|
|
161
|
+
const input = diagnostic('FilterArity');
|
|
162
|
+
const before = { ...input };
|
|
163
|
+
enrichDiagnostics([{ diagnostic: input, startIndex: 8 }], {
|
|
164
|
+
ast: toLiquidHtmlAST('{{ null | hash_merge }}'),
|
|
165
|
+
vocabulary: VOCABULARY,
|
|
166
|
+
});
|
|
167
|
+
expect(input).toEqual(before);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('Unit: SIGNATURE_HINT_CHECKS', () => {
|
|
171
|
+
/**
|
|
172
|
+
* The same guard `BLOCKING_CHECKS` carries, for the same reason: a hand-written set of
|
|
173
|
+
* check codes goes stale silently when one is renamed upstream, and a stale entry here
|
|
174
|
+
* means a hint that quietly stopped appearing.
|
|
175
|
+
*
|
|
176
|
+
* Deliberately NOT the converse — a new check must default to silence, so membership is
|
|
177
|
+
* never required of one.
|
|
178
|
+
*/
|
|
179
|
+
it('names only checks that are registered in check-common', () => {
|
|
180
|
+
const registered = new Set(allChecks.map((check) => check.meta.code));
|
|
181
|
+
expect([...SIGNATURE_HINT_CHECKS].filter((code) => !registered.has(code))).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
//# sourceMappingURL=enrich.spec.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type LiquidHtmlNode } from '@platformos/platformos-check-common';
|
|
2
|
+
/** A symbol the docset may publish an entry for. */
|
|
3
|
+
export interface DocumentedSymbol {
|
|
4
|
+
kind: 'filter' | 'tag' | 'object';
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The nearest enclosing node that names something the docset documents.
|
|
9
|
+
*
|
|
10
|
+
* Searched from the node the offset lands on OUTWARDS through its ancestors, nearest
|
|
11
|
+
* first, because an offense range often points INSIDE the construct it is about: the
|
|
12
|
+
* argument of a filter, the markup of a tag. Stopping at the first match is what makes
|
|
13
|
+
* `{{ x | hash_add_key: 'k' }}` resolve to the filter rather than to the tag or the
|
|
14
|
+
* output that contains it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function documentedSymbolAt(ast: LiquidHtmlNode, offset: number): DocumentedSymbol | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which documented symbol a diagnostic is ABOUT, resolved from the syntax tree.
|
|
3
|
+
*
|
|
4
|
+
* FROM THE TREE, never from the message: parsing names out of English wording this package
|
|
5
|
+
* does not own is forbidden (ARCHITECTURE.md §Invariants #2). The offense carries an offset
|
|
6
|
+
* and the buffer's tree comes back with it, so the name is read from the node the engine
|
|
7
|
+
* pointed at.
|
|
8
|
+
*
|
|
9
|
+
* `findCurrentNode` is check-common's, the same primitive the language server's hover,
|
|
10
|
+
* definition and highlight providers use, asked of the same tree the offense came from
|
|
11
|
+
* (`LintBufferResult.ast`, captured inside the buffer overlay for this).
|
|
12
|
+
*/
|
|
13
|
+
import { NodeTypes } from '@platformos/liquid-html-parser';
|
|
14
|
+
import { findCurrentNode } from '@platformos/platformos-check-common';
|
|
15
|
+
/**
|
|
16
|
+
* The nearest enclosing node that names something the docset documents.
|
|
17
|
+
*
|
|
18
|
+
* Searched from the node the offset lands on OUTWARDS through its ancestors, nearest
|
|
19
|
+
* first, because an offense range often points INSIDE the construct it is about: the
|
|
20
|
+
* argument of a filter, the markup of a tag. Stopping at the first match is what makes
|
|
21
|
+
* `{{ x | hash_add_key: 'k' }}` resolve to the filter rather than to the tag or the
|
|
22
|
+
* output that contains it.
|
|
23
|
+
*/
|
|
24
|
+
export function documentedSymbolAt(ast, offset) {
|
|
25
|
+
const [node, ancestors] = findCurrentNode(ast, offset);
|
|
26
|
+
// Nearest first: `ancestors` is root-to-parent, so it is walked backwards.
|
|
27
|
+
for (const candidate of [node, ...[...ancestors].reverse()]) {
|
|
28
|
+
const symbol = symbolOf(candidate);
|
|
29
|
+
if (symbol)
|
|
30
|
+
return symbol;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The symbol a single node names, if it names one.
|
|
36
|
+
*
|
|
37
|
+
* Only three node types qualify, matching the three things the docset publishes. A tag's
|
|
38
|
+
* `name` can be a `NamedTags` enum member rather than a plain string, and a variable
|
|
39
|
+
* lookup's can be `null` — `{{ ["a"][0] }}` subscripts an array literal, so the lookup has
|
|
40
|
+
* a position and no name — so both are checked rather than assumed. Unchecked, that null
|
|
41
|
+
* would be looked up in the docset as the string `"null"`.
|
|
42
|
+
*/
|
|
43
|
+
function symbolOf(node) {
|
|
44
|
+
switch (node.type) {
|
|
45
|
+
case NodeTypes.LiquidFilter:
|
|
46
|
+
return typeof node.name === 'string' ? { kind: 'filter', name: node.name } : undefined;
|
|
47
|
+
case NodeTypes.LiquidTag:
|
|
48
|
+
case NodeTypes.LiquidRawTag:
|
|
49
|
+
return typeof node.name === 'string' ? { kind: 'tag', name: node.name } : undefined;
|
|
50
|
+
case NodeTypes.VariableLookup:
|
|
51
|
+
return typeof node.name === 'string' && node.name.length > 0
|
|
52
|
+
? { kind: 'object', name: node.name }
|
|
53
|
+
: undefined;
|
|
54
|
+
default:
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=symbol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol resolution is a pure function of a tree and an offset, so these run without a
|
|
3
|
+
* project, a docset or a lint.
|
|
4
|
+
*
|
|
5
|
+
* Every fixture is PARSED, never hand-built. The node shapes this module branches on —
|
|
6
|
+
* which construct yields a `VariableLookup`, when its `name` is null, how deep a tag sits
|
|
7
|
+
* above the argument an offense lands on — are the parser's answers and not anything this
|
|
8
|
+
* package gets to decide. Two of them were documented wrongly here until they were run.
|
|
9
|
+
*/
|
|
10
|
+
import { toLiquidHtmlAST } from '@platformos/liquid-html-parser';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { documentedSymbolAt } from './symbol.js';
|
|
13
|
+
/**
|
|
14
|
+
* Resolve one character INTO `needle`, so the offsets read as source rather than
|
|
15
|
+
* arithmetic.
|
|
16
|
+
*
|
|
17
|
+
* The `+ 1` is `findCurrentNode`'s contract, not a fudge: it takes a CURSOR position — a
|
|
18
|
+
* place between two characters — and its default coverage is `start < offset <= end`, so
|
|
19
|
+
* the position immediately before a node's first character still belongs to that node's
|
|
20
|
+
* parent. A real offense points inside the construct it is about, which is the same place.
|
|
21
|
+
*/
|
|
22
|
+
const symbolAt = (source, needle) => documentedSymbolAt(toLiquidHtmlAST(source), source.indexOf(needle) + 1);
|
|
23
|
+
describe('Unit: documentedSymbolAt', () => {
|
|
24
|
+
it('resolves the FILTER an offense inside a filtered output is about', () => {
|
|
25
|
+
expect(symbolAt('{{ null | hash_merge }}', 'hash_merge')).toEqual({
|
|
26
|
+
kind: 'filter',
|
|
27
|
+
name: 'hash_merge',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it('prefers the nearest symbol, so a filter wins over the tag containing it', () => {
|
|
31
|
+
// The whole reason the walk stops at the first match: every filter is inside
|
|
32
|
+
// something, and the something is never the better answer.
|
|
33
|
+
expect(symbolAt('{% if true %}{{ x | hash_merge }}{% endif %}', 'hash_merge')).toEqual({
|
|
34
|
+
kind: 'filter',
|
|
35
|
+
name: 'hash_merge',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it('reaches an enclosing TAG when the offense lands on one of its arguments', () => {
|
|
39
|
+
// `'ten'` is a String inside a NamedArgument inside a ForMarkup inside the LiquidTag —
|
|
40
|
+
// three levels up, which is why this walks ancestors rather than reading one node.
|
|
41
|
+
expect(symbolAt("{% for x in (1..3) limit: 'ten' %}{% endfor %}", "'ten'")).toEqual({
|
|
42
|
+
kind: 'tag',
|
|
43
|
+
name: 'for',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
it('resolves a RAW tag by name, exactly as an ordinary one', () => {
|
|
47
|
+
// A separate node type with a separate branch: `{% raw %}` is a `LiquidRawTag`, and
|
|
48
|
+
// the docset publishes it under the same `tags` list as `for`.
|
|
49
|
+
expect(symbolAt('{% raw %}x{% endraw %}', 'raw')).toEqual({ kind: 'tag', name: 'raw' });
|
|
50
|
+
});
|
|
51
|
+
it('resolves an OBJECT from a bare variable lookup, ignoring what is looked up on it', () => {
|
|
52
|
+
// `context.params.id` is ONE lookup named `context`; the docset publishes the drop,
|
|
53
|
+
// not each property path through it.
|
|
54
|
+
expect(symbolAt('{{ context.params.id }}', 'context')).toEqual({
|
|
55
|
+
kind: 'object',
|
|
56
|
+
name: 'context',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* A `VariableLookup` whose `name` is null must resolve to NOTHING, not to the string
|
|
61
|
+
* `"null"`. `{{ ["a"][0] }}` subscripts an array literal, so the lookup carries a position
|
|
62
|
+
* and no name; deleting the null guard in `symbolOf` returns
|
|
63
|
+
* `{kind: 'object', name: 'null'}`, and a docset entry published under that name would be
|
|
64
|
+
* rendered as the explanation for an unrelated finding.
|
|
65
|
+
*
|
|
66
|
+
* The offset is the subscript, measured: at the array literal itself the answer is a
|
|
67
|
+
* `String` and this would pass for the wrong reason.
|
|
68
|
+
*/
|
|
69
|
+
it('resolves nothing for a lookup that has no name', () => {
|
|
70
|
+
expect(documentedSymbolAt(toLiquidHtmlAST('{{ ["a"][0] }}'), 8)).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* The CONTROL for the case above: the same subscript shape, with a name, does resolve.
|
|
74
|
+
* Without it, a guard wide enough to reject every lookup would pass.
|
|
75
|
+
*/
|
|
76
|
+
it('CONTROL: the same subscript shape resolves when the lookup is named', () => {
|
|
77
|
+
expect(symbolAt('{{ a["k"] }}', 'a[')).toEqual({ kind: 'object', name: 'a' });
|
|
78
|
+
});
|
|
79
|
+
it('resolves nothing outside every construct, and nothing for plain markup', () => {
|
|
80
|
+
expect({
|
|
81
|
+
// What an out-of-range offset actually does — it is not an error, it answers the
|
|
82
|
+
// Document. `enrich` therefore checks for a MISSING offset rather than passing a
|
|
83
|
+
// sentinel, because a sentinel's silence here is an accident of this switch.
|
|
84
|
+
beforeTheDocument: documentedSymbolAt(toLiquidHtmlAST('{{ null | hash_merge }}'), -1),
|
|
85
|
+
plainHtml: symbolAt('<div>hello</div>', 'hello'),
|
|
86
|
+
}).toEqual({ beforeTheDocument: undefined, plainHtml: undefined });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
//# sourceMappingURL=symbol.spec.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type AdapterInput } from '../adapter-input.js';
|
|
2
|
+
import type { ValidateCodeImpact } from '../result/types.js';
|
|
3
|
+
import type { ProjectScan } from './project-scan.js';
|
|
4
|
+
/**
|
|
5
|
+
* Compute the edited file's blast radius from the project as it is right now.
|
|
6
|
+
* Reports `not_applicable` when nothing could reference the file by name, and
|
|
7
|
+
* throws only on real I/O failure — the caller degrades that to `unavailable`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runImpact(params: AdapterInput, scan: ProjectScan): Promise<ValidateCodeImpact>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Impact (blast-radius) adapter — an I/O boundary on the request path (sibling
|
|
3
|
+
* to `lint/`).
|
|
4
|
+
*
|
|
5
|
+
* Answers the one question lint structurally cannot: "who DEPENDS ON the file
|
|
6
|
+
* being edited?" — its incoming references across the project.
|
|
7
|
+
*
|
|
8
|
+
* HOW, AND WHY IT IS NOT A GRAPH. Every edge platformos-graph records names its target with
|
|
9
|
+
* a STATIC STRING LITERAL: the operand of `render`/`include`/`function`/`background`/
|
|
10
|
+
* `graphql`, the operand of `asset_url`, or an explicit frontmatter `layout:` (an omitted
|
|
11
|
+
* `layout:` synthesizes no edge — see `traverse.ts`). So a file's dependents can only be
|
|
12
|
+
* among the edge sources whose TEXT contains its logical name, and filtering on that name is
|
|
13
|
+
* a sound over-approximation leaving few survivors (measured p50 1, p90 6 candidates on a
|
|
14
|
+
* 2,615-file project). Those are parsed and resolved with {@link extractFileReferences}, the
|
|
15
|
+
* SAME resolver `buildAppGraph` runs, so this answer cannot drift from the graph's — 80
|
|
16
|
+
* targets sampled across two real projects produced 0 mismatches on `(source, kind, args)`.
|
|
17
|
+
*
|
|
18
|
+
* THERE IS NO `computing` STATE. The answer is derived from the project as it is at request
|
|
19
|
+
* time, so it is fresh by construction. A failure or the deadline yields `unavailable`; a
|
|
20
|
+
* file the graph cannot model yields `not_applicable`; everything else is `computed`.
|
|
21
|
+
*
|
|
22
|
+
* The dependents list reads the CHANGESET, not just disk: `ProjectScan` overlays the buffers
|
|
23
|
+
* under validation, so a caller a buffer has just added or removed is counted.
|
|
24
|
+
*/
|
|
25
|
+
import { extractDocDefinition, path, SourceCodeType, } from '@platformos/platformos-check-common';
|
|
26
|
+
import { sourceCodeTypeOf, uriToName } from '@platformos/platformos-common';
|
|
27
|
+
import { extractFileReferences, toSourceCode, } from '@platformos/platformos-graph';
|
|
28
|
+
import { toAbsoluteFilePath } from '../adapter-input.js';
|
|
29
|
+
/** Number of referencing files listed in `sample`/`signature_risk` before truncating. */
|
|
30
|
+
const SAMPLE_LIMIT = 10;
|
|
31
|
+
/**
|
|
32
|
+
* The edge kinds whose call-site arguments are validated against a partial's
|
|
33
|
+
* `{% doc %}` `@param` contract — EXACTLY the kinds the `PartialCallArguments`
|
|
34
|
+
* lint check validates (`render`/`include` via its `RenderMarkup` handler,
|
|
35
|
+
* `function` via `FunctionMarkup`). `background`/`graphql`/`layout`/`asset` edges
|
|
36
|
+
* carry scheduling/operation arguments that are NOT `@param`s, so signature-impact
|
|
37
|
+
* must ignore them or it would flag correct calls (a false positive that the
|
|
38
|
+
* forward `PartialCallArguments` check never produces).
|
|
39
|
+
*/
|
|
40
|
+
const SIGNATURE_EDGE_KINDS = new Set(['render', 'include', 'function']);
|
|
41
|
+
/**
|
|
42
|
+
* Whether the graph can model incoming references to `uri` — i.e. `uri` can be a
|
|
43
|
+
* resolvable edge TARGET (a Liquid page/layout/partial, or a GraphQL operation).
|
|
44
|
+
* Reuses check-common's canonical classifiers so this cannot drift from the
|
|
45
|
+
* graph's own edge resolution.
|
|
46
|
+
*
|
|
47
|
+
* Files that are NOT edge targets — schema / custom-model-type / translation YAML,
|
|
48
|
+
* or any unclassified file — are wired by model/table NAME, not by file reference
|
|
49
|
+
* (ADR 004), so the graph has no dependents for them and `total: 0` would be a
|
|
50
|
+
* false "safe to change". Those get `status: 'not_applicable'` instead.
|
|
51
|
+
*/
|
|
52
|
+
function isGraphTrackable(uri) {
|
|
53
|
+
// The graph's edge-target types, asked by extension. Naming the two explicitly keeps the
|
|
54
|
+
// YAML case visibly excluded, which is the distinction the docblock above turns on.
|
|
55
|
+
const type = sourceCodeTypeOf(uri);
|
|
56
|
+
return type === SourceCodeType.LiquidHtml || type === SourceCodeType.GraphQL;
|
|
57
|
+
}
|
|
58
|
+
/** A fresh zeroed dependents shape for every non-`computed` status. */
|
|
59
|
+
const noDependents = () => ({
|
|
60
|
+
total: 0,
|
|
61
|
+
by_kind: {},
|
|
62
|
+
sample: [],
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* Compute the edited file's blast radius from the project as it is right now.
|
|
66
|
+
* Reports `not_applicable` when nothing could reference the file by name, and
|
|
67
|
+
* throws only on real I/O failure — the caller degrades that to `unavailable`.
|
|
68
|
+
*/
|
|
69
|
+
export async function runImpact(params, scan) {
|
|
70
|
+
const { projectDir, filePath, content } = params;
|
|
71
|
+
const rootUri = path.normalize(path.URI.file(projectDir));
|
|
72
|
+
const fileUri = path.normalize(path.URI.file(toAbsoluteFilePath(projectDir, filePath)));
|
|
73
|
+
// Applicability is a property of the FILE, independent of any scan: a non-trackable file
|
|
74
|
+
// has no dependency edges, and a file in no platformOS directory has no logical NAME for
|
|
75
|
+
// a reference to spell. `total: 0` for either would be a false "safe to change".
|
|
76
|
+
const name = uriToName(fileUri, rootUri)?.name;
|
|
77
|
+
if (!isGraphTrackable(fileUri) || name === undefined) {
|
|
78
|
+
return { scope: 'direct', status: 'not_applicable', dependents: noDependents() };
|
|
79
|
+
}
|
|
80
|
+
const dependents = await incomingReferences(scan, fileUri, name);
|
|
81
|
+
const signature = await docSignature(fileUri, content);
|
|
82
|
+
const signature_risk = signature && computeSignatureRisk(dependents, rootUri, signature);
|
|
83
|
+
return {
|
|
84
|
+
scope: 'direct',
|
|
85
|
+
status: 'computed',
|
|
86
|
+
dependents: summarizeDependents(dependents, rootUri),
|
|
87
|
+
...(signature_risk ? { signature_risk } : {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Every reference in the project that resolves to `fileUri`.
|
|
92
|
+
*
|
|
93
|
+
* The name filter is what keeps this cheap, and it is sound rather than heuristic: an edge
|
|
94
|
+
* exists only where a static literal spells the target's logical name. Everything past the
|
|
95
|
+
* filter is exact — the survivors are resolved by the graph's own resolver, so a candidate
|
|
96
|
+
* that merely MENTIONS the name contributes no reference.
|
|
97
|
+
*/
|
|
98
|
+
async function incomingReferences(scan, fileUri, name) {
|
|
99
|
+
const sources = await scan.sources();
|
|
100
|
+
const candidates = [...sources].filter(([, source]) => source.includes(name));
|
|
101
|
+
const perCandidate = await Promise.all(candidates.map(async ([uri, source]) => {
|
|
102
|
+
const sourceCode = await toSourceCode(uri, source);
|
|
103
|
+
return extractFileReferences(scan.rootUri, uri, sourceCode, { fs: scan.fs });
|
|
104
|
+
}));
|
|
105
|
+
return perCandidate.flat().filter((reference) => reference.target.uri === fileUri);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Reduce the incoming reference edges of the edited file to the agent-facing
|
|
109
|
+
* summary: distinct referencing FILES (`total`), distinct files per edge kind
|
|
110
|
+
* (`by_kind`), and a capped, sorted `sample` of project-relative caller paths.
|
|
111
|
+
*/
|
|
112
|
+
function summarizeDependents(references, rootUri) {
|
|
113
|
+
// caller path (project-relative) → the edge kinds by which it references the file
|
|
114
|
+
const callers = new Map();
|
|
115
|
+
for (const ref of references) {
|
|
116
|
+
if (!ref.kind)
|
|
117
|
+
continue; // every graph edge carries a kind; defensive only
|
|
118
|
+
const caller = path.relative(ref.source.uri, rootUri);
|
|
119
|
+
const kinds = callers.get(caller) ?? new Set();
|
|
120
|
+
kinds.add(ref.kind);
|
|
121
|
+
callers.set(caller, kinds);
|
|
122
|
+
}
|
|
123
|
+
const by_kind = {};
|
|
124
|
+
for (const kinds of callers.values()) {
|
|
125
|
+
for (const kind of kinds)
|
|
126
|
+
by_kind[kind] = (by_kind[kind] ?? 0) + 1;
|
|
127
|
+
}
|
|
128
|
+
const sample = [...callers.keys()].sort((a, b) => a.localeCompare(b)).slice(0, SAMPLE_LIMIT);
|
|
129
|
+
return { total: callers.size, by_kind, sample };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The edited buffer's `{% doc %}` parameter contract (required + all declared
|
|
133
|
+
* names), or `null` when the buffer is non-Liquid, unparseable, or declares no
|
|
134
|
+
* `{% doc %}` block. Reuses check-common's `extractDocDefinition` — the same
|
|
135
|
+
* primitive the `PartialCallArguments` check reads for the doc case — so the
|
|
136
|
+
* two never diverge. `null` deliberately disables signature-impact: without an
|
|
137
|
+
* explicit contract we do NOT guess a signature (no false positives).
|
|
138
|
+
*/
|
|
139
|
+
async function docSignature(fileUri, content) {
|
|
140
|
+
const sourceCode = await toSourceCode(fileUri, content);
|
|
141
|
+
if (sourceCode.type !== SourceCodeType.LiquidHtml || sourceCode.ast instanceof Error)
|
|
142
|
+
return null;
|
|
143
|
+
const definition = await extractDocDefinition(fileUri, sourceCode.ast);
|
|
144
|
+
const parameters = definition.liquidDoc?.parameters;
|
|
145
|
+
if (!parameters || parameters.length === 0)
|
|
146
|
+
return null;
|
|
147
|
+
return {
|
|
148
|
+
required: parameters.filter((p) => p.required).map((p) => p.name),
|
|
149
|
+
allowed: parameters.map((p) => p.name),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The dependent callers whose passed arguments violate `signature` — missing a
|
|
154
|
+
* required `@param`, or passing one the `{% doc %}` block does not declare. The
|
|
155
|
+
* cross-file inverse of `PartialCallArguments`: it checks the edited file's
|
|
156
|
+
* contract against every existing caller at once. Deduplicated per caller,
|
|
157
|
+
* sorted, and capped.
|
|
158
|
+
*/
|
|
159
|
+
function computeSignatureRisk(references, rootUri, signature) {
|
|
160
|
+
const byCaller = new Map();
|
|
161
|
+
for (const ref of references) {
|
|
162
|
+
// Only the kinds whose args ARE `@param`s (see {@link SIGNATURE_EDGE_KINDS}) —
|
|
163
|
+
// a `{% background %}`/`{% graphql %}`/layout edge's args are not, and flagging
|
|
164
|
+
// them would be a false positive the forward check never makes.
|
|
165
|
+
if (!ref.kind || !SIGNATURE_EDGE_KINDS.has(ref.kind))
|
|
166
|
+
continue;
|
|
167
|
+
const args = ref.args ?? [];
|
|
168
|
+
const missing = signature.required.filter((param) => !args.includes(param));
|
|
169
|
+
const unexpected = args.filter((arg) => !signature.allowed.includes(arg));
|
|
170
|
+
if (missing.length === 0 && unexpected.length === 0)
|
|
171
|
+
continue;
|
|
172
|
+
const caller = path.relative(ref.source.uri, rootUri);
|
|
173
|
+
const entry = byCaller.get(caller) ?? { missing: new Set(), unexpected: new Set() };
|
|
174
|
+
for (const m of missing)
|
|
175
|
+
entry.missing.add(m);
|
|
176
|
+
for (const u of unexpected)
|
|
177
|
+
entry.unexpected.add(u);
|
|
178
|
+
byCaller.set(caller, entry);
|
|
179
|
+
}
|
|
180
|
+
return [...byCaller.entries()]
|
|
181
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
182
|
+
.slice(0, SAMPLE_LIMIT)
|
|
183
|
+
.map(([caller, { missing, unexpected }]) => ({
|
|
184
|
+
caller,
|
|
185
|
+
missing_required: [...missing].sort((a, b) => a.localeCompare(b)),
|
|
186
|
+
unexpected_args: [...unexpected].sort((a, b) => a.localeCompare(b)),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=impact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|