diff --git a/scripts/kb-update/lib/course-diff.mjs b/scripts/kb-update/lib/course-diff.mjs new file mode 100644 index 0000000..87ac99a --- /dev/null +++ b/scripts/kb-update/lib/course-diff.mjs @@ -0,0 +1,156 @@ +// course-diff.mjs — PURE course diff + registry-fold + run-mode selector (C3.2). +// Zero imports: writes NO files, reads NO secrets, never touches the ledger or +// KB. The detector (C3.4) loads/saves course-registry.json via registry-io and +// drives this module — keeping the diff logic pure and unit-testable. +// See c3-course-detection-plan.md §4.1 (registry schema), §4.2 (diff semantics), +// and the §8 decisions: (b) baseline emits no leads, (c) `removed` is computed +// ONLY on a full enumeration. + +// A full enumeration is forced when the last one is missing or older than this. +// Only a full enum (no updatedAt.gt filter) can correctly detect retired courses. +export const FULL_ENUM_MAX_AGE_DAYS = 30; +const DAY_MS = 86_400_000; + +/** + * Normalize a raw Platform API item into the registry/lead shape. Keeps the diff + * agnostic of the API field names (id→uid, updatedAt→updated_at, products[].id). + * @param {{id: string, title?: string, url?: string, updatedAt?: string, products?: Array<{id: string}>}} apiItem + * @param {'module'|'learning-path'} type — which endpoint the item came from + * @returns {{uid: string, type: string, title: string, url: string, products: string[], updated_at: string|null}} + */ +export function normalizeCourse(apiItem, type) { + return { + uid: apiItem.id, + type, + title: apiItem.title ?? '', + url: apiItem.url ?? '', + products: (apiItem.products ?? []).map((p) => p.id).filter(Boolean), + updated_at: apiItem.updatedAt ?? null, + }; +} + +/** + * Is a full enumeration due? True when the registry has never run a full enum + * (last_full_enum missing) or the last one is strictly older than maxAgeDays. + * Fails toward `true` on an unparseable timestamp — a full enum is a safe + * superset of an incremental one. + * @param {{last_full_enum?: string|null}} registry + * @param {string} now — full ISO datetime + * @param {{maxAgeDays?: number}} [opts] + * @returns {boolean} + */ +export function isFullEnumDue(registry, now, { maxAgeDays = FULL_ENUM_MAX_AGE_DAYS } = {}) { + if (!registry || !registry.last_full_enum) return true; + const last = new Date(registry.last_full_enum).getTime(); + const t = new Date(now).getTime(); + if (!Number.isFinite(last) || !Number.isFinite(t)) return true; + return t - last > maxAgeDays * DAY_MS; +} + +/** + * Pick the run mode from registry state (§4.1's three run types): + * - 'baseline' — first ever run (no last_run): establish the registry, emit no leads. + * - 'full' — a full enumeration is due: new/updated + removed. + * - 'incremental' — normal run: updatedAt.gt fetch, new/updated, removed:[]. + * @param {{last_run?: string|null, last_full_enum?: string|null}} registry + * @param {string} now — full ISO datetime + * @param {{maxAgeDays?: number}} [opts] + * @returns {'baseline'|'full'|'incremental'} + */ +export function selectMode(registry, now, opts = {}) { + if (!registry || !registry.last_run) return 'baseline'; + return isFullEnumDue(registry, now, opts) ? 'full' : 'incremental'; +} + +// Strictly newer than what we last stored (an equal or older timestamp is a no-op). +function isNewer(apiUpdatedAt, storedUpdatedAt) { + if (!apiUpdatedAt) return false; + if (!storedUpdatedAt) return true; + return new Date(apiUpdatedAt).getTime() > new Date(storedUpdatedAt).getTime(); +} + +/** + * Diff a set of (already-normalized, already-in-domain) courses against the + * registry and classify leads. Leads are the normalized course objects; the + * detector enriches them with suggested_skill/category downstream. + * + * @param {Array} courses — normalized courses from the API for this run + * @param {{courses?: object}} registry + * @param {{mode: 'baseline'|'full'|'incremental'}} options + * @returns {{new: object[], updated: object[], removed: Array<{uid: string, title: string, url: string}>}} + */ +export function diffCourses(courses, registry, { mode } = {}) { + // §8 (b): a baseline run establishes the registry without emitting any leads — + // flagging hundreds of "new" courses on day one trains the operator that the + // tool is noise, not signal. + if (mode === 'baseline') return { new: [], updated: [], removed: [] }; + + const known = registry?.courses ?? {}; + const newLeads = []; + const updatedLeads = []; + for (const c of courses) { + const prev = known[c.uid]; + if (!prev) newLeads.push(c); + else if (isNewer(c.updated_at, prev.updated_at)) updatedLeads.push(c); + } + + // §4.2 correctness pin: `removed` is computed ONLY on a full enumeration. An + // incremental updatedAt.gt response omits everything unchanged, so "in + // registry, not in response" is true for nearly the whole registry — a naive + // removed-diff would be a false-positive flood. Incremental forces removed:[]. + let removed = []; + if (mode === 'full') { + const apiUids = new Set(courses.map((c) => c.uid)); + removed = Object.entries(known) + .filter(([uid]) => !apiUids.has(uid)) + .map(([uid, e]) => ({ uid, title: e.title ?? '', url: e.url ?? '' })); + } + return { new: newLeads, updated: updatedLeads, removed }; +} + +// Build a stored entry, preserving first_seen across runs and advancing last_seen. +function foldEntry(prev, c, now) { + return { + type: c.type, + title: c.title, + url: c.url, + products: c.products, + updated_at: c.updated_at, + first_seen: prev?.first_seen ?? now, + last_seen: now, + }; +} + +/** + * Fold this run's courses into the registry. Pure — returns a new registry, + * never mutates the input. + * - incremental: merge (keep untouched UIDs, add/update the ones returned). + * - baseline | full: the API set IS the complete registry — rebuild from it, + * dropping UIDs absent from the API (retired courses) so they aren't + * re-flagged as removed on the next full enum. Stamps last_full_enum = now. + * Every run advances last_run = now and seeds created_at when missing. + * + * @param {object} registry + * @param {Array} courses — normalized courses from the API for this run + * @param {string} now — full ISO datetime + * @param {{mode: 'baseline'|'full'|'incremental'}} options + * @returns {object} new registry + */ +export function updateRegistry(registry, courses, now, { mode } = {}) { + const base = registry ?? { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} }; + const prevCourses = base.courses ?? {}; + const isFull = mode === 'baseline' || mode === 'full'; + + // incremental keeps the prior set; baseline/full rebuild from the API set only. + const nextCourses = isFull ? {} : { ...prevCourses }; + for (const c of courses) nextCourses[c.uid] = foldEntry(prevCourses[c.uid], c, now); + + return { + ...base, + version: base.version ?? 1, + created_at: base.created_at ?? now, + last_run: now, + last_full_enum: isFull ? now : base.last_full_enum, + courses: nextCourses, + }; +} diff --git a/scripts/kb-update/lib/registry-io.mjs b/scripts/kb-update/lib/registry-io.mjs index f36266f..cfb3c56 100644 --- a/scripts/kb-update/lib/registry-io.mjs +++ b/scripts/kb-update/lib/registry-io.mjs @@ -40,6 +40,40 @@ export function saveRegistry(registry, dataDir = DEFAULT_DATA_DIR) { renameSync(tmp, path); } +/** + * Load the course registry (C3 detector's private diff-state — mirrors the URL + * registry but UID-keyed). Absent file → empty scaffold with the full-ISO + * cursors (last_run + last_full_enum) the diff/mode logic relies on. + * @param {string} [dataDir] — defaults to ../data/ relative to lib/ + * @returns {object} parsed course registry or empty scaffold + */ +export function loadCourseRegistry(dataDir = DEFAULT_DATA_DIR) { + const path = join(dataDir, 'course-registry.json'); + if (!existsSync(path)) { + return { + version: 1, + created_at: null, + last_run: null, + last_full_enum: null, + courses: {}, + }; + } + return JSON.parse(readFileSync(path, 'utf8')); +} + +/** + * Save the course registry atomically (write to .tmp, then rename). + * @param {object} registry + * @param {string} [dataDir] + */ +export function saveCourseRegistry(registry, dataDir = DEFAULT_DATA_DIR) { + ensureDir(dataDir); + const path = join(dataDir, 'course-registry.json'); + const tmp = path + '.tmp'; + writeFileSync(tmp, JSON.stringify(registry, null, 2) + '\n', 'utf8'); + renameSync(tmp, path); +} + /** * Load a JSON report file (change-report.json or discovery-report.json). * @param {string} name — filename without path (e.g. 'change-report.json') diff --git a/tests/kb-update/test-course-diff.test.mjs b/tests/kb-update/test-course-diff.test.mjs new file mode 100644 index 0000000..0f84ae8 --- /dev/null +++ b/tests/kb-update/test-course-diff.test.mjs @@ -0,0 +1,296 @@ +// tests/kb-update/test-course-diff.test.mjs +// Unit tests for scripts/kb-update/lib/course-diff.mjs (C3.2) — the PURE course +// diff + registry-fold + run-mode selector, plus the course-registry load/save +// round-trip (registry-io). Mirrors the diff semantics in c3-course-detection-plan.md +// §4.1/§4.2 and the §8 decisions: +// (b) a baseline run emits NO leads (registry-establishing only), +// (c) `removed` is computed ONLY on a full enumeration — never incrementally, +// where an updatedAt.gt response omits everything unchanged and a naive +// removed-diff would flag nearly the whole registry (false-positive flood). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + diffCourses, + updateRegistry, + selectMode, + isFullEnumDue, + normalizeCourse, + FULL_ENUM_MAX_AGE_DAYS, +} from '../../scripts/kb-update/lib/course-diff.mjs'; +import { loadCourseRegistry, saveCourseRegistry } from '../../scripts/kb-update/lib/registry-io.mjs'; + +const NOW = '2026-06-23T12:00:00Z'; + +// A normalized course (the shape diffCourses/updateRegistry operate on). +function course(uid, updated_at, over = {}) { + return { + uid, + type: 'module', + title: `t-${uid}`, + url: `https://learn.microsoft.com/training/modules/${uid}`, + products: ['azure-openai'], + updated_at, + ...over, + }; +} + +// A stored registry entry. +function entry(updated_at, over = {}) { + return { + type: 'module', + title: 'stored', + url: 'stored-url', + products: [], + updated_at, + first_seen: '2026-01-01T00:00:00Z', + last_seen: '2026-06-20T00:00:00Z', + ...over, + }; +} + +function registryWith(courses = {}, meta = {}) { + return { + version: 1, + created_at: '2026-01-01T00:00:00Z', + last_run: '2026-06-20T00:00:00Z', + last_full_enum: '2026-06-20T00:00:00Z', + courses, + ...meta, + }; +} + +// --- normalizeCourse ------------------------------------------------------- + +test('normalizeCourse — maps API fields to registry shape (products flattened)', () => { + const api = { + id: 'learn.az.x', + title: 'X', + url: 'https://learn.microsoft.com/training/modules/x', + updatedAt: '2026-06-01T00:00:00Z', + products: [{ id: 'azure-openai' }, { id: 'ai-builder' }], + }; + const c = normalizeCourse(api, 'module'); + assert.equal(c.uid, 'learn.az.x'); + assert.equal(c.type, 'module'); + assert.equal(c.title, 'X'); + assert.equal(c.url, 'https://learn.microsoft.com/training/modules/x'); + assert.equal(c.updated_at, '2026-06-01T00:00:00Z'); + assert.deepEqual(c.products, ['azure-openai', 'ai-builder']); +}); + +test('normalizeCourse — missing fields degrade safely', () => { + const c = normalizeCourse({ id: 'u1' }, 'learning-path'); + assert.equal(c.uid, 'u1'); + assert.equal(c.type, 'learning-path'); + assert.equal(c.title, ''); + assert.equal(c.url, ''); + assert.deepEqual(c.products, []); + assert.equal(c.updated_at, null); +}); + +// --- diffCourses: new / updated / no-op ------------------------------------ + +test('diffCourses — new UID (in API, not in registry) → new lead', () => { + const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], registryWith({}), { mode: 'incremental' }); + assert.equal(out.new.length, 1); + assert.equal(out.new[0].uid, 'u1'); + assert.equal(out.updated.length, 0); + assert.deepEqual(out.removed, []); +}); + +test('diffCourses — newer updatedAt on a known UID → updated lead', () => { + const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') }); + const out = diffCourses([course('u1', '2026-06-22T00:00:00Z')], reg, { mode: 'incremental' }); + assert.equal(out.updated.length, 1); + assert.equal(out.updated[0].uid, 'u1'); + assert.equal(out.updated[0].updated_at, '2026-06-22T00:00:00Z'); + assert.equal(out.new.length, 0); +}); + +test('diffCourses — identical updatedAt → no-op (no lead)', () => { + const reg = registryWith({ u1: entry('2026-06-01T00:00:00Z') }); + const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' }); + assert.equal(out.new.length, 0); + assert.equal(out.updated.length, 0); +}); + +test('diffCourses — older updatedAt → no-op (never a downgrade lead)', () => { + const reg = registryWith({ u1: entry('2026-06-10T00:00:00Z') }); + const out = diffCourses([course('u1', '2026-06-01T00:00:00Z')], reg, { mode: 'incremental' }); + assert.equal(out.new.length, 0); + assert.equal(out.updated.length, 0); +}); + +// --- diffCourses: baseline emits no leads (§8 (b)) ------------------------- + +test('diffCourses — baseline (empty registry) emits NO leads (§8 (b) tom-baseline)', () => { + const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} }; + const out = diffCourses( + [course('u1', '2026-06-01T00:00:00Z'), course('u2', '2026-06-02T00:00:00Z')], + empty, + { mode: 'baseline' }, + ); + assert.deepEqual(out, { new: [], updated: [], removed: [] }); +}); + +test('diffCourses — baseline emits no leads even against a non-empty registry', () => { + const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') }); + const out = diffCourses([course('u1', '2026-06-22T00:00:00Z'), course('u2', '2026-06-22T00:00:00Z')], reg, { mode: 'baseline' }); + assert.deepEqual(out, { new: [], updated: [], removed: [] }); +}); + +// --- full-enum-pin: `removed` only on full enumeration (§4.2) -------------- + +test('diffCourses — removed is emitted ONLY in full mode (§4.2 correctness pin)', () => { + const reg = registryWith({ + gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', url: 'gone-url' }), + keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', url: 'keep-url' }), + }); + const apiSet = [course('keep', '2026-01-01T00:00:00Z')]; // 'gone' absent from the full API set + const out = diffCourses(apiSet, reg, { mode: 'full' }); + assert.deepEqual(out.removed.map((r) => r.uid), ['gone']); + assert.equal(out.removed[0].title, 'Gone'); + assert.equal(out.removed[0].url, 'gone-url'); +}); + +test('diffCourses — incremental NEVER emits removed, even when every registry UID is absent from the filtered response', () => { + const reg = registryWith({ a: entry('2026-01-01T00:00:00Z'), b: entry('2026-01-01T00:00:00Z') }); + // An updatedAt.gt response with nothing changed returns []: all registry UIDs + // are "missing" — a naive removed-diff would flag the whole registry. + const out = diffCourses([], reg, { mode: 'incremental' }); + assert.deepEqual(out.removed, []); +}); + +test('diffCourses — baseline never emits removed', () => { + const reg = registryWith({ a: entry('2026-01-01T00:00:00Z') }); + const out = diffCourses([], reg, { mode: 'baseline' }); + assert.deepEqual(out.removed, []); +}); + +// --- selectMode / isFullEnumDue (full-enum kadens) ------------------------- + +test('selectMode — no last_run → baseline', () => { + assert.equal(selectMode({ last_run: null, last_full_enum: null, courses: {} }, NOW), 'baseline'); +}); + +test('selectMode — recent full enum → incremental', () => { + const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-06-20T00:00:00Z' }); + assert.equal(selectMode(reg, NOW), 'incremental'); +}); + +test('selectMode — last_full_enum missing but has run → full', () => { + const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: null }); + assert.equal(selectMode(reg, NOW), 'full'); +}); + +test('selectMode — last_full_enum older than 30 days → full', () => { + const reg = registryWith({}, { last_run: '2026-06-22T00:00:00Z', last_full_enum: '2026-05-01T00:00:00Z' }); + assert.equal(selectMode(reg, NOW), 'full'); +}); + +test('isFullEnumDue — exactly 30 days is NOT due; one second over IS due', () => { + // NOW = 2026-06-23T12:00:00Z; exactly 30 days earlier = 2026-05-24T12:00:00Z. + assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T12:00:00Z' }, NOW), false); + assert.equal(isFullEnumDue({ last_full_enum: '2026-05-24T11:59:59Z' }, NOW), true); +}); + +test('FULL_ENUM_MAX_AGE_DAYS — pinned at 30', () => { + assert.equal(FULL_ENUM_MAX_AGE_DAYS, 30); +}); + +// --- updateRegistry (pure fold) -------------------------------------------- + +test('updateRegistry — incremental merges (keep untouched, update changed, add new) and leaves last_full_enum', () => { + const reg = registryWith( + { + keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }), + chg: entry('2026-01-01T00:00:00Z', { title: 'Old', first_seen: '2026-02-01T00:00:00Z', last_seen: '2026-06-20T00:00:00Z' }), + }, + { last_full_enum: '2026-06-20T00:00:00Z' }, + ); + const next = updateRegistry( + reg, + [course('chg', '2026-06-22T00:00:00Z', { title: 'New' }), course('add', '2026-06-21T00:00:00Z')], + NOW, + { mode: 'incremental' }, + ); + // untouched survives, last_seen NOT advanced + assert.ok(next.courses.keep); + assert.equal(next.courses.keep.last_seen, '2026-06-20T00:00:00Z'); + // changed: updated_at refreshed, first_seen preserved, last_seen advanced + assert.equal(next.courses.chg.title, 'New'); + assert.equal(next.courses.chg.updated_at, '2026-06-22T00:00:00Z'); + assert.equal(next.courses.chg.first_seen, '2026-02-01T00:00:00Z'); + assert.equal(next.courses.chg.last_seen, NOW); + // new: first_seen = now + assert.equal(next.courses.add.first_seen, NOW); + assert.equal(next.courses.add.last_seen, NOW); + // metadata + assert.equal(next.last_run, NOW); + assert.equal(next.last_full_enum, '2026-06-20T00:00:00Z'); +}); + +test('updateRegistry — full rebuilds from the API set (drops retired UIDs) and stamps last_full_enum', () => { + const reg = registryWith( + { + gone: entry('2026-01-01T00:00:00Z', { title: 'Gone', first_seen: '2026-01-01T00:00:00Z' }), + keep: entry('2026-01-01T00:00:00Z', { title: 'Keep', first_seen: '2026-03-01T00:00:00Z' }), + }, + { last_full_enum: '2026-05-01T00:00:00Z' }, + ); + const next = updateRegistry(reg, [course('keep', '2026-01-01T00:00:00Z')], NOW, { mode: 'full' }); + assert.deepEqual(Object.keys(next.courses), ['keep']); // 'gone' dropped + assert.equal(next.courses.keep.first_seen, '2026-03-01T00:00:00Z'); // preserved + assert.equal(next.courses.keep.last_seen, NOW); + assert.equal(next.last_full_enum, NOW); + assert.equal(next.last_run, NOW); +}); + +test('updateRegistry — baseline on empty registry seeds created_at + last_full_enum', () => { + const empty = { version: 1, created_at: null, last_run: null, last_full_enum: null, courses: {} }; + const next = updateRegistry(empty, [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' }); + assert.equal(next.created_at, NOW); + assert.equal(next.last_run, NOW); + assert.equal(next.last_full_enum, NOW); + assert.ok(next.courses.u1); + assert.equal(next.courses.u1.first_seen, NOW); +}); + +test('updateRegistry — does not mutate the input registry', () => { + const reg = registryWith({ u1: entry('2026-01-01T00:00:00Z') }); + const before = JSON.stringify(reg); + updateRegistry(reg, [course('u1', '2026-06-22T00:00:00Z')], NOW, { mode: 'incremental' }); + assert.equal(JSON.stringify(reg), before); +}); + +// --- course-registry load/save round-trip (registry-io) -------------------- + +test('loadCourseRegistry — absent file returns the empty scaffold (schema incl. last_full_enum)', () => { + const dir = mkdtempSync(join(tmpdir(), 'course-reg-')); + try { + assert.deepEqual(loadCourseRegistry(dir), { + version: 1, + created_at: null, + last_run: null, + last_full_enum: null, + courses: {}, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('saveCourseRegistry → loadCourseRegistry round-trips deeply', () => { + const dir = mkdtempSync(join(tmpdir(), 'course-reg-')); + try { + const reg = updateRegistry(loadCourseRegistry(dir), [course('u1', '2026-06-01T00:00:00Z')], NOW, { mode: 'baseline' }); + saveCourseRegistry(reg, dir); + assert.deepEqual(loadCourseRegistry(dir), reg); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/kb-update/test-courses-invariant.test.mjs b/tests/kb-update/test-courses-invariant.test.mjs index a16fbd6..cd6f643 100644 --- a/tests/kb-update/test-courses-invariant.test.mjs +++ b/tests/kb-update/test-courses-invariant.test.mjs @@ -1,8 +1,9 @@ // tests/kb-update/test-courses-invariant.test.mjs // Architecture-invariant guard for the C3 course-detection libs (spec §2, §6). -// The network/secret layers are Claude-free and write NO files: +// The network/secret/diff layers are Claude-free and write NO files: // - learn-api.mjs is a pure network client (writes nothing, no KB/ledger). // - keychain.mjs reads secrets via the `security` CLI only. +// - course-diff.mjs is PURE diff/fold logic (zero imports; no fs, no IO). // As with test-discover-invariant, the checks are import/source-specific: a // comment that merely *describes* the invariant must not be able to satisfy it. @@ -16,6 +17,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const LIB = join(__dirname, '..', '..', 'scripts', 'kb-update', 'lib'); const learnApiSrc = readFileSync(join(LIB, 'learn-api.mjs'), 'utf8'); const keychainSrc = readFileSync(join(LIB, 'keychain.mjs'), 'utf8'); +const courseDiffSrc = readFileSync(join(LIB, 'course-diff.mjs'), 'utf8'); const importLines = (src) => src.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n'); @@ -43,3 +45,18 @@ test('keychain imports only child_process (reads via the security CLI)', () => { assert.match(imports, /node:child_process/); assert.doesNotMatch(imports, /node:fs|atomic-write|decisions-io|registry-io/); }); + +test('course-diff is Claude-free (no claude/anthropic references)', () => { + assert.doesNotMatch(courseDiffSrc, /claude|anthropic/i); +}); + +test('course-diff is PURE — imports NO write-utils (fs / atomic-write / registry-io / decisions-io / save*)', () => { + const imports = importLines(courseDiffSrc); + assert.doesNotMatch(imports, /node:fs/); + assert.doesNotMatch(imports, /atomic-write|registry-io|decisions-io|backup/); + assert.doesNotMatch(imports, /\bsaveRegistry\b|\bsaveCourseRegistry\b|\bsaveDecisions\b|\bsaveReport\b/); +}); + +test('course-diff also does not call fs write functions inline', () => { + assert.doesNotMatch(courseDiffSrc, /\bwriteFileSync\b|\brenameSync\b|\bmkdirSync\b/); +});