@raresportan/marram 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/bin/marram.mjs +19 -0
- package/package.json +51 -4
- package/src/core/boundaries.mjs +76 -0
- package/src/core/check-lint.mjs +107 -0
- package/src/core/commit-lint.mjs +53 -0
- package/src/core/config.mjs +23 -0
- package/src/core/gate-state.mjs +86 -0
- package/src/core/gate.mjs +90 -0
- package/src/core/hook.mjs +114 -0
- package/src/core/no-comments.mjs +30 -0
- package/src/core/ratchet.mjs +72 -0
- package/src/core/until.ts +59 -0
- package/src/electron/feature-map.ts +93 -0
- package/src/electron/frames.ts +91 -0
- package/src/electron/geometry.ts +101 -0
- package/src/electron/journey-lint.mjs +45 -0
- package/src/electron/run.ts +83 -0
- package/src/electron/session.ts +187 -0
- package/src/node/background.ts +17 -0
- package/src/node/promise-lint.mjs +117 -0
- package/src/node/resource-lint.mjs +84 -0
- package/src/node/scope.ts +63 -0
- package/src/react/effect-lint.mjs +60 -0
- package/src/react/keyed.ts +19 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rares Portan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @raresportan/marram
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The gate, ratchet lints and journeys that keep agent-written code from drifting.
|
|
4
|
+
|
|
5
|
+
See [Marram](https://github.com/raresportan/marram) for the guide.
|
package/bin/marram.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Usage:
|
|
3
|
+
// marram gate [fast | full] run the project's gate (src/gate.mjs)
|
|
4
|
+
// marram hook <mode> the gate where work is declared done (src/hook.mjs)
|
|
5
|
+
// marram commit-lint <message file> a review fix names each finding's class and rung (src/commit-lint.mjs)
|
|
6
|
+
// marram commit-lint --range <from>..<to>
|
|
7
|
+
import { lintCommits } from "../src/core/commit-lint.mjs";
|
|
8
|
+
import { load } from "../src/core/config.mjs";
|
|
9
|
+
import { gate } from "../src/core/gate.mjs";
|
|
10
|
+
import { hook } from "../src/core/hook.mjs";
|
|
11
|
+
|
|
12
|
+
const [command, ...args] = process.argv.slice(2);
|
|
13
|
+
const commands = { gate, hook, "commit-lint": lintCommits };
|
|
14
|
+
if (!(command in commands)) {
|
|
15
|
+
console.error("Usage: marram gate [fast | full] | marram hook <mode> | marram commit-lint <message file> | --range <from>..<to>");
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
const { root, config } = await load();
|
|
19
|
+
await commands[command](root, config, args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raresportan/marram",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The gate, ratchet lints and journeys that keep agent-written code from drifting.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/raresportan/marram.git",
|
|
10
|
+
"directory": "packages/marram"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src", "bin"],
|
|
13
|
+
"bin": {
|
|
14
|
+
"marram": "bin/marram.mjs"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./core/boundaries": "./src/core/boundaries.mjs",
|
|
18
|
+
"./core/check-lint": "./src/core/check-lint.mjs",
|
|
19
|
+
"./core/no-comments": "./src/core/no-comments.mjs",
|
|
20
|
+
"./core/ratchet": "./src/core/ratchet.mjs",
|
|
21
|
+
"./core/until": "./src/core/until.ts",
|
|
22
|
+
"./node/background": "./src/node/background.ts",
|
|
23
|
+
"./node/promise-lint": "./src/node/promise-lint.mjs",
|
|
24
|
+
"./node/resource-lint": "./src/node/resource-lint.mjs",
|
|
25
|
+
"./node/scope": "./src/node/scope.ts",
|
|
26
|
+
"./react/effect-lint": "./src/react/effect-lint.mjs",
|
|
27
|
+
"./react/keyed": "./src/react/keyed.ts",
|
|
28
|
+
"./electron/feature-map": "./src/electron/feature-map.ts",
|
|
29
|
+
"./electron/frames": "./src/electron/frames.ts",
|
|
30
|
+
"./electron/geometry": "./src/electron/geometry.ts",
|
|
31
|
+
"./electron/journey-lint": "./src/electron/journey-lint.mjs",
|
|
32
|
+
"./electron/run": "./src/electron/run.ts",
|
|
33
|
+
"./electron/session": "./src/electron/session.ts"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.19.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"playwright-core": "*",
|
|
43
|
+
"react": "^19.3.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"playwright-core": { "optional": true },
|
|
48
|
+
"react": { "optional": true }
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Where code runs decides what it may import. Main-process code imported into a renderer slows the UI, and a feature
|
|
2
|
+
// reaching into another's folder ties them together for good, so the import graph is a rule, not a convention.
|
|
3
|
+
//
|
|
4
|
+
// A project names its layers by folder pattern (`features/*/client`, `*` being one folder). A file may import from its
|
|
5
|
+
// own layer and from the layers it lists. It may reach another feature (`features/<name>`) only through an `index.ts`:
|
|
6
|
+
// the feature's own, or that of one of its sides, as an app's main process needs its host and its renderer its client,
|
|
7
|
+
// and one index for both would pull the host into the renderer. Only a layer with `builtins: true` may import `node:` modules or `electron`. Existing violations go on a
|
|
8
|
+
// ratchet list, by `file -> import`.
|
|
9
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
10
|
+
import ts from "typescript";
|
|
11
|
+
import { lineOf, parse, ratchet, sourceFiles } from "./ratchet.mjs";
|
|
12
|
+
|
|
13
|
+
const posix = (path) => path.split(sep).join("/");
|
|
14
|
+
|
|
15
|
+
/** A pattern as a test of a path's start, `*` standing for one folder, and what it matched, for telling features apart. */
|
|
16
|
+
const matcher = (pattern) => new RegExp(`^(${pattern.split("*").join("[^/]+")})(/|$)`);
|
|
17
|
+
|
|
18
|
+
/** The layer pattern a path (from the folder) is in, with the folder it matched: the longest match wins. */
|
|
19
|
+
function layerOf(path, layers) {
|
|
20
|
+
let found;
|
|
21
|
+
for (const pattern of Object.keys(layers)) {
|
|
22
|
+
const match = matcher(pattern).exec(path);
|
|
23
|
+
if (match !== null && (found === undefined || match[1].length > found.folder.length)) found = { pattern, folder: match[1] };
|
|
24
|
+
}
|
|
25
|
+
return found;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The feature folder (`features/<name>`) of a path, if it's in one. */
|
|
29
|
+
const featureOf = (path) => /^(features\/[^/]+)(\/|$)/.exec(path)?.[1];
|
|
30
|
+
|
|
31
|
+
/** A feature's way in: its folder's `index.ts`, or the `index.ts` of one of its sides (`host/index.ts`, `client/index.ts`). */
|
|
32
|
+
const isEntry = (target, feature) => target === feature || /^features\/[^/]+(\/[^/]+)?\/index(\.tsx?)?$/.test(target);
|
|
33
|
+
|
|
34
|
+
const isBuiltin = (specifier) => specifier === "electron" || specifier.startsWith("node:");
|
|
35
|
+
|
|
36
|
+
/** What's wrong with `from` (a path from the folder) importing `specifier`, if anything. */
|
|
37
|
+
function problem(from, specifier, layers) {
|
|
38
|
+
const mine = layerOf(from, layers);
|
|
39
|
+
if (isBuiltin(specifier)) return layers[mine?.pattern]?.builtins === true || mine === undefined ? undefined : `${mine.pattern} may not import ${specifier}, which needs a host`;
|
|
40
|
+
if (!specifier.startsWith(".")) return undefined;
|
|
41
|
+
const target = posix(relative(".", resolve(dirname(from), specifier)));
|
|
42
|
+
const theirFeature = featureOf(target);
|
|
43
|
+
if (theirFeature !== undefined && theirFeature !== featureOf(from)) return isEntry(target, theirFeature) ? undefined : `reaches inside ${theirFeature}: import an index.ts`;
|
|
44
|
+
const theirs = layerOf(target, layers);
|
|
45
|
+
if (mine === undefined || theirs === undefined || theirs.pattern === mine.pattern) return undefined;
|
|
46
|
+
return (layers[mine.pattern].may ?? []).includes(theirs.pattern) ? undefined : `${mine.pattern} may not import from ${theirs.pattern}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Import specifiers in `source`, including `export … from` and `import()`, each with its node; types count too. */
|
|
50
|
+
function imports(source) {
|
|
51
|
+
const found = [];
|
|
52
|
+
const visit = (node) => {
|
|
53
|
+
const named = (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) ? node.moduleSpecifier : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword ? node.arguments[0] : undefined;
|
|
54
|
+
if (named !== undefined && ts.isStringLiteralLike(named)) found.push(named);
|
|
55
|
+
ts.forEachChild(node, visit);
|
|
56
|
+
};
|
|
57
|
+
visit(source);
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Problems with the imports under `root`/`folder`, given its `layers` (`{ pattern: { may: [patterns], builtins? } }`),
|
|
63
|
+
* against the ratchet list `allowed` (`{ "file -> import": reason }`, kept in `list`).
|
|
64
|
+
*/
|
|
65
|
+
export function importBoundaries({ root, folder, layers, allowed = {}, list }) {
|
|
66
|
+
const base = resolve(root, folder);
|
|
67
|
+
const found = sourceFiles(base).flatMap((file) => {
|
|
68
|
+
const source = parse(file);
|
|
69
|
+
const from = posix(relative(base, file));
|
|
70
|
+
return imports(source).flatMap((specifier) => {
|
|
71
|
+
const wrong = problem(from, specifier.text, layers);
|
|
72
|
+
return wrong === undefined ? [] : [{ key: `${from} -> ${specifier.text}`, at: `${folder}/${from}:${lineOf(source, specifier)}`, what: wrong }];
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
return ratchet({ list, allowed, found, fix: "move the code to a layer that may have it", stale: "is no longer imported that way" });
|
|
76
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// A check runner judges a check by its exit code alone, so a check that prints what it saw without asserting it passes
|
|
2
|
+
// whatever the code does, even while a feature map cites it. So a check file must be able to fail on what it sees: a
|
|
3
|
+
// `node:assert` call, a `throw` (in it or in a helper it defines), or a non-zero exit (`process.exit(1)`,
|
|
4
|
+
// `process.exitCode = 1`). A crash alone doesn't count.
|
|
5
|
+
//
|
|
6
|
+
// And waits go through `until` (./until.ts). A loop that sleeps until something holds is a wait of the check's own:
|
|
7
|
+
// each one has its own bound and a failure that says neither how long it waited nor what it saw, and some run on when
|
|
8
|
+
// time is up as though what they waited for had happened. An upper bound on elapsed time (`Date.now() - started < 900`)
|
|
9
|
+
// is a guess at the machine's speed that fails under load: `sooner` measures it against how long the broken behaviour
|
|
10
|
+
// takes instead.
|
|
11
|
+
import { readdirSync } from "node:fs";
|
|
12
|
+
import { join, relative } from "node:path";
|
|
13
|
+
import ts from "typescript";
|
|
14
|
+
import { lineOf, parse } from "./ratchet.mjs";
|
|
15
|
+
|
|
16
|
+
export const WAITS = "@raresportan/marram/core/until";
|
|
17
|
+
|
|
18
|
+
/** The names an import clause binds: its default, its namespace, or each named import. */
|
|
19
|
+
function bound(clause) {
|
|
20
|
+
const bindings = clause?.namedBindings;
|
|
21
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings)) return [clause.name?.text, bindings.name.text];
|
|
22
|
+
return [clause?.name?.text, ...(bindings !== undefined && ts.isNamedImports(bindings) ? bindings.elements.map((element) => element.name.text) : [])];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The names `source` binds to `node:assert`, or to `waits`, whose waits fail when time is up. */
|
|
26
|
+
function assertNames(source, waits) {
|
|
27
|
+
const failing = (from) => /^(node:)?assert(\/strict)?$/.test(from) || from === waits;
|
|
28
|
+
return new Set(source.statements.filter((statement) => ts.isImportDeclaration(statement) && failing(statement.moduleSpecifier.text)).flatMap((statement) => bound(statement.importClause)).filter(Boolean));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isProcess = (node, property) => ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "process" && node.name.text === property;
|
|
32
|
+
const nonZero = (node) => node !== undefined && !(ts.isNumericLiteral(node) && node.text === "0");
|
|
33
|
+
|
|
34
|
+
/** How `node` can fail the check, or undefined. */
|
|
35
|
+
function failure(node, asserts) {
|
|
36
|
+
if (ts.isThrowStatement(node)) return "throw";
|
|
37
|
+
if (ts.isCallExpression(node)) {
|
|
38
|
+
const callee = ts.isPropertyAccessExpression(node.expression) ? node.expression.expression : node.expression;
|
|
39
|
+
if (ts.isIdentifier(callee) && asserts.has(callee.text)) return "assert";
|
|
40
|
+
if (isProcess(node.expression, "exit") && nonZero(node.arguments[0])) return "exit";
|
|
41
|
+
}
|
|
42
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && isProcess(node.left, "exitCode") && nonZero(node.right)) return "exit";
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function canFail(source, waits) {
|
|
47
|
+
const asserts = assertNames(source, waits);
|
|
48
|
+
let found = false;
|
|
49
|
+
const visit = (node) => {
|
|
50
|
+
if (found) return;
|
|
51
|
+
if (failure(node, asserts) !== undefined) found = true;
|
|
52
|
+
else ts.forEachChild(node, visit);
|
|
53
|
+
};
|
|
54
|
+
visit(source);
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isNow = (node) => ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "now" && ts.isIdentifier(node.expression.expression) && ["Date", "performance"].includes(node.expression.expression.text);
|
|
59
|
+
const contains = (node, test) => test(node) || (ts.forEachChild(node, (child) => (contains(child, test) ? true : undefined)) ?? false);
|
|
60
|
+
/** A time measured from a clock: `Date.now() - started`, or a name holding one. */
|
|
61
|
+
const isElapsed = (node, elapsed) => contains(node, (part) =>
|
|
62
|
+
(ts.isBinaryExpression(part) && part.operatorToken.kind === ts.SyntaxKind.MinusToken && contains(part.left, isNow)) || (ts.isIdentifier(part) && elapsed.has(part.text)));
|
|
63
|
+
/** Waiting on a timer: `setTimeout`, or a helper named for sleeping. */
|
|
64
|
+
const isTimer = (node) => ts.isCallExpression(node) && ts.isIdentifier(node.expression) && /^(setTimeout|sleep|wait|delay)$/.test(node.expression.text);
|
|
65
|
+
/**
|
|
66
|
+
* A loop that can go round until something holds. A loop over a list (`for…of`) runs its cases, sleeping between steps
|
|
67
|
+
* to stage a race, and isn't one; nor is a loop with a `// Not a wait:` comment saying what it is (a retry).
|
|
68
|
+
*/
|
|
69
|
+
const isOpenLoop = (node, source) =>
|
|
70
|
+
(ts.isForStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) &&
|
|
71
|
+
!(ts.getLeadingCommentRanges(source.text, node.getFullStart()) ?? []).some((range) => source.text.slice(range.pos, range.end).includes("Not a wait:"));
|
|
72
|
+
|
|
73
|
+
/** The waits and time bounds `source` has of its own, with their lines. */
|
|
74
|
+
function ownWaits(source, waits) {
|
|
75
|
+
const elapsed = new Set();
|
|
76
|
+
const found = [];
|
|
77
|
+
const visit = (node) => {
|
|
78
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined && isElapsed(node.initializer, elapsed)) elapsed.add(node.name.text);
|
|
79
|
+
if (isOpenLoop(node, source) && contains(node, (part) => ts.isAwaitExpression(part) && contains(part, isTimer))) {
|
|
80
|
+
found.push(`line ${lineOf(source, node)}: a loop that sleeps until something holds: wait with until() from ${waits}, which says what it saw`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (ts.isBinaryExpression(node)) {
|
|
84
|
+
const kind = node.operatorToken.kind;
|
|
85
|
+
const below = [ts.SyntaxKind.LessThanToken, ts.SyntaxKind.LessThanEqualsToken].includes(kind) ? node.left : [ts.SyntaxKind.GreaterThanToken, ts.SyntaxKind.GreaterThanEqualsToken].includes(kind) ? node.right : undefined;
|
|
86
|
+
if (below !== undefined && isElapsed(below, elapsed)) found.push(`line ${lineOf(source, node)}: an upper bound on elapsed time fails under load: use sooner() from ${waits}, measured against how long the broken behaviour takes`);
|
|
87
|
+
}
|
|
88
|
+
ts.forEachChild(node, visit);
|
|
89
|
+
};
|
|
90
|
+
visit(source);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What's wrong with one check's `source`: it can't fail, or it waits or bounds time on its own. `waits` is where `until` comes from. */
|
|
95
|
+
export function checkProblems(source, waits = WAITS) {
|
|
96
|
+
return [
|
|
97
|
+
...(canFail(source, waits) ? [] : ["asserts nothing, so it passes whatever the code does: assert what it sees (node:assert), throw, or exit non-zero"]),
|
|
98
|
+
...ownWaits(source, waits),
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Problems in each file of `root`/`folder` whose name `match`es, as `path, problem`; and how many files there were. */
|
|
103
|
+
export function lintChecks({ root, folder, match, waits = WAITS }) {
|
|
104
|
+
const base = join(root, folder);
|
|
105
|
+
const files = readdirSync(base).filter((name) => match.test(name)).map((name) => join(base, name));
|
|
106
|
+
return { files: files.length, problems: files.flatMap((file) => checkProblems(parse(file), waits).map((problem) => `${relative(root, file)}: ${problem}`)) };
|
|
107
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A commit that fixes review findings names, for each one, its mistake class and the rung that now catches it (the
|
|
2
|
+
// project's mistake-classes doc, `review.classes` in marram.config.mjs). Without that, review findings only ever produce
|
|
3
|
+
// point fixes: nobody sees that a class keeps recurring, or that no rung could have caught it.
|
|
4
|
+
//
|
|
5
|
+
// Applies to a commit whose subject fixes review findings or gaps. Each top-level bullet (`- `) of its body is one
|
|
6
|
+
// finding and needs a line `Class: <a class, joined by / if between two, or new>. Rung: <a rung | none, because …> …`.
|
|
7
|
+
// A body with no bullets needs one such line. The classes are the doc's table rows (`| **A**: …`); the rungs are
|
|
8
|
+
// `review.rungs`.
|
|
9
|
+
// Usage: marram commit-lint <message file> (the commit-msg hook)
|
|
10
|
+
// marram commit-lint --range <from>..<to> (each commit in a range)
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const REVIEW_FIX = /\breview (findings?|gaps?)\b/i;
|
|
16
|
+
const RUNGS = ["type", "lint", "architecture", "check", "journey", "unit"];
|
|
17
|
+
|
|
18
|
+
/** The class names in a mistake-classes doc: each table row that starts with a bold name. */
|
|
19
|
+
export function classesIn(doc) {
|
|
20
|
+
return [...doc.matchAll(/^\| \*\*([A-Za-z0-9-]+)\*\*/gm)].map(([, name]) => name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What's wrong with a commit message, if anything, given the project's classes and rungs. */
|
|
24
|
+
export function problems(message, { classes, rungs = RUNGS }) {
|
|
25
|
+
const lines = message.split("\n").filter((line) => !line.startsWith("#"));
|
|
26
|
+
const subject = lines[0] ?? "";
|
|
27
|
+
if (!REVIEW_FIX.test(subject) || subject.startsWith("Merge ")) return [];
|
|
28
|
+
const one = `(?:${classes.join("|")})`;
|
|
29
|
+
const named = new RegExp(`\\bClass: (?:${one}(?:\\/${one})*|new)\\b[^\\n]*?\\bRung: (?:(?:${rungs.join("|")})\\b|none, because \\S)`);
|
|
30
|
+
const body = lines.slice(1);
|
|
31
|
+
const findings = [];
|
|
32
|
+
for (const line of body) {
|
|
33
|
+
if (line.startsWith("- ")) findings.push([line]);
|
|
34
|
+
else findings.at(-1)?.push(line);
|
|
35
|
+
}
|
|
36
|
+
const example = `\`Class: <${classes[0]}-${classes.at(-1)}>. Rung: <kind> …\``;
|
|
37
|
+
if (findings.length === 0) return named.test(body.join("\n")) ? [] : [`it fixes review findings but names no class and rung: add ${example}`];
|
|
38
|
+
return findings.flatMap((finding) => (named.test(finding.join("\n")) ? [] : [`"${finding[0].slice(2, 72)}" names no class and rung: add ${example}`]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function lintCommits(root, config, [mode, target]) {
|
|
42
|
+
const doc = config.review.classes;
|
|
43
|
+
const rules = { classes: classesIn(readFileSync(join(root, doc), "utf8")), rungs: config.review.rungs };
|
|
44
|
+
if (rules.classes.length === 0) throw new Error(`${doc} lists no classes: its table rows start with | **<name>**`);
|
|
45
|
+
const messages = mode === "--range"
|
|
46
|
+
? execFileSync("git", ["log", "--format=%H%x00%B%x01", target], { cwd: root, encoding: "utf8" }).split("\x01").filter((entry) => entry.trim() !== "").map((entry) => entry.trim().split("\x00"))
|
|
47
|
+
: mode === undefined ? [] : [["this commit", readFileSync(mode, "utf8")]];
|
|
48
|
+
const found = messages.flatMap(([commit, message]) => problems(message, rules).map((problem) => `${commit.slice(0, 12)}: ${problem}`));
|
|
49
|
+
if (found.length > 0) {
|
|
50
|
+
console.error(`commit-lint: a fix for review findings names each one's class and rung (${doc})\n${found.map((problem) => ` ${problem}`).join("\n")}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A project tells Marram what its gate runs and what its review fixes are held to in `marram.config.mjs`, at its root:
|
|
2
|
+
//
|
|
3
|
+
// export default {
|
|
4
|
+
// gate: {
|
|
5
|
+
// logs: "where each run's logs go",
|
|
6
|
+
// progress: /lines from a stage worth showing as it runs/, // besides ✓ and ✗ lines
|
|
7
|
+
// tiers: { fast: [stage, …], default: [stage, …], full: [stage, …] }, // stage: { name, run: [command, …args], env? }
|
|
8
|
+
// },
|
|
9
|
+
// review: { classes: "the mistake-classes doc", rungs: ["type", "lint", …] },
|
|
10
|
+
// };
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
|
|
15
|
+
const FILE = "marram.config.mjs";
|
|
16
|
+
|
|
17
|
+
/** The project root, the nearest folder from `from` up that has a marram.config.mjs, and its config. */
|
|
18
|
+
export async function load(from = process.cwd()) {
|
|
19
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
20
|
+
if (existsSync(join(dir, FILE))) return { root: dir, config: (await import(pathToFileURL(join(dir, FILE)).href)).default };
|
|
21
|
+
if (dirname(dir) === dir) throw new Error(`No ${FILE} in ${resolve(from)} or above it.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// What the gate said about each state of the working tree, so a hook doesn't run it again for code it has judged.
|
|
2
|
+
// Kept in the repository's git folder (shared by its worktrees): marram-gate.json.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
// A failure is reused this long, then the gate runs again: a flaky check gets another chance without a code change.
|
|
9
|
+
const FAILURE_TTL_MS = 15 * 60 * 1000;
|
|
10
|
+
const KEEP = 50;
|
|
11
|
+
// What the lock guards takes milliseconds, so a lock this old was left by a hook that was killed.
|
|
12
|
+
const STALE_LOCK_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
export const git = (cwd, args, env = {}) => execFileSync("git", args, { cwd, encoding: "utf8", env: { ...process.env, ...env } }).trim();
|
|
15
|
+
|
|
16
|
+
/** The working tree as a git tree hash: tracked and untracked files, not ignored ones. Same hash, same code. */
|
|
17
|
+
export function treeOf(cwd) {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "marram-gate-"));
|
|
19
|
+
const index = join(dir, "index");
|
|
20
|
+
try {
|
|
21
|
+
// A copy of the real index, so unchanged files aren't hashed again.
|
|
22
|
+
const real = resolve(cwd, git(cwd, ["rev-parse", "--git-path", "index"]));
|
|
23
|
+
if (existsSync(real)) copyFileSync(real, index);
|
|
24
|
+
git(cwd, ["add", "-A"], { GIT_INDEX_FILE: index });
|
|
25
|
+
return git(cwd, ["write-tree"], { GIT_INDEX_FILE: index });
|
|
26
|
+
} finally {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const statePath = (cwd) => join(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"])), "marram-gate.json");
|
|
32
|
+
|
|
33
|
+
export function readState(cwd) {
|
|
34
|
+
try {
|
|
35
|
+
const state = JSON.parse(readFileSync(statePath(cwd), "utf8"));
|
|
36
|
+
return { trees: state.trees ?? {}, sessions: state.sessions ?? {} };
|
|
37
|
+
} catch {
|
|
38
|
+
return { trees: {}, sessions: {} };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run `action` holding `path`'s lock: a directory, since creating one either succeeds or finds it there. */
|
|
43
|
+
function withLock(path, action) {
|
|
44
|
+
const lock = `${path}.lock`;
|
|
45
|
+
for (;;) {
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(lock);
|
|
48
|
+
break;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error.code !== "EEXIST") throw error;
|
|
51
|
+
const age = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
|
|
52
|
+
if (age > STALE_LOCK_MS) rmSync(lock, { recursive: true, force: true });
|
|
53
|
+
else Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return action();
|
|
58
|
+
} finally {
|
|
59
|
+
rmSync(lock, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read, change and write the state under its lock, so hooks running at once don't lose each other's changes; returns
|
|
65
|
+
* what `change` returns. Written to a temporary file and renamed over the old one, so a reader never sees half a file.
|
|
66
|
+
* Only the newest entries are kept.
|
|
67
|
+
*/
|
|
68
|
+
export function updateState(cwd, change) {
|
|
69
|
+
const path = statePath(cwd);
|
|
70
|
+
return withLock(path, () => {
|
|
71
|
+
const state = readState(cwd);
|
|
72
|
+
const result = change(state);
|
|
73
|
+
const newest = (rows) => Object.fromEntries(Object.entries(rows).sort(([, a], [, b]) => b.at - a.at).slice(0, KEEP));
|
|
74
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
75
|
+
writeFileSync(temporary, JSON.stringify({ trees: newest(state.trees), sessions: newest(state.sessions) }, null, 2));
|
|
76
|
+
renameSync(temporary, path);
|
|
77
|
+
return result;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `{ passed, stage?, log?, at }` for a tree the default or full gate passed, or failed less than 15 minutes ago. */
|
|
82
|
+
export function verdictOf(cwd, tree) {
|
|
83
|
+
const verdict = readState(cwd).trees[tree];
|
|
84
|
+
if (verdict === undefined || verdict.passed) return verdict;
|
|
85
|
+
return Date.now() - verdict.at < FAILURE_TTL_MS ? verdict : undefined;
|
|
86
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// One gate for every change, so "green" means the same thing by hand, in a hook, in CI and in an agent's done-check.
|
|
2
|
+
// Usage: marram gate [fast | full]
|
|
3
|
+
// fast Seconds: after each edit.
|
|
4
|
+
// (default) Minutes: before a task counts as done.
|
|
5
|
+
// full The default gate and more (a build, the slow journeys). Before merging UI work, or nightly.
|
|
6
|
+
// What each tier runs is the project's: `gate.tiers` in marram.config.mjs. Stages run in order and the first failure
|
|
7
|
+
// stops the gate. Each stage's output is kept in its own log under `gate.logs`/gate-<tier>-<time>/, with report.json.
|
|
8
|
+
// The default and full gates record their verdict for the working tree (gate-state.mjs), which the hooks reuse.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { createWriteStream } from "node:fs";
|
|
11
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { treeOf, updateState } from "./gate-state.mjs";
|
|
14
|
+
|
|
15
|
+
const TIERS = ["fast", "default", "full"];
|
|
16
|
+
|
|
17
|
+
export async function gate(root, config, [tier = "default"]) {
|
|
18
|
+
if (!TIERS.includes(tier)) {
|
|
19
|
+
console.error(`Unknown gate "${tier}". Usage: marram gate [fast | full]`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const { logs, progress, tiers } = config.gate;
|
|
23
|
+
const dir = join(root, logs, `gate-${tier}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
24
|
+
await mkdir(dir, { recursive: true });
|
|
25
|
+
const report = { tier, startedAt: new Date().toISOString(), stages: [], passed: false };
|
|
26
|
+
|
|
27
|
+
// A git hook (pre-push) runs with GIT_DIR and the like pointing at this repository; checks that make their own scratch
|
|
28
|
+
// repositories would act on this one instead.
|
|
29
|
+
const outsideGit = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));
|
|
30
|
+
|
|
31
|
+
// Interrupted (Ctrl-C, a hook's timeout), the gate passes the signal to the stage it runs, so a check runner can stop
|
|
32
|
+
// what it started, and records no verdict: an interrupted gate says nothing about the code.
|
|
33
|
+
let running;
|
|
34
|
+
let interrupted = false;
|
|
35
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
36
|
+
process.once(signal, () => {
|
|
37
|
+
interrupted = true;
|
|
38
|
+
running?.kill(signal);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runStage({ name, run: [command, ...args], env = {} }) {
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
const log = createWriteStream(join(dir, `${name}.log`));
|
|
45
|
+
const child = spawn(command, args, { cwd: root, env: { ...outsideGit, ...env }, stdio: ["ignore", "pipe", "pipe"] });
|
|
46
|
+
running = child;
|
|
47
|
+
child.stdout.pipe(log, { end: false });
|
|
48
|
+
child.stderr.pipe(log, { end: false });
|
|
49
|
+
// Progress lines (a check's ✓ or ✗, and what the project's `progress` matches); everything else stays in the log.
|
|
50
|
+
child.stdout.on("data", (chunk) => {
|
|
51
|
+
for (const line of chunk.toString().split("\n")) if (/^\s*[✓✗]/.test(line) || progress?.test(line)) console.log(` ${name}: ${line.trim()}`);
|
|
52
|
+
});
|
|
53
|
+
const code = await new Promise((done) => {
|
|
54
|
+
child.on("error", () => done(1));
|
|
55
|
+
child.on("exit", (exit) => done(exit ?? 1));
|
|
56
|
+
});
|
|
57
|
+
log.end();
|
|
58
|
+
const seconds = Math.round((Date.now() - started) / 1000);
|
|
59
|
+
report.stages.push({ name, code, seconds });
|
|
60
|
+
await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2));
|
|
61
|
+
console.log(`${code === 0 ? "✓" : "✗"} ${name} (${seconds}s)${code === 0 ? "" : `: see ${join(dir, `${name}.log`)}`}`);
|
|
62
|
+
return code === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A fast pass doesn't make a change done, so only the default and full gates record a verdict.
|
|
66
|
+
const tree = tier === "fast" ? undefined : treeOf(root);
|
|
67
|
+
const verdict = (passed, extra = {}) => {
|
|
68
|
+
if (tree !== undefined) updateState(root, (state) => { state.trees[tree] = { passed, tier, at: Date.now(), ...extra }; });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
console.log(`Gate (${tier}). Logs: ${dir}`);
|
|
72
|
+
for (const stage of tiers[tier]) {
|
|
73
|
+
const passed = await runStage(stage);
|
|
74
|
+
if (interrupted) {
|
|
75
|
+
console.log(`\nGate interrupted at ${stage.name}.`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (!passed) {
|
|
79
|
+
verdict(false, { stage: stage.name, log: join(dir, `${stage.name}.log`) });
|
|
80
|
+
console.log(`\nGate failed at ${stage.name}.`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
report.passed = true;
|
|
85
|
+
report.completedAt = new Date().toISOString();
|
|
86
|
+
await writeFile(join(dir, "report.json"), JSON.stringify(report, null, 2));
|
|
87
|
+
// A file changed while the gate ran: what passed isn't what's there now, so the pass isn't recorded.
|
|
88
|
+
if (tree !== undefined && treeOf(root) === tree) verdict(true);
|
|
89
|
+
console.log(`\nGate passed (${report.stages.reduce((total, stage) => total + stage.seconds, 0)}s).`);
|
|
90
|
+
}
|