gitnexus 1.6.11-rc.26 → 1.6.11-rc.28
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.
- package/README.md +1 -1
- package/dist/cli/ai-context.js +85 -11
- package/dist/cli/i18n/en.d.ts +1 -1
- package/dist/cli/i18n/en.js +1 -1
- package/dist/cli/i18n/resources.d.ts +1 -1
- package/dist/cli/i18n/zh-CN.js +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/setup.js +37 -10
- package/dist/core/incremental/spring-config-drift.d.ts +12 -0
- package/dist/core/incremental/spring-config-drift.js +46 -0
- package/dist/core/ingestion/frameworks/spring/config-bindings.d.ts +1 -0
- package/dist/core/ingestion/frameworks/spring/config-bindings.js +2 -1
- package/dist/core/ingestion/languages/java/analysis-features.d.ts +1 -1
- package/dist/core/ingestion/languages/java/analysis-features.js +14 -6
- package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +5 -0
- package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +16 -0
- package/dist/core/ingestion/languages/kotlin/captures.js +3 -1
- package/dist/core/ingestion/languages/kotlin/scope-resolver.js +2 -0
- package/dist/core/ingestion/languages/kotlin/spring-config-bindings.d.ts +18 -0
- package/dist/core/ingestion/languages/kotlin/spring-config-bindings.js +411 -0
- package/dist/core/run-analyze.js +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -240,7 +240,7 @@ gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS
|
|
|
240
240
|
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
|
241
241
|
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
|
|
242
242
|
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
|
243
|
-
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
|
243
|
+
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected)
|
|
244
244
|
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
|
|
245
245
|
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
|
246
246
|
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
|
package/dist/cli/ai-context.js
CHANGED
|
@@ -9,6 +9,7 @@ import fs from 'fs/promises';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { STANDARD_SKILL_CATALOG } from './standard-skills.js';
|
|
12
|
+
import { isEnoent } from './editor-targets.js';
|
|
12
13
|
import { logger } from '../core/logger.js';
|
|
13
14
|
// ESM equivalent of __dirname
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -321,6 +322,55 @@ export async function shouldMirrorSkillsToAgents(repoPath) {
|
|
|
321
322
|
return false;
|
|
322
323
|
}
|
|
323
324
|
}
|
|
325
|
+
const SKILL_PRESERVE_HINT = 'delete the file to refresh from the bundled template, or pass --skip-skills to skip skill install';
|
|
326
|
+
async function readUtf8IfPresent(filePath) {
|
|
327
|
+
try {
|
|
328
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
if (isEnoent(err))
|
|
332
|
+
return null;
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function skillBytesDiverge(existing, bundled) {
|
|
337
|
+
return existing !== null && existing !== bundled;
|
|
338
|
+
}
|
|
339
|
+
/** Write bundled skill bytes unless an existing file already differs. */
|
|
340
|
+
async function writeSkillUnlessDivergent(filePath, content) {
|
|
341
|
+
const existing = await readUtf8IfPresent(filePath);
|
|
342
|
+
if (skillBytesDiverge(existing, content)) {
|
|
343
|
+
logger.warn(`Preserved customized skill ${filePath}; ${SKILL_PRESERVE_HINT}.`);
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
347
|
+
await fs.writeFile(filePath, content, 'utf-8');
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
async function inspectLegacySkillDir(legacyDir) {
|
|
351
|
+
let entries;
|
|
352
|
+
try {
|
|
353
|
+
entries = await fs.readdir(legacyDir);
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
if (isEnoent(err))
|
|
357
|
+
return null;
|
|
358
|
+
throw err;
|
|
359
|
+
}
|
|
360
|
+
const nestedExisting = entries.includes('SKILL.md')
|
|
361
|
+
? await fs.readFile(path.join(legacyDir, 'SKILL.md'), 'utf-8')
|
|
362
|
+
: null;
|
|
363
|
+
return {
|
|
364
|
+
nestedExisting,
|
|
365
|
+
hasSiblings: entries.some((entry) => entry !== 'SKILL.md'),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function formatSkillInstallLine(prefix, total, preserved, allWrittenSuffix, partialSuffix) {
|
|
369
|
+
if (preserved > 0) {
|
|
370
|
+
return `${prefix} (${total - preserved} written, ${preserved} ${partialSuffix})`;
|
|
371
|
+
}
|
|
372
|
+
return `${prefix} (${total} ${allWrittenSuffix})`;
|
|
373
|
+
}
|
|
324
374
|
/**
|
|
325
375
|
* Install GitNexus skills as direct children of .claude/skills/
|
|
326
376
|
* Works natively with Claude Code, Cursor, and GitHub Copilot.
|
|
@@ -330,13 +380,14 @@ async function installSkills(repoPath) {
|
|
|
330
380
|
const skillsDir = path.join(repoPath, '.claude', 'skills');
|
|
331
381
|
const legacySkillsDir = path.join(skillsDir, 'gitnexus');
|
|
332
382
|
const installedSkills = [];
|
|
383
|
+
let claudePreserved = 0;
|
|
384
|
+
let agentsPreserved = 0;
|
|
385
|
+
let legacyPreserved = 0;
|
|
333
386
|
const agentsMirror = await shouldMirrorSkillsToAgents(repoPath);
|
|
334
387
|
for (const skill of STANDARD_SKILL_CATALOG.filter((entry) => entry.distributions.project && entry.distributions.npm)) {
|
|
335
388
|
const skillDir = path.join(skillsDir, skill.name);
|
|
336
389
|
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
337
390
|
try {
|
|
338
|
-
// Create skill directory
|
|
339
|
-
await fs.mkdir(skillDir, { recursive: true });
|
|
340
391
|
// Try to read from package skills directory
|
|
341
392
|
const packageSkillPath = path.join(__dirname, '..', '..', 'skills', `${skill.name}.md`);
|
|
342
393
|
let skillContent;
|
|
@@ -357,13 +408,14 @@ ${skill.fallbackDescription}
|
|
|
357
408
|
Use GitNexus tools to accomplish this task.
|
|
358
409
|
`;
|
|
359
410
|
}
|
|
360
|
-
await
|
|
411
|
+
if (await writeSkillUnlessDivergent(skillPath, skillContent))
|
|
412
|
+
claudePreserved += 1;
|
|
361
413
|
// Mirror to .agents/skills/ for agents that read repo-local skills
|
|
362
414
|
if (agentsMirror) {
|
|
363
415
|
try {
|
|
364
|
-
const
|
|
365
|
-
await
|
|
366
|
-
|
|
416
|
+
const agentsSkillPath = path.join(repoPath, '.agents', 'skills', skill.name, 'SKILL.md');
|
|
417
|
+
if (await writeSkillUnlessDivergent(agentsSkillPath, skillContent))
|
|
418
|
+
agentsPreserved += 1;
|
|
367
419
|
}
|
|
368
420
|
catch (err) {
|
|
369
421
|
logger.warn({ err }, `Warning: Could not mirror skill ${skill.name} to .agents/skills:`);
|
|
@@ -374,7 +426,20 @@ Use GitNexus tools to accomplish this task.
|
|
|
374
426
|
// deep. Remove only the child owned by this installer; unknown siblings
|
|
375
427
|
// under the legacy grouping directory may be user-authored and survive.
|
|
376
428
|
try {
|
|
377
|
-
|
|
429
|
+
const legacyDir = path.join(legacySkillsDir, skill.name);
|
|
430
|
+
const nestedSkill = path.join(legacyDir, 'SKILL.md');
|
|
431
|
+
const leftover = await inspectLegacySkillDir(legacyDir);
|
|
432
|
+
if (leftover !== null && skillBytesDiverge(leftover.nestedExisting, skillContent)) {
|
|
433
|
+
logger.warn(`Preserved customized skill ${nestedSkill}; ${SKILL_PRESERVE_HINT}.`);
|
|
434
|
+
legacyPreserved += 1;
|
|
435
|
+
}
|
|
436
|
+
else if (leftover?.hasSiblings) {
|
|
437
|
+
logger.warn(`Preserved legacy skill directory ${legacyDir} because it contains operator-owned files.`);
|
|
438
|
+
legacyPreserved += 1;
|
|
439
|
+
}
|
|
440
|
+
else if (leftover !== null) {
|
|
441
|
+
await fs.rm(legacyDir, { recursive: true, force: true });
|
|
442
|
+
}
|
|
378
443
|
}
|
|
379
444
|
catch (err) {
|
|
380
445
|
logger.warn({ err }, `Warning: Could not remove legacy skill ${skill.name}:`);
|
|
@@ -385,7 +450,13 @@ Use GitNexus tools to accomplish this task.
|
|
|
385
450
|
logger.warn({ err }, `Warning: Could not install skill ${skill.name}:`);
|
|
386
451
|
}
|
|
387
452
|
}
|
|
388
|
-
return {
|
|
453
|
+
return {
|
|
454
|
+
skills: installedSkills,
|
|
455
|
+
agentsMirror,
|
|
456
|
+
claudePreserved,
|
|
457
|
+
agentsPreserved,
|
|
458
|
+
legacyPreserved,
|
|
459
|
+
};
|
|
389
460
|
}
|
|
390
461
|
/**
|
|
391
462
|
* Generate AI context files after indexing
|
|
@@ -434,11 +505,14 @@ export async function generateAIContextFiles(repoPath, storagePath, projectName,
|
|
|
434
505
|
}
|
|
435
506
|
// Install standard skills directly under .claude/skills/ (unless --skip-skills)
|
|
436
507
|
if (!options?.skipSkills) {
|
|
437
|
-
const { skills: installedSkills, agentsMirror } = await installSkills(repoPath);
|
|
508
|
+
const { skills: installedSkills, agentsMirror, claudePreserved, agentsPreserved, legacyPreserved, } = await installSkills(repoPath);
|
|
438
509
|
if (installedSkills.length > 0) {
|
|
439
|
-
createdFiles.push(
|
|
510
|
+
createdFiles.push(formatSkillInstallLine('.claude/skills/gitnexus-*/', installedSkills.length, claudePreserved, 'skills', 'preserved'));
|
|
440
511
|
if (agentsMirror) {
|
|
441
|
-
createdFiles.push(
|
|
512
|
+
createdFiles.push(formatSkillInstallLine('.agents/skills/gitnexus-*/', installedSkills.length, agentsPreserved, 'skills mirrored for .agents', 'preserved for .agents'));
|
|
513
|
+
}
|
|
514
|
+
if (legacyPreserved > 0) {
|
|
515
|
+
createdFiles.push(`.claude/skills/gitnexus/<name>/ (legacy directories preserved: ${legacyPreserved})`);
|
|
442
516
|
}
|
|
443
517
|
}
|
|
444
518
|
}
|
package/dist/cli/i18n/en.d.ts
CHANGED
|
@@ -162,7 +162,7 @@ export declare const en: {
|
|
|
162
162
|
readonly 'help.option.analyze.embeddings': "Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.";
|
|
163
163
|
readonly 'help.option.analyze.dropEmbeddings': "Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves any embeddings already present in the index.";
|
|
164
164
|
readonly 'help.option.analyze.skills': "Generate repo-specific skill files from detected communities (no-op when --index-only is also set).";
|
|
165
|
-
readonly 'help.option.analyze.skipAgentsMd': "Skip updating the gitnexus section in AGENTS.md and CLAUDE.md";
|
|
165
|
+
readonly 'help.option.analyze.skipAgentsMd': "Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.";
|
|
166
166
|
readonly 'help.option.analyze.noStats': "Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md";
|
|
167
167
|
readonly 'help.option.analyze.selfCommit': "Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.";
|
|
168
168
|
readonly 'help.option.analyze.skipSkills': "Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.";
|
package/dist/cli/i18n/en.js
CHANGED
|
@@ -164,7 +164,7 @@ export const en = {
|
|
|
164
164
|
'help.option.analyze.embeddings': 'Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
|
|
165
165
|
'help.option.analyze.dropEmbeddings': 'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves any embeddings already present in the index.',
|
|
166
166
|
'help.option.analyze.skills': 'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
|
|
167
|
-
'help.option.analyze.skipAgentsMd': 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
|
|
167
|
+
'help.option.analyze.skipAgentsMd': 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.',
|
|
168
168
|
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
|
|
169
169
|
'help.option.analyze.selfCommit': 'Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.',
|
|
170
170
|
'help.option.analyze.skipSkills': 'Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.',
|
|
@@ -163,7 +163,7 @@ export declare const cliResources: {
|
|
|
163
163
|
readonly 'help.option.analyze.embeddings': "Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.";
|
|
164
164
|
readonly 'help.option.analyze.dropEmbeddings': "Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves any embeddings already present in the index.";
|
|
165
165
|
readonly 'help.option.analyze.skills': "Generate repo-specific skill files from detected communities (no-op when --index-only is also set).";
|
|
166
|
-
readonly 'help.option.analyze.skipAgentsMd': "Skip updating the gitnexus section in AGENTS.md and CLAUDE.md";
|
|
166
|
+
readonly 'help.option.analyze.skipAgentsMd': "Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.";
|
|
167
167
|
readonly 'help.option.analyze.noStats': "Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md";
|
|
168
168
|
readonly 'help.option.analyze.selfCommit': "Auto-commit AGENTS.md/CLAUDE.md changes after analyze (opt-in, off by default). Scoped to only those two files (never `git add -A`); no-ops if neither exists, neither changed, or the repo has no git identity configured.";
|
|
169
169
|
readonly 'help.option.analyze.skipSkills': "Skip installing standard GitNexus skill files directly under .claude/skills/ and .agents/skills/. Does not suppress community skills from --skills (those use .claude/skills/gitnexus-area-*). Use --index-only to skip all AI-context file injection.";
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -162,7 +162,7 @@ export const zhCN = {
|
|
|
162
162
|
'help.option.analyze.embeddings': '启用语义搜索的嵌入生成(默认关闭)。可选 [limit] 覆盖 50,000 节点安全上限;传 0 可完全禁用上限。',
|
|
163
163
|
'help.option.analyze.dropEmbeddings': '重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
|
|
164
164
|
'help.option.analyze.skills': '根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
|
|
165
|
-
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus
|
|
165
|
+
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块。不会跳过 .claude/skills 或 .agents/skills 下的标准 skill;如需跳过那些请使用 --skip-skills。--skills 生成的社区 skill 不受影响。',
|
|
166
166
|
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
|
|
167
167
|
'help.option.analyze.selfCommit': '在 analyze 后自动提交 AGENTS.md/CLAUDE.md 的变更(默认关闭,需显式开启)。仅限这两个文件(绝不使用 `git add -A`);若两者均不存在、均未变更,或仓库未配置 git 身份,则不执行任何操作。',
|
|
168
168
|
'help.option.analyze.skipSkills': '跳过直接安装在 .claude/skills/ 和 .agents/skills/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill(位于 .claude/skills/gitnexus-area-*)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
|
package/dist/cli/index.js
CHANGED
|
@@ -46,7 +46,7 @@ program
|
|
|
46
46
|
'preserves any embeddings already present in the index.')
|
|
47
47
|
.option('--skills', 'Generate repo-specific skill files from detected communities ' +
|
|
48
48
|
'(no-op when --index-only is also set).')
|
|
49
|
-
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
|
|
49
|
+
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md. Does not skip standard skills in .claude/skills or .agents/skills; use --skip-skills for those. Community skills from --skills are unaffected.')
|
|
50
50
|
.option('--pdg', 'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
|
|
51
51
|
'for supported languages. Opt-in; off by default. (#2081 M1)')
|
|
52
52
|
.option('--default-branch <branch>', 'Default branch used in the generated regression-compare example (base_ref). ' +
|
package/dist/cli/setup.js
CHANGED
|
@@ -947,15 +947,29 @@ async function installSkillsTo(targetDir) {
|
|
|
947
947
|
for (const [skillName, source] of skillSources) {
|
|
948
948
|
const skillDir = path.join(targetDir, skillName);
|
|
949
949
|
try {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
950
|
+
const sourceSkillPath = source.isDirectory
|
|
951
|
+
? path.join(skillsRoot, skillName, 'SKILL.md')
|
|
952
|
+
: path.join(skillsRoot, `${skillName}.md`);
|
|
953
|
+
const destinationSkillPath = path.join(skillDir, 'SKILL.md');
|
|
954
|
+
const [sourceSkillContent, destinationSkillContent] = await Promise.all([
|
|
955
|
+
fs.readFile(sourceSkillPath, 'utf-8'),
|
|
956
|
+
fs.readFile(destinationSkillPath, 'utf-8').catch((err) => {
|
|
957
|
+
if (!isEnoent(err))
|
|
958
|
+
throw err;
|
|
959
|
+
return null;
|
|
960
|
+
}),
|
|
961
|
+
]);
|
|
962
|
+
const preserved = destinationSkillContent !== null && destinationSkillContent !== sourceSkillContent;
|
|
963
|
+
if (preserved && !source.isDirectory) {
|
|
964
|
+
console.log(`[gitnexus] preserved customized skill ${destinationSkillPath}; ` +
|
|
965
|
+
'delete the file and rerun setup to refresh it.');
|
|
953
966
|
}
|
|
954
|
-
else {
|
|
955
|
-
|
|
956
|
-
|
|
967
|
+
else if (source.isDirectory) {
|
|
968
|
+
await copyDirRecursive(path.join(skillsRoot, skillName), skillDir);
|
|
969
|
+
}
|
|
970
|
+
else if (!preserved) {
|
|
957
971
|
await fs.mkdir(skillDir, { recursive: true });
|
|
958
|
-
await fs.writeFile(
|
|
972
|
+
await fs.writeFile(destinationSkillPath, sourceSkillContent, 'utf-8');
|
|
959
973
|
}
|
|
960
974
|
// A directory superseded by a shipped rename is warned about, never
|
|
961
975
|
// deleted: the installer cannot prove it owns the contents (users
|
|
@@ -968,7 +982,8 @@ async function installSkillsTo(targetDir) {
|
|
|
968
982
|
`left ${legacyDir} in place — delete it manually if you have not customized it.`);
|
|
969
983
|
}
|
|
970
984
|
}
|
|
971
|
-
|
|
985
|
+
if (!preserved)
|
|
986
|
+
installed.push(skillName);
|
|
972
987
|
}
|
|
973
988
|
catch {
|
|
974
989
|
// Source skill not found — skip
|
|
@@ -987,10 +1002,22 @@ async function copyDirRecursive(src, dest) {
|
|
|
987
1002
|
const destPath = path.join(dest, entry.name);
|
|
988
1003
|
if (entry.isDirectory()) {
|
|
989
1004
|
await copyDirRecursive(srcPath, destPath);
|
|
1005
|
+
continue;
|
|
990
1006
|
}
|
|
991
|
-
|
|
992
|
-
|
|
1007
|
+
const [srcBuf, destBuf] = await Promise.all([
|
|
1008
|
+
fs.readFile(srcPath),
|
|
1009
|
+
fs.readFile(destPath).catch((err) => {
|
|
1010
|
+
if (!isEnoent(err))
|
|
1011
|
+
throw err;
|
|
1012
|
+
return null;
|
|
1013
|
+
}),
|
|
1014
|
+
]);
|
|
1015
|
+
if (destBuf !== null && !destBuf.equals(srcBuf)) {
|
|
1016
|
+
console.log(`[gitnexus] preserved customized skill ${destPath}; ` +
|
|
1017
|
+
'delete the file and rerun setup to refresh it.');
|
|
1018
|
+
continue;
|
|
993
1019
|
}
|
|
1020
|
+
await fs.writeFile(destPath, srcBuf);
|
|
994
1021
|
}
|
|
995
1022
|
}
|
|
996
1023
|
/**
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { KnowledgeGraph } from '../graph/types.js';
|
|
2
|
+
export interface PersistedSpringConfigConsumerRow {
|
|
3
|
+
readonly id?: unknown;
|
|
4
|
+
readonly description?: unknown;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Find unchanged Spring consumer files whose unresolved markers changed.
|
|
8
|
+
*
|
|
9
|
+
* A removed config key also removes the old USES edge from the fresh graph, so
|
|
10
|
+
* ordinary new-graph boundary expansion cannot discover the consumer file.
|
|
11
|
+
*/
|
|
12
|
+
export declare function collectSpringConfigConsumerDriftFiles(graph: KnowledgeGraph, persistedRows: readonly PersistedSpringConfigConsumerRow[]): Set<string>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SPRING_CONFIG_UNRESOLVED_PREFIX } from '../ingestion/frameworks/spring/config-bindings.js';
|
|
2
|
+
const CONSUMER_LABELS = new Set(['Property', 'Class', 'Record']);
|
|
3
|
+
function unresolvedKeys(description) {
|
|
4
|
+
if (typeof description !== 'string')
|
|
5
|
+
return [];
|
|
6
|
+
return description
|
|
7
|
+
.split(';')
|
|
8
|
+
.map((part) => part.trim())
|
|
9
|
+
.filter((part) => part.startsWith(SPRING_CONFIG_UNRESOLVED_PREFIX))
|
|
10
|
+
.map((part) => part.slice(SPRING_CONFIG_UNRESOLVED_PREFIX.length))
|
|
11
|
+
.sort();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Find unchanged Spring consumer files whose unresolved markers changed.
|
|
15
|
+
*
|
|
16
|
+
* A removed config key also removes the old USES edge from the fresh graph, so
|
|
17
|
+
* ordinary new-graph boundary expansion cannot discover the consumer file.
|
|
18
|
+
*/
|
|
19
|
+
export function collectSpringConfigConsumerDriftFiles(graph, persistedRows) {
|
|
20
|
+
const persistedById = new Map();
|
|
21
|
+
for (const row of persistedRows) {
|
|
22
|
+
if (typeof row.id !== 'string')
|
|
23
|
+
continue;
|
|
24
|
+
persistedById.set(row.id, unresolvedKeys(row.description));
|
|
25
|
+
}
|
|
26
|
+
const driftFiles = new Set();
|
|
27
|
+
graph.forEachNode((node) => {
|
|
28
|
+
if (!CONSUMER_LABELS.has(node.label))
|
|
29
|
+
return;
|
|
30
|
+
const filePath = node.properties.filePath;
|
|
31
|
+
if (typeof filePath !== 'string')
|
|
32
|
+
return;
|
|
33
|
+
const description = node.properties.description;
|
|
34
|
+
const persisted = persistedById.get(node.id);
|
|
35
|
+
if (persisted === undefined &&
|
|
36
|
+
(typeof description !== 'string' || !description.includes(SPRING_CONFIG_UNRESOLVED_PREFIX))) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const current = unresolvedKeys(description);
|
|
40
|
+
const prior = persisted ?? [];
|
|
41
|
+
if (current.length !== prior.length || current.some((key, index) => key !== prior[index])) {
|
|
42
|
+
driftFiles.add(filePath);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return driftFiles;
|
|
46
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { KnowledgeGraph } from '../../../graph/types.js';
|
|
2
2
|
export declare const SPRING_CONFIG_DESCRIPTION = "Spring configuration property";
|
|
3
|
+
export declare const SPRING_CONFIG_UNRESOLVED_PREFIX = "Spring config unresolved: ";
|
|
3
4
|
export interface SpringValueConsumer {
|
|
4
5
|
readonly kind: 'value';
|
|
5
6
|
readonly fieldName: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { generateId } from '../../../../lib/utils.js';
|
|
2
2
|
export const SPRING_CONFIG_DESCRIPTION = 'Spring configuration property';
|
|
3
|
+
export const SPRING_CONFIG_UNRESOLVED_PREFIX = 'Spring config unresolved: ';
|
|
3
4
|
function closestNode(candidates, filePath, name, line) {
|
|
4
5
|
return candidates
|
|
5
6
|
.filter((node) => node.properties.filePath === filePath && node.properties.name === name)
|
|
@@ -7,7 +8,7 @@ function closestNode(candidates, filePath, name, line) {
|
|
|
7
8
|
Math.abs(Number(right.properties.startLine ?? 0) - line))[0];
|
|
8
9
|
}
|
|
9
10
|
function markUnresolved(node, key) {
|
|
10
|
-
const marker =
|
|
11
|
+
const marker = `${SPRING_CONFIG_UNRESOLVED_PREFIX}${key}`;
|
|
11
12
|
const existing = typeof node.properties.description === 'string' ? node.properties.description : '';
|
|
12
13
|
if (existing.includes(marker))
|
|
13
14
|
return;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnalysisFeatureDescriptor } from '../../../analysis-features.js';
|
|
2
|
-
/** Durable completeness contract for Java Spring configuration bindings. */
|
|
2
|
+
/** Durable completeness contract for Java and Kotlin Spring configuration bindings. */
|
|
3
3
|
export declare const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor;
|
|
4
4
|
/** Durable completeness contract for implicit Java record-component accessors. */
|
|
5
5
|
export declare const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE: AnalysisFeatureDescriptor;
|
|
@@ -2,14 +2,22 @@ function isSpringApplicationConfig(filePath) {
|
|
|
2
2
|
const base = filePath.replaceAll('\\', '/').split('/').pop() ?? '';
|
|
3
3
|
return /^application(?:-[^.]+)?\.(?:properties|ya?ml)$/i.test(base);
|
|
4
4
|
}
|
|
5
|
-
/** Durable completeness contract for Java Spring configuration bindings. */
|
|
5
|
+
/** Durable completeness contract for Java and Kotlin Spring configuration bindings. */
|
|
6
6
|
export const SPRING_CONFIG_BINDINGS_FEATURE = {
|
|
7
7
|
id: 'spring.config-bindings',
|
|
8
|
-
version:
|
|
9
|
-
// Java sources need consumer extraction even without config files
|
|
10
|
-
// placeholders still get unresolved markers). Config-only
|
|
11
|
-
// need a one-time rebuild to backfill language-agnostic
|
|
12
|
-
|
|
8
|
+
version: 2,
|
|
9
|
+
// Java and Kotlin sources need consumer extraction even without config files
|
|
10
|
+
// (missing placeholders still get unresolved markers). Config-only
|
|
11
|
+
// repositories also need a one-time rebuild to backfill language-agnostic
|
|
12
|
+
// Property nodes. Gradle Kotlin DSL is not a consumer source.
|
|
13
|
+
appliesTo: (filePaths) => filePaths.some((filePath) => {
|
|
14
|
+
const normalized = filePath.replaceAll('\\', '/').toLowerCase();
|
|
15
|
+
if (normalized.endsWith('.gradle.kts'))
|
|
16
|
+
return false;
|
|
17
|
+
return (normalized.endsWith('.java') ||
|
|
18
|
+
normalized.endsWith('.kt') ||
|
|
19
|
+
isSpringApplicationConfig(filePath));
|
|
20
|
+
}),
|
|
13
21
|
};
|
|
14
22
|
/** Durable completeness contract for implicit Java record-component accessors. */
|
|
15
23
|
export const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE = {
|
|
@@ -44,6 +44,7 @@ import type { KotlinSpringAopFact } from './spring-aop.js';
|
|
|
44
44
|
import type { KotlinSpringConditionalFact } from './spring-conditionals.js';
|
|
45
45
|
import type { KotlinSpringDiClassFact } from './spring-di.js';
|
|
46
46
|
import type { KotlinSpringNonHttpHandlerFact } from './spring-non-http-handlers.js';
|
|
47
|
+
import type { KotlinSpringConfigConsumerFact } from './spring-config-bindings.js';
|
|
47
48
|
/**
|
|
48
49
|
* Plain JSON-serializable snapshot of the per-file Kotlin capture-time
|
|
49
50
|
* side-channel. Carried opaquely on `ParsedFile.captureSideChannel`. The
|
|
@@ -68,6 +69,8 @@ export interface KotlinCaptureSideChannel {
|
|
|
68
69
|
readonly springDynamicLookupFacts?: readonly SpringDynamicLookupFact[];
|
|
69
70
|
/** Scheduled, event, messaging, and managed-job handler syntax captured per callable. */
|
|
70
71
|
readonly springNonHttpHandlerFacts?: readonly KotlinSpringNonHttpHandlerFact[];
|
|
72
|
+
/** `@Value` / `@ConfigurationProperties` syntax captured per owner. */
|
|
73
|
+
readonly springConfigConsumerFacts?: readonly KotlinSpringConfigConsumerFact[];
|
|
71
74
|
}
|
|
72
75
|
export declare function clearKotlinClassAnnotationFacts(): void;
|
|
73
76
|
export declare function setKotlinSpringAopFacts(filePath: string, facts: readonly KotlinSpringAopFact[]): void;
|
|
@@ -82,6 +85,8 @@ export declare function setKotlinSpringDynamicLookupFacts(filePath: string, fact
|
|
|
82
85
|
export declare function getKotlinSpringDynamicLookupFacts(filePath: string): readonly SpringDynamicLookupFact[];
|
|
83
86
|
export declare function setKotlinSpringNonHttpHandlerFacts(filePath: string, facts: readonly KotlinSpringNonHttpHandlerFact[]): void;
|
|
84
87
|
export declare function getKotlinSpringNonHttpHandlerFacts(filePath: string): readonly KotlinSpringNonHttpHandlerFact[];
|
|
88
|
+
export declare function setKotlinSpringConfigConsumerFacts(filePath: string, facts: readonly KotlinSpringConfigConsumerFact[]): void;
|
|
89
|
+
export declare function getKotlinSpringConfigConsumerFacts(filePath: string): readonly KotlinSpringConfigConsumerFact[];
|
|
85
90
|
/**
|
|
86
91
|
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
|
|
87
92
|
* Returns `undefined` when this file recorded no side-channel state at all, so
|
|
@@ -46,6 +46,7 @@ const springConditionalFacts = new Map();
|
|
|
46
46
|
const springDiFacts = new Map();
|
|
47
47
|
const springDynamicLookupFacts = new Map();
|
|
48
48
|
const springNonHttpHandlerFacts = new Map();
|
|
49
|
+
const springConfigConsumerFacts = new Map();
|
|
49
50
|
export function clearKotlinClassAnnotationFacts() {
|
|
50
51
|
classAnnotations.clear();
|
|
51
52
|
springAopFacts.clear();
|
|
@@ -53,6 +54,7 @@ export function clearKotlinClassAnnotationFacts() {
|
|
|
53
54
|
springDiFacts.clear();
|
|
54
55
|
springDynamicLookupFacts.clear();
|
|
55
56
|
springNonHttpHandlerFacts.clear();
|
|
57
|
+
springConfigConsumerFacts.clear();
|
|
56
58
|
}
|
|
57
59
|
export function setKotlinSpringAopFacts(filePath, facts) {
|
|
58
60
|
if (facts.length === 0)
|
|
@@ -105,6 +107,15 @@ export function setKotlinSpringNonHttpHandlerFacts(filePath, facts) {
|
|
|
105
107
|
export function getKotlinSpringNonHttpHandlerFacts(filePath) {
|
|
106
108
|
return springNonHttpHandlerFacts.get(filePath) ?? [];
|
|
107
109
|
}
|
|
110
|
+
export function setKotlinSpringConfigConsumerFacts(filePath, facts) {
|
|
111
|
+
if (facts.length === 0)
|
|
112
|
+
springConfigConsumerFacts.delete(filePath);
|
|
113
|
+
else
|
|
114
|
+
springConfigConsumerFacts.set(filePath, facts);
|
|
115
|
+
}
|
|
116
|
+
export function getKotlinSpringConfigConsumerFacts(filePath) {
|
|
117
|
+
return springConfigConsumerFacts.get(filePath) ?? [];
|
|
118
|
+
}
|
|
108
119
|
/**
|
|
109
120
|
* `LanguageProvider.collectCaptureSideChannel` implementation for Kotlin.
|
|
110
121
|
* Returns `undefined` when this file recorded no side-channel state at all, so
|
|
@@ -118,6 +129,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
|
|
|
118
129
|
const diFacts = springDiFacts.get(filePath) ?? [];
|
|
119
130
|
const dynamicLookupFacts = springDynamicLookupFacts.get(filePath) ?? [];
|
|
120
131
|
const nonHttpHandlerFacts = springNonHttpHandlerFacts.get(filePath) ?? [];
|
|
132
|
+
const configConsumerFacts = springConfigConsumerFacts.get(filePath) ?? [];
|
|
121
133
|
const packageFact = getKotlinPackageFact(filePath);
|
|
122
134
|
if (companionScopes.length === 0 &&
|
|
123
135
|
annotationFacts.length === 0 &&
|
|
@@ -126,6 +138,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
|
|
|
126
138
|
diFacts.length === 0 &&
|
|
127
139
|
dynamicLookupFacts.length === 0 &&
|
|
128
140
|
nonHttpHandlerFacts.length === 0 &&
|
|
141
|
+
configConsumerFacts.length === 0 &&
|
|
129
142
|
packageFact === undefined) {
|
|
130
143
|
return undefined;
|
|
131
144
|
}
|
|
@@ -139,6 +152,7 @@ export function collectKotlinCaptureSideChannel(filePath) {
|
|
|
139
152
|
...(diFacts.length > 0 ? { springDiFacts: diFacts } : {}),
|
|
140
153
|
...(dynamicLookupFacts.length > 0 ? { springDynamicLookupFacts: dynamicLookupFacts } : {}),
|
|
141
154
|
...(nonHttpHandlerFacts.length > 0 ? { springNonHttpHandlerFacts: nonHttpHandlerFacts } : {}),
|
|
155
|
+
...(configConsumerFacts.length > 0 ? { springConfigConsumerFacts: configConsumerFacts } : {}),
|
|
142
156
|
};
|
|
143
157
|
}
|
|
144
158
|
/**
|
|
@@ -163,6 +177,7 @@ export function applyKotlinCaptureSideChannel(parsed) {
|
|
|
163
177
|
setKotlinSpringDiFacts(parsed.filePath, []);
|
|
164
178
|
setKotlinSpringDynamicLookupFacts(parsed.filePath, []);
|
|
165
179
|
setKotlinSpringNonHttpHandlerFacts(parsed.filePath, []);
|
|
180
|
+
setKotlinSpringConfigConsumerFacts(parsed.filePath, []);
|
|
166
181
|
setKotlinPackageFact(parsed.filePath, UNKNOWN_JVM_PACKAGE_FACT);
|
|
167
182
|
return;
|
|
168
183
|
}
|
|
@@ -175,5 +190,6 @@ export function applyKotlinCaptureSideChannel(parsed) {
|
|
|
175
190
|
setKotlinSpringDiFacts(parsed.filePath, Array.isArray(data.springDiFacts) ? data.springDiFacts : []);
|
|
176
191
|
setKotlinSpringDynamicLookupFacts(parsed.filePath, Array.isArray(data.springDynamicLookupFacts) ? data.springDynamicLookupFacts : []);
|
|
177
192
|
setKotlinSpringNonHttpHandlerFacts(parsed.filePath, Array.isArray(data.springNonHttpHandlerFacts) ? data.springNonHttpHandlerFacts : []);
|
|
193
|
+
setKotlinSpringConfigConsumerFacts(parsed.filePath, Array.isArray(data.springConfigConsumerFacts) ? data.springConfigConsumerFacts : []);
|
|
178
194
|
setKotlinPackageFact(parsed.filePath, isJvmPackageFact(data.packageFact) ? data.packageFact : UNKNOWN_JVM_PACKAGE_FACT);
|
|
179
195
|
}
|
|
@@ -10,11 +10,12 @@ import { normalizeKotlinType } from './interpret.js';
|
|
|
10
10
|
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
|
|
11
11
|
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
|
|
12
12
|
import { markCompanionScope } from './companion-scopes.js';
|
|
13
|
-
import { setKotlinClassAnnotationFacts, setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringDynamicLookupFacts, setKotlinSpringNonHttpHandlerFacts, } from './capture-side-channel.js';
|
|
13
|
+
import { setKotlinClassAnnotationFacts, setKotlinSpringAopFacts, setKotlinSpringConditionalFacts, setKotlinSpringDiFacts, setKotlinSpringDynamicLookupFacts, setKotlinSpringNonHttpHandlerFacts, setKotlinSpringConfigConsumerFacts, } from './capture-side-channel.js';
|
|
14
14
|
import { captureKotlinPackageFact } from './package-facts.js';
|
|
15
15
|
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
|
|
16
16
|
import { synthesizeLombokAccessorCaptures } from './lombok-synthesizer.js';
|
|
17
17
|
import { captureKotlinSpringDiClassFact } from './spring-di.js';
|
|
18
|
+
import { captureKotlinSpringConfigConsumerFacts } from './spring-config-bindings.js';
|
|
18
19
|
import { captureKotlinSpringDynamicLookupFact } from './spring-dynamic-lookup.js';
|
|
19
20
|
import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captures.js';
|
|
20
21
|
import { captureKotlinSpringAopFacts } from './spring-aop.js';
|
|
@@ -292,6 +293,7 @@ export function emitKotlinScopeCaptures(sourceText, filePath, cachedTree) {
|
|
|
292
293
|
setKotlinSpringDiFacts(filePath, springDiFacts);
|
|
293
294
|
setKotlinSpringDynamicLookupFacts(filePath, springDynamicLookupFacts);
|
|
294
295
|
setKotlinSpringNonHttpHandlerFacts(filePath, springNonHttpHandlerFacts);
|
|
296
|
+
setKotlinSpringConfigConsumerFacts(filePath, captureKotlinSpringConfigConsumerFacts(tree.rootNode, filePath));
|
|
295
297
|
out.push(...synthesizeLombokAccessorCaptures(tree.rootNode));
|
|
296
298
|
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, KOTLIN_CALLABLE_CAPTURE_OPTIONS));
|
|
297
299
|
return out;
|
|
@@ -15,6 +15,7 @@ import { clearKotlinPackageFacts } from './package-facts.js';
|
|
|
15
15
|
import { attachKotlinSpringDiMetadata } from './spring-di.js';
|
|
16
16
|
import { attachKotlinSpringConditionalMetadata } from './spring-conditionals.js';
|
|
17
17
|
import { attachKotlinSpringNonHttpHandlerMetadata } from './spring-non-http-handlers.js';
|
|
18
|
+
import { attachKotlinSpringConfigBindings } from './spring-config-bindings.js';
|
|
18
19
|
import { attachKotlinSpringDynamicLookup } from './spring-dynamic-lookup.js';
|
|
19
20
|
/**
|
|
20
21
|
* Kotlin scope resolver for RFC #909 Ring 3.
|
|
@@ -123,6 +124,7 @@ export const kotlinScopeResolver = {
|
|
|
123
124
|
attachKotlinSpringDiMetadata(graph, parsedFiles, nodeLookup, indexes);
|
|
124
125
|
attachKotlinSpringNonHttpHandlerMetadata(graph, parsedFiles, nodeLookup, indexes);
|
|
125
126
|
attachKotlinSpringDynamicLookup(graph, parsedFiles, nodeLookup, indexes);
|
|
127
|
+
attachKotlinSpringConfigBindings(graph, parsedFiles, nodeLookup, indexes);
|
|
126
128
|
},
|
|
127
129
|
};
|
|
128
130
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { KnowledgeGraph } from '../../../graph/types.js';
|
|
2
|
+
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
|
3
|
+
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
|
4
|
+
import { type ParsedFile, type ScopeId } from '../../../../_shared/index.js';
|
|
5
|
+
import { type SpringConfigConsumer } from '../../frameworks/spring/config-bindings.js';
|
|
6
|
+
import { type SyntaxNode } from '../../utils/ast-helpers.js';
|
|
7
|
+
export interface KotlinSpringConfigConsumerFact {
|
|
8
|
+
readonly consumer: SpringConfigConsumer;
|
|
9
|
+
readonly annotationName: string;
|
|
10
|
+
readonly classScopeId: ScopeId;
|
|
11
|
+
}
|
|
12
|
+
/** Collect config facts from the Kotlin parser's existing AST (no reparse). */
|
|
13
|
+
export declare function captureKotlinSpringConfigConsumerFacts(root: SyntaxNode, filePath: string): KotlinSpringConfigConsumerFact[];
|
|
14
|
+
/** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */
|
|
15
|
+
export declare function extractKotlinSpringConfigConsumers(source: string): SpringConfigConsumer[];
|
|
16
|
+
export declare function extractKotlinSpringConfigConsumerFacts(source: string): KotlinSpringConfigConsumerFact[];
|
|
17
|
+
/** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */
|
|
18
|
+
export declare function attachKotlinSpringConfigBindings(graph: KnowledgeGraph, parsedFiles: readonly ParsedFile[], _nodeLookup: GraphNodeLookup, indexes: ScopeResolutionIndexes): void;
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { makeScopeId } from '../../../../_shared/index.js';
|
|
2
|
+
import { bindSpringConfigConsumers, } from '../../frameworks/spring/config-bindings.js';
|
|
3
|
+
import { createSpringAnnotationNameResolver } from '../../frameworks/spring/bean-candidates.js';
|
|
4
|
+
import { parseSpringAnnotationArguments, parseStaticStringLiteral, } from '../../frameworks/spring/annotation-arguments.js';
|
|
5
|
+
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
|
6
|
+
import { nodeToCapture } from '../../utils/ast-helpers.js';
|
|
7
|
+
import { getKotlinParser } from './query.js';
|
|
8
|
+
import { getKotlinSpringConfigConsumerFacts } from './capture-side-channel.js';
|
|
9
|
+
import { isKotlinPackageSiblingVisibilityIncomplete } from './package-siblings.js';
|
|
10
|
+
const VALUE_ANNOTATION = 'org.springframework.beans.factory.annotation.Value';
|
|
11
|
+
const CONFIGURATION_PROPERTIES_ANNOTATION = 'org.springframework.boot.context.properties.ConfigurationProperties';
|
|
12
|
+
const VALUE_SIMPLE = 'Value';
|
|
13
|
+
const CONFIGURATION_PROPERTIES_SIMPLE = 'ConfigurationProperties';
|
|
14
|
+
const SKIP_USE_SITES = new Set(['get', 'property', 'file']);
|
|
15
|
+
const BIND_USE_SITES = new Set(['field', 'set', 'param']);
|
|
16
|
+
const OWNER_TYPES = new Set(['class_declaration', 'object_declaration', 'companion_object']);
|
|
17
|
+
const INTERPOLATION_TYPES = new Set([
|
|
18
|
+
'interpolated_identifier',
|
|
19
|
+
'interpolated_expression',
|
|
20
|
+
'interpolation_expression_start',
|
|
21
|
+
]);
|
|
22
|
+
const STRING_LITERAL_TYPES = new Set(['string_literal', 'character_literal']);
|
|
23
|
+
function firstDescendantOfType(node, type) {
|
|
24
|
+
const stack = [...node.namedChildren].reverse();
|
|
25
|
+
while (stack.length > 0) {
|
|
26
|
+
const current = stack.pop();
|
|
27
|
+
if (current === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
if (current.type === type)
|
|
30
|
+
return current;
|
|
31
|
+
for (let index = current.namedChildren.length - 1; index >= 0; index--) {
|
|
32
|
+
const child = current.namedChildren[index];
|
|
33
|
+
if (child !== undefined)
|
|
34
|
+
stack.push(child);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function ownerName(declaration) {
|
|
40
|
+
if (declaration.type === 'companion_object') {
|
|
41
|
+
const named = declaration.namedChildren.find((child) => child.type === 'type_identifier');
|
|
42
|
+
return named?.text.trim() || 'Companion';
|
|
43
|
+
}
|
|
44
|
+
return (declaration.namedChildren.find((child) => child.type === 'type_identifier')?.text.trim() ??
|
|
45
|
+
declaration.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim());
|
|
46
|
+
}
|
|
47
|
+
function enclosingOwner(node) {
|
|
48
|
+
let current = node.parent;
|
|
49
|
+
while (current !== null) {
|
|
50
|
+
if (OWNER_TYPES.has(current.type))
|
|
51
|
+
return current;
|
|
52
|
+
current = current.parent;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
function classScopeId(filePath, declaration) {
|
|
57
|
+
return makeScopeId({
|
|
58
|
+
filePath,
|
|
59
|
+
range: nodeToCapture('@scope.class', declaration).range,
|
|
60
|
+
kind: 'Class',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function collectKotlinImports(root) {
|
|
64
|
+
const exact = new Map();
|
|
65
|
+
const wildcard = new Set();
|
|
66
|
+
const localTypes = new Map();
|
|
67
|
+
for (const header of root.descendantsOfType('import_header')) {
|
|
68
|
+
const text = header.text.replace(/^import\s+/, '').trim();
|
|
69
|
+
const aliasMatch = text.match(/^([\w.]+)\s+as\s+(\w+)\s*$/);
|
|
70
|
+
if (aliasMatch !== null) {
|
|
71
|
+
exact.set(aliasMatch[2], aliasMatch[1]);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (text.endsWith('.*'))
|
|
75
|
+
wildcard.add(text.slice(0, -2));
|
|
76
|
+
else {
|
|
77
|
+
const simple = text.slice(text.lastIndexOf('.') + 1);
|
|
78
|
+
if (simple.length > 0)
|
|
79
|
+
exact.set(simple, text);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const type of ['class_declaration', 'object_declaration']) {
|
|
83
|
+
for (const declaration of root.descendantsOfType(type)) {
|
|
84
|
+
const name = ownerName(declaration);
|
|
85
|
+
if (name) {
|
|
86
|
+
const declarations = localTypes.get(name) ?? [];
|
|
87
|
+
declarations.push(declaration);
|
|
88
|
+
localTypes.set(name, declarations);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { exact, wildcard, localTypes };
|
|
93
|
+
}
|
|
94
|
+
function annotationFromNode(annotation) {
|
|
95
|
+
const nameNode = firstDescendantOfType(annotation, 'user_type') ??
|
|
96
|
+
firstDescendantOfType(annotation, 'type_identifier') ??
|
|
97
|
+
firstDescendantOfType(annotation, 'simple_identifier');
|
|
98
|
+
if (nameNode === undefined)
|
|
99
|
+
return null;
|
|
100
|
+
const useSiteTarget = annotation.namedChildren
|
|
101
|
+
.find((child) => child.type === 'use_site_target')
|
|
102
|
+
?.text.replace(/:\s*$/, '')
|
|
103
|
+
.trim();
|
|
104
|
+
return {
|
|
105
|
+
name: nameNode.text.trim(),
|
|
106
|
+
node: annotation,
|
|
107
|
+
...(useSiteTarget === undefined || useSiteTarget.length === 0 ? {} : { useSiteTarget }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function annotationsOn(node) {
|
|
111
|
+
const annotations = [];
|
|
112
|
+
for (const child of node.namedChildren) {
|
|
113
|
+
if (child.type === 'annotation') {
|
|
114
|
+
const fact = annotationFromNode(child);
|
|
115
|
+
if (fact !== null)
|
|
116
|
+
annotations.push(fact);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (child.type !== 'modifiers' && child.type !== 'parameter_modifiers')
|
|
120
|
+
continue;
|
|
121
|
+
for (const nested of child.namedChildren) {
|
|
122
|
+
if (nested.type !== 'annotation')
|
|
123
|
+
continue;
|
|
124
|
+
const fact = annotationFromNode(nested);
|
|
125
|
+
if (fact !== null)
|
|
126
|
+
annotations.push(fact);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return annotations;
|
|
130
|
+
}
|
|
131
|
+
function simpleName(rawName) {
|
|
132
|
+
const parts = rawName.split('.');
|
|
133
|
+
return parts[parts.length - 1] ?? rawName;
|
|
134
|
+
}
|
|
135
|
+
function importedAs(imports, simple, fqn, wildcardPackage) {
|
|
136
|
+
if (imports.exact.get(simple) === fqn)
|
|
137
|
+
return true;
|
|
138
|
+
// An explicit import wins over a star import in Kotlin, so a conflicting
|
|
139
|
+
// binding for the same simple name rules the Spring annotation out even when
|
|
140
|
+
// its package is wildcard-imported.
|
|
141
|
+
return imports.exact.get(simple) === undefined && imports.wildcard.has(wildcardPackage);
|
|
142
|
+
}
|
|
143
|
+
function hasVisibleLocalType(imports, simple, annotation) {
|
|
144
|
+
for (const declaration of imports.localTypes.get(simple) ?? []) {
|
|
145
|
+
const declarationOwner = enclosingOwner(declaration);
|
|
146
|
+
if (declarationOwner === undefined)
|
|
147
|
+
return true;
|
|
148
|
+
let current = annotation;
|
|
149
|
+
while (current !== null) {
|
|
150
|
+
if (current.id === declarationOwner.id)
|
|
151
|
+
return true;
|
|
152
|
+
current = current.parent;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
const SIMPLE_CONFIG_ANNOTATIONS = [
|
|
158
|
+
{
|
|
159
|
+
simple: VALUE_SIMPLE,
|
|
160
|
+
kind: 'value',
|
|
161
|
+
fqn: VALUE_ANNOTATION,
|
|
162
|
+
wildcardPackage: 'org.springframework.beans.factory.annotation',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
simple: CONFIGURATION_PROPERTIES_SIMPLE,
|
|
166
|
+
kind: 'configuration-properties',
|
|
167
|
+
fqn: CONFIGURATION_PROPERTIES_ANNOTATION,
|
|
168
|
+
wildcardPackage: 'org.springframework.boot.context.properties',
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
function configAnnotationKind(annotation, imports) {
|
|
172
|
+
const rawName = annotation.name;
|
|
173
|
+
if (rawName === VALUE_ANNOTATION)
|
|
174
|
+
return 'value';
|
|
175
|
+
if (rawName === CONFIGURATION_PROPERTIES_ANNOTATION)
|
|
176
|
+
return 'configuration-properties';
|
|
177
|
+
const simple = simpleName(rawName);
|
|
178
|
+
const aliased = imports.exact.get(simple);
|
|
179
|
+
if (aliased === VALUE_ANNOTATION)
|
|
180
|
+
return 'value';
|
|
181
|
+
if (aliased === CONFIGURATION_PROPERTIES_ANNOTATION)
|
|
182
|
+
return 'configuration-properties';
|
|
183
|
+
for (const candidate of SIMPLE_CONFIG_ANNOTATIONS) {
|
|
184
|
+
if (simple !== candidate.simple)
|
|
185
|
+
continue;
|
|
186
|
+
if (hasVisibleLocalType(imports, simple, annotation.node) && !imports.exact.has(simple)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return importedAs(imports, simple, candidate.fqn, candidate.wildcardPackage)
|
|
190
|
+
? candidate.kind
|
|
191
|
+
: null;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
function hasInterpolation(annotation) {
|
|
196
|
+
const stack = [...annotation.namedChildren];
|
|
197
|
+
while (stack.length > 0) {
|
|
198
|
+
const current = stack.pop();
|
|
199
|
+
if (current === undefined)
|
|
200
|
+
continue;
|
|
201
|
+
if (INTERPOLATION_TYPES.has(current.type))
|
|
202
|
+
return true;
|
|
203
|
+
stack.push(...current.namedChildren);
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
function decodeKotlinStringLiteral(literal) {
|
|
208
|
+
const raw = literal.startsWith('"""') && literal.endsWith('"""');
|
|
209
|
+
const delimiterLength = raw ? 3 : 1;
|
|
210
|
+
if (literal.length < delimiterLength * 2)
|
|
211
|
+
return null;
|
|
212
|
+
const body = literal.slice(delimiterLength, -delimiterLength);
|
|
213
|
+
if (!raw && /(?<!\\)\$\{/.test(body))
|
|
214
|
+
return null;
|
|
215
|
+
if (raw)
|
|
216
|
+
return body;
|
|
217
|
+
return body
|
|
218
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
219
|
+
.replace(/\\(["'\\$btnfr])/g, (_match, escaped) => {
|
|
220
|
+
const controls = {
|
|
221
|
+
b: '\b',
|
|
222
|
+
t: '\t',
|
|
223
|
+
n: '\n',
|
|
224
|
+
f: '\f',
|
|
225
|
+
r: '\r',
|
|
226
|
+
$: '$',
|
|
227
|
+
};
|
|
228
|
+
return controls[escaped] ?? escaped;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function kotlinStringLiterals(annotation) {
|
|
232
|
+
const literals = [];
|
|
233
|
+
const stack = [...annotation.namedChildren];
|
|
234
|
+
while (stack.length > 0) {
|
|
235
|
+
const current = stack.pop();
|
|
236
|
+
if (current === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
if (STRING_LITERAL_TYPES.has(current.type)) {
|
|
239
|
+
const decoded = decodeKotlinStringLiteral(current.text);
|
|
240
|
+
if (decoded !== null)
|
|
241
|
+
literals.push(decoded);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
stack.push(...current.namedChildren);
|
|
245
|
+
}
|
|
246
|
+
return literals;
|
|
247
|
+
}
|
|
248
|
+
function parseValuePlaceholderKeys(annotation) {
|
|
249
|
+
if (hasInterpolation(annotation))
|
|
250
|
+
return [];
|
|
251
|
+
const keys = new Set();
|
|
252
|
+
for (const literal of kotlinStringLiterals(annotation)) {
|
|
253
|
+
for (const match of literal.matchAll(/\$\{([^{}]+)\}/g)) {
|
|
254
|
+
const key = match[1].split(':', 1)[0].trim();
|
|
255
|
+
if (/^[A-Za-z0-9_.-]+$/.test(key))
|
|
256
|
+
keys.add(key);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return [...keys];
|
|
260
|
+
}
|
|
261
|
+
function parseConfigurationPropertiesPrefix(annotation) {
|
|
262
|
+
if (hasInterpolation(annotation))
|
|
263
|
+
return null;
|
|
264
|
+
const argumentsList = parseSpringAnnotationArguments(annotation.text);
|
|
265
|
+
if (argumentsList !== null) {
|
|
266
|
+
const named = argumentsList.filter((argument) => argument.name === 'prefix' || argument.name === 'value');
|
|
267
|
+
const positional = argumentsList.filter((argument) => argument.name === undefined);
|
|
268
|
+
const chosen = named.length === 1 ? named[0] : named.length === 0 ? positional[0] : undefined;
|
|
269
|
+
if (chosen !== undefined) {
|
|
270
|
+
const decoded = parseStaticStringLiteral(chosen.value);
|
|
271
|
+
if (decoded === null)
|
|
272
|
+
return null;
|
|
273
|
+
const prefix = decoded.replace(/^\.+|\.+$/g, '');
|
|
274
|
+
if (/^[A-Za-z0-9_.-]+$/.test(prefix))
|
|
275
|
+
return prefix;
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
if (argumentsList.length > 0)
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const literals = kotlinStringLiterals(annotation);
|
|
282
|
+
if (literals.length !== 1)
|
|
283
|
+
return null;
|
|
284
|
+
const prefix = literals[0].trim().replace(/^\.+|\.+$/g, '');
|
|
285
|
+
return /^[A-Za-z0-9_.-]+$/.test(prefix) ? prefix : null;
|
|
286
|
+
}
|
|
287
|
+
function allowedUseSite(useSiteTarget) {
|
|
288
|
+
if (useSiteTarget === undefined)
|
|
289
|
+
return true;
|
|
290
|
+
if (SKIP_USE_SITES.has(useSiteTarget))
|
|
291
|
+
return false;
|
|
292
|
+
return BIND_USE_SITES.has(useSiteTarget);
|
|
293
|
+
}
|
|
294
|
+
function hasBindingPattern(parameter) {
|
|
295
|
+
return parameter.namedChildren.some((child) => child.type === 'binding_pattern_kind');
|
|
296
|
+
}
|
|
297
|
+
function propertyName(node) {
|
|
298
|
+
if (node.type === 'class_parameter') {
|
|
299
|
+
return node.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
|
|
300
|
+
}
|
|
301
|
+
const variable = node.namedChildren.find((child) => child.type === 'variable_declaration');
|
|
302
|
+
return variable?.namedChildren.find((child) => child.type === 'simple_identifier')?.text.trim();
|
|
303
|
+
}
|
|
304
|
+
function underFileAnnotation(node) {
|
|
305
|
+
let current = node;
|
|
306
|
+
while (current !== null) {
|
|
307
|
+
if (current.type === 'file_annotation')
|
|
308
|
+
return true;
|
|
309
|
+
current = current.parent;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
function pushValueFacts(facts, member, filePath, imports) {
|
|
314
|
+
if (underFileAnnotation(member))
|
|
315
|
+
return;
|
|
316
|
+
const owner = enclosingOwner(member);
|
|
317
|
+
if (owner === undefined)
|
|
318
|
+
return;
|
|
319
|
+
const fieldName = propertyName(member);
|
|
320
|
+
if (fieldName === undefined)
|
|
321
|
+
return;
|
|
322
|
+
for (const annotation of annotationsOn(member)) {
|
|
323
|
+
if (!allowedUseSite(annotation.useSiteTarget))
|
|
324
|
+
continue;
|
|
325
|
+
if (configAnnotationKind(annotation, imports) !== 'value')
|
|
326
|
+
continue;
|
|
327
|
+
const keys = parseValuePlaceholderKeys(annotation.node);
|
|
328
|
+
if (keys.length === 0)
|
|
329
|
+
continue;
|
|
330
|
+
facts.push({
|
|
331
|
+
consumer: {
|
|
332
|
+
kind: 'value',
|
|
333
|
+
fieldName,
|
|
334
|
+
line: member.startPosition.row + 1,
|
|
335
|
+
keys,
|
|
336
|
+
},
|
|
337
|
+
annotationName: annotation.name,
|
|
338
|
+
classScopeId: classScopeId(filePath, owner),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Collect config facts from the Kotlin parser's existing AST (no reparse). */
|
|
343
|
+
export function captureKotlinSpringConfigConsumerFacts(root, filePath) {
|
|
344
|
+
const imports = collectKotlinImports(root);
|
|
345
|
+
const facts = [];
|
|
346
|
+
for (const property of root.descendantsOfType('property_declaration')) {
|
|
347
|
+
pushValueFacts(facts, property, filePath, imports);
|
|
348
|
+
}
|
|
349
|
+
for (const parameter of root.descendantsOfType('class_parameter')) {
|
|
350
|
+
if (!hasBindingPattern(parameter))
|
|
351
|
+
continue;
|
|
352
|
+
pushValueFacts(facts, parameter, filePath, imports);
|
|
353
|
+
}
|
|
354
|
+
for (const type of ['class_declaration', 'object_declaration']) {
|
|
355
|
+
for (const declaration of root.descendantsOfType(type)) {
|
|
356
|
+
const className = ownerName(declaration);
|
|
357
|
+
if (className === undefined)
|
|
358
|
+
continue;
|
|
359
|
+
for (const annotation of annotationsOn(declaration)) {
|
|
360
|
+
if (configAnnotationKind(annotation, imports) !== 'configuration-properties') {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const prefix = parseConfigurationPropertiesPrefix(annotation.node);
|
|
364
|
+
if (prefix === null)
|
|
365
|
+
continue;
|
|
366
|
+
facts.push({
|
|
367
|
+
consumer: {
|
|
368
|
+
kind: 'configuration-properties',
|
|
369
|
+
className,
|
|
370
|
+
line: declaration.startPosition.row + 1,
|
|
371
|
+
prefix,
|
|
372
|
+
},
|
|
373
|
+
annotationName: annotation.name,
|
|
374
|
+
classScopeId: classScopeId(filePath, declaration),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return facts;
|
|
380
|
+
}
|
|
381
|
+
/** Parse Kotlin consumers for focused unit tests; production reuses the worker AST. */
|
|
382
|
+
export function extractKotlinSpringConfigConsumers(source) {
|
|
383
|
+
const tree = parseSourceSafe(getKotlinParser(), source);
|
|
384
|
+
return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>').map((fact) => fact.consumer);
|
|
385
|
+
}
|
|
386
|
+
export function extractKotlinSpringConfigConsumerFacts(source) {
|
|
387
|
+
const tree = parseSourceSafe(getKotlinParser(), source);
|
|
388
|
+
return captureKotlinSpringConfigConsumerFacts(tree.rootNode, '<memory>');
|
|
389
|
+
}
|
|
390
|
+
/** Kotlin ScopeResolver post-resolution hook for Spring configuration consumers. */
|
|
391
|
+
export function attachKotlinSpringConfigBindings(graph, parsedFiles, _nodeLookup, indexes) {
|
|
392
|
+
const resolveAnnotation = createSpringAnnotationNameResolver(indexes);
|
|
393
|
+
const recognizedAnnotations = new Set([VALUE_ANNOTATION, CONFIGURATION_PROPERTIES_ANNOTATION]);
|
|
394
|
+
const batches = [];
|
|
395
|
+
for (const parsed of parsedFiles) {
|
|
396
|
+
const consumers = [];
|
|
397
|
+
for (const fact of getKotlinSpringConfigConsumerFacts(parsed.filePath)) {
|
|
398
|
+
const classScope = indexes.scopeTree.getScope(fact.classScopeId);
|
|
399
|
+
if (classScope === undefined || classScope.kind !== 'Class')
|
|
400
|
+
continue;
|
|
401
|
+
const expectedAnnotation = fact.consumer.kind === 'value' ? VALUE_ANNOTATION : CONFIGURATION_PROPERTIES_ANNOTATION;
|
|
402
|
+
const enclosingScope = fact.consumer.kind === 'value' ? classScope.id : classScope.parent;
|
|
403
|
+
const resolved = resolveAnnotation(fact.annotationName, parsed, enclosingScope, recognizedAnnotations, isKotlinPackageSiblingVisibilityIncomplete(parsed.filePath));
|
|
404
|
+
if (resolved === expectedAnnotation)
|
|
405
|
+
consumers.push(fact.consumer);
|
|
406
|
+
}
|
|
407
|
+
if (consumers.length > 0)
|
|
408
|
+
batches.push({ filePath: parsed.filePath, consumers });
|
|
409
|
+
}
|
|
410
|
+
bindSpringConfigConsumers(graph, batches);
|
|
411
|
+
}
|
package/dist/core/run-analyze.js
CHANGED
|
@@ -41,6 +41,7 @@ import { taintModelVersion } from './ingestion/taint/typescript-model.js';
|
|
|
41
41
|
import { parseTruthyEnv, parsePositiveIntEnv } from './ingestion/utils/env.js';
|
|
42
42
|
import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
|
|
43
43
|
import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental/subgraph-extract.js';
|
|
44
|
+
import { collectSpringConfigConsumerDriftFiles, } from './incremental/spring-config-drift.js';
|
|
44
45
|
import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
|
|
45
46
|
import { shouldEscalateIncrementalWrite } from './incremental/escalation-gate.js';
|
|
46
47
|
import { ftsTablesAmong, incrementalFtsTablesFromGraph, nodeTablesForIncrementalDelete, shouldPreservePersistedDerivedGraph, } from './incremental/derived-writeback.js';
|
|
@@ -55,6 +56,8 @@ import { sanitizeDetectedBranch } from '../cli/analyze-config.js';
|
|
|
55
56
|
import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, STALE_HASH_SENTINEL, SCHEMA_FINGERPRINT, schemaFingerprintMismatch, isSchemaFingerprintShaped, embeddingDimsMismatch, } from './lbug/schema.js';
|
|
56
57
|
import { isSpringBeanCandidateSourceFile } from './ingestion/frameworks/spring/bean-catalog.js';
|
|
57
58
|
import { isSpringBeanFactoryDeclaration } from './ingestion/frameworks/spring/bean-factories.js';
|
|
59
|
+
import { SPRING_CONFIG_UNRESOLVED_PREFIX } from './ingestion/frameworks/spring/config-bindings.js';
|
|
60
|
+
import { classifySpringConfigFile } from './ingestion/pipeline-phases/spring-config.js';
|
|
58
61
|
import { SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_NON_HTTP_HANDLERS_FEATURE, } from './ingestion/frameworks/spring/analysis-features.js';
|
|
59
62
|
import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, } from './ingestion/languages/java/analysis-features.js';
|
|
60
63
|
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE, findAnalysisFeatureMismatches, resolveAnalysisFeatureVersions, } from './analysis-features.js';
|
|
@@ -1812,6 +1815,29 @@ async function runFullAnalysisInner(repoPath, options, callbacks, writeTarget, r
|
|
|
1812
1815
|
// and extractChangedSubgraph — asymmetry between the two would
|
|
1813
1816
|
// leave stale rows or PK-conflict at COPY time.
|
|
1814
1817
|
const effectiveWriteSet = computeEffectiveWriteSet(pipelineResult.graph, writableFiles);
|
|
1818
|
+
const springConfigChanged = hashDiff.toWrite.some((filePath) => classifySpringConfigFile(filePath) !== null) ||
|
|
1819
|
+
hashDiff.deleted.some((filePath) => classifySpringConfigFile(filePath) !== null);
|
|
1820
|
+
if (springConfigChanged) {
|
|
1821
|
+
const unresolvedPrefix = escapeCypherString(SPRING_CONFIG_UNRESOLVED_PREFIX);
|
|
1822
|
+
const persistedSpringConfigConsumers = (await executeQuery('MATCH (n:Property) ' +
|
|
1823
|
+
`WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
|
|
1824
|
+
'RETURN n.id AS id, n.description AS description ' +
|
|
1825
|
+
'UNION ALL ' +
|
|
1826
|
+
'MATCH (n:Class) ' +
|
|
1827
|
+
`WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
|
|
1828
|
+
'RETURN n.id AS id, n.description AS description ' +
|
|
1829
|
+
'UNION ALL ' +
|
|
1830
|
+
'MATCH (n:Record) ' +
|
|
1831
|
+
`WHERE n.description CONTAINS '${unresolvedPrefix}' ` +
|
|
1832
|
+
'RETURN n.id AS id, n.description AS description'));
|
|
1833
|
+
const springConfigConsumerDriftFiles = collectSpringConfigConsumerDriftFiles(pipelineResult.graph, persistedSpringConfigConsumers);
|
|
1834
|
+
for (const filePath of springConfigConsumerDriftFiles)
|
|
1835
|
+
effectiveWriteSet.add(filePath);
|
|
1836
|
+
if (springConfigConsumerDriftFiles.size > 0) {
|
|
1837
|
+
log(`Incremental: +${springConfigConsumerDriftFiles.size} file(s) added for ` +
|
|
1838
|
+
'Spring config consumer property drift');
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1815
1841
|
// `frameworkAnnotations` is derived from cross-file JVM visibility, so
|
|
1816
1842
|
// an unchanged Class row can change when a same-package declaration is
|
|
1817
1843
|
// added or removed without producing an IMPORTS edge. Compare the fresh
|
package/package.json
CHANGED