@wooojin/forgen 0.1.0
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/.claude-plugin/plugin.json +20 -0
- package/CHANGELOG.md +353 -0
- package/CONTRIBUTING.md +98 -0
- package/LICENSE +21 -0
- package/README.ja.md +469 -0
- package/README.ko.md +469 -0
- package/README.md +483 -0
- package/README.zh.md +469 -0
- package/agents/analyst.md +98 -0
- package/agents/architect.md +62 -0
- package/agents/code-reviewer.md +120 -0
- package/agents/code-simplifier.md +197 -0
- package/agents/critic.md +70 -0
- package/agents/debugger.md +117 -0
- package/agents/designer.md +131 -0
- package/agents/executor.md +54 -0
- package/agents/explore.md +145 -0
- package/agents/git-master.md +212 -0
- package/agents/performance-reviewer.md +172 -0
- package/agents/planner.md +29 -0
- package/agents/qa-tester.md +158 -0
- package/agents/refactoring-expert.md +168 -0
- package/agents/scientist.md +144 -0
- package/agents/security-reviewer.md +137 -0
- package/agents/test-engineer.md +153 -0
- package/agents/verifier.md +133 -0
- package/agents/writer.md +184 -0
- package/commands/api-design.md +268 -0
- package/commands/architecture-decision.md +314 -0
- package/commands/ci-cd.md +270 -0
- package/commands/code-review.md +233 -0
- package/commands/compound.md +117 -0
- package/commands/database.md +263 -0
- package/commands/debug-detective.md +99 -0
- package/commands/docker.md +274 -0
- package/commands/documentation.md +276 -0
- package/commands/ecomode.md +51 -0
- package/commands/frontend.md +271 -0
- package/commands/git-master.md +90 -0
- package/commands/incident-response.md +292 -0
- package/commands/migrate.md +101 -0
- package/commands/performance.md +288 -0
- package/commands/refactor.md +105 -0
- package/commands/security-review.md +288 -0
- package/commands/tdd.md +183 -0
- package/commands/testing-strategy.md +265 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +295 -0
- package/dist/core/auto-compound-runner.d.ts +12 -0
- package/dist/core/auto-compound-runner.js +460 -0
- package/dist/core/config-hooks.d.ts +10 -0
- package/dist/core/config-hooks.js +112 -0
- package/dist/core/config-injector.d.ts +50 -0
- package/dist/core/config-injector.js +455 -0
- package/dist/core/doctor.d.ts +1 -0
- package/dist/core/doctor.js +163 -0
- package/dist/core/errors.d.ts +81 -0
- package/dist/core/errors.js +133 -0
- package/dist/core/global-config.d.ts +43 -0
- package/dist/core/global-config.js +25 -0
- package/dist/core/harness.d.ts +24 -0
- package/dist/core/harness.js +621 -0
- package/dist/core/init.d.ts +7 -0
- package/dist/core/init.js +37 -0
- package/dist/core/inspect-cli.d.ts +7 -0
- package/dist/core/inspect-cli.js +47 -0
- package/dist/core/legacy-detector.d.ts +33 -0
- package/dist/core/legacy-detector.js +66 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +121 -0
- package/dist/core/mcp-config.d.ts +44 -0
- package/dist/core/mcp-config.js +177 -0
- package/dist/core/notepad.d.ts +31 -0
- package/dist/core/notepad.js +88 -0
- package/dist/core/paths.d.ts +85 -0
- package/dist/core/paths.js +101 -0
- package/dist/core/plugin-detector.d.ts +44 -0
- package/dist/core/plugin-detector.js +226 -0
- package/dist/core/runtime-detector.d.ts +8 -0
- package/dist/core/runtime-detector.js +49 -0
- package/dist/core/scope-resolver.d.ts +8 -0
- package/dist/core/scope-resolver.js +45 -0
- package/dist/core/session-logger.d.ts +6 -0
- package/dist/core/session-logger.js +111 -0
- package/dist/core/session-store.d.ts +28 -0
- package/dist/core/session-store.js +218 -0
- package/dist/core/settings-lock.d.ts +18 -0
- package/dist/core/settings-lock.js +125 -0
- package/dist/core/spawn.d.ts +3 -0
- package/dist/core/spawn.js +135 -0
- package/dist/core/types.d.ts +108 -0
- package/dist/core/types.js +1 -0
- package/dist/core/uninstall.d.ts +4 -0
- package/dist/core/uninstall.js +307 -0
- package/dist/core/v1-bootstrap.d.ts +26 -0
- package/dist/core/v1-bootstrap.js +155 -0
- package/dist/engine/compound-cli.d.ts +24 -0
- package/dist/engine/compound-cli.js +250 -0
- package/dist/engine/compound-extractor.d.ts +68 -0
- package/dist/engine/compound-extractor.js +860 -0
- package/dist/engine/compound-lifecycle.d.ts +32 -0
- package/dist/engine/compound-lifecycle.js +305 -0
- package/dist/engine/compound-loop.d.ts +32 -0
- package/dist/engine/compound-loop.js +511 -0
- package/dist/engine/match-eval-log.d.ts +139 -0
- package/dist/engine/match-eval-log.js +270 -0
- package/dist/engine/phrase-blocklist.d.ts +119 -0
- package/dist/engine/phrase-blocklist.js +208 -0
- package/dist/engine/skill-promoter.d.ts +20 -0
- package/dist/engine/skill-promoter.js +115 -0
- package/dist/engine/solution-format.d.ts +160 -0
- package/dist/engine/solution-format.js +432 -0
- package/dist/engine/solution-index.d.ts +13 -0
- package/dist/engine/solution-index.js +252 -0
- package/dist/engine/solution-matcher.d.ts +364 -0
- package/dist/engine/solution-matcher.js +656 -0
- package/dist/engine/solution-writer.d.ts +76 -0
- package/dist/engine/solution-writer.js +157 -0
- package/dist/engine/term-matcher.d.ts +81 -0
- package/dist/engine/term-matcher.js +268 -0
- package/dist/engine/term-normalizer.d.ts +116 -0
- package/dist/engine/term-normalizer.js +171 -0
- package/dist/fgx.d.ts +6 -0
- package/dist/fgx.js +42 -0
- package/dist/forge/cli.d.ts +11 -0
- package/dist/forge/cli.js +100 -0
- package/dist/forge/evidence-processor.d.ts +21 -0
- package/dist/forge/evidence-processor.js +87 -0
- package/dist/forge/mismatch-detector.d.ts +44 -0
- package/dist/forge/mismatch-detector.js +83 -0
- package/dist/forge/onboarding-cli.d.ts +6 -0
- package/dist/forge/onboarding-cli.js +89 -0
- package/dist/forge/onboarding.d.ts +25 -0
- package/dist/forge/onboarding.js +122 -0
- package/dist/hooks/compound-reflection.d.ts +45 -0
- package/dist/hooks/compound-reflection.js +82 -0
- package/dist/hooks/context-guard.d.ts +24 -0
- package/dist/hooks/context-guard.js +156 -0
- package/dist/hooks/dangerous-patterns.json +18 -0
- package/dist/hooks/db-guard.d.ts +17 -0
- package/dist/hooks/db-guard.js +105 -0
- package/dist/hooks/hook-config.d.ts +29 -0
- package/dist/hooks/hook-config.js +92 -0
- package/dist/hooks/hook-registry.d.ts +43 -0
- package/dist/hooks/hook-registry.js +31 -0
- package/dist/hooks/hooks-generator.d.ts +49 -0
- package/dist/hooks/hooks-generator.js +99 -0
- package/dist/hooks/intent-classifier.d.ts +12 -0
- package/dist/hooks/intent-classifier.js +62 -0
- package/dist/hooks/keyword-detector.d.ts +25 -0
- package/dist/hooks/keyword-detector.js +389 -0
- package/dist/hooks/notepad-injector.d.ts +18 -0
- package/dist/hooks/notepad-injector.js +51 -0
- package/dist/hooks/permission-handler.d.ts +14 -0
- package/dist/hooks/permission-handler.js +114 -0
- package/dist/hooks/post-tool-failure.d.ts +11 -0
- package/dist/hooks/post-tool-failure.js +118 -0
- package/dist/hooks/post-tool-handlers.d.ts +17 -0
- package/dist/hooks/post-tool-handlers.js +115 -0
- package/dist/hooks/post-tool-use.d.ts +29 -0
- package/dist/hooks/post-tool-use.js +151 -0
- package/dist/hooks/pre-compact.d.ts +10 -0
- package/dist/hooks/pre-compact.js +165 -0
- package/dist/hooks/pre-tool-use.d.ts +31 -0
- package/dist/hooks/pre-tool-use.js +325 -0
- package/dist/hooks/prompt-injection-filter.d.ts +56 -0
- package/dist/hooks/prompt-injection-filter.js +287 -0
- package/dist/hooks/rate-limiter.d.ts +21 -0
- package/dist/hooks/rate-limiter.js +86 -0
- package/dist/hooks/secret-filter.d.ts +14 -0
- package/dist/hooks/secret-filter.js +65 -0
- package/dist/hooks/session-recovery.d.ts +27 -0
- package/dist/hooks/session-recovery.js +406 -0
- package/dist/hooks/shared/atomic-write.d.ts +41 -0
- package/dist/hooks/shared/atomic-write.js +148 -0
- package/dist/hooks/shared/context-budget.d.ts +37 -0
- package/dist/hooks/shared/context-budget.js +45 -0
- package/dist/hooks/shared/file-lock.d.ts +56 -0
- package/dist/hooks/shared/file-lock.js +253 -0
- package/dist/hooks/shared/hook-response.d.ts +33 -0
- package/dist/hooks/shared/hook-response.js +62 -0
- package/dist/hooks/shared/injection-caps.d.ts +39 -0
- package/dist/hooks/shared/injection-caps.js +52 -0
- package/dist/hooks/shared/plugin-signal.d.ts +23 -0
- package/dist/hooks/shared/plugin-signal.js +104 -0
- package/dist/hooks/shared/read-stdin.d.ts +8 -0
- package/dist/hooks/shared/read-stdin.js +63 -0
- package/dist/hooks/shared/sanitize-id.d.ts +7 -0
- package/dist/hooks/shared/sanitize-id.js +9 -0
- package/dist/hooks/shared/sanitize.d.ts +7 -0
- package/dist/hooks/shared/sanitize.js +22 -0
- package/dist/hooks/skill-injector.d.ts +38 -0
- package/dist/hooks/skill-injector.js +285 -0
- package/dist/hooks/slop-detector.d.ts +18 -0
- package/dist/hooks/slop-detector.js +93 -0
- package/dist/hooks/solution-injector.d.ts +58 -0
- package/dist/hooks/solution-injector.js +436 -0
- package/dist/hooks/subagent-tracker.d.ts +10 -0
- package/dist/hooks/subagent-tracker.js +90 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +224 -0
- package/dist/lib.d.ts +14 -0
- package/dist/lib.js +14 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +40 -0
- package/dist/mcp/solution-reader.d.ts +90 -0
- package/dist/mcp/solution-reader.js +273 -0
- package/dist/mcp/tools.d.ts +16 -0
- package/dist/mcp/tools.js +302 -0
- package/dist/preset/facet-catalog.d.ts +17 -0
- package/dist/preset/facet-catalog.js +46 -0
- package/dist/preset/preset-manager.d.ts +31 -0
- package/dist/preset/preset-manager.js +111 -0
- package/dist/renderer/inspect-renderer.d.ts +11 -0
- package/dist/renderer/inspect-renderer.js +123 -0
- package/dist/renderer/rule-renderer.d.ts +18 -0
- package/dist/renderer/rule-renderer.js +159 -0
- package/dist/store/evidence-store.d.ts +23 -0
- package/dist/store/evidence-store.js +58 -0
- package/dist/store/profile-store.d.ts +12 -0
- package/dist/store/profile-store.js +53 -0
- package/dist/store/recommendation-store.d.ts +22 -0
- package/dist/store/recommendation-store.js +64 -0
- package/dist/store/rule-store.d.ts +22 -0
- package/dist/store/rule-store.js +62 -0
- package/dist/store/session-state-store.d.ts +11 -0
- package/dist/store/session-state-store.js +44 -0
- package/dist/store/types.d.ts +159 -0
- package/dist/store/types.js +7 -0
- package/hooks/hook-registry.json +21 -0
- package/hooks/hooks.json +185 -0
- package/package.json +89 -0
- package/plugin.json +20 -0
- package/scripts/postinstall.js +826 -0
- package/skills/api-design/SKILL.md +262 -0
- package/skills/architecture-decision/SKILL.md +309 -0
- package/skills/ci-cd/SKILL.md +264 -0
- package/skills/code-review/SKILL.md +228 -0
- package/skills/compound/SKILL.md +101 -0
- package/skills/database/SKILL.md +257 -0
- package/skills/debug-detective/SKILL.md +95 -0
- package/skills/docker/SKILL.md +268 -0
- package/skills/documentation/SKILL.md +270 -0
- package/skills/ecomode/SKILL.md +46 -0
- package/skills/frontend/SKILL.md +265 -0
- package/skills/git-master/SKILL.md +86 -0
- package/skills/incident-response/SKILL.md +286 -0
- package/skills/migrate/SKILL.md +96 -0
- package/skills/performance/SKILL.md +282 -0
- package/skills/refactor/SKILL.md +100 -0
- package/skills/security-review/SKILL.md +282 -0
- package/skills/tdd/SKILL.md +178 -0
- package/skills/testing-strategy/SKILL.md +260 -0
- package/starter-pack/solutions/starter-api-error-responses.md +37 -0
- package/starter-pack/solutions/starter-async-patterns.md +40 -0
- package/starter-pack/solutions/starter-caching-strategy.md +40 -0
- package/starter-pack/solutions/starter-code-review-checklist.md +39 -0
- package/starter-pack/solutions/starter-debugging-systematic.md +40 -0
- package/starter-pack/solutions/starter-dependency-injection.md +40 -0
- package/starter-pack/solutions/starter-error-handling-patterns.md +38 -0
- package/starter-pack/solutions/starter-git-atomic-commits.md +36 -0
- package/starter-pack/solutions/starter-input-validation.md +40 -0
- package/starter-pack/solutions/starter-n-plus-one-queries.md +37 -0
- package/starter-pack/solutions/starter-refactor-safely.md +38 -0
- package/starter-pack/solutions/starter-secret-management.md +37 -0
- package/starter-pack/solutions/starter-separation-of-concerns.md +36 -0
- package/starter-pack/solutions/starter-tdd-red-green-refactor.md +40 -0
- package/starter-pack/solutions/starter-typescript-strict-types.md +39 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* solution .md 파일의 단일 mutator — race 방지 + lock 통합
|
|
3
|
+
*
|
|
4
|
+
* 문제 (PR2b motivation):
|
|
5
|
+
* 같은 .md 파일을 mutate하는 여러 경로가 lock 없이 동작했다 (compound-cli retag,
|
|
6
|
+
* updateSolutionEvidence, updateNegativeEvidence, solution-reader evidence write,
|
|
7
|
+
* compound-lifecycle updateSolutionFile, …). 동시 hook이 같은 솔루션을 갱신하면
|
|
8
|
+
* last-writer-wins로 evidence 카운터가 손실됐다.
|
|
9
|
+
*
|
|
10
|
+
* 해결:
|
|
11
|
+
* - 모든 read-modify-write를 mutateSolutionFile/mutateSolutionByName 헬퍼로 통일
|
|
12
|
+
* - 헬퍼는 withFileLockSync로 보호된 fresh-read → mutate → atomic write 흐름
|
|
13
|
+
* - mutator callback이 modified flag를 반환해 no-op write 회피
|
|
14
|
+
*
|
|
15
|
+
* 적용 범위 (PR2b):
|
|
16
|
+
* - pre-tool-use.ts:268 updateSolutionEvidence
|
|
17
|
+
* - post-tool-handlers.ts:127 updateNegativeEvidence
|
|
18
|
+
* - mcp/solution-reader.ts:237 evidence write
|
|
19
|
+
* - compound-cli.ts:156 retagSolutions
|
|
20
|
+
* - compound-lifecycle.ts:163 updateSolutionFile
|
|
21
|
+
*
|
|
22
|
+
* 별도 처리 (mutator 패턴이 안 맞음):
|
|
23
|
+
* - compound-extractor saveSolution (새 파일 create, O_EXCL로 race 차단)
|
|
24
|
+
* - compound-extractor updateReExtractedCounter (regex in-place, withFileLockSync 직접)
|
|
25
|
+
* - solution-index V1→V3 migration (parseSolutionV3 불가, withFileLockSync 직접)
|
|
26
|
+
*/
|
|
27
|
+
import { type SolutionV3 } from './solution-format.js';
|
|
28
|
+
/**
|
|
29
|
+
* mutator 콜백. fresh-read된 SolutionV3를 받아 in-place mutate.
|
|
30
|
+
* 변경이 일어났으면 true, no-op이면 false 반환.
|
|
31
|
+
*
|
|
32
|
+
* 주의 (M-3 reentrancy):
|
|
33
|
+
* mutator 콜백 안에서 같은 path로 mutateSolutionFile/mutateSolutionByName을
|
|
34
|
+
* 재호출하지 말 것. file-lock은 advisory + non-reentrant이므로 같은 PID가
|
|
35
|
+
* 같은 path에 대해 재진입하면 stale 검증을 통과하지 못해 timeout 후 silent
|
|
36
|
+
* false를 받는다. 필요하면 mutator 안에서 in-place mutation만 수행할 것.
|
|
37
|
+
*
|
|
38
|
+
* @returns true → write 발생, false → write skip
|
|
39
|
+
*/
|
|
40
|
+
export type SolutionMutator = (solution: SolutionV3) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* 단일 .md 파일에 lock 보호된 read-modify-write 수행.
|
|
43
|
+
*
|
|
44
|
+
* - lock 안에서 fresh re-read (다른 mutator의 변경 보존)
|
|
45
|
+
* - mutator가 false 반환하면 write skip
|
|
46
|
+
* - frontmatter.updated는 자동 갱신 (mutator가 명시 설정해도 덮어씀)
|
|
47
|
+
* - atomicWriteText로 tmp → rename
|
|
48
|
+
*
|
|
49
|
+
* 결함이 발생하면 false 반환 (lock timeout, parse 실패, mutator throw 모두).
|
|
50
|
+
* 운영 관측성은 PR2c 후속 hardening에서 다룬다 (현재는 log.debug로만).
|
|
51
|
+
*/
|
|
52
|
+
export declare function mutateSolutionFile(filePath: string, mutator: SolutionMutator): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* 솔루션 이름으로 .md 파일을 찾아 mutate한다.
|
|
55
|
+
*
|
|
56
|
+
* C3 fix: 사전 필터를 정확한 frontmatter parse로 교체. 이전엔 substring 매칭
|
|
57
|
+
* 이라 `inc1` 찾을 때 `inc12.md`가 먼저 매치되어 silent miss가 발생했다.
|
|
58
|
+
*
|
|
59
|
+
* PR2c-3 (H-S2 fix): caller가 extraDirs로 project/team scope를 추가할 수 있다.
|
|
60
|
+
* 기본은 ME_SOLUTIONS / ME_RULES만 스캔. solution-injector는 cwd 기반 project
|
|
61
|
+
* 디렉터리를 주입해 해당 scope의 솔루션도 evidence 갱신 받게 할 수 있다.
|
|
62
|
+
*
|
|
63
|
+
* PR2c-3 (L-3 fix): mutator가 false 반환하면 다음 후보로 continue.
|
|
64
|
+
* 같은 name이 여러 파일에 있을 가능성은 invariant 위반이지만, 사용자가 수동
|
|
65
|
+
* 중복을 만들 수 있으므로 fail-safe로 다음 후보 시도.
|
|
66
|
+
*
|
|
67
|
+
* symlink는 보안상 무시 (lstatSync 가드).
|
|
68
|
+
*/
|
|
69
|
+
export declare function mutateSolutionByName(name: string, mutator: SolutionMutator, options?: {
|
|
70
|
+
extraDirs?: string[];
|
|
71
|
+
}): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Evidence 카운터 단일 증가 helper.
|
|
74
|
+
* mutateSolutionByName + 카운터 증가 패턴을 한 줄로.
|
|
75
|
+
*/
|
|
76
|
+
export declare function incrementEvidence(solutionName: string, field: 'reflected' | 'negative' | 'injected' | 'sessions' | 'reExtracted'): boolean;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* solution .md 파일의 단일 mutator — race 방지 + lock 통합
|
|
3
|
+
*
|
|
4
|
+
* 문제 (PR2b motivation):
|
|
5
|
+
* 같은 .md 파일을 mutate하는 여러 경로가 lock 없이 동작했다 (compound-cli retag,
|
|
6
|
+
* updateSolutionEvidence, updateNegativeEvidence, solution-reader evidence write,
|
|
7
|
+
* compound-lifecycle updateSolutionFile, …). 동시 hook이 같은 솔루션을 갱신하면
|
|
8
|
+
* last-writer-wins로 evidence 카운터가 손실됐다.
|
|
9
|
+
*
|
|
10
|
+
* 해결:
|
|
11
|
+
* - 모든 read-modify-write를 mutateSolutionFile/mutateSolutionByName 헬퍼로 통일
|
|
12
|
+
* - 헬퍼는 withFileLockSync로 보호된 fresh-read → mutate → atomic write 흐름
|
|
13
|
+
* - mutator callback이 modified flag를 반환해 no-op write 회피
|
|
14
|
+
*
|
|
15
|
+
* 적용 범위 (PR2b):
|
|
16
|
+
* - pre-tool-use.ts:268 updateSolutionEvidence
|
|
17
|
+
* - post-tool-handlers.ts:127 updateNegativeEvidence
|
|
18
|
+
* - mcp/solution-reader.ts:237 evidence write
|
|
19
|
+
* - compound-cli.ts:156 retagSolutions
|
|
20
|
+
* - compound-lifecycle.ts:163 updateSolutionFile
|
|
21
|
+
*
|
|
22
|
+
* 별도 처리 (mutator 패턴이 안 맞음):
|
|
23
|
+
* - compound-extractor saveSolution (새 파일 create, O_EXCL로 race 차단)
|
|
24
|
+
* - compound-extractor updateReExtractedCounter (regex in-place, withFileLockSync 직접)
|
|
25
|
+
* - solution-index V1→V3 migration (parseSolutionV3 불가, withFileLockSync 직접)
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import { withFileLockSync } from '../hooks/shared/file-lock.js';
|
|
30
|
+
import { atomicWriteText } from '../hooks/shared/atomic-write.js';
|
|
31
|
+
import { parseFrontmatterOnly, parseSolutionV3, serializeSolutionV3, } from './solution-format.js';
|
|
32
|
+
import { ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
33
|
+
import { createLogger } from '../core/logger.js';
|
|
34
|
+
const log = createLogger('solution-writer');
|
|
35
|
+
/**
|
|
36
|
+
* 단일 .md 파일에 lock 보호된 read-modify-write 수행.
|
|
37
|
+
*
|
|
38
|
+
* - lock 안에서 fresh re-read (다른 mutator의 변경 보존)
|
|
39
|
+
* - mutator가 false 반환하면 write skip
|
|
40
|
+
* - frontmatter.updated는 자동 갱신 (mutator가 명시 설정해도 덮어씀)
|
|
41
|
+
* - atomicWriteText로 tmp → rename
|
|
42
|
+
*
|
|
43
|
+
* 결함이 발생하면 false 반환 (lock timeout, parse 실패, mutator throw 모두).
|
|
44
|
+
* 운영 관측성은 PR2c 후속 hardening에서 다룬다 (현재는 log.debug로만).
|
|
45
|
+
*/
|
|
46
|
+
export function mutateSolutionFile(filePath, mutator) {
|
|
47
|
+
try {
|
|
48
|
+
return withFileLockSync(filePath, () => {
|
|
49
|
+
let content;
|
|
50
|
+
try {
|
|
51
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
log.debug(`solution file read 실패: ${filePath}`, e);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const solution = parseSolutionV3(content);
|
|
58
|
+
if (!solution) {
|
|
59
|
+
log.debug(`solution parse 실패: ${filePath}`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
let modified;
|
|
63
|
+
try {
|
|
64
|
+
modified = mutator(solution);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
log.debug(`solution mutator throw: ${filePath}`, e);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (!modified)
|
|
71
|
+
return false;
|
|
72
|
+
solution.frontmatter.updated = new Date().toISOString().split('T')[0];
|
|
73
|
+
atomicWriteText(filePath, serializeSolutionV3(solution));
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
log.debug(`solution mutate 실패 (lock): ${filePath}`, e);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 솔루션 이름으로 .md 파일을 찾아 mutate한다.
|
|
84
|
+
*
|
|
85
|
+
* C3 fix: 사전 필터를 정확한 frontmatter parse로 교체. 이전엔 substring 매칭
|
|
86
|
+
* 이라 `inc1` 찾을 때 `inc12.md`가 먼저 매치되어 silent miss가 발생했다.
|
|
87
|
+
*
|
|
88
|
+
* PR2c-3 (H-S2 fix): caller가 extraDirs로 project/team scope를 추가할 수 있다.
|
|
89
|
+
* 기본은 ME_SOLUTIONS / ME_RULES만 스캔. solution-injector는 cwd 기반 project
|
|
90
|
+
* 디렉터리를 주입해 해당 scope의 솔루션도 evidence 갱신 받게 할 수 있다.
|
|
91
|
+
*
|
|
92
|
+
* PR2c-3 (L-3 fix): mutator가 false 반환하면 다음 후보로 continue.
|
|
93
|
+
* 같은 name이 여러 파일에 있을 가능성은 invariant 위반이지만, 사용자가 수동
|
|
94
|
+
* 중복을 만들 수 있으므로 fail-safe로 다음 후보 시도.
|
|
95
|
+
*
|
|
96
|
+
* symlink는 보안상 무시 (lstatSync 가드).
|
|
97
|
+
*/
|
|
98
|
+
export function mutateSolutionByName(name, mutator, options) {
|
|
99
|
+
const dirs = [ME_SOLUTIONS, ME_RULES, ...(options?.extraDirs ?? [])];
|
|
100
|
+
for (const dir of dirs) {
|
|
101
|
+
if (!fs.existsSync(dir))
|
|
102
|
+
continue;
|
|
103
|
+
let files;
|
|
104
|
+
try {
|
|
105
|
+
files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const file of files) {
|
|
111
|
+
const filePath = path.join(dir, file);
|
|
112
|
+
// Security: symlink을 통한 임의 파일 mutate 방지
|
|
113
|
+
try {
|
|
114
|
+
if (fs.lstatSync(filePath).isSymbolicLink())
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// C3 fix: 정확한 frontmatter parse로 사전 필터.
|
|
121
|
+
let content;
|
|
122
|
+
try {
|
|
123
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const fm = parseFrontmatterOnly(content);
|
|
129
|
+
if (!fm || fm.name !== name)
|
|
130
|
+
continue;
|
|
131
|
+
// lock 안에서 fresh re-read 후 한 번 더 검증
|
|
132
|
+
const result = mutateSolutionFile(filePath, sol => {
|
|
133
|
+
if (sol.frontmatter.name !== name)
|
|
134
|
+
return false;
|
|
135
|
+
return mutator(sol);
|
|
136
|
+
});
|
|
137
|
+
if (result)
|
|
138
|
+
return true;
|
|
139
|
+
// L-3 fix: mutator/mutateSolutionFile이 false면 다음 후보로 continue.
|
|
140
|
+
// 같은 name의 다른 파일을 찾아 mutate 시도.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Evidence 카운터 단일 증가 helper.
|
|
147
|
+
* mutateSolutionByName + 카운터 증가 패턴을 한 줄로.
|
|
148
|
+
*/
|
|
149
|
+
export function incrementEvidence(solutionName, field) {
|
|
150
|
+
return mutateSolutionByName(solutionName, sol => {
|
|
151
|
+
const ev = sol.frontmatter.evidence;
|
|
152
|
+
if (!(field in ev))
|
|
153
|
+
return false;
|
|
154
|
+
ev[field] = (ev[field] ?? 0) + 1;
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Term matching utilities for compound negative/reflection attribution.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists (PR3 motivation):
|
|
5
|
+
* 라운드 1~3 리뷰에서 반복적으로 발견된 문제의 근본 원인은
|
|
6
|
+
* `response.toLowerCase().includes(term.toLowerCase())` substring 매칭이었다.
|
|
7
|
+
*
|
|
8
|
+
* Matching model by script (라운드 3 정리):
|
|
9
|
+
*
|
|
10
|
+
* | term script | 매칭 방식 | boundary 의미 |
|
|
11
|
+
* |-------------|--------------------------------------------|------------------------------|
|
|
12
|
+
* | 영어/숫자 | lookaround regex `(?<![a-zA-Z0-9])...(?![a-zA-Z0-9])` | `_`도 boundary (snake_case 분해) |
|
|
13
|
+
* | 한글 | text를 비-한글로 tokenize → stem 정규화 → Set lookup | 조사/어미 제거 후 정확 비교 |
|
|
14
|
+
* | 혼합 스크립트 | false 반환 + debug log | 이전 substring fallback 제거 |
|
|
15
|
+
*
|
|
16
|
+
* 한글 stem 정규화는 두 단계:
|
|
17
|
+
* 1. `stripKoSuffix` (solution-format) — 일반 조사/어미 (`가`, `를`, `는`, …).
|
|
18
|
+
* **추출 시점에도 적용되므로 1글자 suffix는 한자어 명사를 깨뜨리지 않도록
|
|
19
|
+
* 매우 보수적**. extractTags와 정합성을 유지하기 위해 매칭도 같은 함수 사용.
|
|
20
|
+
* 2. `KO_VERBAL_SUFFIXES` (본 모듈) — 동사/명사 활용형 (`중`, `시`).
|
|
21
|
+
* 매칭 전용이라 extractTags에 영향이 없고, stem 결과가 2글자 미만이면
|
|
22
|
+
* 드롭해 `집중`→`집` 같은 false positive를 차단.
|
|
23
|
+
*
|
|
24
|
+
* 이 모듈의 원칙:
|
|
25
|
+
* 1. **Unicode NFC normalize**: 입력 text와 term 모두 NFC로 정규화해 macOS
|
|
26
|
+
* NFD 경로명과의 매칭 실패를 방지. response는 classifyMatch 진입 시 한 번만.
|
|
27
|
+
* 2. **extractTags 계약 정렬**: 영어 3글자 이상, 한국어 2글자 이상.
|
|
28
|
+
* 3. **변조 cache 방어**: MAX_TERM_LENGTH(128) 가드로 거대 term에 의한 RegExp
|
|
29
|
+
* 컴파일 실패 (~32K 이상에서 V8이 throw) 방지.
|
|
30
|
+
* 4. **Negative term blocklist**: 흔한 메타 term은 term-filter 단계에서 제거.
|
|
31
|
+
* 5. **매칭 강도 임계값**: identifier 길이 4 이상 단독도 strong 인정 (Python/C의
|
|
32
|
+
* `init`, `exit`, `main` 같은 공통 4글자 idents가 systematic miss되지 않도록).
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Negative attribution에서 제외할 흔한 메타 term.
|
|
36
|
+
* 거의 모든 Bash 오류/경고에 등장해 signal-to-noise ratio가 0에 가깝다.
|
|
37
|
+
*/
|
|
38
|
+
export declare const NEGATIVE_TERM_BLOCKLIST: Set<string>;
|
|
39
|
+
/**
|
|
40
|
+
* Filter terms usable for word-boundary matching.
|
|
41
|
+
*
|
|
42
|
+
* - Drops non-strings (cache file can be hand-edited to contain garbage).
|
|
43
|
+
* - Drops terms that are too long (MAX_TERM_LENGTH guard — RegExp DoS 방어).
|
|
44
|
+
* - Drops terms that are too short to be meaningful (English < 3, Korean < 2).
|
|
45
|
+
* - Drops meta terms that match almost any Bash error (NEGATIVE_TERM_BLOCKLIST).
|
|
46
|
+
* - NFC normalize on ingest.
|
|
47
|
+
*
|
|
48
|
+
* Drop 사유는 debug 레벨로 로그해 cache 변조 또는 extractor 버그를 운영 중에
|
|
49
|
+
* 추적할 수 있게 한다 (silent drop은 디버깅이 불가능).
|
|
50
|
+
*/
|
|
51
|
+
export declare function filterMatchableTerms(raw: unknown[]): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Check whether `term` matches inside `text` at a word boundary.
|
|
54
|
+
*
|
|
55
|
+
* 주의: 호출자는 일반적으로 `matchesInPrecomputed`를 써서 NFC normalize와
|
|
56
|
+
* stem Set 계산을 재사용하는 게 효율적. 이 함수는 단발성 호출 또는 테스트용.
|
|
57
|
+
*/
|
|
58
|
+
export declare function matchesAtWordBoundary(text: string, term: string): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Classify a solution's match strength against `response`.
|
|
61
|
+
*
|
|
62
|
+
* Returns one of:
|
|
63
|
+
* - 'strong' : identifier 매칭으로 고신뢰 (길이 ≥4 단독, 또는 id≥2, 또는 id+tag)
|
|
64
|
+
* - 'multi' : tag 2개 이상 매칭 (identifier 없음)
|
|
65
|
+
* - 'weak' : tag 1개 OR 짧은 identifier(<4) 단독
|
|
66
|
+
* - 'none' : 매칭 없음
|
|
67
|
+
*
|
|
68
|
+
* Callers (negative attribution, reflection) should only attribute on 'strong'
|
|
69
|
+
* or 'multi'. 'weak' is over-attribution-prone and must be ignored.
|
|
70
|
+
*/
|
|
71
|
+
export type MatchStrength = 'none' | 'weak' | 'multi' | 'strong';
|
|
72
|
+
export interface MatchClassification {
|
|
73
|
+
strength: MatchStrength;
|
|
74
|
+
matchedIdentifiers: string[];
|
|
75
|
+
matchedTags: string[];
|
|
76
|
+
}
|
|
77
|
+
export declare function classifyMatch(response: string, identifiers: unknown[], tags: unknown[]): MatchClassification;
|
|
78
|
+
/**
|
|
79
|
+
* Convenience wrapper for callers that only care about "should attribute?".
|
|
80
|
+
*/
|
|
81
|
+
export declare function shouldAttribute(classification: MatchClassification): boolean;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Term matching utilities for compound negative/reflection attribution.
|
|
3
|
+
*
|
|
4
|
+
* Why this module exists (PR3 motivation):
|
|
5
|
+
* 라운드 1~3 리뷰에서 반복적으로 발견된 문제의 근본 원인은
|
|
6
|
+
* `response.toLowerCase().includes(term.toLowerCase())` substring 매칭이었다.
|
|
7
|
+
*
|
|
8
|
+
* Matching model by script (라운드 3 정리):
|
|
9
|
+
*
|
|
10
|
+
* | term script | 매칭 방식 | boundary 의미 |
|
|
11
|
+
* |-------------|--------------------------------------------|------------------------------|
|
|
12
|
+
* | 영어/숫자 | lookaround regex `(?<![a-zA-Z0-9])...(?![a-zA-Z0-9])` | `_`도 boundary (snake_case 분해) |
|
|
13
|
+
* | 한글 | text를 비-한글로 tokenize → stem 정규화 → Set lookup | 조사/어미 제거 후 정확 비교 |
|
|
14
|
+
* | 혼합 스크립트 | false 반환 + debug log | 이전 substring fallback 제거 |
|
|
15
|
+
*
|
|
16
|
+
* 한글 stem 정규화는 두 단계:
|
|
17
|
+
* 1. `stripKoSuffix` (solution-format) — 일반 조사/어미 (`가`, `를`, `는`, …).
|
|
18
|
+
* **추출 시점에도 적용되므로 1글자 suffix는 한자어 명사를 깨뜨리지 않도록
|
|
19
|
+
* 매우 보수적**. extractTags와 정합성을 유지하기 위해 매칭도 같은 함수 사용.
|
|
20
|
+
* 2. `KO_VERBAL_SUFFIXES` (본 모듈) — 동사/명사 활용형 (`중`, `시`).
|
|
21
|
+
* 매칭 전용이라 extractTags에 영향이 없고, stem 결과가 2글자 미만이면
|
|
22
|
+
* 드롭해 `집중`→`집` 같은 false positive를 차단.
|
|
23
|
+
*
|
|
24
|
+
* 이 모듈의 원칙:
|
|
25
|
+
* 1. **Unicode NFC normalize**: 입력 text와 term 모두 NFC로 정규화해 macOS
|
|
26
|
+
* NFD 경로명과의 매칭 실패를 방지. response는 classifyMatch 진입 시 한 번만.
|
|
27
|
+
* 2. **extractTags 계약 정렬**: 영어 3글자 이상, 한국어 2글자 이상.
|
|
28
|
+
* 3. **변조 cache 방어**: MAX_TERM_LENGTH(128) 가드로 거대 term에 의한 RegExp
|
|
29
|
+
* 컴파일 실패 (~32K 이상에서 V8이 throw) 방지.
|
|
30
|
+
* 4. **Negative term blocklist**: 흔한 메타 term은 term-filter 단계에서 제거.
|
|
31
|
+
* 5. **매칭 강도 임계값**: identifier 길이 4 이상 단독도 strong 인정 (Python/C의
|
|
32
|
+
* `init`, `exit`, `main` 같은 공통 4글자 idents가 systematic miss되지 않도록).
|
|
33
|
+
*/
|
|
34
|
+
import { createLogger } from '../core/logger.js';
|
|
35
|
+
import { stripKoSuffix } from './solution-format.js';
|
|
36
|
+
const log = createLogger('term-matcher');
|
|
37
|
+
/**
|
|
38
|
+
* Negative attribution에서 제외할 흔한 메타 term.
|
|
39
|
+
* 거의 모든 Bash 오류/경고에 등장해 signal-to-noise ratio가 0에 가깝다.
|
|
40
|
+
*/
|
|
41
|
+
export const NEGATIVE_TERM_BLOCKLIST = new Set([
|
|
42
|
+
// English meta terms
|
|
43
|
+
'error', 'errors', 'fail', 'failed', 'failure',
|
|
44
|
+
'bug', 'bugs', 'issue', 'issues', 'warn', 'warning',
|
|
45
|
+
'code', 'file', 'line', 'test', 'tests',
|
|
46
|
+
// Korean meta terms
|
|
47
|
+
'에러', '오류', '실패', '버그', '경고',
|
|
48
|
+
'코드', '파일', '문제', '테스트',
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* 매칭 전용 한국어 동사/명사 활용형 suffix.
|
|
52
|
+
*
|
|
53
|
+
* extractTags에 영향을 주지 않으려고 solution-format의 KO_SUFFIXES와 분리.
|
|
54
|
+
* `리팩토링중`, `배포시` 같은 활용형을 매칭 시점에 stem으로 풀어내되,
|
|
55
|
+
* stem 결과가 2자 미만이면 드롭해 `집중`→`집`, `시도`→`시` 같은 1자 stem
|
|
56
|
+
* 오염을 방지한다.
|
|
57
|
+
*/
|
|
58
|
+
const KO_VERBAL_SUFFIXES = ['중', '시'];
|
|
59
|
+
/**
|
|
60
|
+
* cache 변조 방어용 term 최대 길이. V8의 RegExp는 ~32K 이상에서 컴파일 실패로
|
|
61
|
+
* throw하는데, 악의적 cache가 거대 Latin term을 주입하면 matchesAtWordBoundary가
|
|
62
|
+
* 전체 루프를 abort시킬 수 있다. 128자면 실제 identifier/tag는 모두 커버.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_TERM_LENGTH = 128;
|
|
65
|
+
/**
|
|
66
|
+
* STRONG_ID_MIN_LENGTH: single identifier가 'strong'으로 승급되는 최소 길이.
|
|
67
|
+
*
|
|
68
|
+
* Python/C 빈출 identifier (`init`, `exit`, `main`, `recv`, `send`, `read`,
|
|
69
|
+
* `open`, `kill`, `fork`, `pipe`)는 4자가 대다수이므로 4로 설정. 3자 identifier
|
|
70
|
+
* (`api`, `sql`)는 단독으로는 weak, tag 1개라도 추가되면 strong으로 승급.
|
|
71
|
+
*/
|
|
72
|
+
const STRONG_ID_MIN_LENGTH = 4;
|
|
73
|
+
/**
|
|
74
|
+
* NFC normalize. macOS 파일명은 NFD를 쓰므로 stack trace에 포함될 때 매칭이
|
|
75
|
+
* 실패할 수 있다. 양쪽을 NFC로 맞춘다.
|
|
76
|
+
*/
|
|
77
|
+
function normalize(s) {
|
|
78
|
+
return s.normalize('NFC');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Filter terms usable for word-boundary matching.
|
|
82
|
+
*
|
|
83
|
+
* - Drops non-strings (cache file can be hand-edited to contain garbage).
|
|
84
|
+
* - Drops terms that are too long (MAX_TERM_LENGTH guard — RegExp DoS 방어).
|
|
85
|
+
* - Drops terms that are too short to be meaningful (English < 3, Korean < 2).
|
|
86
|
+
* - Drops meta terms that match almost any Bash error (NEGATIVE_TERM_BLOCKLIST).
|
|
87
|
+
* - NFC normalize on ingest.
|
|
88
|
+
*
|
|
89
|
+
* Drop 사유는 debug 레벨로 로그해 cache 변조 또는 extractor 버그를 운영 중에
|
|
90
|
+
* 추적할 수 있게 한다 (silent drop은 디버깅이 불가능).
|
|
91
|
+
*/
|
|
92
|
+
export function filterMatchableTerms(raw) {
|
|
93
|
+
const out = [];
|
|
94
|
+
for (const t of raw) {
|
|
95
|
+
if (typeof t !== 'string' || t.length === 0) {
|
|
96
|
+
log.debug(`non-string term dropped: ${JSON.stringify(t)}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (t.length > MAX_TERM_LENGTH) {
|
|
100
|
+
log.debug(`oversized term dropped (len=${t.length}): ${JSON.stringify(t.slice(0, 32))}...`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const nt = normalize(t);
|
|
104
|
+
if (NEGATIVE_TERM_BLOCKLIST.has(nt.toLowerCase()))
|
|
105
|
+
continue;
|
|
106
|
+
const isKorean = /[가-힣]/.test(nt);
|
|
107
|
+
if (isKorean) {
|
|
108
|
+
if (nt.length < 2)
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (nt.length < 3)
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
out.push(nt);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 단일 한글 token의 stem 변형을 모두 반환한다.
|
|
121
|
+
*
|
|
122
|
+
* Returns (in order):
|
|
123
|
+
* 1. token 자체 (length ≥ 2일 때만)
|
|
124
|
+
* 2. stripKoSuffix 결과 (원본과 다르고 ≥ 2일 때만)
|
|
125
|
+
* 3. KO_VERBAL_SUFFIXES 각각을 strip한 결과 (≥ 2일 때만)
|
|
126
|
+
*
|
|
127
|
+
* stem 결과가 2자 미만이면 제외 — `집중`→`집` 같은 1자 stem이 Set에 들어가면
|
|
128
|
+
* 무관한 `'집 정리'` text의 1자 token과 매칭되는 over-attribution 발생.
|
|
129
|
+
*
|
|
130
|
+
* 이 헬퍼는 두 곳에서 공유된다:
|
|
131
|
+
* - `koreanStemTokens`: response text의 모든 token을 stem Set으로 변환
|
|
132
|
+
* - `matchesInPrecomputed` (Hangul branch): term 자체의 변형을 stem Set에 질의
|
|
133
|
+
* 둘이 동일한 변환 규칙을 쓰는 게 핵심이라 DRY로 뽑았다.
|
|
134
|
+
*/
|
|
135
|
+
function koreanStemVariants(token) {
|
|
136
|
+
const variants = [];
|
|
137
|
+
if (token.length >= 2)
|
|
138
|
+
variants.push(token);
|
|
139
|
+
const s1 = stripKoSuffix(token);
|
|
140
|
+
if (s1 !== token && s1.length >= 2)
|
|
141
|
+
variants.push(s1);
|
|
142
|
+
for (const suffix of KO_VERBAL_SUFFIXES) {
|
|
143
|
+
if (token.endsWith(suffix) && token.length > suffix.length) {
|
|
144
|
+
const s2 = token.slice(0, -suffix.length);
|
|
145
|
+
if (s2.length >= 2)
|
|
146
|
+
variants.push(s2);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return variants;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 한국어 text를 비-한글로 분해 → 각 token의 stem 변형들을 Set에 담는다.
|
|
153
|
+
*
|
|
154
|
+
* @param nText 호출자가 이미 NFC normalize한 text (중복 normalize 방지)
|
|
155
|
+
*/
|
|
156
|
+
function koreanStemTokens(nText) {
|
|
157
|
+
const tokens = nText.split(/[^가-힣]+/).filter(t => t.length > 0);
|
|
158
|
+
const stems = new Set();
|
|
159
|
+
for (const tok of tokens) {
|
|
160
|
+
for (const v of koreanStemVariants(tok)) {
|
|
161
|
+
stems.add(v);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return stems;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* 영어 매칭 전용 regex 생성. term은 `^[a-zA-Z0-9_]+$` 가드 후라 metachar 없음.
|
|
168
|
+
* lookaround로 `_`를 boundary로 인정 → `api`가 `my_api_call` 안에서 매칭됨.
|
|
169
|
+
*/
|
|
170
|
+
function englishBoundaryRegex(lowerTerm) {
|
|
171
|
+
return new RegExp(`(?<![a-zA-Z0-9])${lowerTerm}(?![a-zA-Z0-9])`);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Check whether `term` matches inside `text` at a word boundary.
|
|
175
|
+
*
|
|
176
|
+
* 주의: 호출자는 일반적으로 `matchesInPrecomputed`를 써서 NFC normalize와
|
|
177
|
+
* stem Set 계산을 재사용하는 게 효율적. 이 함수는 단발성 호출 또는 테스트용.
|
|
178
|
+
*/
|
|
179
|
+
export function matchesAtWordBoundary(text, term) {
|
|
180
|
+
if (!term || !text)
|
|
181
|
+
return false;
|
|
182
|
+
if (term.length > MAX_TERM_LENGTH)
|
|
183
|
+
return false;
|
|
184
|
+
const nText = normalize(text);
|
|
185
|
+
const nTerm = normalize(term);
|
|
186
|
+
return matchesInPrecomputed(nText, nTerm, null);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* 사전 계산된 NFC text와 옵셔널 Korean stem Set을 재사용해 term 매칭.
|
|
190
|
+
* classifyMatch가 내부에서 사용해 response를 여러 번 normalize하지 않도록 한다.
|
|
191
|
+
*/
|
|
192
|
+
function matchesInPrecomputed(nText, nTerm, stems) {
|
|
193
|
+
// Defensive guard: 정상 경로(filterMatchableTerms → classifyMatch)에서는 이미
|
|
194
|
+
// MAX_TERM_LENGTH 초과 term이 제거되므로 여기 오는 경우는 거의 없다. 다만
|
|
195
|
+
// matchesAtWordBoundary가 필터 없이 직접 호출될 수 있고, 테스트/내부 호출자가
|
|
196
|
+
// raw term을 넘길 수 있어 defense-in-depth로 유지. drop은 silent 대신 debug
|
|
197
|
+
// 레벨로 로그해 cache 변조/호출 패턴 문제를 추적 가능하게 둔다.
|
|
198
|
+
if (nTerm.length > MAX_TERM_LENGTH) {
|
|
199
|
+
log.debug(`oversized term dropped in matcher (len=${nTerm.length})`);
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
const isPureLatin = /^[a-zA-Z0-9_]+$/.test(nTerm);
|
|
203
|
+
if (isPureLatin) {
|
|
204
|
+
const lowerText = nText.toLowerCase();
|
|
205
|
+
const lowerTerm = nTerm.toLowerCase();
|
|
206
|
+
return englishBoundaryRegex(lowerTerm).test(lowerText);
|
|
207
|
+
}
|
|
208
|
+
const isPureHangul = /^[가-힣]+$/.test(nTerm);
|
|
209
|
+
if (isPureHangul) {
|
|
210
|
+
const stemSet = stems ?? koreanStemTokens(nText);
|
|
211
|
+
// koreanStemVariants는 koreanStemTokens와 동일한 규칙으로 term의 변형들을
|
|
212
|
+
// 열거 — stem ≥ 2 제약도 동일해 `집중`→`집` 같은 1자 stem 오염 원천 차단.
|
|
213
|
+
for (const variant of koreanStemVariants(nTerm)) {
|
|
214
|
+
if (stemSet.has(variant))
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
// Mixed script (rare). Reject rather than fall back to substring — the
|
|
220
|
+
// previous substring fallback was the exact pattern that caused rounds
|
|
221
|
+
// 1-3 over-attribution. JSON.stringify로 escape해 제어 문자/따옴표가 로그에
|
|
222
|
+
// 그대로 찍혀 log injection 유발하지 않게 한다.
|
|
223
|
+
log.debug(`mixed-script term dropped: ${JSON.stringify(nTerm)}`);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
export function classifyMatch(response, identifiers, tags) {
|
|
227
|
+
// Empty/nullish response는 normalize/stem 계산을 skip해 hot path 비용을 줄임.
|
|
228
|
+
// 이전에는 filterMatchableTerms가 빈 배열을 리턴해도 normalize/split이 수행돼
|
|
229
|
+
// 핸들러 루프에서 불필요한 작업이 누적됐다.
|
|
230
|
+
if (!response) {
|
|
231
|
+
return { strength: 'none', matchedIdentifiers: [], matchedTags: [] };
|
|
232
|
+
}
|
|
233
|
+
const safeIds = filterMatchableTerms(identifiers);
|
|
234
|
+
const safeTags = filterMatchableTerms(tags);
|
|
235
|
+
// response는 한 번만 normalize하고, Korean stem Set도 한 번만 계산.
|
|
236
|
+
const nResponse = normalize(response);
|
|
237
|
+
const stems = koreanStemTokens(nResponse);
|
|
238
|
+
const matchedIdentifiers = safeIds.filter(id => matchesInPrecomputed(nResponse, id, stems));
|
|
239
|
+
const matchedTags = safeTags.filter(tag => matchesInPrecomputed(nResponse, tag, stems));
|
|
240
|
+
// Strong attribution rules:
|
|
241
|
+
// - 2+ distinct identifiers matched (signature evidence)
|
|
242
|
+
// - OR 1 identifier ≥STRONG_ID_MIN_LENGTH chars matched
|
|
243
|
+
// - OR 1 identifier + 1 tag matched (cross-signal corroboration)
|
|
244
|
+
const hasLongIdentifier = matchedIdentifiers.some(id => id.length >= STRONG_ID_MIN_LENGTH);
|
|
245
|
+
const strongIdentifier = matchedIdentifiers.length >= 2
|
|
246
|
+
|| (matchedIdentifiers.length >= 1 && hasLongIdentifier)
|
|
247
|
+
|| (matchedIdentifiers.length >= 1 && matchedTags.length >= 1);
|
|
248
|
+
let strength;
|
|
249
|
+
if (strongIdentifier) {
|
|
250
|
+
strength = 'strong';
|
|
251
|
+
}
|
|
252
|
+
else if (matchedTags.length >= 2) {
|
|
253
|
+
strength = 'multi';
|
|
254
|
+
}
|
|
255
|
+
else if (matchedTags.length === 1 || matchedIdentifiers.length === 1) {
|
|
256
|
+
strength = 'weak';
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
strength = 'none';
|
|
260
|
+
}
|
|
261
|
+
return { strength, matchedIdentifiers, matchedTags };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Convenience wrapper for callers that only care about "should attribute?".
|
|
265
|
+
*/
|
|
266
|
+
export function shouldAttribute(classification) {
|
|
267
|
+
return classification.strength === 'strong' || classification.strength === 'multi';
|
|
268
|
+
}
|