feat(ms-ai-architect): Spor 3 Port 2 — create-time-guard (born-verified contract) [skip-docs]
Type-aware create-guard for reference-filer (Spor 3 Port 1/Port 2): - kb-headers.mjs: parseTypeHeader/parseVerifiedHeader/parseVerifiedByHeader (samme top-500-byte bold-label-skann som parseSourceHeader). - transform.mjs: buildKbHeader er type-aware (reference krever source+verified+verified_by; non-reference kaster pa MS-Learn-source) + emitterer Type/Verified/Verified by; validateKbFile type-aware; stampVerifiedMeta = fodt-verifisert-gate (stempler KUN ved bestatt judge-verdikt, ellers kaster). - validate-kb-file.mjs (ny): kjorbar gate generatorer kaller for commit (exit != 0 ved kontraktbrudd). - generate-skills.md + kb-update.md + transform-prompt.md: wiret til fodt-verifisert kontrakt (header-felt + judge-steg + create-guard-gate). Suite 586/586 (33 nye + 1 invariant). Plugin-validering 239/0/0. Utenfor scope (flagget): generate-skills.sh legacy (sonnet/Cosmo/no-source), Cosmo-persona i generatorene (S-Cosmo), korpus-migrering av 306 filer (Spor 1).
This commit is contained in:
parent
0ded2f9413
commit
5a0ba1fc9e
10 changed files with 621 additions and 54 deletions
|
|
@ -10,6 +10,21 @@ const SOURCE_PATTERNS = [
|
|||
/\*\*Primary source:\*\*\s*(\S+)/i,
|
||||
];
|
||||
|
||||
// Spor 3 Port 1 frontmatter contract: these live in the SAME top-500-byte bold-label
|
||||
// region as **Source:**, so build-registry and the create-guard read one header format.
|
||||
// `Verified:` and `Verified by:` are distinct labels — the date pattern below is
|
||||
// anchored on the colon right after "Verified" so it never swallows the "by:" line.
|
||||
const TYPE_PATTERN = /\*\*Type:\*\*\s*(\S+)/i;
|
||||
const VERIFIED_PATTERN = /\*\*Verified:\*\*\s*(\S+)/i;
|
||||
const VERIFIED_BY_PATTERN = /\*\*Verified by:\*\*\s*(\S+)/i;
|
||||
|
||||
/** Scan only the header region for the first capture of `pattern`, trimmed. */
|
||||
function scanHeader(content, pattern) {
|
||||
if (!content || typeof content !== 'string') return null;
|
||||
const match = content.slice(0, HEADER_REGION_BYTES).match(pattern);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the declared authority source URL from a KB reference file's header.
|
||||
* @param {string} content — full file content (only the first 500 bytes are scanned)
|
||||
|
|
@ -24,3 +39,32 @@ export function parseSourceHeader(content) {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Port 1 `type` (reference|template|methodology|regulatory), lowercased.
|
||||
* @param {string} content — full file content (only the first 500 bytes are scanned)
|
||||
* @returns {string|null} the declared type, or null if absent
|
||||
*/
|
||||
export function parseTypeHeader(content) {
|
||||
const t = scanHeader(content, TYPE_PATTERN);
|
||||
return t === null ? null : t.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Port 1 `verified` date (when the file was last confirmed against source).
|
||||
* Anchored on `**Verified:**` so it never matches the `**Verified by:**` line.
|
||||
* @param {string} content — full file content (only the first 500 bytes are scanned)
|
||||
* @returns {string|null} the verified date (YYYY-MM or YYYY-MM-DD), or null if absent
|
||||
*/
|
||||
export function parseVerifiedHeader(content) {
|
||||
return scanHeader(content, VERIFIED_PATTERN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the Port 1 `verified_by` field (`judge-vN` / `human`).
|
||||
* @param {string} content — full file content (only the first 500 bytes are scanned)
|
||||
* @returns {string|null} the verifier, or null if absent
|
||||
*/
|
||||
export function parseVerifiedByHeader(content) {
|
||||
return scanHeader(content, VERIFIED_BY_PATTERN);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,38 @@
|
|||
// validateKbFile uses the SAME source detection as build-registry — one source of
|
||||
// truth for "what counts as a Source header" (the top-500-byte region).
|
||||
|
||||
import { parseSourceHeader } from './kb-headers.mjs';
|
||||
import {
|
||||
parseSourceHeader,
|
||||
parseTypeHeader,
|
||||
parseVerifiedHeader,
|
||||
parseVerifiedByHeader,
|
||||
} from './kb-headers.mjs';
|
||||
import { getCategorySkill } from './taxonomy.mjs';
|
||||
|
||||
// Mandatory header fields. Status is obligatory per spec ("status-felt obligatorisk");
|
||||
// Source is what lets lag 3 backfill authority_source and lag-5 rule 3 eventually fire.
|
||||
const REQUIRED_META = ['title', 'status', 'category', 'source', 'lastUpdated'];
|
||||
// Base mandatory header fields for EVERY file type. Status is obligatory per spec
|
||||
// ("status-felt obligatorisk").
|
||||
const REQUIRED_BASE_META = ['title', 'status', 'category', 'lastUpdated'];
|
||||
|
||||
// Spor 3 Port 1 — the frontmatter-contract `type`. Only `reference` files are in the
|
||||
// correctness scope (they carry a Microsoft source we verify against). Template /
|
||||
// methodology / regulatory are out of scope and carry NO Microsoft source.
|
||||
const VALID_TYPES = ['reference', 'template', 'methodology', 'regulatory'];
|
||||
const DEFAULT_TYPE = 'reference';
|
||||
|
||||
// Extra fields a `reference` file MUST carry to be "born verified" (Spor 3 Port 2):
|
||||
// `source` lets lag-5 authority checks fire; `verified`/`verified_by` record that the
|
||||
// content cleared the judge before it shipped. A reference file without these is the
|
||||
// drift-introducing failure mode the create-guard exists to stop.
|
||||
const REQUIRED_REFERENCE_META = ['source', 'verified', 'verified_by'];
|
||||
|
||||
// The current GATE-PASS claim judge (judge-claim-prompt-v2). stampVerifiedMeta records
|
||||
// `verified_by: judge-v<JUDGE_VERSION>` unless the caller overrides or marks `human`.
|
||||
const JUDGE_VERSION = 2;
|
||||
|
||||
// A Microsoft Learn / docs authority URL — a non-reference file may NOT carry one.
|
||||
const MS_LEARN_RE = /\b(?:learn|docs)\.microsoft\.com/i;
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}(?:-\d{2})?$/;
|
||||
|
||||
// A reference file longer than this many lines should carry a table of contents
|
||||
// (best-practices §Structure). MIRRORS eval.mjs N4_LARGE_FILE_LINES — kept in sync
|
||||
|
|
@ -60,33 +86,84 @@ function slugify(heading) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the deterministic KB-file header block. Status and Source live IN the
|
||||
* header (top 500 bytes) so kb-headers.parseSourceHeader and the "Last updated:"
|
||||
* scanner can see them — the old bottom-of-file source section was invisible to
|
||||
* those scanners, which is why authority_source coverage was 0%.
|
||||
* Build the deterministic KB-file header block. Status, Source and the Port 1 contract
|
||||
* fields (Type / Verified / Verified by) live IN the header (top 500 bytes) so
|
||||
* kb-headers.* and the "Last updated:" scanner can see them — the old bottom-of-file
|
||||
* source section was invisible to those scanners, which is why authority_source
|
||||
* coverage was 0%.
|
||||
*
|
||||
* @param {{title: string, status: string, category: string, source: string,
|
||||
* lastUpdated: string}} meta
|
||||
* Type-aware create-guard (Spor 3 Port 2): a `reference` file MUST carry
|
||||
* source + verified + verified_by (it is "born verified"); a non-reference file
|
||||
* (template/methodology/regulatory) is out of correctness scope and may NOT carry a
|
||||
* Microsoft Learn source. Either violation is a hard throw — the create-time gate.
|
||||
*
|
||||
* @param {{title: string, status: string, category: string, lastUpdated: string,
|
||||
* type?: string, source?: string, verified?: string, verified_by?: string}} meta
|
||||
* @returns {string} the header block, ending with the `---` rule + trailing newline
|
||||
* @throws if any mandatory field is missing/empty
|
||||
* @throws if the type is unknown, a mandatory field is missing, or a non-reference
|
||||
* file carries a Microsoft Learn source
|
||||
*/
|
||||
export function buildKbHeader(meta) {
|
||||
const m = meta ?? {};
|
||||
const missing = REQUIRED_META.filter((k) => {
|
||||
const type = m.type ?? DEFAULT_TYPE;
|
||||
if (!VALID_TYPES.includes(type)) {
|
||||
throw new Error(`buildKbHeader: invalid type '${type}' (expected ${VALID_TYPES.join('|')})`);
|
||||
}
|
||||
const isReference = type === 'reference';
|
||||
const empty = (k) => {
|
||||
const v = m[k];
|
||||
return v === undefined || v === null || String(v).trim() === '';
|
||||
});
|
||||
};
|
||||
const required = isReference
|
||||
? [...REQUIRED_BASE_META, ...REQUIRED_REFERENCE_META]
|
||||
: REQUIRED_BASE_META;
|
||||
const missing = required.filter(empty);
|
||||
if (missing.length) {
|
||||
throw new Error(`buildKbHeader: missing mandatory field(s): ${missing.join(', ')}`);
|
||||
}
|
||||
return (
|
||||
if (!isReference && !empty('source') && MS_LEARN_RE.test(String(m.source))) {
|
||||
throw new Error(
|
||||
`buildKbHeader: a ${type} file may not carry a Microsoft Learn source (out of correctness scope)`,
|
||||
);
|
||||
}
|
||||
let header =
|
||||
`# ${m.title}\n\n` +
|
||||
`**Last updated:** ${m.lastUpdated}\n` +
|
||||
`**Status:** ${m.status}\n` +
|
||||
`**Category:** ${m.category}\n` +
|
||||
`**Source:** ${m.source}\n\n` +
|
||||
`---\n`
|
||||
);
|
||||
`**Type:** ${type}\n`;
|
||||
if (!empty('source')) header += `**Source:** ${m.source}\n`;
|
||||
if (!empty('verified')) header += `**Verified:** ${m.verified}\n`;
|
||||
if (!empty('verified_by')) header += `**Verified by:** ${m.verified_by}\n`;
|
||||
return `${header}\n---\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Born-verified gate (Spor 3 Port 2): stamp `verified`/`verified_by` onto a reference
|
||||
* meta IFF the judge verdict passed — otherwise REFUSE (throw). This encodes
|
||||
* "nytt/regenerert innhold kjøres gjennom judgen FØR commit → født verifisert; ellers
|
||||
* ingen fil". transform.mjs never calls the judge itself (pure lib); the command runs
|
||||
* the v2 claim judge, aggregates per-claim results into `{pass}`, and calls this. A
|
||||
* failing verdict means no file is born.
|
||||
*
|
||||
* @param {object} baseMeta — the reference meta (title/status/category/source/lastUpdated)
|
||||
* @param {{pass: boolean, judgeVersion?: number, by?: 'human'}} verdict
|
||||
* @param {string} today — YYYY-MM or YYYY-MM-DD, supplied by the caller (lib stays pure)
|
||||
* @returns {object} baseMeta with type='reference' + verified + verified_by set
|
||||
* @throws if the verdict is not passing, or today is not a valid date
|
||||
*/
|
||||
export function stampVerifiedMeta(baseMeta, verdict, today) {
|
||||
if (!verdict || verdict.pass !== true) {
|
||||
throw new Error('stampVerifiedMeta: refusing to stamp verified — judge verdict not passing');
|
||||
}
|
||||
if (!DATE_RE.test(String(today ?? ''))) {
|
||||
throw new Error(`stampVerifiedMeta: invalid today date '${today}' (expected YYYY-MM[-DD])`);
|
||||
}
|
||||
const verifiedBy =
|
||||
verdict.by === 'human'
|
||||
? 'human'
|
||||
: `judge-v${Number.isInteger(verdict.judgeVersion) ? verdict.judgeVersion : JUDGE_VERSION}`;
|
||||
return { ...baseMeta, type: 'reference', verified: today, verified_by: verifiedBy };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -181,10 +258,16 @@ const RE_STATUS = /\*\*Status:\*\*\s*\S/i;
|
|||
/**
|
||||
* Validate that a candidate KB file carries the mandatory contract: a title, a
|
||||
* "Last updated:" header (so it is never dateless → never the 'unverified' bucket),
|
||||
* a Status field, a Source header in the scannable region, and — for a large file
|
||||
* (>100 lines, per N4) — a table of contents. The TOC clause is the write-path
|
||||
* regression gate: a regenerated large file that lost its TOC fails here and the
|
||||
* caller re-prompts, so KB-update can never silently strip a TOC back out.
|
||||
* a Status field, and — for a large file (>100 lines, per N4) — a table of contents.
|
||||
* The TOC clause is the write-path regression gate: a regenerated large file that lost
|
||||
* its TOC fails here and the caller re-prompts, so KB-update can never silently strip a
|
||||
* TOC back out.
|
||||
*
|
||||
* Type-aware (Spor 3 Port 2): a `reference` file MUST additionally carry source +
|
||||
* verified + verified_by (born-verified). A file with no `**Type:**` header is treated
|
||||
* as a reference — the create path always stamps Type, and the legacy corpus (no Type)
|
||||
* is migrated under Spor 1, where this same contract is the target. Non-reference files
|
||||
* (template/methodology/regulatory) are out of correctness scope and need no source.
|
||||
*
|
||||
* @param {string} content — full file content
|
||||
* @returns {{valid: boolean, missing: string[]}}
|
||||
|
|
@ -195,7 +278,12 @@ export function validateKbFile(content) {
|
|||
if (!RE_TITLE.test(s)) missing.push('title');
|
||||
if (!RE_LAST_UPDATED.test(s)) missing.push('last_updated');
|
||||
if (!RE_STATUS.test(s)) missing.push('status');
|
||||
if (parseSourceHeader(s) === null) missing.push('source');
|
||||
const type = parseTypeHeader(s) ?? DEFAULT_TYPE;
|
||||
if (type === 'reference') {
|
||||
if (parseSourceHeader(s) === null) missing.push('source');
|
||||
if (parseVerifiedHeader(s) === null) missing.push('verified');
|
||||
if (parseVerifiedByHeader(s) === null) missing.push('verified_by');
|
||||
}
|
||||
if (isLargeContent(s) && !hasToc(s)) missing.push('toc');
|
||||
return { valid: missing.length === 0, missing };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,13 +23,16 @@ regel 3 (autoritets-mismatch) faktisk fyre.
|
|||
- `{{CATEGORY}}` — taksonomi-kategorinøkkel (f.eks. `rag-architecture`) — bestemmer eierskill via `resolveTargetPath`
|
||||
- `{{SOURCE_URL}}` — den **utpekte autoritetskilden** (kanonisk Learn-URL dokumentet ble hentet fra)
|
||||
- `{{LAST_UPDATED}}` — `YYYY-MM` (dagens måned, caller-injisert)
|
||||
- `{{VERIFIED_DATE}}` — `YYYY-MM-DD` (dagen judgen bekreftet innholdet; settes av `stampVerifiedMeta` etter steg 2, ikke før)
|
||||
- `{{FETCHED_DOC}}` — den hentede markdownen fra `microsoft_docs_fetch`
|
||||
- `{{EXISTING_CONTEXT}}` — sammendrag av overlappende eksisterende ref-filer (unngå duplisering — K4)
|
||||
|
||||
## Header (deterministisk — IKKE la LLM-en skrive den)
|
||||
|
||||
Header-blokka bygges av `buildKbHeader({title, status, category, source, lastUpdated})`,
|
||||
ikke av modellen. Den ser slik ut og MÅ stå først:
|
||||
Header-blokka bygges av `buildKbHeader({title, status, category, type, source, lastUpdated,
|
||||
verified, verified_by})`, ikke av modellen. En `reference`-fil er **født verifisert**
|
||||
(Spor 3 Port 1/Port 2): den MÅ bære `Type` + `Source` + `Verified` + `Verified by`.
|
||||
Headeren ser slik ut og MÅ stå først:
|
||||
|
||||
```markdown
|
||||
# {{TITLE}}
|
||||
|
|
@ -37,17 +40,22 @@ ikke av modellen. Den ser slik ut og MÅ stå først:
|
|||
**Last updated:** {{LAST_UPDATED}}
|
||||
**Status:** {{STATUS}}
|
||||
**Category:** {{CATEGORY}}
|
||||
**Type:** reference
|
||||
**Source:** {{SOURCE_URL}}
|
||||
**Verified:** {{VERIFIED_DATE}}
|
||||
**Verified by:** judge-v2
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Modellen skriver **kun brødteksten under `---`**. `composeKbFile({...}, body)` setter så
|
||||
sammen hele fila — header + (for **store filer >100 linjer**) en deterministisk
|
||||
`## Innhold`-TOC fra brødtekstens `##`-seksjoner + brødteksten. Etterpå kjører
|
||||
`validateKbFile(content)` og MÅ returnere `valid: true` (title + Last updated + Status +
|
||||
Source til stede; **for store filer også TOC**) før noe gates videre. TOC-en fødes inn så
|
||||
en senere regenerering ikke stripper den (write-path-regresjon — Fase 1c).
|
||||
`Verified`/`Verified by` settes IKKE før innholdet har bestått judgen (se «Etter
|
||||
destillasjon» steg 2). Modellen skriver **kun brødteksten under `---`**.
|
||||
`composeKbFile(stampVerifiedMeta({...}, verdict, today), body)` setter så sammen hele fila
|
||||
— header + (for **store filer >100 linjer**) en deterministisk `## Innhold`-TOC fra
|
||||
brødtekstens `##`-seksjoner + brødteksten. Etterpå kjører `validateKbFile(content)` og MÅ
|
||||
returnere `valid: true` (title + Last updated + Status + Source + **Verified + Verified by**
|
||||
til stede; **for store filer også TOC**) før noe gates videre. TOC-en fødes inn så en
|
||||
senere regenerering ikke stripper den (write-path-regresjon — Fase 1c).
|
||||
|
||||
## Prompt (brødtekst)
|
||||
|
||||
|
|
@ -90,10 +98,19 @@ Ingen innledning eller avslutningskommentar — kun markdown-brødteksten.
|
|||
|
||||
## Etter destillasjon (caller, gated)
|
||||
|
||||
1. `content = composeKbFile({title, status, category, source, lastUpdated}, body)` — header + (store filer) `## Innhold`-TOC + brødtekst. Erstatter den bare `buildKbHeader(...) + body`-konkateneringen.
|
||||
2. `validateKbFile(content)` → må være `valid: true` (title + Last updated + Status + Source; **store filer også TOC**) (ellers: be modellen fikse manglende felt).
|
||||
3. For hver status-/load-bearing-påstand: `buildChange({field, oldValue, newValue, sourceUrl, authoritySource})`
|
||||
1. **Født-verifisert judge (FØR commit):** kjør claim-judgen (`scripts/kb-eval/judge-claim-prompt-v2.md`)
|
||||
over brødtekstens maskin-verifiserbare påstander mot `{{SOURCE_URL}}`. Aggreger til `verdict = {pass}`
|
||||
(alle maskin-verifiserbare påstander `grounded` ⇒ `pass: true`).
|
||||
2. `meta = stampVerifiedMeta({title, status, category, source, lastUpdated}, verdict, today)` —
|
||||
stempler `type='reference'` + `verified=today` + `verified_by='judge-v2'` **kun** ved `pass: true`;
|
||||
ellers KASTER den (ingen fil fødes). Deretter `content = composeKbFile(meta, body)` —
|
||||
header + (store filer) `## Innhold`-TOC + brødtekst.
|
||||
3. `validateKbFile(content)` → må være `valid: true` (title + Last updated + Status + Source +
|
||||
**Verified + Verified by**; **store filer også TOC**) (ellers: be modellen fikse manglende felt).
|
||||
4. For hver status-/load-bearing-påstand: `buildChange({field, oldValue, newValue, sourceUrl, authoritySource})`
|
||||
→ `classifyChange(...)` (lag 5). `flagged` → operatør avgjør; status-påstander er alltid `flagged`.
|
||||
4. `resolveTargetPath(tax, category, filename)` → eierskill-sti (null = ukjent kategori → gate, ikke skriv).
|
||||
5. Først etter operatør-gate: atomisk skriving (`lib/atomic-write.mjs` + `lib/backup.mjs`). Transform-libben skriver aldri selv.
|
||||
5. `resolveTargetPath(tax, category, filename)` → eierskill-sti (null = ukjent kategori → gate, ikke skriv).
|
||||
6. **Create-guard FØR skriving:** `node scripts/kb-update/validate-kb-file.mjs <sti>` (exit ≠0 = ikke skriv).
|
||||
Først etter operatør-gate + grønn create-guard: atomisk skriving (`lib/atomic-write.mjs` +
|
||||
`lib/backup.mjs`). Transform-libben skriver aldri selv.
|
||||
```
|
||||
|
|
|
|||
69
scripts/kb-update/validate-kb-file.mjs
Normal file
69
scripts/kb-update/validate-kb-file.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
// validate-kb-file.mjs — the executable create-time-guard (Spor 3 Port 2).
|
||||
//
|
||||
// The gate any generator (generate-skills, kb-update) calls AFTER writing a candidate
|
||||
// KB file and BEFORE committing it. It wraps transform.validateKbFile over one or more
|
||||
// paths and exits NON-ZERO if any file violates the contract — so a regenerated file
|
||||
// that lost its Source, is not born-verified, or dropped its TOC can never ship. This
|
||||
// is what makes "create-guard MUST be in place before Spor 1 corpus-fixing" enforceable:
|
||||
// the generator physically cannot re-introduce the drift the corpus pass is removing.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/kb-update/validate-kb-file.mjs <file.md> [<file2.md> ...]
|
||||
//
|
||||
// Exit code: 0 = all files valid; 1 = at least one violation (or usage error).
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateKbFile } from './lib/transform.mjs';
|
||||
|
||||
/**
|
||||
* Validate a batch of candidate KB files. Pure + dependency-injected (readFile) so it is
|
||||
* unit-testable without disk. A path that cannot be read is reported as invalid (with a
|
||||
* `read-error` marker) rather than throwing — one unreadable file must not crash the gate.
|
||||
*
|
||||
* @param {string[]} paths
|
||||
* @param {(p: string) => string} [readFile] — reader (defaults to readFileSync utf8)
|
||||
* @returns {{ok: boolean, results: Array<{path: string, valid: boolean, missing: string[]}>}}
|
||||
*/
|
||||
export function validatePaths(paths, readFile = (p) => readFileSync(p, 'utf8')) {
|
||||
const results = (paths ?? []).map((path) => {
|
||||
let content;
|
||||
try {
|
||||
content = readFile(path);
|
||||
} catch (err) {
|
||||
return { path, valid: false, missing: [`read-error: ${err.message}`] };
|
||||
}
|
||||
const { valid, missing } = validateKbFile(content);
|
||||
return { path, valid, missing };
|
||||
});
|
||||
return { ok: results.every((r) => r.valid), results };
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const paths = argv.slice(2);
|
||||
if (paths.length === 0) {
|
||||
process.stderr.write('usage: validate-kb-file.mjs <file.md> [<file2.md> ...]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const { ok, results } = validatePaths(paths);
|
||||
for (const r of results) {
|
||||
if (r.valid) {
|
||||
process.stdout.write(`OK ${r.path}\n`);
|
||||
} else {
|
||||
process.stdout.write(`FAIL ${r.path} — missing: ${r.missing.join(', ')}\n`);
|
||||
}
|
||||
}
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
// Robust direct-invocation check (mirrors detect-courses.mjs): only run main when this
|
||||
// file is the entrypoint, not when imported by a test.
|
||||
const isMain = (() => {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
if (isMain) main(process.argv);
|
||||
Loading…
Add table
Add a link
Reference in a new issue