hanlint 0.0.8 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -5
- package/data/checkResult.schema.json +75 -0
- package/data/checkResultV2.schema.json +98 -0
- package/data/exemplars.json +2 -26
- package/data/finding.schema.json +38 -0
- package/data/holeKinds.json +1 -12
- package/data/patch.schema.json +14 -0
- package/data/patchResult.schema.json +86 -0
- package/data/patchResultV2.schema.json +71 -0
- package/data/patterns.json +0 -11
- package/data/platformApis.txt +3 -1
- package/data/profiles.json +1 -1
- package/data/pythonPackages.txt +3 -2
- package/data/readerContract.schema.json +20 -0
- package/data/readerContractConformanceV1.json +168 -0
- package/data/readerContractV2.schema.json +64 -0
- package/data/ruleCategories.json +0 -4
- package/data/ruleDocs.json +7 -11
- package/data/ruleMechanisms.json +0 -4
- package/data/spelling.json +1 -1
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +1 -1
- package/data/writingBriefV2.schema.json +1 -1
- package/koglType1.LICENSE.md +25 -0
- package/package.json +6 -2
- package/src/cli/help.js +34 -0
- package/src/cli/main.js +247 -35
- package/src/config/patch.js +48 -0
- package/src/config/readerContract.js +245 -0
- package/src/config/settings.js +47 -14
- package/src/data/profiles.js +12 -0
- package/src/fingerprint/build.js +10 -7
- package/src/fingerprint/codeMarkers.js +1 -1
- package/src/guard/contract.js +174 -0
- package/src/guard/editLocality.js +27 -0
- package/src/guard/outline.js +46 -0
- package/src/guard/receipt.js +180 -0
- package/src/guard/surface.js +184 -0
- package/src/index.js +39 -1
- package/src/report/compactReport.js +1 -1
- package/src/report/writingSpec.js +174 -0
- package/src/rules/code/inputFileSource.js +5 -3
- package/src/rules/code/installImport.js +8 -6
- package/src/rules/document/keywordHeading.js +7 -2
- package/src/rules/document/noQuestion.js +4 -4
- package/src/rules/document/tableOddCell.js +2 -1
- package/src/rules/readerDebt.js +9 -0
- package/src/rules/registry.js +27 -8
- package/src/rules/sentence/danglingDeixis.js +15 -0
- package/src/rules/sentence/negationRedefine.js +2 -1
- package/src/rules/sentence/nounPile.js +1 -1
- package/src/rules/structure/headingUniform.js +3 -3
- package/src/rules/structure/introLong.js +3 -1
- package/data/coverageTypes.txt +0 -7
- package/src/rules/paragraph/topicBreak.js +0 -26
- package/src/rules/sentence/ellipsis.js +0 -32
- package/src/rules/structure/headingQuestion.js +0 -19
- package/src/rules/structure/sectionResult.js +0 -38
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 모델과 실행 환경에 독립적인 최소 Reader Contract와 정확 Patch. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const CONTRACT_VERSION = 1;
|
|
6
|
+
export const LATEST_CONTRACT_VERSION = 2;
|
|
7
|
+
export const CONTRACT_VERSIONS = Object.freeze([CONTRACT_VERSION, LATEST_CONTRACT_VERSION]);
|
|
8
|
+
|
|
9
|
+
/** @param {unknown} value @param {string} where */
|
|
10
|
+
function checkedString(value, where) {
|
|
11
|
+
if (typeof value !== "string" || !value || value !== value.trim()) {
|
|
12
|
+
throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
|
|
13
|
+
}
|
|
14
|
+
if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {unknown} value @param {string} where @param {boolean} [allowEmpty] */
|
|
19
|
+
function checkedStrings(value, where, allowEmpty = false) {
|
|
20
|
+
if (!Array.isArray(value) || (!allowEmpty && !value.length)) {
|
|
21
|
+
throw new Error(`${where} 는 ${allowEmpty ? "문자열 배열" : "비지 않은 문자열 배열"}이다`);
|
|
22
|
+
}
|
|
23
|
+
const found = value.map((item, index) => checkedString(item, `${where} ${index + 1}번째`));
|
|
24
|
+
if (new Set(found).size !== found.length) throw new Error(`${where} 에 같은 값이 두 번 있다`);
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 파이썬 문자열 순서와 같은 코드 포인트 순서. @param {string} left @param {string} right */
|
|
29
|
+
function compareText(left, right) {
|
|
30
|
+
const leftPoints = [...left].map((value) => /** @type {number} */ (value.codePointAt(0)));
|
|
31
|
+
const rightPoints = [...right].map((value) => /** @type {number} */ (value.codePointAt(0)));
|
|
32
|
+
const limit = Math.min(leftPoints.length, rightPoints.length);
|
|
33
|
+
for (let index = 0; index < limit; index += 1) {
|
|
34
|
+
if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
|
|
35
|
+
}
|
|
36
|
+
return leftPoints.length - rightPoints.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {string} text */
|
|
40
|
+
function digest(text) {
|
|
41
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class Contract {
|
|
45
|
+
/** @param {string} reader @param {string} goal @param {string[]} facts @param {number} [version] */
|
|
46
|
+
constructor(reader, goal, facts, version = CONTRACT_VERSION) {
|
|
47
|
+
if (!Number.isInteger(version) || version !== CONTRACT_VERSION) {
|
|
48
|
+
throw new Error(`reader contract version 은 ${CONTRACT_VERSION}이다: ${version}`);
|
|
49
|
+
}
|
|
50
|
+
this.reader = checkedString(reader, "reader");
|
|
51
|
+
this.goal = checkedString(goal, "goal");
|
|
52
|
+
this.facts = Object.freeze(checkedStrings(facts, "facts"));
|
|
53
|
+
this.version = version;
|
|
54
|
+
Object.freeze(this);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {unknown} raw */
|
|
58
|
+
static fromMapping(raw) {
|
|
59
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
|
|
60
|
+
const data = /** @type {Record<string, unknown>} */ (raw);
|
|
61
|
+
const expected = ["version", "reader", "goal", "facts"];
|
|
62
|
+
const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
|
|
63
|
+
const missing = expected.filter((key) => !(key in data)).sort();
|
|
64
|
+
if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
|
|
65
|
+
if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
|
|
66
|
+
return new Contract(
|
|
67
|
+
/** @type {string} */ (data.reader),
|
|
68
|
+
/** @type {string} */ (data.goal),
|
|
69
|
+
/** @type {string[]} */ (data.facts),
|
|
70
|
+
/** @type {number} */ (data.version),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get text() {
|
|
75
|
+
return [this.reader, this.goal, ...this.facts].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get digest() {
|
|
79
|
+
return digest(JSON.stringify({ facts: this.facts, goal: this.goal, reader: this.reader, version: this.version }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
asDict() {
|
|
83
|
+
return { version: this.version, reader: this.reader, goal: this.goal, facts: [...this.facts] };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class ProtectedSurface {
|
|
88
|
+
/** @param {string[]} [numbers] @param {string[]} [urls] @param {string[]} [code] @param {string[]} [links] */
|
|
89
|
+
constructor(numbers = [], urls = [], code = [], links = []) {
|
|
90
|
+
this.numbers = Object.freeze(checkedStrings(numbers, "surface.numbers", true).sort(compareText));
|
|
91
|
+
this.urls = Object.freeze(checkedStrings(urls, "surface.urls", true).sort(compareText));
|
|
92
|
+
this.code = Object.freeze(checkedStrings(code, "surface.code", true).sort(compareText));
|
|
93
|
+
this.links = Object.freeze(checkedStrings(links, "surface.links", true).sort(compareText));
|
|
94
|
+
Object.freeze(this);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {unknown} raw */
|
|
98
|
+
static fromMapping(raw) {
|
|
99
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("surface 는 JSON 객체다");
|
|
100
|
+
const data = /** @type {Record<string, unknown>} */ (raw);
|
|
101
|
+
const expected = ["numbers", "urls", "code", "links"];
|
|
102
|
+
const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
|
|
103
|
+
const missing = expected.filter((key) => !(key in data)).sort();
|
|
104
|
+
if (unknown.length) throw new Error(`surface 의 모르는 키: ${unknown.join(", ")}`);
|
|
105
|
+
if (missing.length) throw new Error(`surface 의 빠진 키: ${missing.join(", ")}`);
|
|
106
|
+
return new ProtectedSurface(
|
|
107
|
+
/** @type {string[]} */ (data.numbers),
|
|
108
|
+
/** @type {string[]} */ (data.urls),
|
|
109
|
+
/** @type {string[]} */ (data.code),
|
|
110
|
+
/** @type {string[]} */ (data.links),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get text() {
|
|
115
|
+
return [
|
|
116
|
+
...this.numbers,
|
|
117
|
+
...this.urls,
|
|
118
|
+
...this.code.map((value) => `\`${value}\``),
|
|
119
|
+
...this.links.map((value) => `[](${value})`),
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
asDict() {
|
|
124
|
+
return { numbers: [...this.numbers], urls: [...this.urls], code: [...this.code], links: [...this.links] };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class Outline {
|
|
129
|
+
/** @param {number} level @param {string[]} headings */
|
|
130
|
+
constructor(level, headings) {
|
|
131
|
+
if (!Number.isInteger(level) || level < 1 || level > 6) throw new Error(`outline.level 은 1~6 정수다: ${level}`);
|
|
132
|
+
this.level = level;
|
|
133
|
+
this.headings = Object.freeze(checkedStrings(headings, "outline.headings"));
|
|
134
|
+
Object.freeze(this);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {unknown} raw */
|
|
138
|
+
static fromMapping(raw) {
|
|
139
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("outline 은 JSON 객체다");
|
|
140
|
+
const data = /** @type {Record<string, unknown>} */ (raw);
|
|
141
|
+
const expected = ["level", "headings"];
|
|
142
|
+
const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
|
|
143
|
+
const missing = expected.filter((key) => !(key in data)).sort();
|
|
144
|
+
if (unknown.length) throw new Error(`outline 의 모르는 키: ${unknown.join(", ")}`);
|
|
145
|
+
if (missing.length) throw new Error(`outline 의 빠진 키: ${missing.join(", ")}`);
|
|
146
|
+
return new Outline(/** @type {number} */ (data.level), /** @type {string[]} */ (data.headings));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
asDict() {
|
|
150
|
+
return { level: this.level, headings: [...this.headings] };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class ContractV2 {
|
|
155
|
+
/** @param {string} reader @param {string} goal @param {string[]} facts @param {ProtectedSurface | Record<string, unknown>} surface @param {Outline | Record<string, unknown>} outline @param {number} [version] */
|
|
156
|
+
constructor(reader, goal, facts, surface, outline, version = LATEST_CONTRACT_VERSION, lockedFacts = [], editPolicy = {}) {
|
|
157
|
+
if (!Number.isInteger(version) || version !== LATEST_CONTRACT_VERSION) {
|
|
158
|
+
throw new Error(`reader contract version 은 ${LATEST_CONTRACT_VERSION}다: ${version}`);
|
|
159
|
+
}
|
|
160
|
+
this.reader = checkedString(reader, "reader");
|
|
161
|
+
this.goal = checkedString(goal, "goal");
|
|
162
|
+
this.facts = Object.freeze(checkedStrings(facts, "facts", true));
|
|
163
|
+
this.surface = surface instanceof ProtectedSurface ? surface : ProtectedSurface.fromMapping(surface);
|
|
164
|
+
this.outline = outline instanceof Outline ? outline : Outline.fromMapping(outline);
|
|
165
|
+
this.lockedFacts = Object.freeze(checkedStrings(lockedFacts, "lockedFacts", true));
|
|
166
|
+
if (this.lockedFacts.some((fact) => !this.facts.includes(fact))) throw new Error("lockedFacts must select approved facts");
|
|
167
|
+
if (!editPolicy || typeof editPolicy !== "object" || Array.isArray(editPolicy)) throw new Error("editPolicy must be an object");
|
|
168
|
+
this.editPolicy = Object.freeze(Object.fromEntries(Object.entries(editPolicy).sort(([a], [b]) => compareText(a, b)).map(([rule, limits]) => {
|
|
169
|
+
if (!rule || rule !== rule.trim()) throw new Error("editPolicy requires rule names");
|
|
170
|
+
if (!limits || typeof limits !== "object" || Array.isArray(limits) || Object.keys(limits).sort().join(",") !== "maxChars,maxLines") {
|
|
171
|
+
throw new Error("editPolicy requires maxChars and maxLines");
|
|
172
|
+
}
|
|
173
|
+
if (Object.values(limits).some((n) => !Number.isInteger(n) || n < 1)) throw new Error("editPolicy limits must be positive integers");
|
|
174
|
+
return [rule, Object.freeze({maxChars: limits.maxChars, maxLines: limits.maxLines})];
|
|
175
|
+
})));
|
|
176
|
+
this.version = version;
|
|
177
|
+
Object.freeze(this);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @param {unknown} raw */
|
|
181
|
+
static fromMapping(raw) {
|
|
182
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
|
|
183
|
+
const data = /** @type {Record<string, unknown>} */ (raw);
|
|
184
|
+
const expected = ["version", "reader", "goal", "facts", "surface", "outline"];
|
|
185
|
+
const unknown = Object.keys(data).filter((key) => !expected.includes(key) && !["lockedFacts", "editPolicy"].includes(key)).sort();
|
|
186
|
+
const missing = expected.filter((key) => !(key in data)).sort();
|
|
187
|
+
if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
|
|
188
|
+
if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
|
|
189
|
+
return new ContractV2(
|
|
190
|
+
/** @type {string} */ (data.reader),
|
|
191
|
+
/** @type {string} */ (data.goal),
|
|
192
|
+
/** @type {string[]} */ (data.facts),
|
|
193
|
+
/** @type {Record<string, unknown>} */ (data.surface),
|
|
194
|
+
/** @type {Record<string, unknown>} */ (data.outline),
|
|
195
|
+
/** @type {number} */ (data.version),
|
|
196
|
+
Object.hasOwn(data, "lockedFacts") ? data.lockedFacts : [],
|
|
197
|
+
Object.hasOwn(data, "editPolicy") ? data.editPolicy : {},
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
get text() {
|
|
202
|
+
return [this.reader, this.goal, ...this.facts, this.surface.text].filter(Boolean).join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get digest() {
|
|
206
|
+
const encoded = {
|
|
207
|
+
...(Object.keys(this.editPolicy).length ? {editPolicy: this.editPolicy} : {}),
|
|
208
|
+
facts: this.facts,
|
|
209
|
+
goal: this.goal,
|
|
210
|
+
...(this.lockedFacts.length ? {lockedFacts: this.lockedFacts} : {}),
|
|
211
|
+
outline: { headings: this.outline.headings, level: this.outline.level },
|
|
212
|
+
reader: this.reader,
|
|
213
|
+
surface: {
|
|
214
|
+
code: this.surface.code,
|
|
215
|
+
links: this.surface.links,
|
|
216
|
+
numbers: this.surface.numbers,
|
|
217
|
+
urls: this.surface.urls,
|
|
218
|
+
},
|
|
219
|
+
version: this.version,
|
|
220
|
+
};
|
|
221
|
+
return digest(JSON.stringify(encoded));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
asDict() {
|
|
225
|
+
return {
|
|
226
|
+
version: this.version,
|
|
227
|
+
reader: this.reader,
|
|
228
|
+
goal: this.goal,
|
|
229
|
+
facts: [...this.facts],
|
|
230
|
+
surface: this.surface.asDict(),
|
|
231
|
+
outline: this.outline.asDict(),
|
|
232
|
+
...(this.lockedFacts.length ? {lockedFacts: [...this.lockedFacts]} : {}),
|
|
233
|
+
...(Object.keys(this.editPolicy).length ? {editPolicy: this.editPolicy} : {}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @param {unknown} raw */
|
|
239
|
+
export function parseContract(raw) {
|
|
240
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
|
|
241
|
+
const version = /** @type {Record<string, unknown>} */ (raw).version;
|
|
242
|
+
if (version === CONTRACT_VERSION) return Contract.fromMapping(raw);
|
|
243
|
+
if (version === LATEST_CONTRACT_VERSION) return ContractV2.fromMapping(raw);
|
|
244
|
+
throw new Error(`reader contract version 은 ${CONTRACT_VERSIONS.join(",")} 가운데 하나다: ${version}`);
|
|
245
|
+
}
|
package/src/config/settings.js
CHANGED
|
@@ -14,22 +14,51 @@ import { projectPatches } from "../data/patches.js";
|
|
|
14
14
|
*/
|
|
15
15
|
export const PRESETS = {
|
|
16
16
|
blog: [],
|
|
17
|
-
report: ["noQuestion", "
|
|
17
|
+
report: ["noQuestion", "firstResultDistance", "introImage", "moreLater", "numberOrphan"],
|
|
18
18
|
docs: [
|
|
19
19
|
"noQuestion",
|
|
20
|
-
"sectionResult",
|
|
21
20
|
"firstResultDistance",
|
|
22
21
|
"introImage",
|
|
23
22
|
"moreLater",
|
|
24
23
|
"draftHistory",
|
|
25
24
|
"blockUnread",
|
|
26
25
|
"numberOrphan",
|
|
26
|
+
"duplicateBlock",
|
|
27
|
+
"headingUniform",
|
|
27
28
|
],
|
|
28
29
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// 서사 글에서만 끄는 것. 설명글의 전제 (독자가 문장 사이 이유를 따라간다, 말끝을 맺는다) 가 장면과
|
|
31
|
+
// 대사에는 성립하지 않는다. 파이썬 settings.py 의 NARRATIVE 와 같다.
|
|
32
|
+
// 참고 문서와 자습서는 명령 전후 상태와 완결된 예제를 보이려 같은 블록을 다시 둔다. 파이썬의 REFERENCE 와 같다.
|
|
33
|
+
const REFERENCE = ["duplicateBlock", "headingUniform"];
|
|
34
|
+
// 백과 표제어는 첫 절 앞의 머리말이 길게 정의와 요약을 담는 것이 관례다. 파이썬의 ENCYCLOPEDIC 과 같다.
|
|
35
|
+
const ENCYCLOPEDIC = ["introLong"];
|
|
36
|
+
const NARRATIVE = ["factListParagraph"];
|
|
37
|
+
PRESETS.guide = [...PRESETS.blog, ...REFERENCE];
|
|
38
|
+
PRESETS.essay = [...PRESETS.report, ...NARRATIVE];
|
|
39
|
+
PRESETS.fiction = [...PRESETS.report, ...NARRATIVE];
|
|
40
|
+
PRESETS.encyclopedia = [...PRESETS.docs, ...ENCYCLOPEDIC];
|
|
41
|
+
// 대화 답변에서 끄는 것. 글의 짜임을 재는 규칙은 글 한 편을 전제한다. 파이썬 settings.py 의 CONVERSATION 과 같다.
|
|
42
|
+
const CONVERSATION = [
|
|
43
|
+
"countMismatch",
|
|
44
|
+
"duplicateBlock",
|
|
45
|
+
"factListParagraph",
|
|
46
|
+
"firstResultDistance",
|
|
47
|
+
"headingSentence",
|
|
48
|
+
"headingSkip",
|
|
49
|
+
"headingUniform",
|
|
50
|
+
"inputFileSource",
|
|
51
|
+
"installImport",
|
|
52
|
+
"introLong",
|
|
53
|
+
"keywordMissing",
|
|
54
|
+
"noQuestion",
|
|
55
|
+
"outsideProfile",
|
|
56
|
+
"paraFragment",
|
|
57
|
+
"platformApi",
|
|
58
|
+
"promiseRecall",
|
|
59
|
+
"sectionNoProse",
|
|
60
|
+
];
|
|
61
|
+
PRESETS.chat = CONVERSATION;
|
|
33
62
|
|
|
34
63
|
/** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
|
|
35
64
|
export const PROFILE_OF = {
|
|
@@ -40,6 +69,7 @@ export const PROFILE_OF = {
|
|
|
40
69
|
essay: "essay",
|
|
41
70
|
fiction: "fiction",
|
|
42
71
|
encyclopedia: "encyclopedia",
|
|
72
|
+
chat: null,
|
|
43
73
|
};
|
|
44
74
|
|
|
45
75
|
export const PRESET_NAMES = Object.keys(PRESETS);
|
|
@@ -49,6 +79,7 @@ export const DEFAULT_PRESET = PRESET_NAMES[0];
|
|
|
49
79
|
/**
|
|
50
80
|
* @typedef {object} Config
|
|
51
81
|
* @property {string} preset 글의 종류. PRESETS 가 정한 규칙을 처음부터 끈다
|
|
82
|
+
* @property {string[]} enforceStyle
|
|
52
83
|
* @property {Set<string>} disable 끌 규칙 이름
|
|
53
84
|
* @property {string | null} keywordField
|
|
54
85
|
* @property {string[]} introFields
|
|
@@ -73,13 +104,11 @@ export const DEFAULT_PRESET = PRESET_NAMES[0];
|
|
|
73
104
|
* @property {number} endingRun
|
|
74
105
|
* @property {number} factListMinSentences
|
|
75
106
|
* @property {number} factListMaxMeanLength
|
|
76
|
-
* @property {number}
|
|
107
|
+
* @property {number} flowValleyMinSentences
|
|
77
108
|
* @property {number} longSentenceMax
|
|
78
109
|
* @property {number} duplicateBlockRatio
|
|
79
110
|
* @property {number} firstResultMaxParagraphs
|
|
80
|
-
* @property {number} sectionResultMinParagraphs
|
|
81
111
|
* @property {number} introMaxImages
|
|
82
|
-
* @property {number} headingQuestionRatio
|
|
83
112
|
* @property {number} moreLaterMaxChars
|
|
84
113
|
* @property {number} tableOddCellMinRows
|
|
85
114
|
* @property {number} registerMinShare
|
|
@@ -90,6 +119,7 @@ export function defaultConfig() {
|
|
|
90
119
|
return {
|
|
91
120
|
preset: DEFAULT_PRESET,
|
|
92
121
|
disable: new Set(),
|
|
122
|
+
enforceStyle: [],
|
|
93
123
|
keywordField: null,
|
|
94
124
|
introFields: [],
|
|
95
125
|
endingFields: [],
|
|
@@ -113,13 +143,11 @@ export function defaultConfig() {
|
|
|
113
143
|
endingRun: 4,
|
|
114
144
|
factListMinSentences: 3,
|
|
115
145
|
factListMaxMeanLength: 8.0,
|
|
116
|
-
|
|
146
|
+
flowValleyMinSentences: 2,
|
|
117
147
|
longSentenceMax: 30,
|
|
118
148
|
duplicateBlockRatio: 0.9,
|
|
119
149
|
firstResultMaxParagraphs: 4,
|
|
120
|
-
sectionResultMinParagraphs: 3,
|
|
121
150
|
introMaxImages: 1,
|
|
122
|
-
headingQuestionRatio: 0.5,
|
|
123
151
|
moreLaterMaxChars: 150,
|
|
124
152
|
tableOddCellMinRows: 4,
|
|
125
153
|
// 기준 말뭉치에서 일관된 에세이 하위 5%는 0.7576, 실제 혼합 기사는 0.625였다.
|
|
@@ -129,12 +157,12 @@ export function defaultConfig() {
|
|
|
129
157
|
|
|
130
158
|
/** @param {Config} config @param {string} ruleName */
|
|
131
159
|
export function enabled(config, ruleName) {
|
|
132
|
-
return !config.disable.has(ruleName) && !PRESETS[config.preset].includes(ruleName);
|
|
160
|
+
return !config.disable.has(ruleName) && (!PRESETS[config.preset].includes(ruleName) || config.enforceStyle.includes(ruleName));
|
|
133
161
|
}
|
|
134
162
|
|
|
135
163
|
/** 지금 꺼져 있는 규칙 이름. 프리셋이 끈 것과 disable 이 끈 것을 합친다. @param {Config} config */
|
|
136
164
|
export function offRules(config) {
|
|
137
|
-
return [...new Set([...PRESETS[config.preset], ...config.disable])].sort();
|
|
165
|
+
return [...new Set([...PRESETS[config.preset].filter((name) => !config.enforceStyle.includes(name)), ...config.disable])].sort();
|
|
138
166
|
}
|
|
139
167
|
|
|
140
168
|
/** @param {Record<string, unknown>} data @returns {Config} */
|
|
@@ -151,6 +179,11 @@ export function configFromMapping(data) {
|
|
|
151
179
|
throw new Error(`preset 은 ${PRESET_NAMES.join(", ")} 가운데 하나다: ${JSON.stringify(value)}`);
|
|
152
180
|
}
|
|
153
181
|
config.preset = /** @type {string} */ (value);
|
|
182
|
+
} else if (key === "enforceStyle") {
|
|
183
|
+
if (!Array.isArray(value) || value.some((name) => !["noQuestion", "nounPile"].includes(name))) {
|
|
184
|
+
throw new Error("enforceStyle 은 noQuestion, nounPile의 배열이다");
|
|
185
|
+
}
|
|
186
|
+
config.enforceStyle = [...value];
|
|
154
187
|
} else if (key === "dictionary") {
|
|
155
188
|
config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
|
|
156
189
|
} else if (key === "exemplars") {
|
package/src/data/profiles.js
CHANGED
|
@@ -36,6 +36,10 @@ export const TYPE_LABELS = {
|
|
|
36
36
|
* @property {number} sentences
|
|
37
37
|
* @property {number} paragraphs
|
|
38
38
|
* @property {Map<string, Histogram>} sentence
|
|
39
|
+
* @property {Map<string, Histogram>} paragraph
|
|
40
|
+
* @property {Histogram | null} endingRuns
|
|
41
|
+
* @property {Record<string, number>} endingTransitions
|
|
42
|
+
* @property {Record<string, Record<string, number>>} rates
|
|
39
43
|
* @property {string} label
|
|
40
44
|
*/
|
|
41
45
|
|
|
@@ -62,6 +66,10 @@ function profileFromDict(kind, data) {
|
|
|
62
66
|
for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.sentence))) {
|
|
63
67
|
sentence.set(name, histogramFromDict(value));
|
|
64
68
|
}
|
|
69
|
+
const paragraph = new Map();
|
|
70
|
+
for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.paragraph))) {
|
|
71
|
+
paragraph.set(name, histogramFromDict(value));
|
|
72
|
+
}
|
|
65
73
|
const documents = Number(data.documents);
|
|
66
74
|
return {
|
|
67
75
|
kind,
|
|
@@ -69,6 +77,10 @@ function profileFromDict(kind, data) {
|
|
|
69
77
|
sentences: Number(data.sentences),
|
|
70
78
|
paragraphs: Number(data.paragraphs),
|
|
71
79
|
sentence,
|
|
80
|
+
paragraph,
|
|
81
|
+
endingRuns: data.endingRuns ? histogramFromDict(/** @type {Record<string, unknown>} */ (data.endingRuns)) : null,
|
|
82
|
+
endingTransitions: { .../** @type {Record<string, number>} */ (data.endingTransitions ?? {}) },
|
|
83
|
+
rates: { .../** @type {Record<string, Record<string, number>>} */ (data.rates ?? {}) },
|
|
72
84
|
label: TYPE_LABELS[kind] ?? `참조 글 ${documents}편`,
|
|
73
85
|
};
|
|
74
86
|
}
|
package/src/fingerprint/build.js
CHANGED
|
@@ -98,20 +98,20 @@ import { overlap, topicsOf, unionOf } from "./topics.js";
|
|
|
98
98
|
*/
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
* 문장 안의 인용 구간.
|
|
101
|
+
* 문장 안의 인용 구간. 블록 좌표의 구간을 문장 좌표로 옮긴다. 따옴표 쌍도 블록에서 재서 넘겨받는다.
|
|
102
|
+
* 문장 안에서 재면 문장 분리가 따옴표 안에서 잘릴 때 쌍이 안 잡혀 인용 예외가 풀린다. 파이썬과 같다.
|
|
102
103
|
* @param {import("../analysis/splitSentences.js").Sentence} sentence
|
|
103
|
-
* @param {[number, number][]}
|
|
104
|
+
* @param {[number, number][]} blockSpans
|
|
104
105
|
* @returns {[number, number][]}
|
|
105
106
|
*/
|
|
106
|
-
function quotedIn(sentence,
|
|
107
|
+
function quotedIn(sentence, blockSpans) {
|
|
107
108
|
/** @type {[number, number][]} */
|
|
108
109
|
const spans = [];
|
|
109
|
-
for (const [start, end] of
|
|
110
|
+
for (const [start, end] of blockSpans) {
|
|
110
111
|
const lo = Math.max(start, sentence.start);
|
|
111
112
|
const hi = Math.min(end, sentence.end);
|
|
112
113
|
if (lo < hi) spans.push([lo - sentence.start, hi - sentence.start]);
|
|
113
114
|
}
|
|
114
|
-
spans.push(...markers.quoteSpans(sentence.text));
|
|
115
115
|
const unique = new Map(spans.map((span) => [`${span[0]},${span[1]}`, span]));
|
|
116
116
|
return [...unique.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
117
117
|
}
|
|
@@ -219,12 +219,15 @@ function buildSection(build, section, sectionIndex) {
|
|
|
219
219
|
continue;
|
|
220
220
|
}
|
|
221
221
|
const text = plainText(block.text);
|
|
222
|
-
|
|
222
|
+
// 인라인 코드와 따옴표 쌍을 둘 다 블록 좌표에서 잰다. 문장 분리가 인용 안에서 잘려도 쌍이 남는다.
|
|
223
|
+
const blockSpans = [...codeSpans(block.text, text), ...markers.quoteSpans(text)].sort(
|
|
224
|
+
(a, b) => a[0] - b[0] || a[1] - b[1],
|
|
225
|
+
);
|
|
223
226
|
/** @type {SentencePrint[]} */
|
|
224
227
|
const blockSentences = [];
|
|
225
228
|
for (const sentence of splitSentences(text)) {
|
|
226
229
|
const line = block.startLine + countIn(text, "\n", sentence.start);
|
|
227
|
-
const quoted = quotedIn(sentence,
|
|
230
|
+
const quoted = quotedIn(sentence, blockSpans);
|
|
228
231
|
const made = makeSentencePrint(build, sentence.text, line, block, sectionIndex, quoted);
|
|
229
232
|
// 만든 자리에서 바로 쌓는다. 다음 문장의 index 가 이 길이에서 나오므로 미루면 셈이 어긋난다.
|
|
230
233
|
sentences.push(made);
|
|
@@ -13,7 +13,7 @@ const READS = new RegExp(
|
|
|
13
13
|
const OPENS = new RegExp("\\bopen" + QUOTED + "\\s*(?:,\\s*(?:mode\\s*=\\s*)?[\"']([^\"']*)[\"'])?", "g");
|
|
14
14
|
const WRITES = new RegExp(
|
|
15
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,
|
|
16
|
+
"write_text|write_bytes|savefig|save|imwrite|savetxt|dump|ExcelWriter)" + QUOTED,
|
|
17
17
|
"g",
|
|
18
18
|
);
|
|
19
19
|
const SHELL_WRITES = /(?:>>?|-o|-O|--output|Out-File)\s*["']?([\w./\\-]+\.[A-Za-z0-9]+)/g;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Reader Contract 검사와 이유가 붙은 국소 Patch 검증. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import { Patch } from "../config/patch.js";
|
|
6
|
+
import { Contract, ContractV2, Outline, ProtectedSurface, parseContract } from "../config/readerContract.js";
|
|
7
|
+
import { defaultConfig } from "../config/settings.js";
|
|
8
|
+
import { headingsOf } from "../document/model.js";
|
|
9
|
+
import { parseMarkdown } from "../document/parseMarkdown.js";
|
|
10
|
+
import { buildFingerprint } from "../fingerprint/build.js";
|
|
11
|
+
import { runAll } from "../rules/registry.js";
|
|
12
|
+
import { editIssues } from "./editLocality.js";
|
|
13
|
+
import { compareOutline, summarizeDocument } from "./outline.js";
|
|
14
|
+
import { CheckResult, PatchResult } from "./receipt.js";
|
|
15
|
+
import { compareText, factLines, protectedSurface, surfaceDiff, surfaceViolationCount } from "./surface.js";
|
|
16
|
+
|
|
17
|
+
/** @param {string} text */
|
|
18
|
+
function digest(text) {
|
|
19
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 원문의 보호 표면을 모두 덮는 version 1 Contract 초안을 만든다. @param {string} text @param {string} reader @param {string} goal */
|
|
23
|
+
export function contractFromText(text, reader, goal) {
|
|
24
|
+
const contract = new Contract(reader, goal, factLines(text));
|
|
25
|
+
const result = surfaceDiff(contract.text, text);
|
|
26
|
+
const missing = [];
|
|
27
|
+
for (const kind of ["missingNumbers", "missingUrls", "missingCode", "missingLinks"]) {
|
|
28
|
+
for (const value of result[/** @type {keyof typeof result} */ (kind)]) missing.push(`${kind}=${value}`);
|
|
29
|
+
}
|
|
30
|
+
if (missing.length) throw new Error(`reader 또는 goal이 원문에 없는 보호 원자를 넣었다: ${missing.join(", ")}`);
|
|
31
|
+
if (surfaceViolationCount(result)) {
|
|
32
|
+
const issues = Object.entries(result).flatMap(([kind, values]) => values.map((value) => `${kind}=${value}`));
|
|
33
|
+
throw new Error(`계약 초안이 원문의 보호 표면을 모두 담지 못했다: ${issues.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
return contract;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 원문의 자동 표면과 한 수준의 제목을 분리한 version 2 Contract 초안을 만든다. @param {string} text @param {string} reader @param {string} goal @param {number} [outlineLevel] @param {string[]} [facts] */
|
|
39
|
+
export function contractFromTextV2(text, reader, goal, outlineLevel = 2, facts = []) {
|
|
40
|
+
const doc = parseMarkdown(text);
|
|
41
|
+
const headings = headingsOf(doc, outlineLevel).map((heading) => heading.text);
|
|
42
|
+
const surface = protectedSurface(text);
|
|
43
|
+
const contract = new ContractV2(
|
|
44
|
+
reader,
|
|
45
|
+
goal,
|
|
46
|
+
facts,
|
|
47
|
+
new ProtectedSurface(surface.numbers, surface.urls, surface.code, surface.links),
|
|
48
|
+
new Outline(outlineLevel, headings),
|
|
49
|
+
);
|
|
50
|
+
const result = surfaceDiff(contract.text, text);
|
|
51
|
+
if (surfaceViolationCount(result)) {
|
|
52
|
+
const issues = Object.entries(result).flatMap(([kind, values]) => values.map((value) => `${kind}=${value}`));
|
|
53
|
+
throw new Error(`reader, goal 또는 facts가 원문에 없는 보호 원자를 넣었다: ${issues.join(", ")}`);
|
|
54
|
+
}
|
|
55
|
+
return contract;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} text @param {Contract | ContractV2 | Record<string, unknown>} rawContract @param {import("../config/settings.js").Config} [config] @param {string | null} [path] */
|
|
59
|
+
export function check(text, rawContract, config = defaultConfig(), path = null) {
|
|
60
|
+
const contract = rawContract instanceof Contract || rawContract instanceof ContractV2 ? rawContract : parseContract(rawContract);
|
|
61
|
+
const doc = buildFingerprint(parseMarkdown(text, path), config);
|
|
62
|
+
const findings = runAll(doc, config);
|
|
63
|
+
const outline = contract instanceof ContractV2 ? compareOutline(contract.outline, doc) : null;
|
|
64
|
+
const document = contract instanceof ContractV2 ? summarizeDocument(doc) : null;
|
|
65
|
+
return new CheckResult(contract.digest, digest(text), surfaceDiff(contract.text, text), findings, contract.version, outline, document, contract instanceof ContractV2 ? contract.lockedFacts.filter((fact) => !text.includes(fact)) : []);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {CheckResult} result */
|
|
69
|
+
function contractIssues(result) {
|
|
70
|
+
const found = new Set();
|
|
71
|
+
for (const [kind, values] of Object.entries(result.surface)) {
|
|
72
|
+
for (const value of values) found.add(`${kind}\u0000${value}`);
|
|
73
|
+
}
|
|
74
|
+
if (result.outline) {
|
|
75
|
+
for (const mismatch of result.outline.mismatches) {
|
|
76
|
+
found.add(`outline\u0000${mismatch.position}:${mismatch.expected ?? ""}:${mismatch.actual ?? ""}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const fact of result.missingFacts) found.add(`lockedFacts\u0000${fact}`);
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {CheckResult} result @param {string} reason */
|
|
84
|
+
function reasonCount(result, reason) {
|
|
85
|
+
if (reason === "lockedFacts") return result.missingFacts.length;
|
|
86
|
+
if (reason in result.surface) return result.surface[/** @type {keyof typeof result.surface} */ (reason)].length;
|
|
87
|
+
if (reason === "outline" && result.outline) return result.outline.mismatches.length;
|
|
88
|
+
return result.findings.filter((finding) => finding.rule === reason).length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {import("../rules/finding.js").Finding} finding */
|
|
92
|
+
function errorSignature(finding) {
|
|
93
|
+
return `${finding.rule}\u0000${finding.quote}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {CheckResult} before @param {CheckResult} after */
|
|
97
|
+
function addedErrors(before, after) {
|
|
98
|
+
const remaining = new Map();
|
|
99
|
+
for (const finding of before.findings) {
|
|
100
|
+
if (finding.severity !== "error") continue;
|
|
101
|
+
const signature = errorSignature(finding);
|
|
102
|
+
remaining.set(signature, (remaining.get(signature) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
return after.findings.filter((finding) => {
|
|
105
|
+
if (finding.severity !== "error") return false;
|
|
106
|
+
const signature = errorSignature(finding);
|
|
107
|
+
const count = remaining.get(signature) ?? 0;
|
|
108
|
+
if (!count) return true;
|
|
109
|
+
remaining.set(signature, count - 1);
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 파이썬 str.count와 같은 겹치지 않는 정확 출현 수. @param {string} text @param {string} fragment */
|
|
115
|
+
function exactCount(text, fragment) {
|
|
116
|
+
let count = 0;
|
|
117
|
+
let cursor = 0;
|
|
118
|
+
while (true) {
|
|
119
|
+
const at = text.indexOf(fragment, cursor);
|
|
120
|
+
if (at < 0) return count;
|
|
121
|
+
count += 1;
|
|
122
|
+
cursor = at + fragment.length;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} text @param {Patch | Record<string, unknown>} rawPatch @param {Contract | ContractV2 | Record<string, unknown>} rawContract @param {import("../config/settings.js").Config} [config] @param {string | null} [path] */
|
|
127
|
+
export function verifyPatch(text, rawPatch, rawContract, config = defaultConfig(), path = null) {
|
|
128
|
+
const patch = rawPatch instanceof Patch ? rawPatch : Patch.fromMapping(rawPatch);
|
|
129
|
+
const contract = rawContract instanceof Contract || rawContract instanceof ContractV2 ? rawContract : parseContract(rawContract);
|
|
130
|
+
const beforeResult = check(text, contract, config, path);
|
|
131
|
+
const matchCount = exactCount(text, patch.before);
|
|
132
|
+
const reasonBefore = reasonCount(beforeResult, patch.reason);
|
|
133
|
+
if (matchCount !== 1) {
|
|
134
|
+
return new PatchResult({
|
|
135
|
+
contractSha256: contract.digest,
|
|
136
|
+
sourceSha256: beforeResult.draftSha256,
|
|
137
|
+
patchSha256: patch.digest,
|
|
138
|
+
resultSha256: null,
|
|
139
|
+
reason: patch.reason,
|
|
140
|
+
matchCount,
|
|
141
|
+
reasonBefore,
|
|
142
|
+
reasonAfter: null,
|
|
143
|
+
newContractIssues: [],
|
|
144
|
+
newErrors: [],
|
|
145
|
+
resultText: null,
|
|
146
|
+
contractVersion: contract.version,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const resultText = text.replace(patch.before, patch.after);
|
|
150
|
+
const afterResult = check(resultText, contract, config, path);
|
|
151
|
+
const beforeIssues = contractIssues(beforeResult);
|
|
152
|
+
const newContractIssues = [...contractIssues(afterResult)]
|
|
153
|
+
.filter((issue) => !beforeIssues.has(issue))
|
|
154
|
+
.map((issue) => /** @type {[string, string]} */ (issue.split("\u0000", 2)))
|
|
155
|
+
.sort((left, right) => compareText(left[0], right[0]) || compareText(left[1], right[1]));
|
|
156
|
+
if (contract instanceof ContractV2) {
|
|
157
|
+
newContractIssues.push(...editIssues(text, patch, beforeResult.findings, contract.editPolicy));
|
|
158
|
+
newContractIssues.sort((a, b) => compareText(a[0], b[0]) || compareText(a[1], b[1]));
|
|
159
|
+
}
|
|
160
|
+
return new PatchResult({
|
|
161
|
+
contractSha256: contract.digest,
|
|
162
|
+
sourceSha256: beforeResult.draftSha256,
|
|
163
|
+
patchSha256: patch.digest,
|
|
164
|
+
resultSha256: afterResult.draftSha256,
|
|
165
|
+
reason: patch.reason,
|
|
166
|
+
matchCount,
|
|
167
|
+
reasonBefore,
|
|
168
|
+
reasonAfter: reasonCount(afterResult, patch.reason),
|
|
169
|
+
newContractIssues,
|
|
170
|
+
newErrors: addedErrors(beforeResult, afterResult),
|
|
171
|
+
resultText,
|
|
172
|
+
contractVersion: contract.version,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 선택한 규칙의 Finding 줄과 원문 변경 창을 대조한다. */
|
|
3
|
+
export function editIssues(text, patch, findings, policy) {
|
|
4
|
+
if (!Object.keys(policy).length) return [];
|
|
5
|
+
const limits = policy[patch.reason];
|
|
6
|
+
if (!limits) return [["editPolicy", "reason has no edit budget"]];
|
|
7
|
+
const before = [...patch.before], after = [...patch.after];
|
|
8
|
+
let prefix = 0, suffix = 0;
|
|
9
|
+
while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix]) prefix++;
|
|
10
|
+
while (suffix < Math.min(before.length, after.length) - prefix && before.at(-1 - suffix) === after.at(-1 - suffix)) suffix++;
|
|
11
|
+
const changedBefore = before.slice(prefix, before.length - suffix).join("");
|
|
12
|
+
const changedAfter = after.slice(prefix, after.length - suffix).join("");
|
|
13
|
+
const start = text.indexOf(patch.before) + before.slice(0, prefix).join("").length;
|
|
14
|
+
const firstLine = text.slice(0, start).split("\n").length;
|
|
15
|
+
const lastLine = firstLine + changedBefore.split("\n").length - 1;
|
|
16
|
+
const issues = [];
|
|
17
|
+
if (!findings.some((f) => f.rule === patch.reason && firstLine <= f.line && f.line <= lastLine)) {
|
|
18
|
+
issues.push(["editPolicy", "changed range does not contain the Finding line"]);
|
|
19
|
+
}
|
|
20
|
+
if (Math.max([...changedBefore].length, [...changedAfter].length) > limits.maxChars) {
|
|
21
|
+
issues.push(["editPolicy", "changed range exceeds maxChars"]);
|
|
22
|
+
}
|
|
23
|
+
if (Math.max(changedBefore.split("\n").length, changedAfter.split("\n").length) > limits.maxLines) {
|
|
24
|
+
issues.push(["editPolicy", "changed range exceeds maxLines"]);
|
|
25
|
+
}
|
|
26
|
+
return issues;
|
|
27
|
+
}
|