bizdays-toolkit 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,27 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- ## [0.4.0] - 2026-10-16
7
+ ## [0.5.0] - 2026-10-16
8
+
9
+ ### Added
10
+
11
+ - 規則の診断(サブパス `bizdays-toolkit/diagnose`): `diagnoseCalendar(options)`・`diagnoseSchedule(options)`。例外を投げず、`{ severity, code, path, message, relatedPath? }` の配列を返す
12
+ - `error`: 作成で例外になる誤り(`code` は例外と同じ)。作成時は最初の 1 件で止まるが、診断ではすべて返す。祝日の提供元が返すデータは、対応年のすべての年を調べる(`invalid-provider-data`)
13
+ - `warning`: `unknown-option`(知らないキー。近い既知のキーを候補に挙げる)・`shadowed`(同じ種類の先の規則に完全に含まれる。`relatedPath` に先の規則)・`never-matches`(当てはまる日がない繰り返しの規則)・`out-of-range`(個別の日付が祝日の提供元の対応年の外)・`no-business-day`(営業日が 1 日もない設定)
14
+ - `info`: `redundant`(取り除いても結果が変わらない規則)
15
+ - 型: `Diagnostic`・`DiagnosticSeverity`・`DiagnosticCode`・`KnownDiagnosticCode`
16
+
17
+ ### Removed
18
+
19
+ - `HolidayRule` 型(0.4.0 で非推奨): `HolidayValueRule` を使う
20
+
21
+ ### 開発
22
+
23
+ - 診断の性質のテスト(`test/diagnose-properties.test.ts`): 使われない・取り除いても変わらないと診断した規則を実際に取り除き、結果が変わらないことを乱数の設定で確かめる
24
+ - サイズの上限に `./diagnose` を追加
25
+ - `docs/decisions.md` に 15 番目(規則の診断の設計)を追加
26
+
27
+ ## [0.4.0] - 2026-10-15
8
28
 
9
29
  ### Added
10
30
 
@@ -109,7 +129,8 @@
109
129
 
110
130
  - 日本: 2026 年 2 月の官報(2027 年の春分・秋分の日)まで反映(`dataVersion: "2026-02"`、`confirmedThrough: 2027`)
111
131
 
112
- [Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.4.0...HEAD
132
+ [Unreleased]: https://github.com/inohan77/bizdays-toolkit/compare/v0.5.0...HEAD
133
+ [0.5.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.4.0...v0.5.0
113
134
  [0.4.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.3.1...v0.4.0
114
135
  [0.3.1]: https://github.com/inohan77/bizdays-toolkit/compare/v0.3.0...v0.3.1
115
136
  [0.3.0]: https://github.com/inohan77/bizdays-toolkit/compare/v0.2.0...v0.3.0
package/README.md CHANGED
@@ -19,6 +19,7 @@
19
19
  - **日本の祝日を内閣府のデータで全日照合**: 1955 年〜官報公表済みの年まで、内閣府「国民の祝日」CSV のすべての日付と一致することをテストで確かめています(振替休日・国民の休日・皇室行事による休日・五輪特措法による移動を含む)
20
20
  - **繰り返しの規則**: 「第 3 水曜定休」「毎月末日は棚卸」「月曜休館(祝日の場合は翌平日)」のような規則を、祝日に当たったときの扱い(祝日に従う・祝日でも当てはめる・翌日に振り替える)とあわせて指定できます
21
21
  - **日ごとの値(営業時間など)**: 「火曜定休、祝日は日曜の営業時間、元日は休業、大晦日は短縮営業」のように、曜日・祝日・特定日で変わる値を同じ規則で引けます
22
+ - **設定の診断**: `bizdays-toolkit/diagnose` の `diagnoseCalendar`・`diagnoseSchedule` が、決して使われない規則・取り除いても結果が変わらない規則・キーの打ち間違い・営業日が 1 日もない設定などを、例外を投げずに一覧で返します。テストに 1 行足せば、設定の変更で規則が死んでいないかを確かめられます
22
23
  - **営業日の計算**: 定休日の曜日・毎年の休業日(年末年始のような年をまたぐ期間も可)・臨時休業日・臨時営業日と祝日を組み合わせ、「N 営業日後」「締め時刻を過ぎたら翌営業日」などを計算します
23
24
  - **タイムゾーンを間違えない**: `Date` などの瞬間は、カレンダーのタイムゾーン(日本は `Asia/Tokyo`)で暦日に変換してから判定します。UTC で動くサーバーでも、日本時間 0〜9 時が前日扱いになりません
24
25
  - **軽量・依存ゼロ**: minify + gzip で約 6.5KB(日本の祝日込みで約 7.5KB)。ESM・型定義同梱・`sideEffects: false` で、Node.js・ブラウザー・Edge ランタイムで動作します
@@ -275,6 +276,64 @@ try {
275
276
 
276
277
  主なコード: `invalid-date`・`invalid-month-day`・`invalid-time`・`invalid-range`・`invalid-weekday`・`invalid-time-zone`・`invalid-option`・`out-of-range`・`invalid-provider`・`invalid-provider-data`・`no-business-day`。将来の版でコードが増えることがあります。
277
278
 
279
+ ### 設定の診断
280
+
281
+ `bizdays-toolkit/diagnose` の `diagnoseCalendar`・`diagnoseSchedule` は、設定を調べて問題を一覧で返します。例外は投げません(問題がなければ空の配列)。
282
+
283
+ ```ts
284
+ import { diagnoseCalendar } from "bizdays-toolkit/diagnose";
285
+ import { jp } from "bizdays-toolkit/jp";
286
+
287
+ diagnoseCalendar({
288
+ holidays: jp,
289
+ closedWeekdays: [0],
290
+ closedRecurring: [
291
+ { weekday: 3, nth: 3, name: "第 3 水曜定休" },
292
+ { weekday: 3, nth: 3, months: [8], name: "8 月の第 3 水曜" }, // 先の規則に含まれる
293
+ { weekday: 0, name: "日曜休み" }, // 定休日の曜日と同じ
294
+ ],
295
+ closedDates: ["2100-01-05"], // 日本の祝日の対応年(1955〜2099 年)の外
296
+ closeAnnual: ["12-31"], // 打ち間違い
297
+ });
298
+ // [
299
+ // { severity: "warning", code: "unknown-option", path: "closeAnnual", message: '… (did you mean "closedAnnual"?)' },
300
+ // { severity: "warning", code: "out-of-range", path: "closedDates[0]", relatedPath: "holidays", message: "…" },
301
+ // { severity: "warning", code: "shadowed", path: "closedRecurring[1]", relatedPath: "closedRecurring[0]", message: "…" },
302
+ // { severity: "info", code: "redundant", path: "closedRecurring[2]", message: "…" },
303
+ // ]
304
+ ```
305
+
306
+ | 重さ(`severity`) | `code` | 内容 |
307
+ | ------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------- |
308
+ | `error` | 例外と同じコード | `createCalendar`・`createSchedule` で例外になる誤り。作成時は最初の 1 件で止まりますが、診断ではすべて返します |
309
+ | `error` | `invalid-provider-data` | 祝日の提供元が返したデータの誤り。作成時は使った年だけを調べますが、診断では対応年のすべての年を調べます |
310
+ | `warning` | `unknown-option` | 知らないキー(作成時は無視されます)。近い既知のキーがあれば `message` に候補を挙げます |
311
+ | `warning` | `shadowed` | 同じ種類の先の規則(`relatedPath`)に完全に含まれ、決して使われない規則 |
312
+ | `warning` | `never-matches` | 当てはまる日がない繰り返しの規則(`{ day: 30, months: [2] }` など) |
313
+ | `warning` | `out-of-range` | 個別の日付が祝日の提供元(`relatedPath`)の対応年の外 |
314
+ | `warning` | `no-business-day` | 営業日が 1 日もない設定(営業日カレンダーのみ。営業日を探すと例外になります) |
315
+ | `info` | `redundant` | 取り除いても結果が変わらない規則(先の規則の組み合わせに覆われる、定休日の曜日と同じ、スケジュールで曜日の値と同じ値など) |
316
+
317
+ - 判定には `code` を使ってください。`message` は英語で、文言は変わることがあります。`code` は将来の版で増えることがあります
318
+ - `error` があるときは、規則どうしの診断(`shadowed`・`redundant` など)は行いません
319
+ - `shadowed` は同じ種類の規則どうし(個別の日付どうし、毎年の月日どうし、繰り返しの規則どうし)だけを調べます。判定の順番は固定なので、種類をまたいで使われない規則は `redundant` として見つかります。臨時営業日(`openDates`)と臨時休業日(`closedDates`)は同じ種類で、先の臨時営業日に含まれる臨時休業日は `shadowed` です
320
+ - 繰り返しの規則の `shadowed` は、祝日の扱い(`onHolidayRule`)も見ます。たとえば `"under-holiday"` の規則は、同じ日に当たる `"over-holiday"` の規則を覆いません(祝日には後の規則が当たるため)
321
+ - `redundant` は、規則を取り除いた結果と 1 日ずつ比べます。個別の日付はその期間を、毎年の月日と繰り返しの規則は個別の日付を除いた設定で、祝日の提供元の対応年(日本は 1955〜2099 年)を、提供元がなければ 2001〜2028 年(曜日と月日の組み合わせが一巡する 28 年)を調べます。スケジュールの値は、配列と普通のオブジェクトなら中身で、それ以外(`Date` など)は同じものかで比べます
322
+ - 日本の祝日を使う設定では、全日を調べるため数百ミリ秒かかります。リクエストごとではなく、テストや起動時の確認で使ってください。開発・テスト用の道具なので、アプリの本体のバンドルに入らないようサブパスに分けています
323
+
324
+ テストで設定を守る例:
325
+
326
+ ```ts
327
+ import assert from "node:assert/strict";
328
+ import { test } from "node:test";
329
+ import { diagnoseCalendar } from "bizdays-toolkit/diagnose";
330
+ import { shippingOptions } from "../src/calendar.ts";
331
+
332
+ test("出荷カレンダーの設定に問題がない", () => {
333
+ assert.deepEqual(diagnoseCalendar(shippingOptions), []);
334
+ });
335
+ ```
336
+
278
337
  ## バージョン番号とデータの更新
279
338
 
280
339
  [Semantic Versioning](https://semver.org/lang/ja/) に従います。詳しくは [RELEASING.md](./RELEASING.md) を参照してください。
@@ -283,7 +342,7 @@ try {
283
342
  - 祝日の判定結果を固定したい場合は、版を完全一致で指定してください(例: `"bizdays-toolkit": "0.1.0"`)
284
343
  - 反映済みのデータは `dataVersion`(日本は反映済みの官報の年月)と `confirmedThrough` で確認できます
285
344
  - 1.0.0 までは、マイナー版で破壊的変更を行うことがあります
286
- - 判定の理由(`reason`)・祝日の種別(`kind`)・エラーコードは、将来の版で値が増えることがあります。値を列挙して分岐するときは「その他」も扱ってください([RELEASING.md](./RELEASING.md) の「互換性の約束」)
345
+ - 判定の理由(`reason`)・祝日の種別(`kind`)・エラーコード・診断のコードは、将来の版で値が増えることがあります。値を列挙して分岐するときは「その他」も扱ってください([RELEASING.md](./RELEASING.md) の「互換性の約束」)
287
346
 
288
347
  ## 出典
289
348
 
@@ -302,6 +361,7 @@ A business-day calendar that combines public holidays with company or store clos
302
361
  - **Japanese holidays verified day by day** against the Cabinet Office CSV, from 1955 through the latest year officially announced (substitute holidays, citizens' holidays, imperial ceremonies, and the Olympic moves included)
303
362
  - **Day-by-day schedules**: `createSchedule` returns any value per day (e.g. store hours) from weekly values, a holiday rule (`"weekday"`, `{ sameAs: 0 }`, or `{ value }`), annual dates and specific dates
304
363
  - **Recurring rules**: weekly, nth-weekday-of-month (`{ weekday: 3, nth: 3 }`) and day-of-month (`{ day: -1 }`) rules, with per-rule holiday handling (`onHolidayRule`: `"under-holiday"`, `"over-holiday"`, or `"after-holiday"` to move the rule to the next non-holiday, e.g. "closed on Mondays; if Monday is a holiday, closed on the next weekday")
364
+ - **Option diagnostics**: `diagnoseCalendar` / `diagnoseSchedule` from `bizdays-toolkit/diagnose` return a list of problems without throwing: rules that can never apply (`shadowed`, `never-matches`), rules whose removal changes nothing (`redundant`), typos in option keys with a suggestion (`unknown-option`), dates outside the holiday provider's years (`out-of-range`), and calendars with no business day. Use `code` to branch; `message` is for humans
305
365
  - **Business-day arithmetic**: closed weekdays, annual closures (including ranges across the new year), specific closed/open dates, holiday policy, `addBusinessDays`, cut-off times, and more
306
366
  - **Time-zone safe**: instants (`Date`, ISO strings with offsets, Temporal) are converted to a civil date in the calendar's time zone before evaluation
307
367
  - **Lightweight, zero dependencies**: about 6.5 KB min+gzip (about 7.5 KB with Japanese holidays), ESM, bundled types, `sideEffects: false`
@@ -0,0 +1,79 @@
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 { type CalendarOptions } from "./calendar.ts";
21
+ import { type KnownErrorCode } from "./errors.ts";
22
+ import { type ScheduleOptions } from "./schedule.ts";
23
+ /**
24
+ * 診断の重さ。
25
+ * - "error": 作成すると例外になる誤り
26
+ * - "warning": 作れるが、意図と違う可能性が高い
27
+ * - "info": 結果には影響しないが、整理できる
28
+ *
29
+ * 出力の列挙だが、重さの段階は増やさないため閉じた型にしている(docs/decisions.md の 15)。
30
+ * @public
31
+ */
32
+ export type DiagnosticSeverity = "error" | "warning" | "info";
33
+ /**
34
+ * 既知の診断コード(増えることがある)。error の code は例外の code(KnownErrorCode)と同じ。
35
+ * @public
36
+ */
37
+ export type KnownDiagnosticCode = KnownErrorCode
38
+ /** 知らないキー(作成時は無視される) */
39
+ | "unknown-option"
40
+ /** 同じ種類の先の規則に完全に覆われ、決して使われない */
41
+ | "shadowed"
42
+ /** 当てはまる日がない繰り返しの規則 */
43
+ | "never-matches"
44
+ /** 取り除いても結果が変わらない */
45
+ | "redundant";
46
+ /**
47
+ * 診断コード。将来の版で値が増えることがあるので、分岐では「その他」も扱うこと。
48
+ * @public
49
+ */
50
+ export type DiagnosticCode = KnownDiagnosticCode | (string & {});
51
+ /**
52
+ * 診断の結果 1 件。message は英語で、文言は API に含まない(判定には code を使う)。
53
+ * @public
54
+ */
55
+ export interface Diagnostic {
56
+ readonly severity: DiagnosticSeverity;
57
+ readonly code: DiagnosticCode;
58
+ /** 設定の中の位置(例: "closedRecurring[1]"・"closedDates[0].date")。設定全体なら "" */
59
+ readonly path: string;
60
+ readonly message: string;
61
+ /** 関係する別の位置(shadowed では覆っている先の規則、out-of-range では祝日の提供元) */
62
+ readonly relatedPath?: string;
63
+ }
64
+ /**
65
+ * 営業日カレンダーの設定を診断する。例外は投げず、見つけた問題を返す(問題がなければ空の配列)。
66
+ *
67
+ * error があるときは、規則どうしの診断(shadowed・redundant など)は行わない。
68
+ * JavaScript から渡された型の合わない設定も受け付ける。
69
+ * @public
70
+ */
71
+ export declare function diagnoseCalendar(options?: CalendarOptions | object): readonly Diagnostic[];
72
+ /**
73
+ * スケジュールの設定を診断する。例外は投げず、見つけた問題を返す(問題がなければ空の配列)。
74
+ *
75
+ * 値が同じかは、配列と普通のオブジェクトは中身で、それ以外は Object.is で比べる。
76
+ * error があるときは、規則どうしの診断(shadowed・redundant など)は行わない。
77
+ * @public
78
+ */
79
+ export declare function diagnoseSchedule(options: ScheduleOptions<unknown> | object): readonly Diagnostic[];
@@ -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
  * 既知のエラーコード(増えることがある)。
@@ -61,18 +62,28 @@ export type IssueSeverity = "error" | "warning" | "info";
61
62
  export interface Issue {
62
63
  readonly severity: IssueSeverity;
63
64
  readonly code: string;
64
- /** 設定の中の位置(例: "dates[1].from") */
65
+ /** 設定の中の位置(例: "dates[1].from")。設定全体なら "" */
65
66
  readonly path: string;
66
67
  readonly message: string;
68
+ /** 関係する別の位置(例: 覆っている先の規則) */
69
+ readonly relatedPath?: string;
70
+ /** unknown-option の知らないキーと既知のキー(診断で候補を挙げるため。結果には出さない) */
71
+ readonly unknownKey?: string;
72
+ readonly knownKeys?: readonly string[];
67
73
  }
68
74
  /** @internal 問題を集める */
69
75
  export declare class IssueCollector {
70
76
  readonly issues: Issue[];
71
77
  error(code: KnownErrorCode, path: string, message: string): void;
72
- warning(code: string, path: string, message: string): void;
78
+ warning(code: string, path: string, message: string, relatedPath?: string): void;
79
+ info(code: string, path: string, message: string, relatedPath?: string): void;
80
+ private add;
73
81
  get hasErrors(): boolean;
74
82
  /** 最初の誤りを例外にして投げる(誤りがなければ何もしない) */
75
83
  throwFirstError(): void;
76
84
  }
77
- /** @internal 設定のオブジェクトに、知らないキーがあれば警告として記録する */
85
+ /**
86
+ * @internal 設定のオブジェクトに、知らないキーがあれば警告として記録する。
87
+ * 近い既知のキーを候補に挙げるのは規則の診断で行う(作成時は警告を使わないので、ここでは既知のキーを渡すだけ)
88
+ */
78
89
  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
  }
@@ -33,12 +33,6 @@ export type HolidayValueRule<T> =
33
33
  | {
34
34
  readonly value: T;
35
35
  };
36
- /**
37
- * HolidayValueRule の旧名。繰り返しの規則の onHolidayRule と紛れないよう改名した。
38
- * @deprecated HolidayValueRule を使うこと。次以降のマイナー版で削除する
39
- * @public
40
- */
41
- export type HolidayRule<T> = HolidayValueRule<T>;
42
36
  /**
43
37
  * 月の第 n 回目(1〜5)。-1 は最終(その月の最後のその曜日)。
44
38
  * @public
@@ -254,6 +248,8 @@ export declare function isValidMonthDay(value: unknown): value is string;
254
248
  export declare const isWeekday: (value: unknown) => value is Weekday;
255
249
  /** @internal 個別の日付の規則(日数で表した期間) */
256
250
  export interface DateRule<T> {
251
+ /** 設定の中の位置(例: "closedDates[1]") */
252
+ readonly path: string;
257
253
  readonly from: number;
258
254
  readonly to: number;
259
255
  readonly value: T;
@@ -261,6 +257,8 @@ export interface DateRule<T> {
261
257
  }
262
258
  /** @internal 毎年の月日の規則 */
263
259
  export interface AnnualRule<T> {
260
+ /** 設定の中の位置(例: "closedAnnual[0]") */
261
+ readonly path: string;
264
262
  readonly from: string;
265
263
  readonly to: string;
266
264
  readonly value: T;
@@ -275,6 +273,8 @@ export declare function compileDateRules<T>(c: IssueCollector, path: string, ent
275
273
  export declare function compileAnnualRules<T>(c: IssueCollector, path: string, entries: unknown, valueOf: (entry: Record<string, unknown>, path: string) => T | undefined, allowString: boolean): AnnualRule<T>[];
276
274
  /** @internal 繰り返しの規則(検証済み) */
277
275
  export interface RecurringRuleCompiled<T> {
276
+ /** 設定の中の位置(例: "closedRecurring[0]") */
277
+ readonly path: string;
278
278
  /** 曜日の規則なら曜日、日の規則なら null */
279
279
  readonly weekday: Weekday | null;
280
280
  /** 第 n 回目の集合(null は毎週) */
@@ -316,6 +316,21 @@ export interface CompiledSchedule<T, Kind extends string> {
316
316
  export declare const RECURRING_RULE_KEYS: string[];
317
317
  /** @internal スケジュールの設定を検証して取り込む(誤りは c に集める) */
318
318
  export declare function compileScheduleOptions<T, Kind extends string>(c: IssueCollector, options: unknown): CompiledSchedule<T, Kind> | undefined;
319
+ /** @internal 毎年の月日の規則がその月日 "MM-DD" に当てはまるか */
320
+ export declare function matchAnnual(rule: AnnualRule<unknown>, monthDay: string): boolean;
321
+ /** @internal 値を決めた規則(祝日・曜日で決まった場合は null) */
322
+ export type DecidingRule<T> = DateRule<T> | AnnualRule<T> | RecurringRuleCompiled<T>;
323
+ /** @internal 1 日分の結果と、値を決めた規則 */
324
+ export interface Resolution<T, Kind extends string> {
325
+ readonly day: ScheduleDay<T, Kind>;
326
+ readonly rule: DecidingRule<T> | null;
327
+ }
328
+ type HolidayLookupInternal<Kind extends string> = ReturnType<typeof createHolidayLookup<Kind>>;
329
+ /**
330
+ * @internal 検証済みの規則から、1 日分の値と値を決めた規則を返す関数を作る。
331
+ * 規則の診断では、規則を取り除いた版と結果を比べるために、祝日を調べる機能(lookup)を共有して作る
332
+ */
333
+ export declare function createResolver<T, Kind extends string>(compiled: CompiledSchedule<T, Kind>, lookup: HolidayLookupInternal<Kind>): (date: DateString) => Resolution<T, Kind>;
319
334
  /** @internal 検証済みの規則からスケジュールを作る */
320
335
  export declare function buildSchedule<T, Kind extends string>(compiled: CompiledSchedule<T, Kind>): Schedule<T, Kind>;
321
336
  /**
@@ -326,3 +341,4 @@ export declare function buildSchedule<T, Kind extends string>(compiled: Compiled
326
341
  * @public
327
342
  */
328
343
  export declare function createSchedule<T, Kind extends string = string>(options: ScheduleOptions<T, Kind>): Schedule<T, Kind>;
344
+ export {};
@@ -84,7 +84,7 @@ export function compileDateRules(c, path, entries, valueOf, allowString) {
84
84
  checkUnknownKeys(c, p, entry, ["date", "name", "value"]);
85
85
  const n = checkDate(c, `${p}.date`, entry["date"]);
86
86
  if (n !== undefined && value !== undefined)
87
- rules.push({ from: n, to: n, value, name });
87
+ rules.push({ path: p, from: n, to: n, value, name });
88
88
  return;
89
89
  }
90
90
  if (!("from" in entry) || !("to" in entry)) {
@@ -101,7 +101,7 @@ export function compileDateRules(c, path, entries, valueOf, allowString) {
101
101
  return;
102
102
  }
103
103
  if (value !== undefined)
104
- rules.push({ from, to, value, name });
104
+ rules.push({ path: p, from, to, value, name });
105
105
  });
106
106
  return rules;
107
107
  }
@@ -126,8 +126,9 @@ export function compileAnnualRules(c, path, entries, valueOf, allowString) {
126
126
  if ("date" in entry) {
127
127
  checkUnknownKeys(c, p, entry, ["date", "name", "value"]);
128
128
  const md = checkMonthDay(c, `${p}.date`, entry["date"]);
129
- if (md !== undefined && value !== undefined)
130
- rules.push({ from: md, to: md, value, name });
129
+ if (md !== undefined && value !== undefined) {
130
+ rules.push({ path: p, from: md, to: md, value, name });
131
+ }
131
132
  return;
132
133
  }
133
134
  if (!("from" in entry) || !("to" in entry)) {
@@ -138,7 +139,7 @@ export function compileAnnualRules(c, path, entries, valueOf, allowString) {
138
139
  const from = checkMonthDay(c, `${p}.from`, entry["from"]);
139
140
  const to = checkMonthDay(c, `${p}.to`, entry["to"]);
140
141
  if (from !== undefined && to !== undefined && value !== undefined) {
141
- rules.push({ from, to, value, name });
142
+ rules.push({ path: p, from, to, value, name });
142
143
  }
143
144
  });
144
145
  return rules;
@@ -235,6 +236,7 @@ export function compileRecurringRules(c, path, entries, valueOf, keys) {
235
236
  }
236
237
  if (ok && value !== undefined) {
237
238
  rules.push(Object.freeze({
239
+ path: p,
238
240
  weekday,
239
241
  nth,
240
242
  days,
@@ -487,15 +489,18 @@ export function compileScheduleOptions(c, options) {
487
489
  }
488
490
  /** 振替元をさかのぼる最大日数(祝日が続く日数の上限。2019 年の大型連休でも 10 日) */
489
491
  const MAX_HOLIDAY_RUN = 31;
490
- function matchAnnual(rule, monthDay) {
492
+ /** @internal 毎年の月日の規則がその月日 "MM-DD" に当てはまるか */
493
+ export function matchAnnual(rule, monthDay) {
491
494
  return rule.from <= rule.to
492
495
  ? monthDay >= rule.from && monthDay <= rule.to
493
496
  : monthDay >= rule.from || monthDay <= rule.to;
494
497
  }
495
- /** @internal 検証済みの規則からスケジュールを作る */
496
- export function buildSchedule(compiled) {
498
+ /**
499
+ * @internal 検証済みの規則から、1 日分の値と値を決めた規則を返す関数を作る。
500
+ * 規則の診断では、規則を取り除いた版と結果を比べるために、祝日を調べる機能(lookup)を共有して作る
501
+ */
502
+ export function createResolver(compiled, lookup) {
497
503
  const { weekly, holiday: holidayRule, annual, dates, recurring } = compiled;
498
- const lookup = createHolidayLookup(compiled.source);
499
504
  const hasShift = recurring.some((r) => r.onHolidayRule === "after-holiday");
500
505
  /** 振替の判断に使う「祝日」か。提供元の対応年の外は祝日でないとみなす(振替元をさかのぼる途中で範囲を出る場合のため) */
501
506
  function isHolidaySafe(date) {
@@ -520,11 +525,11 @@ export function buildSchedule(compiled) {
520
525
  }
521
526
  return undefined;
522
527
  }
523
- function info(date) {
528
+ return function resolve(date) {
524
529
  const dayNumber = toDayNumber(date);
525
530
  const weekday = weekdayOf(date);
526
531
  const holiday = lookup.holidayOn(date);
527
- const make = (value, reason, name, shiftedFrom) => {
532
+ const make = (value, reason, rule, name, shiftedFrom) => {
528
533
  const result = {
529
534
  date,
530
535
  weekday,
@@ -537,46 +542,52 @@ export function buildSchedule(compiled) {
537
542
  result.holiday = holiday;
538
543
  if (shiftedFrom !== undefined)
539
544
  result.shiftedFrom = shiftedFrom;
540
- return Object.freeze(result);
545
+ return { day: Object.freeze(result), rule };
541
546
  };
542
547
  // 1. 個別の日付 2. 毎年の月日
543
548
  const byDate = dates.find((r) => dayNumber >= r.from && dayNumber <= r.to);
544
549
  if (byDate)
545
- return make(byDate.value, "date", byDate.name);
550
+ return make(byDate.value, "date", byDate, byDate.name);
546
551
  const monthDay = date.slice(5);
547
552
  const byAnnual = annual.find((r) => matchAnnual(r, monthDay));
548
553
  if (byAnnual)
549
- return make(byAnnual.value, "annual", byAnnual.name);
554
+ return make(byAnnual.value, "annual", byAnnual, byAnnual.name);
550
555
  if (holiday) {
551
556
  // 3. 祝日の上("over-holiday")
552
557
  const over = recurring.find((r) => r.onHolidayRule === "over-holiday" && matchRecurring(r, date));
553
558
  if (over)
554
- return make(over.value, "recurring", over.name);
559
+ return make(over.value, "recurring", over, over.name);
555
560
  // 4. 祝日
556
561
  if (holidayRule !== "weekday") {
557
562
  const value = "sameAs" in holidayRule ? weekly[holidayRule.sameAs] : holidayRule.value;
558
- return make(value, "holiday", holiday.name);
563
+ return make(value, "holiday", null, holiday.name);
559
564
  }
560
565
  // 祝日の扱いが「曜日どおり」なら、祝日の下("under-holiday")の規則も当てはまる。
561
566
  // "after-holiday" の規則は祝日には当てはめない(振り替える)
562
567
  const under = recurring.find((r) => r.onHolidayRule === "under-holiday" && matchRecurring(r, date));
563
568
  if (under)
564
- return make(under.value, "recurring", under.name);
565
- return make(weekly[weekday], "weekday", undefined);
569
+ return make(under.value, "recurring", under, under.name);
570
+ return make(weekly[weekday], "weekday", null, undefined);
566
571
  }
567
572
  // 5. 繰り返しの規則(祝日でない日。配列で先に書いたものが優先)。振替先も同じ段階で判定する
568
573
  for (const rule of recurring) {
569
574
  if (matchRecurring(rule, date))
570
- return make(rule.value, "recurring", rule.name);
575
+ return make(rule.value, "recurring", rule, rule.name);
571
576
  if (hasShift && rule.onHolidayRule === "after-holiday") {
572
577
  const origin = shiftedOrigin(rule, date);
573
578
  if (origin !== undefined)
574
- return make(rule.value, "recurring", rule.name, origin);
579
+ return make(rule.value, "recurring", rule, rule.name, origin);
575
580
  }
576
581
  }
577
582
  // 6. 曜日
578
- return make(weekly[weekday], "weekday", undefined);
579
- }
583
+ return make(weekly[weekday], "weekday", null, undefined);
584
+ };
585
+ }
586
+ /** @internal 検証済みの規則からスケジュールを作る */
587
+ export function buildSchedule(compiled) {
588
+ const lookup = createHolidayLookup(compiled.source);
589
+ const resolve = createResolver(compiled, lookup);
590
+ const info = (date) => resolve(date).day;
580
591
  const schedule = {
581
592
  timeZone: lookup.timeZone,
582
593
  providers: lookup.providers,
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 規則の診断(bizdays-toolkit/diagnose)。
3
+ *
4
+ * 営業日カレンダー・スケジュールの設定を調べ、誤り・使われない規則・打ち間違いなどを返す。
5
+ * 開発やテストで使う道具なので、アプリの本体のバンドルに入らないようサブパスに分けている。
6
+ */
7
+ export { diagnoseCalendar, diagnoseSchedule, type Diagnostic, type DiagnosticCode, type DiagnosticSeverity, type KnownDiagnosticCode, } from "../core/diagnose.ts";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 規則の診断(bizdays-toolkit/diagnose)。
3
+ *
4
+ * 営業日カレンダー・スケジュールの設定を調べ、誤り・使われない規則・打ち間違いなどを返す。
5
+ * 開発やテストで使う道具なので、アプリの本体のバンドルに入らないようサブパスに分けている。
6
+ */
7
+ export { diagnoseCalendar, diagnoseSchedule, } from "../core/diagnose.js";
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * 公開する名前と型は etc/api.md に記録し、pnpm check で意図しない変化を検出する。
9
9
  */
10
10
  export { createCalendar, type AnnualDate, type AnnualRange, type BusinessDayOptions, type Calendar, type CalendarOptions, type DateRange, type DayInfo, type DayReason, type HolidayPolicy, type KnownDayReason, type SpecificDate, } from "./core/calendar.ts";
11
- export { createSchedule, type AnnualRangeValue, type AnnualValue, type CivilDateTimeParts, type DateRangeValue, type DateValue, type DayOfMonth, type DayOfMonthPattern, type HolidayLookup, type HolidayRule, type HolidaySourceOptions, type HolidayValueRule, type KnownScheduleReason, type Month, type Nth, type OnHolidayRule, type RecurringPattern, type RecurringRule, type RecurringValue, type Schedule, type ScheduleDay, type ScheduleOptions, type ScheduleReason, type WeekdayPattern, } from "./core/schedule.ts";
11
+ export { createSchedule, type AnnualRangeValue, type AnnualValue, type CivilDateTimeParts, type DateRangeValue, type DateValue, type DayOfMonth, type DayOfMonthPattern, type HolidayLookup, type HolidaySourceOptions, type HolidayValueRule, type KnownScheduleReason, type Month, type Nth, type OnHolidayRule, type RecurringPattern, type RecurringRule, type RecurringValue, type Schedule, type ScheduleDay, type ScheduleOptions, type ScheduleReason, type WeekdayPattern, } from "./core/schedule.ts";
12
12
  export { addDays, diffDays, isValidDate, weekdayOf, type DateString, type Weekday, } from "./core/date.ts";
13
13
  export { isBizdaysError, type ErrorCode, type KnownErrorCode } from "./core/errors.ts";
14
14
  export { toDateString, toDateTimeParts, type DateInput, type TemporalDateLike, type TemporalInstantLike, } from "./core/input.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bizdays-toolkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Holidays, business-day calendar and day-by-day schedules (e.g. store hours) with time zone support. Japanese holidays verified against the Cabinet Office data. / 祝日と営業日のカレンダー(タイムゾーン対応・日本の祝日は内閣府データで全日照合)",
5
5
  "keywords": [
6
6
  "bizdays",
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/jp/index.d.ts",
41
41
  "default": "./dist/jp/index.js"
42
42
  },
43
+ "./diagnose": {
44
+ "types": "./dist/diagnose/index.d.ts",
45
+ "default": "./dist/diagnose/index.js"
46
+ },
43
47
  "./package.json": "./package.json"
44
48
  },
45
49
  "scripts": {