@cheongfish/bouncer 0.0.0-stage → 0.1.13

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.
Files changed (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ // 경로는 emit된 scripts/lib/ 기준(tsc는 require 문자열을 재작성하지 않음).
4
+ const yaml = require('../vendor/js-yaml');
5
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
6
+ function parseFrontmatter(markdown) {
7
+ const match = FRONTMATTER_RE.exec(markdown);
8
+ if (!match) {
9
+ throw new Error('missing frontmatter block');
10
+ }
11
+ // yaml.load는 스칼라·배열·객체를 모두 돌려줄 수 있다. 객체로 좁히면
12
+ // 기존 `|| {}` 폴백(falsy만 빈 객체)과 달라져 게이트 입력이 바뀐다.
13
+ const loaded = yaml.load(match[1]);
14
+ const data = loaded || {};
15
+ return { data, body: match[2] };
16
+ }
17
+ function readDoc(absPath) {
18
+ const raw = fs.readFileSync(absPath, 'utf8');
19
+ const { data, body } = parseFrontmatter(raw);
20
+ return { data, body, path: absPath };
21
+ }
22
+ module.exports = { parseFrontmatter, readDoc };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ const { execFileSync } = require('node:child_process');
3
+ const preCommitHook = require("./pre-commit-hook");
4
+ const { internalCommitEnv } = preCommitHook;
5
+ function realGit(repoRoot) {
6
+ const run = (args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' });
7
+ const lines = (s) => s.split('\n').filter(Boolean);
8
+ return {
9
+ changedFiles: () => lines(run(['diff', '--name-only', 'HEAD'])),
10
+ untrackedFiles: () => lines(run(['ls-files', '--others', '--exclude-standard'])),
11
+ trackedFiles: () => lines(run(['ls-files', '--cached'])),
12
+ stage: (files) => { if (files.length)
13
+ run(['add', '--', ...files]); },
14
+ commit: (msg) => {
15
+ // remainder 커밋도 G17 이후라 hook이 인덱스를 다시 보면 순환 차단한다.
16
+ execFileSync('git', ['commit', '-m', msg], {
17
+ cwd: repoRoot,
18
+ encoding: 'utf8',
19
+ env: internalCommitEnv(),
20
+ });
21
+ },
22
+ headSha: () => run(['rev-parse', 'HEAD']).trim(),
23
+ };
24
+ }
25
+ /**
26
+ * checkout이 실제로 가리키는 local branch를 읽는다. detached HEAD·삭제된
27
+ * worktree·Git 조회 실패는 null로 보존한다. finalize는 provenance를 못 읽었다고
28
+ * 닫기를 거절하지 않으므로, 계산한 이름으로 빈 사실을 채우지 않는다.
29
+ *
30
+ * @param {string | null} worktreePath - branch를 확인할 checkout 경로
31
+ * @returns {string | null} 실제 branch 또는 확인 불가를 뜻하는 null
32
+ */
33
+ function resolveCheckoutBranch(worktreePath) {
34
+ if (!worktreePath)
35
+ return null;
36
+ try {
37
+ const branch = String(execFileSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], {
38
+ cwd: worktreePath,
39
+ encoding: 'utf8',
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ })).trim();
42
+ return branch || null;
43
+ }
44
+ catch (_error) {
45
+ // detached HEAD와 접근 불가 checkout은 provenance에서 구별할 branch가 없다.
46
+ return null;
47
+ }
48
+ }
49
+ module.exports = { realGit, resolveCheckoutBranch };
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFileSync } = require('node:child_process');
5
+ const graphify = require("./graphify");
6
+ const { resolveGraphifyBin } = graphify;
7
+ const posixPath = require("./posix-path");
8
+ const { toPosix } = posixPath;
9
+ // graphify 프로세스와 결과 경로 정규화. 해석기 PATH 탐색이
10
+ // execFileSync('graphify', ['--version']) 를 돌리므로 realHasGraphify 도
11
+ // 여기 둔다 — 이름은 판정처럼 보이지만 프로세스를 띄운다.
12
+ // session-graph 를 require 하지 않는다(exec → plan 순환 금지).
13
+ // PATH/`command -v` 폴백은 resolveGraphifyBin의 hasOnPath가 흡수한다.
14
+ // 해석 실패는 새 상태가 아니라 기존 skip-no-graphify와 같다.
15
+ function realHasGraphify(repoRoot) {
16
+ return resolveGraphifyBin({ repoRoot }).bin !== null;
17
+ }
18
+ // part cwd(보통 ".") 기준 원하는 outDir. graphify가 인정할 값이 필요하면
19
+ // cwd에 대해 resolve — runGraphifyUpdate 참고. graphify join 규칙과 무관하게
20
+ // cwd-relative 계약을 테스트할 수 있도록 순수 relative helper로 유지.
21
+ function graphifyOutEnv(cwdAbs, outDirAbs) {
22
+ return path.relative(cwdAbs, outDirAbs).split(path.sep).join('/') || '.';
23
+ }
24
+ function partOutDir(outDir, dir) {
25
+ const slug = dir.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'root';
26
+ return `${outDir}/parts/${slug}`;
27
+ }
28
+ function runGraphifyUpdate(repoRoot, dir, outDir, opts = null) {
29
+ // part outDir에 cwd를 격리해 graphify 고정
30
+ // `<cwd>/graphify-out/manifest.json`이 repo root가 아니라 part 아래에
31
+ // 생성되게 한다. scan target은 절대 경로 — cwd는 cache/output sandbox만.
32
+ // `--repo .`처럼 상대 경로가 오면 scan target도 상대가 된다. graphify는 part cwd에서 돌아
33
+ // 그 경로를 찾지 못하므로 먼저 절대 경로로 고정한다.
34
+ const rootAbs = path.resolve(repoRoot);
35
+ const partAbs = path.join(rootAbs, outDir);
36
+ const scanAbs = path.join(rootAbs, dir);
37
+ fs.mkdirSync(partAbs, { recursive: true });
38
+ const exec = ((opts && opts.exec) || execFileSync);
39
+ // 실행 대상은 해석기가 준 값만 — 여기 리터럴 'graphify'를 두지 않는다.
40
+ // 호출자(defaultExecGraphify)가 한 번 해석한 bin을 넘기는 것이 정상이며,
41
+ // 미주입 시에만 여기서 한 번 더 해석한다(단위 테스트·직접 호출용).
42
+ const bin = (opts && opts.bin) || resolveGraphifyBin({ repoRoot }).bin;
43
+ // graphify는 *상대* GRAPHIFY_OUT을 scan 디렉터리에 join한다. 절대 scan
44
+ // target이면 cwd-relative "."를 넘기면 source tree에 쓰게 되므로, graph.json이
45
+ // partAbs에 오도록 part cwd 기준으로 resolve.
46
+ const outEnv = path.resolve(partAbs, graphifyOutEnv(partAbs, partAbs));
47
+ // `update`는 AST-only 경로: `extract`와 달리 LLM key 불필요.
48
+ exec(bin, ['update', scanAbs], {
49
+ cwd: partAbs,
50
+ env: { ...process.env, GRAPHIFY_OUT: outEnv },
51
+ stdio: 'ignore',
52
+ });
53
+ }
54
+ // graphify는 scan한 디렉터리 기준 source_file을 기록하고 node id를 파생하므로,
55
+ // `scripts`에서 만든 part는 `src/lib/render.ts`를 주장하고 다른 source dir 아래
56
+ // 동일 경로명과 충돌할 수 있다. 소비 전에 둘 다 repo-relative/네임스페이스
57
+ // 형태로 rewrite.
58
+ function normalizeGraphPaths(repoRoot, partOut, dir) {
59
+ const graph = JSON.parse(fs.readFileSync(path.join(repoRoot, partOut, 'graph.json'), 'utf8'));
60
+ const idPrefix = `${dir.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')}_`;
61
+ const prefixId = (id) => (typeof id === 'string' ? idPrefix + id : id);
62
+ const prefixFile = (f) => (typeof f === 'string' && f ? `${dir}/${f}` : f);
63
+ for (const node of graph.nodes || []) {
64
+ node.id = prefixId(node.id);
65
+ node.source_file = prefixFile(node.source_file);
66
+ }
67
+ for (const link of graph.links || []) {
68
+ link.source = prefixId(link.source);
69
+ link.target = prefixId(link.target);
70
+ link.source_file = prefixFile(link.source_file);
71
+ }
72
+ for (const hyperedge of graph.hyperedges || []) {
73
+ if (Array.isArray(hyperedge.nodes))
74
+ hyperedge.nodes = hyperedge.nodes.map(prefixId);
75
+ hyperedge.source_file = prefixFile(hyperedge.source_file);
76
+ }
77
+ const rel = `${partOut}/graph.normalized.json`;
78
+ fs.writeFileSync(path.join(repoRoot, rel), JSON.stringify(graph));
79
+ return rel;
80
+ }
81
+ /**
82
+ * source_file이 exclude prefix 아래인지 본다. `scripts/lib`가
83
+ * `scripts/liberator`를 삼키지 않도록 exact 또는 `prefix/`만 인정한다.
84
+ *
85
+ * @param {unknown} sourceFile - 노드/링크의 source_file
86
+ * @param {string[]} prefixes - 저장소 상대 제외 prefix
87
+ * @returns {boolean} 제외 대상이면 true
88
+ */
89
+ function matchesExcludePrefix(sourceFile, prefixes) {
90
+ if (typeof sourceFile !== 'string' || !sourceFile)
91
+ return false;
92
+ const posix = toPosix(sourceFile);
93
+ return prefixes.some((raw) => {
94
+ const pref = toPosix(raw).replace(/\/+$/, '');
95
+ if (!pref)
96
+ return false;
97
+ return posix === pref || posix.startsWith(`${pref}/`);
98
+ });
99
+ }
100
+ /**
101
+ * 제외 prefix 아래 node와 그 id를 참조하는 link·hyperedge를 함께 제거한다.
102
+ * 빈 excludeDirs는 입력을 그대로 돌려 JavaScript를 생성물로 추측해
103
+ * 지우지 않는다(정본 JS 저장소 호환).
104
+ *
105
+ * @param {GraphJson} graph - 정규화·병합된 그래프
106
+ * @param {string[]} excludeDirs - 저장소 상대 prefix 목록
107
+ * @returns {GraphJson} 필터된 그래프(동일 객체를 변이)
108
+ */
109
+ function applyExcludeDirs(graph, excludeDirs) {
110
+ if (!excludeDirs || excludeDirs.length === 0)
111
+ return graph;
112
+ const dropped = new Set();
113
+ graph.nodes = (graph.nodes || []).filter((node) => {
114
+ if (matchesExcludePrefix(node.source_file, excludeDirs)) {
115
+ dropped.add(node.id);
116
+ return false;
117
+ }
118
+ return true;
119
+ });
120
+ graph.links = (graph.links || []).filter((link) => (!dropped.has(link.source) && !dropped.has(link.target)));
121
+ graph.hyperedges = (graph.hyperedges || []).filter((hyperedge) => {
122
+ if (!Array.isArray(hyperedge.nodes))
123
+ return true;
124
+ return hyperedge.nodes.every((id) => !dropped.has(id));
125
+ });
126
+ return graph;
127
+ }
128
+ function writeFilteredGraph(targetAbs, excludeDirs) {
129
+ // exclude가 비면 파일을 다시 읽지 않는다 — merge/copy 결과를 그대로 둔다.
130
+ if (!excludeDirs || excludeDirs.length === 0)
131
+ return;
132
+ const graph = JSON.parse(fs.readFileSync(targetAbs, 'utf8'));
133
+ applyExcludeDirs(graph, excludeDirs);
134
+ fs.writeFileSync(targetAbs, JSON.stringify(graph));
135
+ }
136
+ // 각 dir은 parts/ 아래 graphify state를 유지해 incremental rebuild가 graphify가
137
+ // 쓴 id를 본다; scope graph.json은 우리가 만든 파생 artifact.
138
+ function defaultExecGraphify(repoRoot, graph, opts = null) {
139
+ // 루프마다 재해석하지 않음 — config/venv/PATH 판정을 한 번만 하고 part·merge에 공유.
140
+ // 단위 테스트는 bin·exec를 주입해 실제 graphify 없이 source exclude 경로를 검증한다.
141
+ const bin = (opts && opts.bin) || resolveGraphifyBin({ repoRoot }).bin;
142
+ // 소비 측 계약: scanDirs || dirs. source 는 scanDirs 없이 dirs 만 쓴다.
143
+ const scanDirs = graph.scanDirs || graph.dirs;
144
+ const parts = scanDirs.map((dir) => {
145
+ const partOut = partOutDir(graph.outDir, dir);
146
+ runGraphifyUpdate(repoRoot, dir, partOut, {
147
+ bin,
148
+ ...(opts && opts.exec ? { exec: opts.exec } : {}),
149
+ });
150
+ return normalizeGraphPaths(repoRoot, partOut, dir);
151
+ });
152
+ const target = path.join(repoRoot, graph.outDir, 'graph.json');
153
+ if (parts.length === 1) {
154
+ fs.mkdirSync(path.dirname(target), { recursive: true });
155
+ fs.copyFileSync(path.join(repoRoot, parts[0]), target);
156
+ }
157
+ else {
158
+ const exec = ((opts && opts.exec) || execFileSync);
159
+ exec(bin, ['merge-graphs', ...parts, '--out', `${graph.outDir}/graph.json`], {
160
+ cwd: repoRoot,
161
+ stdio: 'ignore',
162
+ });
163
+ }
164
+ // source 병합 결과에만 exclude를 적용. test는 역할이 달라
165
+ // 같은 prefix로 지우면 안 된다.
166
+ if (graph.name === 'source') {
167
+ writeFilteredGraph(target, graph.excludeDirs);
168
+ }
169
+ }
170
+ module.exports = {
171
+ realHasGraphify,
172
+ graphifyOutEnv,
173
+ partOutDir,
174
+ runGraphifyUpdate,
175
+ normalizeGraphPaths,
176
+ applyExcludeDirs,
177
+ writeFilteredGraph,
178
+ defaultExecGraphify,
179
+ };
@@ -0,0 +1,268 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const config = require("./config");
5
+ const { readConfig } = config;
6
+ const posixPath = require("./posix-path");
7
+ const { toPosix } = posixPath;
8
+ // 설정·디렉터리 존재·mtime 판정만. 프로세스를 띄우지 않고 graphify.ts 도
9
+ // require 하지 않는다 — 그 모듈의 PATH 탐색이 execFileSync 를 돌리기 때문.
10
+ // 신선도 계산이 graphify 설치 여부를 묻기 시작하면 테스트가 deps 없이
11
+ // 안전한 함수와 아닌 함수를 구분하지 못한다.
12
+ const DEFAULT_SOURCE_OUT = 'graphify-out/source';
13
+ const DEFAULT_TEST_OUT = 'graphify-out/test';
14
+ // freshness walk 전용 이름 기반 prune — config key 아님.
15
+ // graphify-out 을 건너뛰는 이유 둘:
16
+ // 1) 파생 트리는 freshness 입력이 아니다. 산출물 mtime 이 stale 을 만들면
17
+ // 매 sync 가 자기 산출물을 보고 재빌드한다.
18
+ // 2) 사용자가 source_dirs: ["."] 로 graphify-out 을 다시 넣으면
19
+ // build 가 graphify-out 아래에 쓰기 → mtime 갱신 → rebuild 무한 루프.
20
+ const SCAN_EXCLUDED_DIRS = new Set(['graphify-out', 'node_modules', '.git', '.worktrees']);
21
+ function configField(cfg, key) {
22
+ // cfg?.key 와 같다. `'key' in cfg`로 바꾸면 프로토타입 필드가 생기고
23
+ // null에서 단락되지 않을 수 있다.
24
+ return cfg && typeof cfg === 'object' ? cfg[key] : undefined;
25
+ }
26
+ /**
27
+ * 저장소 상대 디렉터리만 허용한다. 절대 경로·`..` 탈출은 거부해
28
+ * graphify 입력이 작업 트리 밖으로 새지 않게 한다.
29
+ *
30
+ * @param {unknown} value - 검사할 경로 후보
31
+ * @returns {boolean} 상대·비탈출 문자열이면 true
32
+ */
33
+ function isSafeRepoRelativeDir(value) {
34
+ if (typeof value !== 'string' || value.length === 0)
35
+ return false;
36
+ const posix = toPosix(value);
37
+ // path.isAbsolute는 플랫폼 절대만 본다. POSIX 절대·드라이브 문자는
38
+ // Windows host에서도 명시적으로 막는다.
39
+ if (path.isAbsolute(value) || path.isAbsolute(posix))
40
+ return false;
41
+ if (posix.startsWith('/') || /^[A-Za-z]:/.test(posix))
42
+ return false;
43
+ if (posix.split('/').includes('..'))
44
+ return false;
45
+ return true;
46
+ }
47
+ /**
48
+ * graphify.test_dirs / exclude_dirs 공통 파서. 문자열 배열이 아니거나
49
+ * 안전하지 않은 항목이 있으면 값을 적용하지 않고 진단 사유만 남긴다.
50
+ *
51
+ * @param {unknown} raw - config 필드 원본
52
+ * @param {string} fieldName - 진단에 쓸 필드 이름
53
+ * @returns {DirListParse} 성공 시 dirs, 실패 시 reason
54
+ */
55
+ function parseDirList(raw, fieldName) {
56
+ if (!Array.isArray(raw)) {
57
+ return { ok: false, reason: `${fieldName} must be a string array of repo-relative paths` };
58
+ }
59
+ if (!raw.every(isSafeRepoRelativeDir)) {
60
+ return {
61
+ ok: false,
62
+ reason: `${fieldName} entries must be repo-relative paths without '..' or absolute roots`,
63
+ };
64
+ }
65
+ return { ok: true, dirs: raw };
66
+ }
67
+ function graphifyObject(cfg) {
68
+ const graphify = configField(cfg, 'graphify');
69
+ if (!graphify || typeof graphify !== 'object' || Array.isArray(graphify))
70
+ return null;
71
+ return graphify;
72
+ }
73
+ /**
74
+ * 선택 필드 graphify.test_dirs를 읽는다. 키 부재는 present:false —
75
+ * resolveGraphScopes가 test 항목을 skip-unconfigured로 실을 때 쓴다.
76
+ * 잘못된 값은 적용하지 않고 skipReason만 돌려 호출자가 skips와
77
+ * skip-unconfigured reason에 같은 사유를 싣게 한다.
78
+ *
79
+ * @param {string} repoRoot - 저장소 루트
80
+ * @returns {OptionalDirList} 부재·유효 dirs·무효 skip
81
+ */
82
+ function realTestDirs(repoRoot) {
83
+ const graphify = graphifyObject(readConfig(repoRoot));
84
+ if (!graphify || !Object.prototype.hasOwnProperty.call(graphify, 'test_dirs')) {
85
+ return { present: false };
86
+ }
87
+ const parsed = parseDirList(graphify.test_dirs, 'graphify.test_dirs');
88
+ if (!parsed.ok)
89
+ return { present: true, dirs: null, skipReason: parsed.reason };
90
+ return { present: true, dirs: parsed.dirs };
91
+ }
92
+ /**
93
+ * 선택 필드 graphify.exclude_dirs를 읽는다. 키 부재·빈 배열은 제외
94
+ * 없음을 뜻하며 JavaScript를 생성물로 추측해 지우지 않는다. 무효 값도
95
+ * 빈 제외로 수렴하고 skipReason만 남긴다.
96
+ *
97
+ * @param {string} repoRoot - 저장소 루트
98
+ * @returns {{ dirs: string[], skipReason?: string }} 적용할 prefix와 선택적 사유
99
+ */
100
+ function realExcludeDirs(repoRoot) {
101
+ const graphify = graphifyObject(readConfig(repoRoot));
102
+ if (!graphify || !Object.prototype.hasOwnProperty.call(graphify, 'exclude_dirs')) {
103
+ return { dirs: [] };
104
+ }
105
+ const parsed = parseDirList(graphify.exclude_dirs, 'graphify.exclude_dirs');
106
+ if (!parsed.ok)
107
+ return { dirs: [], skipReason: parsed.reason };
108
+ return { dirs: parsed.dirs };
109
+ }
110
+ function realGraphifyEnabled(repoRoot) {
111
+ // ?? {}를 붙이지 않는다. 부재를 빈 객체와 같게 보면 enabled 판정이
112
+ // "키 없음"과 "파일 없음"을 구분하지 못하게 되고, 둘 다 false로 떨어져
113
+ // 겉보기엔 같아 보이지만 cfg?. 전제(null 유지)가 깨진다.
114
+ const cfg = readConfig(repoRoot);
115
+ const graphify = configField(cfg, 'graphify');
116
+ const enabled = graphify && typeof graphify === 'object'
117
+ ? graphify.enabled
118
+ : undefined;
119
+ return enabled === true;
120
+ }
121
+ function realSourceDirs(repoRoot) {
122
+ const cfg = readConfig(repoRoot);
123
+ const dirs = configField(cfg, 'source_dirs');
124
+ return Array.isArray(dirs) ? dirs : [];
125
+ }
126
+ function realExistingDirs(repoRoot, dirs) {
127
+ return dirs.filter((d) => fs.existsSync(path.join(repoRoot, d)));
128
+ }
129
+ function newestMtimeUnder(repoRoot, dir) {
130
+ const root = path.join(repoRoot, dir);
131
+ let newest = 0;
132
+ const walk = (abs) => {
133
+ let entries;
134
+ try {
135
+ entries = fs.readdirSync(abs, { withFileTypes: true });
136
+ }
137
+ catch (_e) {
138
+ return;
139
+ }
140
+ for (const e of entries) {
141
+ const child = path.join(abs, e.name);
142
+ // exclude set 밖의 실제 디렉터리만 descend. symlink dir은
143
+ // 따라가지 않음 — 링크가 ancestor를 가리킬 때 cycle 방지.
144
+ if (e.isDirectory()) {
145
+ if (!e.isSymbolicLink() && !SCAN_EXCLUDED_DIRS.has(e.name))
146
+ walk(child);
147
+ continue;
148
+ }
149
+ const m = fs.statSync(child).mtimeMs;
150
+ if (m > newest)
151
+ newest = m;
152
+ }
153
+ };
154
+ walk(root);
155
+ return newest;
156
+ }
157
+ /**
158
+ * dirs 는 보통 디렉터리 walk, watchFiles 는 config.json 같은 단일 파일 mtime.
159
+ * 디렉터리가 아닌 경로는 walk 하지 않고 statSync 로 직접 잰다.
160
+ * 파생 memory 파일·디렉터리는 freshness 입력이 아니다 — 소스 원본만 본다.
161
+ */
162
+ function realNewestMtime(repoRoot, dirs, watchFiles) {
163
+ let newest = 0;
164
+ for (const d of dirs || []) {
165
+ const abs = path.join(repoRoot, d);
166
+ let st;
167
+ try {
168
+ st = fs.statSync(abs);
169
+ }
170
+ catch (_e) {
171
+ continue;
172
+ }
173
+ if (st.isDirectory()) {
174
+ newest = Math.max(newest, newestMtimeUnder(repoRoot, d));
175
+ }
176
+ else if (st.mtimeMs > newest) {
177
+ newest = st.mtimeMs;
178
+ }
179
+ }
180
+ const files = [...(watchFiles || [])];
181
+ for (const f of files) {
182
+ const abs = path.join(repoRoot, f);
183
+ let st;
184
+ try {
185
+ st = fs.statSync(abs);
186
+ }
187
+ catch (_e) {
188
+ continue;
189
+ }
190
+ if (st.isDirectory()) {
191
+ newest = Math.max(newest, st.mtimeMs, newestMtimeUnder(repoRoot, f));
192
+ }
193
+ else if (st.mtimeMs > newest) {
194
+ newest = st.mtimeMs;
195
+ }
196
+ }
197
+ return newest;
198
+ }
199
+ function realGraphMtime(repoRoot, outDir) {
200
+ const abs = path.join(repoRoot, outDir, 'graph.json');
201
+ return fs.existsSync(abs) ? fs.statSync(abs).mtimeMs : null;
202
+ }
203
+ /**
204
+ * source·test 두 scope 계획을 항상 만든다. testDirs가 null/undefined면
205
+ * 빌드 불가 test 항목을 남기고 unconfiguredReason을 싣는다(보고 길이 2 유지).
206
+ * 빈 배열은 필드 존재 → skip-no-dirs. excludeDirs는 source에만 실어 merge 뒤
207
+ * 필터가 소비한다 — 빈 목록이면 필터 no-op.
208
+ *
209
+ * @param {{
210
+ * sourceDirs: string[],
211
+ * testDirs?: string[] | null,
212
+ * excludeDirs?: string[],
213
+ * testUnconfiguredReason?: string
214
+ * }} args
215
+ * @returns {GraphScopePlan[]} 빌드·freshness 계획 목록(길이 2)
216
+ */
217
+ function resolveGraphScopes({ sourceDirs, testDirs, excludeDirs, testUnconfiguredReason }) {
218
+ const source = {
219
+ name: 'source',
220
+ dirs: sourceDirs,
221
+ outDir: DEFAULT_SOURCE_OUT,
222
+ // exclude_dirs·test_dirs는 config에만 있다. source 입력 mtime만 보면
223
+ // exclude를 바꾼 뒤에도 skip-fresh로 남아 필터가 한 번도 안 돈다.
224
+ // config mtime을 freshness에 넣어 다음 sync가 rebuild·재필터하게 한다.
225
+ watchFiles: ['.bouncer/config.json'],
226
+ };
227
+ // 빈 배열도 "명시적 제외 없음"이라 키를 붙여도 no-op이지만, 호출자가
228
+ // 넘긴 목록만 실어 진단·테스트가 계획 객체를 읽을 수 있게 한다.
229
+ if (excludeDirs && excludeDirs.length > 0) {
230
+ source.excludeDirs = excludeDirs;
231
+ }
232
+ else if (excludeDirs) {
233
+ source.excludeDirs = [];
234
+ }
235
+ const scopes = [source];
236
+ // null/undefined = 미설정·무효 → skip-unconfigured 자리표시. 빈 배열은
237
+ // 필드 존재 → planOneGraph가 skip-no-dirs로 수렴한다(skip-unconfigured 아님).
238
+ if (testDirs != null) {
239
+ scopes.push({ name: 'test', dirs: testDirs, outDir: DEFAULT_TEST_OUT });
240
+ }
241
+ else {
242
+ scopes.push({
243
+ name: 'test',
244
+ dirs: [],
245
+ outDir: DEFAULT_TEST_OUT,
246
+ // 존재하지 않는 입력을 꾸며 싣지 않는다 — configured도 비운다.
247
+ unconfiguredReason: testUnconfiguredReason
248
+ || 'graphify.test_dirs is not configured',
249
+ });
250
+ }
251
+ return scopes;
252
+ }
253
+ module.exports = {
254
+ SCAN_EXCLUDED_DIRS,
255
+ DEFAULT_SOURCE_OUT,
256
+ DEFAULT_TEST_OUT,
257
+ realGraphifyEnabled,
258
+ realSourceDirs,
259
+ realTestDirs,
260
+ realExcludeDirs,
261
+ parseDirList,
262
+ isSafeRepoRelativeDir,
263
+ realExistingDirs,
264
+ newestMtimeUnder,
265
+ realNewestMtime,
266
+ realGraphMtime,
267
+ resolveGraphScopes,
268
+ };