Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ apply creds.
## Getting a server

```sh
npx @intentius/behold serve <chant-project-dir> --port 4600 # or: preview / demo
npx @intentius/behold serve <chant-project-dir> --port 4600 # or: preview / demo / doctor
```

`behold demo` needs no project at all — it copies the bundled example and serves
Expand All @@ -28,7 +28,14 @@ graph.
## The read loop

0. **discover** — GET `/api` lists every route with a one-line description,
plus the server's version and a link back to this guide.
plus the server's version and a link back to this guide. Before the server
exists (or when a route answers with an error you'd have to guess at), run
`npx @intentius/behold doctor <dir> --json`: a read-only diagnosis of the
project's kind, its own chant install and version, declared lexicons, the
envs the picker will infer, the bound kube context versus the ambient one,
substrate readiness and committed Ops. Each check is
`{name, status: pass|warn|fail, detail, fix}`; the process exits non-zero
iff something failed. It starts no server and changes nothing.
1. **observe** — GET `/api/graph` (JSON: `{ ir, svg, meta }`). The mixed graph
of the project, every node with `id`/`kind`/`lexicon`/`attrs`/`sourceLoc`. Drift
status, when present, is `attrs._status` (`good`=managed, `warn`=foreign,
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,19 @@ Already have a chant project?

```sh
cd my-chant-project
npx @intentius/behold doctor # will this project serve well? (read-only)
npx @intentius/behold preview # → http://localhost:4600, this project's graph
npx @intentius/behold serve . --env prod --poll 30 # live drift overlay
```

`behold doctor` is the first thing to run on a project behold hasn't seen: one
line each for the project's kind, its own chant install and version, declared
lexicons, the envs the picker will infer, the kube context chant binds versus
your ambient one, substrate readiness and committed Ops — pass/warn/fail with
a one-line fix. It starts nothing and changes nothing; it exits non-zero only
when something would actually stop behold serving the project well, so CI can
gate on it. `--json` for scripts and agents.

Driving it from an agent or script? `GET /api` lists every JSON route;
[AGENTS.md](./AGENTS.md) (shipped in the package) is the read/act contract.

Expand Down
21 changes: 21 additions & 0 deletions docs/src/content/docs/start/your-project.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ That serves your project's declared graph at **http://localhost:4600**. It shell
`preview` with no path opens the current directory, so running it from inside your project is the shortest thing that works.
</Aside>

## If it doesn't look right: `behold doctor`

```sh
npx behold doctor # the current directory
npx behold doctor ../my-project --json
```

A read-only diagnosis of everything behold needs to serve the project well —
one line each for the project's kind, its own `chant` install and version,
declared lexicons, the environments the picker will infer, the kube context
chant binds versus your ambient one, substrate readiness, and committed Ops.
Each is pass, warn or fail with a one-line fix; the exit code is non-zero only
when something failed, so CI can gate on it. It starts no server and changes
nothing.

The distinction it draws is the useful one: a **fail** is something that stops
behold serving the project (no `chant` in the project's `node_modules` is the
common first-touch one — behold shells your project's chant, so it has to be
installed). A **warn** costs you the live half, not the graph: no environment
declared, no cluster binding, an emulator that isn't up.

## Adding the live overlay

To colour the graph by what is actually deployed, name an environment:
Expand Down
73 changes: 73 additions & 0 deletions src/chant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
classifyChantFailure,
ChantCliError,
graphIr,
meetsFloor,
chantFloor,
resolveChant,
resolveLexicons,
} from "./chant.ts";
import { overlayStatus } from "./overlay.ts";

Expand Down Expand Up @@ -685,3 +689,72 @@ describe("graphIr — the stack lens (#76) reaches the actual chant graph invoca
expect(args).toEqual(["graph", join(dir, "stacks/api"), "--format", "ir"]);
});
});

// #236: the chant install behold will actually shell for a project, its
// version against behold's own floor, and the project's lexicon installs —
// what `behold doctor` reports and what the graph routes act on.
describe("meetsFloor", () => {
it("compares dotted versions numerically, not lexically (0.9.0 beats 0.38.0 only if you compare strings)", () => {
expect(meetsFloor("0.44.2", "0.38.0")).toBe(true);
expect(meetsFloor("0.9.0", "0.38.0")).toBe(false);
expect(meetsFloor("0.38.0", "0.38.0")).toBe(true);
expect(meetsFloor("1.0.0", "0.38.0")).toBe(true);
});

it("treats a prerelease of the floor as meeting it", () => {
expect(meetsFloor("0.38.0-rc.1", "0.38.0")).toBe(true);
});

it("never claims a version is stale on missing or unparseable input — unknown is not old", () => {
expect(meetsFloor(undefined, "0.38.0")).toBe(true);
expect(meetsFloor("0.44.2", undefined)).toBe(true);
expect(meetsFloor("workspace:*", "0.38.0")).toBe(true);
});

it("reads behold's own declared floor as a bare version", () => {
expect(chantFloor()).toMatch(/^\d+\.\d+\.\d+$/);
});
});

describe("resolveChant / resolveLexicons", () => {
let dirs: string[] = [];
afterEach(() => {
dirs.forEach((d) => rmSync(d, { recursive: true, force: true }));
dirs = [];
});
const make = (files: Record<string, string>): string => {
const dir = mkdtempSync(join(tmpdir(), "behold-resolve-"));
dirs.push(dir);
for (const [rel, content] of Object.entries(files)) {
mkdirSync(dirname(join(dir, rel)), { recursive: true });
writeFileSync(join(dir, rel), content);
}
return dir;
};
const pkg = (name: string, version: string): Record<string, string> => ({
[`node_modules/${name}/package.json`]: JSON.stringify({ name, version, main: "index.js", bin: { chant: "bin/chant" } }),
[`node_modules/${name}/index.js`]: "",
});

it("prefers the project's own chant, and reports its version", () => {
const dir = make(pkg("@intentius/chant", "0.44.2"));
const res = resolveChant(dir);
expect(res.source).toBe("project");
expect(res.version).toBe("0.44.2");
// endsWith, not equality: the resolver returns a realpath, and macOS's
// tmpdir is a symlink (/var/folders -> /private/var/folders).
expect(res.bin.endsWith(join("node_modules/@intentius/chant/bin/chant"))).toBe(true);
});

it("reports behold's own install as the fallback, not as the project's — a tmp dir has no chant of its own", () => {
expect(resolveChant(make({ "chant.config.ts": "export default {};" })).source).toBe("behold");
});

it("resolves each declared lexicon to its package, installed or not", () => {
const dir = make(pkg("@intentius/chant-lexicon-aws", "0.44.2"));
expect(resolveLexicons(dir, ["aws", "k8s"])).toEqual([
{ lexicon: "aws", pkg: "@intentius/chant-lexicon-aws", installed: true, version: "0.44.2" },
{ lexicon: "k8s", pkg: "@intentius/chant-lexicon-k8s", installed: false },
]);
});
});
146 changes: 132 additions & 14 deletions src/chant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
*/
import { spawn } from "node:child_process";
import { targetEnvOverrides, type SubstrateTarget } from "./targets.ts";
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import type { GraphIR, Layout, ComponentStatusRow } from "@intentius/chant";
import { detectProject } from "./project.ts";
Expand Down Expand Up @@ -124,21 +125,39 @@ export function graphFlags(opts: GraphOptions): string[] {
return flags;
}

/** Resolve `@intentius/chant`'s bin path as seen from `req`, walking up from the
* resolved entry to the package root. Returns undefined if unresolvable. */
export function chantBinFrom(req: ReturnType<typeof createRequire>): string | undefined {
/** A resolved `@intentius/chant` install: where it lives, its bin, its declared
* version (undefined when the manifest carries none). */
export interface ChantPackage {
/** The package root — the directory holding its package.json. */
root: string;
/** Absolute path to the `chant` bin. */
bin: string;
version?: string;
}

/** Resolve a package as seen from `req` and walk up from the resolved entry to
* its root, returning the manifest. Undefined when the package doesn't resolve.
*
* The walk is not decoration: chant and its lexicons ship raw TypeScript with
* an `exports` map whose `./*` subpath rewrites to `./src/*.ts`, so
* `resolve("<pkg>/package.json")` resolves to a file that doesn't exist. The
* entry point is the only subpath that reliably resolves; the manifest has to
* be found by walking to it. */
function packageManifestFrom(
req: ReturnType<typeof createRequire>,
name: string,
): { root: string; manifest: { name?: string; version?: string; bin?: { chant?: string } } } | undefined {
let entry: string;
try {
entry = req.resolve("@intentius/chant");
entry = req.resolve(name);
} catch {
return undefined;
}
let dir = dirname(entry);
for (;;) {
const manifest = join(dir, "package.json");
try {
const pkg = createRequire(import.meta.url)(manifest) as { name?: string; bin?: { chant?: string } };
if (pkg.name === "@intentius/chant") return join(dir, pkg.bin?.chant ?? "bin/chant");
const pkg = createRequire(import.meta.url)(join(dir, "package.json")) as { name?: string };
if (pkg.name === name) return { root: dir, manifest: pkg };
} catch {
// keep walking up
}
Expand All @@ -149,14 +168,113 @@ export function chantBinFrom(req: ReturnType<typeof createRequire>): string | un
return undefined;
}

function chantBin(projectDir?: string): string {
/** Resolve the `@intentius/chant` package as seen from `req`, walking up from
* the resolved entry to the package root. Returns undefined if unresolvable. */
export function chantPackageFrom(req: ReturnType<typeof createRequire>): ChantPackage | undefined {
const found = packageManifestFrom(req, "@intentius/chant");
if (!found) return undefined;
const { root, manifest } = found;
return { root, bin: join(root, manifest.bin?.chant ?? "bin/chant"), ...(manifest.version ? { version: manifest.version } : {}) };
}

/** One declared lexicon and whether the project can actually load it. `pkg` is
* the npm package a chant lexicon name maps to — `k8s` →
* `@intentius/chant-lexicon-k8s`, the convention every lexicon follows. */
export interface LexiconResolution {
lexicon: string;
pkg: string;
installed: boolean;
version?: string;
}

/** The install state of a project's declared lexicons (#236), resolved from
* the project the same way chant itself will resolve them at graph time — a
* missing one is exactly what `classifyChantFailure` later reports as
* "not-installed", said before a graph route has to. */
export function resolveLexicons(projectDir: string, lexicons: readonly string[]): LexiconResolution[] {
const req = createRequire(join(resolve(projectDir), "noop.js"));
return lexicons.map((lexicon) => {
const pkg = `@intentius/chant-lexicon-${lexicon}`;
const found = packageManifestFrom(req, pkg);
return { lexicon, pkg, installed: !!found, ...(found?.manifest.version ? { version: found.manifest.version } : {}) };
});
}

/** Resolve `@intentius/chant`'s bin path as seen from `req`. Returns undefined
* if unresolvable. */
export function chantBinFrom(req: ReturnType<typeof createRequire>): string | undefined {
return chantPackageFrom(req)?.bin;
}

/** Where the chant behold would shell for a project came from. `project` is the
* intended case (the project decides its chant version); `behold` means the
* project's own install is missing and behold fell back to its bundled chant,
* which has none of the project's lexicons — the #1 first-touch failure (#236,
* and `classifyChantFailure`'s "not-installed" arriving later, in a graph
* route, is the symptom); `path` means neither resolved and the bare `chant`
* on PATH (if any) is all that's left. */
export type ChantSource = "project" | "behold" | "path";

export interface ChantResolution extends Partial<ChantPackage> {
source: ChantSource;
bin: string;
}

/** The chant behold will actually shell for `projectDir` — the project's own
* install first, then behold's bundled one, then a bare `chant` on PATH. The
* resolution `runChantRaw` uses, exposed so `behold doctor` reports the same
* answer the server acts on rather than probing separately (#236). */
export function resolveChant(projectDir?: string): ChantResolution {
const own = chantPackageFrom(createRequire(import.meta.url));
if (projectDir) {
const fromProject = chantBinFrom(createRequire(join(resolve(projectDir), "noop.js")));
if (fromProject) return fromProject;
const fromProject = chantPackageFrom(createRequire(join(resolve(projectDir), "noop.js")));
// Landing on behold's OWN install is the fallback, however it was reached.
// Node resolution walks up parent directories, so a project nested under
// behold's checkout (every bundled example is) resolves behold's chant
// without having one of its own — reporting that as "the project's" would
// make `behold doctor` confidently miss the missing install it exists to
// catch. Comparing package roots, not requires, is what distinguishes them.
if (fromProject) return { source: fromProject.root === own?.root ? "behold" : "project", ...fromProject };
}
const own = chantBinFrom(createRequire(import.meta.url));
if (own) return own;
return "chant";
if (own) return { source: "behold", ...own };
return { source: "path", bin: "chant" };
}

/** behold's declared `@intentius/chant` floor — the caret range in its own
* package.json reduced to the literal version (`^0.38.0` → `0.38.0`), the same
* reading `scripts/typecheck-floor.sh` typechecks against. Undefined when the
* manifest can't be read. */
export function chantFloor(): string | undefined {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
return pkg.dependencies?.["@intentius/chant"]?.replace(/^[^0-9]*/, "") || undefined;
} catch {
return undefined;
}
}

/** Does `version` meet `floor`? Numeric dotted compare, prerelease suffix
* dropped (`0.44.3-rc.1` compares as `0.44.3` — an rc of the floor is close
* enough to not warn about). Unparseable input answers `true`: an unknown
* version is not evidence of an old one. Pure; exported for testing. */
export function meetsFloor(version: string | undefined, floor: string | undefined): boolean {
if (!version || !floor) return true;
const parts = (v: string): number[] => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
const a = parts(version);
const b = parts(floor);
if (a.some(Number.isNaN) || b.some(Number.isNaN)) return true;
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const d = (a[i] ?? 0) - (b[i] ?? 0);
if (d !== 0) return d > 0;
}
return true;
}

function chantBin(projectDir?: string): string {
return resolveChant(projectDir).bin;
}

export interface ChantRun {
Expand Down
Loading
Loading