* test(#2874): add failing-first gate for the executed-plan return Four rows from the matrix's red-first order. E3 pins the one early return, for the opencode family, where a void-shaped hole would otherwise survive unnoticed. E13 sweeps every runtime in the registry - enumerated from the registry rather than hardcoded, so a runtime added later cannot slip past. F2 proves absence of real filesystem contact rather than merely that the happy path ran, which is the difference between a complete seam and a partial one. G1 and G3 are the additive guard and must be green before and after. G3 deliberately leaves the two existing adapter test doubles untouched: if this change required editing them it would not be additive, and the acceptance criterion would be unmet. No production code. All 19 runtimes install without throwing today, so E3 and E13 fail on the undefined comparison alone. Refs #2874 * feat(#2874): return the executed plan and route install IO through a seam installRuntimeArtifacts returned void, so its correctness was observable only by re-reading disk. It now returns what it executed - per kind, per scope - including on the combinedFamilyInstall path, which was the one early return where a void-shaped hole would have survived unnoticed. Failure still throws rather than becoming an ok:false return, so control flow is unchanged for both existing callers. A best-effort cleanup that fails is still swallowed, but is now visible in the returned value rather than silently absent. The fs seam is ambient rather than threaded. Explicit deps through install-profiles and the 3000-line conversion module was impractical; the tradeoff, the synchronous-only re-entrancy assumption, the restore guarantee and the partial-adapter fallback trap are all documented at the seam. findInstallSourceRoot and its sibling stay unrouted by design - they locate the package's own source, not the install destination. readCmdNames keeps a second implementation because the standalone CLI that owns the original cannot require the compiled adapter without a build-order dependency on its own output. A parity test fails if the two ever disagree. Refs #2874 * chore(#2874): gitignore the new build artifact install-fs-adapter.cjs is tsc output from src/install-fs-adapter.cts, not a tracked source file. It was added to eslint's ignore list but not to .gitignore, so it landed as a tracked file - the third time this step of the new-.cts ripple has been missed on this epic. Refs #2874 * fix(#2874): close two seam leaks and correct a false comment A correctness review found the seam still leaked in two places, both subtler than the three already closed. readGsdCommandNames was routed when it should not have been: it reads the package's own commands directory, which a destination-fake is never seeded with, so under a fake adapter it returned an empty or wrong roster instead of failing loudly. It now reads real fs, matching the precedent already documented for findInstallSourceRoot. cleanupStagedSkills ran raw rmSync from a process exit handler, which is real filesystem work deferred past the point where withInstallFs has restored - the one thing the synchronous-only contract exists to exclude. Staging now captures the adapter that created each directory and cleanup replays it, so a real install cleans up exactly as before and a fake-staged path never reaches the real filesystem. Also corrected a comment claiming the migration reads were an unrouted, untested residual gap. They are routed and exercised; a comment understating the seam is as corrosive as one overstating it in a module whose trust rests on being honestly documented. Refs #2874 * test(#2874): migrate the exemplar group and cover the matrix AC3's exemplar migration lands in place: the qwen install group now asserts skills and agents destinations from the returned plan in one deepStrictEqual instead of probing the filesystem for each. Nine facts the old probes established were enumerated first. Two moved to the value assertion; seven were retained deliberately - per-file SKILL.md existence, the VERSION file written outside this function, the manifest content, and the post-uninstall absence checks all sit outside the plan's per-kind contract. A migration that quietly asserts less looks like a win and is a regression, so the enumeration is the guard rather than the line count. Also implements the rest of the matrix: the executed-plan shape, adapter failure modes, the security-boundary rows including a fake that cannot certify an install the real filesystem would refuse, cleanup visibility, and two seeded property tests. Only the two external CI gates are left unticked, because self-certifying them would be a claim rather than a check. Refs #2874 * fix(#2874): restore streaming hashes and derive F2 from the boundary rule The checkpoint found three things reasoning had missed. sha256File had been converted from raw-fd streaming to a single readFileSync on the assumption that GSD artifacts are never large. A test named for exactly that contract already existed and went red. Streaming is restored, now routed through the adapter, which gains openSync, readSync and closeSync. The contract was the specification; the assumption was not. Three existing tests inject faults by monkeypatching real fs. They broke because mkInstallTempDir stopped calling real mkdtempSync, not because of any binding subtlety - the real adapter was already late-bound. It now calls the real function when no fake is injected, so a monkeypatch applied after import is still seen and the additive contract holds. F2 poisoned real fs by method, so a deliberately unrouted package-source read failed a correct design. It now poisons by path: destination IO is forbidden, package-source IO is allowed and positively asserted. The claim was always zero real destination IO, and the test now derives from that rule instead of coincidentally matching it. Refs #2874 * docs(#2874): add the contributor how-to for plan-based test migration The phase gate caught a real gap. The docs plan was Reference plus Explanation only, and every CI check would have passed, because the docs-required lint only verifies that some file under docs/ moved. But this phase exists to demonstrate a pattern for follow-on work, and that work is other contributors migrating probing test groups. The sequence has two live traps - a partial fake silently falls back to real fs, and the seam is ambient and synchronous-only - plus one discipline nobody infers: enumerate the facts before converting, or you assert less and call it a win. The page carries the qwen migration's arithmetic, nine facts enumerated and only two converted, because a reader seeing only the diff would reasonably conclude the pattern is to replace probes wholesale. No locale mirrors: none of the four carries any contributor-only how-to, so a single translated file would manufacture parity rather than provide it. Refs #2874 * chore(#2874): backfill changeset pr number * test(#2874): normalize both sides of the G1 tree comparison G1 failed on Windows only, deterministically on both shards. The defect was in the test helper, not production. _computePathPrefix posix-normalizes the resolved config dir unconditionally, so on Windows the path embedded in every emitted SKILL.md body is forward-slash form. hashDirTree stripped against the raw backslash path from mkdtempSync, so the substring never matched and each install's unique temp suffix stayed baked into every file - all fifteen skill bodies hashed differently for two runs that had written identical bytes. Both sides are now normalized unconditionally rather than gated on path.sep, matching the rule this repo already records: backslash paths arrive on Linux too. Production code is untouched and was verified correct. Normalizing this away on the production side would have hidden a real portability bug if one had existed. Refs #2874 --------- Co-authored-by: sim <sim@local>
149 lines
6.5 KiB
TypeScript
149 lines
6.5 KiB
TypeScript
'use strict';
|
|
|
|
/**
|
|
* CommonJS module-type marker — single source of truth (#2544).
|
|
*
|
|
* GSD stages its own hook scripts and native plugin adapters as `.js` files.
|
|
* Node resolves a `.js` file's module type by walking up for the nearest
|
|
* `package.json`, so an ambient `"type": "module"` above the install location
|
|
* makes every one of those scripts fail with `require is not defined`. GSD
|
|
* pins them to CommonJS by writing a minimal `{"type":"commonjs"}` marker.
|
|
*
|
|
* Two rules govern that marker, and this module exists so both are enforced in
|
|
* exactly one place:
|
|
*
|
|
* 1. **Write only where GSD owns the contents.** The marker belongs in the
|
|
* directories GSD fills with its own `.js` files (`hooks/`, and the
|
|
* `nativePlugin.dir` for the runtimes that declare one) — never at the
|
|
* runtime's shared config root, which on OpenCode and Kilo is documented,
|
|
* user-writable territory for declaring local-plugin npm dependencies.
|
|
*
|
|
* 2. **Never overwrite a file GSD did not write.** Before #2544 the install
|
|
* path wrote the marker unconditionally while the uninstall path already
|
|
* compared content before unlinking. That asymmetry is the defect: the
|
|
* discipline existed in the codebase, it was simply not applied on the
|
|
* write side. `classifyMarker` is now the shared predicate behind both
|
|
* `ensureCommonJsMarker` and `removeCommonJsMarker`, so install and
|
|
* uninstall cannot drift apart again.
|
|
*
|
|
* Ownership is decided by exact content match against the marker GSD itself
|
|
* writes — the same test the uninstall path has always used.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
// #2874 (ADR-58 cleanup phase): ensureCommonJsMarker is reached from
|
|
// install-engine.cts's _installNativePluginIfDeclared, which is on the
|
|
// installRuntimeArtifacts call tree — route this module's fs calls through
|
|
// the injectable seam too. See install-fs-adapter.cts's module doc.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import installFsAdapter = require('./install-fs-adapter.cjs');
|
|
const { installFs } = installFsAdapter;
|
|
|
|
/** The exact marker content GSD writes (and the only content it will remove). */
|
|
export const COMMONJS_MARKER = '{"type":"commonjs"}';
|
|
|
|
/** File bytes written to disk — the marker plus a trailing newline. */
|
|
export const COMMONJS_MARKER_CONTENT = `${COMMONJS_MARKER}\n`;
|
|
|
|
/**
|
|
* `absent` — no package.json here; GSD may create one.
|
|
* `gsd-owned` — content is exactly GSD's marker; GSD may rewrite or remove it.
|
|
* `foreign` — anything else, including a present-but-unreadable file. GSD
|
|
* must leave it strictly alone.
|
|
*/
|
|
export type MarkerOwnership = 'absent' | 'gsd-owned' | 'foreign';
|
|
|
|
/**
|
|
* Outcome of an `ensureCommonJsMarker` call, for caller-side reporting.
|
|
*
|
|
* `failed` is the best-effort outcome: the marker could not be written for an
|
|
* environmental reason (`EACCES` on a read-only `hooks/`, `EROFS`, `ENOSPC`).
|
|
* It is reported, never thrown — see `ensureCommonJsMarker`.
|
|
*/
|
|
export type MarkerWriteOutcome = 'written' | 'unchanged' | 'preserved-foreign' | 'failed';
|
|
|
|
/** The marker path for a directory. */
|
|
export function markerPathFor(dir: string): string {
|
|
return path.join(dir, 'package.json');
|
|
}
|
|
|
|
/**
|
|
* Classify the package.json in `dir` by ownership.
|
|
*
|
|
* Fails CLOSED: a file that exists but cannot be read is reported `foreign`,
|
|
* never `absent`. Reporting it absent would license the overwrite this module
|
|
* exists to prevent. (Same posture as the unreadable-config branch in
|
|
* capability-command-router.cjs: present-but-unreadable never downgrades to
|
|
* the permissive answer.)
|
|
*/
|
|
export function classifyMarker(dir: string): MarkerOwnership {
|
|
const markerPath = markerPathFor(dir);
|
|
let stat: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean };
|
|
try {
|
|
// lstat, not existsSync: existsSync follows symlinks and reports `false`
|
|
// for a DANGLING one, which would classify the path `absent` and let the
|
|
// write below follow the link and land outside the directory GSD owns.
|
|
stat = installFs().lstatSync(markerPath);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 'absent';
|
|
return 'foreign';
|
|
}
|
|
// Anything that is not a regular file (symlink, directory, socket) is not
|
|
// something GSD wrote, so it is never ours to overwrite or remove.
|
|
if (!stat.isFile()) return 'foreign';
|
|
try {
|
|
const content = installFs().readFileSync(markerPath, 'utf8');
|
|
return content.trim() === COMMONJS_MARKER ? 'gsd-owned' : 'foreign';
|
|
} catch {
|
|
return 'foreign';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the CommonJS marker into `dir`, unless a file GSD does not own is
|
|
* already there.
|
|
*
|
|
* Creates `dir` when needed. Returns what happened so the caller can report
|
|
* it; a `preserved-foreign` result is not an error — it is the guard working.
|
|
*
|
|
* NEVER THROWS. Every other marker interaction in this module is best-effort —
|
|
* `removeCommonJsMarker` swallows unlink failures, `classifyMarker` swallows
|
|
* read failures — and the write path is the one most likely to fail on a
|
|
* locked-down config dir (`EACCES` on a read-only `hooks/`, `EROFS`, `ENOSPC`).
|
|
* Letting it throw made an unwritable marker abort the entire install with a
|
|
* raw stack trace, which is a strictly worse outcome than hooks that resolve as
|
|
* ESM: the caller can warn and continue, and does. Both the `mkdir` and the
|
|
* write are inside the guard — creating the directory is the same environmental
|
|
* hazard as writing into it.
|
|
*/
|
|
export function ensureCommonJsMarker(dir: string): MarkerWriteOutcome {
|
|
const ownership = classifyMarker(dir);
|
|
if (ownership === 'foreign') return 'preserved-foreign';
|
|
if (ownership === 'gsd-owned') return 'unchanged';
|
|
try {
|
|
installFs().mkdirSync(dir, { recursive: true });
|
|
// Exclusive create closes the gap between classifying and writing: if
|
|
// anything at all appeared at the path in between — including a symlink —
|
|
// this fails with EEXIST instead of following or overwriting it.
|
|
installFs().writeFileSync(markerPathFor(dir), COMMONJS_MARKER_CONTENT, { flag: 'wx' });
|
|
return 'written';
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'EEXIST') return 'preserved-foreign';
|
|
return 'failed';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove the CommonJS marker from `dir` — only when the content is exactly
|
|
* the marker GSD writes. Returns true when a file was removed.
|
|
*/
|
|
export function removeCommonJsMarker(dir: string): boolean {
|
|
if (classifyMarker(dir) !== 'gsd-owned') return false;
|
|
try {
|
|
installFs().unlinkSync(markerPathFor(dir));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|