polydeukes 0.6.0 → 0.6.1
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.ko.md +54 -80
- package/README.md +55 -94
- package/dist/bin.js +8 -5
- package/dist/docs/README.ko.md +60 -0
- package/dist/docs/README.md +64 -0
- package/dist/docs/catalog.json +464 -0
- package/dist/docs/concepts/judgment.ko.md +113 -0
- package/dist/docs/concepts/judgment.md +113 -0
- package/dist/docs/how-to/configure-project.ko.md +99 -0
- package/dist/docs/how-to/configure-project.md +95 -0
- package/dist/docs/how-to/connect-surfaces.ko.md +115 -0
- package/dist/docs/how-to/connect-surfaces.md +118 -0
- package/dist/docs/how-to/write-disciplines.ko.md +124 -0
- package/dist/docs/how-to/write-disciplines.md +125 -0
- package/dist/docs/index.json +2046 -0
- package/dist/docs/reference/cli/covenant-check.ko.md +101 -0
- package/dist/docs/reference/cli/covenant-check.md +98 -0
- package/dist/docs/reference/cli/docs.ko.md +97 -0
- package/dist/docs/reference/cli/docs.md +95 -0
- package/dist/docs/reference/cli/explain.ko.md +79 -0
- package/dist/docs/reference/cli/explain.md +84 -0
- package/dist/docs/reference/cli/init.ko.md +119 -0
- package/dist/docs/reference/cli/init.md +131 -0
- package/dist/docs/reference/configuration/index.ko.md +448 -0
- package/dist/docs/reference/{configuration.md → configuration/index.md} +48 -30
- package/dist/docs/reference/packages/adapter-claude-code.ko.md +83 -0
- package/dist/docs/reference/{adapter-claude-code.md → packages/adapter-claude-code.md} +10 -6
- package/dist/docs/reference/packages/adapter-git.ko.md +101 -0
- package/dist/docs/reference/{adapter-git.md → packages/adapter-git.md} +20 -12
- package/dist/docs/reference/packages/core.ko.md +128 -0
- package/dist/docs/reference/{core.md → packages/core.md} +21 -8
- package/dist/docs/reference/packages/covenant.ko.md +115 -0
- package/dist/docs/reference/{covenant.md → packages/covenant.md} +18 -11
- package/dist/docs/reference/packages/polydeukes.ko.md +134 -0
- package/dist/docs/reference/packages/polydeukes.md +139 -0
- package/dist/docs/troubleshooting.ko.md +142 -0
- package/dist/docs/troubleshooting.md +98 -150
- package/dist/docs/tutorials/first-judgment.ko.md +82 -0
- package/dist/docs/tutorials/first-judgment.md +81 -0
- package/dist/docs-catalog.d.ts +25 -0
- package/dist/docs-catalog.js +450 -0
- package/dist/docs-library.d.ts +23 -0
- package/dist/docs-library.js +347 -0
- package/dist/docs-markdown.d.ts +32 -0
- package/dist/docs-markdown.js +150 -0
- package/dist/docs-query.d.ts +11 -40
- package/dist/docs-query.js +28 -122
- package/dist/docs-types.d.ts +105 -0
- package/dist/docs-types.js +2 -0
- package/dist/init-claude-code.d.ts +1 -1
- package/dist/init-claude-code.js +159 -42
- package/package.json +5 -5
- package/dist/docs/configuration.md +0 -103
- package/dist/docs/installation.md +0 -241
- package/dist/docs/reference/polydeukes.md +0 -315
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# 설치하고 첫 판정 확인하기
|
|
2
|
+
|
|
3
|
+
[English](./first-judgment.md) · **한국어**
|
|
4
|
+
|
|
5
|
+
빈 예제 프로젝트에 Polydeukes를 설치하고 보호된 파일을 바꾸지 않은 채 쓰기 요청의 판정
|
|
6
|
+
결과를 확인합니다. Node.js 24 이상, pnpm, git이 필요합니다. 실제 세션 호출을 확인하려면
|
|
7
|
+
Claude Code도 필요하지만, 아래 훅 검사 명령은 Claude Code 없이 실행할 수 있습니다.
|
|
8
|
+
|
|
9
|
+
<a id="claude-code"></a>
|
|
10
|
+
|
|
11
|
+
## 클로드 코드 연동 설치와 첫 쓰기 요청 검사
|
|
12
|
+
|
|
13
|
+
기존에 보호 중인 프로젝트 밖에서, 자신의 터미널로 다음 명령을 실행합니다.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
mkdir pdks-example
|
|
17
|
+
cd pdks-example
|
|
18
|
+
git init
|
|
19
|
+
printf '{"name":"pdks-example","private":true}\n' > package.json
|
|
20
|
+
pnpm add -D polydeukes # 프로젝트 의존성. 일회성 npx 실행이 아님
|
|
21
|
+
pnpm exec pdks init claude-code
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
설치기는 각 파일을 만들었으면 `created`, 이미 있어서 보존했으면 `skipped`로 보고합니다.
|
|
25
|
+
초기 설정, 훅 위임 파일, Claude Code 등록 설정, 문서 조회 안내, `discipline-draft` 스킬과
|
|
26
|
+
텔레메트리 제외 항목을 만듭니다. 기존 사용자 파일은 보존하고, 설정은 통째로 덮지 않고
|
|
27
|
+
병합합니다.
|
|
28
|
+
|
|
29
|
+
생성된 훅에 설정 파일을 쓰려는 요청을 전달합니다.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":".claude/settings.json","content":"{}"}}' \
|
|
33
|
+
| node .claude/hooks/covenant-pretooluse.mjs
|
|
34
|
+
printf 'exit=%s\n' "$?"
|
|
35
|
+
tail -n 5 .polydeukes/roi.log
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
예상 결과는 `exit=2`입니다. stderr에는 보호 경로를 지목한 메시지가 나오고, 로그에는
|
|
39
|
+
`blocked` 행이 남습니다. 이 명령은 판정만 요청하며 **실제로 파일을 쓰지 않습니다**.
|
|
40
|
+
설정 파일은 그대로이므로 되돌릴 변경도 없습니다.
|
|
41
|
+
|
|
42
|
+
이번에는 보호 목록 밖의 경로로 같은 검사를 합니다.
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":"example.txt","content":"hello"}}' \
|
|
46
|
+
| node .claude/hooks/covenant-pretooluse.mjs
|
|
47
|
+
printf 'exit=%s\n' "$?"
|
|
48
|
+
tail -n 5 .polydeukes/roi.log
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
초기 설정에서는 `exit=0`과 `passed` 행이 나와야 합니다. 이 명령도 파일을 쓰지는 않습니다.
|
|
52
|
+
설치된 판정기로 차단할 요청과 허용할 요청을 각각 확인한 것입니다.
|
|
53
|
+
|
|
54
|
+
실제 도구 호출에 훅을 적용하려면 이 프로젝트에서 Claude Code를 엽니다. 일반 텍스트 파일을
|
|
55
|
+
만들도록 요청한 뒤 `.polydeukes/roi.log`에 새 행이 생기는지 확인합니다. 앞의 직접 호출
|
|
56
|
+
검사만으로는 특정 호스트 세션이 훅 등록을 읽었다고 판단할 수 없습니다.
|
|
57
|
+
|
|
58
|
+
**처음 설정을 바꾸기 전에 확인하세요.** 로더는 발견한 설정 파일을 자동으로 보호합니다.
|
|
59
|
+
`protectedPaths`에 파일명이 없어도 같습니다. 세션에서 의도적으로 편집하려면
|
|
60
|
+
`witness.token`을 읽고, 사람이 직접 메시지 첫 줄에 그 토큰만 입력합니다. 초기 토큰은
|
|
61
|
+
`pdks witness`이며 유효 시간은 10분입니다. 증인(witness) 밸브는 판정기가 차단한 결과에만
|
|
62
|
+
적용됩니다. 에이전트가 사람을 대신해 메시지를 공급할 수는 없습니다. 자신의 터미널에서
|
|
63
|
+
의도한 설정 변경을 직접 수행해도 됩니다.
|
|
64
|
+
|
|
65
|
+
패키지나 설정을 읽지 못해 검사가 실패하면 자신의 터미널에서 지목된 파일을 고치거나
|
|
66
|
+
패키지를 다시 설치합니다. 밸브를 조립하는 단계에 이르기 전에 발생한 오류는 증인으로 해결할 수 없습니다.
|
|
67
|
+
[설정 오류](../troubleshooting.ko.md#invalid-config)와
|
|
68
|
+
[판정기 로드 실패](../troubleshooting.ko.md#judge-cannot-be-loaded)를 참고하세요.
|
|
69
|
+
|
|
70
|
+
<a id="next-step"></a>
|
|
71
|
+
|
|
72
|
+
## 실제 프로젝트에 적용하기
|
|
73
|
+
|
|
74
|
+
- [프로젝트 설정](../how-to/configure-project.ko.md)에서 임시 언어 이름과 테스트 명령을
|
|
75
|
+
바꿉니다.
|
|
76
|
+
- Grok이나 git pre-commit 훅은 [관측 표면 연결](../how-to/connect-surfaces.ko.md)을 참고합니다.
|
|
77
|
+
- [규율 작성](../how-to/write-disciplines.ko.md) 예제를 실행하고, 권고 결과를 확인한 뒤 차단
|
|
78
|
+
여부를 결정합니다.
|
|
79
|
+
|
|
80
|
+
종료 코드만으로 모든 관측 결과를 알 수는 없습니다. `advised`나 `skipped`가 있어도 exit 0일
|
|
81
|
+
수 있습니다. 진단 메시지와 [판정 결과 어휘](../troubleshooting.ko.md#reading-verdict)를 함께
|
|
82
|
+
확인하세요.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Install and get your first judgment
|
|
2
|
+
|
|
3
|
+
**English** · [한국어](./first-judgment.ko.md)
|
|
4
|
+
|
|
5
|
+
Use an empty example project to install Polydeukes and check a protected write without changing
|
|
6
|
+
the protected file. You need Node.js 24 or later, pnpm, and git. Claude Code is needed to observe
|
|
7
|
+
live session calls, but not to run the hook probe below.
|
|
8
|
+
|
|
9
|
+
<a id="claude-code"></a>
|
|
10
|
+
|
|
11
|
+
## Install Claude Code integration and check a write
|
|
12
|
+
|
|
13
|
+
Run these commands in your own terminal, outside an existing protected project:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
mkdir pdks-example
|
|
17
|
+
cd pdks-example
|
|
18
|
+
git init
|
|
19
|
+
printf '{"name":"pdks-example","private":true}\n' > package.json
|
|
20
|
+
pnpm add -D polydeukes # a project dependency, not a one-off npx run
|
|
21
|
+
pnpm exec pdks init claude-code
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The installer reports `created` or `skipped` for each artifact. It creates a starter config,
|
|
25
|
+
the hook delegator, the Claude Code registration, a documentation discovery file, the
|
|
26
|
+
`discipline-draft` skill, and a telemetry ignore entry. Existing user files are preserved;
|
|
27
|
+
settings are merged rather than replaced.
|
|
28
|
+
|
|
29
|
+
Now send the generated hook an observation of a proposed write to its settings file:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":".claude/settings.json","content":"{}"}}' \
|
|
33
|
+
| node .claude/hooks/covenant-pretooluse.mjs
|
|
34
|
+
printf 'exit=%s\n' "$?"
|
|
35
|
+
tail -n 5 .polydeukes/roi.log
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Expect `exit=2`, a diagnostic naming the protected path on stderr, and a `blocked` row in the
|
|
39
|
+
log. This command only asks for a judgment. It does **not** perform the proposed write, so the
|
|
40
|
+
settings file stays unchanged and there is no destructive edit to undo.
|
|
41
|
+
|
|
42
|
+
Repeat with a path outside the protection list:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
printf '%s\n' '{"tool_name":"Write","tool_input":{"file_path":"example.txt","content":"hello"}}' \
|
|
46
|
+
| node .claude/hooks/covenant-pretooluse.mjs
|
|
47
|
+
printf 'exit=%s\n' "$?"
|
|
48
|
+
tail -n 5 .polydeukes/roi.log
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Expect `exit=0` and a `passed` row for this starter configuration. Again, no file is written.
|
|
52
|
+
You have checked both a blocked and an allowed observation using the installed judge.
|
|
53
|
+
|
|
54
|
+
Open Claude Code in this project to use the same hook on actual tool calls. Ask it to create
|
|
55
|
+
an ordinary text file, then confirm that a new row appears in `.polydeukes/roi.log`. The direct
|
|
56
|
+
probe above does not prove that a particular host session loaded its hook registration.
|
|
57
|
+
|
|
58
|
+
**Before the first config edit:** the loader automatically protects the discovered config
|
|
59
|
+
file, even though it is not written in `protectedPaths`. For an intentional session edit, read
|
|
60
|
+
`witness.token` and type that token yourself on the first line of a message, with nothing else
|
|
61
|
+
on that line. The starter token is `pdks witness` and its window is ten minutes. The valve
|
|
62
|
+
supplies permission only after a blocking verdict; an agent cannot supply the human message
|
|
63
|
+
for you. Alternatively, make the deliberate configuration edit from your own terminal.
|
|
64
|
+
|
|
65
|
+
If the probe fails because the package or configuration cannot load, repair the named file or
|
|
66
|
+
reinstall from your own terminal. A witness cannot repair a failure that occurs before the
|
|
67
|
+
valve is assembled. See [configuration errors](../troubleshooting.md#invalid-config) and
|
|
68
|
+
[missing judge](../troubleshooting.md#judge-cannot-be-loaded).
|
|
69
|
+
|
|
70
|
+
<a id="next-step"></a>
|
|
71
|
+
|
|
72
|
+
## Continue with a real project
|
|
73
|
+
|
|
74
|
+
- [Configure the project](../how-to/configure-project.md) to replace the placeholder language
|
|
75
|
+
and test command.
|
|
76
|
+
- [Connect the surfaces](../how-to/connect-surfaces.md) for Grok or a git pre-commit hook.
|
|
77
|
+
- [Write a discipline](../how-to/write-disciplines.md) and observe an advisory before choosing
|
|
78
|
+
whether it should block.
|
|
79
|
+
|
|
80
|
+
An exit code alone does not describe all observations. `advised` and `skipped` can accompany
|
|
81
|
+
exit 0. Read the diagnostic and the [verdict vocabulary](../troubleshooting.md#reading-verdict).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DocsBundleDocument, DocsBundleSection, DocsIndex, LoadedDocsBundle } from './docs-types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Validate the source collection before replacing the output with bundled Markdown and metadata.
|
|
4
|
+
*
|
|
5
|
+
* @param spec - Source and non-overlapping output roots, with an optional catalog file path.
|
|
6
|
+
* @returns Metadata for bundled documents and sections; excluded documents are not copied.
|
|
7
|
+
* @throws If the roots overlap or the catalog, source files, or topic references fail validation.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildDocs(spec: {
|
|
10
|
+
sourceRoot: string;
|
|
11
|
+
outputRoot: string;
|
|
12
|
+
catalogPath?: string;
|
|
13
|
+
}): {
|
|
14
|
+
documents: DocsBundleDocument[];
|
|
15
|
+
sections: DocsBundleSection[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Load a bundle only after its stored index matches the catalog and bundled Markdown.
|
|
19
|
+
*
|
|
20
|
+
* @param outputRoot - Built documentation directory containing catalog.json and index.json.
|
|
21
|
+
* @returns Validated metadata and bundled documents with their original Markdown and sections.
|
|
22
|
+
* @throws If metadata or documents are missing, invalid, or inconsistent with the stored index.
|
|
23
|
+
*/
|
|
24
|
+
export declare function loadDocsBundle(outputRoot: string): LoadedDocsBundle;
|
|
25
|
+
export type { DocsIndex };
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { hashMarkdown, parseSections } from './docs-markdown.js';
|
|
5
|
+
const catalogSchemaVersion = 1;
|
|
6
|
+
const supportedLanguages = ['en', 'ko'];
|
|
7
|
+
const markdownPattern = /\.md$/i;
|
|
8
|
+
function fail(message) {
|
|
9
|
+
throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function assertString(value, message) {
|
|
15
|
+
if (typeof value !== 'string' || value === '')
|
|
16
|
+
fail(message);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function assertOptionalTerms(value, message) {
|
|
20
|
+
if (value === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || entry === '')) {
|
|
23
|
+
fail(message);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function assertTranslation(value, label) {
|
|
28
|
+
if (!isPlainObject(value))
|
|
29
|
+
fail(`invalid ${label} translation`);
|
|
30
|
+
return {
|
|
31
|
+
path: assertString(value.path, `invalid ${label} path`),
|
|
32
|
+
title: assertString(value.title, `invalid ${label} title`),
|
|
33
|
+
summary: assertString(value.summary, `invalid ${label} summary`),
|
|
34
|
+
terms: assertOptionalTerms(value.terms, `invalid ${label} terms`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function assertReference(value) {
|
|
38
|
+
if (!isPlainObject(value))
|
|
39
|
+
fail('invalid topic reference');
|
|
40
|
+
const documentId = assertString(value.documentId, 'invalid topic documentId');
|
|
41
|
+
const sectionId = value.sectionId;
|
|
42
|
+
if (sectionId !== undefined && (typeof sectionId !== 'string' || sectionId === '')) {
|
|
43
|
+
fail('invalid topic sectionId');
|
|
44
|
+
}
|
|
45
|
+
return { documentId, ...(sectionId === undefined ? {} : { sectionId }) };
|
|
46
|
+
}
|
|
47
|
+
function assertDocument(value) {
|
|
48
|
+
if (!isPlainObject(value))
|
|
49
|
+
fail('invalid document entry');
|
|
50
|
+
if (typeof value.bundled !== 'boolean')
|
|
51
|
+
fail('invalid bundled flag');
|
|
52
|
+
if (typeof value.order !== 'number')
|
|
53
|
+
fail('invalid document order');
|
|
54
|
+
const document = {
|
|
55
|
+
id: assertString(value.id, 'invalid document id'),
|
|
56
|
+
category: assertString(value.category, 'invalid document category'),
|
|
57
|
+
order: Number(value.order),
|
|
58
|
+
bundled: value.bundled === true,
|
|
59
|
+
en: assertTranslation(value.en, 'en'),
|
|
60
|
+
};
|
|
61
|
+
if (!Number.isInteger(document.order))
|
|
62
|
+
fail(`invalid order for ${document.id}`);
|
|
63
|
+
if (value.ko !== undefined)
|
|
64
|
+
document.ko = assertTranslation(value.ko, 'ko');
|
|
65
|
+
return document;
|
|
66
|
+
}
|
|
67
|
+
function assertCatalog(value) {
|
|
68
|
+
if (!isPlainObject(value))
|
|
69
|
+
fail('invalid catalog json');
|
|
70
|
+
const schemaVersion = value.schemaVersion;
|
|
71
|
+
if (schemaVersion !== catalogSchemaVersion)
|
|
72
|
+
fail('unsupported catalog schema version');
|
|
73
|
+
if (!Array.isArray(value.documents))
|
|
74
|
+
fail('invalid documents list');
|
|
75
|
+
if (!isPlainObject(value.topics))
|
|
76
|
+
fail('invalid topics map');
|
|
77
|
+
const redirects = value.redirects === undefined ? [] : value.redirects;
|
|
78
|
+
if (!Array.isArray(redirects))
|
|
79
|
+
fail('invalid redirects list');
|
|
80
|
+
const catalog = {
|
|
81
|
+
schemaVersion: catalogSchemaVersion,
|
|
82
|
+
documents: value.documents.map(assertDocument),
|
|
83
|
+
topics: Object.fromEntries(Object.entries(value.topics).map(([topicId, topic]) => {
|
|
84
|
+
if (!isPlainObject(topic))
|
|
85
|
+
fail(`invalid topic ${topicId}`);
|
|
86
|
+
return [
|
|
87
|
+
topicId,
|
|
88
|
+
{
|
|
89
|
+
references: Array.isArray(topic.references)
|
|
90
|
+
? topic.references.map(assertReference)
|
|
91
|
+
: fail(`invalid topic references for ${topicId}`),
|
|
92
|
+
seeAlso: assertString(topic.seeAlso, `invalid seeAlso for ${topicId}`),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
})),
|
|
96
|
+
redirects: redirects.map((entry) => {
|
|
97
|
+
if (!isPlainObject(entry))
|
|
98
|
+
fail('invalid redirect entry');
|
|
99
|
+
return {
|
|
100
|
+
path: assertString(entry.path, 'invalid redirect path'),
|
|
101
|
+
target: assertString(entry.target, 'invalid redirect target'),
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
return catalog;
|
|
106
|
+
}
|
|
107
|
+
function safeRelativePath(path) {
|
|
108
|
+
if (isAbsolute(path) || path.includes('\\') || /^[A-Za-z]:/.test(path)) {
|
|
109
|
+
fail(`absolute or non-portable path rejected: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
const segments = path.split('/');
|
|
112
|
+
if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
|
|
113
|
+
fail(`escaping path rejected: ${path}`);
|
|
114
|
+
}
|
|
115
|
+
const normalized = normalize(path).replace(/\\/g, '/');
|
|
116
|
+
if (normalized === '.' || normalized === '')
|
|
117
|
+
fail('invalid empty path');
|
|
118
|
+
return normalized;
|
|
119
|
+
}
|
|
120
|
+
function physicalPath(path) {
|
|
121
|
+
const absolute = resolve(path);
|
|
122
|
+
if (existsSync(absolute))
|
|
123
|
+
return realpathSync(absolute);
|
|
124
|
+
const parent = dirname(absolute);
|
|
125
|
+
if (parent === absolute)
|
|
126
|
+
return absolute;
|
|
127
|
+
return join(physicalPath(parent), relative(parent, absolute));
|
|
128
|
+
}
|
|
129
|
+
function contains(root, path) {
|
|
130
|
+
const distance = relative(root, path);
|
|
131
|
+
return distance !== '..' && !distance.startsWith(`..${sep}`) && !isAbsolute(distance);
|
|
132
|
+
}
|
|
133
|
+
function validateDocsRootOverlap(sourceRoot, outputRoot) {
|
|
134
|
+
const source = physicalPath(sourceRoot);
|
|
135
|
+
const output = physicalPath(outputRoot);
|
|
136
|
+
if (contains(source, output) || contains(output, source))
|
|
137
|
+
fail('source and output roots overlap');
|
|
138
|
+
}
|
|
139
|
+
function walkMarkdown(root) {
|
|
140
|
+
const result = [];
|
|
141
|
+
const stack = [root];
|
|
142
|
+
while (stack.length > 0) {
|
|
143
|
+
const current = stack.pop();
|
|
144
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
145
|
+
const path = join(current, entry.name);
|
|
146
|
+
if (entry.isDirectory())
|
|
147
|
+
stack.push(path);
|
|
148
|
+
else if (markdownPattern.test(entry.name))
|
|
149
|
+
result.push(relative(root, path).replace(/\\/g, '/'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result.sort((left, right) => left.localeCompare(right));
|
|
153
|
+
}
|
|
154
|
+
function readMarkdown(root, path) {
|
|
155
|
+
const absolute = resolve(root, path);
|
|
156
|
+
if (!contains(resolve(root), absolute))
|
|
157
|
+
fail(`path escapes source root: ${path}`);
|
|
158
|
+
if (!existsSync(absolute))
|
|
159
|
+
fail(`missing source markdown: ${path}`);
|
|
160
|
+
if (!contains(realpathSync(root), realpathSync(absolute)))
|
|
161
|
+
fail(`source symlink escapes root: ${path}`);
|
|
162
|
+
return readFileSync(absolute, 'utf8');
|
|
163
|
+
}
|
|
164
|
+
function validateDocumentPaths(document) {
|
|
165
|
+
for (const [language, translation] of Object.entries({ en: document.en, ko: document.ko })) {
|
|
166
|
+
if (!translation)
|
|
167
|
+
fail(`missing ${language} translation for ${document.id}`);
|
|
168
|
+
safeRelativePath(translation.path);
|
|
169
|
+
if (!translation.path.endsWith(language === 'en' ? '.md' : '.ko.md') ||
|
|
170
|
+
(language === 'en' && translation.path.endsWith('.ko.md'))) {
|
|
171
|
+
fail(`translation path suffix mismatch for ${document.id}:${language}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function validateDocumentIds(documents) {
|
|
176
|
+
const ids = new Set();
|
|
177
|
+
for (const document of documents) {
|
|
178
|
+
if (ids.has(document.id))
|
|
179
|
+
fail(`duplicate document id: ${document.id}`);
|
|
180
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(document.id)) {
|
|
181
|
+
fail(`invalid document id: ${document.id}`);
|
|
182
|
+
}
|
|
183
|
+
ids.add(document.id);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function validateTopicMap(catalog) {
|
|
187
|
+
for (const [topicId, topic] of Object.entries(catalog.topics)) {
|
|
188
|
+
if (topic.references.length === 0)
|
|
189
|
+
fail(`empty topic references: ${topicId}`);
|
|
190
|
+
if (typeof topic.seeAlso !== 'string' || topic.seeAlso === '')
|
|
191
|
+
fail(`invalid seeAlso: ${topicId}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function validateRedirects(catalog) {
|
|
195
|
+
const paths = new Set();
|
|
196
|
+
for (const redirect of catalog.redirects ?? []) {
|
|
197
|
+
safeRelativePath(redirect.path);
|
|
198
|
+
safeRelativePath(redirect.target);
|
|
199
|
+
if (paths.has(redirect.path))
|
|
200
|
+
fail(`duplicate redirect path: ${redirect.path}`);
|
|
201
|
+
paths.add(redirect.path);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function assertAllSourceMarkdownRegistered(catalog, sourceRoot) {
|
|
205
|
+
const registered = new Set();
|
|
206
|
+
const register = (path) => {
|
|
207
|
+
const safe = safeRelativePath(path);
|
|
208
|
+
if (registered.has(safe))
|
|
209
|
+
fail(`duplicate document path: ${path}`);
|
|
210
|
+
registered.add(safe);
|
|
211
|
+
};
|
|
212
|
+
for (const document of catalog.documents) {
|
|
213
|
+
register(document.en.path);
|
|
214
|
+
if (document.ko)
|
|
215
|
+
register(document.ko.path);
|
|
216
|
+
}
|
|
217
|
+
for (const redirect of catalog.redirects ?? []) {
|
|
218
|
+
if (!registered.has(redirect.target))
|
|
219
|
+
fail(`unknown redirect target: ${redirect.target}`);
|
|
220
|
+
readMarkdown(sourceRoot, redirect.path);
|
|
221
|
+
}
|
|
222
|
+
for (const redirect of catalog.redirects ?? [])
|
|
223
|
+
register(redirect.path);
|
|
224
|
+
for (const path of walkMarkdown(sourceRoot)) {
|
|
225
|
+
if (!registered.has(safeRelativePath(path))) {
|
|
226
|
+
fail(`unregistered source markdown: ${path}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function validateTopicsAgainstDocuments(catalog) {
|
|
231
|
+
const ids = new Set(catalog.documents.filter((document) => document.bundled).map((document) => document.id));
|
|
232
|
+
for (const [topicId, topic] of Object.entries(catalog.topics)) {
|
|
233
|
+
if (!ids.has(topic.seeAlso))
|
|
234
|
+
fail(`unknown seeAlso for ${topicId}: ${topic.seeAlso}`);
|
|
235
|
+
for (const reference of topic.references) {
|
|
236
|
+
if (!ids.has(reference.documentId))
|
|
237
|
+
fail(`unknown topic reference for ${topicId}: ${reference.documentId}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function validateBundledDocument(document, sourceRoot) {
|
|
242
|
+
const enMarkdown = readMarkdown(sourceRoot, document.en.path);
|
|
243
|
+
const ko = document.ko;
|
|
244
|
+
if (!ko)
|
|
245
|
+
fail(`missing ko translation for ${document.id}`);
|
|
246
|
+
const koMarkdown = readMarkdown(sourceRoot, ko.path);
|
|
247
|
+
const enSections = document.bundled ? parseSections(enMarkdown, { requireAnchors: true }) : [];
|
|
248
|
+
const koSections = document.bundled ? parseSections(koMarkdown, { requireAnchors: true }) : [];
|
|
249
|
+
if (document.bundled) {
|
|
250
|
+
const enIds = enSections.map((section) => section.id).sort();
|
|
251
|
+
const koIds = koSections.map((section) => section.id).sort();
|
|
252
|
+
if (enIds.length !== koIds.length || enIds.some((id, index) => id !== koIds[index])) {
|
|
253
|
+
fail(`bundled section set mismatch for ${document.id}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
id: document.id,
|
|
258
|
+
bundled: document.bundled,
|
|
259
|
+
category: document.category,
|
|
260
|
+
order: document.order,
|
|
261
|
+
translations: {
|
|
262
|
+
en: {
|
|
263
|
+
...document.en,
|
|
264
|
+
terms: document.en.terms ?? [],
|
|
265
|
+
markdown: enMarkdown,
|
|
266
|
+
hash: hashMarkdown(enMarkdown),
|
|
267
|
+
sections: enSections,
|
|
268
|
+
},
|
|
269
|
+
ko: {
|
|
270
|
+
...ko,
|
|
271
|
+
terms: ko.terms ?? [],
|
|
272
|
+
markdown: koMarkdown,
|
|
273
|
+
hash: hashMarkdown(koMarkdown),
|
|
274
|
+
sections: koSections,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function buildIndexDocument(document) {
|
|
280
|
+
return {
|
|
281
|
+
id: document.id,
|
|
282
|
+
bundled: document.bundled,
|
|
283
|
+
category: document.category,
|
|
284
|
+
order: document.order,
|
|
285
|
+
translations: {
|
|
286
|
+
en: {
|
|
287
|
+
path: document.translations.en.path,
|
|
288
|
+
title: document.translations.en.title,
|
|
289
|
+
summary: document.translations.en.summary,
|
|
290
|
+
...(document.translations.en.terms.length > 0
|
|
291
|
+
? { terms: document.translations.en.terms }
|
|
292
|
+
: {}),
|
|
293
|
+
},
|
|
294
|
+
ko: {
|
|
295
|
+
path: document.translations.ko.path,
|
|
296
|
+
title: document.translations.ko.title,
|
|
297
|
+
summary: document.translations.ko.summary,
|
|
298
|
+
...(document.translations.ko.terms.length > 0
|
|
299
|
+
? { terms: document.translations.ko.terms }
|
|
300
|
+
: {}),
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
hashes: {
|
|
304
|
+
en: document.translations.en.hash,
|
|
305
|
+
ko: document.translations.ko.hash,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function buildIndexSections(document) {
|
|
310
|
+
if (!document.bundled)
|
|
311
|
+
return [];
|
|
312
|
+
const sections = [];
|
|
313
|
+
for (const language of supportedLanguages) {
|
|
314
|
+
const translation = document.translations[language];
|
|
315
|
+
for (const section of translation.sections) {
|
|
316
|
+
sections.push({
|
|
317
|
+
documentId: document.id,
|
|
318
|
+
language,
|
|
319
|
+
sectionId: section.id,
|
|
320
|
+
title: section.title,
|
|
321
|
+
level: section.level,
|
|
322
|
+
path: translation.path,
|
|
323
|
+
hash: translation.hash,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return sections;
|
|
328
|
+
}
|
|
329
|
+
function copyBundleDocuments(outputRoot, documents) {
|
|
330
|
+
for (const document of documents.values()) {
|
|
331
|
+
for (const language of supportedLanguages) {
|
|
332
|
+
const translation = document.translations[language];
|
|
333
|
+
if (!document.bundled)
|
|
334
|
+
continue;
|
|
335
|
+
const destination = resolve(outputRoot, translation.path);
|
|
336
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
337
|
+
writeFileSync(destination, translation.markdown, 'utf8');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function validateTopicSections(catalog, documents) {
|
|
342
|
+
for (const [topic, entry] of Object.entries(catalog.topics)) {
|
|
343
|
+
for (const reference of entry.references) {
|
|
344
|
+
const document = documents.find((candidate) => candidate.id === reference.documentId);
|
|
345
|
+
if (!document?.bundled)
|
|
346
|
+
fail(`topic ${topic} references an excluded document`);
|
|
347
|
+
if (reference.sectionId !== undefined &&
|
|
348
|
+
!document.translations.en.sections.some((section) => section.id === reference.sectionId))
|
|
349
|
+
fail(`unknown topic section: ${topic}/${reference.sectionId}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function createIndex(documents) {
|
|
354
|
+
const bundled = documents.filter((document) => document.bundled);
|
|
355
|
+
return {
|
|
356
|
+
schemaVersion: catalogSchemaVersion,
|
|
357
|
+
documents: bundled
|
|
358
|
+
.sort((left, right) => left.order - right.order || compare(left.id, right.id))
|
|
359
|
+
.map(buildIndexDocument),
|
|
360
|
+
sections: bundled
|
|
361
|
+
.flatMap(buildIndexSections)
|
|
362
|
+
.sort((left, right) => compare(left.documentId, right.documentId) ||
|
|
363
|
+
compare(left.language, right.language) ||
|
|
364
|
+
compare(left.sectionId, right.sectionId)),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function compare(left, right) {
|
|
368
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
369
|
+
}
|
|
370
|
+
function writeJson(path, value) {
|
|
371
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
372
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
373
|
+
}
|
|
374
|
+
function loadBundle(outputRoot) {
|
|
375
|
+
const catalogPath = join(outputRoot, 'catalog.json');
|
|
376
|
+
const indexPath = join(outputRoot, 'index.json');
|
|
377
|
+
if (!existsSync(catalogPath) || !existsSync(indexPath))
|
|
378
|
+
fail('missing docs bundle metadata');
|
|
379
|
+
const catalog = assertCatalog(JSON.parse(readFileSync(catalogPath, 'utf8')));
|
|
380
|
+
validateDocumentIds(catalog.documents);
|
|
381
|
+
for (const document of catalog.documents)
|
|
382
|
+
validateDocumentPaths(document);
|
|
383
|
+
validateTopicMap(catalog);
|
|
384
|
+
validateTopicsAgainstDocuments(catalog);
|
|
385
|
+
const loaded = catalog.documents
|
|
386
|
+
.filter((document) => document.bundled)
|
|
387
|
+
.map((document) => validateBundledDocument(document, outputRoot));
|
|
388
|
+
validateTopicSections(catalog, loaded);
|
|
389
|
+
const index = createIndex(loaded);
|
|
390
|
+
const stored = JSON.parse(readFileSync(indexPath, 'utf8'));
|
|
391
|
+
if (!isDeepStrictEqual(stored, index))
|
|
392
|
+
fail('docs bundle index or integrity hash mismatch');
|
|
393
|
+
const documents = new Map(loaded.map((document) => [document.id, document]));
|
|
394
|
+
return { catalog, index, documents, topics: catalog.topics };
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Validate the source collection before replacing the output with bundled Markdown and metadata.
|
|
398
|
+
*
|
|
399
|
+
* @param spec - Source and non-overlapping output roots, with an optional catalog file path.
|
|
400
|
+
* @returns Metadata for bundled documents and sections; excluded documents are not copied.
|
|
401
|
+
* @throws If the roots overlap or the catalog, source files, or topic references fail validation.
|
|
402
|
+
*/
|
|
403
|
+
export function buildDocs(spec) {
|
|
404
|
+
const catalogPath = spec.catalogPath ?? join(spec.sourceRoot, 'catalog.json');
|
|
405
|
+
validateDocsRootOverlap(spec.sourceRoot, spec.outputRoot);
|
|
406
|
+
if (!existsSync(catalogPath))
|
|
407
|
+
fail(`missing catalog: ${catalogPath}`);
|
|
408
|
+
const catalog = assertCatalog(JSON.parse(readFileSync(catalogPath, 'utf8')));
|
|
409
|
+
validateDocumentIds(catalog.documents);
|
|
410
|
+
for (const document of catalog.documents)
|
|
411
|
+
validateDocumentPaths(document);
|
|
412
|
+
validateTopicMap(catalog);
|
|
413
|
+
validateRedirects(catalog);
|
|
414
|
+
validateTopicsAgainstDocuments(catalog);
|
|
415
|
+
assertAllSourceMarkdownRegistered(catalog, spec.sourceRoot);
|
|
416
|
+
const documents = catalog.documents.map((document) => validateBundledDocument(document, spec.sourceRoot));
|
|
417
|
+
validateTopicSections(catalog, documents);
|
|
418
|
+
const index = createIndex(documents);
|
|
419
|
+
rmSync(spec.outputRoot, { recursive: true, force: true });
|
|
420
|
+
mkdirSync(spec.outputRoot, { recursive: true });
|
|
421
|
+
writeJson(join(spec.outputRoot, 'catalog.json'), catalog);
|
|
422
|
+
writeJson(join(spec.outputRoot, 'index.json'), index);
|
|
423
|
+
const loadedBundle = {
|
|
424
|
+
catalog,
|
|
425
|
+
index,
|
|
426
|
+
documents: new Map(documents.map((document) => [document.id, document])),
|
|
427
|
+
topics: catalog.topics,
|
|
428
|
+
};
|
|
429
|
+
copyBundleDocuments(spec.outputRoot, loadedBundle.documents);
|
|
430
|
+
return {
|
|
431
|
+
documents: index.documents.map((entry) => ({
|
|
432
|
+
id: entry.id,
|
|
433
|
+
bundled: entry.bundled,
|
|
434
|
+
category: entry.category,
|
|
435
|
+
order: entry.order,
|
|
436
|
+
translations: entry.translations,
|
|
437
|
+
})),
|
|
438
|
+
sections: index.sections,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Load a bundle only after its stored index matches the catalog and bundled Markdown.
|
|
443
|
+
*
|
|
444
|
+
* @param outputRoot - Built documentation directory containing catalog.json and index.json.
|
|
445
|
+
* @returns Validated metadata and bundled documents with their original Markdown and sections.
|
|
446
|
+
* @throws If metadata or documents are missing, invalid, or inconsistent with the stored index.
|
|
447
|
+
*/
|
|
448
|
+
export function loadDocsBundle(outputRoot) {
|
|
449
|
+
return loadBundle(outputRoot);
|
|
450
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildDocs as buildDocsImpl } from './docs-catalog.ts';
|
|
2
|
+
/** Inputs for one query against the installed documentation bundle. */
|
|
3
|
+
export type RunDocsSpec = {
|
|
4
|
+
docsRoot: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
/** Complete stdout, returned only after validation and reading succeed. */
|
|
9
|
+
export type RunDocsOutcome = {
|
|
10
|
+
text: string;
|
|
11
|
+
};
|
|
12
|
+
/** Source catalog and destination for a reproducible documentation build. */
|
|
13
|
+
export type BuildDocsSpec = {
|
|
14
|
+
sourceRoot: string;
|
|
15
|
+
outputRoot: string;
|
|
16
|
+
catalogPath?: string;
|
|
17
|
+
};
|
|
18
|
+
/** Documents and sections included in a successful bundle. */
|
|
19
|
+
export type BuildDocsOutcome = ReturnType<typeof buildDocsImpl>;
|
|
20
|
+
/** Validate the source collection and replace the bundle, removing retired files. */
|
|
21
|
+
export declare function buildDocs(spec: BuildDocsSpec): BuildDocsOutcome;
|
|
22
|
+
/** Answer a docs-only command without loading project configuration or the judge. */
|
|
23
|
+
export declare function runDocs(spec: RunDocsSpec): RunDocsOutcome;
|