别让 AI 瞎读项目:用 Node.js 生成项目上下文

0 阅读8分钟

现在很多人用 AI 写代码,一上来就问:

帮我实现一个用户管理页面

AI 很快就能生成一堆代码。

问题是,它不知道你的项目结构,不知道你用的是 React 还是 Vue,不知道接口目录在哪,不知道组件风格是什么,不知道路由怎么组织,也不知道项目里哪些坑不能碰。

于是它开始猜。

猜目录。
猜技术栈。
猜组件写法。
猜状态管理。
猜接口封装。
猜样式方案。

最后生成的代码看起来很勤奋,合进去一跑,项目像被陌生人装修过一样别扭。

AI 编程最容易被忽略的一点是:模型不是只靠一句需求工作,它更依赖上下文。

所以这篇文章做一个小工具:
用 Node.js 自动扫描项目,生成一份 project-context.md,让 Cursor、Kiro、Claude、ChatGPT 在写代码前先理解项目。

它不调用任何 AI API,也不上传代码,只在本地生成 Markdown 文件。你可以先人工检查,再决定复制哪些内容给 AI。

这一步看起来不酷,但很有用。软件工程里很多有用的东西都不酷,比如日志、测试、文档和不把 .env 提交上去。可惜人类经常只在事故之后才想起它们。


一、为什么 AI 写代码前需要项目上下文?

AI 编程工具确实越来越强,但它们不是你项目里的老员工。

它不知道:

  • 项目用什么技术栈;
  • 路由文件放在哪里;
  • API 请求如何封装;
  • 组件命名规则是什么;
  • 状态管理用什么;
  • 样式是 CSS Modules、Tailwind 还是 Sass;
  • 哪些目录是生成文件;
  • 哪些代码是历史包袱;
  • 哪些接口返回结构不能改;
  • 哪些文件不能随便动。

如果你不给上下文,它只能从你当前贴的代码里猜。

这会带来几个问题:

1. 生成代码不符合项目风格

比如项目里组件都用函数式写法,它突然给你生成一套奇怪的类组件。
项目里接口都走统一 request 封装,它自己写一个 fetch
项目里状态管理已经统一了,它又 invent 一个小型状态宇宙。

这类代码不一定不能跑,但合进去很难看。

2. 改动范围容易失控

你只是想加一个按钮,它顺手帮你重构半个页面。
你只是想补一个 loading,它把接口层、组件层、类型定义全改了。

AI 很热心。热心到有时候像拿着电锯帮你修指甲。

3. Review 成本变高

如果 AI 生成的代码不贴合项目结构,开发者还要花大量时间改回项目原来的风格。

这时候所谓“提效”,就变成了“先快后慢”。


二、我们要做什么?

目标很简单:生成一份 AI 可读的项目上下文文档。

最终输出:

project-context.md

里面包含:

项目基本信息
技术栈和依赖
运行脚本
目录结构
关键文件内容
给 AI 的使用建议
安全提醒

然后你可以这样用:

这是当前项目上下文,请先阅读,不要直接写代码。
后续我会给你具体需求,请严格按照项目结构、技术栈和已有风格生成代码。

这比直接甩一句“帮我写功能”靠谱很多。


三、项目目录示例

假设你的项目是一个前端项目:

my-web-app/
├── src/
│   ├── api/
│   ├── components/
│   ├── pages/
│   ├── router/
│   └── main.tsx
├── package.json
├── vite.config.ts
├── tsconfig.json
└── tools/

我们在 tools 下新建脚本:

mkdir -p tools
touch tools/gen-ai-context.mjs

四、完整 Node.js 脚本

把下面代码写入 tools/gen-ai-context.mjs

import {
  existsSync,
  readdirSync,
  readFileSync,
  statSync,
  writeFileSync,
} from 'node:fs';
import { extname, join, relative } from 'node:path';

const rootDir = process.cwd();

const args = new Map(
  process.argv.slice(2).map((item) => {
    const [key, value = ''] = item.split('=');
    return [key, value];
  })
);

const outputFile = args.get('--out') || 'project-context.md';
const maxFiles = Number(args.get('--maxFiles') || 80);
const maxFileChars = Number(args.get('--maxFileChars') || 5000);
const maxTotalChars = Number(args.get('--maxTotalChars') || 50000);

const includeExtensions = new Set([
  '.js',
  '.jsx',
  '.ts',
  '.tsx',
  '.vue',
  '.svelte',
  '.json',
  '.md',
  '.css',
  '.scss',
  '.less',
]);

const importantFiles = new Set([
  'package.json',
  'vite.config.js',
  'vite.config.ts',
  'webpack.config.js',
  'next.config.js',
  'next.config.mjs',
  'nuxt.config.ts',
  'tsconfig.json',
  'README.md',
]);

const excludeDirs = new Set([
  'node_modules',
  '.git',
  'dist',
  'build',
  'coverage',
  '.next',
  '.nuxt',
  '.output',
  '.turbo',
  '.cache',
]);

const excludeFiles = new Set([
  'package-lock.json',
  'pnpm-lock.yaml',
  'yarn.lock',
  '.env',
  '.env.local',
  '.env.production',
  '.env.development',
]);

const sensitivePatterns = [
  /AKIA[0-9A-Z]{16}/,
  /AIza[0-9A-Za-z_-]{35}/,
  /sk-[A-Za-z0-9_-]{20,}/,
  /(password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^'"]{8,}['"]/i,
];

function toPosixPath(filePath) {
  return filePath.replace(/\/g, '/');
}

function isExcludedDir(name) {
  return excludeDirs.has(name);
}

function isExcludedFile(name) {
  return excludeFiles.has(name);
}

function shouldIncludeFile(filePath) {
  const relativePath = toPosixPath(relative(rootDir, filePath));
  const fileName = relativePath.split('/').at(-1);

  if (isExcludedFile(fileName)) {
    return false;
  }

  if (importantFiles.has(fileName)) {
    return true;
  }

  return includeExtensions.has(extname(fileName));
}

function walk(dir, result = []) {
  const entries = readdirSync(dir, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = join(dir, entry.name);

    if (entry.isDirectory()) {
      if (!isExcludedDir(entry.name)) {
        walk(fullPath, result);
      }
      continue;
    }

    if (entry.isFile() && shouldIncludeFile(fullPath)) {
      result.push(fullPath);
    }
  }

  return result;
}

function readJson(filePath) {
  if (!existsSync(filePath)) {
    return null;
  }

  try {
    return JSON.parse(readFileSync(filePath, 'utf8'));
  } catch {
    return null;
  }
}

function readTextFile(filePath) {
  try {
    return readFileSync(filePath, 'utf8');
  } catch {
    return '';
  }
}

function truncate(text, maxChars) {
  if (text.length <= maxChars) {
    return text;
  }

  return `${text.slice(0, maxChars)}\n\n[内容过长,已截断]`;
}

function detectSensitiveText(text) {
  return sensitivePatterns.some((pattern) => pattern.test(text));
}

function buildTree(files) {
  return files
    .map((file) => `- ${toPosixPath(relative(rootDir, file))}`)
    .join('\n');
}

function collectPackageInfo() {
  const packagePath = join(rootDir, 'package.json');
  const packageJson = readJson(packagePath);

  if (!packageJson) {
    return '未检测到 package.json 或 JSON 格式异常。';
  }

  const scripts = packageJson.scripts || {};
  const dependencies = packageJson.dependencies || {};
  const devDependencies = packageJson.devDependencies || {};

  const scriptText = Object.keys(scripts).length
    ? Object.entries(scripts).map(([key, value]) => `- ${key}: ${value}`).join('\n')
    : '- 未配置 scripts';

  const deps = [
    ...Object.keys(dependencies),
    ...Object.keys(devDependencies),
  ].sort();

  const depsText = deps.length
    ? deps.slice(0, 80).map((name) => `- ${name}`).join('\n')
    : '- 未检测到依赖';

  return `## package.json 摘要

项目名称:${packageJson.name || '未命名项目'}

### scripts

${scriptText}

### dependencies / devDependencies

${depsText}`;
}

function collectFileSections(files) {
  const sections = [];
  let totalChars = 0;

  for (const file of files.slice(0, maxFiles)) {
    const relativePath = toPosixPath(relative(rootDir, file));
    const content = readTextFile(file);

    if (!content.trim()) {
      continue;
    }

    if (detectSensitiveText(content)) {
      sections.push(`## ${relativePath}

[跳过] 该文件疑似包含 token、secret、password、api key 等敏感信息,请人工确认后再决定是否提供给 AI。`);
      continue;
    }

    const safeContent = truncate(content, maxFileChars);
    const ext = extname(file).replace('.', '') || 'text';

    const section = `## ${relativePath}

```${ext}
${safeContent}
````;

    totalChars += section.length;

    if (totalChars > maxTotalChars) {
      sections.push(`

[提示] 上下文内容超过 ${maxTotalChars} 字符,后续文件已省略。建议缩小扫描范围或分模块生成上下文。`);
      break;
    }

    sections.push(section);
  }

  return sections.join('\n\n');
}

function getProjectType(packageInfo) {
  const lower = packageInfo.toLowerCase();

  if (lower.includes('vite')) return '可能是 Vite 项目';
  if (lower.includes('next')) return '可能是 Next.js 项目';
  if (lower.includes('nuxt')) return '可能是 Nuxt 项目';
  if (lower.includes('vue')) return '可能是 Vue 项目';
  if (lower.includes('react')) return '可能是 React 项目';

  return '未自动识别,请人工补充项目类型';
}

function main() {
  const allFiles = walk(rootDir);
  const selectedFiles = allFiles.filter((file) => {
    const relativePath = toPosixPath(relative(rootDir, file));

    if (relativePath.startsWith('tools/gen-ai-context')) {
      return false;
    }

    return true;
  });

  const packageInfo = collectPackageInfo();
  const projectType = getProjectType(packageInfo);
  const tree = buildTree(selectedFiles.slice(0, maxFiles));
  const fileSections = collectFileSections(selectedFiles);

  const markdown = `# AI 项目上下文

> 这份文档由 tools/gen-ai-context.mjs 自动生成。  
> 使用前请人工检查是否包含敏感信息,不要把密码、Token、密钥、公司机密、用户隐私直接提供给外部 AI 工具。

## 项目识别

${projectType}

${packageInfo}

## 目录结构摘要

${tree || '未检测到可用文件。'}

## 给 AI 的使用要求

请先阅读项目上下文,再根据我的具体需求输出代码。

要求:

1. 不要擅自改变项目技术栈;
2. 不要新增不必要的依赖;
3. 优先复用已有目录结构和代码风格;
4. 修改前先说明会影响哪些文件;
5. 涉及接口、鉴权、环境变量时必须提醒我人工确认;
6. 输出代码时说明放在哪个文件;
7. 如果上下文不足,请先提问,不要直接猜。

## 关键文件内容

${fileSections || '未收集到关键文件内容。'}
`;

  writeFileSync(outputFile, markdown, 'utf8');
  console.log(`AI 项目上下文已生成:${outputFile}`);
  console.log(`收集文件数:${selectedFiles.length}`);
}

main();

五、配置 package.json 脚本

package.json 里加一条命令:

{
  "scripts": {
    "ai:context": "node tools/gen-ai-context.mjs"
  }
}

然后执行:

npm run ai:context

或者 pnpm 项目执行:

pnpm ai:context

生成:

project-context.md

如果想控制输出文件名:

node tools/gen-ai-context.mjs --out=docs-ai-context.md

如果项目比较大,可以限制文件数量:

node tools/gen-ai-context.mjs --maxFiles=40 --maxTotalChars=30000

六、生成的上下文长什么样?

生成后的 project-context.md 大概是这样:

# AI 项目上下文

## 项目识别

可能是 Vite 项目

## package.json 摘要

项目名称:my-web-app

### scripts

- dev: vite
- build: vite build
- preview: vite preview
- lint: eslint .

### dependencies / devDependencies

- @vitejs/plugin-react
- typescript
- vite
- react
- react-dom

## 目录结构摘要

- src/api/user.ts
- src/components/UserCard.tsx
- src/pages/UserPage.tsx
- src/router/index.ts
- vite.config.ts
- tsconfig.json

## 给 AI 的使用要求

1. 不要擅自改变项目技术栈;
2. 不要新增不必要的依赖;
3. 优先复用已有目录结构和代码风格;
...

这个文档的价值在于:
它不是让 AI “看完整个项目”,而是先给 AI 一个项目地图。

AI 有了地图,才不至于每次都像新员工第一天入职,连厕所在哪都要靠猜。


七、怎么配合 Cursor、Kiro、Claude、ChatGPT 使用?

生成文档后,不建议直接全量丢给 AI 然后让它随便发挥。

更好的用法是:

这是项目上下文,请先阅读,不要写代码。
阅读后请用 5 点总结你理解的项目结构、技术栈和开发约束。
如果你发现上下文不足,请先问我。

等 AI 总结完,再给具体需求:

基于上面的项目上下文,请帮我新增一个用户详情页。

要求:
1. 复用现有 api/request 封装;
2. 页面放在 src/pages/UserDetail.tsx;
3. 组件风格参考 src/components/UserCard.tsx;
4. 补充 loading、empty、error 状态;
5. 不要新增依赖;
6. 输出需要修改的文件列表。

这样 AI 的回答会稳定很多。

尤其是 Cursor 和 Kiro 这类 AI 编程工具,本身就适合结合项目上下文做开发辅助。你给它一份清晰的 project-context.md,比直接让它在项目里自己乱翻要稳。

Claude 和 ChatGPT 也一样。
它们擅长理解长文本,但前提是你给的上下文是整理过的,而不是一堆文件碎片。


八、为什么要过滤 node_modules、dist、lock 文件?

脚本里过滤了这些目录和文件:

const excludeDirs = new Set([
  'node_modules',
  '.git',
  'dist',
  'build',
  'coverage',
  '.next',
  '.nuxt',
  '.output',
  '.turbo',
  '.cache',
]);

const excludeFiles = new Set([
  'package-lock.json',
  'pnpm-lock.yaml',
  'yarn.lock',
  '.env',
  '.env.local',
  '.env.production',
  '.env.development',
]);

原因很简单:

  • node_modules 没必要给 AI;
  • distbuild 是构建产物;
  • lock 文件太长,价值低;
  • .env 可能包含敏感信息;
  • .git 不是本次任务重点;
  • coverage、cache 都是噪音。

AI 上下文不是越多越好。

给太多无关内容,模型反而更容易分心。
这就像开会时把全公司群聊记录都打印出来,指望大家更快理解需求。不能说完全没用,只能说很像折磨。


九、为什么要扫描敏感信息?

脚本里做了一层很粗的敏感信息检测:

const sensitivePatterns = [  /AKIA[0-9A-Z]{16}/,
  /AIza[0-9A-Za-z_-]{35}/,
  /sk-[A-Za-z0-9_-]{20,}/,
  /(password|passwd|pwd|secret|token|api[_-]?key)\s*[:=]\s*['"][^'"]{8,}['"]/i,
];

如果文件里出现类似 token、secret、password、apiKey 的内容,它会跳过并提醒人工确认。

这不是安全审计。

它只是一个低成本兜底。

真正使用 AI 编程工具时,仍然要注意:

  • 不要上传 .env
  • 不要上传真实密钥;
  • 不要上传用户隐私;
  • 不要上传公司内部敏感业务数据;
  • 不要把生产接口、鉴权信息直接暴露给外部工具;
  • 提供上下文前先人工检查。

安全这事不能靠“我觉得应该没事”。
“应该没事”在工程里通常是事故预告片。


十、适合生成哪些文件?

不是所有源码都应该放进上下文。

比较适合放的文件:

package.json
vite.config.ts
tsconfig.json
README.md
src/main.tsx
src/router/index.ts
src/api/request.ts
src/api/*.ts
src/components/*.tsx
src/pages/*.tsx
src/stores/*.ts

不建议放:

.env
.env.local
大体积 mock 数据
构建产物
日志文件
图片资源
第三方库源码
包含密钥的配置
包含用户隐私的数据

上下文的目标不是“把项目搬给 AI”。

目标是让 AI 理解:

  • 这个项目是什么;
  • 用什么技术栈;
  • 代码怎么组织;
  • 常见写法是什么;
  • 哪些规则不能破坏;
  • 本次需求应该改哪些地方。

这才是有效上下文。


十一、可以再加一个 focus 模式

如果项目很大,你可能不想扫描整个项目。

可以扩展一个 --focus 参数,只扫描某个目录。

比如:

node tools/gen-ai-context.mjs --focus=src/pages

下面是一个简单改法。

先读取参数:

const focusDir = args.get('--focus') || '';

然后把扫描入口改成:

const scanRoot = focusDir ? join(rootDir, focusDir) : rootDir;

if (!existsSync(scanRoot) || !statSync(scanRoot).isDirectory()) {
  console.error(`扫描目录不存在:${scanRoot}`);
  process.exit(1);
}

const allFiles = walk(scanRoot);

这样你就可以针对某个模块生成上下文。

比如只让 AI 看用户模块:

node tools/gen-ai-context.mjs --focus=src/pages/user --out=user-context.md

这在大型项目里很实用。

AI 不需要一上来理解整个系统。
先让它理解当前模块,效果反而更好。
给上下文也要讲边界,不然 AI 和人一样,会在信息过载里开始胡说八道。


十二、加一个 .aiignore 会更好

如果你想更灵活,可以模仿 .gitignore 做一个 .aiignore

比如:

src/mock/
src/legacy/
docs/private/
*.log
*.local.ts

然后脚本读取 .aiignore,跳过这些路径。

最小实现可以先做简单字符串匹配:

function loadAiIgnore() {
  const ignorePath = join(rootDir, '.aiignore');

  if (!existsSync(ignorePath)) {
    return [];
  }

  return readFileSync(ignorePath, 'utf8')
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line && !line.startsWith('#'));
}

const aiIgnoreRules = loadAiIgnore();

function matchAiIgnore(relativePath) {
  return aiIgnoreRules.some((rule) => relativePath.includes(rule));
}

然后在 shouldIncludeFile 里加:

if (matchAiIgnore(relativePath)) {
  return false;
}

这样团队可以自己控制哪些内容不提供给 AI。

这一步很适合团队项目。
毕竟每个项目都有一些“不要让外人看见”的历史遗迹。
有些是敏感信息,有些是代码质量,有些两者都是。惨得很完整。


十三、团队里怎么落地?

个人项目可以直接用:

pnpm ai:context

团队项目建议这样落地:

1. 放到 tools 目录

tools/gen-ai-context.mjs

2. package.json 加脚本

{
  "scripts": {
    "ai:context": "node tools/gen-ai-context.mjs"
  }
}

3. README 加说明

## AI 编程上下文生成

执行:

```bash
pnpm ai:context

生成 project-context.md。

使用前请人工检查敏感信息,不要上传 .env、Token、用户隐私和公司机密。


### 4. PR 模板里加检查项

```markdown
## AI 辅助开发确认

- [ ] 已确认 AI 生成代码符合项目结构
- [ ] 已检查没有上传敏感信息
- [ ] 已人工 Review AI 修改
- [ ] 已补充必要测试

这样团队不会只停留在“大家用 AI 注意点”这种口头文明阶段。

口头提醒在工程里很脆弱。
真正能留下来的,是脚本、文档、检查项和流程。


十四、这套方案的局限

这个脚本不是万能的。

它解决的是“给 AI 一个项目上下文入口”,不是让 AI 完全理解你的系统。

局限包括:

  1. 它不能理解业务规则;
  2. 它不能判断所有安全风险;
  3. 它不能替代代码 Review;
  4. 它不能替代测试;
  5. 它不适合直接输出公司敏感代码;
  6. 项目太大时仍然需要分模块生成;
  7. AI 读完上下文后仍然可能理解错误。

所以你需要把它当成辅助工具,而不是自动驾驶。

更好的用法是:

让 AI 先总结它理解的项目结构
↓
你确认它有没有理解错
↓
再给具体需求
↓
让它输出改动方案
↓
你人工 Review
↓
再决定是否改代码

AI 编程不是“我提一句需求,它替我完成一切”。

那叫许愿。

工程不是许愿池,虽然很多需求文档写得确实像。


十五、总结

这篇文章用 Node.js 做了一个本地脚本:

扫描项目文件
↓
过滤无关目录
↓
跳过敏感配置
↓
提取 package.json
↓
生成目录结构
↓
汇总关键文件
↓
输出 project-context.md

它的核心价值不是复杂,而是稳定。

当你给 Cursor、Kiro、Claude、ChatGPT 提供一个清晰的项目上下文,AI 才更容易生成贴合项目风格的代码。

AI 编程工具真正需要的,不只是更强模型。

还需要你给它更好的上下文、更清楚的边界、更明确的规则。

否则它写得越快,你返工也越快。

如果你长期使用 ChatGPT Plus、Claude Pro、Cursor、Kiro、Gemini Advanced、Grok 这类 AI 工具,也可以把 gpt68.com 作为第三方 AI 会员充值平台入口之一去了解。它解决的是订阅充值流程问题,不是替代工具本身。使用前建议看清楚套餐说明、账号要求、到账说明和售后规则。

工具负责辅助,开发者负责判断。

这个分工虽然冷酷,但至少不会骗你。