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
17 changes: 16 additions & 1 deletion cli/schemas/output.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@
"additionalProperties": false,
"required": [
"query",
"searchMode",
"results",
"pagination"
],
Expand All @@ -382,6 +383,13 @@
"type": "string",
"minLength": 1
},
"searchMode": {
"type": "string",
"enum": [
"lexical",
"cjk-bigram-fallback"
]
},
"results": {
"type": "array",
"items": {
Expand Down Expand Up @@ -859,12 +867,19 @@
"type": "object",
"additionalProperties": false,
"required": [
"created"
"created",
"next"
],
"properties": {
"created": {
"type": "string",
"minLength": 1
},
"next": {
"type": [
"string",
"null"
]
}
}
},
Expand Down
13 changes: 10 additions & 3 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P

program
.command("search")
.description("按词法检索 llmdoc 文档(front matter、标题与正文,返回 snippet)")
.description("按词法检索 llmdoc 文档(自动中文分词,必要时使用 CJK bigram 降级)")
.argument("<query>", "检索词")
.option("--topic <topic>", "限定 topic")
.option("--kind <kind>", "限定类型: architecture | guide | reference")
Expand Down Expand Up @@ -192,6 +192,10 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P
program
.command("init-state")
.description("首次生成 llmdoc/meta.json 台账骨架(validatedRevision 全部为 null)")
.addHelpText(
"after",
"\n前置: Git HEAD 必须已有真实 commit。生成后先 validate,再用 commit --all 完成 bootstrap。"
)
.action(async () => {
const { runInitState } = await import("./commands/init-state.js");
const rootDir = findProjectRoot(cwd);
Expand Down Expand Up @@ -230,14 +234,17 @@ export async function runCli(argv: string[], cwd = process.cwd(), stdin = ""): P
.argument("<path>", "目标相对路径,如 api-client/retry-policy.mdx")
.requiredOption("--kind <kind>", "文档类型: architecture | guide | reference")
.option("--description <description>", "front matter 一句话描述")
.addHelpText(
"after",
"\n首次创建时会自动建立 llmdoc/;完成初始文档后运行 init-state → validate → commit --all。"
)
.action((targetPath, commandOptions) => {
const rootDir = findProjectRoot(cwd);
output.push(
writeOutput(
"new",
runNew({
...globalOptions,
cwd: rootDir,
cwd,
path: targetPath,
kind: commandOptions.kind,
description: commandOptions.description
Expand Down
18 changes: 14 additions & 4 deletions cli/src/commands/init-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";

import { CliError } from "../lib/errors.js";
import { findProjectRoot } from "../lib/fs.js";
import { isUnbornHead } from "../lib/git.js";
import { readWorkspaceGitState } from "../lib/state.js";
import { loadWorkspace } from "../lib/workspace.js";
import { MetaLedger } from "../types.js";
Expand All @@ -21,8 +22,16 @@ export function runInitState(options: InitStateOptions): unknown {
throw new CliError("llmdoc/meta.json 已存在;init-state 只用于首次建立台账,不覆盖现有状态。");
}
const git = readWorkspaceGitState(workspace);
if (!git.available || !git.headRevision) {
throw new CliError(git.degradedReason ?? "无法解析 HEAD commit,init-state 需要 git 仓库。");
if (!git.available) {
throw new CliError("init-state 需要 Git 仓库;请先运行 `git init` 并创建一次真实的初始提交。");
}
if (!git.headRevision) {
if (isUnbornHead(rootDir)) {
throw new CliError(
"HEAD 尚无 commit(当前分支尚未创建首次提交)。请先创建一次真实的初始提交;空仓库可运行 `git commit --allow-empty -m \"chore: initial commit\"`。"
);
}
throw new CliError(`${git.degradedReason ?? "无法解析 HEAD commit。"}请先修复 Git HEAD,再运行 init-state。`);
}

const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
Expand All @@ -47,8 +56,9 @@ export function runInitState(options: InitStateOptions): unknown {
status: "success",
documents: workspace.documents.length,
baselineRevision: git.headRevision,
next: "npx @tokenroll/llmdoc fingerprint --all"
next:
"npx -y @tokenroll/llmdoc validate && npx -y @tokenroll/llmdoc commit --all -m \"docs: bootstrap llmdoc\""
};
}
return `initialized llmdoc/meta.json: ${workspace.documents.length} documents (validatedRevision: null), baseline ${git.headRevision.slice(0, 7)}\nnext: 验证文档内容后运行 \`npx @tokenroll/llmdoc fingerprint --all\` 烙印 revision`;
return `initialized llmdoc/meta.json: ${workspace.documents.length} documents (validatedRevision: null), baseline ${git.headRevision.slice(0, 7)}\nnext: 先运行 \`npx -y @tokenroll/llmdoc validate\`;全部通过后运行 \`npx -y @tokenroll/llmdoc commit --all -m "docs: bootstrap llmdoc"\` 收尾`;
}
14 changes: 10 additions & 4 deletions cli/src/commands/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from "node:path";

import { parseDocTargetShape, assertDocKindMatchesShape, assertDocumentKind } from "../lib/doc-shape.js";
import { CliError } from "../lib/errors.js";
import { ensureDirectory, findProjectRoot, normalizeRepoRelativePath, resolveInsideRoot } from "../lib/fs.js";
import { ensureDirectory, findProjectRootForNew, normalizeRepoRelativePath, resolveInsideRoot } from "../lib/fs.js";
import { packageRootFromImport } from "../lib/package-root.js";
import { loadWorkspace } from "../lib/workspace.js";
import { DocumentKind } from "../types.js";
Expand All @@ -17,7 +17,7 @@ interface NewOptions {
}

export function runNew(options: NewOptions): unknown {
const rootDir = findProjectRoot(options.cwd);
const rootDir = findProjectRootForNew(options.cwd);
const repoRelativePath = normalizeDocDestination(options.path);
const kind = assertDocumentKind(options.kind);
const shape = parseDocTargetShape(repoRelativePath);
Expand All @@ -37,15 +37,21 @@ export function runNew(options: NewOptions): unknown {
.replace("__KIND__", () => kind)
.replace("__TITLE__", () => title);

const metaExists = fs.existsSync(path.join(rootDir, "llmdoc", "meta.json"));
fs.writeFileSync(absolutePath, content);
syncMetaEntry(rootDir, shape.llmdocPath);

if (options.json) {
return {
created: repoRelativePath
created: repoRelativePath,
next: metaExists
? null
: "若 HEAD 尚无 commit,请先创建首次 Git 提交;然后运行 `npx -y @tokenroll/llmdoc init-state`。"
};
}
return `created: ${repoRelativePath}`;
return metaExists
? `created: ${repoRelativePath}`
: `created: ${repoRelativePath}\nnext: 若仓库尚无提交,请先创建首次 Git 提交;然后运行 \`npx -y @tokenroll/llmdoc init-state\` 建立台账。`;
}

function normalizeDocDestination(input: string): string {
Expand Down
12 changes: 8 additions & 4 deletions cli/src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { assertDocumentKind } from "../lib/doc-shape.js";
import { loadWorkspace } from "../lib/workspace.js";
import { OutputOptions } from "../types.js";
import { formatPaginationSummary } from "../lib/format.js";
import { searchDocuments } from "../lib/search.js";
import { SearchResult, searchDocuments } from "../lib/search.js";
import { estimateTokens } from "../lib/markdown.js";

interface SearchOptions extends OutputOptions {
Expand All @@ -16,25 +16,29 @@ interface SearchOptions extends OutputOptions {
export function runSearch(options: SearchOptions): unknown {
const workspace = loadWorkspace(options.cwd);
const kind = options.kind ? assertDocumentKind(options.kind) : undefined;
const results = searchDocuments(workspace, options.query, {
const search = searchDocuments(workspace, options.query, {
topic: options.topic,
kind
});
const paginated = paginate({
items: results,
items: search.results,
estimate: (entry) => estimateTokens(JSON.stringify(toPayload(entry))),
options
});

if (options.json) {
return {
query: options.query,
searchMode: search.mode,
results: paginated.items.map(toPayload),
pagination: paginationMetadata(paginated)
};
}

const lines: string[] = [];
if (search.mode === "cjk-bigram-fallback") {
lines.push("note: 中文分词未命中,已使用 CJK bigram 降级检索。", "");
}
for (const entry of paginated.items) {
lines.push(`llmdoc/${entry.document.llmdocPath} [${entry.document.frontmatter.kind}]`);
lines.push(` ${entry.document.frontmatter.description}`);
Expand All @@ -45,7 +49,7 @@ export function runSearch(options: SearchOptions): unknown {
return lines.join("\n");
}

function toPayload(entry: ReturnType<typeof searchDocuments>[number]): object {
function toPayload(entry: SearchResult): object {
return {
path: `llmdoc/${entry.document.llmdocPath}`,
kind: entry.document.frontmatter.kind,
Expand Down
15 changes: 15 additions & 0 deletions cli/src/lib/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,21 @@ export function findProjectRoot(startDir: string): string {
throw new CliError("未找到 llmdoc/ 目录,请在仓库内运行该命令。", 2);
}

// new 是唯一允许在 llmdoc/ 尚不存在时运行的结构改写命令。
// 首次创建严格锁定最近 Git 根;无 Git 但已有 llmdoc/ 时保留原有兼容路径。
export function findProjectRootForNew(startDir: string): string {
const start = path.resolve(startDir);
const existingWorkspace = findProjectRootOrNull(start);
if (existingWorkspace) {
return existingWorkspace;
}
const gitRoot = findNearestGitRootOrNull(start);
if (gitRoot) {
return gitRoot;
}
throw new CliError("未找到 Git 仓库;请先运行 `git init`,再运行 `llmdoc new`。", 2);
}

export function findProjectRootOrNull(startDir: string): string | null {
const start = path.resolve(startDir);
const gitRoot = findNearestGitRootOrNull(start);
Expand Down
11 changes: 9 additions & 2 deletions cli/src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export function gitCommitExists(rootDir: string, revision: string): boolean {
return result.status === 0;
}

export function isUnbornHead(rootDir: string): boolean {
if (!isGitRepository(rootDir) || runGitSafe(rootDir, ["rev-parse", "--verify", "HEAD"]) !== null) {
return false;
}
return runGitSafe(rootDir, ["symbolic-ref", "--quiet", "HEAD"]) !== null;
}

// shallow clone(CI 常态)里历史 commit 不可达,revision 校验需要据此降级而不是误报陈旧。
export function isShallowRepository(rootDir: string): boolean {
return runGitSafe(rootDir, ["rev-parse", "--is-shallow-repository"]) === "true";
Expand All @@ -56,7 +63,7 @@ export function readGitState(rootDir: string, baselineRevision: string | null):
};
}

const headRevision = runGitSafe(rootDir, ["rev-parse", "HEAD"]);
const headRevision = runGitSafe(rootDir, ["rev-parse", "--verify", "HEAD"]);
const detached = runGitSafe(rootDir, ["symbolic-ref", "--quiet", "--short", "HEAD"]) === null;
const inProgressOperation = detectInProgressOperation(rootDir);
const stagedPaths = readPathList(rootDir, ["diff", "--name-only", "--no-renames", "--cached"]);
Expand All @@ -73,7 +80,7 @@ export function readGitState(rootDir: string, baselineRevision: string | null):

let degradedReason: string | null = null;
if (!headRevision) {
degradedReason = "无法解析 HEAD commit。";
degradedReason = isUnbornHead(rootDir) ? "HEAD 尚无 commit(当前分支尚未创建首次提交)。" : "无法解析 HEAD commit。";
} else if (baselineRevision && !gitCommitExists(rootDir, baselineRevision)) {
degradedReason = `baseline.revision 不存在于当前 git 历史: ${baselineRevision}`;
}
Expand Down
Loading
Loading