@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.
Files changed (85) hide show
  1. package/README.md +90 -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.spec.d.ts +1 -0
  7. package/dist/bin/args.spec.js +35 -0
  8. package/dist/check-docs.d.ts +21 -0
  9. package/dist/check-docs.js +38 -0
  10. package/dist/check-docs.spec.d.ts +1 -0
  11. package/dist/check-docs.spec.js +87 -0
  12. package/dist/context.d.ts +26 -0
  13. package/dist/context.js +11 -0
  14. package/dist/cost-model.d.ts +128 -0
  15. package/dist/cost-model.js +137 -0
  16. package/dist/cost-model.spec.d.ts +1 -0
  17. package/dist/cost-model.spec.js +91 -0
  18. package/dist/deadline.d.ts +27 -0
  19. package/dist/deadline.js +40 -0
  20. package/dist/deadline.spec.d.ts +1 -0
  21. package/dist/deadline.spec.js +83 -0
  22. package/dist/enrich/enrich.d.ts +87 -0
  23. package/dist/enrich/enrich.js +97 -0
  24. package/dist/enrich/enrich.spec.d.ts +1 -0
  25. package/dist/enrich/enrich.spec.js +184 -0
  26. package/dist/enrich/symbol.d.ts +16 -0
  27. package/dist/enrich/symbol.js +58 -0
  28. package/dist/enrich/symbol.spec.d.ts +1 -0
  29. package/dist/enrich/symbol.spec.js +89 -0
  30. package/dist/impact/impact.d.ts +9 -0
  31. package/dist/impact/impact.js +189 -0
  32. package/dist/impact/impact.spec.d.ts +1 -0
  33. package/dist/impact/impact.spec.js +357 -0
  34. package/dist/impact/project-scan.d.ts +37 -0
  35. package/dist/impact/project-scan.js +60 -0
  36. package/dist/impact/project-scan.spec.d.ts +1 -0
  37. package/dist/impact/project-scan.spec.js +121 -0
  38. package/dist/index.d.ts +1 -1
  39. package/dist/lint/fixes.d.ts +28 -0
  40. package/dist/lint/fixes.js +30 -0
  41. package/dist/lint/lint-batch.d.ts +100 -0
  42. package/dist/lint/lint-batch.js +98 -0
  43. package/dist/lint/lint-batch.spec.d.ts +1 -0
  44. package/dist/lint/lint-batch.spec.js +503 -0
  45. package/dist/lint/lint.d.ts +11 -1
  46. package/dist/lint/lint.js +9 -3
  47. package/dist/result/assemble.d.ts +12 -9
  48. package/dist/result/assemble.js +64 -16
  49. package/dist/result/assemble.spec.d.ts +1 -0
  50. package/dist/result/assemble.spec.js +319 -0
  51. package/dist/result/blocking.d.ts +86 -0
  52. package/dist/result/blocking.js +96 -0
  53. package/dist/result/blocking.spec.d.ts +1 -0
  54. package/dist/result/blocking.spec.js +201 -0
  55. package/dist/result/impact-states.d.ts +16 -0
  56. package/dist/result/impact-states.js +18 -0
  57. package/dist/result/response-budget.d.ts +9 -0
  58. package/dist/result/response-budget.js +131 -0
  59. package/dist/result/response-budget.spec.d.ts +1 -0
  60. package/dist/result/response-budget.spec.js +222 -0
  61. package/dist/result/types.d.ts +210 -104
  62. package/dist/result/types.js +4 -13
  63. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  64. package/dist/transport/instructions-coverage.spec.js +176 -0
  65. package/dist/transport/instructions.d.ts +1 -0
  66. package/dist/transport/instructions.js +132 -0
  67. package/dist/transport/process-guards.d.ts +43 -0
  68. package/dist/transport/process-guards.js +63 -0
  69. package/dist/transport/process-guards.spec.d.ts +1 -0
  70. package/dist/transport/process-guards.spec.js +200 -0
  71. package/dist/transport/server.d.ts +1 -1
  72. package/dist/transport/server.js +16 -9
  73. package/dist/transport/validate-code.d.ts +36 -18
  74. package/dist/transport/validate-code.js +200 -33
  75. package/dist/transport/validate-code.spec.d.ts +1 -0
  76. package/dist/transport/validate-code.spec.js +2303 -0
  77. package/dist/validate/batch-bounds.d.ts +34 -0
  78. package/dist/validate/batch-bounds.js +60 -0
  79. package/dist/validate/batch-coherence.d.ts +21 -0
  80. package/dist/validate/batch-coherence.js +54 -0
  81. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  82. package/dist/validate/batch-coherence.spec.js +111 -0
  83. package/dist/validate/validate-buffers.d.ts +27 -0
  84. package/dist/validate/validate-buffers.js +265 -0
  85. 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 {};