leerness 1.36.119 → 1.36.121

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 CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.119 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.121 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.119는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.121는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.119는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.119 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.121 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.119: 2026-08-14
238
+ Last synced by Leerness v1.36.121: 2026-08-15
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.119';
37
+ const VERSION = '1.36.121';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -23935,7 +23935,10 @@ function adapterCmd(root, tool, opts = {}) {
23935
23935
  // 우리가 쓴 항목이 하나라도 있을 때만 그렇게 말한다. 그 밖에는 무엇을 보존했는지만 알린다.
23936
23936
  const _wrote = mcpResults.filter(r => r.action === 'created' || r.action === 'updated');
23937
23937
  const _kept = mcpResults.filter(r => r.preserved && !r.broken);
23938
- if (a.mcp && _wrote.length) log(` ℹ MCP 등록 도구가 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`);
23938
+ // 1.36.120 (검수·자체 재현): cursor MCP 설정이 **둘**(루트 `.mcp.json` + `.cursor/mcp.json`)이다.
23939
+ // 한쪽만 우리가 썼는데 "이 도구가 직접 호출 가능" 이라고 하면, 정작 그 도구가 읽는 파일은 남의 설정일 수 있다.
23940
+ // 주장은 **파일 단위**로 한다 — 우리가 쓴 파일만 이름을 대고 말한다.
23941
+ _wrote.forEach(r => log(` ℹ ${r.file} 에 MCP 등록 — 이 파일을 읽는 도구는 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`));
23939
23942
  _kept.forEach(r => log(` ℹ ${r.file} 의 기존 leerness 항목을 그대로 두었습니다 — 그 항목이 leerness MCP 를 띄우는지는 확인하지 않았습니다`));
23940
23943
  // 보존은 안전하지만 **막다른 길이면 안 된다** — 우리 것이 망가졌을 때 되돌릴 방법을 알려 준다(자체 헌트).
23941
23944
  if (_kept.length || _brk.length) log(` → 표준 설정으로 되돌리려면 그 파일의 "leerness" 항목을 지우고 이 명령을 다시 실행하세요`);
package/lib/audit.js CHANGED
@@ -83,7 +83,9 @@ function _collectGuardInputs(root) {
83
83
  // 1.36.116 (검수 #5): 경로에 `scripts/` 가 있어야만 수집했다 — `tools/run-ci.mjs`·`ci/run.mjs`·`bin/run-checks.js`
84
84
  // 같은 흔한 배치를 놓치고, 그 안의 동적 열거자를 못 보면 다시 오탐이 폭발한다. 디렉터리 이름을 넓힌다.
85
85
  // 경로 구분자는 `/`·`\` 둘 다 받는다 — Windows CI 는 `.\bin\cli.js` 로 적는다(검수 P2 미탐).
86
- const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\][\w.-]+\.(?:m?js|cjs|ts|sh)/g;
86
+ // ⚠ **중간 디렉터리**를 허용해야 한다: `scripts/a/run.js` 처럼 한 단계 더 들어간 러너가 아예 수집되지 않아
87
+ // 그 러너가 배선한 것이 전부 거짓 고아가 됐다(실측: `scripts/run.js` 는 잡히고 `scripts/a/run.js` 는 안 잡힘).
88
+ const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\](?:[\w.-]+[/\\])*[\w.-]+\.(?:m?js|cjs|ts|sh)/g;
87
89
  const _norm = (x) => String(x).replace(/\\/g, '/').replace(/^\.\//, '');
88
90
  // CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
89
91
  // ⚠ 자기 진입점 제외는 **CI 설정이 직접 부르는 경우까지** 막으면 안 된다(검수 P2) — 그 파일이 실제로
@@ -91,9 +93,17 @@ function _collectGuardInputs(root) {
91
93
  // ⚠ 단 **주석 줄에서 수집하면 안 된다**: `# debug: node bin/cli.js` 한 줄로 제품 진입점이 러너가 되고
92
94
  // 그 안의 문자열이 진짜 고아를 덮는다(검수 P2 — 자기참조가 주석 경로로 되살아난다).
93
95
  // CMD 스텝의 `REM`·`::` 도 주석이다(검수 재현: `REM debug: node .\bin\phantom.js` 한 줄로 제품 진입점이 러너가 됐다).
94
- const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|--|;|::|REM\s)/i.test(l)).join('\n');
96
+ // ⚠ `--`·`;` 여기서 다루는 파일 종류(YAML·Makefile·셸·JS)에서 주석이 아니다 — 오히려 여러 줄 명령의
97
+ // 연속 인자(`--file=scripts/x.js`)를 지워 파일 참조를 잃는다. 주석 표기는 실제로 쓰이는 것만 둔다.
98
+ const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|::|@?REM\s)/i.test(l)).join('\n');
95
99
  // 설명 필드(`- name: archive index.js`)는 실행 증거가 아니다 — bare 진입점 매처에도 같은 규칙을 건다.
96
- const _noDescriptive = (s) => String(s).split('\n').filter(l => !/^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:/i.test(l)).join('\n');
100
+ // 단 값이 **산문일 때만** 설명으로 본다(matrix 변수명이 `name` 일 수 있다 — 판정기와 같은 규칙).
101
+ const _noDescriptive = (s) => String(s).split('\n').filter(l => {
102
+ const m = /^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:\s*(.*)$/i.exec(l);
103
+ if (!m) return true;
104
+ const v = String(m[1]).trim();
105
+ return !v || v.startsWith('[') || !/\s/.test(v);
106
+ }).join('\n');
97
107
  for (const r of runners.slice()) {
98
108
  const txt = _noComment(r.text);
99
109
  for (const m of txt.matchAll(_FILE_RE)) push(_norm(m[0]), null);
@@ -142,7 +152,13 @@ function audit(root, opts = {}, deps = {}) {
142
152
  const findings = [];
143
153
  const _finding = (kind, severity, message, details = {}) => findings.push({ kind, severity, message, ...details });
144
154
  const _origWrite = process.stdout.write.bind(process.stdout);
145
- if (jsonMode) process.stdout.write = () => true;
155
+ // 1.36.120: `--json` 은 **stdout 만** 막고 있었다 — `warn()` 쓰는 stderr 로 사람용 문구가 그대로 샜다.
156
+ // 그 결과 JSON 계약 표면에 잡음이 남고, 그 잡음의 내용이 **프로젝트 상태에 따라** 달라져
157
+ // 게이트 판정이 저장소 상태에 종속됐다(같은 코드로 통과/실패가 갈렸다). json 모드에서는 양쪽을 다 막는다.
158
+ // (findings 배열이 그 정보를 이미 싣고 있으므로 사람용 문구는 중복이다.)
159
+ const _origErrWrite = process.stderr.write.bind(process.stderr);
160
+ const _restoreStreams = () => { process.stdout.write = _origWrite; process.stderr.write = _origErrWrite; };
161
+ if (jsonMode) { process.stdout.write = () => true; process.stderr.write = () => true; }
146
162
  try {
147
163
  // 외부리뷰 CV-3/UR-0078: 미초기화/존재하지 않는 경로를 healthy 로 오판하던 것 수정 — 필수 마커 부재 시 failure 승격(verify 와 일관).
148
164
  if (!exists(root) || !exists(path.join(root, '.harness')) || !exists(path.join(root, 'AGENTS.md'))) {
@@ -151,7 +167,7 @@ function audit(root, opts = {}, deps = {}) {
151
167
  _finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
152
168
  // 1.27.1 (13번째 외부리뷰 #2): 미초기화 시 후속 체크(design/reuse 등)를 없는 하네스에 대해 보고하던 모순 출력 차단 — 요약/JSON 으로 직행 후 종료(exit code/JSON 페이로드는 종전과 동일).
153
169
  log(`Audit summary: warnings=${warnings} failures=${failures}`);
154
- if (jsonMode) { process.stdout.write = _origWrite; process.stdout.write(JSON.stringify({ version: VERSION, root, warnings, failures, fixed, healthy: false, fixApplied: fix, strict: has('--strict'), strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null, summary: `warnings=${warnings} failures=${failures}`, findings }, null, 2) + '\n'); }
170
+ if (jsonMode) { _restoreStreams(); process.stdout.write(JSON.stringify({ version: VERSION, root, warnings, failures, fixed, healthy: false, fixApplied: fix, strict: has('--strict'), strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null, summary: `warnings=${warnings} failures=${failures}`, findings }, null, 2) + '\n'); }
155
171
  process.exitCode = 1;
156
172
  return;
157
173
  }
@@ -549,7 +565,7 @@ function audit(root, opts = {}, deps = {}) {
549
565
  log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
550
566
  } finally {
551
567
  // 1.9.102: stdout 복원
552
- if (jsonMode) process.stdout.write = _origWrite;
568
+ if (jsonMode) _restoreStreams();
553
569
  }
554
570
  // 1.9.102: JSON 모드 — 구조화 출력
555
571
  if (jsonMode) {
package/lib/pure-utils.js CHANGED
@@ -1035,6 +1035,15 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
1035
1035
  // 1.36.116 (검수 #3): `guard:*` 가 후보 어휘에 없었다 — 이 기능이 잡으려는 것의 **이름 그 자체**인데
1036
1036
  // `guard:firestore-parity` 같은 스크립트는 후보가 0개라 구조적으로 못 봤다. `validate` 도 접미 위치에 없었다.
1037
1037
  const _GUARD_NAME_RE = /^(?:pre|post)?(?:test|check|verify|validate|guard|gate|lint|typecheck|audit|scan|e2e|smoke)\b|[:-](?:test|check|verify|validate|guard|gate|lint|audit|scan|e2e|smoke)\b/i;
1038
+ // 경로 표기 정규화 — 이 파일 안의 모든 경로 매칭이 같은 규칙을 쓴다(정규화가 갈리면 한쪽만 맞는다).
1039
+ // ① backslash → `/` (이스케이프된 `\\` 도 접힌다) ② 반복 `/./` 축약 ③ 중복 `//` 축약.
1040
+ function _normPath(s) {
1041
+ let t = String(s).replace(/\\+/g, '/');
1042
+ let prev;
1043
+ do { prev = t; t = t.split('/./').join('/'); } while (t !== prev);
1044
+ do { prev = t; t = t.split('//').join('/'); } while (t !== prev);
1045
+ return t;
1046
+ }
1038
1047
  // 동적 열거자 판정은 **두 신호를 독립적으로** 본다 — 한 파일이 package.json 을 읽고, scripts 를 열거한다.
1039
1048
  // ⚠ 근접 창(예: 400자)을 요구했다가 실측에서 깨졌다: 실제 열거자는 읽기(L42)와 열거(L98)가 56줄 떨어져 있었고,
1040
1049
  // 그 결과 가장 잘 만든 프로젝트에서 85건 오탐이 났다. 거리 가정을 쓰지 않는다.
@@ -1124,7 +1133,10 @@ function _detectOrphanGuards(input) {
1124
1133
  // **주석 줄의 언급은 실행이 아니다.** 이 저장소에서 실제로 겪었다 — 이 함정을 설명하려고 쓴 주석에
1125
1134
  // 스크립트 이름을 적었더니 그 이름이 '덮임' 으로 처리돼 진짜 고아가 사라졌다(자기참조 3회차).
1126
1135
  // 전면 문자열 마스킹은 앞서 정상 입력에서 깨졌으므로 쓰지 않는다 — 줄 시작이 주석인 줄만 뺀다(저위험).
1127
- const _dropCommentLines = (s) => String(s).split('\n').filter(l => !/^\s*(\/\/|#|\*|--)/.test(l)).join('\n');
1136
+ // ⚠ 수집기(lib/audit.js) **같은 주석 표기**를 써야 한다 — 1.36.120 에서 수집기에만 CMD 표기(`::`·`@REM`)
1137
+ // 넣고 판정기에 안 넣어서, CMD 주석 줄의 이름이 '호출됨' 으로 계상돼 진짜 고아가 사라졌다(검수 재현).
1138
+ // `--`·`;` 는 여기서도 주석이 아니다(여러 줄 명령의 연속 인자를 지운다).
1139
+ const _dropCommentLines = (s) => String(s).split('\n').filter(l => !/^\s*(\/\/|#|\*|::|@?REM\s)/i.test(l)).join('\n');
1128
1140
  const _has = (hay, name) => {
1129
1141
  const esc = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1130
1142
  return new RegExp(`(^|[^\\w:.-])${esc}([^\\w:.-]|$)`, 'm').test(hay);
@@ -1216,8 +1228,16 @@ function _detectOrphanGuards(input) {
1216
1228
  // 사람이 읽는 설명 필드만 빼고 나머지는 그대로 본다.
1217
1229
  // 그 대가(측정됨): `env: { TARGET: check:x }`·`key: check:x-hash` 처럼 **데이터 값**에 이름이 있으면
1218
1230
  // 그것을 호출로 보고 진짜 고아를 가린다. 그건 미탐이라 감수한다.
1219
- const _DESCRIPTIVE = /^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:/i;
1220
- const _configLineOk = (l) => !_DESCRIPTIVE.test(l);
1231
+ // ⚠ `name:` **무조건** 설명으로 보면 matrix 변수명이 `name` 인 정상 배선이 잘려 나간다(검수 재현):
1232
+ // `matrix: { name: [check:leaf] }` + `run: npm run ${{ matrix.name }}`. 값이 **산문일 때만** 설명으로 본다 —
1233
+ // 목록(`[...]`)이나 공백 없는 단일 토큰은 데이터일 수 있으므로 남긴다(편향은 과소 보고).
1234
+ const _DESCRIPTIVE = /^\s*-?\s*(?:name|description|title|summary|comment|displayName|label)\s*:\s*(.*)$/i;
1235
+ const _configLineOk = (l) => {
1236
+ const m = _DESCRIPTIVE.exec(l);
1237
+ if (!m) return true;
1238
+ const v = String(m[1]).trim();
1239
+ return !v || v.startsWith('[') || !/\s/.test(v);
1240
+ };
1221
1241
  const _raw = _dropCommentLines(String(r.text || ''));
1222
1242
  const t = _isConfigRunner(r.file)
1223
1243
  ? _raw.split('\n').filter(_configLineOk).join('\n')
@@ -1242,8 +1262,16 @@ function _detectOrphanGuards(input) {
1242
1262
  // 1.36.116: 참조하는 쪽이 **도달 가능해야** 덮은 것으로 본다 — 아무도 안 부르는 스크립트가 파일을
1243
1263
  // 언급해 봐야 그 파일도 안 도는 건 마찬가지다(스크립트 쪽 #7 과 같은 결함이 파일 쪽에도 있었다).
1244
1264
  const liveScripts = Object.entries(scripts).filter(([n]) => isLive(n));
1245
- const allText = runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1246
- + liveScripts.map(([, b]) => String(b || '')).join('\n');
1265
+ // ⚠ 매칭 전에 경로 구분자를 정규화한다. Windows CI 는 `node scripts\x\check.js` 적는데
1266
+ // 후보 경로는 `/` 표기라 전체 경로가 안 맞고, basename 경계 판정은 앞의 `\` 때문에 걸러져
1267
+ // **실제로 실행되는 가드가 고아로 보고됐다**(자체 헌트 실측: POSIX 표기만 통과).
1268
+ // `scripts/./check.js` 같은 dot-segment 도 편다 — 전체 경로 비교가 정규화를 안 해서 실제 호출이
1269
+ // 고아로 보고됐다(검수 재현). basename 경계는 앞의 `/` 를 거부하므로 폴백으로도 안 잡혔다.
1270
+ // ⚠ 정규화는 **한 곳**에서 한다(`_normPath`): 실제 JS 원문에는 이스케이프된 `\\` 가 들어 있고
1271
+ // (`require(".\\check.js")` 의 파일 바이트는 backslash 2개), `a/././b` 처럼 dot-segment 가 반복될 수 있다.
1272
+ // 한 번만 치환하면 `.//check.js` 나 `a//b` 가 남아 실제 호출을 놓친다(검수 재현, 실제 파일 바이트로 확인).
1273
+ const allText = _normPath(runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1274
+ + liveScripts.map(([, b]) => String(b || '')).join('\n'));
1247
1275
  // ⚠ basename 만으로 덮였다고 보면 **동명 파일이 서로를 살린다**(검수 P2): `a/check.js` 를 부르는 배선이
1248
1276
  // `b/check.js` 까지 덮어 버린다. basename 은 그 이름이 유일할 때만 근거로 쓴다.
1249
1277
  const _baseCount = new Map();
@@ -1256,15 +1284,21 @@ function _detectOrphanGuards(input) {
1256
1284
  // 구분자는 `\` 도 받는다(`require(".\\a\\check.js")`).
1257
1285
  const _resolved = new Set();
1258
1286
  for (const r of runners.filter(_liveRunner)) {
1259
- const dir = String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
1260
- const body = _dropCommentLines(String(r.text || ''));
1287
+ // ⚠ 기준 디렉터리가 다르다: **CI 설정의 명령은 프로젝트 루트에서 실행**된다(`run: node ./scripts/x.js`).
1288
+ // 설정 파일 위치(`.github/workflows`) 기준으로 풀면 `.github/workflows/scripts/x.js` 돼 실제 호출을 놓친다(검수 재현).
1289
+ // 코드 파일 안의 `require('./x')` 는 그 파일 기준이 맞다.
1290
+ const dir = _isConfigRunner(r.file) ? [] : String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
1291
+ // 원문을 먼저 정규화한다 — 실제 JS 는 `.\\check.js`(backslash 2개)로 저장되고, 한 번만 치환하면
1292
+ // `.//check.js` 가 남아 아래 정규식에 안 걸린다(검수 재현: 실제로 도는 가드가 고아로 보고됐다).
1293
+ const body = _normPath(_dropCommentLines(String(r.text || '')));
1261
1294
  for (const m of body.matchAll(/(\.{1,2}(?:[/\\][\w.-]+)+\.(?:m?js|cjs|ts|sh))/g)) {
1262
1295
  const segs = dir.slice();
1263
1296
  let escaped = false;
1264
- for (const s of m[1].replace(/\\/g, '/').split('/')) {
1297
+ for (const s of _normPath(m[1]).split('/')) {
1265
1298
  if (s === '.' || s === '') continue;
1266
1299
  if (s === '..') { if (!segs.length) { escaped = true; break; } segs.pop(); } else segs.push(s);
1267
1300
  }
1301
+ // `scripts/./check.js` 처럼 중간 dot-segment 가 남아 있으면 여기서 이미 걸러진다(위 루프가 `.` 를 건너뛴다).
1268
1302
  if (!escaped) _resolved.add(segs.join('/'));
1269
1303
  }
1270
1304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.119",
3
+ "version": "1.36.121",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -5088,14 +5088,51 @@ total++;
5088
5088
  const j = JSON.parse(r.stdout); // 계약: stdout 전체가 단일 JSON 문서
5089
5089
  const payloadOk = j.ok === true && j.pass === j.total && j.fail === 0 && j.total > 0;
5090
5090
  const se = r.stderr || '';
5091
- // 알려진 유출원 3종: fail() 프로브의 '✗ ' · graph 사람용 3줄(파일경로/노드수/안내)
5092
- const noiseFree = !se.includes('✗ ') && !se.includes('leerness.html →') && !/nodes · /.test(se);
5091
+ // 1.36.120: 잡음을 **열거**하는 대신 stderr 통째로 0 으로 고정한다. audit 이 `--json` 에서 stdout 만 막고
5092
+ // stderr 열어 둬서 사람용 경고가 샜고(309B), 문구가 **프로젝트 상태에 따라** 달라져
5093
+ // 같은 코드로 게이트가 통과/실패로 갈렸다(실측). 열거는 유계가 아니다 — 원인 단계에서 막았으니 여기선 0을 요구한다.
5094
+ const noiseFree = se.trim() === '';
5093
5095
  ok = pureStdout && payloadOk && noiseFree && r.status === 0;
5094
5096
  } catch {}
5095
5097
  console.log(ok ? '✓ B(1.36.88) selftest --json 계약: 리포 루트 cwd stdout 순수 JSON + stderr 잡음 0' : '✗ selftest --json 계약 실패 (stdout 오염 또는 stderr 잡음)');
5096
5098
  if (!ok) failed++;
5097
5099
  }
5098
5100
 
5101
+ // 1.36.121 — `--json` stderr 계약을 **selftest 하나가 아니라 명령 전반**에 건다.
5102
+ // 1.36.120 에서 audit 이 json 모드에 stdout 만 막고 stderr 를 열어 둔 것이 드러났고, 그 잡음의 내용이
5103
+ // 프로젝트 상태에 따라 달라져 같은 코드로 게이트가 통과/실패로 갈렸다. 한 곳에서 나온 결함은 대개 클래스다 —
5104
+ // 실측 스윕(조회성 79개)에서 정보성 누출은 audit 하나였고 나머지 4건은 **사용법 오류의 stderr 진단**(CLI 관례)이었다.
5105
+ // 그 측정을 래칫으로 고정한다: 성공 경로의 `--json` 은 stderr 가 비어야 한다.
5106
+ total++;
5107
+ {
5108
+ let ok = false; const bad = [];
5109
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-json121-'));
5110
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
5111
+ try {
5112
+ const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
5113
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0","scripts":{"test":"node t.js"}}');
5114
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
5115
+ // 성공 경로만 — 인자 없이도 동작하는 조회성 명령 (사용법 오류 경로는 stderr 진단이 관례라 대상 아님)
5116
+ const CMDS = ['audit', 'health', 'doctor', 'drift', 'plan', 'task', 'pulse', 'tech', 'handoff',
5117
+ 'capabilities', 'commands', 'which', 'gate', 'dashboard', 'state'];
5118
+ for (const c of CMDS) {
5119
+ const r = cp.spawnSync(process.execPath, [CLI, c, '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 180000, env: ENV });
5120
+ const out = String(r.stdout || ''), err = String(r.stderr || '');
5121
+ if (!out.trim()) { bad.push(`${c}:출력없음`); continue; }
5122
+ let j = null; try { j = JSON.parse(out.trim()); } catch {}
5123
+ if (!j) { bad.push(`${c}:stdout이JSON아님`); continue; }
5124
+ if (err.trim() !== '') bad.push(`${c}:stderr잡음(${err.length}B:${err.trim().split('\n')[0].slice(0, 40)})`);
5125
+ }
5126
+ // 판별력 — 이 검사가 실제로 무언가를 실행했는지(전부 건너뛰면 공허하게 통과한다)
5127
+ if (bad.length === 0 && CMDS.length < 10) bad.push('대상이_너무적음');
5128
+ ok = bad.length === 0;
5129
+ } catch (e) { bad.push('ERR:' + String(e && e.message).slice(0, 80)); }
5130
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
5131
+ console.log(ok ? '✓ B(1.36.121) --json stderr 계약 스윕: 조회성 15종 모두 stdout 단일 JSON + stderr 0'
5132
+ : '✗ --json stderr 계약 스윕 실패 ' + JSON.stringify(bad.slice(0, 6)));
5133
+ if (!ok) failed++;
5134
+ }
5135
+
5099
5136
  // 1.9.367 회귀 (UR-0025): _mergeEnvLines 모듈 분리 — migrate 가 사용자 .env 값을 key-aware 로 보존 (덮어쓰기 X)
5100
5137
  total++;
5101
5138
  {
@@ -11242,6 +11279,21 @@ total++;
11242
11279
  const rex = cp.spawnSync(process.execPath, [CLI, 'export', 'claude', '--path', dex, '--json'], { cwd: dex, encoding: 'utf8', timeout: 300000, env: ENV });
11243
11280
  let jex = null; try { jex = JSON.parse(String(rex.stdout || '').trim()); } catch {}
11244
11281
  if (!jex || jex.code !== 'store_corrupt' || rex.status === 0) bad.push(`export별칭:손상코드아님(${jex && jex.code}/${rex.status})`);
11282
+ // (검수 P3) `prompt` 도 같은 별칭이다 — `export` 만 검사하면 `prompt` 를 가드 밖으로 빼는 변이가 산다.
11283
+ const dpr = mk(['--mode', 'minimal']);
11284
+ fs.writeFileSync(path.join(dpr, '.harness', 'manifest.json'), 'false');
11285
+ const rpr = cp.spawnSync(process.execPath, [CLI, 'prompt', 'claude', '--path', dpr, '--json'], { cwd: dpr, encoding: 'utf8', timeout: 300000, env: ENV });
11286
+ let jpr = null; try { jpr = JSON.parse(String(rpr.stdout || '').trim()); } catch {}
11287
+ if (!jpr || jpr.code !== 'store_corrupt' || rpr.status === 0) bad.push(`prompt별칭:손상코드아님(${jpr && jpr.code}/${rpr.status})`);
11288
+ // (검수 P3) MCP 안내가 **MCP 결과에만** 결부되는지 — 지침 파일이 새로 생기는 경로에서 검사해야
11289
+ // `results` 로 되돌리는 변이가 죽는다(기존 픽스처는 모두 init 이 끝난 뒤라 우연히 같았다).
11290
+ const dmr = path.join(sb, 'mcponly'); fs.mkdirSync(dmr, { recursive: true });
11291
+ fs.writeFileSync(path.join(dmr, 'package.json'), '{"name":"m","version":"0.1.0"}');
11292
+ fs.writeFileSync(path.join(dmr, '.mcp.json'), JSON.stringify({ mcpServers: { leerness: { command: 'node', args: ['./mine.js'] } } }, null, 2));
11293
+ const rmr = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dmr], { cwd: dmr, encoding: 'utf8', timeout: 300000, env: ENV });
11294
+ const omr = String(rmr.stdout || '') + String(rmr.stderr || '');
11295
+ if (/MCP 등록 —/.test(omr)) bad.push('MCP안내가_지침파일생성으로_발화');
11296
+ if (!/확인하지 않았습니다/.test(omr)) bad.push('MCP보존_중립문구없음');
11245
11297
  // 판별력 — standard 프로젝트에서는 adapter 가 여전히 standard 를 유지해야 한다(등급 반영이 전부를 줄이면 안 된다)
11246
11298
  const ds2 = mk();
11247
11299
  const sBefore = fs.readFileSync(path.join(ds2, 'CLAUDE.md'), 'utf8').length;
@@ -11332,6 +11384,19 @@ total++;
11332
11384
  cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc3], { cwd: dc3, encoding: 'utf8', timeout: 300000, env: ENV });
11333
11385
  let jc3 = null; try { jc3 = JSON.parse(fs.readFileSync(path.join(dc3, '.cursor', 'mcp.json'), 'utf8')); } catch {}
11334
11386
  if (!jc3 || jc3.mcpServers.leerness.command !== 'node' || !jc3.mcpServers.other) bad.push('cursor:사용자설정미보존');
11387
+ // (검수·자체 재현) cursor 는 MCP 설정이 **둘**이다. 한쪽만 우리가 썼는데 "이 도구가 직접 호출 가능" 이라고 하면
11388
+ // 정작 그 도구가 읽는 파일은 남의 설정일 수 있다 — 주장은 우리가 쓴 **파일 이름과 함께** 해야 한다.
11389
+ const dc4 = mk();
11390
+ fs.rmSync(path.join(dc4, '.mcp.json'), { force: true });
11391
+ fs.mkdirSync(path.join(dc4, '.cursor'), { recursive: true });
11392
+ fs.writeFileSync(path.join(dc4, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { leerness: { command: 'node', args: ['./mine.js'] } } }, null, 2));
11393
+ const rc4 = cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', '--path', dc4], { cwd: dc4, encoding: 'utf8', timeout: 300000, env: ENV });
11394
+ const oc4 = String(rc4.stdout || '') + String(rc4.stderr || '');
11395
+ if (!/\.mcp\.json 에 MCP 등록/.test(oc4)) bad.push('cursor:쓴파일_범위표기없음');
11396
+ if (/\.cursor[\\/]mcp\.json 에 MCP 등록/.test(oc4)) bad.push('cursor:보존파일까지_호출가능주장');
11397
+ if (!/확인하지 않았습니다/.test(oc4)) bad.push('cursor:보존_중립문구없음');
11398
+ let jc4 = null; try { jc4 = JSON.parse(fs.readFileSync(path.join(dc4, '.cursor', 'mcp.json'), 'utf8')); } catch {}
11399
+ if (!jc4 || jc4.mcpServers.leerness.command !== 'node') bad.push('cursor:보존실패');
11335
11400
  }
11336
11401
  // ⑦ (검수 P1) `_ours` 경계 — 우리가 만든 **정확한 형태**만 갱신하고, 모르는 키가 하나라도 붙으면 보존한다.
11337
11402
  // 그리고 명시적으로 적힌 falsy 값을 '없음' 으로 오인해 덮어쓰면 안 된다.
@@ -11799,6 +11864,33 @@ total++;
11799
11864
  const remInp = AUD._collectGuardInputs(d6c);
11800
11865
  if (remInp.runners.some(r => /phantom\.js$/.test(r.file))) bad.push('CMD주석에서_진입점_수집');
11801
11866
  if (!PU._detectOrphanGuards(remInp).orphanScripts.includes('check:leaf')) bad.push('CMD주석이_고아를_덮음');
11867
+ // (검수 P3) `REM` 만 검사하면 `::`·`@REM` 변이가 산다 — 주석 표기별로 잰다.
11868
+ for (const [tag, mark] of [['::', '::'], ['@REM', '@REM']]) {
11869
+ fs.writeFileSync(path.join(d6c, '.github', 'workflows', 'ci.yml'),
11870
+ `jobs:\n a:\n steps:\n - shell: cmd\n run: |\n ${mark} debug: node .\\bin\\phantom.js\n echo harmless\n - run: npm test\n`);
11871
+ const ri = AUD._collectGuardInputs(d6c);
11872
+ if (ri.runners.some(r => /phantom\.js$/.test(r.file))) bad.push(`CMD주석(${tag})에서_진입점_수집`);
11873
+ }
11874
+ // (검수 P3) bare 진입점(`main: index.js`)의 설명 필터 — 픽스처가 없어 되돌리는 변이가 살았다.
11875
+ const dbe = path.join(sb4, 'bare');
11876
+ fs.mkdirSync(path.join(dbe, '.github', 'workflows'), { recursive: true });
11877
+ fs.writeFileSync(path.join(dbe, 'package.json'), JSON.stringify({ name: 'b', version: '0.1.0', main: 'index.js',
11878
+ scripts: { test: 'node t.js', 'check:leaf': 'node l.js' } }));
11879
+ fs.writeFileSync(path.join(dbe, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - name: archive index.js\n run: npm test\n');
11880
+ fs.writeFileSync(path.join(dbe, 'index.js'), 'sh("npm run check:leaf")\n');
11881
+ const beInp = AUD._collectGuardInputs(dbe);
11882
+ if (beInp.runners.some(r => /index\.js$/.test(r.file))) bad.push('설명필드가_bare진입점을_승격');
11883
+ if (!PU._detectOrphanGuards(beInp).orphanScripts.includes('check:leaf')) bad.push('설명필드_승격이_고아를_덮음');
11884
+ // 판별력 — 실제 실행이면 수집돼야 한다
11885
+ fs.writeFileSync(path.join(dbe, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: node index.js --guard\n');
11886
+ const beInp2 = AUD._collectGuardInputs(dbe);
11887
+ if (!beInp2.runners.some(r => /index\.js$/.test(r.file))) bad.push('실제실행인데_bare진입점_미수집');
11888
+ // (검수 P3) 0바이트 package.json 을 **실제로 수집**해서 계상되는지 — 지금까지는 readErrors 를 주입만 했다.
11889
+ const dz = path.join(sb4, 'zero'); fs.mkdirSync(dz, { recursive: true });
11890
+ fs.writeFileSync(path.join(dz, 'package.json'), '');
11891
+ const zInp = AUD._collectGuardInputs(dz);
11892
+ if (!zInp.readErrors) bad.push('0바이트_package.json_미계상');
11893
+ if (!/수집 불완전/.test(PU._detectOrphanGuards(zInp).reason || '')) bad.push('0바이트_근거미표기');
11802
11894
  // (검수 P2) 상대 경로 해석: 주석 속 참조는 무시, 루트 밖은 내부 파일로 오인 금지, 역슬래시도 해석
11803
11895
  const rel1 = PU._detectOrphanGuards({
11804
11896
  packageScripts: { test: 'node scripts/run-a.js && node scripts/run-b.js' },
@@ -11816,6 +11908,89 @@ total++;
11816
11908
  scriptFiles: ['scripts/check.js'],
11817
11909
  });
11818
11910
  if (!rel2.orphanFiles.includes('scripts/check.js')) bad.push('루트밖_참조가_내부파일을_덮음');
11911
+ // ⚠ 루트 이탈(`../../../…`) 가드는 **여기서 관측할 수 없다** — 측정해 보고 단언을 뺐다:
11912
+ // `allText` 의 전체 경로 비교가 부분 문자열이라, 이탈 경로도 우리 후보를 접미로 포함하면 어차피 덮인다.
11913
+ // 경계 판정을 전체 경로에까지 걸면 흔한 `./scripts/x.js` 표기가 깨지므로 그렇게 하지 않는다.
11914
+ // 가드 자체는 남긴다(해석 결과가 접미가 아닌 경우를 위한 방어) — 다만 공허한 단언을 두지는 않는다.
11915
+ const relIn = PU._detectOrphanGuards({
11916
+ packageScripts: { test: 'node scripts/a/run.js' },
11917
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11918
+ { file: 'scripts/a/run.js', text: 'require("../../scripts/./check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
11919
+ scriptFiles: ['scripts/check.js'],
11920
+ });
11921
+ if (relIn.orphanFiles.length) bad.push(`루트내_dot세그먼트_경로_오탐(${relIn.orphanFiles.join(',')})`);
11922
+ // (검수 P3) 역슬래시 상대경로 — 픽스처가 없어 `[/\\]` 를 `/` 로 줄이는 변이가 살았다.
11923
+ const relBs = PU._detectOrphanGuards({
11924
+ packageScripts: { test: 'node scripts/a/run.js' },
11925
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11926
+ { file: 'scripts/a/run.js', text: 'require(".\\check.js");\n', viaScript: 'test', viaScripts: ['test'] }],
11927
+ scriptFiles: ['scripts/a/check.js', 'scripts/b/check.js'],
11928
+ });
11929
+ if (relBs.orphanFiles.includes('scripts/a/check.js')) bad.push('역슬래시_상대경로_미해석');
11930
+ // (검수) CI 설정의 명령은 **프로젝트 루트**에서 돈다 — 설정 파일 위치 기준으로 풀면 실제 호출을 놓친다.
11931
+ const relRoot = PU._detectOrphanGuards({
11932
+ packageScripts: { test: 'node t.js' },
11933
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11934
+ text: 'jobs:\n a:\n steps:\n - run: node ./scripts/deep/check.js\n - run: npm test\n' }],
11935
+ scriptFiles: ['scripts/deep/check.js'],
11936
+ });
11937
+ if (relRoot.orphanFiles.length) bad.push(`CI상대경로_루트기준아님(${relRoot.orphanFiles.join(',')})`);
11938
+ // dot-segment 가 낀 전체 경로도 정규화돼야 한다
11939
+ const relDot = PU._detectOrphanGuards({
11940
+ packageScripts: { test: 'node run.sh' },
11941
+ runners: [{ file: '.github/workflows/ci.yml', text: 'run: npm test', viaScript: null, viaScripts: [], unconditional: true },
11942
+ { file: 'run.sh', text: 'node scripts/./check.js\n', viaScript: 'test', viaScripts: ['test'] }],
11943
+ scriptFiles: ['scripts/check.js'],
11944
+ });
11945
+ if (relDot.orphanFiles.length) bad.push(`dot세그먼트_경로_오탐(${relDot.orphanFiles.join(',')})`);
11946
+ // (검수) 합성 문자열이 아니라 **실제 파일 바이트**로 잰다: JS 원문의 `require(".\\check.js")` 는
11947
+ // backslash 가 두 개다. 한 번만 치환하면 `.//check.js` 가 남아 실제로 도는 가드가 고아로 보고됐다.
11948
+ // 같은 픽스처가 **중첩 러너**(`scripts/a/run.js`)도 함께 잰다 — 그건 아예 수집되지 않고 있었다.
11949
+ const dwin = path.join(sb4, 'winrel');
11950
+ fs.mkdirSync(path.join(dwin, 'scripts', 'a'), { recursive: true });
11951
+ fs.mkdirSync(path.join(dwin, 'scripts', 'b'), { recursive: true });
11952
+ fs.mkdirSync(path.join(dwin, '.github', 'workflows'), { recursive: true });
11953
+ fs.writeFileSync(path.join(dwin, 'package.json'), JSON.stringify({ name: 'w', version: '0.1.0', scripts: { test: 'node scripts/a/run.js' } }));
11954
+ fs.writeFileSync(path.join(dwin, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm test\n');
11955
+ fs.writeFileSync(path.join(dwin, 'scripts', 'a', 'run.js'), 'require(".\\\\check.js");\n');
11956
+ fs.writeFileSync(path.join(dwin, 'scripts', 'a', 'check.js'), '// guard\n');
11957
+ fs.writeFileSync(path.join(dwin, 'scripts', 'b', 'check.js'), '// other\n');
11958
+ const winInp = AUD._collectGuardInputs(dwin);
11959
+ if (!winInp.runners.some(r => /scripts[\\/]a[\\/]run\.js$/.test(r.file))) bad.push('중첩러너_미수집');
11960
+ const winRes = PU._detectOrphanGuards(winInp);
11961
+ if (winRes.orphanFiles.includes('scripts/a/check.js')) bad.push('실제JS의_윈도우_상대경로_오탐');
11962
+ if (!winRes.orphanFiles.includes('scripts/b/check.js')) bad.push('판별력:무관파일까지_덮음');
11963
+ // 반복 dot-segment 정규화
11964
+ const relDot2 = PU._detectOrphanGuards({
11965
+ packageScripts: { test: 'node t.js' },
11966
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11967
+ text: 'jobs:\n a:\n steps:\n - run: node scripts/././check.js\n - run: npm test\n' }],
11968
+ scriptFiles: ['scripts/check.js'],
11969
+ });
11970
+ if (relDot2.orphanFiles.length) bad.push(`반복dot세그먼트_오탐(${relDot2.orphanFiles.join(',')})`);
11971
+ // 판정기의 주석 규칙이 수집기와 같아야 한다 — CMD 표기를 한쪽에만 넣으면 주석 속 이름이 호출로 계상된다
11972
+ const cmt = PU._detectOrphanGuards({
11973
+ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
11974
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11975
+ text: 'jobs:\n a:\n steps:\n - shell: cmd\n run: |\n REM npm run check:leaf\n echo hi\n - run: npm test\n' }],
11976
+ scriptFiles: [],
11977
+ });
11978
+ if (!cmt.orphanScripts.includes('check:leaf')) bad.push('판정기가_CMD주석을_실행으로');
11979
+ // (자체 헌트) Windows CI 는 `node scripts\x\check.js` 로 적는다. 후보 경로는 `/` 표기라
11980
+ // 전체 경로가 안 맞고 basename 경계 판정도 앞의 `\` 때문에 걸러져 **정상 배선이 고아로 보고**됐다.
11981
+ for (const [label, ref] of [['posix', 'scripts/x/check.js'], ['win', 'scripts\\x\\check.js'], ['win-dot', '.\\scripts\\x\\check.js']]) {
11982
+ const rw = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js' },
11983
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11984
+ text: `jobs:\n a:\n steps:\n - run: node ${ref}\n - run: npm test\n` }],
11985
+ scriptFiles: ['scripts/x/check.js'] });
11986
+ if (rw.orphanFiles.includes('scripts/x/check.js')) bad.push(`경로표기_오탐:${label}`);
11987
+ }
11988
+ // 판별력 — 루트의 동명 파일을 부르는 것은 하위 디렉터리 후보를 덮지 않는다(경계 판정의 목적)
11989
+ const rw2 = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js' },
11990
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
11991
+ text: 'jobs:\n a:\n steps:\n - run: node .\\check.js\n - run: npm test\n' }],
11992
+ scriptFiles: ['scripts/x/check.js', 'scripts/y/check.js'] });
11993
+ if (rw2.orphanFiles.length !== 2) bad.push(`경로경계_과다커버(${rw2.orphanFiles.join(',')})`);
11819
11994
  } finally { try { fs.rmSync(sb4, { recursive: true, force: true }); } catch {} }
11820
11995
  }
11821
11996
  // ⑰ (검수 P2) 와일드카드가 있다고 실행은 아니다 — `echo "test:*"` 는 아무것도 안 돌린다.
@@ -11893,6 +12068,9 @@ total++;
11893
12068
  // 이 저장소의 편향은 "오탐이 경고를 죽인다" 이므로 방향은 과소 보고다. 이 두 줄이 그 결정을 고정한다.
11894
12069
  ['matrix 변수명(실행키 아님)', 'jobs:\n a:\n strategy:\n matrix:\n target: [check:leaf]\n steps:\n - run: npm run ${{ matrix.target }}\n', false],
11895
12070
  ['Azure customCommand', 'steps:\n- task: Npm@1\n inputs:\n command: custom\n customCommand: run check:leaf\n', false],
12071
+ // (검수) `name:` 을 **무조건** 설명으로 보면 matrix 변수명이 `name` 인 정상 배선이 잘린다.
12072
+ // 값이 산문일 때만 설명이다 — 목록·단일 토큰은 데이터일 수 있으므로 남긴다.
12073
+ ['matrix 변수명이 name', 'jobs:\n a:\n strategy:\n matrix:\n name: [check:leaf]\n steps:\n - run: npm run ${{ matrix.name }}\n', false],
11896
12074
  ]) {
11897
12075
  const rr = PU._detectOrphanGuards({ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
11898
12076
  runners: [{ file: '.github/workflows/ci.yml', text, viaScript: null, viaScripts: [] }], scriptFiles: [] });