bizdays-toolkit 0.4.0 → 0.6.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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * 規則の診断(diagnoseCalendar・diagnoseSchedule)。
3
+ *
4
+ * 設定を作るときと同じ検証(IssueCollector)で誤りと知らないキーを集め、誤りがなければ
5
+ * 規則どうしの関係を調べる。例外は投げず、見つけた問題を配列で返す。
6
+ *
7
+ * - error … createCalendar・createSchedule で例外になる誤り(code は例外と同じ)
8
+ * - warning … 作れるが、意図と違う可能性が高いもの
9
+ * unknown-option … 知らないキー(作成時は無視される)。近い既知のキーがあれば message に候補を挙げる
10
+ * shadowed … 同じ種類の先の規則に完全に覆われ、決して使われない(relatedPath に先の規則)
11
+ * never-matches … 当てはまる日がない繰り返しの規則(2 月 30 日など)
12
+ * out-of-range … 個別の日付が祝日の提供元の対応年の外にある(relatedPath に提供元)
13
+ * no-business-day … 営業日が 1 日もない設定(営業日カレンダーのみ)
14
+ * - info … redundant: 取り除いても結果が変わらない規則
15
+ *
16
+ * 判定の順番(個別の日付 → 毎年の月日 → 繰り返し(over)→ 祝日 → 繰り返し(under・振替先)→ 曜日)は
17
+ * 固定なので、shadowed は同じ種類の規則どうしに限って調べる。種類をまたいで使われない規則は
18
+ * redundant(取り除いても結果が変わらない)として見つかる。
19
+ */
20
+ import { daysInMonth, fromDayNumber, parseDate, toDayNumber } from "./date.js";
21
+ import { compileCalendarOptions } from "./calendar.js";
22
+ import { IssueCollector } from "./errors.js";
23
+ import { compileScheduleOptions, createHolidayLookup, createResolver, matchAnnual, matchRecurring, } from "./schedule.js";
24
+ // ------------------------------------------------------------
25
+ // 知らないキーの候補
26
+ // ------------------------------------------------------------
27
+ /** 2 つの文字列の編集距離(挿入・削除・置換・隣り合う 2 文字の入れ替えを 1 と数える) */
28
+ function editDistance(a, b) {
29
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
30
+ for (let i = 1; i <= a.length; i++) {
31
+ for (let j = 1; j <= b.length; j++) {
32
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
33
+ let v = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
34
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
35
+ v = Math.min(v, d[i - 2][j - 2] + 1);
36
+ }
37
+ d[i][j] = v;
38
+ }
39
+ }
40
+ return d[a.length][b.length];
41
+ }
42
+ /**
43
+ * 知らないキーに近い既知のキーを返す(なければ undefined)。
44
+ * 大文字・小文字の違い、打ち間違い(編集距離が短いキーで 1・長いキーで 2〜3)、
45
+ * 既知のキーの先頭部分(4 文字以上)の順に探す
46
+ */
47
+ function closestKey(key, known) {
48
+ const lower = key.toLowerCase();
49
+ const sameCase = known.find((k) => k.toLowerCase() === lower);
50
+ if (sameCase !== undefined)
51
+ return sameCase;
52
+ const limit = Math.max(1, Math.floor(key.length / 4));
53
+ let best;
54
+ let bestDistance = limit + 1;
55
+ for (const k of known) {
56
+ const distance = editDistance(lower, k.toLowerCase());
57
+ if (distance < bestDistance) {
58
+ best = k;
59
+ bestDistance = distance;
60
+ }
61
+ }
62
+ if (best !== undefined)
63
+ return best;
64
+ if (key.length >= 4)
65
+ return known.find((k) => k.toLowerCase().startsWith(lower));
66
+ return undefined;
67
+ }
68
+ // ------------------------------------------------------------
69
+ // 調べる期間
70
+ // ------------------------------------------------------------
71
+ /** 繰り返しの規則の当てはまり方は、年の種類(1 月 1 日の曜日 × うるう年か)の 14 通りで決まる。28 年でそろう */
72
+ const PATTERN_FROM = toDayNumber("2001-01-01");
73
+ const PATTERN_TO = toDayNumber("2028-12-31");
74
+ /** 祝日の提供元があるときに調べる年の上限(長すぎる対応年は 1900〜2099 年に絞る) */
75
+ const MAX_WINDOW_YEARS = 200;
76
+ /** 個別の日付の期間で、取り除いた結果と比べる最大日数 */
77
+ const MAX_DATE_RULE_DAYS = 366 * MAX_WINDOW_YEARS;
78
+ /** 長すぎる年の範囲を 1900〜2099 年(重ならなければ先頭の 200 年)に絞る */
79
+ function clipYears(from, to) {
80
+ if (to - from + 1 <= MAX_WINDOW_YEARS)
81
+ return [from, to];
82
+ const a = Math.max(from, 1900);
83
+ const b = Math.min(to, 2099);
84
+ return a <= b ? [a, b] : [from, from + MAX_WINDOW_YEARS - 1];
85
+ }
86
+ /** 毎年の月日・繰り返しの規則を調べる期間(祝日が年ごとに変わるので、提供元の対応年を使う) */
87
+ function scanWindow(source) {
88
+ if (source.providers.length === 0) {
89
+ return { from: PATTERN_FROM, to: PATTERN_TO, label: "2001-2028" };
90
+ }
91
+ const ranges = source.providers.map((p) => p.supportedYears);
92
+ let from;
93
+ let to;
94
+ if (source.outOfRange === "ignore") {
95
+ from = Math.min(...ranges.map((r) => r.from));
96
+ to = Math.max(...ranges.map((r) => r.to));
97
+ }
98
+ else {
99
+ from = Math.max(...ranges.map((r) => r.from));
100
+ to = Math.min(...ranges.map((r) => r.to));
101
+ }
102
+ if (from > to)
103
+ return undefined;
104
+ [from, to] = clipYears(from, to);
105
+ return {
106
+ from: toDayNumber(`${String(from).padStart(4, "0")}-01-01`),
107
+ to: toDayNumber(`${String(to).padStart(4, "0")}-12-31`),
108
+ label: `${from}-${to}`,
109
+ };
110
+ }
111
+ // ------------------------------------------------------------
112
+ // 規則の当てはまる日(覆われているかの判定用)
113
+ // ------------------------------------------------------------
114
+ /** 繰り返しの規則が当てはまる日(代表の 28 年間)。祝日・振替は考えない */
115
+ function recurringMatches(rule) {
116
+ const bits = new Uint8Array(PATTERN_TO - PATTERN_FROM + 1);
117
+ for (let n = PATTERN_FROM; n <= PATTERN_TO; n++) {
118
+ if (matchRecurring(rule, fromDayNumber(n)))
119
+ bits[n - PATTERN_FROM] = 1;
120
+ }
121
+ return bits;
122
+ }
123
+ /** 毎年の月日の規則が当てはまる月日(うるう年の 366 日) */
124
+ const LEAP_MONTH_DAYS = (() => {
125
+ const list = [];
126
+ for (let m = 1; m <= 12; m++) {
127
+ for (let d = 1; d <= daysInMonth(2028, m); d++) {
128
+ list.push(`${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`);
129
+ }
130
+ }
131
+ return list;
132
+ })();
133
+ function annualMatches(rule) {
134
+ return Uint8Array.from(LEAP_MONTH_DAYS, (md) => (matchAnnual(rule, md) ? 1 : 0));
135
+ }
136
+ const isEmpty = (bits) => bits.every((b) => b === 0);
137
+ /** a が当てはまる日に、b もすべて当てはまるか(a ⊆ b) */
138
+ const isSubset = (a, b) => a.every((v, i) => v === 0 || b[i] === 1);
139
+ /**
140
+ * 先の繰り返しの規則 earlier が、後の規則 later を完全に覆えるか(祝日の扱いの組み合わせ)。
141
+ * - "over-holiday" の規則は、祝日でも先に当てはめる "over-holiday" の規則にしか覆われない
142
+ * - "under-holiday" の規則は、"over-holiday"・"under-holiday" の規則に覆われる
143
+ * - "after-holiday" の規則は、振替先まで同じになる "after-holiday" の規則にしか覆われない
144
+ */
145
+ function canCover(earlier, later) {
146
+ switch (later.onHolidayRule) {
147
+ case "over-holiday":
148
+ return earlier.onHolidayRule === "over-holiday";
149
+ case "under-holiday":
150
+ return earlier.onHolidayRule !== "after-holiday";
151
+ case "after-holiday":
152
+ return earlier.onHolidayRule === "after-holiday";
153
+ default: {
154
+ const unreachable = later.onHolidayRule;
155
+ return unreachable;
156
+ }
157
+ }
158
+ }
159
+ // ------------------------------------------------------------
160
+ // 値の比較(取り除いても結果が変わらないか)
161
+ // ------------------------------------------------------------
162
+ /** 値が同じか。配列と普通のオブジェクトは中身で比べ、それ以外は Object.is で比べる */
163
+ function sameValue(a, b) {
164
+ if (Object.is(a, b))
165
+ return true;
166
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
167
+ return false;
168
+ if (Array.isArray(a) || Array.isArray(b)) {
169
+ return (Array.isArray(a) &&
170
+ Array.isArray(b) &&
171
+ a.length === b.length &&
172
+ a.every((v, i) => sameValue(v, b[i])));
173
+ }
174
+ const plain = (o) => {
175
+ const proto = Object.getPrototypeOf(o);
176
+ return proto === Object.prototype || proto === null;
177
+ };
178
+ if (!plain(a) || !plain(b))
179
+ return false;
180
+ const ka = Object.keys(a);
181
+ const kb = Object.keys(b);
182
+ return (ka.length === kb.length &&
183
+ ka.every((k) => Object.hasOwn(b, k) &&
184
+ sameValue(a[k], b[k])));
185
+ }
186
+ // ------------------------------------------------------------
187
+ // 規則どうしの診断
188
+ // ------------------------------------------------------------
189
+ const yearOf = (dayNumber) => parseDate(fromDayNumber(dayNumber)).year;
190
+ function without(compiled, rule, dropDates) {
191
+ return {
192
+ ...compiled,
193
+ dates: dropDates ? [] : compiled.dates.filter((r) => r !== rule),
194
+ annual: compiled.annual.filter((r) => r !== rule),
195
+ recurring: compiled.recurring.filter((r) => r !== rule),
196
+ };
197
+ }
198
+ /**
199
+ * 祝日の提供元が返すデータを、対応年(長すぎる場合は絞った範囲)のすべての年で検証する。
200
+ * 作成時は年ごとに初めて使うときに検証するので、使っていない年の誤りもここで見つける
201
+ */
202
+ function checkProviderData(c, source) {
203
+ let ok = true;
204
+ source.providers.forEach((provider, i) => {
205
+ const path = source.providers.length === 1 ? "holidays" : `holidays[${i}]`;
206
+ const lookup = createHolidayLookup({ ...source, providers: [provider], outOfRange: "ignore" });
207
+ const [from, to] = clipYears(provider.supportedYears.from, provider.supportedYears.to);
208
+ try {
209
+ for (let y = from; y <= to; y++)
210
+ lookup.holidaysInYear(y);
211
+ }
212
+ catch (error) {
213
+ ok = false;
214
+ c.error("invalid-provider-data", path, error instanceof Error ? error.message : `Holiday provider threw ${String(error)}`);
215
+ }
216
+ });
217
+ return ok;
218
+ }
219
+ function analyze(c, compiled, isCalendar) {
220
+ const { source, dates, annual, recurring } = compiled;
221
+ if (!checkProviderData(c, source))
222
+ return;
223
+ const lookup = createHolidayLookup(source);
224
+ const window = scanWindow(source);
225
+ /** 使われないことが分かった規則(redundant を重ねて出さない) */
226
+ const unused = new Set();
227
+ // --- 個別の日付: 対応年の外・覆われている ---
228
+ dates.forEach((rule, j) => {
229
+ const fromYear = yearOf(rule.from);
230
+ const toYear = yearOf(rule.to);
231
+ source.providers.forEach((p, i) => {
232
+ const { from, to } = p.supportedYears;
233
+ if (fromYear >= from && toYear <= to)
234
+ return;
235
+ const related = source.providers.length === 1 ? "holidays" : `holidays[${i}]`;
236
+ c.warning("out-of-range", rule.path, `The date is outside the years supported by holiday provider ${p.id} (${from}-${to}); ` +
237
+ (source.outOfRange === "throw"
238
+ ? "looking up this date throws"
239
+ : "holidays are not considered for this date"), related);
240
+ });
241
+ const cover = dates.slice(0, j).find((r) => r.from <= rule.from && rule.to <= r.to);
242
+ if (cover) {
243
+ unused.add(rule);
244
+ c.warning("shadowed", rule.path, `This rule is never used: the earlier rule ${cover.path} covers all of its days`, cover.path);
245
+ }
246
+ });
247
+ // --- 毎年の月日: 覆われている ---
248
+ const annualBits = annual.map(annualMatches);
249
+ annual.forEach((rule, j) => {
250
+ const k = annualBits.findIndex((bits, i) => i < j && isSubset(annualBits[j], bits));
251
+ if (k < 0)
252
+ return;
253
+ unused.add(rule);
254
+ c.warning("shadowed", rule.path, `This rule is never used: the earlier rule ${annual[k].path} covers all of its days`, annual[k].path);
255
+ });
256
+ // --- 繰り返しの規則: 当てはまる日がない・覆われている ---
257
+ const recurringBits = recurring.map(recurringMatches);
258
+ recurring.forEach((rule, j) => {
259
+ const bits = recurringBits[j];
260
+ if (isEmpty(bits)) {
261
+ unused.add(rule);
262
+ c.warning("never-matches", rule.path, "This rule matches no day (for example, a day that does not exist in the selected months)");
263
+ return;
264
+ }
265
+ const k = recurring.findIndex((r, i) => i < j && canCover(r, rule) && isSubset(bits, recurringBits[i]));
266
+ if (k < 0)
267
+ return;
268
+ unused.add(rule);
269
+ c.warning("shadowed", rule.path, `This rule is never used: the earlier rule ${recurring[k].path} covers all of its days`, recurring[k].path);
270
+ });
271
+ // --- 営業日が 1 日もない(営業日カレンダー) ---
272
+ // 個別の日付は有限なので、臨時営業日(値が true)がなければ、個別の日付を除いて調べる
273
+ if (isCalendar && window && !dates.some((r) => r.value === true)) {
274
+ const resolve = createResolver(without(compiled, null, true), lookup);
275
+ let found = false;
276
+ for (let n = window.from; n <= window.to && !found; n++) {
277
+ if (resolve(fromDayNumber(n)).day.value === true)
278
+ found = true;
279
+ }
280
+ if (!found) {
281
+ c.warning("no-business-day", "", `No day is a business day with these options (checked ${window.label}); ` +
282
+ "searching for a business day throws");
283
+ }
284
+ }
285
+ // --- 取り除いても結果が変わらない ---
286
+ const redundant = (rule, base, days) => {
287
+ const resolve = createResolver(without(base, rule, false), lookup);
288
+ return days.every((n) => sameValue(rule.value, resolve(fromDayNumber(n)).day.value));
289
+ };
290
+ // 個別の日付は、その期間を調べる(対応年の外で例外になる日は除く)
291
+ const full = createResolver(compiled, lookup);
292
+ for (const rule of dates) {
293
+ if (unused.has(rule))
294
+ continue;
295
+ const decided = [];
296
+ let checked = 0;
297
+ const last = Math.min(rule.to, rule.from + MAX_DATE_RULE_DAYS - 1);
298
+ for (let n = rule.from; n <= last; n++) {
299
+ let decidedBy;
300
+ try {
301
+ decidedBy = full(fromDayNumber(n)).rule;
302
+ }
303
+ catch {
304
+ continue;
305
+ }
306
+ checked++;
307
+ if (decidedBy === rule)
308
+ decided.push(n);
309
+ }
310
+ if (checked > 0 && redundant(rule, compiled, decided)) {
311
+ c.info("redundant", rule.path, decided.length === 0
312
+ ? "This rule is never used: earlier rules cover all of its days"
313
+ : "Removing this rule does not change any result");
314
+ }
315
+ }
316
+ // 毎年の月日・繰り返しの規則は、調べる期間の全日を調べる。個別の日付は有限なので除いて調べる
317
+ if (!window)
318
+ return;
319
+ const candidates = [...annual, ...recurring].filter((r) => !unused.has(r));
320
+ if (candidates.length === 0)
321
+ return;
322
+ const base = without(compiled, null, true);
323
+ const resolveBase = createResolver(base, lookup);
324
+ const decided = new Map(candidates.map((r) => [r, []]));
325
+ for (let n = window.from; n <= window.to; n++) {
326
+ const rule = resolveBase(fromDayNumber(n)).rule;
327
+ if (rule)
328
+ decided.get(rule)?.push(n);
329
+ }
330
+ for (const rule of candidates) {
331
+ const days = decided.get(rule);
332
+ if (!redundant(rule, base, days))
333
+ continue;
334
+ c.info("redundant", rule.path, days.length === 0
335
+ ? `This rule is never used: other rules decide every day it matches (checked ${window.label})`
336
+ : `Removing this rule does not change any result (checked ${window.label})`);
337
+ }
338
+ }
339
+ /** 診断を実行する。祝日の提供元の誤り(年ごとに初めて使うときに分かる)も error として返す */
340
+ function run(c, compiled, isCalendar) {
341
+ if (compiled && !c.hasErrors) {
342
+ const before = c.issues.length;
343
+ try {
344
+ analyze(c, compiled, isCalendar);
345
+ }
346
+ catch (error) {
347
+ // 規則どうしの診断の途中の結果は、提供元の誤りで信頼できないので捨てる
348
+ c.issues.length = before;
349
+ const code = error?.code;
350
+ c.error(code === "out-of-range" ? "out-of-range" : "invalid-provider-data", "holidays", error instanceof Error ? error.message : String(error));
351
+ }
352
+ }
353
+ return Object.freeze(c.issues.map(({ unknownKey, knownKeys, ...issue }) => {
354
+ const suggestion = unknownKey === undefined || knownKeys === undefined
355
+ ? undefined
356
+ : closestKey(unknownKey, knownKeys);
357
+ if (suggestion !== undefined) {
358
+ issue.message += ` (did you mean ${JSON.stringify(suggestion)}?)`;
359
+ }
360
+ return Object.freeze(issue);
361
+ }));
362
+ }
363
+ /**
364
+ * 営業日カレンダーの設定を診断する。例外は投げず、見つけた問題を返す(問題がなければ空の配列)。
365
+ *
366
+ * error があるときは、規則どうしの診断(shadowed・redundant など)は行わない。
367
+ * JavaScript から渡された型の合わない設定も受け付ける。
368
+ * @public
369
+ */
370
+ export function diagnoseCalendar(options = {}) {
371
+ const c = new IssueCollector();
372
+ const compiled = compileCalendarOptions(c, options);
373
+ return run(c, compiled?.schedule, true);
374
+ }
375
+ /**
376
+ * スケジュールの設定を診断する。例外は投げず、見つけた問題を返す(問題がなければ空の配列)。
377
+ *
378
+ * 値が同じかは、配列と普通のオブジェクトは中身で、それ以外は Object.is で比べる。
379
+ * error があるときは、規則どうしの診断(shadowed・redundant など)は行わない。
380
+ * @public
381
+ */
382
+ export function diagnoseSchedule(options) {
383
+ const c = new IssueCollector();
384
+ const compiled = compileScheduleOptions(c, options);
385
+ return run(c, compiled, false);
386
+ }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 例外は従来どおり RangeError・TypeError・Error のインスタンスで、code プロパティを持つ。
5
5
  * 例外のメッセージの文言は API に含まない(変わることがある)。判定には code を使う。
6
+ * 問題を集める仕組み(IssueCollector)は、作成時のチェックと規則の診断(diagnose)で共通。
6
7
  */
7
8
  /**
8
9
  * 既知のエラーコード(増えることがある)。
@@ -11,6 +12,8 @@
11
12
  export type KnownErrorCode =
12
13
  /** 日付 "YYYY-MM-DD" の形式・値の誤り */
13
14
  "invalid-date"
15
+ /** 月 "YYYY-MM" の形式・値の誤り */
16
+ | "invalid-month"
14
17
  /** 毎年の月日 "MM-DD" の形式・値の誤り */
15
18
  | "invalid-month-day"
16
19
  /** 時刻・日時の形式・値の誤り */
@@ -61,18 +64,28 @@ export type IssueSeverity = "error" | "warning" | "info";
61
64
  export interface Issue {
62
65
  readonly severity: IssueSeverity;
63
66
  readonly code: string;
64
- /** 設定の中の位置(例: "dates[1].from") */
67
+ /** 設定の中の位置(例: "dates[1].from")。設定全体なら "" */
65
68
  readonly path: string;
66
69
  readonly message: string;
70
+ /** 関係する別の位置(例: 覆っている先の規則) */
71
+ readonly relatedPath?: string;
72
+ /** unknown-option の知らないキーと既知のキー(診断で候補を挙げるため。結果には出さない) */
73
+ readonly unknownKey?: string;
74
+ readonly knownKeys?: readonly string[];
67
75
  }
68
76
  /** @internal 問題を集める */
69
77
  export declare class IssueCollector {
70
78
  readonly issues: Issue[];
71
79
  error(code: KnownErrorCode, path: string, message: string): void;
72
- warning(code: string, path: string, message: string): void;
80
+ warning(code: string, path: string, message: string, relatedPath?: string): void;
81
+ info(code: string, path: string, message: string, relatedPath?: string): void;
82
+ private add;
73
83
  get hasErrors(): boolean;
74
84
  /** 最初の誤りを例外にして投げる(誤りがなければ何もしない) */
75
85
  throwFirstError(): void;
76
86
  }
77
- /** @internal 設定のオブジェクトに、知らないキーがあれば警告として記録する */
87
+ /**
88
+ * @internal 設定のオブジェクトに、知らないキーがあれば警告として記録する。
89
+ * 近い既知のキーを候補に挙げるのは規則の診断で行う(作成時は警告を使わないので、ここでは既知のキーを渡すだけ)
90
+ */
78
91
  export declare function checkUnknownKeys(collector: IssueCollector, path: string, value: object, known: readonly string[]): void;
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 例外は従来どおり RangeError・TypeError・Error のインスタンスで、code プロパティを持つ。
5
5
  * 例外のメッセージの文言は API に含まない(変わることがある)。判定には code を使う。
6
+ * 問題を集める仕組み(IssueCollector)は、作成時のチェックと規則の診断(diagnose)で共通。
6
7
  */
7
8
  /**
8
9
  * このライブラリが投げた例外(code 付き)か。
@@ -36,8 +37,16 @@ export class IssueCollector {
36
37
  error(code, path, message) {
37
38
  this.issues.push({ severity: "error", code, path, message });
38
39
  }
39
- warning(code, path, message) {
40
- this.issues.push({ severity: "warning", code, path, message });
40
+ warning(code, path, message, relatedPath) {
41
+ this.add("warning", code, path, message, relatedPath);
42
+ }
43
+ info(code, path, message, relatedPath) {
44
+ this.add("info", code, path, message, relatedPath);
45
+ }
46
+ add(severity, code, path, message, relatedPath) {
47
+ this.issues.push(relatedPath === undefined
48
+ ? { severity, code, path, message }
49
+ : { severity, code, path, message, relatedPath });
41
50
  }
42
51
  get hasErrors() {
43
52
  return this.issues.some((i) => i.severity === "error");
@@ -51,11 +60,21 @@ export class IssueCollector {
51
60
  throw rangeError(first.code, message);
52
61
  }
53
62
  }
54
- /** @internal 設定のオブジェクトに、知らないキーがあれば警告として記録する */
63
+ /**
64
+ * @internal 設定のオブジェクトに、知らないキーがあれば警告として記録する。
65
+ * 近い既知のキーを候補に挙げるのは規則の診断で行う(作成時は警告を使わないので、ここでは既知のキーを渡すだけ)
66
+ */
55
67
  export function checkUnknownKeys(collector, path, value, known) {
56
68
  for (const key of Object.keys(value)) {
57
69
  if (!known.includes(key)) {
58
- collector.warning("unknown-option", path ? `${path}.${key}` : key, `Unknown option ${JSON.stringify(key)} is ignored`);
70
+ collector.issues.push({
71
+ severity: "warning",
72
+ code: "unknown-option",
73
+ path: path ? `${path}.${key}` : key,
74
+ message: `Unknown option ${JSON.stringify(key)} is ignored`,
75
+ unknownKey: key,
76
+ knownKeys: known,
77
+ });
59
78
  }
60
79
  }
61
80
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 月("YYYY-MM")の計算。締め日の月(closingMonth)や、n か月後の月(monthOf)。
3
+ *
4
+ * 営業日を考えない暦の計算なので、カレンダーなしで使える。営業日を考えた月の計算
5
+ * (月の第 n 営業日・期日)は営業日カレンダーのメソッドにある。
6
+ */
7
+ import { type DateString } from "./date.ts";
8
+ /**
9
+ * 月を表す "YYYY-MM" 文字列。
10
+ * @public
11
+ */
12
+ export type MonthString = string;
13
+ /** @internal 年と月 */
14
+ export interface YearMonth {
15
+ readonly year: number;
16
+ readonly month: number;
17
+ }
18
+ /** @internal "YYYY-MM" を年と月に分解する(誤りは code: "invalid-month") */
19
+ export declare function parseMonth(value: unknown): YearMonth;
20
+ /** @internal 年と月を "YYYY-MM" にする(年が 1000〜9999 の外なら code: "out-of-range") */
21
+ export declare function formatMonth({ year, month }: YearMonth): MonthString;
22
+ /** @internal n か月後(負なら前)の年と月 */
23
+ export declare function shiftMonth({ year, month }: YearMonth, offset: number): YearMonth;
24
+ /** @internal その月の d 日(その月にない日・-1 は末日に読み替える) */
25
+ export declare function dayOfMonth({ year, month }: YearMonth, day: number): DateString;
26
+ /** @internal 月の日(1〜31、-1 は末日)として正しいか */
27
+ export declare const isDayOfMonthValue: (v: unknown) => v is number;
28
+ /**
29
+ * 日付または月の、offset か月後(負なら前)の月 "YYYY-MM"。
30
+ *
31
+ * @example
32
+ * monthOf("2026-10-16"); // "2026-10"
33
+ * monthOf("2026-10-16", 1); // "2026-11"
34
+ * monthOf("2026-01", -1); // "2025-12"
35
+ * @public
36
+ */
37
+ export declare function monthOf(input: DateString | MonthString, offset?: number): MonthString;
38
+ /**
39
+ * 締め日で区切ったときに、日付が属する月 "YYYY-MM"。
40
+ *
41
+ * 締め日の翌日から次の締め日までを、次の締め日の月とする(例: 20 日締めなら 10 月 21 日〜11 月 20 日が 11 月分)。
42
+ * closingDay が -1 またはその月の日数以上なら、締め日はその月の末日(例: 31 日締めの 2 月は 28 日か 29 日締め)。
43
+ *
44
+ * @example
45
+ * closingMonth("2026-10-20", 20); // "2026-10"
46
+ * closingMonth("2026-10-21", 20); // "2026-11"
47
+ * closingMonth("2026-10-31", -1); // "2026-10"(末日締め)
48
+ * @public
49
+ */
50
+ export declare function closingMonth(date: DateString, closingDay: number): MonthString;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 月("YYYY-MM")の計算。締め日の月(closingMonth)や、n か月後の月(monthOf)。
3
+ *
4
+ * 営業日を考えない暦の計算なので、カレンダーなしで使える。営業日を考えた月の計算
5
+ * (月の第 n 営業日・期日)は営業日カレンダーのメソッドにある。
6
+ */
7
+ import { daysInMonth, formatDate, parseDate } from "./date.js";
8
+ import { rangeError } from "./errors.js";
9
+ const MONTH_RE = /^(\d{4})-(\d{2})$/;
10
+ /** @internal "YYYY-MM" を年と月に分解する(誤りは code: "invalid-month") */
11
+ export function parseMonth(value) {
12
+ const m = typeof value === "string" ? MONTH_RE.exec(value) : null;
13
+ const year = Number(m?.[1]);
14
+ const month = Number(m?.[2]);
15
+ if (!m || year < 1000 || month < 1 || month > 12) {
16
+ throw rangeError("invalid-month", `Invalid month: ${JSON.stringify(value)} (expected YYYY-MM)`);
17
+ }
18
+ return { year, month };
19
+ }
20
+ /** @internal 年と月を "YYYY-MM" にする(年が 1000〜9999 の外なら code: "out-of-range") */
21
+ export function formatMonth({ year, month }) {
22
+ if (year < 1000 || year > 9999) {
23
+ throw rangeError("out-of-range", `Year out of range (1000-9999): ${year}`);
24
+ }
25
+ return `${year}-${String(month).padStart(2, "0")}`;
26
+ }
27
+ /** @internal n か月後(負なら前)の年と月 */
28
+ export function shiftMonth({ year, month }, offset) {
29
+ const index = year * 12 + (month - 1) + offset;
30
+ return { year: Math.floor(index / 12), month: (index % 12) + 1 };
31
+ }
32
+ /** @internal その月の d 日(その月にない日・-1 は末日に読み替える) */
33
+ export function dayOfMonth({ year, month }, day) {
34
+ const last = daysInMonth(year, month);
35
+ return formatDate(year, month, day === -1 || day > last ? last : day);
36
+ }
37
+ /** @internal 月の日(1〜31、-1 は末日)として正しいか */
38
+ export const isDayOfMonthValue = (v) => v === -1 || (Number.isInteger(v) && v >= 1 && v <= 31);
39
+ /**
40
+ * 日付または月の、offset か月後(負なら前)の月 "YYYY-MM"。
41
+ *
42
+ * @example
43
+ * monthOf("2026-10-16"); // "2026-10"
44
+ * monthOf("2026-10-16", 1); // "2026-11"
45
+ * monthOf("2026-01", -1); // "2025-12"
46
+ * @public
47
+ */
48
+ export function monthOf(input, offset = 0) {
49
+ if (!Number.isInteger(offset)) {
50
+ throw rangeError("invalid-argument", `offset must be an integer: ${offset}`);
51
+ }
52
+ const base = typeof input === "string" && input.split("-").length === 3
53
+ ? parseDate(input)
54
+ : parseMonth(input);
55
+ return formatMonth(shiftMonth(base, offset));
56
+ }
57
+ /**
58
+ * 締め日で区切ったときに、日付が属する月 "YYYY-MM"。
59
+ *
60
+ * 締め日の翌日から次の締め日までを、次の締め日の月とする(例: 20 日締めなら 10 月 21 日〜11 月 20 日が 11 月分)。
61
+ * closingDay が -1 またはその月の日数以上なら、締め日はその月の末日(例: 31 日締めの 2 月は 28 日か 29 日締め)。
62
+ *
63
+ * @example
64
+ * closingMonth("2026-10-20", 20); // "2026-10"
65
+ * closingMonth("2026-10-21", 20); // "2026-11"
66
+ * closingMonth("2026-10-31", -1); // "2026-10"(末日締め)
67
+ * @public
68
+ */
69
+ export function closingMonth(date, closingDay) {
70
+ if (!isDayOfMonthValue(closingDay)) {
71
+ throw rangeError("invalid-argument", `closingDay must be an integer from 1 to 31, or -1 (last day): ${closingDay}`);
72
+ }
73
+ const { year, month, day } = parseDate(date);
74
+ const closing = closingDay === -1 ? daysInMonth(year, month) : closingDay;
75
+ return formatMonth(shiftMonth({ year, month }, day <= closing ? 0 : 1));
76
+ }