Files
openprose__prose/tools/cli/tests/prose/openprose-root.test.ts
Raymond Weitekamp 9856ac5b12 feat(spec): declared ### Skills section with fail-closed compile resolution (#62)
* feat(spec): declared ### Skills section with fail-closed compile resolution

Implements the spec from issue #60. Components declare required harness
skills via a `### Skills` section (colon form, e.g. `document-skills:pdf`).
`prose compile` resolves declared skills against ./skills/, ~/.claude/skills/,
~/.codex/skills/, and ~/.agents/skills/, and fails closed with
`skill_unresolved` before forwarding to the agent harness when any are
missing.

- Spec: skills/open-prose/contract-markdown.md gains a ### Skills row in
  the Canonical Sections table and a ## Skills H2 covering colon naming,
  search order, the BYO-harness invariant, and fail-closed semantics.
- Implementation: tools/cli/src/skills/declared.ts (parser + resolver +
  directory walker + DeclaredSkillsUnresolvedError). Pure functions; no
  I/O beyond readFile / readdir / stat.
- Wiring: tools/cli/src/commands/compile.ts pre-checks declared skills
  before forwarding the compile prompt; fails closed with
  CompileValidationError when any are unresolved. Gated behind the
  existing skillPreflight option for test parity.
- Example: skills/open-prose/examples/declared-skills/ shows the
  document-skills:pdf canonical pattern.
- Tests: 19 new (18 in declared.test.ts covering parser/resolver/walker/
  error formatter; 1 in cli.test.ts asserting compile fails closed before
  the harness is invoked when a declared skill is missing).
- BYO harness: OpenProse never installs harness skills; resolution failure
  is the user's signal to install the named skill themselves.

Resolves #60.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(spec): move skill resolution into compiler program; trim spec per review

Address review feedback on #62:

- contract-markdown.md: drop the "not duplicated in frontmatter" clause —
  the rejected alternative shouldn't propagate into the spec.
- contract-markdown.md: fail-closed clause now mentions only `prose compile`;
  `prose run` enforcement is deferred per the PR description.
- compiler/index.prose.md: add a `skills_resolver` agent that owns the
  search-path order, scope aggregation, BYO invariant, and fail-closed
  semantics. Skill resolution is now a compiler/program-level
  responsibility, not a harness responsibility, so other harnesses running
  the compiler get the same behavior.
- skills/declared.ts: add a header comment pointing at the program-level
  spec; this module is the harness implementation of `skills_resolver`.
- examples/declared-skills/README.md: update wording to reference the
  compiler agent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ci): make smoke artifacts case-specific

* fix(ci): use deterministic audit policy

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:50:01 -04:00

148 lines
4.3 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { resolveOpenProseRoot } from "../../src/prose/index.js";
describe("OpenProse root resolution", () => {
it("uses the cwd as a native OpenProse root by default", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-native-"));
try {
await expect(resolveOpenProseRoot({ cwd: temp })).resolves.toEqual({
mode: "native",
path: ".",
absolutePath: resolve(temp),
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("uses an enclosing native OpenProse root when cwd is inside one", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-native-nested-"));
const sourceDir = join(temp, "src", "systems");
try {
mkdirSync(sourceDir, { recursive: true });
writeFileSync(join(temp, "prose.lock"), "# No external OpenProse dependencies.\n");
await expect(resolveOpenProseRoot({ cwd: sourceDir })).resolves.toEqual({
mode: "native",
path: resolve(temp),
absolutePath: resolve(temp),
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("uses an enclosing git repository root for native repositories", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-git-native-"));
const sourceDir = join(temp, "src");
try {
mkdirSync(join(temp, ".git"), { recursive: true });
mkdirSync(sourceDir, { recursive: true });
await expect(resolveOpenProseRoot({ cwd: sourceDir })).resolves.toEqual({
mode: "native",
path: resolve(temp),
absolutePath: resolve(temp),
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("does not escape a temporary workspace to an ambient parent repository marker", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-ambient-parent-"));
const sourceDir = join(temp, "src");
try {
mkdirSync(sourceDir, { recursive: true });
await expect(resolveOpenProseRoot({ cwd: sourceDir })).resolves.toEqual({
mode: "native",
path: ".",
absolutePath: resolve(sourceDir),
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("uses an attached root when the cwd contains one", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-attached-"));
const attached = join(temp, ".agents/prose");
try {
mkdirSync(attached, { recursive: true });
await expect(resolveOpenProseRoot({ cwd: temp })).resolves.toEqual({
mode: "attached",
path: ".agents/prose",
absolutePath: attached,
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("uses an enclosing attached root from repository subdirectories", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-attached-repo-"));
const attached = join(temp, ".agents/prose");
const nested = join(temp, "app", "src");
try {
mkdirSync(attached, { recursive: true });
mkdirSync(nested, { recursive: true });
await expect(resolveOpenProseRoot({ cwd: nested })).resolves.toEqual({
mode: "attached",
path: attached,
absolutePath: attached,
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("keeps an enclosing attached root when cwd is inside it", async () => {
const temp = mkdtempSync(join(tmpdir(), "prose-root-attached-nested-"));
const attached = join(temp, ".agents/prose");
const sourceDir = join(attached, "src");
try {
mkdirSync(sourceDir, { recursive: true });
await expect(resolveOpenProseRoot({ cwd: sourceDir })).resolves.toEqual({
mode: "attached",
path: attached,
absolutePath: attached,
});
} finally {
rmSync(temp, { recursive: true, force: true });
}
});
it("recognizes the user-global OpenProse root", async () => {
const home = mkdtempSync(join(tmpdir(), "prose-root-home-"));
const userRoot = join(home, ".agents/prose");
const sourceDir = join(userRoot, "src");
try {
mkdirSync(sourceDir, { recursive: true });
await expect(resolveOpenProseRoot({ cwd: sourceDir, home })).resolves.toEqual({
mode: "user",
path: "~/.agents/prose",
absolutePath: userRoot,
});
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});