hanlint 0.0.7 → 0.0.8

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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -5
  3. package/data/adjectiveStems.txt +118 -0
  4. package/data/baselineAnchors.txt +3 -0
  5. package/data/blindEvaluation.schema.json +26 -0
  6. package/data/bridgeOpeners.txt +10 -0
  7. package/data/candidateActionNouns.txt +39 -0
  8. package/data/candidateSplitEndings.txt +13 -0
  9. package/data/cliches.json +1 -1
  10. package/data/confusable.json +2 -2
  11. package/data/deixis.txt +10 -3
  12. package/data/draftHistory.txt +10 -0
  13. package/data/easyWords.json +0 -42
  14. package/data/enoughMarkers.txt +6 -0
  15. package/data/entailmentPredictions.schema.json +52 -0
  16. package/data/euiNouns.txt +3 -4
  17. package/data/evidenceEntailmentBenchmark.schema.json +126 -0
  18. package/data/exemplars.json +347 -0
  19. package/data/holeKinds.json +1 -2
  20. package/data/iVerbStems.txt +26 -0
  21. package/data/inNouns.txt +147 -0
  22. package/data/irregularStems.txt +64 -0
  23. package/data/nonNouns.txt +153 -0
  24. package/data/panelAssignmentReview.schema.json +117 -0
  25. package/data/panelJudgePredictions.schema.json +78 -0
  26. package/data/panelReviewBatch.schema.json +79 -0
  27. package/data/panelTrialSet.schema.json +44 -0
  28. package/data/patterns.json +114 -0
  29. package/data/profiles.json +1 -0
  30. package/data/readerCalls.txt +12 -7
  31. package/data/redundantPair.json +1 -1
  32. package/data/rieulStems.txt +30 -0
  33. package/data/ruleCategories.json +56 -0
  34. package/data/ruleDocs.json +29 -17
  35. package/data/ruleMechanisms.json +56 -0
  36. package/data/spacing.json +0 -12
  37. package/data/spelling.json +6 -24
  38. package/data/translationese.json +10 -0
  39. package/data/verbTails.txt +17 -0
  40. package/data/version.json +1 -1
  41. package/data/writingBrief.schema.json +64 -0
  42. package/data/writingBriefV2.schema.json +116 -0
  43. package/data/writingTrial.schema.json +36 -0
  44. package/package.json +13 -5
  45. package/src/analysis/grammar/ending.js +336 -0
  46. package/src/analysis/grammar/hangul.js +94 -0
  47. package/src/analysis/grammar/index.js +5 -0
  48. package/src/analysis/grammar/josa.js +73 -0
  49. package/src/analysis/grammar/register.js +115 -0
  50. package/src/analysis/grammar/voice.js +43 -0
  51. package/src/analysis/index.js +4 -31
  52. package/src/analysis/josa.js +2 -0
  53. package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
  54. package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
  55. package/src/baseline/store.js +114 -0
  56. package/src/cli/main.js +506 -58
  57. package/src/cli/walk.js +30 -0
  58. package/src/cli/welcome.js +52 -0
  59. package/src/config/settings.js +103 -8
  60. package/src/data/exemplars.js +177 -0
  61. package/src/data/load.js +10 -0
  62. package/src/data/operations.js +278 -0
  63. package/src/data/patches.js +125 -0
  64. package/src/data/patterns.js +48 -0
  65. package/src/data/profiles.js +102 -0
  66. package/src/document/model.js +2 -0
  67. package/src/document/parseMarkdown.js +84 -11
  68. package/src/edit/applyFixes.js +0 -0
  69. package/src/fingerprint/build.js +50 -32
  70. package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
  71. package/src/fingerprint/codeMarkers.js +73 -0
  72. package/src/fingerprint/markers.js +9 -0
  73. package/src/fingerprint/readerState.js +126 -0
  74. package/src/fingerprint/sourceSentence.js +37 -0
  75. package/src/fingerprint/topics.js +1 -1
  76. package/src/index.js +2 -16
  77. package/src/report/compactReport.js +6 -1
  78. package/src/report/fingerprintJson.js +8 -3
  79. package/src/report/jsonReport.js +50 -3
  80. package/src/report/operationMatch.js +31 -0
  81. package/src/report/patchMatch.js +44 -0
  82. package/src/report/registerMatch.js +40 -0
  83. package/src/report/textReport.js +41 -5
  84. package/src/rules/code/duplicateBlock.js +4 -3
  85. package/src/rules/code/firstResultDistance.js +1 -0
  86. package/src/rules/code/inputFileSource.js +23 -68
  87. package/src/rules/code/installImport.js +4 -4
  88. package/src/rules/code/platformApi.js +7 -6
  89. package/src/rules/document/countMismatch.js +22 -6
  90. package/src/rules/document/enoughOnce.js +25 -0
  91. package/src/rules/document/fieldEcho.js +38 -0
  92. package/src/rules/document/keywordHeading.js +27 -0
  93. package/src/rules/document/keywordMissing.js +1 -0
  94. package/src/rules/document/noQuestion.js +5 -1
  95. package/src/rules/document/promiseRecall.js +4 -2
  96. package/src/rules/document/tableOddCell.js +60 -0
  97. package/src/rules/finding.js +24 -2
  98. package/src/rules/orthography/confusable.js +1 -0
  99. package/src/rules/orthography/spacing.js +1 -0
  100. package/src/rules/orthography/spelling.js +1 -0
  101. package/src/rules/paragraph/factListParagraph.js +1 -0
  102. package/src/rules/paragraph/paraFragment.js +21 -11
  103. package/src/rules/paragraph/topicBreak.js +1 -0
  104. package/src/rules/registry.js +72 -3
  105. package/src/rules/sentence/cliche.js +1 -0
  106. package/src/rules/sentence/connectorRepeat.js +8 -7
  107. package/src/rules/sentence/danglingDeixis.js +8 -3
  108. package/src/rules/sentence/dash.js +2 -1
  109. package/src/rules/sentence/deixis.js +7 -3
  110. package/src/rules/sentence/doubleNegative.js +4 -1
  111. package/src/rules/sentence/doublePassive.js +38 -1
  112. package/src/rules/sentence/draftHistory.js +24 -0
  113. package/src/rules/sentence/ellipsis.js +17 -3
  114. package/src/rules/sentence/endingRepeat.js +6 -2
  115. package/src/rules/sentence/euiChain.js +3 -2
  116. package/src/rules/sentence/fillerOpener.js +1 -0
  117. package/src/rules/sentence/hardWord.js +1 -0
  118. package/src/rules/sentence/imperativePeriod.js +2 -1
  119. package/src/rules/sentence/japaneseLoan.js +1 -0
  120. package/src/rules/sentence/longSentence.js +3 -1
  121. package/src/rules/sentence/negationRedefine.js +10 -1
  122. package/src/rules/sentence/nounPile.js +1 -0
  123. package/src/rules/sentence/numberOrphan.js +45 -0
  124. package/src/rules/sentence/outsideProfile.js +68 -0
  125. package/src/rules/sentence/redundantPair.js +1 -0
  126. package/src/rules/sentence/translationese.js +1 -0
  127. package/src/rules/shared/candidates.js +129 -0
  128. package/src/rules/shared/dictionaryRule.js +12 -2
  129. package/src/rules/shared/localAntecedent.js +12 -0
  130. package/src/rules/shared/repeat.js +48 -0
  131. package/src/rules/structure/blockUnread.js +33 -0
  132. package/src/rules/structure/bridgeRepeat.js +40 -0
  133. package/src/rules/structure/emojiBullet.js +1 -0
  134. package/src/rules/structure/headingQuestion.js +19 -0
  135. package/src/rules/structure/headingSentence.js +9 -4
  136. package/src/rules/structure/headingSkip.js +1 -0
  137. package/src/rules/structure/headingUniform.js +9 -17
  138. package/src/rules/structure/introImage.js +19 -0
  139. package/src/rules/structure/introLong.js +1 -0
  140. package/src/rules/structure/loneSubheading.js +42 -0
  141. package/src/rules/structure/moreLater.js +48 -0
  142. package/src/rules/structure/sectionNoProse.js +1 -0
  143. package/src/rules/structure/sectionResult.js +3 -2
  144. package/src/text.js +19 -0
  145. package/src/rules/document/readerAbsent.js +0 -11
  146. package/src/rules/shared/runs.js +0 -22
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+ /** 평문 지문의 문장을 마크다운 표식을 보존한 원문 문장에 다시 맞춘다. */
3
+ import { splitSentences } from "../analysis/splitSentences.js";
4
+ import { plainText } from "../document/plainText.js";
5
+
6
+ /**
7
+ * @param {import("./build.js").DocumentPrint} document
8
+ * @returns {Map<number, string>}
9
+ */
10
+ export function sourceSentenceTexts(document) {
11
+ /** @type {Map<number, import("./build.js").SentencePrint[]>} */
12
+ const byBlock = new Map();
13
+ for (const sentence of document.sentences) {
14
+ const found = byBlock.get(sentence.blockIndex) ?? [];
15
+ found.push(sentence);
16
+ byBlock.set(sentence.blockIndex, found);
17
+ }
18
+ const blocks = new Map(document.blocks.map((block) => [block.index, block]));
19
+ const result = new Map();
20
+ for (const [blockIndex, plainSentences] of byBlock) {
21
+ const block = blocks.get(blockIndex);
22
+ if (!block) continue;
23
+ const rawSentences = splitSentences(block.text);
24
+ if (rawSentences.length !== plainSentences.length) continue;
25
+ if (plainSentences.some((plain, index) => plainText(rawSentences[index].text) !== plain.text)) continue;
26
+ plainSentences.forEach((plain, index) => result.set(plain.index, rawSentences[index].text));
27
+ }
28
+ return result;
29
+ }
30
+
31
+ /**
32
+ * @param {import("./build.js").DocumentPrint} document
33
+ * @param {import("./build.js").SentencePrint} sentence
34
+ */
35
+ export function sourceSentenceText(document, sentence) {
36
+ return sourceSentenceTexts(document).get(sentence.index);
37
+ }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  /** 화제어 집합. 조사를 뗀 명사 어절의 근사. 파이썬 fingerprint/topics.py 와 같다. */
3
- import { stripJosa, tailOf, words } from "../analysis/surface/tokenize.js";
3
+ import { stripJosa, tailOf, words } from "../analysis/tokenize.js";
4
4
  import { loadLines } from "../data/load.js";
5
5
 
6
6
  const WORD = /^[가-힣A-Za-z][가-힣A-Za-z0-9]*$/;
package/src/index.js CHANGED
@@ -8,11 +8,10 @@
8
8
  * ```
9
9
  *
10
10
  * 합격과 불합격을 판정하지 않는다. 지적 목록이 비어 있다는 것은 세어서 잡히는 결함이 없다는 뜻이다.
11
- * 지문 지도 (audit, map), 프로파일, kiwi 정밀 모드는 파이썬 패키지에 있다.
11
+ * 지문 지도 (audit, map) 와 프로파일은 파이썬 패키지에 있다.
12
12
  */
13
13
  import { readFileSync } from "node:fs";
14
14
 
15
- import { makeAnalyzer } from "./analysis/index.js";
16
15
  import { loadConfig } from "./config/loadConfig.js";
17
16
  import { configFromMapping, defaultConfig } from "./config/settings.js";
18
17
  import { loadVersion } from "./data/load.js";
@@ -25,19 +24,6 @@ import { ruleDoc, ruleNames, ruleSummary, runAll } from "./rules/registry.js";
25
24
  export { applyFixes, configFromMapping, defaultConfig, fingerprintDict, loadConfig, ruleDoc, ruleNames, ruleSummary };
26
25
  export const version = loadVersion();
27
26
 
28
- /** @type {Map<string, import("./analysis/index.js").Analyzer>} */
29
- const analyzers = new Map();
30
-
31
- /** @param {import("./config/settings.js").Config} config */
32
- export function analyzerFor(config) {
33
- let analyzer = analyzers.get(config.analyzer);
34
- if (!analyzer) {
35
- analyzer = makeAnalyzer(config.analyzer);
36
- analyzers.set(config.analyzer, analyzer);
37
- }
38
- return analyzer;
39
- }
40
-
41
27
  /**
42
28
  * 글을 한 번 읽어 지문을 만든다.
43
29
  * @param {string} text
@@ -45,7 +31,7 @@ export function analyzerFor(config) {
45
31
  * @param {string | null} [path]
46
32
  */
47
33
  export function fingerprint(text, config = defaultConfig(), path = null) {
48
- return buildFingerprint(parseMarkdown(text, path), analyzerFor(config), config);
34
+ return buildFingerprint(parseMarkdown(text, path), config);
49
35
  }
50
36
 
51
37
  /**
@@ -1,9 +1,14 @@
1
1
  // @ts-check
2
2
  /** 한 줄에 지적 하나. AI 와 스크립트가 읽는 꼴이다. 파이썬 report/compactReport.py 와 같은 글자다. */
3
3
 
4
+ /** 줄바꿈과 이어진 공백을 하나로 눕힌다. 한 줄은 이 꼴의 계약이다. @param {string} text */
5
+ function flat(text) {
6
+ return text.split(/\s+/).filter(Boolean).join(" ");
7
+ }
8
+
4
9
  /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
5
10
  export function renderCompact(path, findings) {
6
11
  return findings
7
- .map((f) => `${path}:${f.line} [${f.rule}] ${f.why}` + (f.fix ? ` 고친 뒤: ${f.fix}` : ""))
12
+ .map((f) => `${path}:${f.line} [${f.rule}] ${flat(f.why)}` + (f.fix ? ` 고친 뒤: ${flat(f.fix)}` : ""))
8
13
  .join("\n");
9
14
  }
@@ -4,12 +4,14 @@
4
4
  * 아래층을 index 로 가리킨다. 실수는 소수 여섯째 자리로 맞춰 두 구현이 같은 글자를 낸다.
5
5
  */
6
6
 
7
+ import { roundHalfEven } from "../text.js";
8
+
7
9
  export const LAYERS = ["all", "sentences", "paragraphs", "sections", "document"];
8
10
 
9
11
  /** @param {number | null} value */
10
12
  function num(value) {
11
13
  if (value === null) return null;
12
- return Math.round(value * 1e6) / 1e6;
14
+ return roundHalfEven(value, 6);
13
15
  }
14
16
 
15
17
  /** @param {Set<string>} set */
@@ -29,6 +31,7 @@ function sentenceDict(s) {
29
31
  length: s.length,
30
32
  ending: s.ending,
31
33
  mood: s.mood,
34
+ register: s.register,
32
35
  commas: s.commas,
33
36
  connectorStart: s.connectorStart,
34
37
  causal: s.causal,
@@ -102,9 +105,11 @@ function documentDict(doc) {
102
105
  sectionCount: doc.sections.length,
103
106
  questionCount: doc.questionCount,
104
107
  readerCallCount: doc.readerCallCount,
108
+ register: doc.register,
109
+ registerShare: roundHalfEven(doc.registerShare, 3),
105
110
  countPromises: doc.countPromises.map(([n, unit, line, text]) => [n, unit, line, text]),
106
- promises: doc.promises.map(([line, text]) => [line, text]),
107
- recalls: doc.recalls.map(([line, text]) => [line, text]),
111
+ promises: doc.reader.final.promises.map(([line, text]) => [line, text]),
112
+ recalls: doc.reader.final.recalls.map(([line, text]) => [line, text]),
108
113
  disabled: doc.disabled.map(([name, start, end]) => [name, start, end]),
109
114
  };
110
115
  }
@@ -1,13 +1,60 @@
1
1
  // @ts-check
2
- /** 기계가 읽는 꼴. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False) 와 같은 글자다. */
2
+ /**
3
+ * 기계가 읽는 꼴. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False) 와 같은 글자다.
4
+ * 지적마다 `exemplar` 를 붙인다. 승인 원문 완전 일치 `patch`는 맞는 지적에만 별도로 붙인다.
5
+ * 실제 수정 차이는 파이썬의 exemplarLift와 patchMemory 탐침에서 따로 잰다.
6
+ */
7
+ import { exemplarFor } from "../data/exemplars.js";
3
8
  import { findingAsDict } from "../rules/finding.js";
9
+ import { exemplarInRegister } from "./registerMatch.js";
10
+ import { operationGuidance } from "./operationMatch.js";
11
+ import { patchData } from "./patchMatch.js";
12
+
13
+ /** @param {import("../rules/finding.js").Finding} finding @param {string | null | undefined} register @param {string | null | undefined} preset @param {import("../data/exemplars.js").Exemplar[]} customExemplars */
14
+ function findingWithExemplar(finding, document, register, preset, customExemplars, patches) {
15
+ const data = findingAsDict(finding);
16
+ const exemplar = exemplarFor(finding.rule, preset, customExemplars);
17
+ if (exemplar) {
18
+ const adapted = exemplarInRegister(exemplar, register);
19
+ data.exemplar = { before: adapted.before, after: adapted.after, moved: adapted.moved };
20
+ }
21
+ const selected = patchData(document, finding, preset, patches);
22
+ if (selected) data.patch = selected;
23
+ return data;
24
+ }
4
25
 
5
26
  /**
6
27
  * @param {Map<string, import("../rules/finding.js").Finding[]>} results
7
28
  * @param {string | null} [configLabel]
29
+ * @param {Map<string, string> | null} [registers]
30
+ * @param {string | null} [preset]
31
+ * @param {import("../data/exemplars.js").Exemplar[]} [customExemplars]
32
+ * @param {Map<string, import("../fingerprint/build.js").DocumentPrint> | null} [documents]
33
+ * @param {import("../data/patches.js").Patch[]} [patches]
34
+ * @param {import("../data/operations.js").SurfaceOperation[]} [operations]
35
+ * @param {string[]} [protectedTerms]
8
36
  */
9
- export function renderJson(results, configLabel = null) {
10
- const files = [...results].map(([path, findings]) => ({ path, findings: findings.map(findingAsDict) }));
37
+ export function renderJson(
38
+ results,
39
+ configLabel = null,
40
+ registers = null,
41
+ preset = null,
42
+ customExemplars = [],
43
+ documents = null,
44
+ patches = [],
45
+ operations = [],
46
+ protectedTerms = [],
47
+ ) {
48
+ const files = [...results].map(([path, findings]) => {
49
+ const document = documents?.get(path);
50
+ const entry = {
51
+ path,
52
+ findings: findings.map((finding) => findingWithExemplar(finding, document, registers?.get(path), preset, customExemplars, patches)),
53
+ };
54
+ const selectedOperations = operationGuidance(document, findings, preset, operations, patches, protectedTerms);
55
+ if (selectedOperations.length) entry.operations = selectedOperations;
56
+ return entry;
57
+ });
11
58
  /** @type {Record<string, unknown>} */
12
59
  const data = { version: 1 };
13
60
  if (configLabel !== null) data.config = configLabel;
@@ -0,0 +1,31 @@
1
+ // @ts-check
2
+ /** 현재 문장에 승인 표면 치환이 안전하게 하나만 적용되는지 계산한다. */
3
+ import { operationData, operationFor } from "../data/operations.js";
4
+ import { sourceSentenceTexts } from "../fingerprint/sourceSentence.js";
5
+ import { selectedPatch } from "./patchMatch.js";
6
+
7
+ /**
8
+ * @param {import("../fingerprint/build.js").DocumentPrint | null | undefined} document
9
+ * @param {import("../rules/finding.js").Finding[]} findings
10
+ * @param {string | null | undefined} preset
11
+ * @param {import("../data/operations.js").SurfaceOperation[]} operations
12
+ * @param {import("../data/patches.js").Patch[]} [patches]
13
+ * @param {string[]} [protectedTerms]
14
+ */
15
+ export function operationGuidance(document, findings, preset, operations, patches = [], protectedTerms = []) {
16
+ if (!document || !preset || !operations.length) return [];
17
+ const reserved = new Set(findings.filter((finding) => finding.at >= 0 && finding.fix !== null).map((finding) => finding.at));
18
+ for (const finding of findings) {
19
+ if (finding.at >= 0 && selectedPatch(document, finding, preset, patches)) reserved.add(finding.at);
20
+ }
21
+ const sourceTexts = sourceSentenceTexts(document);
22
+ const guidance = [];
23
+ for (const sentence of document.sentences) {
24
+ if (reserved.has(sentence.index)) continue;
25
+ const sourceText = sourceTexts.get(sentence.index);
26
+ if (sourceText === undefined) continue;
27
+ const applied = operationFor(sourceText, preset, operations, protectedTerms);
28
+ if (applied) guidance.push({ line: sentence.line, operation: operationData(applied, preset) });
29
+ }
30
+ return guidance;
31
+ }
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ /** 현재 지적과 지문을 승인 패치의 선택 조건에 맞댄다. */
3
+ import { flatSentence, patchFor } from "../data/patches.js";
4
+ import { readerKind } from "../fingerprint/readerState.js";
5
+ import { sourceSentenceText } from "../fingerprint/sourceSentence.js";
6
+ import { SENTENCE, localCue } from "../rules/finding.js";
7
+
8
+ /**
9
+ * @param {import("../fingerprint/build.js").DocumentPrint | null | undefined} document
10
+ * @param {import("../rules/finding.js").Finding} finding
11
+ * @param {string | null | undefined} preset
12
+ * @param {import("../data/patches.js").Patch[]} patches
13
+ */
14
+ export function selectedPatch(document, finding, preset, patches) {
15
+ if (!patches.length || !document || finding.scope !== SENTENCE || finding.at < 0 || finding.at >= document.sentences.length) return undefined;
16
+ const sentence = document.sentences[finding.at];
17
+ const sourceText = sourceSentenceText(document, sentence);
18
+ if (!sourceText) return undefined;
19
+ const state = document.reader.beforeSentence[finding.at];
20
+ return patchFor(finding.rule, preset, sourceText, sentence.text, localCue(finding), readerKind(sentence, state), patches);
21
+ }
22
+
23
+ /**
24
+ * @param {import("../fingerprint/build.js").DocumentPrint | null | undefined} document
25
+ * @param {import("../rules/finding.js").Finding} finding
26
+ * @param {string | null | undefined} preset
27
+ * @param {import("../data/patches.js").Patch[]} patches
28
+ */
29
+ export function patchData(document, finding, preset, patches) {
30
+ const patch = selectedPatch(document, finding, preset, patches);
31
+ if (!patch || !preset) return undefined;
32
+ return {
33
+ before: patch.before,
34
+ after: patch.after,
35
+ moved: patch.moved,
36
+ match: {
37
+ sourceText: flatSentence(patch.sourceText),
38
+ sentence: flatSentence(patch.sentence),
39
+ preset,
40
+ cue: patch.cue,
41
+ reader: patch.reader,
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+ /** 한 문체로 저장한 본보기와 문형을 검사한 글의 문체에 맞춘다. */
3
+ import { HAPNIDA, REGISTERS, convertTemplate } from "../analysis/grammar/index.js";
4
+
5
+ /** 섞임과 없음은 데이터 정본의 합니다체를 유지한다. @param {string | null | undefined} register */
6
+ export function targetRegister(register) {
7
+ return register && REGISTERS.includes(register) ? register : HAPNIDA;
8
+ }
9
+
10
+ /** @param {import("../data/exemplars.js").Exemplar} exemplar @param {string | null | undefined} register */
11
+ export function exemplarInRegister(exemplar, register) {
12
+ const target = targetRegister(register);
13
+ const before = convertTemplate(exemplar.before, target);
14
+ const after = convertTemplate(exemplar.after, target);
15
+ if (before.skipped || after.skipped) {
16
+ throw new Error(
17
+ exemplar.rule + " 본보기의 서술어를 못 풀었다: " + (before.skipped + after.skipped),
18
+ );
19
+ }
20
+ return { rule: exemplar.rule, before: before.text, after: after.text, moved: exemplar.moved, presets: exemplar.presets };
21
+ }
22
+
23
+ /** @param {import("../data/patterns.js").Pattern} pattern @param {string | null | undefined} register */
24
+ export function patternInRegister(pattern, register) {
25
+ const target = targetRegister(register);
26
+ const form = convertTemplate(pattern.form, target);
27
+ const example = convertTemplate(pattern.example, target);
28
+ const instead = convertTemplate(pattern.instead, target);
29
+ const skipped = form.skipped + example.skipped + instead.skipped;
30
+ if (skipped) throw new Error(pattern.name + " 문형의 서술어를 못 풀었다: " + skipped);
31
+ return {
32
+ name: pattern.name,
33
+ form: form.text,
34
+ when: pattern.when,
35
+ example: example.text,
36
+ instead: instead.text,
37
+ avoids: pattern.avoids,
38
+ source: pattern.source,
39
+ };
40
+ }
@@ -1,8 +1,42 @@
1
1
  // @ts-check
2
- /** 사람이 읽는 지적 목록. `파일:줄` 로 시작해 편집기가 바로 연다. 파이썬 report/textReport.py 와 같은 글자다. */
2
+ /**
3
+ * 사람이 읽는 지적 목록. `파일:줄` 로 시작해 편집기가 바로 연다. 파이썬 report/textReport.py 와 같은 글자다.
4
+ * 끝에 본보기를 붙인다. 전과 후를 제 줄에 둔다. 왜 한 줄이 아닌지는 파이썬 report/textReport.py 가 소유한다.
5
+ */
6
+ import { exemplarFor, shortened, twoLines } from "../data/exemplars.js";
7
+ import { exemplarInRegister } from "./registerMatch.js";
3
8
 
4
- /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
5
- export function renderText(path, findings) {
9
+ /** 그 글에 나온 규칙의 본보기. 이름 순이고 규칙 하나에 세 줄이다. @param {import("../rules/finding.js").Finding[]} findings @param {string | null | undefined} register @param {string | null | undefined} preset @param {import("../data/exemplars.js").Exemplar[]} customExemplars */
10
+ function exemplarLines(findings, register, preset, customExemplars) {
11
+ const lines = [];
12
+ let cut = false;
13
+ for (const name of [...new Set(findings.map((f) => f.rule))].sort()) {
14
+ let exemplar = exemplarFor(name, preset, customExemplars);
15
+ if (!exemplar) continue;
16
+ exemplar = exemplarInRegister(exemplar, register);
17
+ const [before, after] = twoLines(exemplar);
18
+ cut = cut || shortened(exemplar);
19
+ lines.push(` [${name}]`, ` 전 ${before}`, ` 후 ${after}`);
20
+ }
21
+ if (!lines.length) return [];
22
+ const head = "본보기 (고치기 전, 고친 뒤)";
23
+ return [cut ? `${head}. 잘린 것은 hanlint explain <규칙>` : head, ...lines];
24
+ }
25
+
26
+ /** 본보기 아래에 붙이는 선택지. 지적 자리별이며 순위는 없다. @param {import("../rules/finding.js").Finding[]} findings */
27
+ function candidateLines(findings) {
28
+ const chosen = findings.filter((finding) => finding.candidates.length);
29
+ if (!chosen.length) return [];
30
+ const lines = ["후보 (기계가 고르지 않음)"];
31
+ for (const finding of chosen) {
32
+ lines.push(` ${finding.line}줄 [${finding.rule}]`);
33
+ for (const candidate of finding.candidates) lines.push(` - ${candidate.text} (${candidate.why})`);
34
+ }
35
+ return lines;
36
+ }
37
+
38
+ /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings @param {string | null | undefined} [register] @param {string | null | undefined} [preset] @param {import("../data/exemplars.js").Exemplar[]} [customExemplars] */
39
+ export function renderText(path, findings, register = null, preset = null, customExemplars = []) {
6
40
  if (!findings.length) return `${path} 집은 자리 없음`;
7
41
  const errors = findings.filter((f) => f.severity === "error").length;
8
42
  const notices = findings.length - errors;
@@ -14,6 +48,8 @@ export function renderText(path, findings) {
14
48
  if (f.fix) lines.push(` 고친 뒤: ${f.fix}`);
15
49
  lines.push("");
16
50
  }
17
- lines.push(`규칙이 왜 있는지 보려면: hanlint explain ${findings[0].rule}`);
18
- return lines.join("\n");
51
+ lines.push(...exemplarLines(findings, register, preset, customExemplars));
52
+ const candidates = candidateLines(findings);
53
+ if (candidates.length) lines.push("", ...candidates);
54
+ return lines.join("\n").replace(/\n+$/, "");
19
55
  }
@@ -1,8 +1,9 @@
1
1
  // @ts-check
2
+ import { roundHalfEven } from "../../text.js";
2
3
  import { DOCUMENT, NOTICE, finding } from "../finding.js";
3
- import { codeBlocksOf } from "../shared/codeBlocks.js";
4
4
 
5
5
  export const name = "duplicateBlock";
6
+ export const mechanism = "repeat";
6
7
  const MIN_LINES = 4;
7
8
 
8
9
  /** 줄 다중집합의 겹침 비. 2 * 공통 / (a + b). @param {string[]} a @param {string[]} b */
@@ -28,7 +29,7 @@ function similarity(a, b) {
28
29
  */
29
30
  export function run(doc, config) {
30
31
  const findings = [];
31
- const blocks = codeBlocksOf(doc).filter((b) => b.lines.filter(([, line]) => line.trim()).length >= MIN_LINES);
32
+ const blocks = doc.codeBlocks.filter((b) => b.lines.filter(([, line]) => line.trim()).length >= MIN_LINES);
32
33
  for (let later = 0; later < blocks.length; later++) {
33
34
  const current = blocks[later];
34
35
  const currentLines = current.lines.filter(([, line]) => line.trim()).map(([, line]) => line.trim());
@@ -38,7 +39,7 @@ export function run(doc, config) {
38
39
  const ratio = similarity(previous.lines.filter(([, line]) => line.trim()).map(([, line]) => line.trim()), currentLines);
39
40
  if (ratio >= config.duplicateBlockRatio) {
40
41
  findings.push(
41
- finding(name, current.startLine, currentLines[0], `${previous.startLine}번째 줄의 블록과 ${Math.round(ratio * 100)}% 같다. 다른 줄만 남기거나 앞 블록을 가리킨다`, null, NOTICE, DOCUMENT, current.index),
42
+ finding(name, current.startLine, currentLines[0], `${previous.startLine}번째 줄의 블록과 ${roundHalfEven(ratio * 100, 0)}% 같다. 다른 줄만 남기거나 앞 블록을 가리킨다`, null, NOTICE, DOCUMENT, current.index),
42
43
  );
43
44
  break;
44
45
  }
@@ -3,6 +3,7 @@ import { CODE, IMAGE, TABLE } from "../../document/model.js";
3
3
  import { NOTICE, PARAGRAPH, finding } from "../finding.js";
4
4
 
5
5
  export const name = "firstResultDistance";
6
+ export const mechanism = "threshold";
6
7
  const RESULT_KINDS = [CODE, IMAGE, TABLE];
7
8
 
8
9
  /**
@@ -1,39 +1,23 @@
1
1
  // @ts-check
2
+ import { createdIn, fileName, readsIn, writeTargets } from "../../fingerprint/codeMarkers.js";
2
3
  import { DOCUMENT, NOTICE, finding } from "../finding.js";
3
- import { codeBlocksOf } from "../shared/codeBlocks.js";
4
4
 
5
5
  export const name = "inputFileSource";
6
+ export const mechanism = "reader";
6
7
 
7
- const QUOTED = "\\(\\s*[\"']([^\"'\\n]+)[\"']";
8
- const READS = new RegExp(
9
- "\\b(?:read_csv|read_excel|read_parquet|read_json|read_sql|read_table|read_ndjson|scan_csv|scan_parquet|scan_ndjson|" +
10
- "read_text|read_bytes|loadtxt|imread|load_workbook)" + QUOTED,
11
- "g",
12
- );
13
- const OPENS = new RegExp("\\bopen" + QUOTED + "\\s*(?:,\\s*(?:mode\\s*=\\s*)?[\"']([^\"']*)[\"'])?", "g");
14
- const WRITES = new RegExp(
15
- "\\b(?:to_csv|to_excel|to_parquet|to_json|to_sql|write_csv|write_parquet|write_json|write_ndjson|sink_parquet|sink_csv|" +
16
- "write_text|write_bytes|savefig|save|imwrite|savetxt|dump)" + QUOTED,
17
- "g",
18
- );
19
- const SHELL_WRITES = /(?:>>?|-o|-O|--output|Out-File)\s*["']?([\w./\\-]+\.[A-Za-z0-9]+)/g;
20
- const SQL_PATHS = /FROM\s+'([^']+\.(?:csv|parquet|json|xlsx))'/gi;
21
- const MAKES_DIR = /(?:mkdir(?:\s+-p)?|makedirs|md)\s*\(?\s*["']?([\w./\\-]+)|Path\(\s*["']([^"']+)["']\s*\)\.mkdir/g;
22
8
  const EXTENSIONS = new Set(
23
9
  "csv xlsx xls parquet json jsonl txt png jpg jpeg svg gif db sqlite md py yaml yml toml html pdf zip ndjson".split(" "),
24
10
  );
25
-
26
- /** @param {string} path */
27
- function fileName(path) {
28
- const parts = path.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
29
- return parts[parts.length - 1];
30
- }
11
+ /** 별표, 서식 자리표시자, URL 이 든 경로는 파일 하나를 가리키지 않는다. */
12
+ const NOT_A_FILE = ["*", "{", "http", "://", "<", ">"];
13
+ /** 폴더를 볼 때는 물음표까지 뺀다. */
14
+ const NOT_A_PATH = [...NOT_A_FILE, "?"];
31
15
 
32
16
  /** @param {string} path */
33
17
  function isDataFile(path) {
34
- if (["*", "{", "http", "://", "<", ">"].some((mark) => path.includes(mark))) return false;
35
- const name = fileName(path);
36
- return name.includes(".") && EXTENSIONS.has(name.split(".").pop()?.toLowerCase() ?? "");
18
+ if (NOT_A_FILE.some((mark) => path.includes(mark))) return false;
19
+ const name2 = fileName(path);
20
+ return name2.includes(".") && EXTENSIONS.has(name2.split(".").pop()?.toLowerCase() ?? "");
37
21
  }
38
22
 
39
23
  /** @param {string} path */
@@ -42,62 +26,33 @@ function directoryOf(path) {
42
26
  return parts.length > 1 && !["", ".", ".."].includes(parts[0]) ? parts[0] : null;
43
27
  }
44
28
 
45
- /** @param {import("../../fingerprint/build.js").DocumentPrint} doc @param {string} needle @param {number} line */
46
- function mentionedBefore(doc, needle, line) {
47
- return doc.sentences.some((s) => s.line < line && s.text.includes(needle));
48
- }
49
-
50
- /** @param {RegExp} pattern @param {string} text @param {number} group */
51
- function all(pattern, text, group) {
52
- pattern.lastIndex = 0;
53
- const found = [];
54
- for (const match of text.matchAll(pattern)) if (match[group] !== undefined) found.push(match[group]);
55
- return found;
56
- }
57
-
58
29
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
59
30
  export function run(doc) {
60
31
  const findings = [];
61
- /** @type {Set<string>} */
62
- const created = new Set();
63
- for (const block of codeBlocksOf(doc)) {
64
- /** @type {Set<string>} */
65
- const blockWrites = new Set();
66
- /** @type {Set<string>} */
67
- const blockDirs = new Set();
68
- for (const [, code] of block.lines) {
69
- for (const name2 of all(WRITES, code, 1)) blockWrites.add(fileName(name2));
70
- for (const name2 of all(SHELL_WRITES, code, 1)) blockWrites.add(fileName(name2));
71
- OPENS.lastIndex = 0;
72
- for (const match of code.matchAll(OPENS)) {
73
- const mode = match[2] ?? "r";
74
- if ([..."wax"].some((flag) => mode.includes(flag))) blockWrites.add(fileName(match[1]));
75
- }
76
- MAKES_DIR.lastIndex = 0;
77
- for (const match of code.matchAll(MAKES_DIR)) blockDirs.add((match[1] ?? match[2]).replace(/\\/g, "/").split("/")[0]);
78
- }
79
- for (const dir of blockDirs) created.add(dir);
32
+ /** 이미 짚은 폴더. 같은 폴더를 두 번 짚지 않는다. @type {Set<string>} */
33
+ const reported = new Set();
34
+ for (const block of doc.codeBlocks) {
35
+ const reader = doc.reader.beforeBlock[block.index];
36
+ const [blockWrites, blockDirs] = createdIn(block.lines.map(([, line]) => line));
37
+ // 이 블록이 만드는 폴더는 같은 블록의 읽기에도 미리 센다. 파일은 블록 안에서 쓴 것 (blockWrites) 만 따로 본다.
38
+ const have = new Set([...reader.files, ...blockDirs, ...reported]);
80
39
  for (const [line, code] of block.lines) {
81
- OPENS.lastIndex = 0;
82
- const reads = [...all(READS, code, 1), ...all(SQL_PATHS, code, 1)];
83
- for (const match of code.matchAll(OPENS)) {
84
- if (![..."wax"].some((flag) => (match[2] ?? "r").includes(flag))) reads.push(match[1]);
85
- }
40
+ const reads = readsIn(code);
86
41
  for (const path of reads) {
87
42
  if (!isDataFile(path)) continue;
88
43
  const name2 = fileName(path);
89
- if (created.has(name2) || blockWrites.has(name2) || mentionedBefore(doc, name2, block.startLine)) continue;
44
+ if (have.has(name2) || blockWrites.has(name2) || doc.reader.mentionedBefore(block.index, name2)) continue;
90
45
  findings.push(finding(name, line, code.trim(), `\`${name2}\` 을 읽는데 글 어디에서도 만들지 않았다. 독자는 여기서 파일 없음 오류로 멈춘다`, null, "error", DOCUMENT, block.index));
91
46
  }
92
- for (const path of [...all(WRITES, code, 1), ...reads]) {
93
- if (["http", "://", "{", "<", ">", "*", "?"].some((mark) => path.includes(mark))) continue;
47
+ for (const path of [...writeTargets(code), ...reads]) {
48
+ if (NOT_A_PATH.some((mark) => path.includes(mark))) continue;
94
49
  const directory = directoryOf(path);
95
- if (!directory || created.has(directory) || mentionedBefore(doc, directory, block.startLine)) continue;
96
- created.add(directory);
50
+ if (!directory || have.has(directory) || doc.reader.mentionedBefore(block.index, directory)) continue;
51
+ have.add(directory);
52
+ reported.add(directory);
97
53
  findings.push(finding(name, line, code.trim(), `\`${directory}\` 폴더를 쓰는데 글 어디에서도 만들지 않았다. 없으면 파일을 쓰다 멈춘다`, null, NOTICE, DOCUMENT, block.index));
98
54
  }
99
55
  }
100
- for (const write of blockWrites) created.add(write);
101
56
  }
102
57
  return findings;
103
58
  }
@@ -1,9 +1,9 @@
1
1
  // @ts-check
2
2
  import { loadLines } from "../../data/load.js";
3
3
  import { DOCUMENT, NOTICE, finding } from "../finding.js";
4
- import { codeBlocksOf } from "../shared/codeBlocks.js";
5
4
 
6
5
  export const name = "installImport";
6
+ export const mechanism = "contrast";
7
7
 
8
8
  const INSTALL = /(?:pip\s+install|uv\s+add|uv\s+pip\s+install|conda\s+install|poetry\s+add)\s+([^\n#|&;]+)/g;
9
9
  const IMPORT = /^\s*(?:import\s+([\w.]+(?:\s*,\s*[\w.]+)*)|from\s+([\w.]+)\s+import\b)/;
@@ -49,7 +49,7 @@ function normalize(value) {
49
49
 
50
50
  /** 설치 줄이 말한 패키지 → extras. 설치 줄이 하나도 없으면 null. @param {import("../../fingerprint/build.js").DocumentPrint} doc */
51
51
  function installed(doc) {
52
- const texts = [...codeBlocksOf(doc).flatMap((b) => b.lines.map(([, line]) => line)), ...doc.sentences.map((s) => s.text)];
52
+ const texts = [...doc.codeBlocks.flatMap((b) => b.lines.map(([, line]) => line)), ...doc.sentences.map((s) => s.text)];
53
53
  /** @type {Map<string, Set<string>>} */
54
54
  const packages = new Map();
55
55
  let seen = false;
@@ -80,7 +80,7 @@ function localModules(doc) {
80
80
  PY_FILE.lastIndex = 0;
81
81
  for (const match of sentence.text.matchAll(PY_FILE)) names.add(match[1]);
82
82
  }
83
- for (const block of codeBlocksOf(doc)) {
83
+ for (const block of doc.codeBlocks) {
84
84
  for (const [, line] of block.lines) {
85
85
  PY_FILE.lastIndex = 0;
86
86
  for (const match of line.matchAll(PY_FILE)) names.add(match[1]);
@@ -95,7 +95,7 @@ export function run(doc) {
95
95
  if (packages === null) return [];
96
96
  const local = localModules(doc);
97
97
  const findings = [];
98
- for (const block of codeBlocksOf(doc)) {
98
+ for (const block of doc.codeBlocks) {
99
99
  if (!["python", "py"].includes(block.language)) continue;
100
100
  for (const [line, code] of block.lines) {
101
101
  const match = IMPORT.exec(code);
@@ -1,17 +1,18 @@
1
1
  // @ts-check
2
2
  import { loadLines } from "../../data/load.js";
3
3
  import { DOCUMENT, NOTICE, finding } from "../finding.js";
4
- import { codeBlocksOf } from "../shared/codeBlocks.js";
5
4
 
6
5
  export const name = "platformApi";
6
+ export const mechanism = "dictionary";
7
7
 
8
- /** @type {[RegExp, string, string][] | null} */
8
+ /** @type {[RegExp, string, string, string][] | null} */
9
9
  let cache = null;
10
10
  function platformApis() {
11
11
  if (!cache) {
12
12
  cache = loadLines("platformApis.txt").map((line) => {
13
13
  const [pattern, platform, alternative] = line.split("\t");
14
- return /** @type {[RegExp, string, string]} */ ([new RegExp(pattern), platform, alternative]);
14
+ // 지적문에는 정규식 원문을 적는다. V8 의 RegExp.source 는 / 를 \/ 로 돌려줘 파이썬의 pattern.pattern 과 갈린다.
15
+ return /** @type {[RegExp, string, string, string]} */ ([new RegExp(pattern), pattern, platform, alternative]);
15
16
  });
16
17
  }
17
18
  return cache;
@@ -20,13 +21,13 @@ function platformApis() {
20
21
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
21
22
  export function run(doc) {
22
23
  const findings = [];
23
- for (const block of codeBlocksOf(doc)) {
24
+ for (const block of doc.codeBlocks) {
24
25
  if (block.isOutput) continue;
25
26
  for (const [line, code] of block.lines) {
26
- for (const [pattern, platform, alternative] of platformApis()) {
27
+ for (const [pattern, source, platform, alternative] of platformApis()) {
27
28
  pattern.lastIndex = 0;
28
29
  if (pattern.test(code)) {
29
- findings.push(finding(name, line, code.trim(), `\`${pattern.source}\` 은 ${platform} 전용이다. 다른 운영체제 독자는 여기서 멈춘다. ${alternative}`, null, NOTICE, DOCUMENT, block.index));
30
+ findings.push(finding(name, line, code.trim(), `\`${source}\` 은 ${platform} 전용이다. 다른 운영체제 독자는 여기서 멈춘다. ${alternative}`, null, NOTICE, DOCUMENT, block.index));
30
31
  break;
31
32
  }
32
33
  }