@ngockhoale/ukit 2.6.6 → 2.6.8

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 (59) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +40 -177
  3. package/manifests/documentation.yaml +143 -15
  4. package/manifests/hostCapabilities.yaml +49 -0
  5. package/manifests/instructionRules.yaml +383 -0
  6. package/manifests/platform.full.yaml +15 -0
  7. package/package.json +3 -1
  8. package/scripts/bench/goldTasks.json +38 -0
  9. package/scripts/bench/runGold.mjs +220 -0
  10. package/scripts/docs/render-instructions.mjs +42 -0
  11. package/scripts/release/verify-release.mjs +6 -0
  12. package/src/cli/commands/code.js +182 -0
  13. package/src/cli/commands/doctor.js +35 -3
  14. package/src/cli/commands/indexTools.js +102 -1
  15. package/src/cli/commands/memory.js +137 -0
  16. package/src/cli/index.js +7 -0
  17. package/src/core/codeintel/compiler.js +316 -0
  18. package/src/core/codeintel/diagnostics.js +114 -0
  19. package/src/core/codeintel/freshness.js +295 -0
  20. package/src/core/codeintel/impact.js +251 -0
  21. package/src/core/codeintel/invalidation.js +150 -0
  22. package/src/core/codeintel/manifest.js +176 -0
  23. package/src/core/codeintel/packet.js +146 -0
  24. package/src/core/codeintel/providers.js +201 -0
  25. package/src/core/codeintel/retriever.js +372 -0
  26. package/src/core/codeintel/router.js +149 -0
  27. package/src/core/codeintel/semanticProvider.js +235 -0
  28. package/src/core/docContracts.js +723 -0
  29. package/src/core/memory/migrate.js +324 -0
  30. package/src/core/memory/records.js +172 -0
  31. package/src/core/memory/retrieval.js +161 -11
  32. package/src/core/memory/store.js +398 -0
  33. package/src/core/memory/storeV2.js +171 -0
  34. package/src/core/memory/storeV2Loader.js +22 -0
  35. package/src/core/projectImportant.js +1 -1
  36. package/src/core/runtimeConfig.js +125 -0
  37. package/src/core/runtimePaths.js +3 -0
  38. package/src/core/uninstall.js +1 -1
  39. package/src/index/taskRouting.js +39 -0
  40. package/src/render/instructionRenderer.js +226 -0
  41. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  42. package/templates/.gitignore +2 -2
  43. package/templates/.omp/RULES.md +1 -0
  44. package/templates/AGENTS.md +89 -218
  45. package/templates/CLAUDE.md +85 -212
  46. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  47. package/templates/docs/BUGFIX.md +2 -19
  48. package/templates/docs/BUG_INDEX.md +43 -0
  49. package/templates/docs/BUG_METRICS.md +1 -5
  50. package/templates/docs/BUG_TEMPLATE.md +1 -11
  51. package/templates/docs/UKIT_INTERNALS.md +223 -0
  52. package/templates/instructions/core.md +157 -0
  53. package/templates/instructions/layout.yaml +149 -0
  54. package/templates/instructions/overlays/agents.md +15 -0
  55. package/templates/instructions/overlays/claude.md +3 -0
  56. package/templates/instructions/overlays/omp-rules.md +74 -0
  57. package/templates/instructions/overlays/repo.md +9 -0
  58. package/templates/instructions/repo-vars.yaml +23 -0
  59. package/templates/ukit/storage/config.json +30 -0
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ import { getIndexDir } from '../../index/paths.js';
6
+
7
+ // Manifest scanner (SPEC §1) — walks the project, records a sha1 per file, and
8
+ // caches the result at `getIndexDir()/manifest.json`. The cache is validated on
9
+ // (path, mtime, size) so an unchanged tree never re-hashes, and any mtime/size
10
+ // drift forces a full rescan.
11
+
12
+ const HARD_EXCLUDES = new Set(['.git', '.cache', 'node_modules']);
13
+ const MANIFEST_CACHE = 'manifest.json';
14
+
15
+ const IGNORED_BASENAMES = new Set(['.DS_Store']);
16
+
17
+ function sha1(content) {
18
+ return crypto.createHash('sha1').update(content).digest('hex');
19
+ }
20
+
21
+ // Minimal .gitignore subset: blank lines and `#` comments skipped, `!` negation
22
+ // unsupported (documented — negations are rare in-repo and the safe failure mode
23
+ // is scanning a file that should be ignored, never dropping a needed one).
24
+ // Supported: `name` (basename match anywhere), `dir/` (directory), `/path`
25
+ // (root-anchored), `*` single-segment wildcard.
26
+ function parseGitignore(content) {
27
+ return String(content ?? '')
28
+ .split(/\r?\n/)
29
+ .map((line) => line.trim())
30
+ .filter((line) => line.length > 0 && !line.startsWith('#') && !line.startsWith('!'));
31
+ }
32
+
33
+ function gitignorePatternToRegExp(pattern) {
34
+ let anchored = false;
35
+ let dirOnly = false;
36
+ let body = pattern;
37
+ if (body.endsWith('/')) {
38
+ dirOnly = true;
39
+ body = body.slice(0, -1);
40
+ }
41
+ if (body.startsWith('/')) {
42
+ anchored = true;
43
+ body = body.slice(1);
44
+ }
45
+ const escaped = body
46
+ .split('*')
47
+ .map((part) => part.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
48
+ .join('[^/]*');
49
+ if (anchored || body.includes('/')) {
50
+ return { re: new RegExp(`^${escaped}(?:/.*)?$`), dirOnly };
51
+ }
52
+ return { re: new RegExp(`(?:^|/)${escaped}(?:/.*)?$`), dirOnly };
53
+ }
54
+
55
+ function loadIgnoreMatchers(rootDir, extraIgnore) {
56
+ return fs.readFile(path.join(rootDir, '.gitignore'), 'utf8')
57
+ .then((content) => [...parseGitignore(content), ...(extraIgnore ?? [])])
58
+ .then((patterns) => patterns.map(gitignorePatternToRegExp))
59
+ .catch(() => (extraIgnore ?? []).map(gitignorePatternToRegExp));
60
+ }
61
+
62
+ function isIgnored(relPath, isDir, matchers) {
63
+ return matchers.some(({ re, dirOnly }) => (!dirOnly || isDir) && re.test(relPath));
64
+ }
65
+
66
+ async function walk(rootDir, matchers, options) {
67
+ const results = [];
68
+ const stack = [''];
69
+ while (stack.length > 0) {
70
+ const relDir = stack.pop();
71
+ const absDir = path.join(rootDir, relDir);
72
+ let entries;
73
+ try {
74
+ entries = await fs.readdir(absDir, { withFileTypes: true });
75
+ } catch {
76
+ continue;
77
+ }
78
+ for (const entry of entries) {
79
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
80
+ if (entry.isDirectory()) {
81
+ if (HARD_EXCLUDES.has(entry.name)) {
82
+ continue;
83
+ }
84
+ if (isIgnored(relPath, true, matchers)) {
85
+ continue;
86
+ }
87
+ stack.push(relPath);
88
+ } else if (entry.isFile()) {
89
+ if (IGNORED_BASENAMES.has(entry.name)) {
90
+ continue;
91
+ }
92
+ if (isIgnored(relPath, false, matchers)) {
93
+ continue;
94
+ }
95
+ if (options?.filter && !options.filter(relPath)) {
96
+ continue;
97
+ }
98
+ try {
99
+ const stat = await fs.stat(path.join(rootDir, relPath));
100
+ results.push({ path: relPath, mtime: stat.mtimeMs, size: stat.size });
101
+ } catch {
102
+ // Vanished mid-walk — skip.
103
+ }
104
+ }
105
+ }
106
+ }
107
+ results.sort((a, b) => a.path.localeCompare(b.path));
108
+ return results;
109
+ }
110
+
111
+ async function readCachedManifest(rootDir) {
112
+ try {
113
+ const raw = await fs.readFile(path.join(getIndexDir(rootDir), MANIFEST_CACHE), 'utf8');
114
+ const parsed = JSON.parse(raw);
115
+ if (!parsed || !Array.isArray(parsed.files)) {
116
+ return null;
117
+ }
118
+ return parsed;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ function snapshotMatches(cachedFiles, scannedFiles) {
125
+ if (!Array.isArray(cachedFiles) || cachedFiles.length !== scannedFiles.length) {
126
+ return false;
127
+ }
128
+ const byPath = new Map(cachedFiles.map((f) => [f.path, f]));
129
+ return scannedFiles.every((entry) => {
130
+ const cached = byPath.get(entry.path);
131
+ return cached && cached.mtime === entry.mtime && cached.size === entry.size
132
+ && typeof cached.sha1 === 'string';
133
+ });
134
+ }
135
+
136
+ /**
137
+ * scanManifest(projectRoot, { ignore, filter }) →
138
+ * { files: [{ path, sha1, mtime, size }], generatedAt, fromCache }
139
+ *
140
+ * `ignore` adds .gitignore-style patterns on top of the repo .gitignore.
141
+ * `filter(relPath)` optionally keeps a subset of scanned files.
142
+ * Never throws on a missing/unreadable tree — returns an empty manifest.
143
+ */
144
+ export async function scanManifest(projectRoot, { ignore, filter } = {}) {
145
+ const rootDir = path.resolve(projectRoot ?? process.cwd());
146
+ const matchers = await loadIgnoreMatchers(rootDir, ignore);
147
+ const scanned = await walk(rootDir, matchers, { filter });
148
+
149
+ const cached = await readCachedManifest(rootDir);
150
+ if (cached && snapshotMatches(cached.files, scanned)) {
151
+ return { files: cached.files, generatedAt: cached.generatedAt, fromCache: true };
152
+ }
153
+
154
+ const files = await Promise.all(scanned.map(async (entry) => {
155
+ let fileSha = null;
156
+ try {
157
+ const content = await fs.readFile(path.join(rootDir, entry.path));
158
+ fileSha = sha1(content);
159
+ } catch {
160
+ fileSha = null;
161
+ }
162
+ return { path: entry.path, sha1: fileSha, mtime: entry.mtime, size: entry.size };
163
+ }));
164
+
165
+ const manifest = { files, generatedAt: new Date().toISOString() };
166
+ try {
167
+ await fs.mkdir(getIndexDir(rootDir), { recursive: true });
168
+ await fs.writeFile(
169
+ path.join(getIndexDir(rootDir), MANIFEST_CACHE),
170
+ JSON.stringify({ files, generatedAt: manifest.generatedAt }, null, 2),
171
+ );
172
+ } catch {
173
+ // Cache write failure must not fail the scan.
174
+ }
175
+ return { ...manifest, fromCache: false };
176
+ }
@@ -0,0 +1,146 @@
1
+ // Context Packet v1 — shared envelope for every `ukit code` result (SPEC §5).
2
+ // JSON-serializable contract; no clock/random in static fields (CTX-04).
3
+
4
+ export const PACKET_VERSION = 1;
5
+
6
+ const EVIDENCE_LEVELS = new Set(['L0', 'L1', 'L2', 'L3', 'L4']);
7
+ const EVIDENCE_SOURCES = new Set(['index', 'file', 'git', 'memory']);
8
+
9
+ /**
10
+ * createPacket({ repo, snapshot, taskType, budget }) → packet
11
+ */
12
+ export function createPacket({ repo, snapshot, taskType, budget } = {}) {
13
+ const requested = budget && typeof budget.requested === 'number' ? budget.requested : 0;
14
+ return {
15
+ version: PACKET_VERSION,
16
+ repo: repo ?? '',
17
+ snapshot: snapshot ?? '',
18
+ task_type: taskType ?? '',
19
+ freshness: { level: 'L0', headSha: '', overlayHash: '', configHash: '', stale: false },
20
+ budget: { requested, used: 0 },
21
+ anchors: [],
22
+ evidence: [],
23
+ relations: [],
24
+ tests: [],
25
+ memory: [],
26
+ omitted: [],
27
+ next_actions: [],
28
+ };
29
+ }
30
+
31
+ function validateEvidenceItem(item, i, errors) {
32
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
33
+ errors.push(`evidence[${i}] must be an object`);
34
+ return;
35
+ }
36
+ if (!EVIDENCE_LEVELS.has(item.level)) errors.push(`evidence[${i}].level must be one of L0..L4`);
37
+ if (!EVIDENCE_SOURCES.has(item.source)) errors.push(`evidence[${i}].source must be one of index|file|git|memory`);
38
+ if (typeof item.why !== 'string' || item.why.length === 0) errors.push(`evidence[${i}].why is required`);
39
+ }
40
+
41
+ /**
42
+ * validatePacket(packet) → { ok, errors }
43
+ */
44
+ export function validatePacket(packet) {
45
+ const errors = [];
46
+ if (!packet || typeof packet !== 'object' || Array.isArray(packet)) {
47
+ return { ok: false, errors: ['packet must be an object'] };
48
+ }
49
+ if (packet.version !== PACKET_VERSION) errors.push(`version must be ${PACKET_VERSION}`);
50
+ if (typeof packet.repo !== 'string' || packet.repo.length === 0) errors.push('repo is required');
51
+ if (typeof packet.snapshot !== 'string' || packet.snapshot.length === 0) errors.push('snapshot is required');
52
+ if (typeof packet.task_type !== 'string') errors.push('task_type must be a string');
53
+ if (!packet.freshness || typeof packet.freshness !== 'object') {
54
+ errors.push('freshness object is required');
55
+ } else if (typeof packet.freshness.stale !== 'boolean') {
56
+ errors.push('freshness.stale must be a boolean');
57
+ }
58
+ if (!packet.budget || typeof packet.budget !== 'object') {
59
+ errors.push('budget object is required');
60
+ } else {
61
+ if (typeof packet.budget.requested !== 'number') errors.push('budget.requested must be a number');
62
+ if (typeof packet.budget.used !== 'number') errors.push('budget.used must be a number');
63
+ }
64
+ for (const key of ['anchors', 'evidence', 'relations', 'tests', 'memory', 'omitted', 'next_actions']) {
65
+ if (!Array.isArray(packet[key])) errors.push(`${key} must be an array`);
66
+ }
67
+ if (Array.isArray(packet.evidence)) {
68
+ packet.evidence.forEach((item, i) => validateEvidenceItem(item, i, errors));
69
+ }
70
+ return { ok: errors.length === 0, errors };
71
+ }
72
+
73
+ /**
74
+ * addEvidence(packet, evidence) → packet — enforces shape, then appends.
75
+ * Throws on invalid evidence; packet left unchanged.
76
+ */
77
+ export function addEvidence(packet, evidence) {
78
+ const errors = [];
79
+ validateEvidenceItem(evidence, 0, errors);
80
+ if (errors.length > 0) throw new Error(`invalid evidence: ${errors.join('; ')}`);
81
+ if (!Array.isArray(packet.evidence)) packet.evidence = [];
82
+ packet.evidence.push({ ...evidence });
83
+ return packet;
84
+ }
85
+
86
+ /**
87
+ * packetToText(packet) → string — deterministic human/LLM-facing render.
88
+ */
89
+ export function packetToText(packet) {
90
+ const lines = [];
91
+ lines.push('## Context Packet');
92
+ lines.push(`version: ${packet.version}`);
93
+ lines.push(`repo: ${packet.repo}`);
94
+ lines.push(`snapshot: ${packet.snapshot}`);
95
+ lines.push(`task_type: ${packet.task_type}`);
96
+ const f = packet.freshness || {};
97
+ lines.push(`freshness: level=${f.level ?? ''} stale: ${f.stale === true}`);
98
+ lines.push(`budget: ${packet.budget?.used ?? 0}/${packet.budget?.requested ?? 0}`);
99
+
100
+ lines.push('');
101
+ lines.push('## Anchors');
102
+ for (const a of packet.anchors ?? []) {
103
+ lines.push(`- ${a.path ?? ''}${typeof a.line === 'number' ? `:${a.line}` : ''}${a.symbol ? ` (${a.symbol})` : ''}`);
104
+ }
105
+ if ((packet.anchors ?? []).length === 0) lines.push('(none)');
106
+
107
+ lines.push('');
108
+ lines.push('## Evidence');
109
+ for (const e of packet.evidence ?? []) {
110
+ lines.push(`- [${e.level}/${e.source}] ${e.path ?? ''} — ${e.why ?? ''}`);
111
+ if (e.excerpt) lines.push(` ${e.excerpt}`);
112
+ }
113
+ if ((packet.evidence ?? []).length === 0) lines.push('(none)');
114
+
115
+ lines.push('');
116
+ lines.push('## Relations');
117
+ for (const r of packet.relations ?? []) {
118
+ lines.push(`- ${r.from ?? ''} -[${r.kind ?? ''}]-> ${r.to ?? ''} (confidence: ${r.confidence ?? 0}${r.provider ? `, ${r.provider}` : ''})`);
119
+ }
120
+ if ((packet.relations ?? []).length === 0) lines.push('(none)');
121
+
122
+ lines.push('');
123
+ lines.push('## Tests');
124
+ for (const t of packet.tests ?? []) lines.push(`- ${t}`);
125
+ if ((packet.tests ?? []).length === 0) lines.push('(none)');
126
+
127
+ lines.push('');
128
+ lines.push('## Omitted');
129
+ for (const o of packet.omitted ?? []) lines.push(`- ${o.what ?? ''} — ${o.why ?? ''}`);
130
+ if ((packet.omitted ?? []).length === 0) lines.push('(none)');
131
+
132
+ lines.push('');
133
+ lines.push('## Next Actions');
134
+ for (const n of packet.next_actions ?? []) {
135
+ if (n && typeof n === 'object') {
136
+ // Object form (SPEC §8): - [<kind>] <file(s)> — <message>
137
+ const target = Array.isArray(n.files) ? n.files.join(', ') : (n.file ?? '');
138
+ lines.push(`- [${n.kind ?? 'action'}] ${target} — ${n.message ?? n.why ?? ''}`);
139
+ } else {
140
+ lines.push(`- ${n}`);
141
+ }
142
+ }
143
+ if ((packet.next_actions ?? []).length === 0) lines.push('(none)');
144
+
145
+ return lines.join('\n');
146
+ }
@@ -0,0 +1,201 @@
1
+ import fs from 'node:fs';
2
+
3
+ import { getFileOutline } from '../../index/queryIndex.js';
4
+ import { getArtifactPath, INDEX_ARTIFACTS } from '../../index/paths.js';
5
+
6
+ /**
7
+ * Provider contracts (SPEC §8) — the future LSP adapter seam. Contract only, no new deps.
8
+ *
9
+ * SyntaxProvider (plain object or class instance):
10
+ * .name → string, unique across the registry
11
+ * .supports(filePath) → bool (sync, cheap — no I/O beyond an existence check)
12
+ * .symbols(filePath, src?) → Promise<[{ name, kind, line }]>
13
+ * .outline(filePath, src?) → Promise<[{ kind, line }]>
14
+ *
15
+ * SemanticProvider:
16
+ * .name → string
17
+ * .capabilities() → { defs: bool, refs: bool, types: bool }
18
+ * .resolve(symbol, ctx) → Promise<edge[] | null>
19
+ *
20
+ * Every returned edge carries: { from, to, kind, confidence, provider, evidence, snapshot }.
21
+ */
22
+
23
+ export class UnsupportedOperation extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'UnsupportedOperation';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Canonical edge shape for codeintel results (SPEC §8). `confidence` is 0..1,
32
+ * `provider` names the producing provider, `evidence` records where the edge came
33
+ * from (artifact, rule, probe), `snapshot` ties it to the index/content revision.
34
+ */
35
+ export function createEdge({ from, to, kind, confidence = 1, provider = 'unknown', evidence = null, snapshot = null } = {}) {
36
+ return Object.freeze({
37
+ from: from ?? null,
38
+ to: to ?? null,
39
+ kind: kind ?? 'unknown',
40
+ confidence: typeof confidence === 'number' ? confidence : 1,
41
+ provider,
42
+ evidence,
43
+ snapshot,
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Syntax provider backed by the existing `.cache/index/` artifacts — zero new
49
+ * parsing. Reads `symbols.json` through `getFileOutline` (`src/index/queryIndex.js`),
50
+ * which already handles the artifact cache, schema guard, and never-throws contract.
51
+ */
52
+ export class IndexFileSyntaxProvider {
53
+ constructor({ rootDir = process.cwd() } = {}) {
54
+ this.name = 'index-file';
55
+ this.rootDir = rootDir;
56
+ }
57
+
58
+ // Sync existence check only — the expensive read stays inside getFileOutline's
59
+ // cached artifact load, and a missing index must never throw.
60
+ supports(filePath) {
61
+ if (!filePath) {
62
+ return false;
63
+ }
64
+ try {
65
+ return fs.existsSync(getArtifactPath(this.rootDir, INDEX_ARTIFACTS.symbols));
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ async symbols(filePath) {
72
+ const outline = await this.#outline(filePath);
73
+ return outline.map((item) => ({
74
+ name: item.name,
75
+ kind: item.type,
76
+ line: item.line,
77
+ }));
78
+ }
79
+
80
+ async outline(filePath) {
81
+ const outline = await this.#outline(filePath);
82
+ return outline.map((item) => ({ kind: item.type, line: item.line }));
83
+ }
84
+
85
+ async #outline(filePath) {
86
+ if (!filePath) {
87
+ return [];
88
+ }
89
+ try {
90
+ return await getFileOutline({ rootDir: this.rootDir, filePath, limit: Number.MAX_SAFE_INTEGER });
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Default semantic provider: advertises no capabilities and resolves nothing.
99
+ * Keeps call sites uniform until a real provider (e.g. LspSemanticProvider, CI-2xx)
100
+ * registers over it.
101
+ */
102
+ export class NullSemanticProvider {
103
+ constructor() {
104
+ this.name = 'null';
105
+ }
106
+
107
+ capabilities() {
108
+ return { defs: false, refs: false, types: false };
109
+ }
110
+
111
+ async resolve() {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ const registeredProviders = [];
117
+ const registeredNames = new Set();
118
+ let semanticProvider = null;
119
+
120
+ /**
121
+ * Register a provider instance. Custom providers dispatch ahead of the built-in
122
+ * `index-file` provider (which is appended lazily so it is always the fallback).
123
+ * Duplicate names are rejected — a name collision would make dispatch ambiguous.
124
+ */
125
+ export function registerProvider(provider) {
126
+ if (!provider || typeof provider !== 'object') {
127
+ throw new TypeError('registerProvider expects a provider object');
128
+ }
129
+ if (typeof provider.name !== 'string' || provider.name.length === 0) {
130
+ throw new TypeError('provider.name must be a non-empty string');
131
+ }
132
+ if (registeredNames.has(provider.name)) {
133
+ throw new UnsupportedOperation(`provider already registered: ${provider.name}`);
134
+ }
135
+ registeredNames.add(provider.name);
136
+
137
+ if (typeof provider.resolve === 'function' && typeof provider.capabilities === 'function'
138
+ && typeof provider.symbols !== 'function') {
139
+ semanticProvider = provider;
140
+ return provider;
141
+ }
142
+
143
+ const indexFile = registeredProviders.find((entry) => entry.name === 'index-file');
144
+ if (indexFile) {
145
+ registeredProviders.splice(registeredProviders.indexOf(indexFile), 0, provider);
146
+ } else {
147
+ registeredProviders.push(provider);
148
+ }
149
+ return provider;
150
+ }
151
+
152
+ export function listProviders() {
153
+ ensureBuiltins();
154
+ return [...registeredProviders];
155
+ }
156
+
157
+ /**
158
+ * First registered provider whose `supports(filePath)` returns true, checked in
159
+ * registration order with `index-file` always last (the fallback).
160
+ */
161
+ export function getSyntaxProvider(filePath) {
162
+ ensureBuiltins();
163
+ for (const provider of registeredProviders) {
164
+ try {
165
+ if (provider.supports(filePath)) {
166
+ return provider;
167
+ }
168
+ } catch {
169
+ // A throwing supports() must not break dispatch — treat as unsupported.
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * The registered SemanticProvider, or NullSemanticProvider when none was
177
+ * registered (the `codeIntel.providers.semantic: "null"` default).
178
+ */
179
+ export function getSemanticProvider() {
180
+ return semanticProvider ?? new NullSemanticProvider();
181
+ }
182
+
183
+ let builtinsEnsured = false;
184
+ function ensureBuiltins() {
185
+ if (builtinsEnsured) {
186
+ return;
187
+ }
188
+ builtinsEnsured = true;
189
+ if (!registeredProviders.some((entry) => entry.name === 'index-file')) {
190
+ registeredProviders.push(new IndexFileSyntaxProvider({}));
191
+ registeredNames.add('index-file');
192
+ }
193
+ }
194
+
195
+ // Exported for tests: clears registry state back to defaults.
196
+ export function resetProvidersForTest() {
197
+ registeredProviders.length = 0;
198
+ registeredNames.clear();
199
+ semanticProvider = null;
200
+ builtinsEnsured = false;
201
+ }