Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Dysflow exports are no longer read twice. Every form and report layout, test manifest and test sequence was being parsed once by the VBA dispatcher and again by the Dysflow framework hook, which made a full index do double the work on those files and left duplicate pending references behind. Turning Dysflow expansion off with `vba.dysflowExport: false` is also honoured again in every case. (#314)
- A test manifest or test sequence saved with a UTF-8 byte-order mark is read correctly again. Until now such a file was rejected outright, so every test it registered quietly vanished from the graph with nothing but a warning to show for it — and on Windows that mark is what PowerShell writes by default. (#316)
- Saved Access queries now appear in the graph no matter what order Dysflow writes them. The `queries.json` manifest is what marks the `.sql` files beside it as saved queries, and a manifest arriving after its queries left them unindexed until the next manual sync. Editing only the manifest — renaming a query, adding an entry — now re-indexes too. (#318)
- Agents are now told about three Access surfaces the index already carried but never advertised: saved queries and the tables they touch, Dysflow test sequences alongside the manifests, and the table-and-column structure from the Access ERD export. An agent that was not told these exist fell back to reading the files by hand. (#320)

## [1.17.1] - 2026-09-12

Expand Down
123 changes: 123 additions & 0 deletions __tests__/mcp-server-instructions-vba.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* The MCP `initialize` instructions must describe the VBA/Access surfaces the
* fork actually indexes (issue #320).
*
* `src/mcp/server-instructions.ts` is the single source of truth for
* agent-facing guidance (#529) — it is the first and only thing an agent is
* told about what lives in this index. Prose there is cheap to write and easy
* to let rot, so these tests do NOT assert on wording. They run the real
* extractors, collect the node kinds and `synthesizedBy` tags those extractors
* emit, and require the instructions to name each one. Rename a tag in an
* extractor and this suite fails until the guidance follows.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { SERVER_INSTRUCTIONS } from '../src/mcp/server-instructions';
import { SqlQueryExtractor } from '../src/extraction/sql-query-extractor';
import { VbaTestSequenceExtractor } from '../src/extraction/vba-test-sequence-extractor';
import { VbaTestManifestExtractor } from '../src/extraction/vba-test-manifest-extractor';
import {
AccessErdExtractor,
ERD_TABLE_SYNTHESIZED_BY,
ERD_LINKED_TABLE_SYNTHESIZED_BY,
} from '../src/extraction/access-erd-extractor';
import { EXTERNAL_BACKEND_SYNTHESIZED_BY } from '../src/extraction/sql-table-scan';

/** Every `synthesizedBy` tag on the nodes, edges and refs of a result. */
function tagsOf(result: {
nodes: Array<{ metadata?: Record<string, unknown> }>;
edges: Array<{ metadata?: Record<string, unknown> }>;
unresolvedReferences: Array<{ metadata?: Record<string, unknown> }>;
}): string[] {
const out = new Set<string>();
for (const item of [...result.nodes, ...result.edges, ...result.unresolvedReferences]) {
const tag = item.metadata?.synthesizedBy;
if (typeof tag === 'string') out.add(tag);
}
return [...out];
}

describe('MCP server instructions — VBA/Access coverage (#320)', () => {
it('names every tag and node kind a saved Access query emits', () => {
const r = new SqlQueryExtractor(
'queries/Q_Ventas.sql',
'SELECT * FROM TbVentas INNER JOIN TbClientes ON 1 = 1;\n',
).extract();

// Sanity: the fixture really does produce a query node with table refs, so
// an extractor that silently stopped emitting them cannot pass this test.
expect(r.nodes.some((n) => n.kind === 'query')).toBe(true);
const tags = tagsOf(r);
expect(tags).toContain('sql-query-table');

for (const tag of tags) {
expect(SERVER_INSTRUCTIONS, `undocumented tag: ${tag}`).toContain(tag);
}
expect(SERVER_INSTRUCTIONS).toContain('query');
expect(SERVER_INSTRUCTIONS).toContain('queries.json');
expect(SERVER_INSTRUCTIONS).toContain(EXTERNAL_BACKEND_SYNTHESIZED_BY);
});

it('names the test-sequence surface, not just the manifests', () => {
const r = new VbaTestSequenceExtractor(
'tests/sequences/cache.json',
JSON.stringify({
runnerPolicy: { tool: 'dysflow', sequential: true },
procedures: ['Test_Cache_RunSlice'],
}),
).extract();

const tags = tagsOf(r);
expect(tags).toContain('vba-test-sequence');
for (const tag of tags) {
expect(SERVER_INSTRUCTIONS, `undocumented tag: ${tag}`).toContain(tag);
}

// The metadata an agent has to know exists to act on a sequence.
const ref = r.unresolvedReferences[0];
for (const key of Object.keys(ref?.metadata ?? {})) {
if (key === 'synthesizedBy') continue;
expect(SERVER_INSTRUCTIONS, `undocumented metadata key: ${key}`).toContain(key);
}
});

it('still names the manifest surface it already documented', () => {
// Guard against a future edit trimming the paragraph the sequences one
// now sits beside.
const r = new VbaTestManifestExtractor(
'tests/tests.vba.smoke.json',
JSON.stringify({ tests: [{ procedure: 'Test_X_RunAll' }] }),
).extract();
for (const tag of tagsOf(r)) {
expect(SERVER_INSTRUCTIONS, `undocumented tag: ${tag}`).toContain(tag);
}
});

it('names every tag the Access ERD export emits', () => {
const fixture = path.join(
__dirname,
'fixtures',
'access-erd',
'ERD',
'Estructura_Datos.md',
);
const r = new AccessErdExtractor(
'ERD/Estructura_Datos.md',
fs.readFileSync(fixture, 'utf-8'),
).extract();

// The fixture carries both a plain and a LINKED table, so both tags are
// exercised rather than assumed.
const tags = tagsOf(r);
expect(tags).toContain(ERD_TABLE_SYNTHESIZED_BY);
expect(tags).toContain(ERD_LINKED_TABLE_SYNTHESIZED_BY);

for (const tag of tags) {
expect(SERVER_INSTRUCTIONS, `undocumented tag: ${tag}`).toContain(tag);
}
// The node kinds an agent would have to ask for by name.
expect(SERVER_INSTRUCTIONS).toContain('type_member');
expect(SERVER_INSTRUCTIONS).toContain('ERD/');
});
});
22 changes: 22 additions & 0 deletions src/mcp/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,28 @@ format):
production symbol reaches its covering test atoms with the manifest and tags
to run.
Pass \`projectPath\` to a codegraph index that includes VBA files.
- **Saved Access queries are first-class nodes — do not grep the \`.sql\`.** A
Dysflow-exported \`queries/<Name>.sql\` whose directory also carries a
\`queries.json\` manifest becomes a \`query\` node (language \`sql\`, named after
the file) with a \`references\` edge to every table its \`FROM\` / \`JOIN\` /
\`INTO\` / \`UPDATE\` names, tagged \`sql-query-table\`; a table that resolves to
another backend file is tagged \`vba-external-backend\` instead. A \`.sql\` with
no sibling \`queries.json\` is deliberately NOT indexed, so an ordinary SQL
migration in a non-Access repo stays out of the graph.
- **Dysflow test sequences sit beside the manifests.** A \`sequences/*.json\`
emits a \`file\` node and one \`references\` edge per \`procedures[]\` entry to
that \`Test_*\` procedure, tagged \`vba-test-sequence\` and carrying
\`runnerPolicy\`, \`sequenceFile\` and \`procedureIndex\`. So \`getCallers\` of a
test atom reaches both the manifest that registers it (\`vba-test-manifest\`)
and the sequence that orders it, with the runner policy to honour.
- **Table structure comes from the Access ERD export.** An \`ERD/*.md\` produced
by the Access structure generator emits one \`class\` node per table (tagged
\`access-erd-table\`), one \`type_member\` per field joined by \`contains\`, and a
\`references\` edge from a linked table to the backend it really lives in
(tagged \`vba-linked-table\`). This is the cheapest answer to "what columns
does this table have" and to "which backend owns it" — ask codegraph before
reading the export. The extractor requires the generated header, so a
hand-written diagram in an \`ERD/\` folder contributes nothing.
- **VBA unresolved refs carry syntactic shape** (v1.7+). \`unresolved_refs.reference_kind\` is no longer the literal string \`"references"\` — it reports what the syntactic shape actually was. Values: \`call\` (paren-form or statement-form call site), \`qualified-call\` (\`obj.Foo(...)\` with runtime receiver), \`property-get\` / \`property-set\` (\`Me.Name\`, \`obj.Prop = value\`), \`bang-get\` / \`bang-set\` (\`Me!SubCtl\`, \`obj!Field = value\`), \`unqualified-ident\` (bare identifier like \`HayErrorEnRiesgo\` in an \`If\` condition), \`member-with\` (\`.Member\` inside a \`With\` block), \`dao-query\` (\`DoCmd.OpenQuery "X"\` argument). The legacy value \`references\` is retained on any path the round did not reclassify, so older SQL filters that key on it keep working. To find real missing callees, filter \`WHERE reference_kind IN ('call','qualified-call','unqualified-ident','member-with','bang-get')\` — that set has <10% false positives (DAO-field accesses, form-property reads, and bang refs no longer pollute the bucket).
- **Post-extraction stub resolver** (v1.7+). Edges with \`metadata.synthesizedBy='vba-name-resolution'\` start life pointing at a synthetic function node; the resolver at \`src/resolution/index.ts:resolveVbaCallStubs\` (invoked from \`indexAll\` and \`sync\`) walks them and repoints each \`target\` to the real \`nodes.id\` when one exists. Runtime-object calls (\`DAO.*\`, \`fso.*\`, \`ListBox.*\`, \`Collection.*\`, \`err.*\`, \`VBA.*\`, \`Application.*\`, \`Screen.*\`, \`DoCmd.*\`, \`CurrentDb.*\`, \`Forms\`, \`Reports\`, \`Debug\`, \`Modules\`, \`References\`, \`CommandBars\`, \`SysCmd\`, \`CreateObject\`, \`GetObject\`, \`Fields\`) are explicitly declined — they remain \`stub:true\` because they can never link to user code. Shadow user classes (e.g. a user class actually named \`DAO\` with an \`Execute\` method) are preserved and linked normally. Every stub edge carries \`metadata.repointDecision\` with one of \`reponted-to-real\` (linked to a real \`nodes.id\`), \`declined-runtime\` (runtime object — never user code, filter OUT), \`declined-ambiguous\` (multiple real candidates — investigate), or \`declined-not-found\` (genuinely missing callee — this is the actionable signal). Consumers detecting "missing callees" MUST filter on \`repointDecision='declined-not-found'\`, NOT on the raw \`stub=true\` count — the raw count is dominated by runtime-object noise. See \`docs/vba-stub-repoint-decision.md\` for the full contract.
`;
Expand Down