@platformos/platformos-mcp-supervisor 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +105 -0
  2. package/dist/adapter-input.d.ts +113 -0
  3. package/dist/adapter-input.js +210 -0
  4. package/dist/adapter-input.spec.d.ts +1 -0
  5. package/dist/adapter-input.spec.js +275 -0
  6. package/dist/bin/args.d.ts +15 -2
  7. package/dist/bin/args.js +31 -3
  8. package/dist/bin/args.spec.d.ts +1 -0
  9. package/dist/bin/args.spec.js +35 -0
  10. package/dist/bin/platformos-mcp-supervisor.js +6 -2
  11. package/dist/check-docs.d.ts +21 -0
  12. package/dist/check-docs.js +38 -0
  13. package/dist/check-docs.spec.d.ts +1 -0
  14. package/dist/check-docs.spec.js +87 -0
  15. package/dist/context.d.ts +34 -0
  16. package/dist/context.js +12 -0
  17. package/dist/cost-model.d.ts +188 -0
  18. package/dist/cost-model.js +197 -0
  19. package/dist/cost-model.spec.d.ts +1 -0
  20. package/dist/cost-model.spec.js +91 -0
  21. package/dist/deadline.d.ts +27 -0
  22. package/dist/deadline.js +40 -0
  23. package/dist/deadline.spec.d.ts +1 -0
  24. package/dist/deadline.spec.js +83 -0
  25. package/dist/enrich/enrich.d.ts +105 -0
  26. package/dist/enrich/enrich.js +136 -0
  27. package/dist/enrich/enrich.spec.d.ts +1 -0
  28. package/dist/enrich/enrich.spec.js +184 -0
  29. package/dist/enrich/symbol.d.ts +16 -0
  30. package/dist/enrich/symbol.js +58 -0
  31. package/dist/enrich/symbol.spec.d.ts +1 -0
  32. package/dist/enrich/symbol.spec.js +89 -0
  33. package/dist/impact/dependants.d.ts +75 -0
  34. package/dist/impact/dependants.js +104 -0
  35. package/dist/impact/diff.d.ts +27 -0
  36. package/dist/impact/diff.js +33 -0
  37. package/dist/impact/impact.d.ts +21 -0
  38. package/dist/impact/impact.js +160 -0
  39. package/dist/impact/impact.spec.d.ts +1 -0
  40. package/dist/impact/impact.spec.js +357 -0
  41. package/dist/impact/project-scan.d.ts +39 -0
  42. package/dist/impact/project-scan.js +62 -0
  43. package/dist/impact/project-scan.spec.d.ts +1 -0
  44. package/dist/impact/project-scan.spec.js +121 -0
  45. package/dist/index.d.ts +1 -1
  46. package/dist/lint/fixes.d.ts +28 -0
  47. package/dist/lint/fixes.js +30 -0
  48. package/dist/lint/lint-batch.d.ts +100 -0
  49. package/dist/lint/lint-batch.js +98 -0
  50. package/dist/lint/lint-batch.spec.d.ts +1 -0
  51. package/dist/lint/lint-batch.spec.js +503 -0
  52. package/dist/lint/lint.d.ts +11 -1
  53. package/dist/lint/lint.js +9 -3
  54. package/dist/result/assemble.d.ts +11 -9
  55. package/dist/result/assemble.js +63 -16
  56. package/dist/result/assemble.spec.d.ts +1 -0
  57. package/dist/result/assemble.spec.js +319 -0
  58. package/dist/result/blocking.d.ts +92 -0
  59. package/dist/result/blocking.js +116 -0
  60. package/dist/result/blocking.spec.d.ts +1 -0
  61. package/dist/result/blocking.spec.js +201 -0
  62. package/dist/result/impact-states.d.ts +19 -0
  63. package/dist/result/impact-states.js +10 -0
  64. package/dist/result/response-budget.d.ts +9 -0
  65. package/dist/result/response-budget.js +133 -0
  66. package/dist/result/response-budget.spec.d.ts +1 -0
  67. package/dist/result/response-budget.spec.js +222 -0
  68. package/dist/result/types.d.ts +212 -104
  69. package/dist/result/types.js +4 -13
  70. package/dist/transport/instructions-coverage.spec.d.ts +1 -0
  71. package/dist/transport/instructions-coverage.spec.js +176 -0
  72. package/dist/transport/instructions.d.ts +1 -0
  73. package/dist/transport/instructions.js +150 -0
  74. package/dist/transport/process-guards.d.ts +43 -0
  75. package/dist/transport/process-guards.js +63 -0
  76. package/dist/transport/process-guards.spec.d.ts +1 -0
  77. package/dist/transport/process-guards.spec.js +200 -0
  78. package/dist/transport/server.d.ts +6 -1
  79. package/dist/transport/server.js +21 -10
  80. package/dist/transport/validate-code.d.ts +50 -15
  81. package/dist/transport/validate-code.js +238 -33
  82. package/dist/transport/validate-code.spec.d.ts +1 -0
  83. package/dist/transport/validate-code.spec.js +2303 -0
  84. package/dist/validate/batch-bounds.d.ts +34 -0
  85. package/dist/validate/batch-bounds.js +65 -0
  86. package/dist/validate/batch-coherence.d.ts +21 -0
  87. package/dist/validate/batch-coherence.js +54 -0
  88. package/dist/validate/batch-coherence.spec.d.ts +1 -0
  89. package/dist/validate/batch-coherence.spec.js +111 -0
  90. package/dist/validate/validate-buffers.d.ts +27 -0
  91. package/dist/validate/validate-buffers.js +319 -0
  92. package/package.json +8 -7
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The PURE enrichment stage: what the supervisor adds to a diagnostic beyond what the
3
+ * engine reported.
4
+ *
5
+ * It adds exactly two things, and **authors neither of them**:
6
+ *
7
+ * `see_also` the check's own documentation page, from check-common `meta.docs.url`.
8
+ * `hint` the docset entry for the symbol the diagnostic is about, rendered by the
9
+ * same function every editor hover uses.
10
+ *
11
+ * The restraint is the design. The supervisor ships no documentation (ARCHITECTURE.md
12
+ * §Invariants #6): the words an agent reads come from `filters.json` / `tags.json` /
13
+ * `objects.json` and from the check registry, both owned and tested where they are
14
+ * published. A hint composed here would be a second source of truth that goes stale.
15
+ *
16
+ * PURE, and mechanically so: no `fs`, no `process`, no import of `lint/`, nothing async.
17
+ * The docset arrives already resolved to plain arrays, so "no I/O below the lint edge" is a
18
+ * matter of shape rather than of trust.
19
+ */
20
+ import type { FilterEntry, LiquidHtmlNode, ObjectEntry, TagEntry } from '@platformos/platformos-check-common';
21
+ import type { ValidateCodeDiagnostic } from '../result/types.js';
22
+ /**
23
+ * The docset, resolved to data.
24
+ *
25
+ * Plain arrays rather than the `AugmentedPlatformOSDocset` itself, whose methods return
26
+ * promises and would let a future edit `await` inside the pure stage. The caller resolves it
27
+ * once per request and hands over the result.
28
+ */
29
+ export interface DocsetVocabulary {
30
+ filters: readonly FilterEntry[];
31
+ tags: readonly TagEntry[];
32
+ objects: readonly ObjectEntry[];
33
+ }
34
+ /**
35
+ * The checks for which a published SIGNATURE is the answer.
36
+ *
37
+ * Policy, not documentation — a statement about which findings an agent can act on with a
38
+ * signature in front of it, the same kind of judgement as `BLOCKING_CHECKS`. Each entry
39
+ * says why, because "it seemed relevant" is how this grows into noise.
40
+ *
41
+ * DEFAULTS TO SILENCE. A check not listed gets no hint, so a check added upstream cannot
42
+ * start emitting an unrelated wall of markdown before anyone has looked at it.
43
+ *
44
+ * A list rather than a structural rule, because no structural rule separates the useful
45
+ * case from the useless one: `{% render 'ghost' %}` resolves to the `render` TAG, so
46
+ * rendering whatever symbol an offense sits on answers "'ghost' does not exist" with
47
+ * general documentation about how `render` works.
48
+ */
49
+ export declare const SIGNATURE_HINT_CHECKS: ReadonlySet<string>;
50
+ /** One diagnostic, with the buffer offset the engine reported it at. */
51
+ export interface EnrichInput {
52
+ diagnostic: ValidateCodeDiagnostic;
53
+ /**
54
+ * 0-based offset of the offense start, in the buffer as sent. Carried alongside rather
55
+ * than added to `ValidateCodeDiagnostic`, which an agent reads.
56
+ *
57
+ * OPTIONAL, and absence means "do not resolve a symbol for this one" — not spelled as an
58
+ * out-of-range sentinel, which only LOOKS inert: `findCurrentNode` always answers, and at
59
+ * an impossible offset it answers `Document`.
60
+ */
61
+ startIndex?: number;
62
+ }
63
+ /** Everything enrichment reads. All of it data; none of it fetched here. */
64
+ export interface EnrichContext {
65
+ /**
66
+ * The buffer's Liquid tree, as returned by the lint that produced these diagnostics.
67
+ *
68
+ * `undefined` for a buffer with no Liquid tree — GraphQL, YAML, or a file that did not
69
+ * parse. Those diagnostics simply get no symbol hint; the check's documentation URL
70
+ * still attaches, because it does not depend on the tree.
71
+ */
72
+ ast?: LiquidHtmlNode;
73
+ vocabulary: DocsetVocabulary;
74
+ }
75
+ /**
76
+ * Attach what we can to each diagnostic, and nothing we cannot.
77
+ *
78
+ * Returns NEW objects; the inputs are not mutated. A diagnostic gains a key only when there
79
+ * is a real value for it — never an empty string or a placeholder — so an agent can read
80
+ * absence as "nothing more is known".
81
+ *
82
+ * ONE HINT PER SYMBOL PER FILE. A signature is ~880 bytes of markdown and it is the SAME
83
+ * bytes every time the symbol is misused, so repeats would spend the diagnostic budget
84
+ * (`result/response-budget.ts`) restating one paragraph. The FIRST occurrence keeps it,
85
+ * which is also the one truncation keeps: the cap retains the top of the file.
86
+ */
87
+ export declare function enrichDiagnostics(inputs: readonly EnrichInput[], context: EnrichContext): ValidateCodeDiagnostic[];
88
+ /**
89
+ * Enrich a whole lint pass, reusing ONE docset resolution for every file in it.
90
+ *
91
+ * Shared by the primary lint and by the impact diff, so a finding reported in a file the
92
+ * agent is not editing carries the same documentation URL and the same hint as one in a file
93
+ * it is. A failure here degrades to unenriched findings rather than losing them: enrichment
94
+ * is additive, and dropping a diagnostic because its docs could not be read would trade a
95
+ * real finding for a cosmetic one.
96
+ */
97
+ export declare function enrichBatch(lint: EnrichableBatch, resolve: () => Promise<DocsetVocabulary>, log: (message: string) => void): Promise<Map<string, ValidateCodeDiagnostic[]>>;
98
+ /** The part of a lint pass {@link enrichBatch} reads — structural, so impact can pass its own. */
99
+ export interface EnrichableBatch {
100
+ diagnostics: Map<string, ValidateCodeDiagnostic[]>;
101
+ sources?: Map<string, {
102
+ ast?: LiquidHtmlNode;
103
+ startIndexes: number[];
104
+ }>;
105
+ }
@@ -0,0 +1,136 @@
1
+ // Deep import, NOT the package root: measured at +5 ms against +110 ms, the difference
2
+ // being the css / json / graphql language services this package never uses. The bin is
3
+ // launched once per agent session, so that is paid every session. See ADR 002 Amendment 1.
4
+ import { render } from '@platformos/platformos-language-server-common/dist/docset/index.js';
5
+ import { checkDocs } from '../check-docs.js';
6
+ import { documentedSymbolAt } from './symbol.js';
7
+ // Local, matching `validate-buffers.ts` and `transport/process-guards.ts`, which each carry
8
+ // their own. Three copies is a smell worth collapsing into one shared helper — separately,
9
+ // not in the middle of a feature.
10
+ const describe = (error) => (error instanceof Error ? error.message : String(error));
11
+ /**
12
+ * The checks for which a published SIGNATURE is the answer.
13
+ *
14
+ * Policy, not documentation — a statement about which findings an agent can act on with a
15
+ * signature in front of it, the same kind of judgement as `BLOCKING_CHECKS`. Each entry
16
+ * says why, because "it seemed relevant" is how this grows into noise.
17
+ *
18
+ * DEFAULTS TO SILENCE. A check not listed gets no hint, so a check added upstream cannot
19
+ * start emitting an unrelated wall of markdown before anyone has looked at it.
20
+ *
21
+ * A list rather than a structural rule, because no structural rule separates the useful
22
+ * case from the useless one: `{% render 'ghost' %}` resolves to the `render` TAG, so
23
+ * rendering whatever symbol an offense sits on answers "'ghost' does not exist" with
24
+ * general documentation about how `render` works.
25
+ */
26
+ export const SIGNATURE_HINT_CHECKS = new Set([
27
+ // "given 1, expected 2" — the published arity IS the fix, and it is the one thing the
28
+ // message cannot carry without restating the documentation.
29
+ 'FilterArity',
30
+ // An argument of the wrong type. What the filter actually accepts is the answer, and
31
+ // the parameter list is where it is written down.
32
+ 'ValidFilterArgumentTypes',
33
+ // The same for a tag's arguments: `{% for x in y limit: 'ten' %}` is answered by the
34
+ // `for` entry's parameter types.
35
+ 'ValidTagArgumentTypes',
36
+ ]);
37
+ /**
38
+ * Attach what we can to each diagnostic, and nothing we cannot.
39
+ *
40
+ * Returns NEW objects; the inputs are not mutated. A diagnostic gains a key only when there
41
+ * is a real value for it — never an empty string or a placeholder — so an agent can read
42
+ * absence as "nothing more is known".
43
+ *
44
+ * ONE HINT PER SYMBOL PER FILE. A signature is ~880 bytes of markdown and it is the SAME
45
+ * bytes every time the symbol is misused, so repeats would spend the diagnostic budget
46
+ * (`result/response-budget.ts`) restating one paragraph. The FIRST occurrence keeps it,
47
+ * which is also the one truncation keeps: the cap retains the top of the file.
48
+ */
49
+ export function enrichDiagnostics(inputs, context) {
50
+ const rendered = new Set();
51
+ return inputs.map(({ diagnostic, startIndex }) => {
52
+ const enriched = { ...diagnostic };
53
+ const url = checkDocs(diagnostic.check)?.url;
54
+ if (url)
55
+ enriched.see_also = url;
56
+ const hint = hintFor(diagnostic.check, startIndex, context, rendered);
57
+ if (hint)
58
+ enriched.hint = hint;
59
+ return enriched;
60
+ });
61
+ }
62
+ /**
63
+ * The rendered docset entry for the symbol at `offset`, when the docset publishes one and
64
+ * this file has not already been shown it.
65
+ *
66
+ * NOTHING when it does not: a symbol the docset has never heard of has no signature to
67
+ * show, and the engine's own `suggestions` are what help there.
68
+ *
69
+ * Searched from the offset OUTWARDS through the ancestors — `{% for x in y limit: 'ten' %}`
70
+ * reports on the argument, and the `for` entry that answers it is three levels up.
71
+ *
72
+ * `rendered` is mutated only when a hint is actually produced, so an unpublished symbol
73
+ * cannot consume the slot its name has and silence a later published one.
74
+ */
75
+ function hintFor(check, offset, context, rendered) {
76
+ if (!SIGNATURE_HINT_CHECKS.has(check))
77
+ return undefined;
78
+ if (!context.ast || offset === undefined)
79
+ return undefined;
80
+ const symbol = documentedSymbolAt(context.ast, offset);
81
+ if (!symbol)
82
+ return undefined;
83
+ const key = `${symbol.kind}:${symbol.name}`;
84
+ if (rendered.has(key))
85
+ return undefined;
86
+ const entry = entryFor(symbol, context.vocabulary);
87
+ if (!entry)
88
+ return undefined;
89
+ rendered.add(key);
90
+ return render(entry, undefined, symbol.kind);
91
+ }
92
+ /** The published entry for a symbol, matched by exact name within its own kind. */
93
+ function entryFor(symbol, vocabulary) {
94
+ const entries = symbol.kind === 'filter'
95
+ ? vocabulary.filters
96
+ : symbol.kind === 'tag'
97
+ ? vocabulary.tags
98
+ : vocabulary.objects;
99
+ return entries.find((entry) => entry.name === symbol.name);
100
+ }
101
+ /**
102
+ * Enrich a whole lint pass, reusing ONE docset resolution for every file in it.
103
+ *
104
+ * Shared by the primary lint and by the impact diff, so a finding reported in a file the
105
+ * agent is not editing carries the same documentation URL and the same hint as one in a file
106
+ * it is. A failure here degrades to unenriched findings rather than losing them: enrichment
107
+ * is additive, and dropping a diagnostic because its docs could not be read would trade a
108
+ * real finding for a cosmetic one.
109
+ */
110
+ export async function enrichBatch(lint, resolve, log) {
111
+ let found = 0;
112
+ for (const diagnostics of lint.diagnostics.values())
113
+ found += diagnostics.length;
114
+ if (found === 0)
115
+ return lint.diagnostics;
116
+ try {
117
+ const vocabulary = await resolve();
118
+ return new Map([...lint.diagnostics].map(([key, diagnostics]) => {
119
+ const source = lint.sources?.get(key);
120
+ // `startIndexes` is index-aligned with `diagnostics` by construction in
121
+ // `runBatchLint`; a missing entry can only mean the two got out of step, so the
122
+ // offset is passed as ABSENT rather than as an out-of-range sentinel that would
123
+ // resolve to some unrelated symbol.
124
+ const inputs = diagnostics.map((diagnostic, index) => ({
125
+ diagnostic,
126
+ startIndex: source?.startIndexes[index],
127
+ }));
128
+ return [key, enrichDiagnostics(inputs, { ast: source?.ast, vocabulary })];
129
+ }));
130
+ }
131
+ catch (error) {
132
+ log(`enrichment failed, returning findings unenriched: ${describe(error)}`);
133
+ return lint.diagnostics;
134
+ }
135
+ }
136
+ //# sourceMappingURL=enrich.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -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