@mrclrchtr/supi-code-intelligence 5.0.0 → 6.0.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 (151) hide show
  1. package/README.md +4 -2
  2. package/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  3. package/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  5. package/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  8. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  10. package/node_modules/@mrclrchtr/supi-lsp/README.md +36 -3
  11. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  12. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  13. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  14. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  15. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  16. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  17. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  18. package/node_modules/@mrclrchtr/supi-lsp/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  19. package/node_modules/@mrclrchtr/supi-lsp/package.json +3 -3
  20. package/node_modules/@mrclrchtr/supi-lsp/src/api.ts +12 -1
  21. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-cache.ts +67 -28
  22. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-collection.ts +63 -6
  23. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-evidence.ts +46 -5
  24. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-host.ts +1 -0
  25. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-publication.ts +214 -0
  26. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-refresh.ts +278 -79
  27. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostic-timing.ts +29 -8
  28. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-diagnostics.ts +101 -19
  29. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-state.ts +19 -1
  30. package/node_modules/@mrclrchtr/supi-lsp/src/client/client-document-sync.ts +49 -3
  31. package/node_modules/@mrclrchtr/supi-lsp/src/client/client.ts +73 -15
  32. package/node_modules/@mrclrchtr/supi-lsp/src/client/transport.ts +1 -1
  33. package/node_modules/@mrclrchtr/supi-lsp/src/config/capabilities.ts +2 -2
  34. package/node_modules/@mrclrchtr/supi-lsp/src/config/config.ts +5 -5
  35. package/node_modules/@mrclrchtr/supi-lsp/src/config/server-config.ts +3 -0
  36. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-extends.ts +68 -0
  37. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-path.ts +8 -0
  38. package/node_modules/@mrclrchtr/supi-lsp/src/config/tsconfig-scope.ts +176 -24
  39. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/evidence.ts +4 -0
  40. package/node_modules/@mrclrchtr/supi-lsp/src/diagnostics/workspace-sentinels.ts +46 -10
  41. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-diagnostics.ts +4 -3
  42. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager-workspace-recovery.ts +65 -17
  43. package/node_modules/@mrclrchtr/supi-lsp/src/manager/manager.ts +143 -21
  44. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostic-surface.ts +2 -0
  45. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-diagnostics.ts +43 -1
  46. package/node_modules/@mrclrchtr/supi-lsp/src/session/runtime-registry.ts +31 -0
  47. package/node_modules/@mrclrchtr/supi-tree-sitter/README.md +2 -0
  48. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/README.md +2 -0
  49. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-code-runtime/package.json +1 -1
  50. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/README.md +2 -0
  51. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/package.json +2 -2
  52. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/api.ts +1 -1
  53. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +0 -3
  54. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/debug.ts +9 -0
  55. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/@mrclrchtr/supi-core/src/index.ts +1 -1
  56. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm +0 -0
  57. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/debug/web-tree-sitter.wasm.map +4 -4
  58. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/package.json +1 -1
  59. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm +0 -0
  60. package/node_modules/@mrclrchtr/supi-tree-sitter/node_modules/web-tree-sitter/web-tree-sitter.wasm.map +4 -4
  61. package/node_modules/@mrclrchtr/supi-tree-sitter/package.json +3 -3
  62. package/package.json +5 -5
  63. package/src/analysis/health/diagnostics.ts +58 -126
  64. package/src/analysis/health/file-scope.ts +184 -0
  65. package/src/analysis/health/recovery.ts +8 -1
  66. package/src/analysis/readiness.ts +4 -0
  67. package/src/analysis/search/ast-scan.ts +4 -1
  68. package/src/analysis/search/pattern-analysis.ts +10 -3
  69. package/src/analysis/search/pattern.ts +6 -1
  70. package/src/config.ts +35 -1
  71. package/src/extension.ts +57 -14
  72. package/src/overview/overview-data.ts +9 -2
  73. package/src/overview/overview.ts +49 -30
  74. package/src/overview/types.ts +2 -0
  75. package/src/session/find-types.ts +2 -2
  76. package/src/session/find-workflow.ts +3 -2
  77. package/src/session/health-refresh.ts +76 -49
  78. package/src/session/health-types.ts +7 -1
  79. package/src/session/health-workflow.ts +35 -18
  80. package/src/session/input/workflows.ts +2 -2
  81. package/src/session/inspect-workflow.ts +2 -1
  82. package/src/session/orientation/collect.ts +2 -2
  83. package/src/session/orientation-types.ts +2 -0
  84. package/src/session/orientation-workflow.ts +8 -2
  85. package/src/session/refactor-workflow.ts +2 -1
  86. package/src/session/session.ts +25 -1
  87. package/src/substrate/lsp/maintenance.ts +105 -23
  88. package/src/substrate/lsp/recovery.ts +11 -5
  89. package/src/tool/code_find/execute.ts +23 -0
  90. package/src/tool/code_find/guidance.ts +8 -0
  91. package/src/tool/{find → code_find}/render.ts +1 -1
  92. package/src/tool/{result/find.ts → code_find/result.ts} +36 -11
  93. package/src/tool/code_find/spec.ts +37 -0
  94. package/src/tool/code_graph/execute.ts +24 -0
  95. package/src/tool/code_graph/guidance.ts +6 -0
  96. package/src/tool/{graph → code_graph}/markdown.ts +1 -1
  97. package/src/tool/{result/graph.ts → code_graph/result.ts} +69 -4
  98. package/src/tool/code_graph/spec.ts +39 -0
  99. package/src/tool/{health → code_health}/execute.ts +2 -18
  100. package/src/tool/code_health/guidance.ts +8 -0
  101. package/src/tool/{health → code_health}/markdown.ts +68 -18
  102. package/src/tool/{result/health.ts → code_health/result.ts} +29 -4
  103. package/src/tool/code_health/spec.ts +41 -0
  104. package/src/tool/code_inspect/execute.ts +20 -0
  105. package/src/tool/code_inspect/guidance.ts +6 -0
  106. package/src/tool/{inspect → code_inspect}/markdown.ts +1 -1
  107. package/src/tool/{result/inspect.ts → code_inspect/result.ts} +32 -3
  108. package/src/tool/code_inspect/spec.ts +25 -0
  109. package/src/tool/code_orientation/execute.ts +25 -0
  110. package/src/tool/code_orientation/guidance.ts +9 -0
  111. package/src/tool/{orientation → code_orientation}/markdown.ts +1 -1
  112. package/src/tool/{result/orientation.ts → code_orientation/result.ts} +67 -3
  113. package/src/tool/code_orientation/spec.ts +25 -0
  114. package/src/tool/code_refactor_apply/execute.ts +17 -0
  115. package/src/tool/code_refactor_apply/guidance.ts +5 -0
  116. package/src/tool/{refactor-apply/execute.ts → code_refactor_apply/result.ts} +5 -11
  117. package/src/tool/code_refactor_apply/spec.ts +26 -0
  118. package/src/tool/code_refactor_plan/execute.ts +27 -0
  119. package/src/tool/code_refactor_plan/guidance.ts +8 -0
  120. package/src/tool/{refactor-plan/execute.ts → code_refactor_plan/result.ts} +9 -22
  121. package/src/tool/code_refactor_plan/spec.ts +25 -0
  122. package/src/tool/code_resolve/execute.ts +19 -0
  123. package/src/tool/code_resolve/guidance.ts +8 -0
  124. package/src/tool/{resolve → code_resolve}/markdown.ts +1 -1
  125. package/src/tool/{result/resolve.ts → code_resolve/result.ts} +37 -24
  126. package/src/tool/code_resolve/spec.ts +25 -0
  127. package/src/tool/guidance.ts +71 -52
  128. package/src/tool/infra/truncate.ts +40 -0
  129. package/src/tool/{refactor-plan/markdown.ts → refactor-markdown.ts} +4 -7
  130. package/src/tool/register.ts +9 -97
  131. package/src/tool/result/refactor.ts +1 -4
  132. package/src/tool/schemas.ts +26 -157
  133. package/src/tool/specs.ts +40 -126
  134. package/src/tool/find/execute.ts +0 -43
  135. package/src/tool/graph/execute.ts +0 -82
  136. package/src/tool/inspect/execute.ts +0 -40
  137. package/src/tool/orientation/execute.ts +0 -80
  138. package/src/tool/resolve/execute.ts +0 -53
  139. /package/src/tool/{find → code_find}/ast-kinds.ts +0 -0
  140. /package/src/tool/{find → code_find}/markdown.ts +0 -0
  141. /package/src/tool/{find → code_find}/modes.ts +0 -0
  142. /package/src/tool/{find → code_find}/tui.ts +0 -0
  143. /package/src/tool/{graph → code_graph}/tui.ts +0 -0
  144. /package/src/tool/{health → code_health}/refresh-status.ts +0 -0
  145. /package/src/tool/{health → code_health}/semantic-state.ts +0 -0
  146. /package/src/tool/{health → code_health}/tui.ts +0 -0
  147. /package/src/tool/{inspect → code_inspect}/tui.ts +0 -0
  148. /package/src/tool/{orientation → code_orientation}/tui.ts +0 -0
  149. /package/src/tool/{refactor-apply → code_refactor_apply}/tui.ts +0 -0
  150. /package/src/tool/{refactor-plan → code_refactor_plan}/tui.ts +0 -0
  151. /package/src/tool/{resolve → code_resolve}/tui.ts +0 -0
@@ -0,0 +1,68 @@
1
+ import * as path from "node:path";
2
+ import ts from "typescript";
3
+ import { normalizeTsconfigPath as normalizePath } from "./tsconfig-path.ts";
4
+
5
+ /**
6
+ * Collect recognized local project configs declared by `extends`, including
7
+ * transitive dependencies and paths that do not exist yet.
8
+ */
9
+ export function collectExtendedProjectConfigs(configPath: string): Set<string> {
10
+ const dependencies = new Set<string>();
11
+ const visited = new Set<string>();
12
+
13
+ const visit = (currentConfigPath: string): void => {
14
+ const normalizedConfigPath = normalizePath(currentConfigPath);
15
+ if (visited.has(normalizedConfigPath)) return;
16
+ visited.add(normalizedConfigPath);
17
+
18
+ const config = ts.readConfigFile(currentConfigPath, ts.sys.readFile).config;
19
+ if (!config || typeof config !== "object") return;
20
+
21
+ const extendsValues =
22
+ typeof config.extends === "string"
23
+ ? [config.extends]
24
+ : Array.isArray(config.extends)
25
+ ? config.extends.filter((value: unknown): value is string => typeof value === "string")
26
+ : [];
27
+
28
+ for (const extendsValue of extendsValues) {
29
+ const extendedConfigPath = resolveLocalExtendedConfigPath(currentConfigPath, extendsValue);
30
+ if (!extendedConfigPath) continue;
31
+
32
+ const normalizedExtendedConfigPath = normalizePath(extendedConfigPath);
33
+ dependencies.add(normalizedExtendedConfigPath);
34
+ visit(extendedConfigPath);
35
+ }
36
+ };
37
+
38
+ visit(configPath);
39
+ return dependencies;
40
+ }
41
+
42
+ /**
43
+ * Resolve a local project-config extends entry.
44
+ *
45
+ * Package names are not local paths and are deliberately ignored. TypeScript
46
+ * uses the `.json` suffix for extensionless local config references. Only
47
+ * recognized project-config names are tracked; arbitrary local JSON files may
48
+ * still be parsed by TypeScript but are outside this invalidation contract.
49
+ */
50
+ function resolveLocalExtendedConfigPath(configPath: string, extendsValue: string): string | null {
51
+ if (!path.isAbsolute(extendsValue) && !/^\.{1,2}(?:[\\/]|$)/.test(extendsValue)) {
52
+ return null;
53
+ }
54
+
55
+ const resolved = path.resolve(path.dirname(configPath), extendsValue);
56
+ const candidate =
57
+ path.extname(resolved).toLowerCase() === ".json" ? resolved : `${resolved}.json`;
58
+ return isProjectConfigFileName(path.basename(candidate)) ? candidate : null;
59
+ }
60
+
61
+ /** Whether a file name is a tsconfig.json, jsconfig.json, or tsconfig.*.json name. */
62
+ export function isProjectConfigFileName(name: string): boolean {
63
+ return (
64
+ name === "tsconfig.json" ||
65
+ name === "jsconfig.json" ||
66
+ (name.startsWith("tsconfig.") && name.endsWith(".json"))
67
+ );
68
+ }
@@ -0,0 +1,8 @@
1
+ import * as path from "node:path";
2
+ import ts from "typescript";
3
+
4
+ /** Normalize a path for tsconfig cache keys and comparisons. */
5
+ export function normalizeTsconfigPath(target: string): string {
6
+ const resolved = path.resolve(target).replaceAll("\\", "/");
7
+ return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
8
+ }
@@ -7,6 +7,10 @@
7
7
 
8
8
  import * as path from "node:path";
9
9
  import ts from "typescript";
10
+ import { collectExtendedProjectConfigs } from "./tsconfig-extends.ts";
11
+ import { normalizeTsconfigPath as normalizePath } from "./tsconfig-path.ts";
12
+
13
+ export { isProjectConfigFileName } from "./tsconfig-extends.ts";
10
14
 
11
15
  interface ParsedProjectConfig {
12
16
  configPath: string;
@@ -19,8 +23,39 @@ interface ParsedProjectConfig {
19
23
  usesDefaultInclude: boolean;
20
24
  }
21
25
 
26
+ interface ParsedProjectConfigCacheEntry {
27
+ parsed: ParsedProjectConfig | null;
28
+ dependencies: Set<string>;
29
+ }
30
+
31
+ /** Whether a file is inside the compilation scope of its nearest project config. */
32
+ export type FileScopeStatus = "included" | "excluded" | "no-config" | "out-of-tree";
33
+
34
+ /**
35
+ * The mechanism that produced a file scope decision.
36
+ *
37
+ * `extension` covers unsupported file extensions (checked before any pattern).
38
+ */
39
+ export type ScopeDecisionBasis =
40
+ | "fileNames"
41
+ | "explicit"
42
+ | "include-pattern"
43
+ | "default-include"
44
+ | "exclude-pattern"
45
+ | "extension";
46
+
47
+ /** An explainable tsconfig scope decision for one file. */
48
+ export interface FileScopeDecision {
49
+ status: FileScopeStatus;
50
+ /** Decision mechanism; null when no decision applies (no-config, out-of-tree). */
51
+ basis: ScopeDecisionBasis | null;
52
+ /** Absolute path of the config that decided the file, when one exists. */
53
+ configPath: string | null;
54
+ caseSensitiveFileNames: boolean;
55
+ }
56
+
22
57
  const nearestConfigCache = new Map<string, string | null>();
23
- const parsedConfigCache = new Map<string, ParsedProjectConfig | null>();
58
+ const parsedConfigCache = new Map<string, ParsedProjectConfigCacheEntry>();
24
59
 
25
60
  const tsInternal = ts as typeof ts & {
26
61
  getFileMatcherPatterns?: (
@@ -47,14 +82,63 @@ const tsInternal = ts as typeof ts & {
47
82
  * @returns `true` if the file is excluded from compilation scope
48
83
  */
49
84
  export function isFileExcludedByTsconfig(filePath: string, cwd: string): boolean {
85
+ // Legacy filter semantics: only a resolved "excluded" decision filters a
86
+ // file. Out-of-tree and no-config files stay unfiltered; the decision API
87
+ // still reports those statuses honestly for consumers that want them.
88
+ return getFileScopeDecision(filePath, cwd).status === "excluded";
89
+ }
90
+
91
+ /**
92
+ * Compute the explainable tsconfig scope decision for one file.
93
+ *
94
+ * The decision carries the mechanism that produced it (the scope decision
95
+ * basis) so consumers can report why a file is inside or outside the nearest
96
+ * project config without re-deriving the scope logic.
97
+ */
98
+ export function getFileScopeDecision(filePath: string, cwd: string): FileScopeDecision {
99
+ const caseSensitiveFileNames = ts.sys.useCaseSensitiveFileNames;
50
100
  const absolutePath = path.resolve(cwd, filePath);
101
+ if (isOutOfTree(cwd, absolutePath)) {
102
+ return {
103
+ status: "out-of-tree",
104
+ basis: null,
105
+ configPath: null,
106
+ caseSensitiveFileNames,
107
+ };
108
+ }
109
+
51
110
  const configPath = findNearestProjectConfig(path.dirname(absolutePath), cwd);
52
- if (!configPath) return false;
111
+ if (!configPath) {
112
+ return {
113
+ status: "no-config",
114
+ basis: null,
115
+ configPath: null,
116
+ caseSensitiveFileNames,
117
+ };
118
+ }
53
119
 
54
120
  const parsed = parseProjectConfig(configPath);
55
- if (!parsed) return false;
121
+ if (!parsed) {
122
+ return {
123
+ status: "no-config",
124
+ basis: null,
125
+ configPath,
126
+ caseSensitiveFileNames,
127
+ };
128
+ }
56
129
 
57
- return !isFileInProjectScope(parsed, absolutePath);
130
+ const decision = decideFileScope(parsed, absolutePath);
131
+ return {
132
+ status: decision.included ? "included" : "excluded",
133
+ basis: decision.basis,
134
+ configPath: parsed.configPath,
135
+ caseSensitiveFileNames,
136
+ };
137
+ }
138
+
139
+ function isOutOfTree(cwd: string, absolutePath: string): boolean {
140
+ const relative = path.relative(path.resolve(cwd), absolutePath);
141
+ return relative.startsWith(`..${path.sep}`) || relative === "..";
58
142
  }
59
143
 
60
144
  /**
@@ -104,11 +188,15 @@ function getLocalProjectConfig(directory: string): string | null {
104
188
  function parseProjectConfig(configPath: string): ParsedProjectConfig | null {
105
189
  const normalizedConfigPath = normalizePath(configPath);
106
190
  const cached = parsedConfigCache.get(normalizedConfigPath);
107
- if (cached !== undefined) return cached;
191
+ if (cached !== undefined) return cached.parsed;
108
192
 
193
+ const cacheEntry: ParsedProjectConfigCacheEntry = {
194
+ parsed: null,
195
+ dependencies: collectExtendedProjectConfigs(configPath),
196
+ };
109
197
  const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, createParseConfigHost());
110
198
  if (!parsed) {
111
- parsedConfigCache.set(normalizedConfigPath, null);
199
+ parsedConfigCache.set(normalizedConfigPath, cacheEntry);
112
200
  return null;
113
201
  }
114
202
 
@@ -119,7 +207,10 @@ function parseProjectConfig(configPath: string): ParsedProjectConfig | null {
119
207
  configDir,
120
208
  parsed.raw.include,
121
209
  parsed.raw.exclude,
122
- usesDefaultInclude,
210
+ {
211
+ useDefaultInclude: usesDefaultInclude,
212
+ useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
213
+ },
123
214
  );
124
215
  const supportedExtensions = new Set(getSupportedExtensions(parsed.options));
125
216
  if (parsed.options.resolveJsonModule) supportedExtensions.add(".json");
@@ -134,7 +225,8 @@ function parseProjectConfig(configPath: string): ParsedProjectConfig | null {
134
225
  supportedExtensions,
135
226
  usesDefaultInclude,
136
227
  } satisfies ParsedProjectConfig;
137
- parsedConfigCache.set(normalizedConfigPath, result);
228
+ cacheEntry.parsed = result;
229
+ parsedConfigCache.set(normalizedConfigPath, cacheEntry);
138
230
  return result;
139
231
  }
140
232
 
@@ -147,15 +239,21 @@ function extractExplicitFiles(rawFiles: unknown, configDir: string): Set<string>
147
239
  );
148
240
  }
149
241
 
242
+ interface FileMatcherOptions {
243
+ useDefaultInclude: boolean;
244
+ useCaseSensitiveFileNames: boolean;
245
+ }
246
+
150
247
  function createFileMatchers(
151
248
  configDir: string,
152
249
  rawInclude: unknown,
153
250
  rawExclude: unknown,
154
- useDefaultInclude: boolean,
251
+ options: FileMatcherOptions,
155
252
  ): {
156
253
  includeFilePattern: RegExp | null;
157
254
  excludePattern: RegExp | null;
158
255
  } {
256
+ const { useDefaultInclude, useCaseSensitiveFileNames } = options;
159
257
  const includeSpecs = Array.isArray(rawInclude)
160
258
  ? rawInclude.filter((entry): entry is string => typeof entry === "string")
161
259
  : undefined;
@@ -167,29 +265,45 @@ function createFileMatchers(
167
265
  excludeSpecs,
168
266
  useDefaultInclude ? ["**/*"] : includeSpecs,
169
267
  );
268
+ // normalizePath() lowercases paths when the filesystem is case-insensitive,
269
+ // so the include/exclude regexes must match case-insensitively too. Otherwise
270
+ // files created after the parse (absent from fileNames, tested against the
271
+ // include pattern) are wrongly declared excluded.
272
+ const regexFlags = useCaseSensitiveFileNames ? undefined : "i";
170
273
 
171
274
  return {
172
275
  includeFilePattern: matcherPatterns.includeFilePattern
173
- ? new RegExp(matcherPatterns.includeFilePattern)
276
+ ? new RegExp(matcherPatterns.includeFilePattern, regexFlags)
174
277
  : null,
175
278
  excludePattern: matcherPatterns.excludePattern
176
- ? new RegExp(matcherPatterns.excludePattern)
279
+ ? new RegExp(matcherPatterns.excludePattern, regexFlags)
177
280
  : null,
178
281
  };
179
282
  }
180
283
 
181
- function isFileInProjectScope(parsed: ParsedProjectConfig, absolutePath: string): boolean {
284
+ function decideFileScope(
285
+ parsed: ParsedProjectConfig,
286
+ absolutePath: string,
287
+ ): { included: boolean; basis: ScopeDecisionBasis } {
182
288
  const normalizedPath = normalizePath(absolutePath);
183
- if (parsed.fileNames.has(normalizedPath)) return true;
289
+ if (parsed.fileNames.has(normalizedPath)) return { included: true, basis: "fileNames" };
184
290
 
185
291
  const extension = path.extname(absolutePath).toLowerCase();
186
- if (!parsed.supportedExtensions.has(extension)) return false;
292
+ if (!parsed.supportedExtensions.has(extension)) return { included: false, basis: "extension" };
187
293
 
188
- if (parsed.explicitFiles) return parsed.explicitFiles.has(normalizedPath);
189
- if (!isWithinOrEqual(parsed.configDir, absolutePath)) return false;
190
- if (parsed.excludePattern?.test(normalizedPath)) return false;
191
- if (parsed.usesDefaultInclude) return true;
192
- return parsed.includeFilePattern ? parsed.includeFilePattern.test(normalizedPath) : false;
294
+ if (parsed.explicitFiles) {
295
+ return { included: parsed.explicitFiles.has(normalizedPath), basis: "explicit" };
296
+ }
297
+ // Include patterns are rooted at the config directory; a file outside it can
298
+ // only be in scope through the parse-time file set or an explicit files list.
299
+ if (!isWithinOrEqual(parsed.configDir, absolutePath)) {
300
+ return { included: false, basis: "include-pattern" };
301
+ }
302
+ if (parsed.excludePattern?.test(normalizedPath))
303
+ return { included: false, basis: "exclude-pattern" };
304
+ if (parsed.usesDefaultInclude) return { included: true, basis: "default-include" };
305
+ if (!parsed.includeFilePattern) return { included: false, basis: "include-pattern" };
306
+ return { included: parsed.includeFilePattern.test(normalizedPath), basis: "include-pattern" };
193
307
  }
194
308
 
195
309
  function getSupportedExtensions(options: ts.CompilerOptions): string[] {
@@ -229,11 +343,6 @@ function isWithinOrEqual(root: string, target: string): boolean {
229
343
  return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
230
344
  }
231
345
 
232
- function normalizePath(target: string): string {
233
- const resolved = path.resolve(target).replaceAll("\\", "/");
234
- return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase();
235
- }
236
-
237
346
  /**
238
347
  * Clear cached nearest-config lookups and parsed project config state.
239
348
  * Useful for testing and after workspace file changes.
@@ -242,3 +351,46 @@ export function clearTsconfigCache(): void {
242
351
  nearestConfigCache.clear();
243
352
  parsedConfigCache.clear();
244
353
  }
354
+
355
+ /**
356
+ * Invalidate cached state for one config file that changed, was created, or
357
+ * was deleted.
358
+ *
359
+ * Removes the changed config and every cached root config that depends on it,
360
+ * so the next decision re-reads current config data. The wholesale
361
+ * {@link clearTsconfigCache} stays for lifecycle events; change signals use
362
+ * this targeted invalidation instead.
363
+ */
364
+ export function invalidateTsconfigCacheForConfig(configPath: string): void {
365
+ const normalizedConfigPath = normalizePath(configPath);
366
+ const invalidated = new Set([normalizedConfigPath]);
367
+
368
+ for (const [cachedConfigPath, cacheEntry] of parsedConfigCache) {
369
+ if (cacheEntry.dependencies.has(normalizedConfigPath)) invalidated.add(cachedConfigPath);
370
+ }
371
+
372
+ for (const cachedConfigPath of invalidated) {
373
+ parsedConfigCache.delete(cachedConfigPath);
374
+ }
375
+
376
+ for (const [key, value] of nearestConfigCache) {
377
+ if (value !== null && invalidated.has(normalizePath(value))) nearestConfigCache.delete(key);
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Invalidate nearest-config lookups that found nothing under `dir`.
383
+ *
384
+ * Used when a config is created: directories below the new config that
385
+ * previously resolved to no config may now resolve to it.
386
+ */
387
+ export function invalidateTsconfigCacheForConfigDir(dir: string): void {
388
+ const normalizedDir = normalizePath(dir);
389
+ for (const [key, value] of nearestConfigCache) {
390
+ if (value !== null) continue;
391
+ const keyDir = key.split("::", 1)[0];
392
+ if (keyDir === normalizedDir || keyDir.startsWith(`${normalizedDir}/`)) {
393
+ nearestConfigCache.delete(key);
394
+ }
395
+ }
396
+ }
@@ -1,3 +1,7 @@
1
+ /** Reason for a partial or unavailable single-file result when the push stays tentative. */
2
+ export const TENTATIVE_PUSH_UNAVAILABLE_REASON =
3
+ "The current push publication is tentative: a later diagnostic republish for the same document synchronization is required.";
4
+
1
5
  /** A final evidence state for one tracked document. */
2
6
  export type DiagnosticEvidenceStatus = "confirmed" | "unconfirmed" | "failed" | "removed";
3
7
 
@@ -1,20 +1,32 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { FileChangeType, type FileEvent } from "../config/types.ts";
4
- import { fileToUri } from "../utils.ts";
4
+ import { fileToUri, uriToFile } from "../utils.ts";
5
5
 
6
6
  const IGNORED_DIRECTORIES = new Set(["node_modules", ".pnpm", ".git", "dist", "coverage"]);
7
7
  const ROOT_LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"];
8
8
 
9
+ export interface WorkspaceSentinelScanOptions {
10
+ /**
11
+ * Also track every regular source file (not just sentinels). The workspace
12
+ * refresh path uses this to discover files created since the last refresh;
13
+ * the diff then reports them as `sourceChanges` so the caller can pull them.
14
+ */
15
+ includeSourceFiles?: boolean;
16
+ }
17
+
9
18
  /** Build a fresh snapshot of workspace sentinel files and their mtimes. */
10
- export function scanWorkspaceSentinels(cwd: string): Map<string, number> {
19
+ export function scanWorkspaceSentinels(
20
+ cwd: string,
21
+ options: WorkspaceSentinelScanOptions = {},
22
+ ): Map<string, number> {
11
23
  const resolvedCwd = path.resolve(cwd);
12
24
  const snapshot = new Map<string, number>();
13
25
 
14
26
  if (!fs.existsSync(resolvedCwd)) return snapshot;
15
27
 
16
28
  try {
17
- walkWorkspace(resolvedCwd, resolvedCwd, snapshot);
29
+ walkWorkspace(resolvedCwd, resolvedCwd, snapshot, options.includeSourceFiles ?? false);
18
30
  } catch {
19
31
  return snapshot;
20
32
  }
@@ -48,15 +60,34 @@ export function diffWorkspaceSentinelSnapshot(
48
60
  return changes.sort((a, b) => a.uri.localeCompare(b.uri));
49
61
  }
50
62
 
51
- /** Refresh a previous snapshot and return the new snapshot plus change events. */
63
+ /**
64
+ * Refresh a previous snapshot and return the new snapshot plus change events.
65
+ *
66
+ * When `includeSourceFiles` is set, the returned `changes` list keeps
67
+ * sentinel-file events only; source-file events are returned separately in
68
+ * `sourceChanges` so the caller can act on them without interpreting every
69
+ * regular file edit as a config-level workspace change.
70
+ */
52
71
  export function syncWorkspaceSentinelSnapshot(
53
72
  cwd: string,
54
73
  previous: Map<string, number>,
55
- ): { snapshot: Map<string, number>; changes: FileEvent[] } {
56
- const snapshot = scanWorkspaceSentinels(cwd);
74
+ options: WorkspaceSentinelScanOptions = {},
75
+ ): { snapshot: Map<string, number>; changes: FileEvent[]; sourceChanges: FileEvent[] } {
76
+ const snapshot = scanWorkspaceSentinels(cwd, options);
77
+ const changes = diffWorkspaceSentinelSnapshot(previous, snapshot);
78
+ if (!options.includeSourceFiles) return { snapshot, changes, sourceChanges: [] };
79
+
80
+ const sourceChanges = changes.filter((change) => {
81
+ const filePath = uriToFile(change.uri);
82
+ return !isWorkspaceSentinelPath(filePath, cwd);
83
+ });
57
84
  return {
58
85
  snapshot,
59
- changes: diffWorkspaceSentinelSnapshot(previous, snapshot),
86
+ changes: changes.filter((change) => {
87
+ const filePath = uriToFile(change.uri);
88
+ return isWorkspaceSentinelPath(filePath, cwd);
89
+ }),
90
+ sourceChanges,
60
91
  };
61
92
  }
62
93
 
@@ -66,7 +97,12 @@ export function isWorkspaceRecoveryTrigger(filePath: string, cwd: string): boole
66
97
  return isWorkspaceSentinelPath(path.resolve(root, filePath), root);
67
98
  }
68
99
 
69
- function walkWorkspace(root: string, directory: string, snapshot: Map<string, number>): void {
100
+ function walkWorkspace(
101
+ root: string,
102
+ directory: string,
103
+ snapshot: Map<string, number>,
104
+ includeSourceFiles: boolean,
105
+ ): void {
70
106
  let entries: fs.Dirent[];
71
107
  try {
72
108
  entries = fs.readdirSync(directory, { withFileTypes: true });
@@ -80,14 +116,14 @@ function walkWorkspace(root: string, directory: string, snapshot: Map<string, nu
80
116
  for (const entry of entries) {
81
117
  if (entry.isDirectory()) {
82
118
  if (IGNORED_DIRECTORIES.has(entry.name)) continue;
83
- walkWorkspace(root, path.join(directory, entry.name), snapshot);
119
+ walkWorkspace(root, path.join(directory, entry.name), snapshot, includeSourceFiles);
84
120
  continue;
85
121
  }
86
122
 
87
123
  if (!entry.isFile()) continue;
88
124
 
89
125
  const filePath = path.join(directory, entry.name);
90
- if (!isWorkspaceSentinelPath(filePath, root)) continue;
126
+ if (!includeSourceFiles && !isWorkspaceSentinelPath(filePath, root)) continue;
91
127
  try {
92
128
  snapshot.set(filePath, fs.statSync(filePath).mtimeMs);
93
129
  } catch {
@@ -5,6 +5,7 @@ import {
5
5
  mapCodeQueryResult,
6
6
  } from "@mrclrchtr/supi-code-runtime/api";
7
7
  import type { LspClient } from "../client/client.ts";
8
+ import type { DiagnosticEntry } from "../client/client-document-state.ts";
8
9
  import type { Diagnostic } from "../config/types.ts";
9
10
  import { relativeFilePathFromUri } from "../diagnostics/diagnostic-summary.ts";
10
11
  import { shouldIgnoreLspPath } from "../summary.ts";
@@ -27,15 +28,15 @@ export async function syncClientFileAndGetDiagnostics(
27
28
  }
28
29
 
29
30
  export function collectOutstandingDiagnosticsDetailed(
30
- clients: Iterable<Pick<LspClient, "getAllDiagnostics">>,
31
+ clientEntries: Iterable<ReadonlyArray<DiagnosticEntry>>,
31
32
  cwd: string,
32
33
  excludePatterns: string[],
33
34
  maxSeverity: number,
34
35
  ): Array<{ file: string; diagnostics: Diagnostic[] }> {
35
36
  const fileDiags = new Map<string, Diagnostic[]>();
36
37
 
37
- for (const client of clients) {
38
- for (const entry of client.getAllDiagnostics()) {
38
+ for (const entries of clientEntries) {
39
+ for (const entry of entries) {
39
40
  const file = relativeFilePathFromUri(entry.uri, cwd);
40
41
  if (shouldIgnoreLspPath(file, cwd)) continue;
41
42
  if (isExcludedByPattern(file, excludePatterns)) continue;
@@ -10,13 +10,13 @@ import type { Diagnostic, FileEvent } from "../config/types.ts";
10
10
  import {
11
11
  type DiagnosticEvidenceDocument,
12
12
  type DiagnosticEvidenceSummary,
13
- emptyDiagnosticEvidence,
14
13
  summarizeDiagnosticEvidence,
15
14
  } from "../diagnostics/evidence.ts";
16
15
  import {
17
16
  assessStaleDiagnostics,
18
17
  type StaleDiagnosticAssessment,
19
18
  } from "../diagnostics/stale-diagnostics.ts";
19
+ import type { WorkspaceDiagnosticReport } from "../session/runtime-diagnostics.ts";
20
20
 
21
21
  export interface WorkspaceRecoveryResult {
22
22
  /** Active clients targeted by the best-effort refresh, not confirmed successful refreshes. */
@@ -28,8 +28,10 @@ export interface WorkspaceRecoveryResult {
28
28
  restartedServers: string[];
29
29
  /** Stall signal of the first restarted route, when this pass restarted a client. */
30
30
  restartReason?: RecoveryRestartReason;
31
- /** Final document-level evidence from the last refresh in this recovery pass. */
31
+ /** Final document-level evidence from this pass, starting from the caller's initial evidence when one was supplied. */
32
32
  diagnosticEvidence: DiagnosticEvidenceSummary;
33
+ /** Final diagnostic report captured after all refresh and recovery work. */
34
+ diagnosticReport: WorkspaceDiagnosticReport;
33
35
  /** Failure from the first refresh, when no later pass replaced it. */
34
36
  refreshFailureReason?: string;
35
37
  /** Wall-clock duration of the whole recovery pass, for telemetry. */
@@ -62,6 +64,7 @@ export interface WorkspaceRecoveryHost {
62
64
  isDiagnosticFile(filePath: string): boolean;
63
65
  getClientDiagnosticRoutes(): WorkspaceDiagnosticRoute[];
64
66
  getDiagnosticEvidence(): DiagnosticEvidenceSummary;
67
+ getWorkspaceDiagnosticReport(maxSeverity?: number): WorkspaceDiagnosticReport;
65
68
  getCwd(): string;
66
69
  restartClientsForFiles(
67
70
  filePaths: string[],
@@ -82,6 +85,13 @@ export function softRecoverWorkspaceDiagnostics(
82
85
  /**
83
86
  * Run a recovery pass, refreshing diagnostics and escalating if stale state remains.
84
87
  *
88
+ * When the caller already ran a refresh this pass, it can hand its evidence in
89
+ * `initialEvidence`; the recovery pass then skips its own initial refresh and
90
+ * still runs stale assessment and restart escalation on the current state.
91
+ * Caller evidence is only reused when the pass applies no watched-file changes:
92
+ * evidence captured before a change would otherwise be reported as current
93
+ * after it.
94
+ *
85
95
  * The pass observes request cancellation between its phases and propagates
86
96
  * the interruption as a rejection instead of swallowing it. A pass that
87
97
  * starts already cancelled rejects before any client or evidence work.
@@ -93,6 +103,8 @@ export async function recoverWorkspaceDiagnostics(
93
103
  restartIfStillStale?: boolean;
94
104
  maxWaitMs?: number;
95
105
  quietMs?: number;
106
+ /** Evidence from a refresh the caller already completed; skips this pass's own refresh when no watched-file changes apply. */
107
+ initialEvidence?: DiagnosticEvidenceSummary;
96
108
  control?: CodeRequestControl;
97
109
  } = {},
98
110
  ): Promise<WorkspaceRecoveryResult> {
@@ -100,22 +112,11 @@ export async function recoverWorkspaceDiagnostics(
100
112
  // evidence work may start for a pass the caller no longer awaits.
101
113
  throwIfCodeRequestInterrupted(options.control);
102
114
  const recoveryStartedAt = Date.now();
103
- const attemptedClients = softRecoverWorkspaceDiagnostics(host, options.changes ?? []);
115
+ const initial = await collectInitialEvidence(host, options);
116
+ const attemptedClients = initial.attemptedClients;
104
117
  const attemptedServers = host.getRunningClientNames();
105
- let diagnosticEvidence = emptyDiagnosticEvidence();
106
- let refreshFailureReason: string | undefined;
107
-
108
- try {
109
- diagnosticEvidence = await host.refreshOpenDiagnostics({
110
- maxWaitMs: options.maxWaitMs,
111
- quietMs: options.quietMs,
112
- ...options.control,
113
- });
114
- } catch (error) {
115
- if (isCodeRequestInterruption(error, options.control)) throw error;
116
- refreshFailureReason = errorMessage(error);
117
- diagnosticEvidence = host.getDiagnosticEvidence();
118
- }
118
+ let diagnosticEvidence = initial.diagnosticEvidence;
119
+ let refreshFailureReason = initial.refreshFailureReason;
119
120
 
120
121
  let staleAssessment = assessStaleDiagnostics(host.getOutstandingDiagnostics(1));
121
122
  let restartedClients = 0;
@@ -148,6 +149,7 @@ export async function recoverWorkspaceDiagnostics(
148
149
  // refresh-only pass must not report a clean result after the caller
149
150
  // cancelled the request.
150
151
  throwIfCodeRequestInterrupted(options.control);
152
+ const diagnosticReport = host.getWorkspaceDiagnosticReport(2);
151
153
 
152
154
  return recoveryResult();
153
155
 
@@ -159,6 +161,7 @@ export async function recoverWorkspaceDiagnostics(
159
161
  restartedServers: restartServerNames,
160
162
  ...(restartReason ? { restartReason } : {}),
161
163
  diagnosticEvidence,
164
+ diagnosticReport,
162
165
  ...(refreshFailureReason ? { refreshFailureReason } : {}),
163
166
  elapsedMs: Date.now() - recoveryStartedAt,
164
167
  staleAssessment,
@@ -173,6 +176,51 @@ interface RecoveryRestartTarget {
173
176
  files: string[];
174
177
  }
175
178
 
179
+ /** Establish the pass's starting evidence: caller-supplied reuse or a fresh refresh. */
180
+ async function collectInitialEvidence(
181
+ host: WorkspaceRecoveryHost,
182
+ options: {
183
+ changes?: FileEvent[];
184
+ initialEvidence?: DiagnosticEvidenceSummary;
185
+ maxWaitMs?: number;
186
+ quietMs?: number;
187
+ control?: CodeRequestControl;
188
+ },
189
+ ): Promise<{
190
+ attemptedClients: number;
191
+ diagnosticEvidence: DiagnosticEvidenceSummary;
192
+ refreshFailureReason?: string;
193
+ }> {
194
+ const changes = options.changes ?? [];
195
+ if (options.initialEvidence !== undefined && changes.length === 0) {
196
+ // Reuse skips the soft invalidation: the caller already refreshed this
197
+ // pass, and clearing cached pull result IDs without a refresh only forces
198
+ // a full pull on a later pass.
199
+ return {
200
+ attemptedClients: host.getRunningClientCount(),
201
+ diagnosticEvidence: options.initialEvidence,
202
+ };
203
+ }
204
+ const attemptedClients = softRecoverWorkspaceDiagnostics(host, changes);
205
+ try {
206
+ return {
207
+ attemptedClients,
208
+ diagnosticEvidence: await host.refreshOpenDiagnostics({
209
+ maxWaitMs: options.maxWaitMs,
210
+ quietMs: options.quietMs,
211
+ ...options.control,
212
+ }),
213
+ };
214
+ } catch (error) {
215
+ if (isCodeRequestInterruption(error, options.control)) throw error;
216
+ return {
217
+ attemptedClients,
218
+ diagnosticEvidence: host.getDiagnosticEvidence(),
219
+ refreshFailureReason: errorMessage(error),
220
+ };
221
+ }
222
+ }
223
+
176
224
  /**
177
225
  * Collect restart targets: push-only routes with a protocol-stall signal.
178
226
  *