Skip to content
Draft
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
10 changes: 8 additions & 2 deletions docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,12 @@ output:

Atlassian Confluence publishing settings.

> **Not implemented.** RepoScribe has no Confluence renderer and no `docgen publish` command; the CLI has
> `init`, `generate`, `validate`, `diff` and `adr`. These settings are accepted by the configuration schema so
> that existing files still load, but setting `output.confluence.enabled: true` (or running
> `generate --format confluence`) makes every command that loads plugins stop with "Confluence output is not
> available". The `docgen publish` entries below describe intended behaviour, not a current command.

---

#### `output.confluence.enabled`
Expand All @@ -863,8 +869,8 @@ Atlassian Confluence publishing settings.
| **Type** | `boolean` |
| **Required** | No |
| **Default** | `false` |
| **Description** | When `true`, the `docgen publish` command pushes documentation pages to Confluence. |
| **CLI commands**| `docgen publish` |
| **Description** | Reserved. There is no Confluence renderer and no `docgen publish` command; `true` makes every command that loads plugins stop with an error (see the note above). |
| **CLI commands**| none |
| **Related** | `output.confluence.baseUrl`, `output.confluence.spaceKey` |

```yaml
Expand Down
20 changes: 16 additions & 4 deletions docs/technical/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ src/
generate.ts # Documentation generation and watch mode
validate.ts # Coverage validation with JSON output
diff.ts # DocIR diff against a Git ref and breaking-change gate
adr.ts # Architecture Decision Records (114 lines)
adr.ts # Architecture Decision Records (124 lines)
reporters/
index.ts # CI output formatters (82 lines)
```
Expand Down Expand Up @@ -210,12 +210,24 @@ async function generateCommand(options: GenerateOptions): Promise<void>

| Flag | Type | Default | Description |
|-------------------------|----------|---------|----------------------------------------------------|
| `-f, --format <formats...>` | string[] | (all enabled) | Output format(s): `markdown`, `html`, `pdf`, `confluence` |
| `-f, --format <formats...>` | string[] | (all enabled) | Output format(s): `markdown`, `html`, `pdf`. `confluence` is accepted in the configuration but has no renderer and is rejected with a clear error |
| `-o, --output <dir>` | string | (from config) | Override output directory |
| `--json` | boolean | `false` | Output result as JSON (for CI pipelines) |
| `-v, --verbose` | boolean | `false` | Enable verbose logging |
| `-w, --watch` | boolean | `false` | Watch for changes and regenerate |

#### Watch mode

`--watch` generates once, then watches every configured language source root recursively
(`fs.watch`). Behaviour, covered by `src/commands/generate.watch.test.ts` and checked against the real
command:

- A change starts a 200 ms timer; further changes restart it, so a burst of saves causes one regeneration.
- Only one generation runs at a time. A change that arrives while one is running is queued and runs after it.
- A failed generation is logged and watching continues.
- A watcher error ends the command with that error.
- `--watch` cannot be combined with `--json`.

#### Pipeline

1. **Load config:** `loadConfig(workDir)` reads `.docgen.yaml` from the current directory.
Expand Down Expand Up @@ -421,7 +433,7 @@ Comparing documentation against HEAD~1...

### 4.5 `docgen adr <action> [title]`

**Source:** `src/commands/adr.ts` (114 lines)
**Source:** `src/commands/adr.ts` (124 lines)

**Signature:**

Expand Down Expand Up @@ -462,7 +474,7 @@ Creates a new Architecture Decision Record file.

**Validation:** If `title` is not provided, prints an error and exits with code 1.

**ID Assignment:** Reads all files in the ADR directory matching the pattern `/^ADR-\d+/`, counts them, and assigns the next sequential number. IDs are zero-padded to three digits: `ADR-001`, `ADR-002`, etc.
**ID Assignment:** Reads the names of the files in the ADR directory that match `/^ADR-(\d+)/` and assigns one more than the highest number found (`nextAdrNumber()`), so a gap or a deleted ADR never causes a number to be reused. Files that are not numbered ADRs, such as a `README.md` or `ADR-template.md`, are ignored. With none, the first ADR is `ADR-001`. IDs are zero-padded to three digits (`ADR-001`, `ADR-002`, ...) and grow past that without truncation (`ADR-1000`).

**File Naming:** The file name is constructed as:

Expand Down
4 changes: 3 additions & 1 deletion docs/technical/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -1816,6 +1816,8 @@ All configuration is defined in `.docgen.yaml` (or `.docgen.yml` or `docgen.conf

#### `output.confluence`

> **Not implemented.** The settings below are accepted so existing configuration files still load, but RepoScribe has no Confluence renderer. Setting `enabled: true` makes plugin loading fail with a clear error.

| Option | Type | Default | Description |
| ----------------- | ---------- | ---------------------- | -------------------------------------- |
| `enabled` | `boolean` | `false` | Enable Confluence output |
Expand Down Expand Up @@ -2277,7 +2279,7 @@ The `Orchestrator.loadAllPlugins()` method additionally:
- `markdown` -> `@docgen/renderer-markdown`
- `html` -> `@docgen/renderer-html`
- `pdf` -> `@docgen/renderer-pdf`
- `confluence` -> `@docgen/renderer-confluence`
- `confluence` -> not available: there is no Confluence renderer, so `loadAllPlugins()` throws "Confluence output is not available: RepoScribe has no Confluence renderer." instead of trying to load a package
- After loading external plugins, instantiates and registers the built-in `CoverageAnalyzer` and `LinkResolver` transformers
- Sorts all transformers by priority (ascending)
- Caches the `PluginRegistry` so subsequent calls return the same instance
4 changes: 2 additions & 2 deletions docs/technical/parser-java.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Other member fields:
- `visibility`: `private`, `protected` or `public` from the modifiers; anything else (package-private, or an interface method with no modifier) is `internal`.
- `isStatic` and `isAbstract` come from the modifier text; `isAsync` is always `false`.
- `signature` for methods and constructors is the text up to the body.
- `parameters`: `name`, `type`, `isRest` (true for varargs); `isOptional` is always `false`.
- `parameters`: `name`, `type`, `description`, `isRest` (true for varargs); `isOptional` is always `false`. `description` is the text of the matching `@param` tag in the Javadoc of the method or constructor, or an empty string. A varargs parameter (`String... parts`) is reported under its own name with the array type of its element (`String[]`). A `@param` tag that names no real parameter is ignored there but stays in the member's `tags`. Covered by `src/index.test.ts`.
- `deprecated`: set only from a Javadoc `@deprecated` tag. The `@Deprecated` annotation does not set it.

### Javadoc handling
Expand Down Expand Up @@ -217,7 +217,7 @@ Example, taken from the package test (`UserController.getUser`):
- `DecoratorNode.arguments` is always `{}`; only the raw annotation text is kept.
- Interface `extends` lists are not recorded as `implements` (the parser reads the grammar's `interfaces` field only).
- `isNullable` and `isUnion` on type references are always `false`; arrays are recognised only by a trailing `[]`.
- **Parameter descriptions are always empty.** The description lookup reads a Javadoc comment before the parameter list rather than before the method, so no comment is found. `@param` text is still available in the member's `tags`. No test covers this.
- *Fixed by AUDIT-001 (RS-05):* parameter descriptions used to be empty because the lookup read a comment before the parameter list instead of before the method, and a varargs parameter was reported as `arg` with type `Object`. Both are now tested.

---

Expand Down
41 changes: 29 additions & 12 deletions docs/technical/parser-typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ matched by `input.include` / `input.exclude` globs rooted at

---

### Module cache

One `TypeScriptParser` instance keeps the modules it extracted for each file, so a later `parse` call (watch
mode reuses one instance) skips files that have not changed. Covered by `src/cache.test.ts`.

- A file is served from the cache when its modification time **and** size are both unchanged. A change to
either one parses the file again.
- Files that are no longer in the list passed to `parse` are dropped from the cache.
- Callers receive copies of the cached modules, so changing a result does not change the cache.
- The check does not read file contents: an edit that keeps both the size and the modification time is not
noticed.

---

### File-Level Parsing

#### `parseSourceFile(sourceFile: SourceFile, sourceRoot: string): ModuleNode[]`
Expand Down Expand Up @@ -656,16 +670,18 @@ text.replace(/import\([^)]+\)\./g, "")

---

#### `extractParameters(params: ParameterDeclaration[]): ParamNode[]`
#### `extractParameters(params: ParameterDeclaration[], docNode: Node): ParamNode[]`

Maps an array of ts-morph `ParameterDeclaration` nodes to DocIR `ParamNode`
objects.
objects. `docNode` is the node that carries the JSDoc block: the function,
method, constructor or accessor itself, or the variable statement for an arrow
function assigned to a `const`.

```typescript
{
name: p.getName(),
type: this.buildTypeRef(p.getType()),
description: this.getParamDescription(p),
description: this.getParamDescription(p, docNode),
isOptional: p.isOptional(),
isRest: p.isRestParameter(),
defaultValue: p.getInitializer()?.getText(),
Expand All @@ -674,17 +690,18 @@ objects.

---

#### `getParamDescription(param: ParameterDeclaration): string`

Walks up to the parameter's parent function/method, extracts all `@param`
tags, and finds the one whose `value` starts with the parameter name. Then
strips the parameter name and any leading `- ` separator from the value.
#### `getParamDescription(param: ParameterDeclaration, docNode: Node): string`

```typescript
paramTag.value.replace(new RegExp(`^${param.getName()}\\s*-?\\s*`), "")
```
Looks through the JSDoc blocks on `docNode` for the `@param` tag whose name is
exactly the parameter's name (so `id` never picks up the text written for
`idx`). The text is the tag's comment: the optional `{type}`, the name and a
leading `-` are not part of it, and line breaks inside a multi-line description
become single spaces.

Returns `""` if no matching `@param` tag is found.
Returns `""` if no matching `@param` tag exists. Both `@param a - text` and
`@param a text` are accepted. Before this was corrected, `@param` text never
reached `ParamNode.description`, so documented parameters were reported as
undocumented by `docgen validate`.

---

Expand Down
196 changes: 196 additions & 0 deletions packages/cli/src/commands/adr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { adrCommand } from "./adr";

// AUDIT-001 / RS-14: the `adr` command had no tests. These pin what it does today.

describe("adrCommand", () => {
let dir: string;
let log: ReturnType<typeof vi.spyOn>;
let error: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "reposcribe-adr-"));
vi.spyOn(process, "cwd").mockReturnValue(dir);
log = vi.spyOn(console, "log").mockImplementation(() => undefined);
error = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${code})`);
}) as never);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-04T10:00:00Z"));
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
fs.rmSync(dir, { recursive: true, force: true });
});

const decisions = () => path.join(dir, "docs", "decisions");
const output = (spy: ReturnType<typeof vi.spyOn>) => spy.mock.calls.map((call) => call.join(" ")).join("\n");

describe("new", () => {
it("creates ADR-001 in docs/decisions with the title, status and today's date", async () => {
await adrCommand("new", "Use PostgreSQL", { status: "proposed" });

const files = fs.readdirSync(decisions());
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^ADR-001-use-postgresql/);
const content = fs.readFileSync(path.join(decisions(), files[0]), "utf-8");
expect(content).toContain("# ADR-001: Use PostgreSQL");
expect(content).toMatch(/## Status\s+proposed/);
expect(content).toMatch(/## Date\s+2026-03-04/);
for (const heading of ["## Context", "## Decision", "## Consequences", "## Related"]) {
expect(content).toContain(heading);
}
expect(output(log)).toContain("Created:");
});

it("uses the status it is given", async () => {
await adrCommand("new", "Adopt React", { status: "accepted" });

const [file] = fs.readdirSync(decisions());
expect(fs.readFileSync(path.join(decisions(), file), "utf-8")).toMatch(/## Status\s+accepted/);
});

it("numbers the next ADR after the existing ones and builds a lowercase hyphenated file name", async () => {
await adrCommand("new", "First", { status: "proposed" });
await adrCommand("new", "Use PostgreSQL for data", { status: "proposed" });

const files = fs.readdirSync(decisions()).sort();
expect(files[1]).toMatch(/^ADR-002-use-postgresql-for-data/);
});

it("refuses to create an ADR without a title", async () => {
await expect(adrCommand("new", undefined, { status: "proposed" })).rejects.toThrow("process.exit(1)");

expect(output(error)).toContain("Title is required");
expect(fs.existsSync(decisions())).toBe(false);
});

it("writes to adr.directory from .docgen.yaml when one is configured", async () => {
fs.writeFileSync(
path.join(dir, ".docgen.yaml"),
[
"project:",
" name: Demo",
"languages:",
" - name: typescript",
" source: src",
' parser: "@docgen/parser-typescript"',
"adr:",
" directory: records/adr",
"",
].join("\n"),
);

await adrCommand("new", "Configured location", { status: "proposed" });

expect(fs.readdirSync(path.join(dir, "records", "adr"))[0]).toMatch(/^ADR-001-configured-location/);
expect(fs.existsSync(decisions())).toBe(false);
});
});

describe("list", () => {
it("explains how to create the first ADR when none exist", async () => {
await adrCommand("list", undefined, { status: "proposed" });

expect(output(log)).toContain("No ADRs found. Use 'docgen adr new <title>' to create one.");
});

it("lists each ADR with its status and title, sorted by file name, ignoring non-markdown files", async () => {
await adrCommand("new", "Alpha", { status: "accepted" });
await adrCommand("new", "Beta", { status: "proposed" });
fs.writeFileSync(path.join(decisions(), "notes.txt"), "not an ADR");

await adrCommand("list", undefined, { status: "proposed" });

const text = output(log);
expect(text).toContain("Architecture Decision Records (2):");
expect(text.indexOf("ADR-001: Alpha")).toBeGreaterThan(-1);
expect(text.indexOf("ADR-001: Alpha")).toBeLessThan(text.indexOf("ADR-002: Beta"));
expect(text).toMatch(/accepted\s+# ADR-001: Alpha|accepted\s+ADR-001: Alpha/);
expect(text).toMatch(/proposed\s+ADR-002: Beta/);
});
});

describe("unsupported actions", () => {
it("rejects `update`, which only the old help text advertised", async () => {
await expect(adrCommand("update", undefined, { status: "proposed" })).rejects.toThrow("process.exit(1)");

expect(output(error)).toContain("Unknown ADR action: update");
expect(output(log)).toContain("Available actions: new, list");
});
});

// The audit found that numbering used the file count, so gaps produced duplicate numbers.
describe("numbering with gaps and other files", () => {
const seed = (...names: string[]) => {
fs.mkdirSync(decisions(), { recursive: true });
for (const name of names) fs.writeFileSync(path.join(decisions(), name), `# ${name}\n`);
};

it("numbers after the highest existing number, not after the file count", async () => {
seed("ADR-001-first.md", "ADR-003-third.md");

await adrCommand("new", "Fourth", { status: "proposed" });

expect(fs.readdirSync(decisions()).sort()).toEqual([
"ADR-001-first.md",
"ADR-003-third.md",
"ADR-004-fourth.md",
]);
});

it("does not reuse a number after an earlier ADR was deleted", async () => {
await adrCommand("new", "One", { status: "proposed" });
await adrCommand("new", "Two", { status: "proposed" });
fs.rmSync(path.join(decisions(), fs.readdirSync(decisions()).sort()[0]));

await adrCommand("new", "Three", { status: "proposed" });

expect(fs.readdirSync(decisions()).sort()).toEqual([
expect.stringMatching(/^ADR-002-two/),
expect.stringMatching(/^ADR-003-three/),
]);
});

it("keeps counting past three digits instead of truncating or repeating", async () => {
seed("ADR-999-nine-nine-nine.md", "ADR-1000-one-thousand.md");

await adrCommand("new", "Next", { status: "proposed" });

expect(fs.readdirSync(decisions())).toContain("ADR-1001-next.md");
});

it("ignores files that are not numbered ADRs when choosing the number", async () => {
seed("README.md", "ADR-template.md", "notes-2024.md", "ADR-002-real.md");

await adrCommand("new", "After real", { status: "proposed" });

expect(fs.readdirSync(decisions())).toContain("ADR-003-after-real.md");
expect(fs.readdirSync(decisions())).toHaveLength(5);
});

it("starts at ADR-001 when the folder holds no numbered ADRs", async () => {
seed("README.md");

await adrCommand("new", "First", { status: "proposed" });

expect(fs.readdirSync(decisions())).toContain("ADR-001-first.md");
});

it("never overwrites an existing file", async () => {
seed("ADR-001-first.md");
const before = fs.readFileSync(path.join(decisions(), "ADR-001-first.md"), "utf-8");

await adrCommand("new", "First", { status: "proposed" });

expect(fs.readFileSync(path.join(decisions(), "ADR-001-first.md"), "utf-8")).toBe(before);
expect(fs.readdirSync(decisions())).toContain("ADR-002-first.md");
});
});
});
Loading
Loading