@litfamily/litopencode 1.0.28 → 1.0.29

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 CHANGED
@@ -5,6 +5,16 @@ are tracked separately from this product history.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 1.0.29 - 2026-10-19
9
+
10
+ - The write guard now catches a paragraph that keeps defining a figure by negation. Two Korean definitional negations in one paragraph, list or table column, or three in any language, are refused, and reworded forms such as `~로 읽을 수 없음`, `~와는 다름`, `does not mean` or `must not be read as` count too; exactly two, unless both are Korean, draw a warning. The refusal carries the fix: say what the figure is first, then give the caveat once, for example "18,420 visits in total, including repeat visits". A limitation stated once, short table cells such as `해당 아님`, plain comparisons such as `~와 다르다`, code and quoted examples still pass. The lit-humanizer skill and its bundled rule teach the same habit.
11
+
12
+ - Editing a line that sits between two repeated pledges in a document no longer re-raises the pledge that was already there; adding another one is still refused.
13
+
14
+ - When OpenCode is started from a subfolder of the project, the write guard now resolves relative paths such as `../report.md` against that folder, so those deliverables are checked and internal folders such as `plans/` stay exempt.
15
+
16
+ - A flood of warnings can no longer push a refusal out of the guard's output: refusals are listed first and the list is capped.
17
+
8
18
  ## 1.0.28 - 2026-10-18
9
19
 
10
20
  - The README gets you started sooner and says who LitOpenCode is for: people who give OpenCode work that spans more than one sitting, such as a feature over a few sessions, a planned refactor, research they come back to, or a report or deck built from their notes. A short evening-and-morning sketch shows a session picked up the next day, and the requirements now name OpenCode 1.18 or 1.19, because OpenCode 2 loads plugins differently and is not supported yet. The images are unchanged.
package/README-Ko-KR.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode는 OpenCode용 워크플로 플러그인입니다. 프롬프트에 `lit`만 붙이면 계획하고, 만들고, 확인하고, 기록까지 남겨서 다음 세션이 어디서 멈췄는지 바로 압니다.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/readme/badge-version.svg" alt="1.0.28" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-version.svg" alt="1.0.29" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="월간 npm 다운로드 수" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub 스타" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## 더 보기
58
58
 
59
59
  - [GitHub의 사용 안내](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide-Ko-KR.md): 설치 옵션, 자동 인수인계, 문제 해결, 제거
60
- - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/reference.md)
61
- - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/privacy.md)
62
- - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/reference.md)
61
+ - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/privacy.md)
62
+ - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>ASCII 로고 복사</summary>
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode is a workflow plugin for OpenCode. Add `lit` to a prompt and the work gets planned, built, checked and written down, so the next session knows where you stopped.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/readme/badge-version.svg" alt="1.0.28" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-version.svg" alt="1.0.29" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="npm downloads per month" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## More
58
58
 
59
59
  - [Guide on GitHub](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide.md): install options, automatic handoff, troubleshooting and removal.
60
- - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/reference.md)
61
- - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/privacy.md)
62
- - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.28/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/reference.md)
61
+ - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/privacy.md)
62
+ - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.29/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>Copy ASCII logo</summary>
@@ -255,6 +255,15 @@ function applyEdit(oldText, oldString, newString, replaceAll) {
255
255
  }
256
256
  return oldText.slice(0, index) + newString + oldText.slice(index + oldString.length);
257
257
  }
258
+ // The block path throws this text, so the model sees the positive-first rewrite in the refusal itself.
259
+ const negationAdvice = "Several negations sit in one place. Say what the thing is first, then add the caveat once. " +
260
+ 'Example: write "18,420 visits in total, including repeat visits" instead of "18,420 is not the number of residents".';
261
+ function warnMessage(count, file, negationCluster) {
262
+ const base = `LIT_HUMANIZER_WARN findings=${count} path=${displayPath(file)}`;
263
+ return negationCluster
264
+ ? `${base} rule=negation-cluster. ${negationAdvice}`
265
+ : `${base}. Review the new wording; keep any accurate, useful warning.`;
266
+ }
258
267
  function remember(pending, callID, notice) {
259
268
  if (pending.size >= maxPendingCalls)
260
269
  pending.delete(pending.keys().next().value);
@@ -303,9 +312,10 @@ export function createDeliverableHedgeGuard(options) {
303
312
  const block = findings.filter((finding) => finding.severity === "block");
304
313
  if (block.length > 0) {
305
314
  const first = block[0];
306
- throw new Error(`LIT_HUMANIZER_BLOCKED rule=${first.rule} line=${first.line} path=${displayPath(file)}. Revise the new text before saving.`);
315
+ const advice = block.some((finding) => finding.rule === "negation-cluster") ? ` ${negationAdvice}` : "";
316
+ throw new Error(`LIT_HUMANIZER_BLOCKED rule=${first.rule} line=${first.line} path=${displayPath(file)}. Revise the new text before saving.${advice}`);
307
317
  }
308
- return findings.length;
318
+ return findings;
309
319
  }
310
320
  // An apply_patch call can add several files; each deliverable-path target gets the same per-file scan as write
311
321
  // and edit. A chunk that cannot be located in the current file is scanned as appended text instead, so an added
@@ -316,6 +326,7 @@ export function createDeliverableHedgeGuard(options) {
316
326
  throw new Error("patch text exceeds scan limit");
317
327
  let warned = 0;
318
328
  let warnedFile = "";
329
+ let negationCluster = false;
319
330
  for (const entry of parsePatch(patchText)) {
320
331
  const target = entry.moveTo ?? entry.file;
321
332
  if (isInternalPath(target) || !readerFacingExtensions.has(path.extname(target).toLowerCase()))
@@ -332,15 +343,16 @@ export function createDeliverableHedgeGuard(options) {
332
343
  newText = `${oldText}\n${entry.added.join("\n")}`;
333
344
  }
334
345
  }
335
- const count = await scanTarget(target, oldText, newText);
336
- if (count > 0 && warned === 0)
346
+ const found = await scanTarget(target, oldText, newText);
347
+ if (found.length > 0 && warned === 0)
337
348
  warnedFile = target;
338
- warned += count;
349
+ warned += found.length;
350
+ negationCluster ||= found.some((finding) => finding.rule === "negation-cluster");
339
351
  }
340
352
  if (warned > 0) {
341
353
  remember(pending, callID, {
342
354
  tier: "warn",
343
- message: `LIT_HUMANIZER_WARN findings=${warned} path=${displayPath(warnedFile)}. Review the new wording; keep any accurate, useful warning.`
355
+ message: warnMessage(warned, warnedFile, negationCluster)
344
356
  });
345
357
  }
346
358
  }
@@ -377,11 +389,11 @@ export function createDeliverableHedgeGuard(options) {
377
389
  const newText = input.tool === "write"
378
390
  ? text.value ?? ""
379
391
  : applyEdit(oldText, input.args.oldString ?? input.args.old_string, text.value ?? "", input.args.replaceAll);
380
- const warned = await scanTarget(text.file, oldText, newText);
381
- if (warned > 0) {
392
+ const found = await scanTarget(text.file, oldText, newText);
393
+ if (found.length > 0) {
382
394
  remember(pending, input.callID, {
383
395
  tier: "warn",
384
- message: `LIT_HUMANIZER_WARN findings=${warned} path=${displayPath(text.file)}. Review the new wording; keep any accurate, useful warning.`
396
+ message: warnMessage(found.length, text.file, found.some((finding) => finding.rule === "negation-cluster"))
385
397
  });
386
398
  }
387
399
  }
package/dist/server.js CHANGED
@@ -54,7 +54,8 @@ export function createLitOpenCodePlugin(autoUpdateRunner = runPluginAutoUpdate)
54
54
  config: loaded.config.autoHandoff,
55
55
  ...(getSession === undefined ? {} : { getSession })
56
56
  });
57
- const deliverableHedgeGuard = createDeliverableHedgeGuard({ projectRoot: root });
57
+ // The host resolves a relative write or edit path against its launch directory, which can be a subfolder of the worktree.
58
+ const deliverableHedgeGuard = createDeliverableHedgeGuard({ projectRoot: input?.directory?.trim() || root });
58
59
  const recordSkillActivation = async (sessionID, skillId, appendedLit = false) => {
59
60
  showActivationToast(input?.client, skillId);
60
61
  ignitionState.activate(sessionID, skillId, !appendedLit);
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.28"><title>release: 1.0.28</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.28</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.29"><title>release: 1.0.29</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.29</text></g></svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/litopencode",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "Workflow plugin for OpenCode: add lit to a prompt and the work is planned, built, checked and written down so the next session can pick it up.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -9,4 +9,6 @@ Check only new or changed model text. Skip fenced and inline code, Markdown bloc
9
9
 
10
10
  Use footnotes or a reference list when citations are requested. Do not put a confidence ledger, verification inventory, or limitations checklist in a reader-facing deliverable. State a material risk once, plainly, in the chat reply. Keep internal evidence, plans, ledgers, handoffs, status, and machine-readable records detailed. Preserve a real limitation when removing it would change meaning or mislead the reader.
11
11
 
12
+ Lead with what a figure or thing is, then give a needed caveat once. Rewrite `18,420회는 주민 수가 아님` as "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다". The pre-write guard refuses a paragraph, list or table column that stacks two Korean definitional negations or three in any language, and reworded forms such as `~로 읽을 수 없음` or `does not mean` count too. Exactly two that are not both Korean draw a `negation-cluster` warning. Write bad examples in inline code or a blockquote.
13
+
12
14
  Review comments, commit messages, pull requests, changelogs, and README prose. Remove narration and needless one-use code wrappers only when behavior, trust-boundary checks, accessibility, and data integrity remain intact.
@@ -119,4 +119,8 @@ Scan only text being added or changed. Preserve pre-existing file content and us
119
119
 
120
120
  Block only high-confidence patterns such as label-only evidence lines, stacked disclaimers, and chatbot residue. Word choice, contrast, triads, and rhythm are warnings. A hit is an editing signal, not proof of model authorship. Stop when another change would alter meaning, hide a real limitation, or flatten the writer's voice.
121
121
 
122
+ ## Positive-first definitions
123
+
124
+ Say what a number or thing is first, then add the caveat once. `18,420회는 주민 수가 아님. 고유 방문자 수도 아님.` leaves the reader guessing; "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다" answers the question and carries the caveat inside the definition. The `negation-cluster` check counts definitional negations per paragraph, list block or Markdown table column: the plain forms (Korean `아닙니다`, `아님`, `아니다`, `아니며`, `아니라`, `아닌`; English `is not`, `are not`, `isn't`, `aren't`, `not a`, `rather than`) and the reworded forms that restate the same definition (`~로 읽을 수 없음`, `~로 볼 수 없음`, `~로 보지 않음`, `~을 뜻하지 않음`, `~를 의미하지 않음`, `~와는 다름`, `~로 읽으면 안 됨`; English `should not be read as`, `cannot be taken as`, `does not mean`). Two Korean negations in one block, or three in any language, are block tier: the pre-write hook refuses the write, and the refusal carries the rewrite advice with the 18,420 example. Exactly two that are not both Korean stay a warning, and the write goes through with an after-write notice. Swapping `아님` for `~로 읽을 수 없음`, `~와는 다르다` or `~로 읽으면 안 된다` therefore does not get past the guard; rewrite the block so it says what the figure is. An honest limitation stated once, such as "고유 이용자 지표는 자료에 없습니다", passes. Table cells of 12 characters or fewer, such as a `해당 아님` status, do not count, and code, quotes and internal paths stay exempt as above. Put any bad example you write in inline code or a blockquote so it stays exempt too. The check sits beside the rule table in `scripts/core.mjs` because it counts per block.
125
+
122
126
  For complete pattern lists, channel guidance, Korean metrics, and motion work, open the named reference rather than loading the entire corpus. Detector rules and examples are packaged with this skill; internal product records remain outside the reader-facing guard.
@@ -189,6 +189,11 @@ function ruleSegments(rows, rule, cache) {
189
189
  return segments;
190
190
  }
191
191
 
192
+ function lineTextAt(text, offset) {
193
+ const end = text.indexOf('\n', offset);
194
+ return text.slice(text.lastIndexOf('\n', offset - 1) + 1, end < 0 ? text.length : end).trim();
195
+ }
196
+
192
197
  export function scanText(text, rules, file = '<stdin>') {
193
198
  if (isInternalPath(file)) return [];
194
199
  const findings = [];
@@ -200,15 +205,90 @@ export function scanText(text, rules, file = '<stdin>') {
200
205
  rule.regex.lastIndex = 0;
201
206
  const match = rule.regex.exec(segment.text);
202
207
  if (!match) continue;
203
- findings.push({
208
+ const finding = {
204
209
  file,
205
210
  rule: rule.id,
206
211
  severity: rule.severity,
207
212
  line: lineAtOffset(segment, match.index),
208
213
  excerpt: segment.text.trim().replace(/\s+/g, ' ').slice(0, 180),
214
+ };
215
+ // A document-scope match runs from its first occurrence to its last; the lines in between are not part of it.
216
+ if (rule.scope === 'document') {
217
+ finding.occurrences = [lineTextAt(segment.text, match.index), lineTextAt(segment.text, match.index + match[0].length - 1)];
218
+ }
219
+ findings.push(finding);
220
+ }
221
+ }
222
+ return findings;
223
+ }
224
+
225
+ // negation-cluster sits beside the rule engine because it counts per block, not per line or sentence.
226
+ // A paragraph or list block (split on blank lines) is counted as a whole; a Markdown table is counted per column
227
+ // and ignores cells of 12 characters or fewer, where status values such as '해당 아님' live. Fenced code,
228
+ // blockquote lines and inline code were already blanked by sourceRows. Reworded forms that restate a definition
229
+ // count too, so swapping one negation for another does not pass. Two Korean negations, or three in any language,
230
+ // block; exactly two that are not both Korean warn.
231
+ const NEGATION_KO = /아닙니다|아님|아니다|아니며|아니라|아닌/gu;
232
+ const NEGATION_EN = /(?<![\p{L}\p{N}_])(?:is not|are not|isn't|aren't|not a|rather than)(?![\p{L}\p{N}_])/giu;
233
+ const NEGATION_KO_REWORD = /(?:로|으로) (?:읽을|볼|해석할|간주할) 수 없|(?:로|으로) 보지 않|(?:을|를) (?:뜻하지|의미하지|나타내지|가리키지) 않|(?:와|과)는 다(?:릅니다|르다|름|르며|르고|르므로)|(?:로|으로) (?:읽으면|보면|해석하면) 안 /gu;
234
+ const NEGATION_EN_REWORD = /(?<![\p{L}\p{N}_])(?:(?:should not|cannot|can't|must not) be (?:read|taken|interpreted|treated) as|(?:does|do) not (?:mean|represent|indicate))(?![\p{L}\p{N}_])/giu;
235
+ const NEGATION_BLOCK_KO = 2;
236
+ const NEGATION_BLOCK_TOTAL = 3;
237
+ const NEGATION_WARN_TOTAL = 2;
238
+
239
+ function matches(text, pattern) {
240
+ return text.match(pattern)?.length ?? 0;
241
+ }
242
+
243
+ function negationTier(text) {
244
+ const ko = matches(text, NEGATION_KO) + matches(text, NEGATION_KO_REWORD);
245
+ const total = ko + matches(text, NEGATION_EN) + matches(text, NEGATION_EN_REWORD);
246
+ if (ko >= NEGATION_BLOCK_KO || total >= NEGATION_BLOCK_TOTAL) return 'block';
247
+ return total >= NEGATION_WARN_TOTAL ? 'warn' : null;
248
+ }
249
+
250
+ function clusteredText(block) {
251
+ const lines = block.map((row) => row.candidate);
252
+ if (!lines.every((line) => /^\s*\|/.test(line))) {
253
+ const text = lines.join('\n');
254
+ const severity = negationTier(text);
255
+ return severity ? { text, severity } : null;
256
+ }
257
+ const cells = lines.filter((line) => !/^[\s|:-]+$/.test(line))
258
+ .map((line) => line.trim().replace(/^\||\|$/g, '').split('|').map((cell) => cell.trim()));
259
+ const width = Math.max(...cells.map((row) => row.length));
260
+ let warned = null;
261
+ for (let column = 0; column < width; column += 1) {
262
+ const text = cells.map((row) => row[column] ?? '').filter((cell) => [...cell].length > 12).join('\n');
263
+ const severity = negationTier(text);
264
+ if (severity === 'block') return { text, severity };
265
+ if (severity === 'warn') warned ??= { text, severity };
266
+ }
267
+ return warned;
268
+ }
269
+
270
+ export function negationClusterFindings(text, file = '<stdin>') {
271
+ if (isInternalPath(file)) return [];
272
+ const findings = [];
273
+ let block = [];
274
+ const flush = () => {
275
+ const hit = block.length ? clusteredText(block) : null;
276
+ if (hit !== null) {
277
+ findings.push({
278
+ file,
279
+ rule: 'negation-cluster',
280
+ severity: hit.severity,
281
+ line: block[0].number,
282
+ excerpt: hit.text.trim().replace(/\s+/g, ' ').slice(0, 180),
209
283
  });
210
284
  }
285
+ block = [];
286
+ };
287
+ for (const row of sourceRows(text)) {
288
+ if (row.skipped || !row.candidate.trim()) flush();
289
+ else block.push(row);
211
290
  }
291
+ flush();
212
292
  return findings;
213
293
  }
214
294
 
@@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { dirname, extname, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { exitCode, parseRules, scanText } from './core.mjs';
6
+ import { exitCode, negationClusterFindings, parseRules, scanText } from './core.mjs';
7
7
 
8
8
  const here = dirname(fileURLToPath(import.meta.url));
9
9
  const usage = 'Usage: node scripts/detect.mjs [--json] [--rules FILE] [--path LABEL] [FILE ...] (use - or omit FILE to read stdin)';
@@ -62,7 +62,8 @@ function main() {
62
62
  for (const file of files) {
63
63
  try {
64
64
  const label = file === '-' ? stdinLabel : file;
65
- const hits = scanText(contentFor(file), rules, label);
65
+ const content = contentFor(file);
66
+ const hits = [...scanText(content, rules, label), ...negationClusterFindings(content, label)];
66
67
  findings.push(...hits);
67
68
  scanned.push(file);
68
69
  } catch (error) {
@@ -2,10 +2,12 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseRules, scanText } from "./core.mjs";
5
+ import { negationClusterFindings, parseRules, scanText } from "./core.mjs";
6
6
 
7
7
  const here = dirname(fileURLToPath(import.meta.url));
8
8
  const maxInputBytes = 8 * 1024 * 1024;
9
+ // The host refuses worker output over its byte cap and then allows the write, so blocks go first and the list is bounded.
10
+ const maxFindings = 1000;
9
11
 
10
12
  try {
11
13
  const input = readFileSync(0);
@@ -15,21 +17,25 @@ try {
15
17
  throw new Error("guard input must contain file, oldText, and newText strings");
16
18
  }
17
19
  const rules = parseRules(readFileSync(join(here, "../rules.json"), "utf8"));
18
- const oldFindings = scanText(request.oldText, rules, request.file);
20
+ const scan = (text) => [...scanText(text, rules, request.file), ...negationClusterFindings(text, request.file)];
21
+ // A document-scope finding is keyed by its occurrence lines, so an edit between two old occurrences does not re-raise it.
22
+ const keyOf = (finding) => JSON.stringify([finding.rule, finding.severity, finding.occurrences ?? finding.excerpt]);
23
+ const oldFindings = scan(request.oldText);
19
24
  const remaining = new Map();
20
25
  for (const finding of oldFindings) {
21
- const key = JSON.stringify([finding.rule, finding.severity, finding.excerpt]);
26
+ const key = keyOf(finding);
22
27
  remaining.set(key, (remaining.get(key) ?? 0) + 1);
23
28
  }
24
- const findings = scanText(request.newText, rules, request.file).filter((finding) => {
25
- const key = JSON.stringify([finding.rule, finding.severity, finding.excerpt]);
29
+ const findings = scan(request.newText).filter((finding) => {
30
+ const key = keyOf(finding);
26
31
  const count = remaining.get(key) ?? 0;
27
32
  if (count === 0) return true;
28
33
  if (count === 1) remaining.delete(key);
29
34
  else remaining.set(key, count - 1);
30
35
  return false;
31
36
  });
32
- process.stdout.write(`${JSON.stringify({ findings })}\n`);
37
+ const ordered = [...findings.filter((finding) => finding.severity === "block"), ...findings.filter((finding) => finding.severity !== "block")];
38
+ process.stdout.write(`${JSON.stringify({ findings: ordered.slice(0, maxFindings) })}\n`);
33
39
  } catch (error) {
34
40
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
35
41
  process.exitCode = 2;
@@ -301,11 +301,11 @@
301
301
  },
302
302
  {
303
303
  "path": "scripts/core.mjs",
304
- "sha256": "abaff0390680e9c2602cde701f7224acd3586ebed0dcb2a807570359baac62fc"
304
+ "sha256": "c237695aa44185e948fbd43eaccf71c15e264311325d06563d8d06b74af1e560"
305
305
  },
306
306
  {
307
307
  "path": "scripts/detect.mjs",
308
- "sha256": "45a853a1341c5e4d61022edd4ed1289e49028005251682e3298a3946411f18bf"
308
+ "sha256": "5b70f4253334681e382fad7d1d6070509afd3bfc821cb83fea718456c9eea817"
309
309
  },
310
310
  {
311
311
  "path": "scripts/extract_office_text.py",
@@ -313,7 +313,7 @@
313
313
  },
314
314
  {
315
315
  "path": "scripts/guard-scan.mjs",
316
- "sha256": "f9677316adaf262496cc2222eb4b3fca8c1f3b8bf8288a324ca68b97fe09d67d"
316
+ "sha256": "6567964e02e5a6843a8b2b5920cfcad4f80f2642ccf84eb2eb316facb714e6a7"
317
317
  },
318
318
  {
319
319
  "path": "scripts/ko-metrics.mjs",