Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config/devel.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"fixtures": {
// @todo these constants needs to be in sync in cve-fixtures
// so that testing snapshots are consistent and valid
"name": "fixtures-search-baseline-1086", // release tag
"numCves": "1086" // possible identifier assuming we always add cves to a new release
"name": "fixtures-search-baseline-1008", // release tag
"numCves": "1008" // possible identifier assuming we always add cves to a new release
}
},
// constants for testing node-config
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions src/adapters/search/OpensearchDatetimeUtils.test.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, test, expect } from '@jest/globals';
import { toSearchDateDslString } from './OpensearchDatetimeUtils';
import { IsoDate, IsoDatetime } from '../../common/IsoDate/IsoDatetime';


describe('OpensearchDatetimeUtils.toSearchDateDslString()', () => {
const testcases = [
{ input: '2025-03-01T12:34:56Z', expected: '2025-03-01T12:34:56Z' },
{ input: '2025-03-01', expected: '2025-03-01||/d' },
{ input: '2025-03', expected: '2025-03||/M' },
{ input: '2025', expected: '2025||/y' },
];

testcases.forEach(({ input, expected }) => {
test(`throws for "${input}"`, () => {
let iso = (input.length > 10) ? IsoDatetime.parse(input) : IsoDate.parse(input);
expect(toSearchDateDslString(iso)).toBe(expected);
});
});

test('throws on invalid date string', () => {
expect(() => IsoDate.parse('2025-13')).toThrow();
});
});
25 changes: 25 additions & 0 deletions src/adapters/search/OpensearchDatetimeUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* OpenSearch and ElasticSearch both use additional symbols in the Search DSL to make ISO 8601 representations
* even more precise for years, months, days. This utility class simplifies that
*/

import { IsoDate, IsoDatetime } from '../../common/IsoDate/IsoDatetime.js';

export const toSearchDateDslString = (iso: IsoDate | IsoDatetime): string => {
if (iso.isMonth()) {
// isoDate is a month
return `${iso.toString()}||/M`;
}
else if (iso.isYear()) {
// isoDate is a year
return `${iso.toString()}||/y`;
}
else if (iso.isDate()) {
// isoDate is a year
return `${iso.toString()}||/d`;
}
else {
// all other instances will be the original string
return iso.toString();
}
};
121 changes: 121 additions & 0 deletions src/common/IsoDate/IsoDate.test.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Unit tests for IsoDate
*
* AI Usage:
* - This code was originally generated using
* 1. OpenAI/GPT OSS 120b on Roo Code
* 2. Gemini 2.5 Flash and 2.5 Pro
* then modified to fix incorrect implementations and fit project needs.
* The first commit contains these corrections so that all code committed
* works as designed.
*/

import { describe, test, expect } from '@jest/globals';
import { IsoDate, isValidIsoDate } from './IsoDate';

describe('IsoDate.parse – valid inputs', () => {

test('full date with hyphens', () => {
const str = '2025-01-01';
expect(isValidIsoDate(str)).toBeTruthy();
const d = IsoDate.parse(str);
expect(d.year).toBe(2025);
expect(d.month).toBe(1);
expect(d.day).toBe(1);
expect(d.toString()).toBe('2025-01-01');
expect(d.isDate()).toBeTruthy();
expect(d.isMonth()).toBeFalsy();
expect(d.isYear()).toBeFalsy()
});

test('year‑month', () => {
const str = '2025-01';
expect(isValidIsoDate(str)).toBeTruthy();
const d = IsoDate.parse(str);
expect(d.year).toBe(2025);
expect(d.month).toBe(1);
expect(d.day).toBeUndefined();
expect(d.toString()).toBe('2025-01');
expect(d.isDate()).toBeFalsy();
expect(d.isMonth()).toBeTruthy();
expect(d.isYear()).toBeFalsy()
});

test('year only', () => {
const str = '2025';
expect(isValidIsoDate(str)).toBeTruthy();
const d = IsoDate.parse(str);
expect(d.year).toBe(2025);
expect(d.month).toBeUndefined();
expect(d.day).toBeUndefined();
expect(d.toString()).toBe('2025');
expect(d.isDate()).toBeFalsy();
expect(d.isMonth()).toBeFalsy();
expect(d.isYear()).toBeTruthy()
});

test('leap‑year February 29', () => {
const str = '2024-02-29';
expect(isValidIsoDate(str)).toBeTruthy();
const d = IsoDate.parse(str);
expect(d.year).toBe(2024);
expect(d.month).toBe(2);
expect(d.day).toBe(29);
expect(d.toString()).toBe('2024-02-29');
});

test('month‑day boundary', () => {
const str = '2025-01-30';
expect(isValidIsoDate(str)).toBeTruthy();
const d = IsoDate.parse(str);
expect(d.year).toBe(2025);
expect(d.month).toBe(1);
expect(d.day).toBe(30);
expect(d.toString()).toBe('2025-01-30');
});
});

describe('IsoDate.parse – invalid inputs', () => {
const invalid = [
'202501', // year+month without hyphen
'20250101', // compact full date (properly rejected in this class)
'250101', // two‑digit year
'2025-13-01', // invalid month
'2025-02-30', // invalid day (Feb 30)
'2025-04-31', // invalid day (April 31)
'-2025-04-31', // invalid year
'--01-01', // leading hyphens
'-2025-01', // leading hyphen before year
'2025--01', // double hyphen between year and month
'2025-01--01', // double hyphen before day
'2025-02-29', // illegal leap year
'2025-01-01T014:00:00:00Z', // datetime does not match in this class
];

invalid.forEach((value) => {
test(`throws for "${value}"`, () => {
expect(() => IsoDate.parse(value)).toThrow(Error);
});
});

invalid.forEach((value) => {
test(`"${value}" is not an IsoDate`, () => {
expect(isValidIsoDate(value)).toBeFalsy();
});
});
});

describe('IsoDate.toString', () => {
const tests: Array<{ input: string; expected: string; }> = [
{ input: '2025-01-01', expected: '2025-01-01' },
{ input: '2025-01', expected: '2025-01' },
{ input: '2025', expected: '2025' }
];

tests.forEach(({input, expected}) => {
test(`properly prints out '${input}' as '${expected}'`, () => {
const isoDate = IsoDate.parse(input)
expect(isoDate.toString()).toBe(expected)
});
});
})
176 changes: 176 additions & 0 deletions src/common/IsoDate/IsoDate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* IsoDate – a lightweight class for representing calendar dates.
*
* Supported input formats:
* - YYYY-MM-DD (e.g., 2025-01-01, equivalent to 2025-01-01T00:00:00.000Z/2025-01-01T23:59:59.999Z)
* - YYYY-MM (e.g., 2025-01, equivalent to 2025-01-01T00:00:00.000Z/2025-01-31T23:59:59.999Z)
* - YYYY (e.g., 2025, equivalent to 2025-01-01T00:00:00.000Z/2025-12-31T23:59:59.999Z)
*
* As shown in the example above, all IsoDate is assumed to be UTC (i.e., timezone = 'Z')
*
* Note we do not support (even though it is allowed by ISO 8601)
* - "compact date" (i.e., YYYYMMDD)
* - years previous to 1 AD (i.e., zero and negative years)
* - years after 2500
*
* The class validates monthly day rules and leap‑year rules.
* It provides a normalized `toString()` output:
* - full date → YYYY-MM-DD
* - year‑month → YYYY-MM
* - year only → YYYY
*
* Example:
* const d = IsoDate.parse('2025-01-01');
* console.log(d.year, d.month, d.day); // 2025 1 1
* console.log(d.toString()); // "2025-01-01"
*
* AI Usage:
* - This code was originally generated using
* 1. OpenAI/GPT OSS 120b on Roo Code
* 2. Gemini 2.5 Flash and 2.5 Pro
* then modified to fix incorrect implementations and fit project needs.
* The first commit contains these corrections so that all code committed
* works as designed.
*/

export class IsoDate {

/** Full year (e.g., 2025) */
public readonly year: number;
/** Month number 1‑12 (optional) */
public readonly month?: number;
/** Day number 1‑31 (optional, requires month) */
public readonly day?: number;

protected constructor(year: number, month?: number, day?: number) {
this.year = year;
if (month !== undefined) this.month = month;
if (day !== undefined) this.day = day;
}

/**
* Parse a string into a IsoDate.
* Throws an Error if the string does not match any supported format
* or if the date components are out of range.
*/
public static parse(value: string): IsoDate {
// Regex with named capture groups for clarity.
// 1. YYYY‑MM‑DD
// 2. we do not allow YYYYMMDD anymore
// 3. YYYY‑MM
// 4. YYYY
const regex =
// GPT OSS 120b generated regex
// /^(?<year>\d{4})(?:[-]?(?<month>\d{2})(?:[-]?(?<day>\d{2})?)?)?$/;
/^(?<year>\d{4})(?:[-](?<month>\d{2})(?:[-](?<day>\d{2})?)?)?$/;

const match = regex.exec(value);
if (!match || !match.groups) {
throw new Error(`Invalid calendar date format: "${value}": must be one of YYYY-MM-DD, YYYY-MM, or YYYY`);
}

const year = Number(match.groups.year);
const monthStr = match.groups.month;
const dayStr = match.groups.day;

// Validate year range (reasonable limits)
if (year < 1 || year > 2500) {
throw new Error(`Year out of range: ${year}`);
}

// If month is present, validate it.
if (monthStr !== undefined) {
const month = Number(monthStr);
if (month < 1 || month > 12) {
throw new Error(`Month out of range: ${monthStr}`);
}

// If day is present, validate day according to month & leap year.
if (dayStr !== undefined) {
const day = Number(dayStr);
const maxDay = IsoDate.daysInMonth(year, month);
if (day < 1 || day > maxDay) {
throw new Error(
`Day out of range for ${year}-${String(month).padStart(
2,
'0'
)}: ${dayStr}`
);
}
return new IsoDate(year, month, day);
}

// Month only (no day)
return new IsoDate(year, month);
}

// Year only
return new IsoDate(year);
}

/** Return true if the stored year is a leap year. */
public isLeapYear(): boolean {
return IsoDate.isLeapYear(this.year);
}

/** Return true if the stored year is a leap year. */
public isYear(): boolean {
return this.toString().length === 4;
}

/** Return true if the stored year is a leap year. */
public isMonth(): boolean {
return this.toString().length === 7;
}

/** Return true if the stored year is a leap year. */
public isDate(): boolean {
return this.toString().length === 10;
}


/** Normalized string representation. */
public toString(): string {
const y = String(this.year).padStart(4, '0');
if (this.month !== undefined) {
const m = String(this.month).padStart(2, '0');
if (this.day !== undefined) {
const d = String(this.day).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return `${y}-${m}`;
}
return y;
}

/** Static function that returns true iff year is a leap‑year */
public static isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

/** Static function that returns number of days in a given month/year. */
public static daysInMonth(year: number, month: number): number {
switch (month) {
case 2:
return IsoDate.isLeapYear(year) ? 29 : 28;
case 4:
case 6:
case 9:
case 11:
return 30;
default:
return 31;
}
}
}

/* Utility function to check if a string is a valid ISO date according to IsoDate parsing rules
*/
export function isValidIsoDate(value: string): boolean {
try {
IsoDate.parse(value);
return true;
} catch {
return false;
}
}
1 change: 1 addition & 0 deletions src/common/IsoDate/IsoDateString.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { describe, it, expect } from '@jest/globals';
import { IsoDateStringRegEx, IsoDateString } from './IsoDateString.js';

describe(`IsoDateString`, () => {
Expand Down
2 changes: 2 additions & 0 deletions src/common/IsoDate/IsoDateString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*
* Note that in the future, if necessary, we can extend what this class covers, but for now
* this strict and opinionated set is very useful for processing ISO Date+Time+TZ strings
*
* @deprecated Use IsoDatetime or IsoDate instead for safer and more efficient datetime and date functions
*/

/** a regular expression to represent an ISO Date+Time+TZ string
Expand Down
Loading
Loading