From 396dbd92764810ac6ecde355e963b74f5e065442 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 21:54:27 +0200 Subject: [PATCH 1/3] chore(quality): integrate shared offline CLI gate --- .gitignore | 1 + .oxlintrc.json | 10 +- .quality/README.md | 18 + .quality/builtin.mjs | 44 ++ .quality/config.json | 173 ++++++ .quality/hook.mjs | 109 ++++ .quality/manifest.json | 10 + .quality/native.mjs | 19 + .quality/regression.test.mjs | 68 ++ .quality/run.mjs | 583 ++++++++++++++++++ CLAUDE.md | 2 +- .../quality-loop-cli-20260914/change.md | 14 + .../source-manifest.json | 27 + .../quality-loop-cli-20260914/verification.md | 19 + package.json | 8 +- tests/quality-loop.test.ts | 16 + 16 files changed, 1116 insertions(+), 5 deletions(-) create mode 100644 .quality/README.md create mode 100644 .quality/builtin.mjs create mode 100644 .quality/config.json create mode 100644 .quality/hook.mjs create mode 100644 .quality/manifest.json create mode 100644 .quality/native.mjs create mode 100644 .quality/regression.test.mjs create mode 100644 .quality/run.mjs create mode 100644 context/changes/quality-loop-cli-20260914/change.md create mode 100644 context/changes/quality-loop-cli-20260914/source-manifest.json create mode 100644 context/changes/quality-loop-cli-20260914/verification.md create mode 100644 tests/quality-loop.test.ts diff --git a/.gitignore b/.gitignore index 902f3b5..9302e49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ tests/e2e/.env.test .vitest-cache/ .claude/ +.quality-local/ diff --git a/.oxlintrc.json b/.oxlintrc.json index fd8d7ec..416889f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -2,7 +2,13 @@ "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", "rules": { "no-unused-vars": "warn", - "no-console": "off" + "no-console": "off", + "no-debugger": "error", + "no-dupe-keys": "error" }, - "ignorePatterns": ["dist", "node_modules", "src/generated"] + "ignorePatterns": [ + "dist", + "node_modules", + "src/generated" + ] } diff --git a/.quality/README.md b/.quality/README.md new file mode 100644 index 0000000..0c5f126 --- /dev/null +++ b/.quality/README.md @@ -0,0 +1,18 @@ +# CLI quality checks + +Install the CI development toolchain: Node 22, Bun 1.3.8, npm 11.12.1, then `bun install --frozen-lockfile`. These development requirements do not change the published CLI's Node >=20 engine. Runtime checks use only this checkout and its installed tools; no toolkit checkout or global TypeScript is needed. + +| Command | Scope | +| --- | --- | +| `node .quality/run.mjs fast` | Changed JSON syntax and JS/TS Oxlint, read-only, maximum 30 seconds | +| `node .quality/run.mjs affected --base ` | Toolchain, runner regressions, types, lint, helper packaging validation and all top-level unit/integration tests | +| `node .quality/run.mjs gate` | All affected checks, Node build, standalone binary build and binary/package smoke tests | +| `node .quality/run.mjs risk` | Reports unavailable private release evidence; no production calls | + +The `quality:*` Bun scripts call the same entry points. Without `--base`, changed paths are relative to HEAD and include index/worktree/untracked files. This single-package CLI conservatively checks its complete affected scope. Gate always runs anew, including in a clean checkout. `--check ` is a partial check and never issues a full gate receipt. + +Existing Linux CI retains its native steps, matching every command in the local gate. The top-level Bun suite includes `tests/quality-loop.test.ts`, which runs the four real adapter regressions. Existing Windows Bun tests/builds/smoke remain native: quality-loop 1.0.0 process-group cancellation supports macOS/Linux. Existing helper validation and release evidence gates remain required. The runner does not send email, call models, publish packages or activate agent hooks. Private CLI/API coordinated evidence remains a separate release prerequisite. + +Nonzero exits, missing tools, empty test discovery, zero passing tests, timeouts, busy locks and files changed during a run are incomplete results. Full logs and JSON receipts are in ignored `.quality-local/`; do not commit raw logs or use a historical receipt as today's proof. Source files are never autofixed. The gate retains existing Oxlint warnings and additionally rejects debugger statements and duplicate object keys. + +The dependency-free runtime is a controlled copy of `@przeprogramowani/quality-loop` 1.0.0. `.quality/manifest.json` checks its hashes before execution; provenance is recorded in this change's source manifest. From an explicitly selected source package run `node sync.mjs /absolute/consumer` or add `--check` to compare bytes. The source package is separately prepared in Toolkit and has not yet landed on its master. The consumer is runnable without it. Updates require a reviewed source diff, matching runtime hashes and fresh gates. Revert the integration commit to remove it; no global configuration is installed. diff --git a/.quality/builtin.mjs b/.quality/builtin.mjs new file mode 100644 index 0000000..db80677 --- /dev/null +++ b/.quality/builtin.mjs @@ -0,0 +1,44 @@ +import * as fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { rootFor, git } from "./run.mjs"; +const root = rootFor(); +const mode = process.argv[2] || "syntax"; +let files = process.argv.slice(3); +if (!files.length) files = git(root, ["ls-files", "-z"]).toString().split("\0").filter(Boolean); +let checked = 0, + failed = 0; +for (const f of files) { + const full = path.resolve(process.cwd(), f); + if (!full.startsWith(root + path.sep)) throw new Error("Path outside worktree"); + if (!fs.existsSync(full) || !fs.statSync(full).isFile()) continue; + const ext = path.extname(full); + try { + if (ext === ".json") JSON.parse(fs.readFileSync(full, "utf8")); + else if ([".mjs", ".cjs", ".js"].includes(ext) && mode === "syntax") + execFileSync(process.execPath, ["--check", full], { stdio: "pipe" }); + else if (ext === ".py" && mode === "syntax") + execFileSync( + "python3", + [ + "-c", + 'import ast,sys; ast.parse(open(sys.argv[1], encoding="utf-8").read(), filename=sys.argv[1])', + full, + ], + { stdio: "pipe" }, + ); + else if (ext === ".sh" && mode === "syntax") + execFileSync("bash", ["-n", full], { stdio: "pipe" }); + else if (ext === ".md" && mode === "docs") { + if (!fs.readFileSync(full, "utf8").trim()) throw new Error("Empty Markdown document"); + } else continue; + checked++; + } catch (e) { + failed++; + console.error(`${f}: ${e.stderr?.toString() || e.message}`); + } +} +console.log( + `${checked} files structurally checked; ${failed} failed. This is syntax/document validation, not a test suite.`, +); +process.exitCode = failed ? 1 : checked ? 0 : 3; diff --git a/.quality/config.json b/.quality/config.json new file mode 100644 index 0000000..a2556e3 --- /dev/null +++ b/.quality/config.json @@ -0,0 +1,173 @@ +{ + "schemaVersion": 1, + "repo": "10x-cli", + "nodeMajors": [ + 22 + ], + "units": [], + "checks": [ + { + "id": "toolchain", + "command": [ + "node", + ".quality/native.mjs", + "toolchain" + ], + "levels": [ + "affected", + "gate" + ], + "timeoutMs": 30000 + }, + { + "id": "config-changed", + "command": [ + "node", + ".quality/builtin.mjs", + "syntax" + ], + "levels": [ + "fast" + ], + "filePattern": "\\.json$", + "appendFiles": true, + "timeoutMs": 30000 + }, + { + "id": "lint-changed", + "command": [ + "bun", + "run", + "--bun", + "node_modules/oxlint/bin/oxlint" + ], + "cwd": ".", + "levels": [ + "fast" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check.", + "filePattern": "\\.(?:[cm]?[jt]sx?)$", + "appendFiles": true + }, + { + "id": "types", + "command": [ + "node", + "node_modules/typescript/bin/tsc", + "--noEmit" + ], + "cwd": ".", + "levels": [ + "affected", + "gate" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check." + }, + { + "id": "lint", + "command": [ + "bun", + "run", + "lint" + ], + "cwd": ".", + "levels": [ + "affected", + "gate" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check." + }, + { + "id": "cli-skills", + "command": [ + "bun", + "run", + "validate:cli-skills" + ], + "levels": [ + "affected", + "gate" + ], + "timeoutMs": 120000 + }, + { + "id": "unit", + "command": [ + "node", + ".quality/native.mjs", + "unit" + ], + "cwd": ".", + "levels": [ + "affected", + "gate" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check.", + "test": true, + "countPattern": "^\\s*(\\d+) pass$" + }, + { + "id": "build", + "command": [ + "bun", + "run", + "build" + ], + "cwd": ".", + "levels": [ + "gate" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check." + }, + { + "id": "binary", + "command": [ + "bun", + "run", + "build:binary" + ], + "cwd": ".", + "levels": [ + "gate" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check." + }, + { + "id": "smoke", + "command": [ + "bun", + "test", + "tests/smoke/" + ], + "cwd": ".", + "levels": [ + "gate" + ], + "timeoutMs": 120000, + "next": "Fix the reported diagnostics and rerun this check.", + "test": true, + "countPattern": "^\\s*(\\d+) pass$" + } + ], + "coverage": [ + { + "area": "formatter", + "status": "not_applicable", + "reason": "No existing formatter; retain repository oxlint rules." + }, + { + "area": "live-auth-e2e", + "status": "unavailable", + "reason": "Private CLI/API release evidence is a separate existing workflow_dispatch gate. Offline checks never call production or send email.", + "levels": [ + "risk" + ] + } + ] +} diff --git a/.quality/hook.mjs b/.quality/hook.mjs new file mode 100644 index 0000000..81fac4c --- /dev/null +++ b/.quality/hook.mjs @@ -0,0 +1,109 @@ +import * as fs from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { rootFor, snapshot, run, summary } from "./run.mjs"; + +const ownRoot = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")); +function save(file, value) { + const tmp = file + "." + randomUUID(); + fs.writeFileSync(tmp, JSON.stringify(value)); + fs.renameSync(tmp, file); +} +function read(file) { + try { + return JSON.parse(fs.readFileSync(file)); + } catch { + return null; + } +} +function feedback(event, text) { + return { hookSpecificOutput: { hookEventName: event, additionalContext: text } }; +} +try { + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + if (input.length > 1024 * 1024) throw new Error("Hook input exceeds 1MiB"); + } + const payload = JSON.parse(input); + if (typeof payload.cwd !== "string" || typeof payload.session_id !== "string") + throw new Error("Missing cwd/session_id"); + const root = rootFor(payload.cwd); + if (root !== ownRoot) throw new Error("Hook cwd belongs to another worktree"); + const event = payload.hook_event_name; + if (!["SessionStart", "PostToolUse", "PostToolUseFailure", "Stop"].includes(event)) + throw new Error(`Unsupported hook event ${event}`); + if (process.env.QUALITY_LOOP_RUNNING === "1") { + console.log("{}"); + } else { + const dir = path.join(root, ".quality-local"); + fs.mkdirSync(dir, { recursive: true }); + const key = createHash("sha256").update(payload.session_id).digest("hex"); + const sessionFile = path.join(dir, "session-" + key + ".json"); + let session = read(sessionFile); + if (!session) { + session = { base: snapshot(root).head }; + save(sessionFile, session); + } + const state = snapshot(root, session.base); + if (event === "SessionStart") + console.log( + JSON.stringify( + feedback( + event, + "Quality loop: fast after edits; affected for dependents; node .quality/run.mjs gate before completion. Risk checks remain explicit; source is never autoformatted.", + ), + ), + ); + else if (event === "Stop") { + const receipt = read(path.join(dir, "gate.json")); + const fresh = + receipt && + receipt.level === "gate" && + receipt.status === "passed" && + Date.now() - Date.parse(receipt.startedAt) < 600000 && + snapshot(root, receipt.baseline).fingerprint === receipt.fingerprint; + if ((!state.files.length && state.head === session.base) || fresh) console.log("{}"); + else if (payload.stop_hook_active) + console.log( + JSON.stringify({ + systemMessage: + "Quality verification incomplete. Stop continuation limited; no passing gate is credited. Run node .quality/run.mjs gate and report failures/coverage gaps.", + }), + ); + else + console.log( + JSON.stringify({ + decision: "block", + reason: + "Quality verification incomplete: run node .quality/run.mjs gate before claiming completion. Report existing failures, unavailable coverage and risk checks honestly; do not weaken rules to pass.", + }), + ); + } else { + const cacheFile = path.join(dir, "fast-hook-" + key + ".json"); + const cached = read(cacheFile); + if (cached?.fingerprint === state.fingerprint && Date.now() - cached.time < 30000) + console.log("{}"); + else { + const result = await run({ root, level: "fast", base: session.base, quiet: true }); + const detail = result.checks + .filter((c) => !["passed", "not_selected"].includes(c.status)) + .map((c) => `${c.id}: ${c.detail || c.status}\nNext: ${c.next || "retry"}`) + .join("\n"); + if (["passed", "no_changes"].includes(result.status)) + save(cacheFile, { fingerprint: state.fingerprint, time: Date.now() }); + console.log( + JSON.stringify(feedback(event, (summary(result) + "\n" + detail).slice(0, 7000))), + ); + } + } + } +} catch (e) { + console.log( + JSON.stringify({ + systemMessage: `Quality hook unavailable: ${e.message}. No check credited; run node .quality/run.mjs gate manually.`, + }), + ); + process.exitCode = 1; +} diff --git a/.quality/manifest.json b/.quality/manifest.json new file mode 100644 index 0000000..996d531 --- /dev/null +++ b/.quality/manifest.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "package": "@przeprogramowani/quality-loop", + "version": "1.0.0", + "files": { + "run.mjs": "24b84b9df60a043d3683b59c9d4e75d02cbfc6cf6d26df3493702fb22173af35", + "hook.mjs": "5b29bc88b42d1b9ed0c8008a24a3c27a275101f088a034e9e64cff894229e908", + "builtin.mjs": "1469de4e8c662bc376c1ae117e18367c007b677751b9d0d4a2ba0178f8c87c86" + } +} diff --git a/.quality/native.mjs b/.quality/native.mjs new file mode 100644 index 0000000..762a99f --- /dev/null +++ b/.quality/native.mjs @@ -0,0 +1,19 @@ +import { readdirSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +if (process.argv[2] === 'toolchain') { + for (const [command, expected] of [['bun', '1.3.8'], ['npm', '11.12.1']]) { + const result = spawnSync(command, ['--version'], { encoding: 'utf8', env: process.env }); + if (result.error || result.status !== 0 || result.stdout.trim() !== expected) { + console.error(`Required ${command} ${expected}; install the CI toolchain before checking.`); + process.exit(1); + } + } + console.log('Bun 1.3.8 and npm 11.12.1 verified'); + process.exit(0); +} +if (process.argv[2] !== 'unit') throw new Error('Expected unit'); +const files = readdirSync('tests').filter(name => name.endsWith('.test.ts')).sort().map(name => `./tests/${name}`); +if (!files.length) throw new Error('No unit/integration test suites discovered in tests/'); +const child = spawnSync('bun', ['test', ...files], { stdio: 'inherit', env: process.env }); +if (child.error) throw child.error; +process.exit(child.status ?? 1); diff --git a/.quality/regression.test.mjs b/.quality/regression.test.mjs new file mode 100644 index 0000000..a705fa8 --- /dev/null +++ b/.quality/regression.test.mjs @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync, symlinkSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync, execFileSync } from 'node:child_process'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const profile = JSON.parse(readFileSync(join(root, '.quality/config.json'))); +function fixture(t, ids) { + const dir = mkdtempSync(join(tmpdir(), 'cli-quality-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + mkdirSync(join(dir, '.quality')); + for (const file of ['run.mjs', 'hook.mjs', 'builtin.mjs', 'manifest.json', 'native.mjs']) { + copyFileSync(join(root, '.quality', file), join(dir, '.quality', file)); + } + copyFileSync(join(root, '.oxlintrc.json'), join(dir, '.oxlintrc.json')); + symlinkSync(join(root, 'node_modules'), join(dir, 'node_modules'), 'dir'); + writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.quality-local/\n'); + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { noEmit: true, skipLibCheck: true, types: [] }, include: ['sample.ts'] })); + writeFileSync(join(dir, '.quality/config.json'), JSON.stringify({ ...profile, checks: profile.checks.filter(c => ids.includes(c.id)), coverage: [] })); + for (const args of [['init', '-q'], ['config', 'user.name', 'Quality fixture'], ['config', 'user.email', 'quality@example.invalid'], ['add', '.'], ['commit', '-qm', 'fixture']]) { + execFileSync('git', args, { cwd: dir, stdio: 'pipe' }); + } + return dir; +} +function run(dir, level) { + const child = spawnSync(process.execPath, ['.quality/run.mjs', level, '--json'], { cwd: dir, encoding: 'utf8', env: process.env }); + assert.ifError(child.error); + return { code: child.status, report: child.stdout.trim() ? JSON.parse(child.stdout) : null, error: child.stderr }; +} +test('fast detects an actual Oxlint violation and passes the restored source', t => { + const dir = fixture(t, ['lint-changed']); + writeFileSync(join(dir, 'sample.ts'), 'debugger;\n'); + assert.equal(run(dir, 'fast').report.checks[0].status, 'failed'); + writeFileSync(join(dir, 'sample.ts'), 'export const answer = 42;\n'); + assert.equal(run(dir, 'fast').code, 0); +}); +test('affected detects a TypeScript regression and missing local compiler', t => { + const dir = fixture(t, ['types']); + writeFileSync(join(dir, 'sample.ts'), 'export const answer: string = 42;\n'); + assert.equal(run(dir, 'affected').report.checks[0].status, 'failed'); + writeFileSync(join(dir, 'sample.ts'), 'export const answer: string = "42";\n'); + assert.equal(run(dir, 'affected').code, 0); + rmSync(join(dir, 'node_modules')); + assert.notEqual(run(dir, 'affected').code, 0); +}); +test('native Bun discovery rejects missing/empty suites and real failed assertions', t => { + const dir = fixture(t, ['unit']); + assert.notEqual(run(dir, 'gate').code, 0); + mkdirSync(join(dir, 'tests')); + assert.notEqual(run(dir, 'gate').code, 0); + const suite = join(dir, 'tests/sample.test.ts'); + writeFileSync(suite, 'import {test, expect} from "bun:test"; test("quality regression", () => expect(1).toBe(2));\n'); + assert.equal(run(dir, 'gate').report.checks[0].status, 'failed'); + writeFileSync(suite, 'import {test, expect} from "bun:test"; test("module5 passes validation", () => expect(1).toBe(1));\n'); + const result = run(dir, 'gate'); + assert.equal(result.code, 0); + assert.equal(result.report.checks[0].testCount, 1); +}); +test('managed runtime drift fails before any check can pass', t => { + const dir = fixture(t, ['unit']); + writeFileSync(join(dir, '.quality/run.mjs'), readFileSync(join(dir, '.quality/run.mjs'), 'utf8') + '\n// drift\n'); + const result = run(dir, 'gate'); + assert.notEqual(result.code, 0); + assert.match(result.error, /Standard drift/); +}); diff --git a/.quality/run.mjs b/.quality/run.mjs new file mode 100644 index 0000000..37cb1eb --- /dev/null +++ b/.quality/run.mjs @@ -0,0 +1,583 @@ +import { spawn, execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const VERSION = "1.0.0"; +const here = path.dirname(fileURLToPath(import.meta.url)); +const digest = (value) => createHash("sha256").update(value).digest("hex"); +export function git(root, args) { + return execFileSync("git", ["-C", root, ...args], { maxBuffer: 64 * 1024 * 1024 }); +} +export function rootFor(cwd = process.cwd()) { + return fs.realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]).toString().trim()); +} +function safe(root, relative) { + const resolved = path.resolve(root, relative); + if (resolved !== root && !resolved.startsWith(root + path.sep)) + throw new Error(`Path escapes worktree: ${relative}`); + if (fs.existsSync(resolved)) { + const real = fs.realpathSync(resolved); + if (real !== root && !real.startsWith(root + path.sep)) + throw new Error(`Symlink escapes worktree: ${relative}`); + } + return resolved; +} +function nul(buffer) { + return buffer.toString().split("\0").filter(Boolean); +} +export function snapshot(root, base = "HEAD") { + root = fs.realpathSync(root); + const baseline = git(root, ["rev-parse", "--verify", `${base}^{commit}`]) + .toString() + .trim(); + const head = git(root, ["rev-parse", "HEAD"]).toString().trim(); + // --no-renames includes both old and new paths and avoids porcelain quoting. + const files = [ + ...new Set([ + ...nul(git(root, ["diff", "--no-renames", "--name-only", "-z", baseline, "--"])), + ...nul(git(root, ["diff", "--no-renames", "--name-only", "-z", "--cached", baseline, "--"])), + ...nul(git(root, ["ls-files", "--others", "--exclude-standard", "-z"])), + ]), + ] + .filter((f) => !f.startsWith(".quality-local/")) + .sort(); + const hash = createHash("sha256"); + hash.update(JSON.stringify([VERSION, process.version, process.execPath, root, head, baseline])); + hash.update(git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=no"])); + // Index changes matter even if the worktree bytes happen to be identical. + hash.update(git(root, ["diff", "--cached", "--binary", "--no-ext-diff", baseline, "--"])); + const inputs = [ + ...new Set([ + ...files, + ".quality/config.json", + ".quality/manifest.json", + ".quality/run.mjs", + ".quality/hook.mjs", + ".quality/builtin.mjs", + ]), + ].sort(); + for (const f of inputs) { + const target = safe(root, f); + hash.update("\0" + f + "\0"); + try { + const stat = fs.lstatSync(target); + hash.update(String(stat.mode)); + hash.update( + stat.isSymbolicLink() + ? fs.readlinkSync(target) + : stat.isFile() + ? fs.readFileSync(target) + : "", + ); + } catch (e) { + if (e.code !== "ENOENT") throw e; + hash.update(""); + } + } + // Installed tools are ignored by Git but can invalidate a successful receipt. + const configFile = path.join(root, ".quality/config.json"); + const config = JSON.parse(fs.readFileSync(configFile)); + const sentinels = new Set([ + "node_modules", + "node_modules/.package-lock.json", + "node_modules/.modules.yaml", + "node_modules/.pnpm/lock.yaml", + ]); + const envKeys = new Set(["NODE_OPTIONS", "TZ", "LANG"]); + for (const check of config.checks || []) { + const executable = check.command?.[0]; + if (executable && executable !== "node") { + const candidates = executable.includes("/") + ? [path.resolve(root, check.cwd || ".", executable)] + : [path.dirname(process.execPath), ...(process.env.PATH || "").split(path.delimiter)].map( + (dir) => path.join(dir, executable), + ); + const found = candidates.find((candidate) => { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return true; + } catch { + return false; + } + }); + hash.update( + JSON.stringify(["executable", executable, found ? fs.realpathSync(found) : null]), + ); + if (found) { + const stat = fs.statSync(found); + hash.update(JSON.stringify([stat.ino, stat.size, stat.mtimeMs])); + } + } + const cwd = path.resolve(root, check.cwd || "."); + sentinels.add(path.relative(root, path.join(cwd, "node_modules"))); + for (const arg of check.command || []) { + if (arg.includes("/") && !arg.startsWith("-") && !path.isAbsolute(arg)) { + const candidate = path.resolve(cwd, arg); + if (candidate.startsWith(root + path.sep)) sentinels.add(path.relative(root, candidate)); + } + } + for (const key of check.requiredEnv || []) envKeys.add(key); + } + for (const key of [...envKeys].sort()) + hash.update(JSON.stringify([key, process.env[key] ?? null])); + for (const name of [...sentinels].sort()) { + hash.update("tool:" + name); + try { + const info = fs.statSync(path.join(root, name)); + hash.update(JSON.stringify([info.ino, info.size, info.mtimeMs])); + if (info.isFile() && info.size < 1024 * 1024) + hash.update(fs.readFileSync(path.join(root, name))); + } catch (e) { + if (e.code !== "ENOENT") throw e; + hash.update("missing"); + } + } + return { head, baseline, files, fingerprint: hash.digest("hex") }; +} +export function validate(config) { + if ( + config.schemaVersion !== 1 || + typeof config.repo !== "string" || + !Array.isArray(config.checks) || + !Array.isArray(config.nodeMajors) + ) + throw new Error("Invalid quality config schemaVersion/repo/checks/nodeMajors"); + const units = new Map((config.units || []).map((u) => [u.id, u])); + for (const u of units.values()) { + if ( + !Array.isArray(u.paths) || + !u.paths.length || + u.paths.some((p) => typeof p !== "string" || p.includes("..") || p.startsWith("/")) + ) + throw new Error(`Invalid unit paths: ${u.id}`); + for (const d of u.dependsOn || []) + if (!units.has(d)) throw new Error(`Unknown dependency ${d} in ${u.id}`); + } + const ids = new Set(); + for (const c of config.checks) { + if ( + !c.id || + ids.has(c.id) || + !Array.isArray(c.command) || + !c.command.length || + c.command.some((a) => typeof a !== "string") || + !Array.isArray(c.levels) || + c.levels.some((l) => !["fast", "affected", "gate", "risk"].includes(l)) + ) + throw new Error(`Invalid check: ${c.id}`); + ids.add(c.id); + if (c.test && !c.countPattern) throw new Error(`Test ${c.id} requires positive countPattern`); + if (c.countPattern) new RegExp(c.countPattern, "gm"); + if (c.filePattern) new RegExp(c.filePattern); + if ( + c.timeoutMs !== undefined && + (!Number.isFinite(c.timeoutMs) || c.timeoutMs <= 0 || c.timeoutMs > 600000) + ) + throw new Error(`Invalid timeout: ${c.id}`); + for (const u of c.units || []) + if (!units.has(u)) throw new Error(`Unknown unit ${u} in ${c.id}`); + } + return config; +} +export function selectedUnits(config, files) { + const units = config.units || []; + const selected = new Set(); + for (const f of files) { + const owners = units.filter((u) => + u.paths.some((p) => f === p || f.startsWith(p.endsWith("/") ? p : p + "/")), + ); + if (!owners.length) return units.map((u) => u.id); + owners.forEach((u) => selected.add(u.id)); + } + let before; + do { + before = selected.size; + for (const u of units) if ((u.dependsOn || []).some((d) => selected.has(d))) selected.add(u.id); + } while (before !== selected.size); + return [...selected]; +} +export function verifyManifest(root) { + const manifest = JSON.parse(fs.readFileSync(safe(root, ".quality/manifest.json"))); + if (manifest.version !== VERSION || manifest.schemaVersion !== 1) + throw new Error("Unsupported standard manifest version; sync from pinned upstream"); + for (const f of ["run.mjs", "hook.mjs", "builtin.mjs"]) { + if (manifest.files[f] !== digest(fs.readFileSync(safe(root, `.quality/${f}`)))) + throw new Error(`Standard drift: .quality/${f}; restore or update using canonical sync.mjs`); + } + return manifest; +} +function atomic(file, value) { + const temp = `${file}.${randomUUID()}.tmp`; + fs.writeFileSync(temp, JSON.stringify(value, null, 2) + "\n"); + fs.renameSync(temp, file); +} +function acquire(dir) { + const lock = path.join(dir, "lock"); + const token = randomUUID(); + try { + fs.mkdirSync(lock); + } catch (e) { + if (e.code !== "EEXIST") throw e; + // Never steal a lock: even stale reclamation can race a new owner. + // A killed process leaves an explicit busy state for manual PID verification. + return null; + } + atomic(path.join(lock, "owner.json"), { pid: process.pid, token }); + return () => { + try { + if (JSON.parse(fs.readFileSync(path.join(lock, "owner.json"))).token === token) + fs.rmSync(lock, { recursive: true }); + } catch { + /* owned cleanup only */ + } + }; +} +function excerpt(text) { + const clean = text.replace(/\x1b\[[0-9;]*m/g, ""); + const lines = clean.split("\n"); + const at = lines.findIndex((l) => + /error TS\d|error:|FAIL|AssertionError|No test|Error:|✖/.test(l), + ); + return (at >= 0 ? lines.slice(Math.max(0, at - 2), at + 14) : lines.slice(-16)) + .join("\n") + .slice(0, 3000); +} +async function execute(root, check, files, log, signal) { + const started = Date.now(); + const cwd = safe(root, check.cwd || "."); + const command = [...check.command]; + if (command[0] === "node") command[0] = process.execPath; + if (check.appendFiles) + command.push(...files.map((f) => "./" + path.relative(cwd, safe(root, f)))); + const result = { + id: check.id, + command, + cwd: path.relative(root, cwd) || ".", + files, + status: "failed", + durationMs: 0, + next: check.next || `Run node .quality/run.mjs gate --check ${check.id}`, + log: path.relative(root, log), + }; + const missing = (check.requiredEnv || []).filter((k) => !process.env[k]); + if (missing.length) + return { + ...result, + status: "unavailable", + detail: `Missing environment: ${missing.join(", ")}`, + }; + if (!fs.existsSync(cwd)) + return { ...result, status: "unavailable", detail: `Missing cwd: ${check.cwd}` }; + if (signal.aborted) return { ...result, status: "cancelled" }; + const stream = fs.openSync(log, "w"); + let output = "", + killTimer, + timer; + return await new Promise((resolve) => { + const childEnv = { + ...process.env, + ...check.env, + PATH: path.dirname(process.execPath) + path.delimiter + (process.env.PATH || ""), + CI: "true", + QUALITY_LOOP_RUNNING: "1", + FORCE_COLOR: "0", + NO_COLOR: "1", + }; + delete childEnv.NODE_TEST_CONTEXT; + const child = spawn(command[0], command.slice(1), { + cwd, + shell: false, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env: childEnv, + }); + let termination; + const kill = (reason) => { + termination = reason; + if (!child.pid) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + /* exited */ + } + killTimer = setTimeout(() => { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + /* exited */ + } + }, 300); + }; + const cancel = () => kill("cancelled"); + signal.addEventListener("abort", cancel, { once: true }); + timer = setTimeout(() => kill("timeout"), check.timeoutMs || 180000); + for (const pipe of [child.stdout, child.stderr]) + pipe.on("data", (chunk) => { + fs.writeSync(stream, chunk); + output += chunk.toString(); + if (output.length > 8 * 1024 * 1024) output = output.slice(-8 * 1024 * 1024); + }); + let spawnError; + child.on("error", (e) => { + spawnError = e; + }); + child.on("close", (code) => { + clearTimeout(timer); + signal.removeEventListener("abort", cancel); + // Keep escalation alive after parent exits; descendants may ignore TERM. + if (!termination) clearTimeout(killTimer); + fs.closeSync(stream); + result.durationMs = Date.now() - started; + result.exitCode = code; + result.status = + termination || (spawnError ? "unavailable" : code === 0 ? "passed" : "failed"); + const clean = output.replace(/\x1b\[[0-9;]*m/g, ""); + if (result.status === "passed" && check.countPattern) { + const matches = [...clean.matchAll(new RegExp(check.countPattern, "gm"))]; + const count = matches.reduce((sum, m) => sum + Number(m[1] || 0), 0); + result.testCount = count; + if (!(count > 0) || /^# pass 0$/m.test(clean)) { + result.status = "failed"; + result.detail = + "No positive test count: runner missing, empty discovery or unsupported reporter"; + } + } + if ( + result.status === "passed" && + check.rejectPattern && + new RegExp(check.rejectPattern, "m").test(clean) + ) { + result.status = "failed"; + result.detail = "Forbidden skip/incomplete marker in runner output"; + } + if (result.status !== "passed") + result.detail = (result.detail || spawnError?.message || "") + "\n" + excerpt(output); + resolve(result); + }); + }); +} +export async function run(options = {}) { + const started = Date.now(); + const root = rootFor(options.root); + const level = options.level || "fast"; + if (!["fast", "affected", "gate", "risk"].includes(level)) + throw new Error(`Unknown level: ${level}`); + const config = validate(JSON.parse(fs.readFileSync(safe(root, ".quality/config.json")))); + verifyManifest(root); + const manifests = (config.units || []) + .filter((u) => u.manifest) + .map((u) => ({ unit: u, pkg: JSON.parse(fs.readFileSync(safe(root, u.manifest))) })); + const names = new Map(manifests.map(({ unit, pkg }) => [pkg.name, unit.id])); + for (const { unit, pkg } of manifests) { + for (const dep of Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + ...pkg.peerDependencies, + ...pkg.optionalDependencies, + })) { + if ( + names.has(dep) && + names.get(dep) !== unit.id && + !(unit.dependsOn || []).includes(names.get(dep)) + ) + throw new Error( + `Workspace graph drift: ${unit.id} requires ${names.get(dep)}; update .quality/config.json dependency edges`, + ); + } + } + if (process.platform === "win32") + throw new Error( + "quality-loop v1 supports macOS/Linux; use preserved native Windows CI commands", + ); + if (!config.nodeMajors.includes(Number(process.versions.node.split(".")[0]))) + throw new Error( + `Unsupported Node ${process.version}; required majors ${config.nodeMajors.join(",")}`, + ); + const state = snapshot(root, options.base); + const units = selectedUnits(config, state.files); + const ids = options.checks || []; + for (const id of ids) + if (!config.checks.some((c) => c.id === id)) throw new Error(`Unknown check: ${id}`); + const report = { + schemaVersion: 1, + standard: VERSION, + repo: config.repo, + root, + level, + ...state, + units, + status: "planned", + checks: [], + coverage: config.coverage || [], + riskChecks: config.checks + .filter((c) => c.levels.includes("risk")) + .map((c) => ({ id: c.id, status: level === "risk" ? "selected" : "not_checked" })), + startedAt: new Date().toISOString(), + }; + const checks = config.checks.filter( + (c) => c.levels.includes(level) && (!ids.length || ids.includes(c.id)), + ); + const dir = safe(root, ".quality-local"); + fs.mkdirSync(dir, { recursive: true }); + const runId = Date.now() + "-" + randomUUID(); + report.reportPath = path.join(".quality-local", runId + ".json"); + if (options.plan) { + report.checks = checks.map((c) => ({ + id: c.id, + status: + level === "affected" && c.units && !c.units.some((u) => units.includes(u)) + ? "not_selected" + : "planned", + })); + return report; + } + const release = acquire(dir); + if (!release) + return { + ...report, + status: "busy", + exitCode: 75, + detail: + "Another check owns this worktree lock. Retry after it finishes; no verification credited.", + }; + const controller = new AbortController(); + const abort = () => controller.abort(); + process.once("SIGINT", abort); + process.once("SIGTERM", abort); + try { + for (const check of checks) { + if (level === "affected" && check.units && !check.units.some((u) => units.includes(u))) { + report.checks.push({ id: check.id, status: "not_selected" }); + continue; + } + const files = state.files.filter((f) => { + const target = safe(root, f); + return ( + fs.existsSync(target) && + fs.statSync(target).isFile() && + (!check.filePattern || new RegExp(check.filePattern).test(f)) + ); + }); + if (check.appendFiles && !files.length) { + report.checks.push({ + id: check.id, + status: "not_selected", + detail: + "No existing matching changed files; deletions/renames still select affected units.", + }); + continue; + } + if (controller.signal.aborted) { + report.checks.push({ id: check.id, status: "cancelled" }); + continue; + } + if (!options.quiet) process.stdout.write(`[quality] ${check.id} …\n`); + const bounded = + level === "fast" + ? { + ...check, + timeoutMs: Math.max( + 1, + Math.min(check.timeoutMs || 30000, 30000 - (Date.now() - started)), + ), + } + : check; + const item = await execute( + root, + bounded, + check.appendFiles ? files : [], + path.join(dir, runId + "-" + check.id.replace(/[^a-zA-Z0-9_-]/g, "_") + ".log"), + controller.signal, + ); + report.checks.push(item); + if (!options.quiet) + process.stdout.write( + `[quality] ${item.status} ${item.id} ${(item.durationMs / 1000).toFixed(2)}s${item.status === "passed" ? "" : `\n${item.detail || ""}\nNext: ${item.next}`}\n`, + ); + } + const executed = report.checks.filter( + (c) => !["not_selected", "not_applicable"].includes(c.status), + ); + const gaps = + !ids.length && + report.coverage.some( + (c) => c.status === "unavailable" && (c.levels || ["gate"]).includes(level), + ); + report.status = + executed.some((c) => c.status !== "passed") || gaps + ? "failed" + : executed.length + ? "passed" + : state.files.length + ? "not_checked" + : "no_changes"; + if (snapshot(root, options.base).fingerprint !== state.fingerprint) { + report.status = "stale"; + report.detail = "Files changed during checks; rerun before claiming completion."; + } + report.exitCode = + report.status === "passed" || (report.status === "no_changes" && level === "fast") + ? 0 + : report.status === "not_checked" + ? 3 + : 1; + report.riskChecks = report.riskChecks.map((item) => ({ + ...item, + status: report.checks.find((c) => c.id === item.id)?.status || "not_checked", + })); + report.durationMs = Date.now() - started; + atomic(path.join(root, report.reportPath), report); + atomic(path.join(dir, "latest.json"), report); + if (level === "gate" && !ids.length) atomic(path.join(dir, "gate.json"), report); + return report; + } finally { + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); + release(); + } +} +export function summary(report) { + return ( + `quality ${report.level}: ${report.status}; ${report.repo}; files=${report.files.length}; units=${report.units.join(",") || "root"}; ` + + report.checks.map((c) => `${c.id}=${c.status}`).join(", ") + + `; report=${report.reportPath}.` + + (report.coverage.some((c) => c.status === "unavailable") + ? " Coverage gaps: " + + report.coverage + .filter((c) => c.status === "unavailable") + .map((c) => `${c.area}: ${c.reason}`) + .join("; ") + : "") + + (report.riskChecks.length + ? " Risk: " + report.riskChecks.map((c) => `${c.id}=${c.status}`).join(", ") + : "") + ); +} +if ( + process.argv[1] && + fs.existsSync(process.argv[1]) && + fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const args = process.argv.slice(2); + const options = { level: args.shift() || "fast", checks: [] }; + try { + while (args.length) { + const a = args.shift(); + if (a === "--base") options.base = args.shift(); + else if (a === "--check") options.checks.push(args.shift()); + else if (a === "--json") options.quiet = true; + else if (a === "--plan") options.plan = true; + else throw new Error(`Unknown argument: ${a}`); + } + const result = await run(options); + console.log(options.quiet ? JSON.stringify(result) : summary(result)); + process.exitCode = result.exitCode ?? 3; + } catch (e) { + console.error( + `[quality] unavailable: ${e.message}\nNext: inspect .quality/config.json and install the pinned toolchain; rerun the same command.`, + ); + process.exitCode = 2; + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 5ea306d..a2493d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ bun run generate-types # refetch /openapi.json → src/generated/api-types.t `generate-types` hits the production delivery API by default. To regenerate against a local backend: `API_BASE_URL=http://localhost:8787 bun run generate-types`. The same env var is read at CLI runtime by `resolveApiBase()` to point the CLI at a non-production API. **The allowlist is strict**: only the exact production host or `http://localhost` / `http://127.0.0.1` (any port) are accepted — any other URL throws and exits 2. If you need a staging host, add it explicitly to `PROD_HOSTNAME` / `DEV_HOSTNAMES` in `src/lib/api-client.ts`. -CI (`.github/workflows/ci.yml`) runs typecheck → lint → test → build → build:binary on every PR. Anything that breaks one of those steps will block merge. +CI (`.github/workflows/ci.yml`) retains typecheck, lint, helper validation, unit/integration tests, both builds and smoke on every PR. The Linux unit suite also exercises the shared runner regressions; Windows retains native checks. Use `bun run quality:fast` after edits, `bun run quality:affected --base ` for branch changes, and `bun run quality:gate` for the complete matching offline scope; see `.quality/README.md`. ## Architecture diff --git a/context/changes/quality-loop-cli-20260914/change.md b/context/changes/quality-loop-cli-20260914/change.md new file mode 100644 index 0000000..765fc3e --- /dev/null +++ b/context/changes/quality-loop-cli-20260914/change.md @@ -0,0 +1,14 @@ +--- +id: quality-loop-cli-20260914 +title: Integrate the shared offline quality gate in CLI +status: in-progress +owner: night-quality-loop +--- + +The operator authorized integration of the existing quality-loop package through a green CLI PR, without merge, publication, formal reviews, or agent permission/configuration changes. Baseline: current origin/master a704a863, CLI 1.22.0. The September 8 pilot used CLI 1.10.0 and had never landed. + +This change installs the dependency-free 1.0.0 managed runtime, a CLI adapter and explicit quality scripts. The local gate matches the preserved Linux CI steps: local TypeScript, Oxlint, current helper packaging validation, all top-level Bun tests, both builds and smoke tests. The existing CI unit suite exercises the four new adapter regressions. Windows and release workflows retain their native behavior. Public application source, authentication, package files and engines are unchanged. The typecheck script now names the installed compiler explicitly. + +Runtime regressions use real Oxlint, TypeScript and Bun in isolated Git fixtures. The source package's existing 12 tests independently exercise timeout, cancellation, empty discovery, locks, stale receipts and controlled-copy updates. Canonical source is provided separately as a local Toolkit source diff, without triggering its paid generation pipeline. No client settings or privileged launcher are installed. + +Validation and terminal PR evidence are recorded in verification.md. Raw local logs stay in ignored .quality-local. diff --git a/context/changes/quality-loop-cli-20260914/source-manifest.json b/context/changes/quality-loop-cli-20260914/source-manifest.json new file mode 100644 index 0000000..cace9cc --- /dev/null +++ b/context/changes/quality-loop-cli-20260914/source-manifest.json @@ -0,0 +1,27 @@ +{ + "repository": "przeprogramowani/10x-toolkit", + "sourceCommit": "591aeaac2240026c17a5d495b8aa26fac4d729ef", + "sourceStatus": "", + "files": [ + { + "path": "packages/quality-loop/run.mjs", + "sha256": "24b84b9df60a043d3683b59c9d4e75d02cbfc6cf6d26df3493702fb22173af35", + "gitBlob": "37cb1eb2e5663bcbf941f0b1da58c62f7a12228a" + }, + { + "path": "packages/quality-loop/hook.mjs", + "sha256": "5b29bc88b42d1b9ed0c8008a24a3c27a275101f088a034e9e64cff894229e908", + "gitBlob": "81fac4c06f988ba312ffeaf2c503eedb7bd61607" + }, + { + "path": "packages/quality-loop/builtin.mjs", + "sha256": "1469de4e8c662bc376c1ae117e18367c007b677751b9d0d4a2ba0178f8c87c86", + "gitBlob": "db806774da87405bb6d5883b5f6fb19c60f26ae5" + } + ], + "excluded": [ + "configure.mjs", + "codex-exec.template.mjs" + ], + "delivery": "controlled copy; canonical source pending integration, not a permanent master pin" +} diff --git a/context/changes/quality-loop-cli-20260914/verification.md b/context/changes/quality-loop-cli-20260914/verification.md new file mode 100644 index 0000000..7ff5010 --- /dev/null +++ b/context/changes/quality-loop-cli-20260914/verification.md @@ -0,0 +1,19 @@ +# Verification + +Baseline: `a704a86311c318f9d649000a97521da1bf94503a`, fresh CLI origin/master, version 1.22.0. All work uses an isolated worktree. Primary CLI and both historical pilot checkouts are preserved. + +## Reproduction and lightweight checks + +- The baseline had no `.quality` runtime or scripts. Historical local pilot passes do not establish present coverage. +- First native baseline: typecheck, lint and helper validation passed; unit suite reported 791 passing / 1 failing. The failure was the release identity fixture requiring npm 11.12.1 while the local Node 22 installation had another npm. Builds/smoke were not reached in that run. +- Installed npm 11.12.1 with Node 22.14.0 in an ignored, worktree-local toolchain. No global package changes. The affected release identity/workflow/evidence tests then passed 48/48 with the existing assertions and timeouts. +- Added an explicit Bun 1.3.8 / npm 11.12.1 preflight so missing CI prerequisites are visible. Published CLI engines and dependencies remain unchanged. +- Four adapter regression tests passed: real debugger lint failure and restoration; real TypeScript assignment error and missing compiler; missing/empty Bun discovery, failing assertion and exact positive count; managed runtime drift rejected before execution. +- Fast passed on the new files; managed-copy comparison, actionlint and whitespace checks passed. +- Separate canonical source package: 12/12 fresh tests passed in its own Toolkit worktree. Imported runtime bytes are identical to its recorded source manifest. Source is not yet on Toolkit master and is not a permanent release pin. + +## Complete gates + +Full local gate and final PR checks are pending at this implementation commit. Existing GitHub credentials reject changes to workflow files (missing workflow scope), so this integration preserves the workflow and includes adapter regressions through its existing top-level Bun suite; no credential change or fallback is needed. The operator gave the EDU runtime correction priority for the shared serial gate. Raw local logs and receipts remain under ignored `.quality-local/`. Terminal results will be added after that window is released. + +No live auth/email checks, private coordinated release, model generation, npm publication, merge, or production deployment is part of this validation. Existing release checks remain separately required. diff --git a/package.json b/package.json index b7c8e08..6732ddb 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,14 @@ "build": "bun build src/index.ts --outfile dist/index.mjs --target node", "build:binary": "bun build --compile --minify src/index.ts --outfile dist/10x", "generate-types": "bun run scripts/generate-types.ts", - "typecheck": "tsc --noEmit", + "typecheck": "node node_modules/typescript/bin/tsc --noEmit", "test": "bun test", "lint": "bun run --bun node_modules/oxlint/bin/oxlint .", - "validate:cli-skills": "node scripts/validate-cli-skills.mjs" + "validate:cli-skills": "node scripts/validate-cli-skills.mjs", + "quality:fast": "node .quality/run.mjs fast", + "quality:affected": "node .quality/run.mjs affected", + "quality:gate": "node .quality/run.mjs gate", + "quality:risk": "node .quality/run.mjs risk" }, "dependencies": { "@clack/prompts": "0.9.1", diff --git a/tests/quality-loop.test.ts b/tests/quality-loop.test.ts new file mode 100644 index 0000000..3f2cbe5 --- /dev/null +++ b/tests/quality-loop.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// quality-loop v1 uses POSIX process groups. Windows retains the native CLI suite. +const qualityTest = process.platform === "win32" ? test.skip : test; +qualityTest("shared quality runner detects real lint, type, test and copy regressions", () => { + const result = spawnSync("node", ["--test", "--test-reporter=tap", ".quality/regression.test.mjs"], { + cwd: import.meta.dir + "/..", + encoding: "utf8", + timeout: 120000, + }); + expect(result.error, result.stderr).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout).toMatch(/^# pass 4$/m); + expect(result.stdout).toMatch(/^# fail 0$/m); +}, 120000); From 33f862af21dfaf8105e23645329d0dfbb0f6c023 Mon Sep 17 00:00:00 2001 From: przeprogramowani <51264853+przeprogramowani@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:08:00 +0200 Subject: [PATCH 2/3] chore(release): prepare v1.22.1 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6732ddb..5105f37 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@przeprogramowani/10x-cli", - "version": "1.22.0", + "version": "1.22.1", "description": "Open-source CLI for 10xDevs course content", "repository": { "type": "git", @@ -57,4 +57,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file From 2c1fd6e1371a78605f36b0eef351567c7df4505d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 22:11:55 +0200 Subject: [PATCH 3/3] docs(quality): record complete local gate and source provenance --- .../quality-loop-cli-20260914/change.md | 2 +- .../quality-loop-cli-20260914/local-gate.json | 64 +++++++++++++++++++ .../source-manifest.json | 3 +- .../quality-loop-cli-20260914/verification.md | 18 +++++- 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 context/changes/quality-loop-cli-20260914/local-gate.json diff --git a/context/changes/quality-loop-cli-20260914/change.md b/context/changes/quality-loop-cli-20260914/change.md index 765fc3e..6b5754e 100644 --- a/context/changes/quality-loop-cli-20260914/change.md +++ b/context/changes/quality-loop-cli-20260914/change.md @@ -1,7 +1,7 @@ --- id: quality-loop-cli-20260914 title: Integrate the shared offline quality gate in CLI -status: in-progress +status: implemented owner: night-quality-loop --- diff --git a/context/changes/quality-loop-cli-20260914/local-gate.json b/context/changes/quality-loop-cli-20260914/local-gate.json new file mode 100644 index 0000000..c59dc68 --- /dev/null +++ b/context/changes/quality-loop-cli-20260914/local-gate.json @@ -0,0 +1,64 @@ +{ + "checkedHead": "396dbd92764810ac6ecde355e963b74f5e065442", + "baseline": "a704a86311c318f9d649000a97521da1bf94503a", + "status": "passed", + "exitCode": 0, + "durationMs": 125856, + "toolchain": { + "node": "22.14.0", + "bun": "1.3.8", + "npm": "11.12.1" + }, + "checks": [ + { + "id": "toolchain", + "status": "passed", + "durationMs": 575, + "exitCode": 0 + }, + { + "id": "types", + "status": "passed", + "durationMs": 4487, + "exitCode": 0 + }, + { + "id": "lint", + "status": "passed", + "durationMs": 115, + "exitCode": 0 + }, + { + "id": "cli-skills", + "status": "passed", + "durationMs": 1296, + "exitCode": 0 + }, + { + "id": "unit", + "status": "passed", + "durationMs": 99861, + "exitCode": 0, + "testCount": 793 + }, + { + "id": "build", + "status": "passed", + "durationMs": 76, + "exitCode": 0 + }, + { + "id": "binary", + "status": "passed", + "durationMs": 167, + "exitCode": 0 + }, + { + "id": "smoke", + "status": "passed", + "durationMs": 18200, + "exitCode": 0, + "testCount": 31 + } + ] +} diff --git a/context/changes/quality-loop-cli-20260914/source-manifest.json b/context/changes/quality-loop-cli-20260914/source-manifest.json index cace9cc..896edb3 100644 --- a/context/changes/quality-loop-cli-20260914/source-manifest.json +++ b/context/changes/quality-loop-cli-20260914/source-manifest.json @@ -23,5 +23,6 @@ "configure.mjs", "codex-exec.template.mjs" ], - "delivery": "controlled copy; canonical source pending integration, not a permanent master pin" + "delivery": "controlled copy; canonical source pending integration, not a permanent master pin", + "preparedSourceCommit": "7864a61970cfa2da268c28f87e368e14927efc25" } diff --git a/context/changes/quality-loop-cli-20260914/verification.md b/context/changes/quality-loop-cli-20260914/verification.md index 7ff5010..ecf7d59 100644 --- a/context/changes/quality-loop-cli-20260914/verification.md +++ b/context/changes/quality-loop-cli-20260914/verification.md @@ -14,6 +14,20 @@ Baseline: `a704a86311c318f9d649000a97521da1bf94503a`, fresh CLI origin/master, v ## Complete gates -Full local gate and final PR checks are pending at this implementation commit. Existing GitHub credentials reject changes to workflow files (missing workflow scope), so this integration preserves the workflow and includes adapter regressions through its existing top-level Bun suite; no credential change or fallback is needed. The operator gave the EDU runtime correction priority for the shared serial gate. Raw local logs and receipts remain under ignored `.quality-local/`. Terminal results will be added after that window is released. +`node .quality/run.mjs gate` passed on clean implementation HEAD `396dbd92764810ac6ecde355e963b74f5e065442`: exit 0, 125.856 seconds. It ran under the shared serial gate after the prioritized EDU verification and pre-push finished. -No live auth/email checks, private coordinated release, model generation, npm publication, merge, or production deployment is part of this validation. Existing release checks remain separately required. +| Check | Result | +| --- | --- | +| Toolchain | Node 22.14.0, Bun 1.3.8, npm 11.12.1 | +| Types / lint / helper validation | PASS | +| Unit + integration | 793 passed, 0 failed; includes the wrapper asserting four adapter regressions | +| Node build / standalone binary | PASS | +| Binary + package smoke | 31 passed, 0 failed | + +Sanitized receipt: [local-gate.json](local-gate.json). Raw logs stay under ignored `.quality-local/`. CLI has no installed commit/pre-push hook; normal Git commit/push ran without bypass settings. The canonical Toolkit source passed its actual Husky lint/format hook and is retained as local commit `7864a61970cfa2da268c28f87e368e14927efc25`, with a separately delivered patch; no Toolkit push occurred. + +The existing repository automation subsequently prepared version 1.22.1 in commit `33f862af21dfaf8105e23645329d0dfbb0f6c023`. That is a package metadata change, not a publication. Subsequent changes in this PR record evidence only. Existing workflow files remain byte-identical to baseline, and their top-level Bun suite exercises the runner's four regressions through `tests/quality-loop.test.ts`. + +Hosted terminal checks belong to the exact current head of [PR #47](https://github.com/przeprogramowani/10x-cli/pull/47); do not substitute this earlier local receipt for current-head hosted results. The PR is made ready only after both native Linux and Windows CI finish successfully. + +Risk mode was explicitly tested: exit 1 with unavailable private release evidence and no checks invoked. No live auth/email, private coordinated release, model generation, npm publication, merge, or production deployment is part of this validation. Existing release evidence remains separately required.