@descent-vtt/spec-guard 0.11.0 → 0.12.1

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 (157) hide show
  1. package/CHANGELOG.md +443 -31
  2. package/README.md +403 -41
  3. package/dist/cites.d.ts +192 -0
  4. package/dist/cites.d.ts.map +1 -0
  5. package/dist/cites.js +599 -0
  6. package/dist/cites.js.map +1 -0
  7. package/dist/cli.d.ts +14 -6
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +315 -22
  10. package/dist/cli.js.map +1 -1
  11. package/dist/comments.d.ts +1 -1
  12. package/dist/comments.js +1 -1
  13. package/dist/config.d.ts +3 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +46 -5
  16. package/dist/config.js.map +1 -1
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +38 -14
  19. package/dist/engine.js.map +1 -1
  20. package/dist/glob.d.ts +128 -24
  21. package/dist/glob.d.ts.map +1 -1
  22. package/dist/glob.js +304 -61
  23. package/dist/glob.js.map +1 -1
  24. package/dist/impact.d.ts +179 -0
  25. package/dist/impact.d.ts.map +1 -0
  26. package/dist/impact.js +360 -0
  27. package/dist/impact.js.map +1 -0
  28. package/dist/index.d.ts +15 -7
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +8 -4
  31. package/dist/index.js.map +1 -1
  32. package/dist/mcp.d.ts +22 -75
  33. package/dist/mcp.d.ts.map +1 -1
  34. package/dist/mcp.js +206 -370
  35. package/dist/mcp.js.map +1 -1
  36. package/dist/overlay.d.ts +39 -0
  37. package/dist/overlay.d.ts.map +1 -0
  38. package/dist/overlay.js +133 -0
  39. package/dist/overlay.js.map +1 -0
  40. package/dist/parser.d.ts +41 -24
  41. package/dist/parser.d.ts.map +1 -1
  42. package/dist/parser.js +217 -160
  43. package/dist/parser.js.map +1 -1
  44. package/dist/prove.d.ts +46 -0
  45. package/dist/prove.d.ts.map +1 -0
  46. package/dist/prove.js +549 -0
  47. package/dist/prove.js.map +1 -0
  48. package/dist/query.d.ts +18 -1
  49. package/dist/query.d.ts.map +1 -1
  50. package/dist/query.js +9 -3
  51. package/dist/query.js.map +1 -1
  52. package/dist/reporter.d.ts +139 -1
  53. package/dist/reporter.d.ts.map +1 -1
  54. package/dist/reporter.js +593 -21
  55. package/dist/reporter.js.map +1 -1
  56. package/dist/rules.js +3 -3
  57. package/dist/rules.js.map +1 -1
  58. package/dist/runner.d.ts +31 -2
  59. package/dist/runner.d.ts.map +1 -1
  60. package/dist/runner.js +85 -21
  61. package/dist/runner.js.map +1 -1
  62. package/dist/specs.d.ts +27 -1
  63. package/dist/specs.d.ts.map +1 -1
  64. package/dist/specs.js +45 -10
  65. package/dist/specs.js.map +1 -1
  66. package/dist/structure.d.ts.map +1 -1
  67. package/dist/structure.js +7 -6
  68. package/dist/structure.js.map +1 -1
  69. package/dist/types.d.ts +193 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/vendor/spec-core/jsonrpc/index.d.ts +3 -0
  72. package/dist/vendor/spec-core/jsonrpc/index.d.ts.map +1 -0
  73. package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
  74. package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
  75. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +158 -0
  76. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts.map +1 -0
  77. package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
  78. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
  79. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +104 -0
  80. package/dist/vendor/spec-core/markdown/frontmatter.d.ts.map +1 -0
  81. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  82. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  83. package/dist/vendor/spec-core/markdown/headings.d.ts +46 -0
  84. package/dist/vendor/spec-core/markdown/headings.d.ts.map +1 -0
  85. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  86. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  87. package/dist/vendor/spec-core/markdown/index.d.ts +6 -0
  88. package/dist/vendor/spec-core/markdown/index.d.ts.map +1 -0
  89. package/dist/vendor/spec-core/markdown/index.js +4 -0
  90. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  91. package/dist/vendor/spec-core/markdown/layout.d.ts +40 -0
  92. package/dist/vendor/spec-core/markdown/layout.d.ts.map +1 -0
  93. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  94. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  95. package/dist/vendor/spec-core/markdown/links.d.ts +26 -0
  96. package/dist/vendor/spec-core/markdown/links.d.ts.map +1 -0
  97. package/dist/vendor/spec-core/markdown/links.js +394 -0
  98. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  99. package/dist/vendor/spec-core/markdown/lists.d.ts +23 -0
  100. package/dist/vendor/spec-core/markdown/lists.d.ts.map +1 -0
  101. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  102. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  103. package/dist/vendor/spec-core/markdown/scan.d.ts +37 -0
  104. package/dist/vendor/spec-core/markdown/scan.d.ts.map +1 -0
  105. package/dist/vendor/spec-core/markdown/scan.js +463 -0
  106. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  107. package/dist/vendor/spec-core/markdown/syntax.d.ts +76 -0
  108. package/dist/vendor/spec-core/markdown/syntax.d.ts.map +1 -0
  109. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  110. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  111. package/dist/vendor/spec-core/markdown/tables.d.ts +19 -0
  112. package/dist/vendor/spec-core/markdown/tables.d.ts.map +1 -0
  113. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  114. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  115. package/dist/vendor/spec-core/markdown/types.d.ts +291 -0
  116. package/dist/vendor/spec-core/markdown/types.d.ts.map +1 -0
  117. package/dist/vendor/spec-core/markdown/types.js +9 -0
  118. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  119. package/dist/vendor/spec-core/path/index.d.ts +2 -0
  120. package/dist/vendor/spec-core/path/index.d.ts.map +1 -0
  121. package/dist/vendor/spec-core/path/index.js +2 -0
  122. package/dist/vendor/spec-core/path/index.js.map +1 -0
  123. package/dist/vendor/spec-core/path/posix.d.ts +84 -0
  124. package/dist/vendor/spec-core/path/posix.d.ts.map +1 -0
  125. package/dist/vendor/spec-core/path/posix.js +150 -0
  126. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  127. package/dist/vendor/spec-core/pattern/automaton.d.ts +130 -0
  128. package/dist/vendor/spec-core/pattern/automaton.d.ts.map +1 -0
  129. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  130. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  131. package/dist/vendor/spec-core/pattern/charset.d.ts +59 -0
  132. package/dist/vendor/spec-core/pattern/charset.d.ts.map +1 -0
  133. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  134. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  135. package/dist/vendor/spec-core/pattern/glob.d.ts +122 -0
  136. package/dist/vendor/spec-core/pattern/glob.d.ts.map +1 -0
  137. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  138. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  139. package/dist/vendor/spec-core/pattern/index.d.ts +7 -0
  140. package/dist/vendor/spec-core/pattern/index.d.ts.map +1 -0
  141. package/dist/vendor/spec-core/pattern/index.js +4 -0
  142. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  143. package/dist/vendor/spec-core/pattern/regex.d.ts +68 -0
  144. package/dist/vendor/spec-core/pattern/regex.d.ts.map +1 -0
  145. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  146. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  147. package/dist/vendor/spec-core/text/index.d.ts +3 -0
  148. package/dist/vendor/spec-core/text/index.d.ts.map +1 -0
  149. package/dist/vendor/spec-core/text/index.js +2 -0
  150. package/dist/vendor/spec-core/text/index.js.map +1 -0
  151. package/dist/vendor/spec-core/text/lines.d.ts +73 -0
  152. package/dist/vendor/spec-core/text/lines.d.ts.map +1 -0
  153. package/dist/vendor/spec-core/text/lines.js +156 -0
  154. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  155. package/package.json +9 -2
  156. package/src/vendor/spec-core/LICENSE +21 -0
  157. package/src/vendor/spec-core/README.md +8 -0
package/dist/mcp.js CHANGED
@@ -2,167 +2,119 @@
2
2
  * `spec-guard mcp`: the rules, served to an agent over the Model Context
3
3
  * Protocol on stdio.
4
4
  *
5
- * No SDK. MCP over stdio is JSON-RPC 2.0, one message per line, and what a
6
- * server has to get right fits in this file: framing, the two eras of the
7
- * protocol, and four methods' worth of results. The official SDK brings a
8
- * schema library and a dependency tree to do the same, and spec-guard's first
9
- * invariant is that it has no runtime dependencies at all. ADR-0012 has the
10
- * rest, including what this file deliberately does not implement.
11
- *
12
- * Two eras, served side by side. Revisions up to 2025-11-25 open with an
13
- * `initialize` handshake; 2026-07-28 has none, and every request carries its
14
- * protocol version in `_meta`. Clients of both are in use, so each request is
15
- * classified on its own - by the rule the TypeScript SDK's server applies, not
16
- * by one invented here: a request that claims a version in `_meta` is modern
17
- * and its claim is validated; anything else, `initialize` included, is legacy.
5
+ * No SDK. The protocol - framing, the two eras, classifying each request,
6
+ * dispatching it and shaping its result - is spec-core's `jsonrpc` module,
7
+ * copied into `src/vendor` and verified by hash (ADR-0015). It was written
8
+ * here first and moved there so that a second server in the family does not
9
+ * relearn it. What is spec-guard's, and stays here, is what the server says:
10
+ * its instructions, its three tools, and the rules and documents it serves as
11
+ * resources. ADR-0012 has the rest, including what this server deliberately
12
+ * does not implement; ADR-0018 has `get_dependents`.
18
13
  */
19
14
  import { toPosix } from './glob.js';
15
+ import { formatImpact, ImpactError, impactDocument, impactOf } from './impact.js';
20
16
  import { nodeIo, readText } from './io.js';
21
17
  import { formatReport } from './reporter.js';
22
18
  import { elapsed, runSpecGuard } from './runner.js';
23
19
  import { answerQuery, formatQuery, inQueriedPaths, loadRuleSet, QueryPathError, resolveQueryPath, viewDocument, } from './query.js';
24
20
  import { governs, viewRule } from './rules.js';
25
- /* ----------------------------------------------------------------- protocol */
26
- /** Revisions that carry their version on every request. Newest first. */
27
- export const MODERN_PROTOCOL_VERSIONS = ['2026-07-28'];
28
- /** Revisions negotiated by `initialize`, newest first - the SDK's own list. */
29
- export const LEGACY_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];
30
- export const PROTOCOL_VERSION_KEY = 'io.modelcontextprotocol/protocolVersion';
31
- export const CLIENT_CAPABILITIES_KEY = 'io.modelcontextprotocol/clientCapabilities';
32
- export const CLIENT_INFO_KEY = 'io.modelcontextprotocol/clientInfo';
33
- export const SERVER_INFO_KEY = 'io.modelcontextprotocol/serverInfo';
34
- export const PARSE_ERROR = -32700;
35
- export const INVALID_REQUEST = -32600;
36
- export const METHOD_NOT_FOUND = -32601;
37
- export const INVALID_PARAMS = -32602;
38
- export const INTERNAL_ERROR = -32603;
39
- /** Resource not found before 2026-07-28, which forbids it in favour of -32602. */
40
- export const LEGACY_RESOURCE_NOT_FOUND = -32002;
41
- export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
42
- class ProtocolError extends Error {
43
- code;
44
- data;
45
- constructor(code, message, data) {
46
- super(message);
47
- this.code = code;
48
- this.data = data;
49
- }
50
- }
51
- function isObject(value) {
52
- return typeof value === 'object' && value !== null && !Array.isArray(value);
53
- }
54
- /** JSON-RPC allows a null id; MCP does not, and requires an integer if a number. */
55
- function isRequestId(value) {
56
- return typeof value === 'string' || Number.isInteger(value);
57
- }
58
- /**
59
- * What is wrong with a request's `_meta`, once it claims a protocol version.
60
- *
61
- * Only what the server depends on or the revision requires: a version that is a
62
- * string, capabilities that are an object, and client info shaped as client info
63
- * when it is sent. A claim is never silently ignored - a malformed one is an
64
- * error, not a reason to fall back to legacy handling.
65
- */
66
- export function envelopeIssue(meta) {
67
- if (!(CLIENT_CAPABILITIES_KEY in meta))
68
- return `${CLIENT_CAPABILITIES_KEY}: missing`;
69
- if (typeof meta[PROTOCOL_VERSION_KEY] !== 'string')
70
- return `${PROTOCOL_VERSION_KEY}: expected a string`;
71
- if (!isObject(meta[CLIENT_CAPABILITIES_KEY]))
72
- return `${CLIENT_CAPABILITIES_KEY}: expected an object`;
73
- const info = meta[CLIENT_INFO_KEY];
74
- if (info !== undefined && !(isObject(info) && typeof info['name'] === 'string' && typeof info['version'] === 'string')) {
75
- return `${CLIENT_INFO_KEY}: expected an object with a string name and version`;
76
- }
77
- return undefined;
78
- }
79
- /**
80
- * The era a request belongs to, or the error that answers it instead.
81
- *
82
- * `initialize` is the legacy handshake unless it carries a valid modern claim.
83
- * Any other request is modern exactly when `_meta` holds the version key - its
84
- * presence is the claim, whatever its value - and legacy otherwise.
85
- */
86
- export function classifyRequest(method, params) {
87
- const meta = params?.['_meta'];
88
- const claimed = isObject(meta) && PROTOCOL_VERSION_KEY in meta;
89
- if (!claimed)
90
- return 'legacy';
91
- const issue = envelopeIssue(meta);
92
- const version = meta[PROTOCOL_VERSION_KEY];
93
- const supported = issue === undefined && MODERN_PROTOCOL_VERSIONS.includes(version);
94
- if (method === 'initialize' && !supported)
95
- return 'legacy';
96
- if (issue !== undefined)
97
- throw new ProtocolError(INVALID_PARAMS, `Invalid _meta envelope: ${issue}`);
98
- if (!supported) {
99
- throw new ProtocolError(UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', {
100
- supported: [...MODERN_PROTOCOL_VERSIONS],
101
- requested: version,
102
- });
103
- }
104
- return 'modern';
105
- }
106
- /** The version `initialize` settles on: the one asked for, or the newest legacy one. */
107
- export function negotiateLegacyVersion(requested) {
108
- // No typeof check: a list of strings includes nothing that is not one.
109
- return LEGACY_PROTOCOL_VERSIONS.includes(requested) ? requested : LEGACY_PROTOCOL_VERSIONS[0];
110
- }
21
+ import { createDocumentMemo } from './specs.js';
22
+ import { createMcpServer, serveLines, toolError, unknownArguments, } from './vendor/spec-core/jsonrpc/index.js';
23
+ // The protocol's names, re-exported from where they now live, so that a
24
+ // caller of the API who imported them from here still finds them.
25
+ export { classifyRequest, envelopeIssue, negotiateLegacyVersion, CLIENT_CAPABILITIES_KEY, CLIENT_INFO_KEY, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, LEGACY_PROTOCOL_VERSIONS, LEGACY_RESOURCE_NOT_FOUND, METHOD_NOT_FOUND, MODERN_PROTOCOL_VERSIONS, PARSE_ERROR, PROTOCOL_VERSION_KEY, SERVER_INFO_KEY, UNSUPPORTED_PROTOCOL_VERSION, } from './vendor/spec-core/jsonrpc/index.js';
111
26
  /* -------------------------------------------------------------- the surface */
112
27
  export const SERVER_NAME = 'spec-guard';
113
28
  export const INSTRUCTIONS = 'spec-guard enforces the architecture decisions written in this project\'s Markdown specs and ADRs. ' +
114
29
  'Before creating or changing a file, call get_architectural_rules with its path to learn the rules in force there: ' +
115
30
  'imports it must not make, the layer it belongs to, what it must be named and the files it needs beside it, text it must not contain. ' +
31
+ 'Before changing a file that other files import - renaming or removing an export, changing what a function takes or returns - ' +
32
+ 'call get_dependents with its path to learn every file the change can reach and the rules in force over them. ' +
116
33
  'After changing files, call check_architecture with their paths to find violations before CI does. ' +
117
- 'Rules in draft, proposed, rejected, deprecated or superseded documents are not in force and are only counted.';
34
+ 'Rules in draft, proposed, rejected, deprecated, superseded or archived documents are not in force and are only counted.';
118
35
  const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
119
- export const TOOLS = [
120
- {
121
- name: 'get_architectural_rules',
122
- title: 'Architectural rules for a path',
123
- description: 'Lists the architecture rules in force for a file or directory, grouped by the ADR or spec that states them: ' +
124
- 'modules it must not import, the layer it belongs to and the layers it must not depend on, import cycles, ' +
125
- 'what it must be named and the partner files it needs, ' +
126
- 'and text or symbols it must not contain, each with the reason the document gives. ' +
127
- 'Answers from the specs without reading the codebase, so it works for a file that does not exist yet. ' +
128
- 'Rules in documents that are not in force are counted and named but not listed unless include_inactive is true.',
129
- inputSchema: {
130
- type: 'object',
131
- properties: {
132
- path: {
133
- type: 'string',
134
- description: 'A file or directory, relative to the project root or absolute inside it.',
135
- },
136
- include_inactive: {
137
- type: 'boolean',
138
- description: 'Also list rules from draft, proposed, rejected, deprecated and superseded documents.',
139
- },
36
+ const GET_RULES = {
37
+ name: 'get_architectural_rules',
38
+ title: 'Architectural rules for a path',
39
+ description: 'Lists the architecture rules in force for a file or directory, grouped by the ADR or spec that states them: ' +
40
+ 'modules it must not import, the layer it belongs to and the layers it must not depend on, import cycles, ' +
41
+ 'what it must be named and the partner files it needs, ' +
42
+ 'and text or symbols it must not contain, each with the reason the document gives. ' +
43
+ 'Answers from the specs without reading the codebase, so it works for a file that does not exist yet. ' +
44
+ 'Rules in documents that are not in force are counted and named but not listed unless include_inactive is true.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ path: {
49
+ type: 'string',
50
+ description: 'A file or directory, relative to the project root or absolute inside it.',
51
+ },
52
+ include_inactive: {
53
+ type: 'boolean',
54
+ description: 'Also list rules from draft, proposed, rejected, deprecated, superseded and archived documents.',
140
55
  },
141
- required: ['path'],
142
- additionalProperties: false,
143
56
  },
144
- annotations: READ_ONLY,
57
+ required: ['path'],
58
+ additionalProperties: false,
145
59
  },
146
- {
147
- name: 'check_architecture',
148
- title: 'Check the architecture rules',
149
- description: 'Runs the architecture rules in force against the files on disk and reports every violation, as CI would. ' +
150
- 'Given paths, runs only the rules that govern them - each over its whole scope, so a count or a cycle is judged ' +
151
- 'exactly as CI judges it - and marks which violations lie in those paths. Save your edits before calling.',
152
- inputSchema: {
153
- type: 'object',
154
- properties: {
155
- paths: {
156
- type: 'array',
157
- items: { type: 'string' },
158
- description: 'Files or directories to check the rules of, relative to the project root. Omit to run every rule.',
159
- },
60
+ annotations: READ_ONLY,
61
+ };
62
+ const CHECK_ARCHITECTURE = {
63
+ name: 'check_architecture',
64
+ title: 'Check the architecture rules',
65
+ description: 'Runs the architecture rules in force against the files on disk and reports every violation, as CI would. ' +
66
+ 'Given paths, runs only the rules that govern them - each over its whole scope, so a count or a cycle is judged ' +
67
+ 'exactly as CI judges it - and marks which violations lie in those paths. Save your edits before calling.',
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ paths: {
72
+ type: 'array',
73
+ items: { type: 'string' },
74
+ description: 'Files or directories to check the rules of, relative to the project root. Omit to run every rule.',
160
75
  },
161
- additionalProperties: false,
162
76
  },
163
- annotations: READ_ONLY,
77
+ additionalProperties: false,
164
78
  },
165
- ];
79
+ annotations: READ_ONLY,
80
+ };
81
+ const GET_DEPENDENTS = {
82
+ name: 'get_dependents',
83
+ title: 'Files that depend on a path',
84
+ description: 'Lists the files that import each given file or directory, directly or through other files, ' +
85
+ 'each with how many imports away it is and the import that leads there, ' +
86
+ 'and the architecture rules in force that govern the paths or any of those files. ' +
87
+ 'Call it before changing a file that other files import - renaming or removing an export, changing a signature - ' +
88
+ 'to see everything the change can reach. ' +
89
+ 'Relative JavaScript, TypeScript and Python imports are followed; an import that cannot be resolved is listed, ' +
90
+ 'and Go, Rust, C# and absolute Python imports, which name modules rather than files, are counted, never guessed, ' +
91
+ 'so a short list is not mistaken for a complete one. Reads the codebase on disk, so every path must exist.',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ paths: {
96
+ type: 'array',
97
+ items: { type: 'string' },
98
+ minItems: 1,
99
+ description: 'Files or directories that exist, relative to the project root or absolute inside it.',
100
+ },
101
+ depth: {
102
+ type: 'integer',
103
+ minimum: 1,
104
+ description: 'Follow dependents at most this many imports away. Omit to follow every one.',
105
+ },
106
+ include_inactive: {
107
+ type: 'boolean',
108
+ description: 'Also list rules from draft, proposed, rejected, deprecated, superseded and archived documents.',
109
+ },
110
+ },
111
+ required: ['paths'],
112
+ additionalProperties: false,
113
+ },
114
+ annotations: READ_ONLY,
115
+ };
116
+ /** What `tools/list` answers, in the order it lists them. */
117
+ export const TOOLS = [GET_RULES, CHECK_ARCHITECTURE, GET_DEPENDENTS];
166
118
  export const RULES_URI = 'spec://rules';
167
119
  export const DOCUMENT_URI_PREFIX = 'spec://doc/';
168
120
  export const RESOURCE_TEMPLATES = [
@@ -189,15 +141,24 @@ export function documentPath(uri) {
189
141
  return null;
190
142
  }
191
143
  }
192
- function toolError(text) {
193
- return { text, isError: true };
194
- }
195
- /** Arguments a tool does not declare, which the model should hear about. */
196
- function unknownArguments(args, allowed) {
197
- const unknown = Object.keys(args).filter((name) => !allowed.includes(name));
198
- return unknown.length === 0
199
- ? undefined
200
- : toolError(`Unknown argument${unknown.length === 1 ? '' : 's'} ${unknown.map((name) => `"${name}"`).join(', ')}; this tool takes ${allowed.join(' and ')}.`);
144
+ /**
145
+ * One of the server's tools.
146
+ *
147
+ * A path argument outside the root, or one that must exist and does not, is
148
+ * the model's to fix, so it hears the message alone. Anything else a tool
149
+ * throws is caught by the protocol layer and reported after the server's name,
150
+ * "spec-guard failed: ...", which is still better than a protocol failure the
151
+ * model cannot read.
152
+ */
153
+ function tool(descriptor, run) {
154
+ return {
155
+ descriptor,
156
+ call: (args) => run(args).catch((error) => {
157
+ if (error instanceof QueryPathError || error instanceof ImpactError)
158
+ return toolError(error.message);
159
+ throw error;
160
+ }),
161
+ };
201
162
  }
202
163
  /**
203
164
  * Builds the function that answers one parsed JSON-RPC message.
@@ -206,14 +167,18 @@ function unknownArguments(args, allowed) {
206
167
  * notification, or a response the client should not have sent.
207
168
  */
208
169
  export function createMcpHandler(options) {
209
- const serverInfo = { name: SERVER_NAME, version: options.version };
210
- const capabilities = { tools: {}, resources: {} };
211
170
  const readFile = options.readFile ?? ((file) => readText(nodeIo, file));
212
171
  /** The settings a request is answered under, read when the request arrives. */
213
172
  const current = async () => options.settings ? options.settings() : { patterns: options.patterns, run: options.run };
173
+ // Every request reads the specs, and parsing them was most of what a query
174
+ // cost; a document whose bytes have not changed since the last request is
175
+ // not parsed again (ADR-0012's amendment of 2026-09-27). The memo holds the
176
+ // documents of the spec set read last and nothing else: specs.ts says how.
177
+ const documents = options.documents ?? createDocumentMemo();
214
178
  const ruleSetOptions = ({ patterns, run }) => ({
215
179
  patterns,
216
180
  root: options.root,
181
+ documents,
217
182
  // No defaults of their own: loadRuleSet has them, and a second copy here
218
183
  // could never disagree with it in a way anything could see.
219
184
  includeSpecs: run?.includeSpecs,
@@ -255,6 +220,7 @@ export function createMcpHandler(options) {
255
220
  ...settings.run,
256
221
  patterns: settings.patterns,
257
222
  root: options.root,
223
+ documents,
258
224
  select: (assertion) => {
259
225
  inForce += 1;
260
226
  return paths === undefined || paths.some((query) => governs(assertion, query));
@@ -292,6 +258,13 @@ export function createMcpHandler(options) {
292
258
  })),
293
259
  inactiveSpecs: report.inactiveSpecs,
294
260
  warnings: report.warnings,
261
+ // A document read differently from how it was written: see ADR-0010 and ADR-0002.
262
+ // A run always reports them; the type leaves them out only for a result a caller built.
263
+ specWarnings: report.specWarnings.map((warning) => ({
264
+ file: warning.location.relativeFile,
265
+ line: warning.location.line,
266
+ message: warning.message,
267
+ })),
295
268
  exclude: report.exclude,
296
269
  config: settings.config,
297
270
  durationMs: Math.round(report.durationMs * 1000) / 1000,
@@ -299,36 +272,62 @@ export function createMcpHandler(options) {
299
272
  const text = `Checked ${scope}.\n\n${formatReport({ ...report, config: settings.config }, { color: false, verbose: false, ascii: true }, settings.run?.maxSnippets)}`;
300
273
  return { text, structured };
301
274
  }
302
- async function callTool(params) {
303
- const name = params['name'];
304
- if (typeof name !== 'string')
305
- throw new ProtocolError(INVALID_PARAMS, 'tools/call needs the name of a tool.');
306
- const tool = name === 'get_architectural_rules' ? getRules : name === 'check_architecture' ? checkArchitecture : undefined;
307
- if (tool === undefined)
308
- throw new ProtocolError(INVALID_PARAMS, `Unknown tool: ${name}`);
309
- const args = params['arguments'] ?? {};
310
- if (!isObject(args))
311
- throw new ProtocolError(INVALID_PARAMS, 'Tool arguments must be an object.');
312
- let outcome;
313
- try {
314
- outcome = await tool(args);
275
+ /**
276
+ * `spec-guard impact`, answered as `impact --json` answers: the same
277
+ * document, and the human report as the text. Unlike the other two tools it
278
+ * answers without specs, as the command does, since who imports a file does
279
+ * not depend on them; the report says no rules are shown.
280
+ */
281
+ async function getDependents(args) {
282
+ const unknown = unknownArguments(args, ['paths', 'depth', 'include_inactive']);
283
+ if (unknown)
284
+ return unknown;
285
+ const paths = args['paths'];
286
+ if (!Array.isArray(paths) || paths.length === 0 || !paths.every((entry) => typeof entry === 'string')) {
287
+ return toolError('"paths" is required and must be a non-empty array of strings.');
315
288
  }
316
- catch (error) {
317
- // A path outside the root is the model's to fix; anything else is still
318
- // better reported to the model than turned into a protocol failure it
319
- // cannot read.
320
- outcome = toolError(error instanceof QueryPathError ? error.message : `spec-guard failed: ${error instanceof Error ? error.message : String(error)}`);
289
+ const depth = args['depth'];
290
+ if (depth !== undefined && !(Number.isInteger(depth) && depth >= 1)) {
291
+ return toolError('"depth" must be a whole number of 1 or more: a depth of 0 would follow no import.');
321
292
  }
322
- return {
323
- content: [{ type: 'text', text: outcome.text }],
324
- ...(outcome.structured === undefined ? {} : { structuredContent: outcome.structured }),
325
- ...(outcome.isError ? { isError: true } : {}),
293
+ const settings = await current();
294
+ const includeInactive = args['include_inactive'] ?? settings.run?.ignoreStatus ?? false;
295
+ if (typeof includeInactive !== 'boolean')
296
+ return toolError('"include_inactive" must be true or false.');
297
+ const report = {
298
+ ...(await impactOf({
299
+ ...ruleSetOptions(settings),
300
+ paths: paths,
301
+ // Absent and undefined are one answer to impactOf: every dependent.
302
+ depth: depth,
303
+ includeInactive,
304
+ })),
305
+ config: settings.config,
326
306
  };
307
+ return { text: formatImpact(report), structured: { ...impactDocument(report) } };
327
308
  }
328
- async function listResources() {
329
- const ruleSet = await loadRuleSet(ruleSetOptions(await current()));
309
+ function rulesResource(ruleSet) {
330
310
  return {
331
- resources: [
311
+ root: toPosix(options.root),
312
+ specFiles: ruleSet.specFiles,
313
+ rules: ruleSet.rules
314
+ .filter(({ document }) => document.inForce)
315
+ .map(({ assertion, document }) => viewRule(assertion, document)),
316
+ notInForce: ruleSet.documents
317
+ .filter((document) => !document.inForce)
318
+ .map((document) => ({ ...viewDocument(document), rules: document.directives.length })),
319
+ errors: ruleSet.errors.map((error) => ({
320
+ file: error.location.relativeFile,
321
+ line: error.location.line,
322
+ message: error.message,
323
+ })),
324
+ };
325
+ }
326
+ const resources = {
327
+ templates: RESOURCE_TEMPLATES,
328
+ async list() {
329
+ const ruleSet = await loadRuleSet(ruleSetOptions(await current()));
330
+ return [
332
331
  {
333
332
  uri: RULES_URI,
334
333
  name: 'rules',
@@ -346,207 +345,44 @@ export function createMcpHandler(options) {
346
345
  title: document.title ?? document.relativeFile,
347
346
  mimeType: 'text/markdown',
348
347
  })),
349
- ],
350
- };
351
- }
352
- function rulesResource(ruleSet) {
353
- return {
354
- root: toPosix(options.root),
355
- specFiles: ruleSet.specFiles,
356
- rules: ruleSet.rules
357
- .filter(({ document }) => document.inForce)
358
- .map(({ assertion, document }) => viewRule(assertion, document)),
359
- notInForce: ruleSet.documents
360
- .filter((document) => !document.inForce)
361
- .map((document) => ({ ...viewDocument(document), rules: document.directives.length })),
362
- errors: ruleSet.errors.map((error) => ({
363
- file: error.location.relativeFile,
364
- line: error.location.line,
365
- message: error.message,
366
- })),
367
- };
368
- }
369
- async function readResource(params, era) {
370
- const uri = params['uri'];
371
- if (typeof uri !== 'string')
372
- throw new ProtocolError(INVALID_PARAMS, 'resources/read needs a uri.');
373
- const ruleSet = await loadRuleSet(ruleSetOptions(await current()));
374
- if (uri === RULES_URI) {
375
- return { contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(rulesResource(ruleSet), null, 2) }] };
376
- }
377
- // Membership, never a path join: the only files this reads are the ones the
378
- // spec patterns matched, so no URI can reach anything else on disk.
379
- const wanted = documentPath(uri);
380
- const document = ruleSet.documents.find((candidate) => candidate.relativeFile === wanted);
381
- const text = document === undefined ? undefined : await readFile(document.file).catch(() => undefined);
382
- if (text === undefined) {
383
- throw new ProtocolError(era === 'modern' ? INVALID_PARAMS : LEGACY_RESOURCE_NOT_FOUND, 'Resource not found', { uri });
384
- }
385
- return { contents: [{ uri, mimeType: 'text/markdown', text }] };
386
- }
387
- /** A list request's cursor. This server never issues one, so any is invalid. */
388
- function refuseCursor(params) {
389
- if (params?.['cursor'] !== undefined)
390
- throw new ProtocolError(INVALID_PARAMS, 'Invalid cursor: this server does not paginate.');
391
- }
392
- async function dispatch(method, params, era) {
393
- const given = params ?? {};
394
- if (era === 'legacy') {
395
- if (method === 'initialize') {
396
- return {
397
- protocolVersion: negotiateLegacyVersion(given['protocolVersion']),
398
- capabilities,
399
- serverInfo,
400
- instructions: INSTRUCTIONS,
401
- };
348
+ ];
349
+ },
350
+ async read(uri) {
351
+ const ruleSet = await loadRuleSet(ruleSetOptions(await current()));
352
+ if (uri === RULES_URI) {
353
+ return [{ uri, mimeType: 'application/json', text: JSON.stringify(rulesResource(ruleSet), null, 2) }];
402
354
  }
403
- if (method === 'ping')
404
- return {};
405
- }
406
- else if (method === 'server/discover') {
407
- return { supportedVersions: [...MODERN_PROTOCOL_VERSIONS], capabilities, instructions: INSTRUCTIONS };
408
- }
409
- switch (method) {
410
- case 'tools/list':
411
- refuseCursor(params);
412
- return { tools: TOOLS };
413
- case 'tools/call':
414
- return callTool(given);
415
- case 'resources/list':
416
- refuseCursor(params);
417
- return listResources();
418
- case 'resources/templates/list':
419
- refuseCursor(params);
420
- return { resourceTemplates: RESOURCE_TEMPLATES };
421
- case 'resources/read':
422
- return readResource(given, era);
423
- default:
424
- throw new ProtocolError(METHOD_NOT_FOUND, 'Method not found');
425
- }
426
- }
427
- /** The 2026-07-28 shape: a result type, caching hints where required, and identity. */
428
- function modernResult(method, result) {
429
- // Every cacheable result is marked stale at once and private. The rules are
430
- // read fresh on each request, and a client serving an ADR edited a minute ago
431
- // from cache would be doing the one thing this tool exists to stop.
432
- const cacheable = method !== 'tools/call';
433
- return {
434
- resultType: 'complete',
435
- ...result,
436
- ...(cacheable ? { ttlMs: 0, cacheScope: 'private' } : {}),
437
- _meta: { [SERVER_INFO_KEY]: serverInfo },
438
- };
439
- }
440
- return async (message) => {
441
- if (!isObject(message)) {
442
- return {
443
- jsonrpc: '2.0',
444
- error: {
445
- code: INVALID_REQUEST,
446
- message: Array.isArray(message) ? 'Invalid Request: batches are not supported' : 'Invalid Request',
447
- },
448
- };
449
- }
450
- const { id, method, params } = message;
451
- if (message['jsonrpc'] === '2.0' && method === undefined && isRequestId(id) && ('result' in message || 'error' in message)) {
452
- return null;
453
- }
454
- if (message['jsonrpc'] !== '2.0' || typeof method !== 'string' || (id !== undefined && !isRequestId(id))) {
455
- return { jsonrpc: '2.0', ...(isRequestId(id) ? { id } : {}), error: { code: INVALID_REQUEST, message: 'Invalid Request' } };
456
- }
457
- if (id === undefined)
458
- return null;
459
- try {
460
- if (params !== undefined && !isObject(params))
461
- throw new ProtocolError(INVALID_PARAMS, 'params must be an object.');
462
- const era = classifyRequest(method, params);
463
- const result = await dispatch(method, params, era);
464
- return { jsonrpc: '2.0', id, result: era === 'modern' ? modernResult(method, result) : result };
465
- }
466
- catch (error) {
467
- const failure = error instanceof ProtocolError
468
- ? { code: error.code, message: error.message, data: error.data }
469
- : { code: INTERNAL_ERROR, message: `Internal error: ${error instanceof Error ? error.message : String(error)}` };
470
- return { jsonrpc: '2.0', id, error: failure };
471
- }
355
+ // Membership, never a path join: the only files this reads are the ones the
356
+ // spec patterns matched, so no URI can reach anything else on disk.
357
+ const wanted = documentPath(uri);
358
+ const document = ruleSet.documents.find((candidate) => candidate.relativeFile === wanted);
359
+ const text = document === undefined ? undefined : await readFile(document.file).catch(() => undefined);
360
+ return text === undefined ? null : [{ uri, mimeType: 'text/markdown', text }];
361
+ },
472
362
  };
363
+ // Every result a modern client may cache is marked stale at once and
364
+ // private, which is the protocol layer's default and the one this server
365
+ // needs: the rules are read fresh on each request, and a client serving an
366
+ // ADR edited a minute ago from its cache would be doing the one thing this
367
+ // tool exists to stop. No prompts are declared, so prompts/list is answered
368
+ // "Method not found", as it is for any method the server does not have.
369
+ return createMcpServer({
370
+ name: SERVER_NAME,
371
+ version: options.version,
372
+ instructions: INSTRUCTIONS,
373
+ tools: [tool(GET_RULES, getRules), tool(CHECK_ARCHITECTURE, checkArchitecture), tool(GET_DEPENDENTS, getDependents)],
374
+ resources,
375
+ });
473
376
  }
474
377
  /* -------------------------------------------------------------------- stdio */
475
- /** The key a request is tracked under: `1` and `"1"` are different ids. */
476
- function trackingKey(id) {
477
- return `${typeof id}:${id}`;
478
- }
479
378
  /**
480
379
  * Serves a handler over newline-delimited JSON-RPC until the input ends.
481
380
  *
482
- * The whole stdio binding. Messages are split on `\n` with a trailing `\r`
483
- * dropped, blank lines are skipped, and every response is written as a single
484
- * line - `JSON.stringify` never emits a raw newline, which is what the binding
485
- * requires. Requests are answered concurrently and in whatever order they
486
- * finish. A request the client cancels gets no response at all. When the input
487
- * closes, requests still running are allowed to finish and then this resolves.
381
+ * spec-core's `serveLines`, under the name this module has always exported:
382
+ * one message per line, answered concurrently, a cancelled request answered
383
+ * with nothing, and the input's end awaited until the last request finishes.
488
384
  */
489
385
  export function serveStdio(input, write, handle) {
490
- return new Promise((resolve, reject) => {
491
- // Decoded here rather than with setEncoding, which cannot be told apart
492
- // from not setting one: Node reads an empty encoding name as UTF-8 too.
493
- const decoder = new TextDecoder();
494
- let buffer = '';
495
- let running = 0;
496
- let ended = false;
497
- const inFlight = new Set();
498
- const cancelled = new Set();
499
- // Resolves once the input has ended and the last request has answered. A
500
- // count rather than a set of promises, so that getting it wrong either hangs
501
- // or ends early - both of which a test sees.
502
- const settle = () => {
503
- if (ended && running === 0)
504
- resolve();
505
- };
506
- const receive = (line) => {
507
- // A CRLF line keeps its \r: JSON allows it as whitespace, and a line of
508
- // nothing else is blank.
509
- if (line.trim() === '')
510
- return;
511
- let message;
512
- try {
513
- message = JSON.parse(line);
514
- }
515
- catch {
516
- write(JSON.stringify({ jsonrpc: '2.0', error: { code: PARSE_ERROR, message: 'Parse error' } }));
517
- return;
518
- }
519
- if (isObject(message) && message['method'] === 'notifications/cancelled') {
520
- const requestId = isObject(message['params']) ? message['params']['requestId'] : undefined;
521
- if (isRequestId(requestId) && inFlight.has(trackingKey(requestId)))
522
- cancelled.add(trackingKey(requestId));
523
- return;
524
- }
525
- const key = isObject(message) && typeof message['method'] === 'string' ? trackingKey(message['id']) : undefined;
526
- inFlight.add(key);
527
- running += 1;
528
- void handle(message).then((response) => {
529
- running -= 1;
530
- inFlight.delete(key);
531
- if (!cancelled.delete(key) && response !== null)
532
- write(JSON.stringify(response));
533
- settle();
534
- });
535
- };
536
- input.on('data', (chunk) => {
537
- buffer += decoder.decode(chunk, { stream: true });
538
- for (let newline = buffer.indexOf('\n'); newline !== -1; newline = buffer.indexOf('\n')) {
539
- const line = buffer.slice(0, newline);
540
- buffer = buffer.slice(newline + 1);
541
- receive(line);
542
- }
543
- });
544
- input.once('end', () => {
545
- ended = true;
546
- receive(buffer);
547
- settle();
548
- });
549
- input.once('error', reject);
550
- });
386
+ return serveLines(input, write, handle);
551
387
  }
552
388
  //# sourceMappingURL=mcp.js.map