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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"test": "node scripts/test.mjs",
"test:vitest": "node node_modules/vitest/vitest.mjs run",
"test:watch": "node node_modules/vitest/vitest.mjs",
"lint": "ESLINT_USE_FLAT_CONFIG=false node node_modules/eslint/bin/eslint.js src --ext ts",
"lint": "node scripts/lint.mjs",
"format": "node node_modules/prettier/bin/prettier.cjs --check .",
"format:fix": "node node_modules/prettier/bin/prettier.cjs --write .",
"rebuild-native": "cd node_modules/better-sqlite3 && npm run build-release",
Expand Down
18 changes: 18 additions & 0 deletions scripts/lint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';

const eslintCli = resolve('node_modules/eslint/bin/eslint.js');
const result = spawnSync(process.execPath, [eslintCli, 'src', '--ext', 'ts'], {
env: {
...process.env,
ESLINT_USE_FLAT_CONFIG: 'false',
},
stdio: 'inherit',
});

if (result.error) {
console.error(`Failed to start ESLint: ${result.error.message}`);
process.exitCode = 1;
} else {
process.exitCode = result.status ?? 1;
}
103 changes: 102 additions & 1 deletion src/advance/classic/cognitive-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ const ALREADY_FIXED_CHECK_TOOL: ToolDefinition = {
alreadyFixed: { type: 'boolean' },
reason: { type: 'string' },
evidence: { type: 'string' },
evidenceSnippet: {
type: 'string',
description:
'alreadyFixed=true 时,从当前目标文件或已提供的额外文件上下文中原样摘录的最小代码片段',
},
evidenceLine: {
type: 'number',
description: 'evidenceSnippet 在对应文件中的起始行号;无法确定时可省略',
},
needsMoreContext: {
type: 'boolean',
description:
Expand All @@ -106,6 +115,75 @@ const ALREADY_FIXED_CHECK_TOOL: ToolDefinition = {
},
};

function normalizeEvidenceFragment(text: string): string {
return text.replace(/\s+/g, ' ').trim().toLowerCase();
}

function extractPathReferences(text: string): string[] {
return Array.from(
text.matchAll(/[A-Za-z0-9_.@*~:-]+(?:[\\/][A-Za-z0-9_.@*~()-]+)+\.[A-Za-z0-9]+/g),
match => match[0].replace(/\\/g, '/').toLowerCase()
);
}

function extractDistinctiveCodeAnchors(text: string): string[] {
const withoutPaths = text.replace(
/[A-Za-z0-9_.@*~:-]+(?:[\\/][A-Za-z0-9_.@*~()-]+)+\.[A-Za-z0-9]+/g,
' '
);
return Array.from(
withoutPaths.matchAll(/\b[A-Za-z_$][A-Za-z0-9_$]*\b/g),
match => match[0]
).filter(
token =>
token.includes('_') || /[a-z][A-Z]/.test(token) || (token.match(/[A-Z]/g)?.length ?? 0) >= 2
);
}

/** already-fixed 证据必须能绑定到当前 finding 的文件或显式补充上下文。 */
export function isAlreadyFixedEvidenceGrounded(params: {
findingFile: string;
fileContent: string;
extraFileContexts?: string[];
evidence?: string;
evidenceSnippet?: string;
}): boolean {
const evidence = params.evidence?.trim() ?? '';
const evidenceSnippet = params.evidenceSnippet?.trim() ?? '';
if (!evidence && !evidenceSnippet) return false;

const extraContexts = params.extraFileContexts ?? [];
const corpus = [params.fileContent, ...extraContexts].join('\n');
const normalizedCorpus = normalizeEvidenceFragment(corpus);
const allowedPaths = new Set([
params.findingFile.replace(/\\/g, '/').toLowerCase(),
...extraContexts.flatMap(extractPathReferences),
]);

for (const path of extractPathReferences(`${evidence}\n${evidenceSnippet}`)) {
if (
!Array.from(allowedPaths).some(allowed => allowed.endsWith(path) || path.endsWith(allowed))
) {
return false;
}
}

if (evidenceSnippet && !normalizedCorpus.includes(normalizeEvidenceFragment(evidenceSnippet))) {
return false;
}

const quotedAnchors = Array.from(evidence.matchAll(/`([^`\n]{2,160})`/g), match => match[1])
.map(normalizeEvidenceFragment)
.filter(anchor => /[a-z_$]/i.test(anchor) && !extractPathReferences(anchor).length);
const distinctiveAnchors = extractDistinctiveCodeAnchors(evidence).map(normalizeEvidenceFragment);
const codeAnchors = [...new Set([...quotedAnchors, ...distinctiveAnchors])];
if (codeAnchors.some(anchor => !normalizedCorpus.includes(anchor))) {
return false;
}

return true;
}

const FAST_DECISION_TOOL: ToolDefinition = {
name: 'fast_decision',
description:
Expand Down Expand Up @@ -398,7 +476,9 @@ export class CognitiveEngine {
try {
const matches = await manager.searchWorkspace(keyword);
if (matches.length === 0) return null;
const lines = matches.map(match => `- ${match.file}:${match.line} ${match.content}`).join('\n');
const lines = matches
.map(match => `- ${match.file}:${match.line} ${match.content}`)
.join('\n');
return `## 工作区中 ${keyword} 的匹配位置\n${lines}`;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
Expand Down Expand Up @@ -478,8 +558,29 @@ export class CognitiveEngine {
alreadyFixed?: boolean;
reason?: string;
evidence?: string;
evidenceSnippet?: string;
evidenceLine?: number;
needsMoreContext?: boolean;
};
if (
input.alreadyFixed === true &&
!isAlreadyFixedEvidenceGrounded({
findingFile: context.finding.file,
fileContent,
extraFileContexts: context.extraFileContexts,
evidence: input.evidence,
evidenceSnippet: input.evidenceSnippet,
})
) {
console.warn(
`[CognitiveEngine] already_fixed_check (${sourceLabel}) 证据与目标 finding 不匹配: ${context.finding.file}:${context.finding.line}`
);
return {
alreadyFixed: false,
reason: 'already-fixed 证据无法绑定到当前 finding 的代码上下文,拒绝复用该结论',
needsMoreContext: sourceLabel === '聚焦窗口',
};
}
return {
alreadyFixed: input.alreadyFixed === true,
reason: input.reason ?? '未说明理由',
Expand Down
50 changes: 50 additions & 0 deletions src/advance/classic/fix/ask-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* ask 门禁(L2,见 docs/goals/maintainer-llm-centric-goal.md)
*
* 向 Reviewer 提问前,框架先校验「这个问题能否用 worktree 工具自答」。
* 仓库内文件内容/代码片段属于可自查信息,禁止索问——这类问题一旦被提出,
* 暴露的是框架失职而非信息缺失(真实现场实证:"请提供 tracker.ts 文件的内容")。
*
* 命中以下模式的提问应被门禁拦截并转为修复自查,而不是出现在 MR 上。
*/

const SELF_ANSWERABLE_PATTERNS: RegExp[] = [
// 请提供/请贴出 xx 文件(的内容/代码/实现/片段)——间距放宽以容纳长文件路径
/请(?:提供|贴出|贴下|发一下|发送)[\s\S]{0,120}(?:文件|内容|代码|实现|片段)/,
// 能否/可以/能不能 提供/贴/发 xx 文件/代码/内容/实现
/(?:能否|可以|能不能|烦请|麻烦)[\s\S]{0,15}(?:提供|贴|发)[\s\S]{0,30}(?:文件|代码|内容|片段|实现)/,
// 把 xx 文件/代码 发/贴/提供 给我
/把[\s\S]{0,15}(?:文件|代码|内容)[\s\S]{0,10}(?:发|贴|提供)/,
// xx 文件的(完整|当前)内容/代码 是什么
/(?:文件|代码)的(?:完整|当前|全部)?(?:内容|实现|代码)是(?:什么|啥)/,
// 英文等价形态
/please (?:provide|share|paste|show)[\s\S]{0,40}(?:file|code|content|snippet)/i,
/(?:could|can) you (?:provide|share|paste|show)[\s\S]{0,40}(?:file|code|content|snippet)/i,
];

/**
* 判断提问是否属于「仓库内可自查」的索问。
*
* 只拦截明确的文件内容/代码片段索问;意图澄清、方案取舍、业务上下文
* 等真正需要人来回答的问题不在此列(保守放行)。
*/
export function isSelfAnswerableQuestion(question: string): boolean {
const normalized = question.trim();
if (!normalized) return false;
return SELF_ANSWERABLE_PATTERNS.some(pattern => pattern.test(normalized));
}

/**
* 判断 Reviewer 的回复是否「本身就是仓库内可查信息」(代码块或文件路径引用)。
*
* 用途(G7):交互提问收到人工回复后直接转修复,若回复内容其实躺在仓库里,
* 说明这次提问疑似本可被门禁拦截——作为漏判候选回流 EverOS 供模式库扩充。
* 保守判定:只认代码围栏与带扩展名的文件路径,纯文字方案讨论不算。
*/
export function isRepoContentReply(body: string): boolean {
if (!body) return false;
if (/```/.test(body)) return true;
return /[\w@~.-]+(?:\/[\w@~.()-]+)+\.(?:ts|tsx|js|jsx|mts|cts|py|go|java|rs|vue|json|ya?ml|toml|md)(?::\d+)?\b/.test(
body
);
}
180 changes: 180 additions & 0 deletions src/advance/classic/fix/commit-pipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* 提交管道:框架侧的确定性兜底
*
* 目标(见 docs/goals/maintainer-llm-centric-goal.md 子目标 B):
* - F2:commit message 三级兜底(EverOS 记忆 → 仓库静态探测 → 合规通用默认),
* LLM 只需提供"改了什么",格式拼装是框架的事;
* - F3:任何 commit/push 失败先由框架机械预处理(去 ANSI、截尾、归类、蒸馏),
* 可模板化修复的直接重试,不能的蒸馏成 ≤10 行诊断再交给上层;
* - L3:lint/test 类 hook 失败的蒸馏结果可回流修复循环,而非直接判死。
*/

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { stripTerminalControlCodes } from '../runners/shared/reply-safety.js';

/** commit/push 失败的机械归类 */
export type CommitFailureKind =
| 'commit-message'
| 'lint'
| 'test'
| 'typecheck'
| 'permission'
| 'push'
| 'unknown';

/** 去除 ANSI 转义码,避免 hook 输出中的颜色控制字符干扰规范提取 */
export function stripAnsiCodes(text: string): string {
return stripTerminalControlCodes(text);
}

/** 保留 hook 输出尾部诊断,避免前置 lint/test 日志淹没最终拒绝原因 */
export function extractCommitRejectionSection(text: string): string {
const normalized = text.replace(/\r\n?/g, '\n').trimEnd();
const tailLines = normalized.split('\n').slice(-120).join('\n');
return tailLines.slice(-8000);
}

const COMMIT_MESSAGE_PATTERN =
/commit\s?message|提交信息|提交标题|conventional\s?commits|commitlint|header|subject|首行|格式.*不合规|不符合.*(?:规范|模板|约束|规则)/i;
const TEST_PATTERN = /\bFAIL\b|✗|✘|failed\s+tests?|Tests\s+\d+\s+failed|测试失败/i;
const LINT_PATTERN = /eslint|lint\s*错误|✖\s+\d+\s+problem|\d+\s+errors?/i;
const TYPECHECK_PATTERN = /tsc|TS\d{4}|type ?check|类型检查/i;
const PERMISSION_PATTERN = /permission\s+denied|403|401|unauthorized|forbidden|没有权限|权限不足/i;
const PUSH_PATTERN = /non-fast-forward|rejected|failed to push|推送被拒|冲突/i;

/**
* 对 commit/push 失败输出做机械归类。
*
* 只看蒸馏后的尾部诊断(最终拒绝原因通常在末尾),优先级:
* commit-message > test > typecheck > lint > permission > push > unknown。
* commit-message 最优先:pre-commit hook 往往先跑 lint/test(产生大量噪音),
* 最后才以提交信息规范为由拒绝,尾部模式才是真实死因。
*/
export function classifyCommitFailure(diagnostic: string): CommitFailureKind {
const tail = extractCommitRejectionSection(stripAnsiCodes(diagnostic));
const tailLines = tail.split('\n').slice(-30).join('\n');
if (COMMIT_MESSAGE_PATTERN.test(tailLines)) return 'commit-message';
if (TEST_PATTERN.test(tailLines)) return 'test';
if (TYPECHECK_PATTERN.test(tailLines)) return 'typecheck';
if (LINT_PATTERN.test(tailLines)) return 'lint';
if (PERMISSION_PATTERN.test(tailLines)) return 'permission';
if (PUSH_PATTERN.test(tailLines)) return 'push';
return 'unknown';
}

/** 各归类的一句话处置建议,供蒸馏诊断与上层决策使用 */
const KIND_GUIDANCE: Record<CommitFailureKind, string> = {
'commit-message': '提交信息不符合项目规范,应理解规则后重写 message 重试',
lint: 'pre-commit hook 的 lint 检查未通过,应回流修复循环消除新增 error 后重试',
test: 'pre-commit hook 的测试未通过,应回流修复循环修复失败用例后重试',
typecheck: 'pre-commit hook 的类型检查未通过,应回流修复循环消除类型错误后重试',
permission: 'git 权限不足,属于环境/凭据问题,不应重试,需人工介入',
push: 'push 被拒绝(多为分支冲突/非快进),应先同步远端再试,必要时人工介入',
unknown: '无法机械归类,需 LLM 阅读蒸馏诊断后判断',
};

/**
* 把任意 commit/push 失败输出蒸馏为 ≤ maxLines 行的诊断。
* 第一行为归类与建议,其后为尾部关键证据——发布到 MR 的只能是这个,
* 而不是几千行的 hook 原文。
*/
export function distillCommitFailure(rawError: string, maxLines = 10): string {
const kind = classifyCommitFailure(rawError);
const tail = extractCommitRejectionSection(stripAnsiCodes(rawError))
.split('\n')
.filter(line => line.trim().length > 0);
const evidenceBudget = Math.max(1, maxLines - 1);
const evidence = tail
.slice(-evidenceBudget)
.map(line => (line.length > 800 ? `${line.slice(0, 799)}…` : line));
const distilled = [`【提交失败分类: ${kind}】${KIND_GUIDANCE[kind]}`, ...evidence].join('\n');
return distilled.length > 6_000 ? `${distilled.slice(0, 5_999)}…` : distilled;
}

/** commitlint/husky 等静态配置探测结果 */
const CONVENTIONAL_COMMITS_HINT =
'Conventional Commits:格式 <type>(<scope>): <description>,' +
'type 通常为 feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert';

/**
* 静态探测仓库的提交信息规范(第二级兜底)。
*
* 依次检查 commitlint 配置文件、package.json 的 commitlint 键、husky commit-msg 钩子;
* 命中即返回 Conventional Commits 提示。探测不到返回 undefined,
* 由调用方回退到合规通用默认 message。
*/
export function detectCommitConvention(repoRoot: string): string | undefined {
try {
const commitlintFiles = [
'commitlint.config.js',
'commitlint.config.ts',
'commitlint.config.mjs',
'commitlint.config.cjs',
'.commitlintrc',
'.commitlintrc.json',
'.commitlintrc.js',
'.commitlintrc.yml',
'.commitlintrc.yaml',
];
if (commitlintFiles.some(file => existsSync(join(repoRoot, file)))) {
return CONVENTIONAL_COMMITS_HINT;
}
const pkgPath = join(repoRoot, 'package.json');
if (existsSync(pkgPath)) {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as Record<string, unknown>;
if (pkg.commitlint || pkg['commitlint-config']) {
return CONVENTIONAL_COMMITS_HINT;
}
}
if (existsSync(join(repoRoot, '.husky', 'commit-msg'))) {
return CONVENTIONAL_COMMITS_HINT;
}
} catch {
// 探测失败不阻断提交,回退默认
}
return undefined;
}

const CONVENTIONAL_SUBJECT = /^[a-z]+(?:\([^)]*\))?:\s/i;

/** 为朴素主题行补一个 Conventional Commits 前缀(若尚未具备) */
export function ensureConventionalSubject(subject: string, type: string, scope: string): string {
const trimmed = subject.trim();
if (CONVENTIONAL_SUBJECT.test(trimmed)) return trimmed;
return `${type}(${scope}): ${trimmed}`;
}

/** 合规的单 finding 默认提交信息 */
export function buildDefaultFixMessage(finding: {
message: string;
ruleId?: string;
file: string;
line: number;
}): string {
const subject = ensureConventionalSubject(finding.message, 'fix', 'review');
return [
subject,
'',
`规则: ${finding.ruleId ?? 'N/A'}`,
`文件: ${finding.file}:${finding.line}`,
].join('\n');
}

/** 合规的批量修复默认提交信息 */
export function buildDefaultBatchMessage(appliedFiles: string[], deletedFiles: string[]): string {
const total = appliedFiles.length + deletedFiles.length;
const lines = [`fix(review): 批量修复 ${total} 个 Reviewer 问题`, ''];
if (appliedFiles.length > 0) {
lines.push('修改文件:', ...appliedFiles.map(f => `- ${f}`), '');
}
if (deletedFiles.length > 0) {
lines.push('删除文件:', ...deletedFiles.map(f => `- ${f}`), '');
}
return lines.join('\n');
}

/** 合规的删除文件默认提交信息 */
export function buildDefaultDeleteMessage(fileName: string): string {
return `chore(review): 移除不应上传的文件 ${fileName}`;
}
Loading
Loading