sdd-forge 0.1.0-alpha.759 → 0.1.0-alpha.774
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.md +1 -0
- package/package.json +3 -2
- package/src/AGENTS.md +57 -10
- package/src/docs/lib/data-source-loader.js +26 -14
- package/src/flow/commands/merge.js +6 -6
- package/src/flow/commands/review.js +20 -13
- package/src/flow/lib/get-check.js +2 -3
- package/src/flow/lib/run-finalize.js +15 -14
- package/src/flow/lib/run-gate.js +3 -2
- package/src/flow/lib/run-impl-confirm.js +2 -2
- package/src/flow/lib/run-prepare-spec.js +14 -13
- package/src/flow/lib/run-retro.js +3 -3
- package/src/flow/lib/run-sync.js +4 -3
- package/src/lib/cli.js +4 -0
- package/src/lib/flow-state.js +78 -44
- package/src/lib/git-helpers.js +27 -6
- package/src/lib/lint.js +3 -2
- package/src/lib/process.js +5 -11
- package/src/locale/en/messages.json +1 -1
- package/src/locale/ja/messages.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,7 @@ See the [configuration reference](docs/configuration.md) for details.
|
|
|
129
129
|
| [CLI Command Reference](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/cli_commands.md) | This chapter documents 8 CLI entrypoints and routed commands in the analyzed files: setup, check, check freshness, do… |
|
|
130
130
|
| [Configuration and Customization](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/configuration.md) | sdd-forge is configured through a single project-level JSON file (.sdd-forge/config.json) and optionally extended by … |
|
|
131
131
|
| [Internal Design](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/internal_design.md) | This chapter describes the internal structure of sdd-forge: a layered CLI tool built entirely on Node.js built-in mod… |
|
|
132
|
+
| [Preset Creation Guide](https://github.com/SpreadWorks/sdd-forge/blob/main/docs/creating_presets.md) | |
|
|
132
133
|
<!-- {{/data}} -->
|
|
133
134
|
|
|
134
135
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sdd-forge",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.774",
|
|
4
4
|
"description": "Spec-Driven Development tooling for automated documentation generation",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/sdd-forge.js",
|
|
15
|
-
"./api": "./src/api.js"
|
|
15
|
+
"./api": "./src/api.js",
|
|
16
|
+
"./presets/*": "./src/presets/*"
|
|
16
17
|
},
|
|
17
18
|
"files": [
|
|
18
19
|
"src/",
|
package/src/AGENTS.md
CHANGED
|
@@ -152,16 +152,12 @@ base → api → graphql
|
|
|
152
152
|
```
|
|
153
153
|
presets/<key>/
|
|
154
154
|
├── preset.json プリセット定義
|
|
155
|
-
├── data/ DataSource
|
|
155
|
+
├── data/ DataSource クラス群(scan + resolve を兼ねる)
|
|
156
156
|
│ ├── config.js 設定解析
|
|
157
157
|
│ ├── controllers.js コントローラ解析
|
|
158
158
|
│ └── ...
|
|
159
|
-
├── scan/ scan パーサー群
|
|
160
|
-
│ ├── routes.js ルート解析
|
|
161
|
-
│ ├── config.js 設定解析
|
|
162
|
-
│ └── ...
|
|
163
159
|
├── tests/ プリセット固有テスト
|
|
164
|
-
│ ├── unit/ ユニットテスト(
|
|
160
|
+
│ ├── unit/ ユニットテスト(DataSource の match/parse I/O テスト等)
|
|
165
161
|
│ ├── e2e/ E2E テスト(統合スキャンテスト等)
|
|
166
162
|
│ └── acceptance/ acceptance テスト(preset ローカル fixture + test.js)
|
|
167
163
|
└── templates/ 章テンプレート
|
|
@@ -177,21 +173,36 @@ presets/<key>/
|
|
|
177
173
|
|
|
178
174
|
## プリセット作成ルール
|
|
179
175
|
|
|
176
|
+
### MUST: プリセット作成ガイドとの同期
|
|
177
|
+
|
|
178
|
+
プリセットの仕様・作成手順・契約(`preset.json` スキーマ、DataSource のインターフェース、`match()` / `parse()` の引数契約、resolve メソッドの戻り値型、import ルール、テンプレートディレクティブ、scan/data ペアリング規則等)を変更した場合、**`.sdd-forge/templates/*/docs/creating_presets.md`(全言語)を同じコミット内で必ず更新すること。**
|
|
179
|
+
|
|
180
|
+
対象となる変更の例:
|
|
181
|
+
|
|
182
|
+
- `src/api.js` の公開クラス追加・削除・シグネチャ変更
|
|
183
|
+
- `package.json` の `exports` 変更
|
|
184
|
+
- `DataSource` / `Scannable` / `AnalysisEntry` / `Table` / `MarkdownText` のインターフェース変更
|
|
185
|
+
- `preset.json` スキーマの追加・変更
|
|
186
|
+
- `data/` loader のロード規約変更
|
|
187
|
+
- テンプレートディレクティブ(`{%extends%}`, `{%block%}`, `{{data}}`, `{{text}}`)の文法・挙動変更
|
|
188
|
+
- プリセット作成手順・MUST ルールの追加・変更
|
|
189
|
+
|
|
190
|
+
本ガイドは AI エージェントがプリセットを作成する際の単一の参照ドキュメントである。**ガイドが実装とズレるとプリセット作成が破綻する**ため、実装変更と文書更新を同一 PR で行うこと。別 PR に分割してはならない。
|
|
191
|
+
|
|
180
192
|
### MUST: プリセット作成手順(トップダウン設計)
|
|
181
193
|
|
|
182
194
|
プリセットの構成要素は以下の順序で作成すること:
|
|
183
195
|
|
|
184
196
|
1. **テンプレート** (`templates/`) — どんなドキュメントを出力するか定義する
|
|
185
|
-
2. **DataSource** (`data/`) —
|
|
186
|
-
3. **scan パーサー** (`scan/`) — DataSource にデータを供給するパーサーを実装する
|
|
197
|
+
2. **DataSource** (`data/`) — テンプレートが必要とするデータを `Scannable` DataSource の `match` / `parse` / `scan` で収集し、resolve メソッドで提供する
|
|
187
198
|
|
|
188
|
-
消費者(テンプレート)→
|
|
199
|
+
消費者(テンプレート)→ 生産者(DataSource)の順に作ることで、不要な解析を書かず、必要なデータの漏れがなくなる。scan 処理は独立した `scan/` ディレクトリではなく DataSource クラス自身が `Scannable` mixin 経由で担う。
|
|
189
200
|
|
|
190
201
|
### MUST: プリセットテストの作成
|
|
191
202
|
|
|
192
203
|
プリセットは `tests/` ディレクトリを含むこと。
|
|
193
204
|
|
|
194
|
-
- `tests/unit/` —
|
|
205
|
+
- `tests/unit/` — DataSource の `match` / `parse` I/O テスト。最小限のフィクスチャを `createTmpDir()` で作成し、入出力を検証する
|
|
195
206
|
- `tests/e2e/` — preset.json の scan 設定検証、フルスキャンパイプラインテスト
|
|
196
207
|
- `tests/acceptance/test.js` — preset ローカル fixture を使う acceptance テスト。共有処理は `tests/acceptance/lib/` を使う
|
|
197
208
|
- テンプレートを作成・変更した場合は acceptance テストも実装し、実行すること
|
|
@@ -295,6 +306,42 @@ export default class SchemaSource extends DataSource {
|
|
|
295
306
|
- analysis にデータがあれば動作する(なければ null を返す)
|
|
296
307
|
- **MUST**: このパターンを使う場合、読む analysis キーを書く scan DataSource がチェーン内に存在する必要がある。対応する scan がないなら data DataSource を作ってはならない
|
|
297
308
|
|
|
309
|
+
### DataSource メソッドの戻り値型(設計方針)
|
|
310
|
+
|
|
311
|
+
DataSource の resolve メソッド(`list()` / `tables()` 等、テンプレートから `{{data}}` で呼ばれるメソッド)の戻り値は、**レンダリング可能な値クラスのインスタンスまたは null** とする。生の Markdown 文字列を返してはならない。
|
|
312
|
+
|
|
313
|
+
プロジェクトは TypeScript を採用しないため、値の構造は OOP(クラス)で表現する(プロジェクト `CLAUDE.md` の「OOP による型表現」を参照)。戻り値には以下の専用クラスを用いる。
|
|
314
|
+
|
|
315
|
+
- `Table` — 表形式データ(labels と rows を保持、列数の整合を invariant として強制)
|
|
316
|
+
- `MarkdownText` — そのまま埋め込む Markdown 断片
|
|
317
|
+
- (必要に応じて)`BulletList` / `Heading` 等の追加クラス
|
|
318
|
+
|
|
319
|
+
```javascript
|
|
320
|
+
import { Table } from "../../../docs/lib/renderable.js";
|
|
321
|
+
|
|
322
|
+
export default class RoutesSource extends DataSource {
|
|
323
|
+
list(analysis, labels) {
|
|
324
|
+
const routes = analysis.routes?.entries || [];
|
|
325
|
+
if (routes.length === 0) return null;
|
|
326
|
+
const rows = routes.map(r => [r.pattern, r.controller, r.action]);
|
|
327
|
+
return new Table(labels, rows);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**利点**:
|
|
333
|
+
|
|
334
|
+
- コンストラクタで invariant を強制(例: Table は列数一致、labels 非空)
|
|
335
|
+
- `instanceof` による確実な型判別
|
|
336
|
+
- `toMarkdown()` を型に所属させることで、出力形式の拡張(将来の `toHtml()` 等)が Open/Closed に従う
|
|
337
|
+
- テンプレート展開層は `result.toMarkdown()` をポリモルフィックに呼ぶだけで済む
|
|
338
|
+
- オブジェクトリテラル(`{ type: "table", ... }`)のような discriminated union もどきは採用しない
|
|
339
|
+
|
|
340
|
+
**禁止事項**:
|
|
341
|
+
|
|
342
|
+
- 基底 `DataSource` の `toMarkdownTable()` のような Markdown 文字列生成ヘルパーを新設してはならない(既存のものは `Table` クラスへの段階移行対象)
|
|
343
|
+
- 戻り値として生の文字列を返してはならない。構造が単なるテキストなら `MarkdownText` でラップする
|
|
344
|
+
|
|
298
345
|
---
|
|
299
346
|
|
|
300
347
|
## テンプレート構文
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
|
-
import {
|
|
9
|
+
import { pathToFileURL } from "url";
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const MAX_DATA_SOURCE_FILES = 1000;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Load DataSource classes from a directory and instantiate them.
|
|
@@ -23,22 +23,34 @@ const logger = createLogger("datasource");
|
|
|
23
23
|
export async function loadDataSources(dataDir, opts) {
|
|
24
24
|
const { existing, onInstance } = opts || {};
|
|
25
25
|
const sources = new Map(existing || []);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = await fs.promises.readdir(dataDir);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code === "ENOENT") return sources;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
const files = entries.filter((f) => f.endsWith(".js"));
|
|
34
|
+
if (files.length > MAX_DATA_SOURCE_FILES) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`DataSource directory ${dataDir} contains ${files.length} files, exceeding limit ${MAX_DATA_SOURCE_FILES}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
29
39
|
for (const file of files) {
|
|
30
40
|
const name = path.basename(file, ".js");
|
|
41
|
+
const filePath = path.join(dataDir, file);
|
|
42
|
+
let mod;
|
|
31
43
|
try {
|
|
32
|
-
|
|
33
|
-
const Source = mod.default;
|
|
34
|
-
if (typeof Source === "function") {
|
|
35
|
-
const instance = new Source();
|
|
36
|
-
instance._sourceFilePath = path.join(dataDir, file);
|
|
37
|
-
if (onInstance && onInstance(instance, name) === false) continue;
|
|
38
|
-
sources.set(name, instance);
|
|
39
|
-
}
|
|
44
|
+
mod = await import(pathToFileURL(filePath).href);
|
|
40
45
|
} catch (err) {
|
|
41
|
-
|
|
46
|
+
throw new Error(`failed to load DataSource at ${filePath}: ${err.message}`, { cause: err });
|
|
47
|
+
}
|
|
48
|
+
const Source = mod.default;
|
|
49
|
+
if (typeof Source === "function") {
|
|
50
|
+
const instance = new Source();
|
|
51
|
+
instance._sourceFilePath = filePath;
|
|
52
|
+
if (onInstance && onInstance(instance, name) === false) continue;
|
|
53
|
+
sources.set(name, instance);
|
|
42
54
|
}
|
|
43
55
|
}
|
|
44
56
|
return sources;
|
|
@@ -9,7 +9,7 @@ import { readFileSync } from "fs";
|
|
|
9
9
|
import { runCmd, assertOk } from "../../lib/process.js";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { loadConfig } from "../../lib/config.js";
|
|
12
|
-
import { isGhAvailable } from "../../lib/git-helpers.js";
|
|
12
|
+
import { isGhAvailable, runGit } from "../../lib/git-helpers.js";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Resolve push remote from config.
|
|
@@ -165,7 +165,7 @@ function main(ctx) {
|
|
|
165
165
|
const title = buildPrTitle(spec, fallbackTitle);
|
|
166
166
|
const body = buildPrBody(state, spec);
|
|
167
167
|
|
|
168
|
-
const pushRes =
|
|
168
|
+
const pushRes = runGit(["push", "-u", remote, featureBranch]);
|
|
169
169
|
assertOk(pushRes, "git push failed");
|
|
170
170
|
const prRes = runCmd("gh", [
|
|
171
171
|
"pr", "create",
|
|
@@ -185,12 +185,12 @@ function main(ctx) {
|
|
|
185
185
|
function runSquashMerge(gitPrefix, hint) {
|
|
186
186
|
const mergeArgs = [...gitPrefix, "merge", "--squash", featureBranch];
|
|
187
187
|
const resetArgs = [...gitPrefix, "reset", "--merge"];
|
|
188
|
-
const mergeRes =
|
|
188
|
+
const mergeRes = runGit(mergeArgs);
|
|
189
189
|
if (!mergeRes.ok) {
|
|
190
|
-
|
|
190
|
+
runGit(resetArgs);
|
|
191
191
|
throw new Error(`Merge conflict detected. ${hint}`);
|
|
192
192
|
}
|
|
193
|
-
const commitRes =
|
|
193
|
+
const commitRes = runGit([...gitPrefix, "commit", "-m", commitMsg]);
|
|
194
194
|
assertOk(commitRes, "commit after squash merge failed");
|
|
195
195
|
}
|
|
196
196
|
|
|
@@ -200,7 +200,7 @@ function main(ctx) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
// Branch mode
|
|
203
|
-
const checkoutRes =
|
|
203
|
+
const checkoutRes = runGit(["checkout", baseBranch]);
|
|
204
204
|
assertOk(checkoutRes, "git checkout failed");
|
|
205
205
|
runSquashMerge([], `Run 'git rebase ${baseBranch}' and retry finalize.`);
|
|
206
206
|
return { strategy: "squash" };
|
|
@@ -24,7 +24,7 @@ import { loadAgentConfig, callAgentAwaitLog, resolveAgent, ensureAgentWorkDir }
|
|
|
24
24
|
*/
|
|
25
25
|
const callReviewAgent = (agent, prompt, root, systemPrompt) =>
|
|
26
26
|
callAgentAwaitLog(agent, prompt, undefined, root, { systemPrompt });
|
|
27
|
-
import {
|
|
27
|
+
import { runGit } from "../../lib/git-helpers.js";
|
|
28
28
|
import { EXIT_ERROR } from "../../lib/exit-codes.js";
|
|
29
29
|
import { VALID_PHASES } from "../../lib/constants.js";
|
|
30
30
|
|
|
@@ -70,12 +70,7 @@ function resolveReviewTarget(root, flow) {
|
|
|
70
70
|
for (const f of scopeFiles) {
|
|
71
71
|
const abs = path.resolve(root, f);
|
|
72
72
|
if (!fs.existsSync(abs)) continue;
|
|
73
|
-
|
|
74
|
-
const committed = runCmd("git", ["-C", root, "diff", flow.baseBranch, "--", f]);
|
|
75
|
-
if (committed.ok && committed.stdout.trim()) diffs.push(committed.stdout);
|
|
76
|
-
// Staged but uncommitted changes
|
|
77
|
-
const staged = runCmd("git", ["-C", root, "diff", "--cached", "--", f]);
|
|
78
|
-
if (staged.ok && staged.stdout.trim()) diffs.push(staged.stdout);
|
|
73
|
+
diffs.push(...collectCommittedAndStagedDiff(root, flow.baseBranch, f));
|
|
79
74
|
}
|
|
80
75
|
if (diffs.length > 0) return diffs.join("\n");
|
|
81
76
|
}
|
|
@@ -83,12 +78,24 @@ function resolveReviewTarget(root, flow) {
|
|
|
83
78
|
}
|
|
84
79
|
|
|
85
80
|
// Fallback: committed diff against base branch + staged changes
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
return collectCommittedAndStagedDiff(root, flow.baseBranch).join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Collect non-empty committed (vs base) and staged diff outputs.
|
|
86
|
+
* @param {string} root
|
|
87
|
+
* @param {string} baseBranch
|
|
88
|
+
* @param {string} [filePath] - optional path to scope the diff to
|
|
89
|
+
* @returns {string[]} array of non-empty diff outputs
|
|
90
|
+
*/
|
|
91
|
+
function collectCommittedAndStagedDiff(root, baseBranch, filePath) {
|
|
92
|
+
const out = [];
|
|
93
|
+
const fileArgs = filePath ? ["--", filePath] : [];
|
|
94
|
+
const committed = runGit(["-C", root, "diff", baseBranch, ...fileArgs]);
|
|
95
|
+
if (committed.ok && committed.stdout.trim()) out.push(committed.stdout);
|
|
96
|
+
const staged = runGit(["-C", root, "diff", "--cached", ...fileArgs]);
|
|
97
|
+
if (staged.ok && staged.stdout.trim()) out.push(staged.stdout);
|
|
98
|
+
return out;
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
/**
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
* ctx.target — one of: impl, finalize, dirty, gh
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import { isGhAvailable } from "../../lib/git-helpers.js";
|
|
9
|
+
import { isGhAvailable, runGit } from "../../lib/git-helpers.js";
|
|
11
10
|
import { VALID_CHECK_TARGETS } from "../../lib/constants.js";
|
|
12
11
|
import { FlowCommand } from "./base-command.js";
|
|
13
12
|
|
|
@@ -29,7 +28,7 @@ function checkStepPrereqs(state, required) {
|
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
function checkDirty(root) {
|
|
32
|
-
const res =
|
|
31
|
+
const res = runGit(["status", "--short"], { cwd: root });
|
|
33
32
|
if (!res.ok) {
|
|
34
33
|
return { pass: false, summary: "git status failed", checks: [{ id: "dirty", pass: false, message: res.stderr }] };
|
|
35
34
|
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
resolveWorktreePaths, clearFlowState, specIdFromPath,
|
|
16
16
|
} from "../../lib/flow-state.js";
|
|
17
17
|
import { loadIssueLog, saveIssueLog } from "./set-issue-log.js";
|
|
18
|
-
import { isGhAvailable, commentOnIssue, collectGitSummary } from "../../lib/git-helpers.js";
|
|
18
|
+
import { isGhAvailable, commentOnIssue, collectGitSummary, runGit } from "../../lib/git-helpers.js";
|
|
19
19
|
import { VALID_MERGE_STRATEGIES } from "../../lib/constants.js";
|
|
20
20
|
import { FlowCommand } from "./base-command.js";
|
|
21
21
|
import { FLOW_COMMANDS } from "../registry.js";
|
|
@@ -58,13 +58,13 @@ function executeCleanupImpl({ root, flowState, worktreePath, mainRepoPath }) {
|
|
|
58
58
|
if (worktree && mainRepoPath) {
|
|
59
59
|
const wtPath = worktreePath || root;
|
|
60
60
|
if (fs.existsSync(wtPath)) {
|
|
61
|
-
|
|
61
|
+
runGit(["-C", mainRepoPath, "worktree", "remove", wtPath]);
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
runGit(["-C", mainRepoPath, "branch", "-D", featureBranch]);
|
|
64
64
|
return { status: "done" };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
runGit(["branch", "-D", featureBranch], { cwd: root });
|
|
68
68
|
return { status: "done" };
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -76,7 +76,7 @@ function executeCleanupImpl({ root, flowState, worktreePath, mainRepoPath }) {
|
|
|
76
76
|
* @returns {{ status: string, message?: string }}
|
|
77
77
|
*/
|
|
78
78
|
export function commitOrSkip(args, opts) {
|
|
79
|
-
const res =
|
|
79
|
+
const res = runGit(["commit", ...args], opts);
|
|
80
80
|
if (res.ok) return { status: "done" };
|
|
81
81
|
const output = res.stderr || res.stdout || "";
|
|
82
82
|
if (/nothing to commit|no changes added to commit/i.test(output)) {
|
|
@@ -86,7 +86,7 @@ export function commitOrSkip(args, opts) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function resolveGitCommonDir(root) {
|
|
89
|
-
const res =
|
|
89
|
+
const res = runGit(["-C", root, "rev-parse", "--git-common-dir"]);
|
|
90
90
|
assertOk(res, "finalize preflight failed: unable to resolve git common dir");
|
|
91
91
|
return path.resolve(root, res.stdout.trim());
|
|
92
92
|
}
|
|
@@ -212,11 +212,12 @@ export async function executeCommitPost(ctx) {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
// commit retro + report files
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
215
|
+
runGit(["add", "-A"], { cwd: root });
|
|
216
|
+
try {
|
|
217
|
+
commitOrSkip(["-m", "chore: add retro and report"], { cwd: root });
|
|
218
|
+
} catch (e) {
|
|
218
219
|
if (results.report) {
|
|
219
|
-
results.report.commitNote = "retro/report commit failed: " + (
|
|
220
|
+
results.report.commitNote = "retro/report commit failed: " + String(e.message || e).slice(0, 200);
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
}
|
|
@@ -274,7 +275,7 @@ export class RunFinalizeCommand extends FlowCommand {
|
|
|
274
275
|
results.commit = { status: "dry-run", message: message || "(auto)" };
|
|
275
276
|
} else {
|
|
276
277
|
results.commit = await runSubStep("commit", () => {
|
|
277
|
-
|
|
278
|
+
runGit(["add", "-A"], { cwd: root });
|
|
278
279
|
const msg = message || `feat: ${state.featureBranch || "finalize"}`;
|
|
279
280
|
const res = commitOrSkip(["-m", msg], { cwd: root });
|
|
280
281
|
return { ...res, message: msg };
|
|
@@ -329,12 +330,12 @@ export class RunFinalizeCommand extends FlowCommand {
|
|
|
329
330
|
if (!buildRes.ok) {
|
|
330
331
|
assertOk(buildRes, "docs build failed");
|
|
331
332
|
}
|
|
332
|
-
|
|
333
|
+
runGit(["add", "docs/", "AGENTS.md", "CLAUDE.md", "README.md", ".sdd-forge/output/analysis.json"], { cwd: syncCwd });
|
|
333
334
|
let diffStat = null;
|
|
334
335
|
let diffSummary = null;
|
|
335
|
-
const statRes =
|
|
336
|
+
const statRes = runGit(["diff", "--cached", "--stat"], { cwd: syncCwd });
|
|
336
337
|
if (statRes.ok) diffStat = statRes.stdout.trim();
|
|
337
|
-
const nameRes =
|
|
338
|
+
const nameRes = runGit(["diff", "--cached", "--name-only"], { cwd: syncCwd });
|
|
338
339
|
if (nameRes.ok) diffSummary = nameRes.stdout.trim();
|
|
339
340
|
const commitRes = commitOrSkip(["-m", "docs: sync documentation"], { cwd: syncCwd });
|
|
340
341
|
return { ...commitRes, ...(diffStat && { diffStat }), ...(diffSummary && { diffSummary }) };
|
package/src/flow/lib/run-gate.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import fs from "fs";
|
|
14
14
|
import path from "path";
|
|
15
|
-
import {
|
|
15
|
+
import { assertOk } from "../../lib/process.js";
|
|
16
|
+
import { runGit } from "../../lib/git-helpers.js";
|
|
16
17
|
import { callAgentWithLog, resolveAgent, ensureAgentWorkDir } from "../../lib/agent.js";
|
|
17
18
|
import { filterByPhase, loadMergedGuardrails } from "../../lib/guardrail.js";
|
|
18
19
|
import { getSpecName } from "../../lib/flow-state.js";
|
|
@@ -32,7 +33,7 @@ import { FlowCommand } from "./base-command.js";
|
|
|
32
33
|
* @returns {string}
|
|
33
34
|
*/
|
|
34
35
|
function runGitDiff(args, errorMessage, cwd) {
|
|
35
|
-
const res =
|
|
36
|
+
const res = runGit(["diff", ...args], { cwd });
|
|
36
37
|
assertOk(res, errorMessage);
|
|
37
38
|
return res.stdout;
|
|
38
39
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
-
import {
|
|
10
|
+
import { runGit } from "../../lib/git-helpers.js";
|
|
11
11
|
import { VALID_IMPL_CONFIRM_MODES } from "../../lib/constants.js";
|
|
12
12
|
import { FlowCommand } from "./base-command.js";
|
|
13
13
|
|
|
@@ -18,7 +18,7 @@ import { FlowCommand } from "./base-command.js";
|
|
|
18
18
|
* @returns {string[]} changed file paths
|
|
19
19
|
*/
|
|
20
20
|
function getChangedFiles(root, baseBranch) {
|
|
21
|
-
const res =
|
|
21
|
+
const res = runGit(["-C", root, "diff", `${baseBranch}...HEAD`, "--name-only"]);
|
|
22
22
|
if (!res.ok) return [];
|
|
23
23
|
return res.stdout.trim().split("\n").filter(Boolean);
|
|
24
24
|
}
|
|
@@ -9,17 +9,17 @@ import fs from "fs";
|
|
|
9
9
|
import path from "path";
|
|
10
10
|
import { isInsideWorktree } from "../../lib/cli.js";
|
|
11
11
|
import { sddDir, DEFAULT_LANG } from "../../lib/config.js";
|
|
12
|
-
import {
|
|
12
|
+
import { assertOk } from "../../lib/process.js";
|
|
13
13
|
import { translate } from "../../lib/i18n.js";
|
|
14
14
|
import {
|
|
15
15
|
saveFlowState, buildInitialSteps, addActiveFlow, cleanStaleFlows,
|
|
16
16
|
generateRunId, deletePreparingFlow, cleanStalePreparingFlows,
|
|
17
17
|
} from "../../lib/flow-state.js";
|
|
18
|
-
import { getWorktreeStatus } from "../../lib/git-helpers.js";
|
|
18
|
+
import { getWorktreeStatus, runGit } from "../../lib/git-helpers.js";
|
|
19
19
|
import { FlowCommand } from "./base-command.js";
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
const res =
|
|
21
|
+
function runGitTrim(root, args) {
|
|
22
|
+
const res = runGit(["-C", root, ...args]);
|
|
23
23
|
if (res.ok) return res.stdout.trim();
|
|
24
24
|
assertOk(res, `git ${args.join(" ")} failed`);
|
|
25
25
|
}
|
|
@@ -44,7 +44,7 @@ function nextIndex(root) {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const branchLines =
|
|
47
|
+
const branchLines = runGitTrim(root, ["branch", "--list", "feature/[0-9][0-9][0-9]-*"])
|
|
48
48
|
.split("\n")
|
|
49
49
|
.map((x) => x.replace(/^[* ]+/, "").trim())
|
|
50
50
|
.filter(Boolean);
|
|
@@ -57,7 +57,7 @@ function nextIndex(root) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
function ensureClean(root) {
|
|
60
|
-
const status =
|
|
60
|
+
const status = runGitTrim(root, ["status", "--porcelain"]);
|
|
61
61
|
if (status.trim()) {
|
|
62
62
|
throw new Error("worktree is dirty. commit/stash before spec.");
|
|
63
63
|
}
|
|
@@ -65,16 +65,17 @@ function ensureClean(root) {
|
|
|
65
65
|
|
|
66
66
|
function ensureBaseBranch(root, base) {
|
|
67
67
|
try {
|
|
68
|
-
|
|
69
|
-
} catch (
|
|
70
|
-
throw new Error(`base branch not found: ${base}`);
|
|
68
|
+
runGitTrim(root, ["rev-parse", "--verify", base]);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new Error(`base branch not found: ${base}: ${e.message}`);
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function detectBaseBranch(root) {
|
|
75
75
|
try {
|
|
76
|
-
return
|
|
77
|
-
} catch (
|
|
76
|
+
return runGitTrim(root, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
77
|
+
} catch (e) {
|
|
78
|
+
process.stderr.write(`[sdd-forge] failed to detect current branch, falling back to "main": ${e.message}\n`);
|
|
78
79
|
return "main";
|
|
79
80
|
}
|
|
80
81
|
}
|
|
@@ -273,7 +274,7 @@ export class RunPrepareSpecCommand extends FlowCommand {
|
|
|
273
274
|
const lines = [];
|
|
274
275
|
|
|
275
276
|
if (useWorktree) {
|
|
276
|
-
|
|
277
|
+
runGitTrim(root, ["worktree", "add", worktreePath, "-b", branchName, resolvedBase]);
|
|
277
278
|
writeSpecFiles();
|
|
278
279
|
writeFlowState({ worktree: true });
|
|
279
280
|
addActiveFlow(root, specDirName, "worktree");
|
|
@@ -303,7 +304,7 @@ export class RunPrepareSpecCommand extends FlowCommand {
|
|
|
303
304
|
`3) start implementation`,
|
|
304
305
|
);
|
|
305
306
|
} else {
|
|
306
|
-
|
|
307
|
+
runGitTrim(root, ["checkout", "-b", branchName, resolvedBase]);
|
|
307
308
|
writeSpecFiles();
|
|
308
309
|
writeFlowState();
|
|
309
310
|
addActiveFlow(root, specDirName, "branch");
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
-
import {
|
|
10
|
+
import { runGit } from "../../lib/git-helpers.js";
|
|
11
11
|
import { callAgentAwaitLog, resolveAgent, ensureAgentWorkDir } from "../../lib/agent.js";
|
|
12
12
|
import { repairJson } from "../../lib/json-parse.js";
|
|
13
13
|
import { getSpecName } from "../../lib/flow-state.js";
|
|
@@ -30,7 +30,7 @@ function extractRequirements(specText) {
|
|
|
30
30
|
* Get git diff between base branch and HEAD.
|
|
31
31
|
*/
|
|
32
32
|
function getDiff(root, baseBranch) {
|
|
33
|
-
const res =
|
|
33
|
+
const res = runGit(["diff", `${baseBranch}...HEAD`, "--stat"], { cwd: root });
|
|
34
34
|
return res.ok ? res.stdout.trim() : "";
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -38,7 +38,7 @@ function getDiff(root, baseBranch) {
|
|
|
38
38
|
* Get detailed diff for AI evaluation.
|
|
39
39
|
*/
|
|
40
40
|
function getDetailedDiff(root, baseBranch) {
|
|
41
|
-
const res =
|
|
41
|
+
const res = runGit(["diff", `${baseBranch}...HEAD`], { cwd: root });
|
|
42
42
|
return res.ok ? res.stdout.trim() : "";
|
|
43
43
|
}
|
|
44
44
|
|
package/src/flow/lib/run-sync.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { PKG_DIR } from "../../lib/cli.js";
|
|
9
9
|
import { runCmd, assertOk } from "../../lib/process.js";
|
|
10
|
+
import { runGit } from "../../lib/git-helpers.js";
|
|
10
11
|
import { FlowCommand } from "./base-command.js";
|
|
11
12
|
import path from "path";
|
|
12
13
|
|
|
@@ -56,11 +57,11 @@ export class RunSyncCommand extends FlowCommand {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
// Step 3: git add (ignore errors for missing files)
|
|
59
|
-
|
|
60
|
+
runGit(["add", "docs/", "AGENTS.md", "CLAUDE.md", "README.md", ".sdd-forge/output/"], { cwd: root });
|
|
60
61
|
|
|
61
62
|
// Collect changed files
|
|
62
63
|
let changed = [];
|
|
63
|
-
const diffRes =
|
|
64
|
+
const diffRes = runGit(["diff", "--cached", "--name-only"], { cwd: root });
|
|
64
65
|
if (diffRes.ok) {
|
|
65
66
|
const diff = diffRes.stdout.trim();
|
|
66
67
|
changed = diff ? diff.split("\n").filter(Boolean) : [];
|
|
@@ -68,7 +69,7 @@ export class RunSyncCommand extends FlowCommand {
|
|
|
68
69
|
|
|
69
70
|
// Step 4: commit (skip if nothing to commit)
|
|
70
71
|
if (changed.length > 0) {
|
|
71
|
-
const commitRes =
|
|
72
|
+
const commitRes = runGit(["commit", "-m", "docs: sync documentation"], { cwd: root });
|
|
72
73
|
if (!commitRes.ok && !/nothing to commit/i.test(commitRes.stderr || commitRes.stdout)) {
|
|
73
74
|
assertOk(commitRes, "git commit failed");
|
|
74
75
|
}
|
package/src/lib/cli.js
CHANGED
|
@@ -25,6 +25,8 @@ export const PKG_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url))
|
|
|
25
25
|
*/
|
|
26
26
|
export function repoRoot(importMetaUrl) {
|
|
27
27
|
if (process.env.SDD_FORGE_WORK_ROOT) return process.env.SDD_FORGE_WORK_ROOT;
|
|
28
|
+
// Logger 基盤の依存元のため runCmd を直接使う。runGit に変更してはならない
|
|
29
|
+
// (Logger.git → resolveLogDir → repoRoot → runGit → Logger.git で無限再帰になる)。
|
|
28
30
|
const res = runCmd("git", ["rev-parse", "--show-toplevel"]);
|
|
29
31
|
if (res.ok) return res.stdout.trim();
|
|
30
32
|
// npm パッケージとしてインストールされた場合、相対パス推定は
|
|
@@ -104,6 +106,8 @@ export function isInsideWorktree(root) {
|
|
|
104
106
|
* @returns {string} メインリポジトリの絶対パス
|
|
105
107
|
*/
|
|
106
108
|
export function getMainRepoPath(root) {
|
|
109
|
+
// Logger 基盤の依存元のため runCmd を直接使う。runGit に変更してはならない
|
|
110
|
+
// (Logger.git → resolveLogDir → getMainRepoPath → runGit → Logger.git で無限再帰になる)。
|
|
107
111
|
const res = runCmd("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
108
112
|
assertOk(res, "failed to resolve git-common-dir");
|
|
109
113
|
const gitCommonDir = res.stdout.trim();
|
package/src/lib/flow-state.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import path from "path";
|
|
11
11
|
import crypto from "crypto";
|
|
12
|
-
import {
|
|
12
|
+
import { runGit } from "./git-helpers.js";
|
|
13
13
|
import { sddDir } from "./config.js";
|
|
14
14
|
import { isInsideWorktree, getMainRepoPath } from "./cli.js";
|
|
15
15
|
|
|
@@ -18,6 +18,7 @@ const ACTIVE_FLOW_FILE = ".active-flow";
|
|
|
18
18
|
const PREPARING_PREFIX = ".active-flow.";
|
|
19
19
|
const PREPARING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
20
20
|
const PREPARING_SCAN_LIMIT = 100;
|
|
21
|
+
const SCAN_FLOWS_LIMIT = 200;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Extract the spec name (e.g. "152-add-logger-to-callsites") from a flow object or state.
|
|
@@ -182,10 +183,29 @@ export function removeActiveFlow(workRoot, specId) {
|
|
|
182
183
|
* @param {string} branch
|
|
183
184
|
* @returns {boolean}
|
|
184
185
|
*/
|
|
186
|
+
/**
|
|
187
|
+
* Run a git query and apply a stdout predicate; on git failure, log to stderr
|
|
188
|
+
* and return `true` (fail-open: prevents accidental deletion when status is unknown).
|
|
189
|
+
* @param {string[]} args - git arguments
|
|
190
|
+
* @param {(stdout: string) => boolean} predicate
|
|
191
|
+
* @param {string} contextLabel - label used in the stderr warning
|
|
192
|
+
* @returns {boolean}
|
|
193
|
+
*/
|
|
194
|
+
function runGitFailOpenBoolean(args, predicate, contextLabel) {
|
|
195
|
+
const res = runGit(args);
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
process.stderr.write(`[sdd-forge] ${contextLabel}: git ${args.join(" ")} failed, assuming exists: ${res.stderr}\n`);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return predicate(res.stdout);
|
|
201
|
+
}
|
|
202
|
+
|
|
185
203
|
function worktreeExists(mainRoot, branch) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
204
|
+
return runGitFailOpenBoolean(
|
|
205
|
+
["-C", mainRoot, "worktree", "list", "--porcelain"],
|
|
206
|
+
(stdout) => stdout.includes(`branch refs/heads/${branch}`),
|
|
207
|
+
"worktreeExists",
|
|
208
|
+
);
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
/**
|
|
@@ -195,9 +215,11 @@ function worktreeExists(mainRoot, branch) {
|
|
|
195
215
|
* @returns {boolean}
|
|
196
216
|
*/
|
|
197
217
|
function branchExists(mainRoot, branch) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
218
|
+
return runGitFailOpenBoolean(
|
|
219
|
+
["-C", mainRoot, "branch", "--list", branch],
|
|
220
|
+
(stdout) => stdout.trim().length > 0,
|
|
221
|
+
"branchExists",
|
|
222
|
+
);
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
/**
|
|
@@ -397,7 +419,7 @@ function resolveCurrentFlow(workRoot, flows) {
|
|
|
397
419
|
|
|
398
420
|
// Try matching by current branch
|
|
399
421
|
let currentBranch;
|
|
400
|
-
const res =
|
|
422
|
+
const res = runGit(["-C", workRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
401
423
|
if (!res.ok) return null;
|
|
402
424
|
currentBranch = res.stdout.trim();
|
|
403
425
|
|
|
@@ -491,7 +513,7 @@ export function loadFlowState(workRoot, specId) {
|
|
|
491
513
|
* @returns {string|null}
|
|
492
514
|
*/
|
|
493
515
|
function specIdFromBranch(workRoot) {
|
|
494
|
-
const res =
|
|
516
|
+
const res = runGit(["-C", workRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
|
|
495
517
|
if (!res.ok) return null;
|
|
496
518
|
const branch = res.stdout.trim();
|
|
497
519
|
const prefix = "feature/";
|
|
@@ -732,12 +754,14 @@ export function scanAllFlows(workRoot) {
|
|
|
732
754
|
const mainRoot = resolveMainRoot(workRoot);
|
|
733
755
|
const results = [];
|
|
734
756
|
const seen = new Set();
|
|
757
|
+
let truncated = false;
|
|
735
758
|
|
|
736
759
|
// 1. Local: specs/*/ in main repo (with or without flow.json)
|
|
737
760
|
const specsDir = path.join(mainRoot, "specs");
|
|
738
761
|
if (fs.existsSync(specsDir)) {
|
|
739
762
|
for (const entry of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
740
763
|
if (!entry.isDirectory() || !/^\d{3}-/.test(entry.name)) continue;
|
|
764
|
+
if (results.length >= SCAN_FLOWS_LIMIT) { truncated = true; break; }
|
|
741
765
|
const fp = path.join(specsDir, entry.name, STATE_FILE);
|
|
742
766
|
if (fs.existsSync(fp)) {
|
|
743
767
|
const state = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
@@ -751,54 +775,64 @@ export function scanAllFlows(workRoot) {
|
|
|
751
775
|
}
|
|
752
776
|
|
|
753
777
|
// 2. Worktrees: scan each worktree's specs/*/flow.json
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
778
|
+
if (!truncated) {
|
|
779
|
+
const wtRes = runGit(["-C", mainRoot, "worktree", "list", "--porcelain"]);
|
|
780
|
+
if (wtRes.ok) {
|
|
781
|
+
const output = wtRes.stdout;
|
|
782
|
+
let wtPath = null;
|
|
783
|
+
outer: for (const line of output.split("\n")) {
|
|
784
|
+
if (line.startsWith("worktree ")) {
|
|
785
|
+
wtPath = line.slice("worktree ".length);
|
|
786
|
+
} else if (line === "" && wtPath && wtPath !== mainRoot) {
|
|
787
|
+
const wtSpecs = path.join(wtPath, "specs");
|
|
788
|
+
if (fs.existsSync(wtSpecs)) {
|
|
789
|
+
for (const entry of fs.readdirSync(wtSpecs, { withFileTypes: true })) {
|
|
790
|
+
if (!entry.isDirectory() || seen.has(entry.name)) continue;
|
|
791
|
+
if (results.length >= SCAN_FLOWS_LIMIT) { truncated = true; break outer; }
|
|
792
|
+
const fp = path.join(wtSpecs, entry.name, STATE_FILE);
|
|
793
|
+
if (fs.existsSync(fp)) {
|
|
794
|
+
const state = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
795
|
+
results.push({ specId: entry.name, mode: "worktree", state, location: wtPath });
|
|
796
|
+
seen.add(entry.name);
|
|
797
|
+
}
|
|
771
798
|
}
|
|
772
799
|
}
|
|
800
|
+
wtPath = null;
|
|
773
801
|
}
|
|
774
|
-
wtPath = null;
|
|
775
802
|
}
|
|
776
803
|
}
|
|
777
804
|
}
|
|
778
805
|
|
|
779
806
|
// 3. Branches: check feature/* branches for specs/*/flow.json
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
807
|
+
if (!truncated) {
|
|
808
|
+
const branchRes = runGit(["-C", mainRoot, "branch", "--list", "feature/*"]);
|
|
809
|
+
if (branchRes.ok) {
|
|
810
|
+
for (const line of branchRes.stdout.split("\n")) {
|
|
811
|
+
const branch = line.replace(/^[*+ ]+/, "").trim();
|
|
812
|
+
if (!branch) continue;
|
|
813
|
+
const specId = branch.replace("feature/", "");
|
|
814
|
+
if (seen.has(specId)) continue;
|
|
815
|
+
if (results.length >= SCAN_FLOWS_LIMIT) { truncated = true; break; }
|
|
816
|
+
const showRes = runGit(
|
|
817
|
+
["-C", mainRoot, "show", `${branch}:specs/${specId}/flow.json`],
|
|
818
|
+
);
|
|
819
|
+
if (showRes.ok) {
|
|
820
|
+
try {
|
|
821
|
+
const state = JSON.parse(showRes.stdout);
|
|
822
|
+
results.push({ specId, mode: "branch", state, location: `branch:${branch}` });
|
|
823
|
+
seen.add(specId);
|
|
824
|
+
} catch (e) {
|
|
825
|
+
process.stderr.write(`[sdd-forge] scanAllFlows: invalid JSON in ${branch}:specs/${specId}/flow.json: ${e.message}\n`);
|
|
826
|
+
}
|
|
797
827
|
}
|
|
798
828
|
}
|
|
799
829
|
}
|
|
800
830
|
}
|
|
801
831
|
|
|
832
|
+
if (truncated) {
|
|
833
|
+
process.stderr.write(`[sdd-forge] scanAllFlows: truncated at ${SCAN_FLOWS_LIMIT} entries\n`);
|
|
834
|
+
}
|
|
835
|
+
|
|
802
836
|
return results;
|
|
803
837
|
}
|
|
804
838
|
|
package/src/lib/git-helpers.js
CHANGED
|
@@ -6,10 +6,31 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { runCmd, formatError } from "./process.js";
|
|
9
|
+
import { Logger } from "./log.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run a git command and record a JSONL log entry via Logger.
|
|
13
|
+
*
|
|
14
|
+
* All "business" git operations (commit, push, diff, branch, merge, worktree, status, log, etc.)
|
|
15
|
+
* SHOULD go through this wrapper instead of calling `runCmd("git", ...)` directly,
|
|
16
|
+
* so they are uniformly logged.
|
|
17
|
+
*
|
|
18
|
+
* Exception: git invocations that the Logger itself depends on (repo top-level /
|
|
19
|
+
* git-common-dir resolution in `cli.js`) MUST stay on `runCmd` to avoid recursion.
|
|
20
|
+
*
|
|
21
|
+
* @param {string[]} args - git argument array (without the leading "git")
|
|
22
|
+
* @param {Object} [opts] - same shape as runCmd opts
|
|
23
|
+
* @returns {{ ok: boolean, status: number, stdout: string, stderr: string, signal: string|null, killed: boolean }}
|
|
24
|
+
*/
|
|
25
|
+
export function runGit(args, opts = {}) {
|
|
26
|
+
const result = runCmd("git", args, opts);
|
|
27
|
+
Logger.getInstance().git({ cmd: ["git", ...args], exitCode: result.status, stderr: result.stderr });
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
9
30
|
|
|
10
31
|
/** @returns {{ dirty: boolean, dirtyFiles: string[] }} */
|
|
11
32
|
export function getWorktreeStatus(cwd) {
|
|
12
|
-
const res =
|
|
33
|
+
const res = runGit(["status", "--short"], { cwd });
|
|
13
34
|
if (!res.ok) return { dirty: false, dirtyFiles: [] };
|
|
14
35
|
const files = res.stdout.trim().split("\n").filter(Boolean);
|
|
15
36
|
return { dirty: files.length > 0, dirtyFiles: files };
|
|
@@ -17,19 +38,19 @@ export function getWorktreeStatus(cwd) {
|
|
|
17
38
|
|
|
18
39
|
/** @returns {string|null} */
|
|
19
40
|
export function getCurrentBranch(cwd) {
|
|
20
|
-
const res =
|
|
41
|
+
const res = runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
|
|
21
42
|
return res.ok ? res.stdout.trim() : null;
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
/** @returns {number} */
|
|
25
46
|
export function getAheadCount(cwd, baseBranch) {
|
|
26
|
-
const res =
|
|
47
|
+
const res = runGit(["rev-list", "--count", `${baseBranch}..HEAD`], { cwd });
|
|
27
48
|
return res.ok ? parseInt(res.stdout.trim(), 10) || 0 : 0;
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
/** @returns {string|null} */
|
|
31
52
|
export function getLastCommit(cwd) {
|
|
32
|
-
const res =
|
|
53
|
+
const res = runGit(["log", "-1", "--oneline"], { cwd });
|
|
33
54
|
return res.ok ? res.stdout.trim() : null;
|
|
34
55
|
}
|
|
35
56
|
|
|
@@ -47,9 +68,9 @@ export function isGhAvailable() {
|
|
|
47
68
|
export function collectGitSummary(root, baseBranch) {
|
|
48
69
|
let diffStat = "";
|
|
49
70
|
let commitMessages = [];
|
|
50
|
-
const diffRes =
|
|
71
|
+
const diffRes = runGit(["diff", "--stat", `${baseBranch}...HEAD`], { cwd: root });
|
|
51
72
|
if (diffRes.ok) diffStat = diffRes.stdout.trim();
|
|
52
|
-
const logRes =
|
|
73
|
+
const logRes = runGit(["log", "--format=%s", `${baseBranch}..HEAD`], { cwd: root });
|
|
53
74
|
if (logRes.ok) commitMessages = logRes.stdout.trim().split("\n").filter(Boolean);
|
|
54
75
|
return { diffStat, commitMessages };
|
|
55
76
|
}
|
package/src/lib/lint.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import path from "path";
|
|
10
|
-
import {
|
|
10
|
+
import { assertOk } from "./process.js";
|
|
11
|
+
import { runGit } from "./git-helpers.js";
|
|
11
12
|
import { filterByPhase, matchScope } from "./guardrail.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -35,7 +36,7 @@ export function validateLintGuardrails(guardrails) {
|
|
|
35
36
|
* @returns {string[]} Relative file paths
|
|
36
37
|
*/
|
|
37
38
|
export function getChangedFiles(root, base) {
|
|
38
|
-
const res =
|
|
39
|
+
const res = runGit(["-C", root, "diff", "--name-only", `${base}...HEAD`]);
|
|
39
40
|
if (!res.ok) {
|
|
40
41
|
assertOk(res, "git diff failed");
|
|
41
42
|
}
|
package/src/lib/process.js
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { execFileSync, execFile } from "child_process";
|
|
9
|
-
import { Logger } from "./log.js";
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* Run a command synchronously.
|
|
13
12
|
*
|
|
13
|
+
* Pure command runner: no domain-specific logging is performed here.
|
|
14
|
+
* For git command logging, callers should use `runGit` from `git-helpers.js`.
|
|
15
|
+
*
|
|
14
16
|
* @param {string} cmd - Command to execute
|
|
15
17
|
* @param {string[]} args - Argument array
|
|
16
18
|
* @param {Object} [opts]
|
|
@@ -31,12 +33,9 @@ export function runCmd(cmd, args, opts = {}) {
|
|
|
31
33
|
stdio: ["pipe", "pipe", "pipe"],
|
|
32
34
|
...(opts.env && { env: opts.env }),
|
|
33
35
|
});
|
|
34
|
-
|
|
35
|
-
// TODO: git logging in runCmd causes infinite recursion via resolveLogDir → getMainRepoPath → runCmd
|
|
36
|
-
// if (cmd === "git") Logger.getInstance().git({ cmd: [cmd, ...args], exitCode: 0, stderr: "" });
|
|
37
|
-
return result;
|
|
36
|
+
return { ok: true, status: 0, stdout: String(stdout || ""), stderr: "", signal: null, killed: false };
|
|
38
37
|
} catch (e) {
|
|
39
|
-
|
|
38
|
+
return {
|
|
40
39
|
ok: false,
|
|
41
40
|
status: e.status ?? 1,
|
|
42
41
|
stdout: String(e.stdout || ""),
|
|
@@ -44,9 +43,6 @@ export function runCmd(cmd, args, opts = {}) {
|
|
|
44
43
|
signal: e.signal ?? null,
|
|
45
44
|
killed: e.killed ?? false,
|
|
46
45
|
};
|
|
47
|
-
// TODO: git logging in runCmd causes infinite recursion via resolveLogDir → getMainRepoPath → runCmd
|
|
48
|
-
// if (cmd === "git") Logger.getInstance().git({ cmd: [cmd, ...args], exitCode: result.status, stderr: result.stderr });
|
|
49
|
-
return result;
|
|
50
46
|
}
|
|
51
47
|
}
|
|
52
48
|
|
|
@@ -96,8 +92,6 @@ export function runCmdAsync(cmd, args, opts = {}) {
|
|
|
96
92
|
killed: false,
|
|
97
93
|
};
|
|
98
94
|
}
|
|
99
|
-
// TODO: git logging in runCmdAsync causes infinite recursion via resolveLogDir → getMainRepoPath → runCmd
|
|
100
|
-
// if (cmd === "git") Logger.getInstance().git({ cmd: [cmd, ...args], exitCode: result.status, stderr: result.stderr });
|
|
101
95
|
resolve(result);
|
|
102
96
|
},
|
|
103
97
|
);
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"init": {
|
|
108
108
|
"noType": "ERROR: type is not set. Set \"type\" in .sdd-forge/config.json or use --type option.",
|
|
109
109
|
"noTemplates": "ERROR: no template files found in chain",
|
|
110
|
-
"conflictsExist": "
|
|
110
|
+
"conflictsExist": "WARN: {{count}} file(s) already exist in docs/:",
|
|
111
111
|
"useForce": "Use --force to overwrite.",
|
|
112
112
|
"aiFilterFailed": "[init] WARN: AI chapter selection failed: {{message}}",
|
|
113
113
|
"aiResponseInvalid": "[init] WARN: AI response is not valid JSON, skipping AI filter.",
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
"init": {
|
|
108
108
|
"noType": "ERROR: type が設定されていません。.sdd-forge/config.json に \"type\" を設定するか --type オプションを指定してください。",
|
|
109
109
|
"noTemplates": "ERROR: テンプレートファイルがチェーン内に見つかりません",
|
|
110
|
-
"conflictsExist": "
|
|
110
|
+
"conflictsExist": "WARN: {{count}} 件のファイルが docs/ に既に存在します:",
|
|
111
111
|
"useForce": "--force で上書きできます。",
|
|
112
112
|
"aiFilterFailed": "[init] WARN: AI 章選択に失敗: {{message}}",
|
|
113
113
|
"aiResponseInvalid": "[init] WARN: AI レスポンスが有効な JSON ではありません。AI フィルタをスキップします。",
|