@mudraforge/statutory 2.4.1 → 2.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +53 -12
  3. package/bin/cli.js +102 -8
  4. package/docs/ARCHITECTURE_DECISIONS.md +13 -9
  5. package/docs/CATALOG.md +164 -0
  6. package/docs/CORPUS_QUALITY.md +9 -6
  7. package/docs/PILOT_EVALUATION_GUIDE.md +149 -0
  8. package/okf/direct_tax/tds_commission_194h/circulars/finance_act_2024_rate_cut_memo.md +29 -0
  9. package/okf/direct_tax/tds_commission_194h/forms/form_26q_quarterly_return.json +30 -0
  10. package/okf/direct_tax/tds_commission_194h/section.json +63 -0
  11. package/okf/direct_tax/tds_contractor_194c/circulars/cbdt_circular_23_2017_gst_unbundling.md +27 -0
  12. package/okf/direct_tax/tds_contractor_194c/forms/form_26q_quarterly_return.json +31 -0
  13. package/okf/direct_tax/tds_contractor_194c/forms/transporter_194c_declaration.json +14 -0
  14. package/okf/direct_tax/tds_contractor_194c/section.json +66 -0
  15. package/okf/direct_tax/tds_rent_194i/circulars/cbdt_circular_23_2017_gst_unbundling.md +27 -0
  16. package/okf/direct_tax/tds_rent_194i/forms/form_26q_quarterly_return.json +31 -0
  17. package/okf/direct_tax/tds_rent_194i/section.json +63 -0
  18. package/okf/indirect_tax_gst/composite_mixed_supplies_sec8/circulars/cbic_circular_47_2018_composite_mixed_supplies.md +41 -0
  19. package/okf/indirect_tax_gst/composite_mixed_supplies_sec8/forms/composite_mixed_supply_assessment_matrix.json +51 -0
  20. package/okf/indirect_tax_gst/composite_mixed_supplies_sec8/section.json +58 -0
  21. package/okf/indirect_tax_gst/itc_eligibility_16_2/circulars/cbic_circular_170_2022_itc_reporting.md +53 -0
  22. package/okf/indirect_tax_gst/itc_eligibility_16_2/forms/gstr_3b_table_4_itc_reversal.json +32 -0
  23. package/okf/indirect_tax_gst/itc_eligibility_16_2/section.json +84 -0
  24. package/okf/indirect_tax_gst/rcm_liability_sec9_3/circulars/cbic_circular_102_2019_rcm_clarifications.md +33 -0
  25. package/okf/indirect_tax_gst/rcm_liability_sec9_3/forms/rcm_self_invoice_payment_voucher.json +65 -0
  26. package/okf/indirect_tax_gst/rcm_liability_sec9_3/section.json +67 -0
  27. package/okf/international_tax/cross_border_remittance_195/circulars/cbdt_circular_03_2022_form10f_electronic.md +31 -0
  28. package/okf/international_tax/cross_border_remittance_195/circulars/supreme_court_engineering_analysis_doctrine.md +41 -0
  29. package/okf/international_tax/cross_border_remittance_195/forms/form_15ca_15cb_compliance_checklist.json +73 -0
  30. package/okf/international_tax/cross_border_remittance_195/section.json +74 -0
  31. package/okf/manifest.json +66 -13
  32. package/okf/msme_commercial/enterprise_classification_sec7/circulars/msme_notification_2119_e_composite_criteria.md +37 -0
  33. package/okf/msme_commercial/enterprise_classification_sec7/forms/udyam_composite_calculation_worksheet.json +46 -0
  34. package/okf/msme_commercial/enterprise_classification_sec7/section.json +64 -0
  35. package/okf/msme_commercial/mca_msme_1_filing/circulars/mca_order_2019_specified_companies.md +42 -0
  36. package/okf/msme_commercial/mca_msme_1_filing/forms/form_msme_1_half_yearly_return.json +93 -0
  37. package/okf/msme_commercial/mca_msme_1_filing/section.json +56 -0
  38. package/okf/msme_commercial/statutory_disclosures_sec22/circulars/icai_guidance_note_schedule_iii_trade_payables.md +42 -0
  39. package/okf/msme_commercial/statutory_disclosures_sec22/forms/schedule_iii_msme_aging_table.json +60 -0
  40. package/okf/msme_commercial/statutory_disclosures_sec22/forms/section_23_tax_addback_schedule.json +35 -0
  41. package/okf/msme_commercial/statutory_disclosures_sec22/section.json +65 -0
  42. package/package.json +2 -2
  43. package/src/index.js +360 -37
  44. package/types/index.d.ts +27 -0
package/src/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
+ import os from 'node:os';
13
14
  import crypto from 'node:crypto';
14
15
  import { fileURLToPath } from 'node:url';
15
16
 
@@ -17,13 +18,18 @@ const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = path.dirname(__filename);
18
19
  export const DEFAULT_LOCAL_OKF_DIR = path.resolve(__dirname, '..', 'okf');
19
20
  export const DEFAULT_BASE_URL = 'https://api.mudraforge.in';
20
- export const STATUTORY_CLIENT_VERSION = '2.4.1';
21
+ export const STATUTORY_CLIENT_VERSION = '2.5.0';
21
22
 
22
23
  /**
23
24
  * Pinned official release Merkle root digests.
24
25
  * Prevents unauthorized bundle substitution attacks even if an attacker controls the remote endpoint.
25
26
  */
26
27
  export const PINNED_RELEASE_ROOTS = new Set([
28
+ 'sha256-c32be389bfb82a52ffc40670b74103216c45f3b284c66d2b0020097eb36a25f9', // v2.5.0 Merkle Manifest Root (18 Sections across 5 Domains)
29
+ 'sha256-f3b230a0774eb55d607403b5a656bbe2950a32d07145a46f0eb083232ec12986', // v2.6.0 Edge Sync Bundle Root (12 Sections across 5 Domains)
30
+ 'sha256-4e9d9c064e55748dc6389eae1b5cb2fec1ad7f1c811097afd57e4fb0a6f6454e', // v2.6.0 Local Static Manifest Root (12 Sections across 5 Domains)
31
+ 'sha256-b421910e35f4f16321f6a72382377db66a05ead521ff09d18a91ec9d534d0069', // v2.6.0 Edge Sync Bundle Root (11 Sections)
32
+ 'sha256-dbbd3bc618b7807c4e76861329bd8c6903ef7f9cb28efc9a8693abc06915c9c1', // v2.6.0 Local Static Manifest Root (11 Sections)
27
33
  'sha256-7fe98cf432db5d31ee6b65bbb9d77575ce8c9192eec6f318e3ad3382af0ba900', // v2.5.0 Edge Sync Bundle Root
28
34
  'sha256-96089fe2835d0b9493293a1e5205e0b6b46f16771c9380d1f571969b453ba80d', // v2.4.0 Local Static Manifest Root
29
35
  'sha256-d9612074241f7c2cc1d05fa5eee254996f21db56ad1ab71ac36a084ffd4169e1' // Baseline Release Root
@@ -33,14 +39,16 @@ export const PINNED_RELEASE_ROOTS = new Set([
33
39
  * Canonical MudraForge Statutory Root Ed25519 Public Key (SPKI DER Base64).
34
40
  * Used for verifying digital signatures on remote statutory update bundles.
35
41
  */
36
- export const MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY = 'MCowBQYDK2VwAyEAw/XsE6QGtQzdq3EUP3xFpPFHu1x87VSNreXmXr6W17s=';
42
+ export const MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY = 'MCowBQYDK2VwAyEAYN4ZtkUBOwybN68SiVUI9YMDlEBNiCa1CAgUNx6rXUo=';
43
+ export const LEGACY_STATUTORY_ROOT_PUBLIC_KEY = 'MCowBQYDK2VwAyEAw/XsE6QGtQzdq3EUP3xFpPFHu1x87VSNreXmXr6W17s=';
37
44
 
38
45
  /**
39
46
  * Authoritative set of trusted MudraForge Root Public Keys.
40
47
  * Public keys supplied through options that are not present in this set are rejected.
41
48
  */
42
49
  export const TRUSTED_STATUTORY_PUBLIC_KEYS = new Set([
43
- MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY
50
+ MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY,
51
+ LEGACY_STATUTORY_ROOT_PUBLIC_KEY
44
52
  ]);
45
53
 
46
54
  /**
@@ -50,7 +58,15 @@ export const TRUSTED_STATUTORY_KEYS_METADATA = {
50
58
  [MUDRAFORGE_STATUTORY_ROOT_PUBLIC_KEY]: {
51
59
  keyId: 'mudraforge-root-2026-v1',
52
60
  algorithm: 'Ed25519',
53
- purpose: 'MudraForge Open Knowledge Format Statutory Root Authority',
61
+ purpose: 'MudraForge Open Knowledge Format Statutory Root Authority (Active)',
62
+ validFrom: '2026-01-01T00:00:00Z',
63
+ validTo: '2027-12-31T23:59:59Z',
64
+ status: 'ACTIVE'
65
+ },
66
+ [LEGACY_STATUTORY_ROOT_PUBLIC_KEY]: {
67
+ keyId: 'mudraforge-root-2026-v0',
68
+ algorithm: 'Ed25519',
69
+ purpose: 'MudraForge Statutory Genesis Anchor (Legacy)',
54
70
  validFrom: '2026-01-01T00:00:00Z',
55
71
  validTo: '2027-12-31T23:59:59Z',
56
72
  status: 'ACTIVE'
@@ -304,6 +320,7 @@ export function lookupLocalStatute(section, okfDir = DEFAULT_LOCAL_OKF_DIR) {
304
320
  }
305
321
 
306
322
  const files = findJsonFilesRecursively(okfDir);
323
+ const records = [];
307
324
  for (const filePath of files) {
308
325
  let data;
309
326
  try {
@@ -311,55 +328,68 @@ export function lookupLocalStatute(section, okfDir = DEFAULT_LOCAL_OKF_DIR) {
311
328
  } catch (_) {
312
329
  continue;
313
330
  }
314
-
315
- // Must be a valid statutory record with sectionCode
316
331
  if (!data || !data.sectionCode || typeof data.sectionCode !== 'string') continue;
332
+ records.push({ filePath, data });
333
+ }
317
334
 
318
- let isMatch = false;
335
+ let matchedRecord = null;
336
+
337
+ // Pass 1: Authoritative exact match on sectionCode, sectionId, or aliases
338
+ for (const rec of records) {
339
+ const { data } = rec;
319
340
  const secCode = data.sectionCode.toLowerCase();
320
341
  const secId = (data.sectionId && typeof data.sectionId === 'string') ? data.sectionId.toLowerCase() : '';
321
342
 
322
343
  // 1. Direct sectionCode / sectionId match
323
344
  if (secCode === rawClean || (secId && secId === rawClean)) {
324
- isMatch = true;
345
+ matchedRecord = rec;
346
+ break;
325
347
  }
326
348
 
327
349
  // 2. Normalized alphanumeric match (handles "17_5", "17(5)", "17-5", "msmed_15_16")
328
350
  if (
329
- !isMatch && (
330
- secCode.replace(/[()_-\s]+/g, '') === normalized ||
331
- (secId && secId.replace(/[()_-\s]+/g, '') === normalized)
332
- )
351
+ secCode.replace(/[()_-\s]+/g, '') === normalized ||
352
+ (secId && secId.replace(/[()_-\s]+/g, '') === normalized)
333
353
  ) {
334
- isMatch = true;
354
+ matchedRecord = rec;
355
+ break;
335
356
  }
336
357
 
337
358
  // 3. Aliases match
338
359
  if (
339
- !isMatch &&
340
360
  data.aliases &&
341
361
  data.aliases.some(
342
362
  a => a.toLowerCase() === rawClean || a.toLowerCase().replace(/[()_-\s]+/g, '') === normalized
343
363
  )
344
364
  ) {
345
- isMatch = true;
365
+ matchedRecord = rec;
366
+ break;
346
367
  }
368
+ }
347
369
 
348
- // 4. Folder or filename match (handles "tds_goods_194q", "194Q-tds-on-purchase...")
349
- if (!isMatch) {
370
+ // Pass 2: Heuristic folder or filename match (only if no authoritative code match found)
371
+ if (!matchedRecord) {
372
+ const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
373
+ const tokenRegex = new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i');
374
+ for (const rec of records) {
375
+ const { filePath } = rec;
350
376
  const fileName = path.basename(filePath).toLowerCase().replace(/\.json$/, '');
351
377
  const folderName = path.basename(path.dirname(filePath)).toLowerCase();
378
+
352
379
  if (
353
- fileName.startsWith(rawClean) || fileName.startsWith(normalized) ||
354
- folderName.includes(rawClean) || folderName.includes(normalized)
380
+ fileName.startsWith(`${rawClean}-`) || fileName.startsWith(`${normalized}-`) ||
381
+ tokenRegex.test(folderName) || tokenRegex.test(fileName)
355
382
  ) {
356
- isMatch = true;
383
+ matchedRecord = rec;
384
+ break;
357
385
  }
358
386
  }
387
+ }
359
388
 
360
- if (isMatch) {
361
- // Attach supporting circulars and compliance forms if present in domain hierarchy
362
- const parentDir = path.dirname(filePath);
389
+ if (matchedRecord) {
390
+ const { filePath, data } = matchedRecord;
391
+ // Attach supporting circulars and compliance forms if present in domain hierarchy
392
+ const parentDir = path.dirname(filePath);
363
393
  const circularsDir = path.join(parentDir, 'circulars');
364
394
  const formsDir = path.join(parentDir, 'forms');
365
395
 
@@ -369,7 +399,6 @@ export function lookupLocalStatute(section, okfDir = DEFAULT_LOCAL_OKF_DIR) {
369
399
  };
370
400
 
371
401
  return data;
372
- }
373
402
  }
374
403
  return null;
375
404
  }
@@ -430,6 +459,78 @@ export async function checkUpdates(options = {}) {
430
459
  };
431
460
  }
432
461
 
462
+ /**
463
+ * Check if a newer version of the @mudraforge/statutory npm package is published on npmjs.com.
464
+ * Uses a lightweight cached file in os.tmpdir() to prevent frequent network roundtrips.
465
+ *
466
+ * @param {Object} [options]
467
+ * @param {string} [options.currentVersion] - Current installed version (defaults to STATUTORY_CLIENT_VERSION).
468
+ * @param {number} [options.cacheTtlMs] - Cache duration in milliseconds (default: 24 hours).
469
+ * @param {number} [options.timeoutMs] - Request timeout in milliseconds (default: 1500ms).
470
+ * @param {string} [options.registryUrl] - Registry endpoint (default: https://registry.npmjs.org/@mudraforge/statutory/latest).
471
+ * @returns {Promise<{ updateAvailable: boolean, currentVersion: string, latestVersion: string } | null>}
472
+ */
473
+ export async function checkForPackageUpdate(options = {}) {
474
+ if (process.env.NO_UPDATE_NOTIFIER === '1' || process.env.CI === 'true') {
475
+ return null;
476
+ }
477
+
478
+ const currentVersion = options.currentVersion || STATUTORY_CLIENT_VERSION;
479
+ const cacheTtlMs = options.cacheTtlMs !== undefined ? options.cacheTtlMs : 24 * 60 * 60 * 1000;
480
+ const timeoutMs = options.timeoutMs || 1500;
481
+ const cacheFile = path.join(os.tmpdir(), '.mudraforge_statutory_npm_update.json');
482
+
483
+ let cached = null;
484
+ if (cacheTtlMs > 0) {
485
+ try {
486
+ if (fs.existsSync(cacheFile)) {
487
+ const data = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
488
+ if (data && typeof data.lastChecked === 'number' && (Date.now() - data.lastChecked < cacheTtlMs)) {
489
+ cached = data;
490
+ }
491
+ }
492
+ } catch (_) {}
493
+ }
494
+
495
+ let latestVersion = cached?.latestVersion;
496
+
497
+ if (!latestVersion) {
498
+ try {
499
+ const url = options.registryUrl || 'https://registry.npmjs.org/@mudraforge/statutory/latest';
500
+ const res = await fetch(url, {
501
+ signal: AbortSignal.timeout(timeoutMs),
502
+ headers: { 'Accept': 'application/json' }
503
+ });
504
+ if (res.ok) {
505
+ const json = await res.json();
506
+ if (json && json.version && typeof json.version === 'string') {
507
+ latestVersion = json.version;
508
+ if (cacheTtlMs > 0) {
509
+ try {
510
+ fs.writeFileSync(cacheFile, JSON.stringify({
511
+ lastChecked: Date.now(),
512
+ latestVersion
513
+ }), 'utf8');
514
+ } catch (_) {}
515
+ }
516
+ }
517
+ }
518
+ } catch (_) {
519
+ // Fail open and silent on network error, offline, or timeout
520
+ return null;
521
+ }
522
+ }
523
+
524
+ if (!latestVersion) return null;
525
+
526
+ const cmp = compareSemver(latestVersion, currentVersion);
527
+ return {
528
+ updateAvailable: cmp > 0,
529
+ currentVersion,
530
+ latestVersion
531
+ };
532
+ }
533
+
433
534
  /**
434
535
  * Download and synchronize the latest OKF knowledge bundle to disk.
435
536
  * Hardened with:
@@ -466,7 +567,6 @@ export async function syncOkf(options = {}) {
466
567
  assertSecureEndpoint(baseUrl);
467
568
 
468
569
  const okfDir = options.okfDir || DEFAULT_LOCAL_OKF_DIR;
469
- const sectionsDir = path.join(okfDir, 'sections');
470
570
  const localManifest = getLocalManifest(okfDir);
471
571
 
472
572
  const bundleUrl = new URL('/api/v1/statutory/bundle.json', baseUrl);
@@ -554,6 +654,7 @@ export async function syncOkf(options = {}) {
554
654
  const fileHashes = {};
555
655
  const syncedSectionCodes = [];
556
656
  const seenCodes = new Set();
657
+ const stagedRelPaths = [];
557
658
 
558
659
  for (const statute of bundle.statutes) {
559
660
  // Verify core required statutory fields
@@ -567,20 +668,30 @@ export async function syncOkf(options = {}) {
567
668
  }
568
669
  seenCodes.add(codeKey);
569
670
 
570
- const fileName = resolveSafeStatuteFilename(statute);
571
- const targetPath = path.resolve(stagingDir, fileName);
671
+ // Determine relative deployment target:
672
+ // If statute provides relPath, preserve domain hierarchy (e.g. "direct_tax/tds_goods_194q/section.json")
673
+ // Otherwise, fall back to flat "sections/<filename>.json" for legacy compatibility
674
+ const relTarget = statute.relPath
675
+ ? statute.relPath.replace(/\\/g, '/')
676
+ : path.join('sections', resolveSafeStatuteFilename(statute));
677
+
678
+ const targetPath = path.resolve(stagingDir, relTarget);
572
679
 
573
680
  // Path Traversal Containment Assertion
574
681
  if (!targetPath.startsWith(path.resolve(stagingDir) + path.sep)) {
575
- throw new Error(`[@mudraforge/statutory] Security violation: Path traversal detected in filename "${fileName}".`);
682
+ throw new Error(`[@mudraforge/statutory] Security violation: Path traversal detected in target "${relTarget}".`);
576
683
  }
577
684
 
685
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
686
+
578
687
  const jsonStr = JSON.stringify(statute, null, 2);
579
688
  fs.writeFileSync(targetPath, jsonStr, 'utf8');
580
689
 
690
+ const fileName = resolveSafeStatuteFilename(statute);
581
691
  const fileHash = crypto.createHash('sha256').update(jsonStr).digest('hex');
582
692
  fileHashes[fileName] = fileHash;
583
693
  syncedSectionCodes.push(statute.sectionCode);
694
+ stagedRelPaths.push(relTarget);
584
695
  }
585
696
 
586
697
  // 2.5 Cryptographic Root Merkle Digest Verification
@@ -621,7 +732,14 @@ export async function syncOkf(options = {}) {
621
732
  const isPinned = PINNED_RELEASE_ROOTS.has(bundle.rootHash);
622
733
 
623
734
  if (!isSignatureValid && !isPinned) {
624
- throw new Error(`[@mudraforge/statutory] Security violation: Untrusted bundle rootHash "${bundle.rootHash}". Bundle is neither cryptographically signed by MudraForge Root Authority nor pinned in authorized release roots. Set options.allowUnverifiedRoot=true for local non-production fixtures.`);
735
+ const isEdgeNewer = bundle.okfVersion && compareSemver(bundle.okfVersion, STATUTORY_CLIENT_VERSION) >= 0;
736
+ const remediation = isEdgeNewer
737
+ ? `The remote legal bundle (OKF v${bundle.okfVersion}) has a newer cryptographic release root than recognized by your installed package (v${STATUTORY_CLIENT_VERSION}).\n` +
738
+ ` To receive the updated release roots and trust anchors, update your client:\n` +
739
+ ` npm install @mudraforge/statutory@latest\n` +
740
+ ` npx @mudraforge/statutory@latest sync`
741
+ : `Bundle is neither cryptographically signed by MudraForge Root Authority nor pinned in authorized release roots. Set options.allowUnverifiedRoot=true for local non-production fixtures.`;
742
+ throw new Error(`[@mudraforge/statutory] Security violation: Untrusted bundle rootHash "${bundle.rootHash}". ${remediation}`);
625
743
  }
626
744
  }
627
745
 
@@ -647,19 +765,18 @@ export async function syncOkf(options = {}) {
647
765
  }
648
766
 
649
767
  // 3. Atomically deploy from staging to destination
650
- fs.mkdirSync(sectionsDir, { recursive: true });
651
-
652
- const stagedFiles = fs.readdirSync(stagingDir);
653
- for (const stagedFile of stagedFiles) {
654
- const srcPath = path.join(stagingDir, stagedFile);
655
- const destPath = path.join(sectionsDir, stagedFile);
768
+ for (const relTarget of stagedRelPaths) {
769
+ const srcPath = path.join(stagingDir, relTarget);
770
+ const destPath = path.join(okfDir, relTarget);
656
771
 
657
772
  // Safety assertion on destination path containment
658
773
  const resolvedDest = path.resolve(destPath);
659
- if (!resolvedDest.startsWith(path.resolve(sectionsDir) + path.sep)) {
660
- throw new Error(`[@mudraforge/statutory] Security violation: Destination path traversal detected in "${stagedFile}".`);
774
+ if (!resolvedDest.startsWith(path.resolve(okfDir) + path.sep)) {
775
+ throw new Error(`[@mudraforge/statutory] Security violation: Destination path traversal detected in "${relTarget}".`);
661
776
  }
662
777
 
778
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
779
+
663
780
  try {
664
781
  fs.renameSync(srcPath, destPath);
665
782
  } catch {
@@ -669,12 +786,20 @@ export async function syncOkf(options = {}) {
669
786
  }
670
787
 
671
788
  // 4. Write verified manifest
789
+ const manifestDomains = {};
790
+ for (const statute of bundle.statutes) {
791
+ const domain = statute.domain || (statute.relPath ? statute.relPath.split('/')[0] : 'sections');
792
+ if (!manifestDomains[domain]) manifestDomains[domain] = [];
793
+ if (statute.sectionCode) manifestDomains[domain].push(statute.sectionCode);
794
+ }
795
+
672
796
  const manifest = {
673
797
  okfVersion: bundle.okfVersion,
674
798
  publishedAt: bundle.publishedAt || new Date().toISOString(),
675
799
  rootHash: bundle.rootHash,
676
800
  totalSections: bundle.statutes.length,
677
801
  changelog: bundle.changelog || 'Synchronized from Cloudflare Edge API',
802
+ domains: manifestDomains,
678
803
  sections: syncedSectionCodes
679
804
  };
680
805
  fs.writeFileSync(path.join(okfDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
@@ -1120,3 +1245,201 @@ export class StatutoryClient {
1120
1245
  return await syncOkf({ baseUrl: this.baseUrl, okfDir: this.okfDir });
1121
1246
  }
1122
1247
  }
1248
+
1249
+ /**
1250
+ * Standard Indian State & Union Territory Codes (Census 2011 / GSTN 2-digit prefix).
1251
+ */
1252
+ export const INDIAN_STATE_CODES = {
1253
+ '01': 'Jammu & Kashmir',
1254
+ '02': 'Himachal Pradesh',
1255
+ '03': 'Punjab',
1256
+ '04': 'Chandigarh',
1257
+ '05': 'Uttarakhand',
1258
+ '06': 'Haryana',
1259
+ '07': 'Delhi',
1260
+ '08': 'Rajasthan',
1261
+ '09': 'Uttar Pradesh',
1262
+ '10': 'Bihar',
1263
+ '11': 'Sikkim',
1264
+ '12': 'Arunachal Pradesh',
1265
+ '13': 'Nagaland',
1266
+ '14': 'Manipur',
1267
+ '15': 'Mizoram',
1268
+ '16': 'Tripura',
1269
+ '17': 'Meghalaya',
1270
+ '18': 'Assam',
1271
+ '19': 'West Bengal',
1272
+ '20': 'Jharkhand',
1273
+ '21': 'Odisha',
1274
+ '22': 'Chhattisgarh',
1275
+ '23': 'Madhya Pradesh',
1276
+ '24': 'Gujarat',
1277
+ '26': 'Dadra & Nagar Haveli and Daman & Diu',
1278
+ '27': 'Maharashtra',
1279
+ '29': 'Karnataka',
1280
+ '30': 'Goa',
1281
+ '31': 'Lakshadweep',
1282
+ '32': 'Kerala',
1283
+ '33': 'Tamil Nadu',
1284
+ '34': 'Puducherry',
1285
+ '35': 'Andaman & Nicobar Islands',
1286
+ '36': 'Telangana',
1287
+ '37': 'Andhra Pradesh',
1288
+ '38': 'Ladakh',
1289
+ '97': 'Other Territory',
1290
+ '99': 'Centre Jurisdiction'
1291
+ };
1292
+
1293
+ /**
1294
+ * Statutory 4th character legal status classification for Indian Permanent Account Numbers (PAN).
1295
+ * Enacted under Section 139A of the Income-tax Act, 1961 read with Rule 114 of Income Tax Rules.
1296
+ */
1297
+ export const PAN_ENTITY_TYPES = {
1298
+ 'P': { code: 'P', name: 'Individual', isIndividualOrHuf: true, standard194cRatePercent: 1.0 },
1299
+ 'H': { code: 'H', name: 'Hindu Undivided Family (HUF)', isIndividualOrHuf: true, standard194cRatePercent: 1.0 },
1300
+ 'C': { code: 'C', name: 'Company', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1301
+ 'F': { code: 'F', name: 'Partnership Firm / LLP', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1302
+ 'T': { code: 'T', name: 'Trust', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1303
+ 'A': { code: 'A', name: 'Association of Persons (AOP)', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1304
+ 'B': { code: 'B', name: 'Body of Individuals (BOI)', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1305
+ 'L': { code: 'L', name: 'Local Authority', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1306
+ 'J': { code: 'J', name: 'Artificial Juridical Person', isIndividualOrHuf: false, standard194cRatePercent: 2.0 },
1307
+ 'G': { code: 'G', name: 'Government Agency', isIndividualOrHuf: false, standard194cRatePercent: 2.0 }
1308
+ };
1309
+
1310
+ const BASE36_ALPHANUMERIC = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
1311
+
1312
+ /**
1313
+ * Validates the check digit of a 15-character Goods and Services Tax Identification Number (GSTIN)
1314
+ * using the official ISO/IEC 7064 Mod 36, 36 check digit algorithm.
1315
+ *
1316
+ * Offline, zero-dependency, and deterministic. Requires zero network calls.
1317
+ *
1318
+ * @param {string} gstin 15-character alphanumeric GSTIN string
1319
+ * @returns {boolean} True if the 15th character exactly matches the calculated check character
1320
+ */
1321
+ export function isValidGstinChecksum(gstin) {
1322
+ if (!gstin || typeof gstin !== 'string') return false;
1323
+ const clean = gstin.trim().toUpperCase();
1324
+
1325
+ // Structural syntax: State (2 digits) + PAN (10 chars) + Entity (1 char) + Z (1 char) + Check (1 char)
1326
+ const pattern = /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$/;
1327
+ if (!pattern.test(clean)) return false;
1328
+
1329
+ let total = 0;
1330
+ for (let i = 0; i < 14; i++) {
1331
+ const val = BASE36_ALPHANUMERIC.indexOf(clean[i]);
1332
+ if (val === -1) return false;
1333
+ const factor = (i % 2 === 0) ? 1 : 2;
1334
+ const product = val * factor;
1335
+ total += Math.floor(product / 36) + (product % 36);
1336
+ }
1337
+
1338
+ const expectedIndex = (36 - (total % 36)) % 36;
1339
+ const expectedChar = BASE36_ALPHANUMERIC[expectedIndex];
1340
+ return clean[14] === expectedChar;
1341
+ }
1342
+
1343
+ /**
1344
+ * Deterministically decodes an Indian Tax Identifier (PAN or GSTIN).
1345
+ *
1346
+ * Capabilities:
1347
+ * - Structural regular expression pattern validation
1348
+ * - Offline ISO/IEC 7064 Mod 36, 36 checksum calculation for GSTIN
1349
+ * - State code and name extraction from GSTIN prefix
1350
+ * - Embedded 10-character PAN extraction from GSTIN positions 3..12
1351
+ * - Statutory legal entity classification via 4th character of PAN (Individual, HUF, Company, Firm, etc.)
1352
+ * - Statutory Section 194C withholding rate derivation (1% for Individual/HUF vs 2% for Other Entities)
1353
+ *
1354
+ * Zero dependencies, 0-neuron, zero network calls.
1355
+ *
1356
+ * @param {string} rawId PAN (10 characters) or GSTIN (15 characters)
1357
+ * @returns {Object} Decoded tax entity metadata
1358
+ */
1359
+ export function decodeTaxIdentifier(rawId) {
1360
+ if (!rawId || typeof rawId !== 'string') {
1361
+ return {
1362
+ isValid: false,
1363
+ error: 'Tax identifier must be a non-empty string.'
1364
+ };
1365
+ }
1366
+
1367
+ const clean = rawId.trim().toUpperCase();
1368
+
1369
+ // 1. Process 15-character GSTIN
1370
+ if (clean.length === 15) {
1371
+ const pattern = /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z\d]Z[A-Z\d]$/;
1372
+ if (!pattern.test(clean)) {
1373
+ return {
1374
+ isValid: false,
1375
+ type: 'GSTIN',
1376
+ gstin: clean,
1377
+ error: 'Malformed GSTIN format: expected 2 digits state + 10 chars PAN + 1 entity char + Z + 1 check char.'
1378
+ };
1379
+ }
1380
+
1381
+ const isChecksumValid = isValidGstinChecksum(clean);
1382
+ const stateCode = clean.slice(0, 2);
1383
+ const pan = clean.slice(2, 12);
1384
+ const entityCode = pan[3];
1385
+ const entityMeta = PAN_ENTITY_TYPES[entityCode] || {
1386
+ code: entityCode,
1387
+ name: 'Other Entity / Non-Individual',
1388
+ isIndividualOrHuf: false,
1389
+ standard194cRatePercent: 2.0
1390
+ };
1391
+
1392
+ return {
1393
+ isValid: isChecksumValid,
1394
+ checksumValid: isChecksumValid,
1395
+ type: 'GSTIN',
1396
+ gstin: clean,
1397
+ pan,
1398
+ stateCode,
1399
+ stateName: INDIAN_STATE_CODES[stateCode] || 'Unknown Territory',
1400
+ entityCode,
1401
+ entityType: entityMeta.name,
1402
+ isIndividualOrHuf: entityMeta.isIndividualOrHuf,
1403
+ standard194cRatePercent: entityMeta.standard194cRatePercent,
1404
+ error: isChecksumValid ? null : 'GSTIN checksum mismatch under ISO/IEC 7064 Mod 36, 36.'
1405
+ };
1406
+ }
1407
+
1408
+ // 2. Process 10-character PAN
1409
+ if (clean.length === 10) {
1410
+ const panPattern = /^[A-Z]{5}\d{4}[A-Z]$/;
1411
+ if (!panPattern.test(clean)) {
1412
+ return {
1413
+ isValid: false,
1414
+ type: 'PAN',
1415
+ pan: clean,
1416
+ error: 'Malformed PAN format: expected 5 letters + 4 digits + 1 letter.'
1417
+ };
1418
+ }
1419
+
1420
+ const entityCode = clean[3];
1421
+ const entityMeta = PAN_ENTITY_TYPES[entityCode] || {
1422
+ code: entityCode,
1423
+ name: 'Other Entity / Non-Individual',
1424
+ isIndividualOrHuf: false,
1425
+ standard194cRatePercent: 2.0
1426
+ };
1427
+
1428
+ return {
1429
+ isValid: true,
1430
+ type: 'PAN',
1431
+ pan: clean,
1432
+ entityCode,
1433
+ entityType: entityMeta.name,
1434
+ isIndividualOrHuf: entityMeta.isIndividualOrHuf,
1435
+ standard194cRatePercent: entityMeta.standard194cRatePercent,
1436
+ error: null
1437
+ };
1438
+ }
1439
+
1440
+ return {
1441
+ isValid: false,
1442
+ error: `Invalid identifier length (${clean.length}): Expected 10 characters for PAN or 15 characters for GSTIN.`
1443
+ };
1444
+ }
1445
+
package/types/index.d.ts CHANGED
@@ -148,6 +148,12 @@ export declare function getLocalManifest(okfDir?: string): any;
148
148
  export declare function lookupLocalStatute(section: string, okfDir?: string): StatutoryRecord | null;
149
149
  export declare function computeTemporalStatus(statute: StatutoryRecord, asOfDate?: string): 'ACTIVE' | 'SUNSET_SUPERSEDED' | 'FUTURE_PENDING';
150
150
  export declare function checkUpdates(options?: { baseUrl?: string; okfDir?: string }): Promise<UpdateInfo>;
151
+ export declare function checkForPackageUpdate(options?: {
152
+ currentVersion?: string;
153
+ cacheTtlMs?: number;
154
+ timeoutMs?: number;
155
+ registryUrl?: string;
156
+ }): Promise<{ updateAvailable: boolean; currentVersion: string; latestVersion: string } | null>;
151
157
  export declare function syncOkf(options?: { baseUrl?: string; okfDir?: string }): Promise<SyncResult>;
152
158
 
153
159
  export declare function lookupStatute(
@@ -184,3 +190,24 @@ export declare class StatutoryClient {
184
190
  checkUpdates(): Promise<UpdateInfo>;
185
191
  sync(): Promise<SyncResult>;
186
192
  }
193
+
194
+ export interface TaxIdentifierResult {
195
+ isValid: boolean;
196
+ checksumValid?: boolean;
197
+ type?: 'GSTIN' | 'PAN';
198
+ gstin?: string;
199
+ pan?: string;
200
+ stateCode?: string;
201
+ stateName?: string;
202
+ entityCode?: string;
203
+ entityType?: string;
204
+ isIndividualOrHuf?: boolean;
205
+ standard194cRatePercent?: number;
206
+ error?: string | null;
207
+ }
208
+
209
+ export declare const INDIAN_STATE_CODES: Record<string, string>;
210
+ export declare const PAN_ENTITY_TYPES: Record<string, { code: string; name: string; isIndividualOrHuf: boolean; standard194cRatePercent: number }>;
211
+ export declare function isValidGstinChecksum(gstin: string): boolean;
212
+ export declare function decodeTaxIdentifier(rawId: string): TaxIdentifierResult;
213
+