Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
315 lines
13 KiB
JavaScript
315 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Shared library for the compact-content VARIANT guard (ADR-4139, epic #4139,
|
|
* Phase 6 #4406). See `msd-core/references/compact-content-gate.md` §"Streams
|
|
* 1b and 4 — variant resolution" for the operational rule this module checks;
|
|
* this file is the mechanics, not the source of truth for behavior.
|
|
*
|
|
* This is a DIFFERENT shape from `compact-content-split.cjs` (Phase 3, stream
|
|
* 1's spine+detail partition). A partition is one document split into two
|
|
* halves that must never overlap (disjointness) and whose union must equal
|
|
* the original (completeness). A variant pair is two INDEPENDENT, complete
|
|
* documents that are EXPECTED to overlap heavily — the compact file is a
|
|
* hand-terser rewrite of the same content, not an extracted remainder. So
|
|
* this module has no disjointness check and no completeness-at-split-time
|
|
* check; it has the five checks `40-design.md` (Phase 6) describes instead:
|
|
*
|
|
* 1. Registration — `discoverRegisteredVariants` (a `.compact.md`
|
|
* file with no canonical sibling is not a registered pair; the guard
|
|
* test reports it as an orphan).
|
|
* 2. Reachability — `checkReachability` (a registered pair whose
|
|
* compact path is never named by any spine's "Read ... variant
|
|
* resolution" call site is unwired dead weight).
|
|
* 3. Protected content preserved — `checkProtectedContentPreserved` (a
|
|
* `<!-- msd:protected -->` block's lines must appear verbatim in BOTH
|
|
* files, since nothing is "moved" in a variant pair — it is duplicated).
|
|
* 4. Size smaller — `checkSizeSmaller`.
|
|
* 5. Template consumer parity — NOT implemented here; it needs a real
|
|
* artifact-generation + real-parser round trip per template, which is
|
|
* the domain of `tests/compact-content-template-variant-parity.test.cjs`
|
|
* directly, not a generic file-shape check.
|
|
*
|
|
* This module only reads (filesystem + a search of markdown source for
|
|
* literal path substrings). No writes, no network, no git.
|
|
*
|
|
* `agents/*.compact.md` (Phase 7, #4407, ADR-4139 stream 2) is a THIRD shape
|
|
* layered onto this module's roots: registration, protected-content and
|
|
* size checks apply unchanged, but reachability does not — an agent variant
|
|
* is reached by a generic, config-driven code construction in
|
|
* `cmdAgentSkills` (`src/init.cts`), not by a literal path substring named in
|
|
* markdown prose. `checkReachability`'s markdown-search shape has nothing to
|
|
* find there, so callers scanning `agents/` skip it and instead assert the
|
|
* code seam exists once (see `tests/agent-skills-compact-variant.test.cjs`).
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { extractProtectedBlocks, normalizeNonTrivialLines } = require('./compact-content-split.cjs');
|
|
|
|
/** Default scan roots: everywhere a `.compact.md` sibling can legally live. */
|
|
const DEFAULT_VARIANT_ROOTS = [
|
|
path.join(__dirname, '..', '..', 'msd-core', 'workflows'),
|
|
path.join(__dirname, '..', '..', 'msd-core', 'templates'),
|
|
];
|
|
|
|
/** Every markdown-source root a spine/fragment might name a variant path from. */
|
|
const DEFAULT_SEARCH_ROOTS = [
|
|
path.join(__dirname, '..', '..', 'msd-core', 'workflows'),
|
|
];
|
|
|
|
/**
|
|
* `agents/*.compact.md` root (Phase 7, #4407). Deliberately NOT folded into
|
|
* `DEFAULT_VARIANT_ROOTS` — `checkReachability`'s markdown-literal-search shape
|
|
* has nothing to find for an agent variant (reached by a generic code
|
|
* construction in `cmdAgentSkills`, not a named path in prose), so a caller
|
|
* that discovers agent pairs through the shared default and then runs
|
|
* `checkReachability` on them would report false violations. Callers that want
|
|
* agent pairs pass `[AGENTS_ROOT]` explicitly to `discoverRegisteredVariants`.
|
|
*/
|
|
const AGENTS_ROOT = path.join(__dirname, '..', '..', 'agents');
|
|
|
|
const COMPACT_SUFFIX = '.compact.md';
|
|
|
|
/**
|
|
* Recursively list every file under `dir` whose name ends with `suffix`.
|
|
* Shared by both file-discovery needs this module has — `.compact.md` files
|
|
* (`findCompactFiles`) and general `.md` files to search for reachability
|
|
* (`findMarkdownFiles`) — which otherwise duplicated the same walk with only
|
|
* the extension predicate differing.
|
|
* @param {string} dir
|
|
* @param {string} suffix
|
|
* @returns {string[]} absolute paths
|
|
*/
|
|
function findFilesWithSuffix(dir, suffix) {
|
|
const results = [];
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return results;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
results.push(...findFilesWithSuffix(full, suffix));
|
|
} else if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
results.push(full);
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Recursively list every `*.compact.md` file under `dir`.
|
|
* @param {string} dir
|
|
* @returns {string[]} absolute paths
|
|
*/
|
|
function findCompactFiles(dir) {
|
|
return findFilesWithSuffix(dir, COMPACT_SUFFIX);
|
|
}
|
|
|
|
/**
|
|
* Discover every registered compact/canonical variant pair under `roots`.
|
|
*
|
|
* A pair is registered by a `<dir>/<stem>.compact.md` file existing on disk —
|
|
* there is no separate registry. Its canonical sibling is `<dir>/<stem>.md`
|
|
* in the SAME directory. A `.compact.md` file with no canonical sibling is
|
|
* still returned (with `canonicalExists: false`) so the registration check
|
|
* can report it as an orphan by name, rather than silently skipping it.
|
|
*
|
|
* @param {string[]} roots
|
|
* @returns {{compactPath: string, canonicalPath: string, canonicalExists: boolean}[]}
|
|
*/
|
|
function discoverRegisteredVariants(roots = DEFAULT_VARIANT_ROOTS) {
|
|
const pairs = [];
|
|
for (const root of roots) {
|
|
for (const compactPath of findCompactFiles(root)) {
|
|
const dir = path.dirname(compactPath);
|
|
const stem = path.basename(compactPath, COMPACT_SUFFIX);
|
|
const canonicalPath = path.join(dir, `${stem}.md`);
|
|
pairs.push({
|
|
compactPath,
|
|
canonicalPath,
|
|
canonicalExists: fs.existsSync(canonicalPath),
|
|
});
|
|
}
|
|
}
|
|
return pairs.sort((a, b) => a.compactPath.localeCompare(b.compactPath));
|
|
}
|
|
|
|
/**
|
|
* Check 1 — registration. A `.compact.md` file must have a canonical sibling.
|
|
* @param {ReturnType<typeof discoverRegisteredVariants>} pairs
|
|
*/
|
|
function checkRegistration(pairs) {
|
|
const violations = [];
|
|
for (const pair of pairs) {
|
|
if (!pair.canonicalExists) {
|
|
violations.push({ kind: 'orphan_compact_file', compactPath: pair.compactPath });
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Check 2 — reachability. A registered pair's compact path must be named by
|
|
* at least one markdown file under `searchRoots` (a spine's "Read ... variant
|
|
* resolution" call site). Three needle forms, matched differently, because
|
|
* this corpus has two live conventions for naming these paths (found by
|
|
* walking up from the compact file itself to its nearest `msd-core` ancestor,
|
|
* so this works the same way against the real repo and against a fixture
|
|
* that builds its own `<tmp>/msd-core/...` tree):
|
|
*
|
|
* - The `msd-core/<rest>` form (e.g. `msd-core/workflows/autonomous/steps/
|
|
* converge-fail-fast.md`'s own convention) is unambiguous on its own — a
|
|
* different, longer path coincidentally ending in this exact multi-segment
|
|
* suffix is not a realistic false positive, so a plain substring match is
|
|
* sufficient without the "unprefixed" guard below.
|
|
* - The `<rest>` form without the leading `msd-core/` (e.g. `workflows/help/
|
|
* modes/full.compact.md`, `help.md`'s own dispatch-table convention) is
|
|
* equally unambiguous for the same reason.
|
|
* - The bare `<stem>.compact.md` form has no such guarantee — a same-named
|
|
* file under an unrelated nested directory (the exact class of bug already
|
|
* hit once this phase: `discuss-phase/templates/context.md` vs. the root
|
|
* `templates/context.md`) could grant it a false reachability. This form
|
|
* keeps the `isUnprefixedMatch` guard from `namesFragmentAsEntryPoint`
|
|
* (`scripts/lint-response-language-coverage.cjs`): a path character
|
|
* immediately before the match means this is the tail of some longer,
|
|
* different path, not the fragment itself.
|
|
*
|
|
* @param {ReturnType<typeof discoverRegisteredVariants>} pairs
|
|
* @param {string[]} searchRoots
|
|
*/
|
|
function checkReachability(pairs, searchRoots = DEFAULT_SEARCH_ROOTS) {
|
|
const violations = [];
|
|
const haystacks = [];
|
|
for (const root of searchRoots) {
|
|
for (const file of findMarkdownFiles(root)) {
|
|
haystacks.push(fs.readFileSync(file, 'utf8'));
|
|
}
|
|
}
|
|
for (const pair of pairs) {
|
|
if (!pair.canonicalExists) continue; // already reported by checkRegistration
|
|
const msdCoreRelative = relativeToNearestMsdCore(pair.compactPath);
|
|
const stem = path.basename(pair.compactPath, COMPACT_SUFFIX);
|
|
const bareNeedle = `${stem}${COMPACT_SUFFIX}`;
|
|
const reached = haystacks.some((text) => {
|
|
if (msdCoreRelative && (text.includes(`msd-core/${msdCoreRelative}`) || text.includes(msdCoreRelative))) {
|
|
return true;
|
|
}
|
|
return isUnprefixedMatch(text, bareNeedle);
|
|
});
|
|
if (!reached) {
|
|
violations.push({ kind: 'unreachable_compact_file', compactPath: pair.compactPath });
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Walk up from `filePath` to the nearest ancestor directory literally named
|
|
* `msd-core`, and return the path from there to `filePath` (POSIX-separated).
|
|
* Returns `null` if no such ancestor exists. Anchoring on the literal
|
|
* `msd-core` segment — rather than a hardcoded repo-root constant — is what
|
|
* lets this match both the real repo and a fixture built under its own
|
|
* `<tmp>/msd-core/...` tree the same way.
|
|
* @param {string} filePath
|
|
* @returns {string | null}
|
|
*/
|
|
function relativeToNearestMsdCore(filePath) {
|
|
const segments = filePath.split(path.sep);
|
|
const idx = segments.lastIndexOf('msd-core');
|
|
if (idx === -1) return null;
|
|
return segments.slice(idx + 1).join('/');
|
|
}
|
|
|
|
/** Is `needle` present in `text` with no path character immediately before it (any line)? */
|
|
function isUnprefixedMatch(text, needle) {
|
|
return text.split(/\r?\n/).some((line) => {
|
|
const at = line.indexOf(needle);
|
|
if (at === -1) return false;
|
|
const before = at > 0 ? line[at - 1] : '';
|
|
return !/[A-Za-z0-9_\-./]/.test(before);
|
|
});
|
|
}
|
|
|
|
function findMarkdownFiles(dir) {
|
|
return findFilesWithSuffix(dir, '.md');
|
|
}
|
|
|
|
/**
|
|
* Check 3 — protected content preserved. Every protected block's non-trivial
|
|
* lines in the canonical file must also appear (verbatim, after the same
|
|
* normalization the partition guard uses) somewhere in the compact sibling.
|
|
* Unlike the partition guard, this is NOT a sentinel-presence check on the
|
|
* compact file itself — the compact file need not carry `<!-- msd:protected -->`
|
|
* markers of its own, since it is not itself audited for content it might
|
|
* shed later; it only must not have DROPPED the protected wording.
|
|
*
|
|
* @param {ReturnType<typeof discoverRegisteredVariants>} pairs
|
|
*/
|
|
function checkProtectedContentPreserved(pairs) {
|
|
const violations = [];
|
|
for (const pair of pairs) {
|
|
if (!pair.canonicalExists) continue;
|
|
const canonical = fs.readFileSync(pair.canonicalPath, 'utf8');
|
|
const compact = fs.readFileSync(pair.compactPath, 'utf8');
|
|
const { blocks } = extractProtectedBlocks(canonical);
|
|
if (blocks.length === 0) continue;
|
|
const compactLines = new Set(normalizeNonTrivialLines(compact));
|
|
for (const block of blocks) {
|
|
const missing = block.lines
|
|
.map((l) => l.trim())
|
|
.filter((l) => l.length > 0)
|
|
.filter((l) => !compactLines.has(l));
|
|
if (missing.length > 0) {
|
|
violations.push({
|
|
kind: 'protected_content_dropped',
|
|
canonicalPath: pair.canonicalPath,
|
|
compactPath: pair.compactPath,
|
|
missing,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
/**
|
|
* Check 4 — size smaller. The compact file must be strictly smaller than its
|
|
* canonical sibling; a same-size-or-larger "compact" file is not one.
|
|
* @param {ReturnType<typeof discoverRegisteredVariants>} pairs
|
|
*/
|
|
function checkSizeSmaller(pairs) {
|
|
const violations = [];
|
|
for (const pair of pairs) {
|
|
if (!pair.canonicalExists) continue;
|
|
const canonicalSize = fs.statSync(pair.canonicalPath).size;
|
|
const compactSize = fs.statSync(pair.compactPath).size;
|
|
if (!(compactSize < canonicalSize)) {
|
|
violations.push({
|
|
kind: 'compact_not_smaller',
|
|
canonicalPath: pair.canonicalPath,
|
|
compactPath: pair.compactPath,
|
|
canonicalSize,
|
|
compactSize,
|
|
});
|
|
}
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_VARIANT_ROOTS,
|
|
DEFAULT_SEARCH_ROOTS,
|
|
AGENTS_ROOT,
|
|
COMPACT_SUFFIX,
|
|
discoverRegisteredVariants,
|
|
checkRegistration,
|
|
checkReachability,
|
|
checkProtectedContentPreserved,
|
|
checkSizeSmaller,
|
|
};
|