baodan/.agents/skills/impeccable/scripts/lib/staleness-deep.mjs
wsb1224 e3479f0546 上线阻断问题全部修复
#	问题	修复	文件
1	前端构建失败(引号错误)	size="small type=" → size="small" type="	PosterHistoryPage.vue
2	migrate_014 ORM vs 缺失列	全部改为原始 SQL,不再引用 ORM 模型	migrate_014.py
3	cleanup 字段名错误	output_path → ppt_path	cleanup.py
4	文案生成 case 越权	添加 case.user_id != user_id 校验	poster/service.py
5	存储路径未接通持久化卷	全部改用 get_storage_root()(默认 /app/api/storage/insurance)	config.py, ppt/routes.py, poster/service.py, poster/tasks.py
高风险问题修复
#	问题	修复	文件
6	migrate_019 rollback 撤销成功字段	每个 ALTER 后立即 commit,失败只回滚当前语句	migrate_019.py
7	迁移锁 Windows 不兼容 + 句柄未持久化	全局变量保存锁句柄,支持 Windows msvcrt	api/insurance/db/__init__.py
8	PDF 校验异常时放行	异常返回 False(文件损坏)	security.py
9	健康检查始终返回成功	缺少关键资源时返回 503 + missing 列表	poster/routes.py
10	短密钥掩码泄露原值	≤4 字符返回 ****	ppt_admin_service.py
11	设置无键名白名单	添加 _ALLOWED_SETTING_KEYS 白名单	ppt_admin_service.py
12	容器重启任务永久 stuck	添加 recover_stale_tasks() 启动恢复函数	poster/tasks.py, ppt/parse_worker.py
2026-07-27 13:52:09 +08:00

411 lines
17 KiB
JavaScript

/**
* Tier 2 staleness checks: the ones that cost too much to run on every session
* boot. Shelling out to git, walking workspaces, resolving hook script paths,
* and validating ignore lists against the live rule registry all belong here.
*
* The boot tier answers "did an older Impeccable write this". This tier also
* asks "does it still describe the code", which no file comparison can settle
* on its own. Where the answer needs judgment, the finding reports a measured
* proxy and says it is a proxy. It never claims a document is wrong because a
* number is large.
*
* Same finding shape and severities as lib/staleness.mjs.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const VISUAL_SOURCE_DIRS = ['src', 'app', 'pages', 'components', 'site', 'styles', 'public'];
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
agents: ['.codex/hooks.json'],
cursor: ['.cursor/hooks.json'],
github: ['.github/hooks/impeccable.json'],
grok: ['.grok/hooks/impeccable.json'],
});
const HOOK_SCRIPT_MARKERS = [
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
];
// Retired live-mode state locations. impeccable-paths still reads these as
// fallbacks; reporting them is what eventually lets the fallbacks go.
const LEGACY_LIVE_PATHS = ['.impeccable-live.json', '.impeccable-live'];
function finding({ id, artifact, filePath = null, severity, summary, fix }) {
return { id, artifact, path: filePath, severity, summary, fix };
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function toRelative(filePath, root) {
if (!filePath) return null;
const rel = path.relative(root, filePath);
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
? rel.split(path.sep).join('/')
: filePath;
}
function git(args, cwd) {
try {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
}).trim();
} catch {
return null;
}
}
// ─── DESIGN.md truth drift ─────────────────────────────────────────────────
/**
* How much UI work has landed since DESIGN.md was last touched, measured in
* commits to the visual source directories. A proxy, and reported as one: a
* large number means the document is worth re-reading, not that it is wrong.
* Silent outside a git repo, on an untracked DESIGN.md, and when the count is
* small enough to be ordinary maintenance.
*/
export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
if (!designPath || !projectRoot) return [];
if (!git(['rev-parse', '--is-inside-work-tree'], projectRoot)) return [];
const relDesign = toRelative(designPath, projectRoot);
const lastDesignCommit = git(['log', '-1', '--format=%H', '--', relDesign], projectRoot);
if (!lastDesignCommit) return [];
const dirs = VISUAL_SOURCE_DIRS.filter((dir) => fs.existsSync(path.join(projectRoot, dir)));
if (!dirs.length) return [];
const log = git(
['log', '--oneline', `${lastDesignCommit}..HEAD`, '--', ...dirs],
projectRoot,
);
if (log === null) return [];
const commits = log ? log.split('\n').filter(Boolean).length : 0;
if (commits < threshold) return [];
const when = git(['log', '-1', '--format=%ad', '--date=short', '--', relDesign], projectRoot);
return [finding({
id: 'design-md-drift',
artifact: 'DESIGN.md',
filePath: relDesign,
severity: 'route',
summary: `${commits} commits have touched ${dirs.join(', ')} since ${relDesign} was last edited`
+ `${when ? ` (${when})` : ''}. This counts commits, not contradictions: it says the document is worth `
+ 're-reading, not that it is wrong.',
fix: 'Read DESIGN.md against the current tokens and components before trusting it as authority. '
+ 'If it has genuinely drifted, `document` regenerates it from the code.',
})];
}
/**
* Canonical DESIGN.md sections that carry nothing. Distinct from truth drift:
* a section can be absent because it never applied, so this is reported as a
* documentation gap for a human to judge, never as an error.
*/
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
if (!design || typeof parseDesignMd !== 'function') return [];
let model;
try {
model = parseDesignMd(design);
} catch {
return [];
}
const missing = ['colors', 'typography', 'components']
.filter((section) => !model[section]);
if (!missing.length) return [];
return [finding({
id: 'design-md-coverage',
artifact: 'DESIGN.md',
filePath: designPath,
severity: 'mention',
summary: `${designPath || 'DESIGN.md'} has no ${missing.join(', ')} section. `
+ 'Agents generating new screens get no normative guidance for those, and the live design panel renders '
+ 'generic approximations in their place.',
fix: 'Ask whether the section never applied or was never written. `document` fills it from the code if the '
+ 'project has the answer in its CSS.',
})];
}
// ─── detector ignore lists ─────────────────────────────────────────────────
/**
* Ignore entries that no longer match anything: rule ids the engine dropped or
* renamed, and file paths that are gone. Both read as working suppressions
* until someone checks, and a dead rule ignore also hides that the rule left.
*/
export function checkDetectorIgnores({ projectRoot, knownRuleIds = null }) {
const findings = [];
if (!projectRoot) return findings;
for (const name of ['config.json', 'config.local.json']) {
const filePath = path.join(projectRoot, '.impeccable', name);
const raw = readJson(filePath);
const detector = raw?.detector;
if (!detector || typeof detector !== 'object') continue;
const rel = toRelative(filePath, projectRoot);
if (knownRuleIds && Array.isArray(detector.ignoreRules)) {
const unknown = detector.ignoreRules
.map((rule) => String(rule || '').trim().toLowerCase())
.filter((rule) => rule && rule !== '*' && !knownRuleIds.has(rule));
if (unknown.length) {
findings.push(finding({
id: 'detector-ignore-rules-unknown',
artifact: 'config.json',
filePath: rel,
severity: 'mention',
summary: `${rel} ignores rule id(s) the detector does not have: `
+ `${unknown.map((rule) => `\`${rule}\``).join(', ')}. Either the rule was renamed or removed, or the `
+ 'id was mistyped and has never suppressed anything.',
fix: 'Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.',
}));
}
}
if (Array.isArray(detector.ignoreFiles)) {
const missing = detector.ignoreFiles
.map((entry) => String(entry || '').trim())
.filter((entry) => entry && !entry.includes('*') && !fs.existsSync(path.join(projectRoot, entry)));
if (missing.length) {
findings.push(finding({
id: 'detector-ignore-files-missing',
artifact: 'config.json',
filePath: rel,
severity: 'mention',
summary: `${rel} ignores file path(s) that no longer exist: `
+ `${missing.map((entry) => `\`${entry}\``).join(', ')}.`,
fix: 'Ask whether the file moved (repoint the entry) or was deleted (drop it). '
+ 'A stale entry silently stops covering the file that replaced it.',
}));
}
}
}
return findings;
}
// ─── hook installation ─────────────────────────────────────────────────────
function collectHookCommands(value, out = []) {
if (typeof value === 'string') {
if (HOOK_SCRIPT_MARKERS.some((marker) => value.includes(marker))) out.push(value);
return out;
}
if (Array.isArray(value)) {
for (const entry of value) collectHookCommands(entry, out);
return out;
}
if (value && typeof value === 'object') {
for (const entry of Object.values(value)) collectHookCommands(entry, out);
}
return out;
}
// Pull the script path out of a hook command line. Commands look like
// `node .claude/skills/impeccable/scripts/hook.mjs` and may be quoted or carry
// trailing arguments.
function hookScriptPathFrom(command) {
const match = String(command).match(/(\S*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
return match ? match[1].replace(/^['"]|['"]$/g, '') : null;
}
/**
* A hook whose script path does not resolve is a silent no-op, and the user
* believes the project is covered. Also catches the contradiction of an
* installed manifest against `hook.enabled: false`.
*/
export function checkHookInstallation({ projectRoot, repoRoot, providerId }) {
const findings = [];
const manifests = HOOK_MANIFESTS_BY_PROVIDER[providerId] || [];
if (!manifests.length) return findings;
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
let installedAt = null;
for (const root of roots) {
for (const rel of manifests) {
const manifestPath = path.join(root, rel);
const raw = readJson(manifestPath);
if (!raw?.hooks) continue;
const commands = collectHookCommands(raw.hooks);
if (!commands.length) continue;
installedAt = toRelative(manifestPath, projectRoot || root);
const broken = commands.filter((command) => {
const scriptPath = hookScriptPathFrom(command);
if (!scriptPath) return false;
const abs = path.isAbsolute(scriptPath) ? scriptPath : path.join(root, scriptPath);
return !fs.existsSync(abs);
});
if (broken.length) {
findings.push(finding({
id: 'hook-script-missing',
artifact: 'hook manifest',
filePath: installedAt,
severity: 'mention',
summary: `${installedAt} installs the design hook, but its script path does not exist: `
+ `${broken.map((command) => `\`${command}\``).join(', ')}. The hook runs as a no-op, so UI edits `
+ 'have been going unscanned while the project looks covered.',
fix: `Reinstall with \`impeccable hooks on\`, which rewrites the manifest against the skill's current location.`,
}));
}
}
}
if (installedAt) {
for (const root of roots) {
for (const name of ['config.json', 'config.local.json']) {
const raw = readJson(path.join(root, '.impeccable', name));
if (raw?.hook && raw.hook.enabled === false) {
findings.push(finding({
id: 'hook-enabled-conflict',
artifact: 'config.json',
filePath: toRelative(path.join(root, '.impeccable', name), projectRoot || root),
severity: 'mention',
summary: `${installedAt} installs the design hook while this config sets \`hook.enabled: false\`, `
+ 'so the hook fires and then declines to scan.',
fix: 'Ask which was intended: `impeccable hooks on` to enable, or `impeccable hooks off` to uninstall '
+ 'the manifest entry as well.',
}));
return findings;
}
}
}
}
return findings;
}
// ─── retired locations ─────────────────────────────────────────────────────
export function checkLegacyLiveState({ projectRoot }) {
if (!projectRoot) return [];
const present = LEGACY_LIVE_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
if (!present.length) return [];
return [finding({
id: 'legacy-live-state',
artifact: 'live state',
filePath: present.join(', '),
severity: 'auto',
summary: `Live-mode state sits in retired location(s): ${present.map((rel) => `\`${rel}\``).join(', ')}. `
+ 'Current live mode writes under `.impeccable/live/`.',
fix: 'These are read only through backward-compatible fallbacks and are safe to delete once no live session '
+ 'is running. No user decision is needed.',
})];
}
// ─── monorepo sweep ────────────────────────────────────────────────────────
/**
* Per-workspace context, plus the case worth acting on: a workspace with
* native build files inheriting a repo-root PRODUCT.md that says web. Each
* such app gets web guidance and never loads the native references, and
* nothing at boot reports it because the root record parses cleanly.
*
* `candidates` comes from context.mjs's discovery so the walk is not repeated.
*/
export function checkWorkspaces({ repoRoot, candidates = [], checkNativePlatformEvidence, extractPlatform, readFile }) {
if (!repoRoot || !candidates.length) return { findings: [], workspaces: [] };
const findings = [];
const workspaces = [];
for (const candidate of candidates) {
const workspaceRoot = path.join(repoRoot, candidate.path);
const productPath = candidate.productPath ? path.join(repoRoot, candidate.productPath) : null;
const product = productPath && readFile ? readFile(productPath) : null;
const platform = extractPlatform ? extractPlatform(product) : null;
workspaces.push({
name: candidate.name,
path: candidate.path,
productStatus: candidate.productStatus,
productPath: candidate.productPath,
designStatus: candidate.designStatus,
designPath: candidate.designPath,
platform: platform || (product ? 'web (default)' : null),
});
if (!checkNativePlatformEvidence) continue;
const native = checkNativePlatformEvidence({
projectRoot: workspaceRoot,
platform,
product,
productPath: candidate.productPath,
});
for (const entry of native) {
findings.push(finding({
id: 'workspace-platform-native-evidence',
artifact: 'PRODUCT.md',
filePath: candidate.productPath || `${candidate.path}/PRODUCT.md`,
severity: 'mention',
summary: `Workspace \`${candidate.path}\` ${
candidate.productStatus === 'inherited'
? 'inherits the repo-root PRODUCT.md'
: 'has a PRODUCT.md'
} that resolves to web, but the workspace itself carries native build files. ${entry.summary}`,
fix: candidate.productStatus === 'inherited'
? `Give \`${candidate.path}\` its own PRODUCT.md with the right \`## Platform\`. `
+ 'An inherited record cannot describe two platforms at once.'
: entry.fix,
}));
}
}
const inherited = workspaces.filter((entry) => entry.productStatus === 'inherited');
if (inherited.length) {
findings.push(finding({
id: 'workspace-context-inherited',
artifact: 'PRODUCT.md',
filePath: null,
severity: 'mention',
summary: `${inherited.length} of ${workspaces.length} workspace(s) inherit the repo-root PRODUCT.md: `
+ `${inherited.map((entry) => `\`${entry.path}\``).join(', ')}. Inheritance is intended; whether one `
+ 'record truthfully describes these apps is not something this check can tell.',
fix: 'Ask the user whether the inherited record describes each app. Where it does not, `init` in that '
+ 'workspace writes a child PRODUCT.md that overrides it.',
}));
}
return { findings, workspaces };
}
// ─── rule registry ─────────────────────────────────────────────────────────
/**
* Rule ids from the bundled detector, or null when it cannot be resolved (a
* partial install, or a harness that ships the skill without the engine).
* Null means "cannot check", which the ignore-rule check treats as skip rather
* than as every id being unknown.
*/
export async function loadKnownRuleIds(scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
// Same two locations detect.mjs resolves: the bundled copy in an installed
// skill, then the source-repo engine when running from a checkout.
const candidates = [
path.join(scriptsDir, 'detector', 'detect-antipatterns.mjs'),
path.join(scriptsDir, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!detectorPath) return null;
try {
const { ANTIPATTERNS } = await import(pathToFileURL(detectorPath).href);
if (!Array.isArray(ANTIPATTERNS)) return null;
return new Set(ANTIPATTERNS.map((rule) => String(rule.id).toLowerCase()));
} catch {
return null;
}
}