Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b1181e4
added code complexity report
robgruen Jul 3, 2026
8939028
link to files
robgruen Jul 3, 2026
a81d492
complexity report now has a ratcheting cap on existing and new files.…
robgruen Jul 3, 2026
8210a53
added more code reporting tools
robgruen Jul 3, 2026
7f17f02
added more code quality tooling.
robgruen Jul 3, 2026
e0223f7
added code quality build checks (ratchet-down)
robgruen Jul 3, 2026
01aca35
fixed merged conflicts
robgruen Jul 3, 2026
4abd259
Potential fix for pull request finding 'CodeQL / Incomplete HTML attr…
robgruen Jul 3, 2026
589c30d
refactored
robgruen Jul 3, 2026
684b794
Merge branch 'dev/robgruen/code_complexity' of https://github.com/mic…
robgruen Jul 3, 2026
db2e89d
excluded tool folder
robgruen Jul 3, 2026
2e10471
added --fix
robgruen Jul 4, 2026
1f59cc7
enforced const/var/let usage
robgruen Jul 6, 2026
16de2f0
documented progress
robgruen Jul 6, 2026
c90fedd
Fix Windows lint failure by batching prettier-changed arguments
Copilot Jul 6, 2026
5efe5a9
fix: add shell:true on Windows for pnpm.cmd spawning (Node 24 CVE-202…
Copilot Jul 6, 2026
3aefc2c
Merge remote-tracking branch 'origin' into dev/robgruen/code_complexity
robgruen Jul 7, 2026
da3489d
removed stale todo and enabled test
robgruen Jul 7, 2026
3db0be5
updated test skipping logic test
robgruen Jul 7, 2026
ecb8b78
fixed stale test
robgruen Jul 7, 2026
ef63a0e
updated readme
robgruen Jul 7, 2026
9e53f45
Fixed bad test.
robgruen Jul 7, 2026
55c9ffd
updated test run
robgruen Jul 7, 2026
571c1c8
updated to output lines and locations of offenders. Added exception …
robgruen Jul 8, 2026
1e1888b
Merge branch 'dev/robgruen/code_complexity' of https://github.com/mic…
robgruen Jul 8, 2026
3104b08
style: apply prettier formatting and policy fixes
typeagent-bot[bot] Jul 8, 2026
c83b42c
fixed complexity
robgruen Jul 8, 2026
4acf94a
Merge branch 'dev/robgruen/code_complexity' of https://github.com/mic…
robgruen Jul 8, 2026
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
4 changes: 2 additions & 2 deletions ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
"knowledgeVisualizer": "pnpm -C packages/knowledgeVisualizer exec npm run start",
"kv": "pnpm -C packages/knowledgeVisualizer exec npm run start",
"line-histogram": "npx tsx tools/scripts/lineHistogram.ts",
"lint": "fluid-build . -t prettier",
"lint:fix": "pnpm run prettier:fix",
"lint": "fluid-build . -t prettier && pnpm run code-lint -- --ratchet",
"lint:fix": "pnpm run code-lint -- --fix --changed && pnpm run prettier:fix",
"montage": "pnpm -C packages/montage exec npm run start",
"postinstall:better-sqlite3-node-copy": "node ./tools/scripts/copy-better-sqlite3-node.js",
"postinstall:better-sqlite3-node-restore": "node ./tools/scripts/restore-better-sqlite3-node.js",
Expand Down
15 changes: 8 additions & 7 deletions ts/packages/actionGrammar/test/dynamicGrammarLoader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,10 @@ describe("Dynamic Grammar Loader", () => {
);
});

// TODO: Re-enable after grammar imports and type declarations for converters are complete
it.skip("should load rules with CalendarDate symbol", () => {
it("should load rules with CalendarDate symbol", () => {
const loader = new DynamicGrammarLoader();

const generatedRule = `@ import { CalendarDate } from "types.ts"
<Start> = <scheduleEvent>;
const generatedRule = `<Start> = <scheduleEvent>;
<scheduleEvent> = schedule $(event:string) on $(date:CalendarDate) -> {
actionName: "scheduleEvent",
parameters: {
Expand All @@ -446,9 +444,12 @@ describe("Dynamic Grammar Loader", () => {
]);
expect(matchResult.matched).toBe(true);
expect(matchResult.actionValue?.parameters?.event).toBe("meeting");
expect(matchResult.actionValue?.parameters?.date).toBeInstanceOf(
Date,
);
// CalendarDate preserves the user's original text (resolved to a
// Date lazily via CalendarDateValue.asDate()), so the slot value
// carries { text } rather than a raw Date.
expect(matchResult.actionValue?.parameters?.date).toMatchObject({
text: "tomorrow",
});
});
});
});
3 changes: 2 additions & 1 deletion ts/packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"prettier:fix": "prettier --write . --ignore-path ../../.prettierignore",
"start": "node ./dist/index.js",
"test": "npm run test:local",
"test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"",
"test:live": "pnpm run jest-esm --testPathPattern=\".*[.]test[.]js\"",
"test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\" --passWithNoTests",
"test:local:debug": "node --inspect-brk --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js --testPathPattern=\".*\\.spec\\.js\"",
"tsc": "tsc -b"
},
Expand Down
22 changes: 0 additions & 22 deletions ts/packages/api/test/api.spec.ts

This file was deleted.

78 changes: 78 additions & 0 deletions ts/packages/api/test/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { TypeAgentAPIWebServer } from "../src/webServer.js";
import type { TypeAgentAPIServerConfig } from "../src/webServer.js";
import type { AddressInfo } from "node:net";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

// Exercises the real static-file HTTP server (TypeAgentAPIWebServer) against a
// throwaway web root on an ephemeral port. It intentionally does not spin up the
// full TypeAgentServer (which brings up the LLM-backed dispatcher and needs API
// keys + data/config.json); this covers the web-serving behavior the old
// (skipped, no-op) api.spec.ts was meant to check.

describe("api web server", () => {
let wwwroot: string;
let server: TypeAgentAPIWebServer;
let baseUrl: string;

beforeAll(async () => {
// realpathSync so the canonical path matches what the server resolves
// requests against (avoids Windows short/long-name mismatches that would
// otherwise trip the server's under-web-root check).
wwwroot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "api-webserver-")),
);
fs.writeFileSync(
path.join(wwwroot, "index.html"),
"<!doctype html><title>TypeAgent</title><h1>ok</h1>",
);

const config: TypeAgentAPIServerConfig = {
wwwroot,
port: 0, // unused: we bind an ephemeral port directly below
broadcast: false,
blobBackupEnabled: false,
};

// A no-op action handler is sufficient; these checks don't post actions.
server = new TypeAgentAPIWebServer(config, () => undefined);

// Bind an ephemeral loopback port instead of calling start(), which
// hardcodes 3000 (and 3443) and would conflict / bind all interfaces.
await new Promise<void>((resolve, reject) => {
server.server.once("error", reject);
server.server.listen(0, "127.0.0.1", resolve);
});
const { port } = server.server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${port}`;
});

afterAll(async () => {
await new Promise<void>((resolve) =>
server.server.close(() => resolve()),
);
fs.rmSync(wwwroot, { recursive: true, force: true });
});

it("serves index.html at the web root", async () => {
const response = await fetch(`${baseUrl}/`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
expect(await response.text()).toContain("<h1>ok</h1>");
});

it("serves a file requested by name", async () => {
const response = await fetch(`${baseUrl}/index.html`);
expect(response.status).toBe(200);
expect(await response.text()).toContain("<h1>ok</h1>");
});

it("returns 404 for a missing file", async () => {
const response = await fetch(`${baseUrl}/does-not-exist.asdfsdf`);
expect(response.status).toBe(404);
});
});
35 changes: 31 additions & 4 deletions ts/tools/scripts/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Run any of these from `ts/`. All accept `--help`, `--root <path>`,
| `npm run code-circular` | madge | runtime import cycles | 232 cycles (dispatcher 115) | **ratchet** (no new cycles) |
| `npm run code-consistency` | custom | cross-package duplicate exports, direct `process.env`, agent-layout conformance | 16 dup exports · 263 env refs · agents 33/34 | — |
| `npm run code-deadcode` | knip | unused files / exports / deps | ~1,790 raw (needs config) | — (report-only) |
| `npm run code-debt` | regex scan | TODO/FIXME/HACK/XXX, `@deprecated`, skipped/focused tests | TODO 208 · `@deprecated` 41 · skipped 14 · focused 0 | **gate** (no focused / no new skips) |
| `npm run code-debt` | regex scan | TODO/FIXME/HACK/XXX, `@deprecated`, skipped/focused tests | TODO 207 · `@deprecated` 41 · skipped 2 · focused 0 | **gate** (no focused / no new skips) |

### Modes

Expand All @@ -38,12 +38,28 @@ Run any of these from `ts/`. All accept `--help`, `--root <path>`,
### Tool-specific flags

- **code-lint:** `--fix` (apply `no-var`/`prefer-const` autofixes in place),
`--changed` (with `--fix`, only rewrite files changed vs `--base`),
`--type-aware` (adds `no-floating-promises`, `no-misused-promises`,
`no-deprecated` — slower, report only), `--new-file-max <n>`.
- **code-complexity:** `--cyclomatic <n>`, `--cognitive <n>`,
`--new-file-cyclomatic <n>`, `--new-file-cognitive <n>`.
- **code-deadcode:** `--config` (defaults to [`knip.jsonc`](./knip.jsonc)).

### Baseline exceptions

The ratchet/gate tools accept `--exceptions-file <path>` — an optional JSON file
that grandfathers a specific set of known offenders so they don't trip the gate
(useful when a file move that git rename detection misses makes pre-existing
debt look new). Each file's `--ratchet`/`--gate` step honors it; the report
modes ignore it. Shape is `{ "exceptions": [ ... ] }` (a bare array also works):

- **code-lint** / **code-complexity** / **code-debt:** entries are
`{ "file": "packages/foo/src/bar.ts", "line": 42 }` (paths are normalized, so
a leading `ts/` is optional). Matches by `file:line`.
- **code-circular:** entries are `{ "cycle": ["packages/a/src/x.ts", "packages/b/src/y.ts"] }`
or `{ "key": "packages/a/src/x.ts > packages/b/src/y.ts" }`. Matched
rotation-invariantly against detected cycles.

### Notes for maintainers

- `npx` cannot resolve local bins in some dev environments; the tools invoke
Expand All @@ -64,8 +80,8 @@ PR-only steps in `build-ts.yml`, after the build, each fetching the base ref:
3. **Circular dependency ratchet** — the change may not introduce a new import
cycle (compares HEAD vs the merge base via a throwaway git worktree; heavier,
~80s).
4. **Test debt gate** — zero focused tests (`.only`/`fit`/`fdescribe`), no newly
skipped tests (`.skip`/`xit`/`xdescribe`) in changed files.
4. **Test debt gate** — zero focused tests (`.only`/`.only.each`/`fit`/`fdescribe`),
no newly skipped tests (`.skip`/`.skip.each`/`xit`/`xdescribe`) in changed files.

Dead-code is **not** gated yet — knip's numbers are inflated until
[`knip.jsonc`](./knip.jsonc) declares the entry points it can't infer.
Expand All @@ -80,10 +96,21 @@ Dead-code is **not** gated yet — knip's numbers are inflated until
`code-lint --fix` + 7 hand-fixed; now gated at zero). Verified: full build
passes, and every unit/live test failure was proven pre-existing (the changes
are semantics-preserving `let`/`var`→`const`).
- **Quick win #2 — skipped-test detection + triage.** Taught `code-debt` to see
`.skip.each`/`.only.each` and to ignore conditional/placeholder stubs (empty
`() => {}` bodies from the `testIf`/`describeIf` key-gates and data-driven
loops), so the count reflects genuinely disabled tests. Re-enabled the
`CalendarDate` grammar test (the converter it waited on now exists) and
replaced the dead, no-op `api/test/api.spec.ts` with a real `api.test.ts`
web-server smoke test: **14 → 2** (both remaining are intentional).

### Next — quick wins (low risk)

- **14 skipped tests** — un-skip or delete; the debt gate already blocks new ones.
- **Skipped tests — down to 2, both intentional** (no action needed): the
documented `.skip.each` suites `actionGrammar/test/nfaDfaParity.spec.ts`
(DFA-AST ruleRef-binding gap) and `actionSchema/test/regen.spec.ts`
(exact-regen, explicitly not a goal). Un-skip when those land; the debt gate
already blocks new ones.
- **WebSocket helpers** — `createWebSocket` + `keepWebSocketAlive` are
reimplemented in 4 packages; point `browser`/`coda`/`shell` at
`utils/webSocketUtils` and delete the forks.
Expand Down
81 changes: 79 additions & 2 deletions ts/tools/scripts/code/circularDepsReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* --out-dir <path> Output directory (default tools/scripts/code/circular-report).
* --ratchet CI gate: fail if changed code introduces new cycles vs --base.
* --base <ref> Base git ref for --ratchet (default origin/main).
* --exceptions-file <path> Baseline exceptions (known cycles) ignored by --ratchet.
* --help Show this help.
*/

Expand Down Expand Up @@ -148,6 +149,62 @@ function canonicalKey(cycle: string[]): string {
return [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)].join(" > ");
}

// Baseline exceptions: a JSON file of known cycles the ratchet should ignore.
// Each entry is either a "cycle" (array of module paths) or a "key" string
// ("a > b > c"); both are canonicalized the same way as detected cycles, so a
// grandfathered cycle matches regardless of rotation. Lets a pre-existing cycle
// that a refactor merely relocates pass without weakening the gate.
function normalizeCycleNode(node: string): string {
return node
.trim()
.replace(/\\/g, "/")
.replace(/^\.\//, "")
.replace(/^ts\//, "");
}

function loadCycleExceptionSet(
exceptionsFile: string | undefined,
): Set<string> {
if (!exceptionsFile) {
return new Set();
}
const filePath = path.isAbsolute(exceptionsFile)
? exceptionsFile
: path.resolve(process.cwd(), exceptionsFile);
if (!fs.existsSync(filePath)) {
throw new Error(`Exceptions file not found: ${filePath}`);
}
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Invalid JSON in exceptions file ${filePath}: ${message}`,
);
}
const entries = Array.isArray(raw)
? raw
: ((raw as { exceptions?: Array<{ cycle?: string[]; key?: string }> })
.exceptions ?? []);
const out = new Set<string>();
for (const entry of entries) {
let nodes: string[] = [];
if (Array.isArray(entry?.cycle)) {
nodes = entry.cycle.map(normalizeCycleNode).filter(Boolean);
} else if (typeof entry?.key === "string") {
nodes = entry.key
.split(">")
.map(normalizeCycleNode)
.filter(Boolean);
}
if (nodes.length > 0) {
out.add(canonicalKey(nodes));
}
}
return out;
}

// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
Expand All @@ -159,6 +216,7 @@ interface Options {
top: number;
ratchet: boolean;
base: string;
exceptionsFile?: string;
help: boolean;
}

Expand All @@ -170,6 +228,7 @@ function parseArgs(argv: string[]): Options {
top: 25,
ratchet: false,
base: "origin/main",
exceptionsFile: undefined,
help: false,
};

Expand Down Expand Up @@ -211,6 +270,14 @@ function parseArgs(argv: string[]): Options {
opts.base = next;
i++;
break;
case "--exceptions-file":
case "--exceptionsFile":
if (next === undefined) {
throw new Error(`${arg} requires a path`);
}
opts.exceptionsFile = next;
i++;
break;
case "--root":
if (next === undefined) {
throw new Error("--root requires a path");
Expand Down Expand Up @@ -251,6 +318,10 @@ Options:
--out-dir <path> Output directory (default: tools/scripts/code/circular-report).
--ratchet CI gate: fail if changed code introduces new cycles vs --base.
--base <ref> Base git ref for --ratchet (default origin/main).
--exceptions-file <path>
Optional JSON baseline-exception file. Cycles listed in it
(as a "cycle" node array or a "key" string) are ignored by
the --ratchet gate.
--help Show this help.`;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -551,6 +622,7 @@ function git(args: string[], cwd: string): string {
}

async function runRatchet(opts: Options): Promise<number> {
const exceptions = loadCycleExceptionSet(opts.exceptionsFile);
let repoRoot: string;
let mergeBase: string;
try {
Expand Down Expand Up @@ -600,20 +672,25 @@ async function runRatchet(opts: Options): Promise<number> {

const newCycles: string[][] = [];
for (const [key, cycle] of headKeys) {
if (!baseKeys.has(key)) {
if (!baseKeys.has(key) && !exceptions.has(key)) {
newCycles.push(cycle);
}
}

console.log(
`Ratchet: cycles base ${baseKeys.size} -> head ${headKeys.size}`,
);
if (exceptions.size > 0) {
console.log(
` Baseline exceptions ignored: ${exceptions.size} cycle(s).`,
);
}

if (newCycles.length > 0) {
console.error(
`\nRatchet FAILED: ${newCycles.length} new circular dependency(ies) introduced vs the base:`,
);
for (const c of newCycles.slice(0, 50)) {
for (const c of newCycles) {
console.error(` ${c.join(" > ")}`);
}
console.error(
Expand Down
2 changes: 1 addition & 1 deletion ts/tools/scripts/code/complexityReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,7 @@ async function runRatchet(opts: Options): Promise<number> {
console.log("");
console.log(title);
console.log(" CC Cog Location");
for (const f of rows.slice(0, opts.top)) {
for (const f of rows) {
console.log(
` ${String(f.cyclomatic).padStart(3)} ${String(
f.cognitive,
Expand Down
Loading
Loading