throughline 0.10.10 → 0.10.12
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/CHANGELOG.md +17 -1
- package/README.md +1 -1
- package/docs/04_public_release_plan.md +1 -1
- package/package.json +1 -1
- package/src/codex-capture.test.mjs +63 -0
- package/src/codex-rollout-memory.mjs +33 -21
- package/src/docs-contract.test.mjs +7 -4
- package/src/product-ci-plan.test.mjs +150 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,20 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.10.12] — 2026-09-01
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- 変更した実装の依存関係から必要なテストと工場環境を選び、依存を確定できない変更だけ全件へ広げ、選択jobのskip・cancel・failureを最終gateで拒否する製品所有CIへ更新した。
|
|
18
|
+
- 文書だけの変更はLinuxの文書検査だけを実行し、共通・未分類の製品変更は従来どおり3環境で検証する。
|
|
19
|
+
|
|
20
|
+
## [0.10.11] — 2026-09-01
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Codex 0.151以降が会話本文を`response_item`だけに記録するrolloutでも、userとassistantの
|
|
25
|
+
発言をL2へ取り込む。旧`event_msg`が併記されるrolloutでは同じ発言を二重保存しない。
|
|
26
|
+
|
|
13
27
|
## [0.10.10] — 2026-09-01
|
|
14
28
|
|
|
15
29
|
### Fixed
|
|
@@ -1407,7 +1421,9 @@ two attempts, instrument first instead of patching again.
|
|
|
1407
1421
|
|
|
1408
1422
|
---
|
|
1409
1423
|
|
|
1410
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.
|
|
1424
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.10.12...HEAD
|
|
1425
|
+
[0.10.12]: https://github.com/kitepon/Throughline/compare/v0.10.11...v0.10.12
|
|
1426
|
+
[0.10.11]: https://github.com/kitepon/Throughline/compare/v0.10.10...v0.10.11
|
|
1411
1427
|
[0.10.10]: https://github.com/kitepon/Throughline/compare/v0.10.9...v0.10.10
|
|
1412
1428
|
[0.10.9]: https://github.com/kitepon/Throughline/compare/v0.10.8...v0.10.9
|
|
1413
1429
|
[0.10.8]: https://github.com/kitepon/Throughline/compare/v0.10.7...v0.10.8
|
package/README.md
CHANGED
|
@@ -817,7 +817,7 @@ entry to the `tasks` array yourself:
|
|
|
817
817
|
|
|
818
818
|
## Commands
|
|
819
819
|
|
|
820
|
-
**The current release is v0.10.
|
|
820
|
+
**The current release is v0.10.12.** Throughline supports Claude Code, Codex,
|
|
821
821
|
Grok, and Cursor as documented host adapters. The versioned, read-only
|
|
822
822
|
`handoff-context` boundary opens only an existing database and leaves baton
|
|
823
823
|
state, session ownership, and memory rows unchanged. Factory diagnostics,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 現行状態
|
|
8
8
|
|
|
9
|
-
- release candidateのpackage版は `0.10.
|
|
9
|
+
- release candidateのpackage版は `0.10.12`。tagとnpmの公開確認はpublish後に行う。
|
|
10
10
|
- Claude Code、Codex、Grok、Cursorをfirst-class hostとして扱う。
|
|
11
11
|
- 現行DB schemaはv9。schemaの正本は [src/db.mjs](../src/db.mjs) の
|
|
12
12
|
`CURRENT_VERSION`、二相handoffの判断は
|
package/package.json
CHANGED
|
@@ -248,6 +248,69 @@ test('captureCodexRolloutToDb: rebuilds namespaced Codex session from active rol
|
|
|
248
248
|
}
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
+
test('captureCodexRolloutToDb: Codex 0.151 response_item会話を取り込み旧event_msgとの重複を除く', () => {
|
|
252
|
+
const db = makeDb();
|
|
253
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
254
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-codex-project-'));
|
|
255
|
+
const threadId = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
256
|
+
try {
|
|
257
|
+
writeRollout(home, {
|
|
258
|
+
id: threadId,
|
|
259
|
+
cwd: project,
|
|
260
|
+
events: [
|
|
261
|
+
event('task_started'),
|
|
262
|
+
responseItem({
|
|
263
|
+
type: 'message',
|
|
264
|
+
role: 'user',
|
|
265
|
+
content: [{ type: 'input_text', text: 'current request' }],
|
|
266
|
+
}),
|
|
267
|
+
responseItem({
|
|
268
|
+
type: 'message',
|
|
269
|
+
role: 'assistant',
|
|
270
|
+
content: [{ type: 'output_text', text: 'current answer' }],
|
|
271
|
+
}),
|
|
272
|
+
event('task_complete'),
|
|
273
|
+
event('user_message', { message: 'dual request' }),
|
|
274
|
+
event('task_started'),
|
|
275
|
+
responseItem({
|
|
276
|
+
type: 'message',
|
|
277
|
+
role: 'user',
|
|
278
|
+
content: [{ type: 'input_text', text: 'dual request' }],
|
|
279
|
+
}),
|
|
280
|
+
event('agent_message', { message: 'dual answer' }),
|
|
281
|
+
responseItem({
|
|
282
|
+
type: 'message',
|
|
283
|
+
role: 'assistant',
|
|
284
|
+
content: [{ type: 'output_text', text: 'dual answer' }],
|
|
285
|
+
}),
|
|
286
|
+
event('task_complete'),
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const result = captureCodexRolloutToDb(db, {
|
|
291
|
+
threadId,
|
|
292
|
+
codexHome: home,
|
|
293
|
+
projectPath: project,
|
|
294
|
+
now: 1234,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
assert.equal(result.capturedTurns, 2);
|
|
298
|
+
assert.equal(result.capturedRows, 4);
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY id').all().map((row) => ({ ...row })),
|
|
301
|
+
[
|
|
302
|
+
{ turn_number: 1, role: 'user', text: 'current request' },
|
|
303
|
+
{ turn_number: 1, role: 'assistant', text: 'current answer' },
|
|
304
|
+
{ turn_number: 2, role: 'user', text: 'dual request' },
|
|
305
|
+
{ turn_number: 2, role: 'assistant', text: 'dual answer' },
|
|
306
|
+
],
|
|
307
|
+
);
|
|
308
|
+
} finally {
|
|
309
|
+
rmSync(home, { recursive: true, force: true });
|
|
310
|
+
rmSync(project, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
251
314
|
test('captureCodexRolloutToDb: second capture removes stale rows from previous active tail', () => {
|
|
252
315
|
const db = makeDb();
|
|
253
316
|
const home = mkdtempSync(join(tmpdir(), 'tl-codex-home-'));
|
|
@@ -106,21 +106,20 @@ export function parseCodexRolloutFile(
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
if (row?.type === 'response_item') {
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
109
|
+
const responseMessage = responseItemToConversationMessage(payload, row.timestamp);
|
|
110
|
+
if (responseMessage?.role === 'user') {
|
|
111
111
|
if (stats.rollbackEvents > 0) {
|
|
112
112
|
stats.userMessagesAfterRollback++;
|
|
113
113
|
}
|
|
114
114
|
notePotentialResurrectedUserMessage({
|
|
115
|
-
message:
|
|
115
|
+
message: responseMessage,
|
|
116
116
|
compactedReplacementUserTexts,
|
|
117
117
|
rolledBackUserTexts,
|
|
118
118
|
resurrectedUserTexts,
|
|
119
119
|
stats,
|
|
120
120
|
});
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
if (injectedMessage) {
|
|
122
|
+
if (responseMessage?.role === 'developer') {
|
|
124
123
|
stats.injectedDeveloperMessages++;
|
|
125
124
|
}
|
|
126
125
|
const detail = responseItemToDetail(payload, row.timestamp, toolNameByCallId);
|
|
@@ -144,6 +143,24 @@ export function parseCodexRolloutFile(
|
|
|
144
143
|
pendingDetails.push(detail);
|
|
145
144
|
}
|
|
146
145
|
}
|
|
146
|
+
if (responseMessage && responseMessage.role !== 'developer') {
|
|
147
|
+
if (openTurn) {
|
|
148
|
+
appendUniqueMessage(openTurn.messages, responseMessage);
|
|
149
|
+
} else if (afterRollback && responseMessage.role === 'assistant') {
|
|
150
|
+
if (!postRollbackTurn) {
|
|
151
|
+
postRollbackTurn = {
|
|
152
|
+
number: `rollout-${stats.parsedRows}`,
|
|
153
|
+
messages: [],
|
|
154
|
+
details: [],
|
|
155
|
+
completedAt: null,
|
|
156
|
+
};
|
|
157
|
+
activeTurns.push(postRollbackTurn);
|
|
158
|
+
}
|
|
159
|
+
appendUniqueMessage(postRollbackTurn.messages, responseMessage);
|
|
160
|
+
} else {
|
|
161
|
+
appendUniqueMessage(pendingMessages, responseMessage);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
147
164
|
continue;
|
|
148
165
|
}
|
|
149
166
|
|
|
@@ -226,7 +243,7 @@ export function parseCodexRolloutFile(
|
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
if (openTurn) {
|
|
229
|
-
openTurn.messages
|
|
246
|
+
appendUniqueMessage(openTurn.messages, message);
|
|
230
247
|
} else if (afterRollback && message.role === 'assistant') {
|
|
231
248
|
if (!postRollbackTurn) {
|
|
232
249
|
postRollbackTurn = {
|
|
@@ -237,9 +254,9 @@ export function parseCodexRolloutFile(
|
|
|
237
254
|
};
|
|
238
255
|
activeTurns.push(postRollbackTurn);
|
|
239
256
|
}
|
|
240
|
-
postRollbackTurn.messages
|
|
257
|
+
appendUniqueMessage(postRollbackTurn.messages, message);
|
|
241
258
|
} else {
|
|
242
|
-
pendingMessages
|
|
259
|
+
appendUniqueMessage(pendingMessages, message);
|
|
243
260
|
}
|
|
244
261
|
}
|
|
245
262
|
|
|
@@ -490,27 +507,22 @@ function eventPayloadToMemoryMessage(payload, timestamp) {
|
|
|
490
507
|
return null;
|
|
491
508
|
}
|
|
492
509
|
|
|
493
|
-
function
|
|
494
|
-
if (payload?.type !== 'message' ||
|
|
510
|
+
function responseItemToConversationMessage(payload, timestamp) {
|
|
511
|
+
if (payload?.type !== 'message' || !['user', 'assistant', 'developer'].includes(payload.role)) return null;
|
|
495
512
|
const text = normalizeMessageText(messageContentToText(payload.content));
|
|
496
|
-
if (!isThroughlineInjectedDeveloperMemory(text)) return null;
|
|
497
513
|
if (!text) return null;
|
|
514
|
+
if (payload.role === 'developer' && !isThroughlineInjectedDeveloperMemory(text)) return null;
|
|
515
|
+
if (payload.role === 'user' && shouldSkipUserMessage(text)) return null;
|
|
498
516
|
return {
|
|
499
517
|
time: timestamp ?? null,
|
|
500
|
-
role:
|
|
518
|
+
role: payload.role,
|
|
501
519
|
text,
|
|
502
520
|
};
|
|
503
521
|
}
|
|
504
522
|
|
|
505
|
-
function
|
|
506
|
-
if (
|
|
507
|
-
|
|
508
|
-
if (!text || shouldSkipUserMessage(text)) return null;
|
|
509
|
-
return {
|
|
510
|
-
time: timestamp ?? null,
|
|
511
|
-
role: 'user',
|
|
512
|
-
text,
|
|
513
|
-
};
|
|
523
|
+
function appendUniqueMessage(messages, message) {
|
|
524
|
+
if (messages.some((item) => item.role === message.role && item.text === message.text)) return;
|
|
525
|
+
messages.push(message);
|
|
514
526
|
}
|
|
515
527
|
|
|
516
528
|
function isThroughlineInjectedDeveloperMemory(text) {
|
|
@@ -24,16 +24,19 @@ test('Markdown-only CI runs the product-owned documentation contract', () => {
|
|
|
24
24
|
);
|
|
25
25
|
assert.match(
|
|
26
26
|
workflow,
|
|
27
|
-
/documentation-command:\s+npm
|
|
27
|
+
/documentation-command:\s+npm run verify:docs/,
|
|
28
28
|
);
|
|
29
29
|
assert.doesNotMatch(workflow, /documentation-command:\s*(?:["']{2})?\s*$/m);
|
|
30
30
|
|
|
31
31
|
const reusable = readFileSync(new URL('../.github/workflows/product-full-ci.yml', import.meta.url), 'utf8');
|
|
32
|
-
|
|
32
|
+
const runner = readFileSync(new URL('../scripts/run-product-ci-tests.mjs', import.meta.url), 'utf8');
|
|
33
|
+
assert.match(reusable, /name: documentation dependency install/);
|
|
34
|
+
assert.match(reusable, /scripts\/product-ci-plan\.mjs verify/);
|
|
33
35
|
assert.ok(
|
|
34
|
-
reusable.indexOf('
|
|
35
|
-
'documentation
|
|
36
|
+
reusable.indexOf('documentation dependency install') < reusable.indexOf('- name: documentation check'),
|
|
37
|
+
'documentation dependencies must be installed before verification',
|
|
36
38
|
);
|
|
39
|
+
assert.doesNotMatch(runner, /npm\.cmd|cmd\.exe/);
|
|
37
40
|
|
|
38
41
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
39
42
|
const result = spawnPortableSync(npmCommand, ['run', 'verify:docs', '--silent'], {
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ALL_ENVIRONMENTS,
|
|
10
|
+
classifyPaths,
|
|
11
|
+
selectTestFiles,
|
|
12
|
+
verifyResults,
|
|
13
|
+
} from '../scripts/product-ci-plan.mjs';
|
|
14
|
+
|
|
15
|
+
const SCRIPT = resolve(import.meta.dirname, '..', 'scripts', 'product-ci-plan.mjs');
|
|
16
|
+
|
|
17
|
+
function git(root, ...args) {
|
|
18
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
19
|
+
assert.equal(result.status, 0, result.stderr);
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function repository(t) {
|
|
24
|
+
const root = await mkdtemp(join(tmpdir(), 'throughline-ci-plan-'));
|
|
25
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
26
|
+
git(root, 'init');
|
|
27
|
+
git(root, 'config', 'user.name', 'CI test');
|
|
28
|
+
git(root, 'config', 'user.email', 'ci@example.invalid');
|
|
29
|
+
await writeFile(join(root, 'README.md'), '# fixture\n', 'utf8');
|
|
30
|
+
git(root, 'add', 'README.md');
|
|
31
|
+
git(root, 'commit', '-m', 'initial');
|
|
32
|
+
const base = git(root, 'rev-parse', 'HEAD');
|
|
33
|
+
await mkdir(join(root, 'src', 'os'), { recursive: true });
|
|
34
|
+
await writeFile(join(root, 'src', 'os', 'windows-acl.mjs'), '// fixture\n', 'utf8');
|
|
35
|
+
git(root, 'add', 'src/os/windows-acl.mjs');
|
|
36
|
+
git(root, 'commit', '-m', 'windows');
|
|
37
|
+
return { root, base, head: git(root, 'rev-parse', 'HEAD') };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('文書だけの変更はLinuxの文書検査へ分類する', () => {
|
|
41
|
+
const plan = classifyPaths(['README.md', 'docs/04_public_release_plan.md']);
|
|
42
|
+
assert.equal(plan.productChange, false);
|
|
43
|
+
assert.deepEqual(plan.environments, ['linux-workstation']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('macOS固有変更はmacOSとLinuxへ分類する', () => {
|
|
47
|
+
const plan = classifyPaths(['src/os/macos-terminal.mjs']);
|
|
48
|
+
assert.equal(plan.productChange, true);
|
|
49
|
+
assert.deepEqual(plan.environments, ['macos-native', 'linux-workstation']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('Windows固有変更はLinuxとWindowsへ分類する', () => {
|
|
53
|
+
const plan = classifyPaths(['src/os/windows-acl.mjs']);
|
|
54
|
+
assert.equal(plan.productChange, true);
|
|
55
|
+
assert.deepEqual(plan.environments, ['linux-workstation', 'windows-native']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('共通変更と未分類変更は全環境へ広げる', () => {
|
|
59
|
+
for (const paths of [['src/db.mjs'], ['new-product/file.txt'], ['.github/workflows/test.yml'], []]) {
|
|
60
|
+
const plan = classifyPaths(paths);
|
|
61
|
+
assert.equal(plan.productChange, true);
|
|
62
|
+
assert.deepEqual(plan.environments, ALL_ENVIRONMENTS);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('実装変更は依存するテストだけを選ぶ', () => {
|
|
67
|
+
const plan = selectTestFiles(['src/terminal-size.mjs'], resolve(import.meta.dirname, '..'));
|
|
68
|
+
assert.equal(plan.testScope, 'selected');
|
|
69
|
+
assert.ok(plan.testFiles.includes('src/terminal-size.test.mjs'));
|
|
70
|
+
assert.ok(plan.testFiles.length < 82);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('依存を確定できない変更は全テストへ広げる', () => {
|
|
74
|
+
assert.deepEqual(selectTestFiles(['package.json'], resolve(import.meta.dirname, '..')), {
|
|
75
|
+
testScope: 'all',
|
|
76
|
+
testFiles: [],
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('結果判定は選択されたfullの成功と文書時の明示skipだけを受け入れる', () => {
|
|
81
|
+
assert.doesNotThrow(() => verifyResults({
|
|
82
|
+
classifyResult: 'success',
|
|
83
|
+
fullResult: 'success',
|
|
84
|
+
productChange: 'true',
|
|
85
|
+
}));
|
|
86
|
+
assert.doesNotThrow(() => verifyResults({
|
|
87
|
+
classifyResult: 'success',
|
|
88
|
+
fullResult: 'skipped',
|
|
89
|
+
productChange: 'false',
|
|
90
|
+
}));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('結果判定は分類失敗と理由不明skipを拒否する', () => {
|
|
94
|
+
assert.throws(() => verifyResults({
|
|
95
|
+
classifyResult: 'failure',
|
|
96
|
+
fullResult: 'skipped',
|
|
97
|
+
productChange: '',
|
|
98
|
+
}), /変更分類jobが成功していません/u);
|
|
99
|
+
assert.throws(() => verifyResults({
|
|
100
|
+
classifyResult: 'success',
|
|
101
|
+
fullResult: 'skipped',
|
|
102
|
+
productChange: 'true',
|
|
103
|
+
}), /選択計画と実行結果が一致しません/u);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('plan入口はGit履歴から変更を分類してGitHub outputへ書く', async (t) => {
|
|
107
|
+
const repo = await repository(t);
|
|
108
|
+
const output = join(repo.root, 'github-output.txt');
|
|
109
|
+
const result = spawnSync(process.execPath, [SCRIPT, 'plan'], {
|
|
110
|
+
cwd: repo.root,
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
env: {
|
|
113
|
+
...process.env,
|
|
114
|
+
EVENT_NAME: 'push',
|
|
115
|
+
BEFORE_SHA: repo.base,
|
|
116
|
+
BASE_SHA: '',
|
|
117
|
+
DISPATCH_BASE: '',
|
|
118
|
+
GITHUB_REF: 'refs/heads/main',
|
|
119
|
+
GITHUB_SHA: repo.head,
|
|
120
|
+
GITHUB_OUTPUT: output,
|
|
121
|
+
REQUESTED_ENVIRONMENT: 'all',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
assert.equal(result.status, 0, result.stderr);
|
|
125
|
+
const outputs = Object.fromEntries((await readFile(output, 'utf8')).trim().split('\n')
|
|
126
|
+
.map((line) => line.split(/=(.*)/su).slice(0, 2)));
|
|
127
|
+
assert.equal(outputs.product_change, 'true');
|
|
128
|
+
assert.deepEqual(JSON.parse(outputs.environments), ['linux-workstation', 'windows-native']);
|
|
129
|
+
assert.equal(outputs.test_scope, 'all');
|
|
130
|
+
assert.equal(outputs.comparison_base, repo.base);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('plan入口はzero SHAを明示失敗にする', async (t) => {
|
|
134
|
+
const repo = await repository(t);
|
|
135
|
+
const result = spawnSync(process.execPath, [SCRIPT, 'plan'], {
|
|
136
|
+
cwd: repo.root,
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
env: {
|
|
139
|
+
...process.env,
|
|
140
|
+
EVENT_NAME: 'push',
|
|
141
|
+
BEFORE_SHA: '0'.repeat(40),
|
|
142
|
+
GITHUB_REF: 'refs/heads/main',
|
|
143
|
+
GITHUB_SHA: repo.head,
|
|
144
|
+
GITHUB_OUTPUT: join(repo.root, 'github-output.txt'),
|
|
145
|
+
REQUESTED_ENVIRONMENT: 'all',
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
assert.equal(result.status, 2);
|
|
149
|
+
assert.match(result.stderr, /CI comparison baseが必要です/u);
|
|
150
|
+
});
|