@intentic/constants 1.248.0 → 1.249.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/README.md +10 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/provider-logos.d.ts.map +1 -1
- package/dist/provider-logos.js.map +1 -1
- package/package.json +8 -2
- package/src/assertion-measure.d.mts +5 -0
- package/src/assertion-measure.mjs +176 -79
- package/src/ci-infra-steps.d.mts +3 -0
- package/src/ci-infra-steps.mjs +6 -0
- package/src/contract-shrink.mjs +5 -29
- package/src/control-bytes.mjs +7 -17
- package/src/mirror-roots.mjs +17 -85
- package/src/node.mjs +14 -54
package/README.md
CHANGED
|
@@ -14,10 +14,13 @@ The ports, paths and image references the daemon, the CLIs and the desktop app a
|
|
|
14
14
|
- [src/node.mjs](src/node.mjs): `repoRoot()` and `packageRoot()`, behind the `@intentic/constants/node`
|
|
15
15
|
subpath. Node-only, and hand-written JavaScript rather than compiled TypeScript.
|
|
16
16
|
- [src/assertion-measure.mjs](src/assertion-measure.mjs), [src/contract-shrink.mjs](src/contract-shrink.mjs),
|
|
17
|
-
[src/control-bytes.mjs](src/control-bytes.mjs)
|
|
18
|
-
|
|
17
|
+
[src/control-bytes.mjs](src/control-bytes.mjs), [src/mirror-roots.mjs](src/mirror-roots.mjs) and
|
|
18
|
+
[src/ci-infra-steps.mjs](src/ci-infra-steps.mjs): the five judgments the repository's scripts and checks
|
|
19
|
+
(`_tools/checks/`, `_tools/scripts/ci/ci-audit.mjs`) and the daemon both make, kept as one copy each.
|
|
19
20
|
Hand-written JavaScript for the same reason `node.mjs` is: a gate that runs before `pnpm install` imports them
|
|
20
|
-
by relative path, and the daemon imports them as subpaths of this package.
|
|
21
|
+
by relative path, and the daemon imports them as subpaths of this package. `assertion-measure.mjs` carries
|
|
22
|
+
three of them, not one: which files are test files (`TEST_FILE`), how strong a TypeScript file's assertions
|
|
23
|
+
are, and how strong a python file's are.
|
|
21
24
|
|
|
22
25
|
## How it fits
|
|
23
26
|
|
|
@@ -26,7 +29,10 @@ makes it the home of the few pure judgments a pre-install script and the daemon
|
|
|
26
29
|
they could both import would need an install to resolve. `mirror-roots.mjs` is the clearest case of that: the
|
|
27
30
|
set of directories an isolated turn overlays is the daemon's business (`agents/isolation.ts` mounts them), and
|
|
28
31
|
whether a build script may `rm -rf` one of them is a checkout gate's business, and the two answers have to be
|
|
29
|
-
the same answer or a name added to one is a directory the other stops protecting.
|
|
32
|
+
the same answer or a name added to one is a directory the other stops protecting. `assertion-measure.mjs` is
|
|
33
|
+
the same shape of problem one level up: the push gate refuses an undeclared weakening and the daemon reports one
|
|
34
|
+
at the Stop, so if the two disagreed about which files are tests, or about what a python `assert` is worth, a
|
|
35
|
+
file could be measured by one and ignored by the other while both claimed to hold the same line. A port number that lives
|
|
30
36
|
in two files is a port number that will eventually be two different numbers, which is the entire argument for
|
|
31
37
|
this package existing. The same argument covers the directory layouts: `/work`, `/history`, `.intentic`,
|
|
32
38
|
`/opt/intentic`: which were previously typed out by hand across dozens of files with nothing linking the copies.
|
package/dist/index.d.ts
CHANGED
|
@@ -82,4 +82,6 @@ export declare const DAEMON_PORT = 8787;
|
|
|
82
82
|
export declare const PREVIEW_PORT = 5173;
|
|
83
83
|
export declare const LOCAL_PORT = 8788;
|
|
84
84
|
export declare const TRANSLATOR_PORT = 8789;
|
|
85
|
+
export declare const GOOGLE_CLIENT_ID = "481795963975-cq9msl6higcd91joidrfp8mjlkuq5fk3.apps.googleusercontent.com";
|
|
86
|
+
export declare const GOOGLE_TOKEN_STORAGE_KEY = "intentic.gid.481795963975-cq9msl6higcd91joidrfp8mjlkuq5fk3.apps.googleusercontent.com";
|
|
85
87
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,qBAAqB,CAAC;AAOpC,eAAO,MAAM,cAAc,UAAU,CAAC;AAGtC,eAAO,MAAM,YAAY,aAAa,CAAC;AAGvC,eAAO,MAAM,SAAS,cAAc,CAAC;AAGrC,eAAO,MAAM,eAAe,kBAAkB,CAAC;AAG/C,eAAO,MAAM,aAAa,eAAe,CAAC;AAC1C,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAI1D,eAAO,MAAM,iBAAiB,uCAAuC,CAAC;AACtE,eAAO,MAAM,oBAAoB,WAAW,CAAC;AAC7C,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAGtC,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAG5C,eAAO,MAAM,mBAAmB,6BAA6B,CAAC;AAG9D,eAAO,MAAM,oBAAoB,yBAAyB,CAAC;AAI3D,eAAO,MAAM,mBAAmB,8BAA8B,CAAC;AAE/D,eAAO,MAAM,eAAe;;iBAClB,IAAI,EAAE,UAAU;iBAAE,IAAI,EAAE,YAAY;;;iBACnC,IAAI,EAAE,cAAc;iBAAE,IAAI,EAAE,aAAa;;;iBAEtC,IAAI,EAAE,eAAe;iBAAE,IAAI,EAAE,iBAAiB;;;iBAC7C,IAAI,EAAE,mBAAmB;iBAAE,IAAI,EAAE,kBAAkB;;;iBAC/C,IAAI,EAAE,eAAe;iBAAE,IAAI,EAAE,iBAAiB;;;iBAChD,IAAI,EAAE,OAAO;iBAAE,IAAI,EAAE,SAAS;;;iBAC7B,IAAI,EAAE,WAAW;iBAAE,IAAI,EAAE,UAAU;;;iBACrC,IAAI,EAAE,SAAS;iBAAE,IAAI,EAAE,WAAW;;;iBACjC,IAAI,EAAE,aAAa;iBAAE,IAAI,EAAE,YAAY;;;iBACzC,IAAI,EAAE,UAAU;iBAAE,IAAI,EAAE,aAAa;;;iBAClC,IAAI,EAAE,cAAc;iBAAE,IAAI,EAAE,cAAc;;;iBAC9C,IAAI,EAAE,SAAS;iBAAE,IAAI,EAAE,aAAa;;;iBACjC,IAAI,EAAE,aAAa;iBAAE,IAAI,EAAE,cAAc;;;iBAC3C,IAAI,EAAE,UAAU;iBAAE,IAAI,EAAE,YAAY;;;iBACjC,IAAI,EAAE,cAAc;iBAAE,IAAI,EAAE,aAAa;;CACQ,CAAC;AAEpE,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,eAAe,CAAC;AAGzD,eAAO,MAAM,gBAAgB,QAAS,aAAa,KAAG,MAA+D,CAAC;AAGtH,eAAO,MAAM,iBAAiB,QAAS,aAAa,KAAG,MAA+D,CAAC;AAIvH,eAAO,MAAM,WAAW,OAAO,CAAC;AAChC,eAAO,MAAM,YAAY,OAAO,CAAC;AAGjC,eAAO,MAAM,UAAU,OAAO,CAAC;AAG/B,eAAO,MAAM,eAAe,OAAO,CAAC;AAGpC,eAAO,MAAM,gBAAgB,6EAA6E,CAAC;AAG3G,eAAO,MAAM,wBAAwB,0FAAqC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -36,4 +36,6 @@ export const DAEMON_PORT = 8787;
|
|
|
36
36
|
export const PREVIEW_PORT = 5173;
|
|
37
37
|
export const LOCAL_PORT = 8788;
|
|
38
38
|
export const TRANSLATOR_PORT = 8789;
|
|
39
|
+
export const GOOGLE_CLIENT_ID = "481795963975-cq9msl6higcd91joidrfp8mjlkuq5fk3.apps.googleusercontent.com";
|
|
40
|
+
export const GOOGLE_TOKEN_STORAGE_KEY = `intentic.gid.${GOOGLE_CLIENT_ID}`;
|
|
39
41
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,cAAc,qBAAqB,CAAC;AAOpC,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC;AAGtC,MAAM,CAAC,MAAM,YAAY,GAAG,UAAU,CAAC;AAGvC,MAAM,CAAC,MAAM,SAAS,GAAG,WAAW,CAAC;AAGrC,MAAM,CAAC,MAAM,eAAe,GAAG,eAAe,CAAC;AAG/C,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAC;AAC1C,MAAM,CAAC,MAAM,mBAAmB,GAAG,sBAAsB,CAAC;AAI1D,MAAM,CAAC,MAAM,iBAAiB,GAAG,oCAAoC,CAAC;AACtE,MAAM,CAAC,MAAM,oBAAoB,GAAG,QAAQ,CAAC;AAC7C,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,CAAC;AACvC,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAGtC,MAAM,CAAC,MAAM,yBAAyB,GAAG,EAAE,CAAC;AAG5C,MAAM,CAAC,MAAM,mBAAmB,GAAG,0BAA0B,CAAC;AAG9D,MAAM,CAAC,MAAM,oBAAoB,GAAG,sBAAsB,CAAC;AAI3D,MAAM,CAAC,MAAM,mBAAmB,GAAG,2BAA2B,CAAC;AAE/D,MAAM,CAAC,MAAM,eAAe,GAAG;IAC3B,EAAE,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,YAAY,EAAE;IAC5C,GAAG,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE;IAElD,MAAM,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,iBAAiB,EAAE;IAC1D,OAAO,EAAE,EAAE,IAAI,EAAE,mBAAmB,EAAE,IAAI,EAAE,kBAAkB,EAAE;IAChE,WAAW,EAAE,EAAE,IAAI,EAAE,eAAe,EAAE,IAAI,EAAE,iBAAiB,EAAE;IAC/D,SAAS,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE;IAC7C,UAAU,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE;IACnD,QAAQ,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,WAAW,EAAE;IAChD,SAAS,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,YAAY,EAAE;IACtD,OAAO,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,aAAa,EAAE;IAClD,UAAU,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,cAAc,EAAE;IAC1D,MAAM,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,aAAa,EAAE;IAChD,SAAS,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,cAAc,EAAE;IACxD,OAAO,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,YAAY,EAAE;IACjD,UAAU,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,aAAa,EAAE;CACM,CAAC;AAKpE,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,GAAkB,EAAU,EAAE,CAAC,GAAG,oBAAoB,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AAGtH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,GAAkB,EAAU,EAAE,CAAC,GAAG,mBAAmB,IAAI,eAAe,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;AAIvH,MAAM,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC;AAChC,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,CAAC;AAGjC,MAAM,CAAC,MAAM,UAAU,GAAG,IAAI,CAAC;AAG/B,MAAM,CAAC,MAAM,eAAe,GAAG,IAAI,CAAC;AAGpC,MAAM,CAAC,MAAM,gBAAgB,GAAG,0EAA0E,CAAC;AAG3G,MAAM,CAAC,MAAM,wBAAwB,GAAG,gBAAgB,gBAAgB,EAAE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider-logos.d.ts","sourceRoot":"","sources":["../src/provider-logos.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"provider-logos.d.ts","sourceRoot":"","sources":["../src/provider-logos.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,oBAAoB;aAC7B,MAAM,EAAE,qxDAAqxD;aAC7xD,KAAK,EAAE,s7CAAs7C;aAC77C,IAAI,EAAE,8iBAA8iB;aAEpjB,IAAI,EAAE,iDAAiD;aACvD,MAAM,EAAE,8RAA8R;aAEtS,MAAM,EAAE,0VAA0V;aAElW,IAAI,EAAE,ksCAAksC;aAExsC,GAAG,EAAE,yHAAyH;CACxH,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,oBAAoB,CAAC;AAK9D,eAAO,MAAM,gBAAgB,UAAW,aAAa,KAAG,SAAS,GAAG,SAAgE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider-logos.js","sourceRoot":"","sources":["../src/provider-logos.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"provider-logos.js","sourceRoot":"","sources":["../src/provider-logos.ts"],"names":[],"mappings":"AAGA,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAChC,MAAM,EAAE,qxDAAqxD;IAC7xD,KAAK,EAAE,s7CAAs7C;IAC77C,IAAI,EAAE,8iBAA8iB;IAEpjB,IAAI,EAAE,iDAAiD;IACvD,MAAM,EAAE,8RAA8R;IAEtS,MAAM,EAAE,0VAA0V;IAElW,IAAI,EAAE,ksCAAksC;IAExsC,GAAG,EAAE,yHAAyH;CACxH,CAAC;AAMX,MAAM,cAAc,GAA+B,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;AAC5E,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,KAAoB,EAAyB,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentic/constants",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.249.0",
|
|
4
4
|
"description": "Shared constants for the intentic packages, ports, paths, and image references the daemon, CLIs and desktop app all agree on",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
"src/control-bytes.mjs",
|
|
22
22
|
"src/control-bytes.d.mts",
|
|
23
23
|
"src/mirror-roots.mjs",
|
|
24
|
-
"src/mirror-roots.d.mts"
|
|
24
|
+
"src/mirror-roots.d.mts",
|
|
25
|
+
"src/ci-infra-steps.mjs",
|
|
26
|
+
"src/ci-infra-steps.d.mts"
|
|
25
27
|
],
|
|
26
28
|
"exports": {
|
|
27
29
|
".": {
|
|
@@ -51,6 +53,10 @@
|
|
|
51
53
|
"types": "./src/control-bytes.d.mts",
|
|
52
54
|
"default": "./src/control-bytes.mjs"
|
|
53
55
|
},
|
|
56
|
+
"./ci-infra-steps": {
|
|
57
|
+
"types": "./src/ci-infra-steps.d.mts",
|
|
58
|
+
"default": "./src/ci-infra-steps.mjs"
|
|
59
|
+
},
|
|
54
60
|
"./mirror-roots": {
|
|
55
61
|
"types": "./src/mirror-roots.d.mts",
|
|
56
62
|
"default": "./src/mirror-roots.mjs"
|
|
@@ -7,8 +7,13 @@ export interface AssertionMeasure {
|
|
|
7
7
|
}
|
|
8
8
|
export type Weakening = "downgrade" | "narrowing";
|
|
9
9
|
export const NARROWING: number;
|
|
10
|
+
export const TEST_FILE: RegExp;
|
|
10
11
|
export const EXACT: readonly string[];
|
|
11
12
|
export const LOOSE: readonly string[];
|
|
13
|
+
export const PY_EXACT: readonly string[];
|
|
14
|
+
export const PY_LOOSE: readonly string[];
|
|
12
15
|
export function measure(source: string): AssertionMeasure;
|
|
16
|
+
export function measurePython(source: string): AssertionMeasure;
|
|
17
|
+
export function measureFile(source: string, path: string): AssertionMeasure;
|
|
13
18
|
export function weakened(before: AssertionMeasure | undefined, after: AssertionMeasure): Weakening | undefined;
|
|
14
19
|
export function describeWeakening(path: string, shape: Weakening, before: AssertionMeasure, after: AssertionMeasure): string;
|
|
@@ -1,63 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* runs it over a commit range or the working tree, importing this file by relative path because a pre-push hook
|
|
5
|
-
* runs on a clone that may never have installed) and by the daemon's turn-ending check
|
|
6
|
-
* (_sandbox/sandbox/src/agent/agent-tests.ts, importing it as @intentic/constants/assertion-measure), which asks
|
|
7
|
-
* the same question of the test files a turn touched and tells the model while it can still act. One copy, so
|
|
8
|
-
* the two readers cannot disagree.
|
|
9
|
-
*
|
|
10
|
-
* WHY. On 2026-08-31 eight commits in fifty minutes "relaxed" about 180 test files. `toEqual({ …, message:
|
|
11
|
-
* "Reached Example, authenticated as ada." })` became `toMatchObject({ … })` plus `toContain("ada")`; `"9 of 12
|
|
12
|
-
* files still in your workspace"` became `toContain("9")` and `toContain("12")`; `"Start your first agent"`
|
|
13
|
-
* became `"first agent"`. Every suite stayed green, every gate said yes, and each of those tests can now barely
|
|
14
|
-
* fail. AGENTS.md forbids exactly this ("an assertion that cannot fail is worse than no test") and no linter can
|
|
15
|
-
* enforce it: the shape of a weak `toContain` is the shape of a strong one. What CAN be seen is the direction
|
|
16
|
-
* of travel between two versions of the same file, which is what this measures.
|
|
17
|
-
*
|
|
18
|
-
* THREE NUMBERS PER FILE: EXACT matchers (toBe, toEqual, toStrictEqual, toHaveLength, toHaveBeenCalledWith,
|
|
19
|
-
* snapshots…), LOOSE matchers (toContain, toMatch, toMatchObject, toBeTruthy, toBeGreaterThan, expect.any…), and
|
|
20
|
-
* the characters of literal text the assertions pin down (every string, regex and template run inside a
|
|
21
|
-
* matcher's argument list, comments excluded). A file is weaker in either of two shapes:
|
|
22
|
-
*
|
|
23
|
-
* · a DOWNGRADE: fewer exact matchers and more loose ones AND no more asserted text than before, the
|
|
24
|
-
* `toEqual` → `toMatchObject` move. That third clause is what tells the move from its opposite. The move
|
|
25
|
-
* always SHEDS pinned text — it replaces a whole expected object with a fragment of one — so a file that
|
|
26
|
-
* ends up pinning more text than it did is doing something else, whatever its matcher mix did. Without the
|
|
27
|
-
* clause the rule read absolute counts with no sense of scale, and a suite that grew by five tests and 247
|
|
28
|
-
* characters of expectation was refused for turning one `toEqual({})` — an exact matcher asserting that a
|
|
29
|
-
* result is EMPTY — into `toMatchObject({ permissionDecision: "deny" })`, which pins a value the old
|
|
30
|
-
* assertion could not see. One matcher moved from the exact column to the loose one and the file got
|
|
31
|
-
* stronger. Over the 400 commits before this clause was written it changes exactly one verdict, and that
|
|
32
|
-
* one was wrong.
|
|
33
|
-
* · a NARROWING: the asserted text shrinks by more than a quarter while the file keeps as many tests as it had,
|
|
34
|
-
* the "first agent" move. Tests removed with their text are not a narrowing, and the test count says so.
|
|
35
|
-
* Deliberately left on absolute ratio with no floor and no exemption for a file whose matcher mix improved:
|
|
36
|
-
* both were tried against the same 400 commits and both cost more than they bought. A floor big enough to
|
|
37
|
-
* excuse an 85→52 character file exempts 60% of the repository's test files, because the median test file
|
|
38
|
-
* pins only 110 characters; and exempting "the exact count went up while the loose count went down" lets a
|
|
39
|
-
* commit gut six text assertions and buy the exemption with one added `toBe`, which is a real commit
|
|
40
|
-
* (daf77486) this would then have missed. Of the 62 narrowings in that range, 60 sit on commits whose own
|
|
41
|
-
* subject says they relaxed assertions. The two that do not are a `toEqual({…})` replaced by
|
|
42
|
-
* `toBeUndefined()` and a 33-character trim — both worth a reviewer's eye, which is all a flag asks for.
|
|
43
|
-
*
|
|
44
|
-
* A HEURISTIC, AND SAID TO BE ONE. A refactor that replaces twenty `toBe` lines with one `toEqual` of a whole
|
|
45
|
-
* object reads as fewer exact matchers; a suite that switches from asserting prose to asserting structure reads
|
|
46
|
-
* as narrowing. Both are legitimate, and both are exactly the changes a reviewer should be told about, which is
|
|
47
|
-
* why the gate refuses only an UNDECLARED weakening and the turn-ending check reports rather than refuses.
|
|
48
|
-
*
|
|
49
|
-
* Deliberately regex over source, not an AST: this runs from a pre-push hook on a clone that may never have
|
|
50
|
-
* installed, so it can import nothing, and the matchers it counts are names, which a regex reads as well as a
|
|
51
|
-
* parser does. It cannot see a matcher called through a helper (`expectRow(row).toBe(…)` counts, `check(row)`
|
|
52
|
-
* does not), which is the direction of error that under-reports rather than nags. */
|
|
1
|
+
// Three numbers per test file (exact matchers, loose matchers, asserted-text characters) that flag a weaker second
|
|
2
|
+
// version; shared by the push gate and the daemon's turn-ending check so both agree. Regex over source, not an AST,
|
|
3
|
+
// since the push gate runs before install.
|
|
53
4
|
|
|
54
5
|
// Asserted text that shrinks past this fraction of what it was, with no test removed, is a narrowing.
|
|
55
6
|
export const NARROWING = 0.75;
|
|
56
7
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
8
|
+
// Test file naming this ratchet knows: `foo.test.ts`/`foo.spec.tsx`, or pytest's `test_foo.py`/`foo_test.py`.
|
|
9
|
+
export const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?|(^|\/)(test_[^/]+|[^/]+_test)\.py)$/;
|
|
10
|
+
|
|
11
|
+
// toThrow/toHaveProperty count as neither (an argument makes them exact); expect.any always counts as loose.
|
|
61
12
|
export const EXACT = [
|
|
62
13
|
"toBe",
|
|
63
14
|
"toEqual",
|
|
@@ -105,23 +56,9 @@ const ASYMMETRIC = /\bexpect\.(any|anything|stringContaining|stringMatching|obje
|
|
|
105
56
|
const MATCHER = /\.(to[A-Z][A-Za-z]*)\s*\(/g;
|
|
106
57
|
const TEST_CASE = /^\s*(?:test|it)(?:\.(?:each|skip|only|concurrent|todo|fails|skipIf|runIf))?\s*\(/gm;
|
|
107
58
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
* bound.
|
|
112
|
-
*
|
|
113
|
-
* COMMENTS ARE SKIPPED FIRST, and that is not tidiness. Assertions here are routinely commented one by one,
|
|
114
|
-
* the prose says "the owner's" and "the agent's", and to a walker that reads an apostrophe as an opening quote
|
|
115
|
-
* that comment is a string running to the next apostrophe — over the `)` that closes the matcher, over the
|
|
116
|
-
* tests below it, to the end of the file. The number that came back was not an overcount of one file's text so
|
|
117
|
-
* much as a coin flip on how many apostrophes the prose happened to hold, and editing a comment landed a
|
|
118
|
-
* "narrowing" on a file whose assertions nobody had touched.
|
|
119
|
-
*
|
|
120
|
-
* A TEMPLATE'S STATIC RUNS COUNT, only its `${…}` does not. `${STATE_DIR}/config/safety.md` pins seventeen
|
|
121
|
-
* characters of path and one interpolation, and reading the whole literal as computed made every assertion in
|
|
122
|
-
* a repository that composes its paths from constants — which this one requires, see _tools/checks/path-literals.mjs —
|
|
123
|
-
* look like an assertion about nothing. */
|
|
124
|
-
// Past a template's `${…}`, by brace depth: the expression inside is computed, so none of it is asserted text.
|
|
59
|
+
// Text a matcher's argument list pins, walked by hand (args can be multi-line, nested calls). Comments are stripped
|
|
60
|
+
// first so a comment's apostrophe can't read as an open quote; a template's static runs count, its `${…}` does not.
|
|
61
|
+
// Skips past a template's `${…}` by brace depth; the expression inside is computed, not asserted text.
|
|
125
62
|
const pastInterpolation = (source, from) => {
|
|
126
63
|
let braces = 1;
|
|
127
64
|
let i = from + 2;
|
|
@@ -132,7 +69,7 @@ const pastInterpolation = (source, from) => {
|
|
|
132
69
|
return i;
|
|
133
70
|
};
|
|
134
71
|
|
|
135
|
-
// A template literal
|
|
72
|
+
// A template literal from its opening backtick: the character count of its static runs, and where it ends.
|
|
136
73
|
const templateChars = (source, from) => {
|
|
137
74
|
let chars = 0;
|
|
138
75
|
let run = from + 1;
|
|
@@ -190,7 +127,7 @@ const assertedChars = (source, from) => {
|
|
|
190
127
|
chars += template.chars;
|
|
191
128
|
i = template.end;
|
|
192
129
|
} else if (ch === "/" && /[(,\s=]/.test(source[i - 1] ?? "(")) {
|
|
193
|
-
// A regex literal in argument position
|
|
130
|
+
// A regex literal in argument position; its source counts as asserted text like a string's.
|
|
194
131
|
let j = i + 1;
|
|
195
132
|
for (; j < source.length && source[j] !== "/" && source[j] !== "\n"; j += 1) {
|
|
196
133
|
if (source[j] === "\\") {
|
|
@@ -223,13 +160,173 @@ export const measure = (source) => {
|
|
|
223
160
|
return { exact: exactCount, loose: looseCount, chars, tests };
|
|
224
161
|
};
|
|
225
162
|
|
|
226
|
-
//
|
|
163
|
+
// Same three numbers over Python's `assert` statements and unittest methods, mapped onto the same vocabulary (`==`/`is`
|
|
164
|
+
// exact, `in`/comparisons/bare assert loose, `approx(...)` loosens). A compound assert counts toward both exact and
|
|
165
|
+
// loose. assertRaises counts as neither, like toThrow.
|
|
166
|
+
export const PY_EXACT = [
|
|
167
|
+
"assertEqual",
|
|
168
|
+
"assertNotEqual",
|
|
169
|
+
"assertIs",
|
|
170
|
+
"assertIsNot",
|
|
171
|
+
"assertIsNone",
|
|
172
|
+
"assertIsNotNone",
|
|
173
|
+
"assertListEqual",
|
|
174
|
+
"assertDictEqual",
|
|
175
|
+
"assertSetEqual",
|
|
176
|
+
"assertTupleEqual",
|
|
177
|
+
"assertSequenceEqual",
|
|
178
|
+
"assertMultiLineEqual",
|
|
179
|
+
"assertCountEqual",
|
|
180
|
+
"assertAlmostEqual",
|
|
181
|
+
];
|
|
182
|
+
export const PY_LOOSE = [
|
|
183
|
+
"assertTrue",
|
|
184
|
+
"assertFalse",
|
|
185
|
+
"assertIn",
|
|
186
|
+
"assertNotIn",
|
|
187
|
+
"assertIsInstance",
|
|
188
|
+
"assertNotIsInstance",
|
|
189
|
+
"assertGreater",
|
|
190
|
+
"assertGreaterEqual",
|
|
191
|
+
"assertLess",
|
|
192
|
+
"assertLessEqual",
|
|
193
|
+
"assertRegex",
|
|
194
|
+
"assertNotRegex",
|
|
195
|
+
"assertWarns",
|
|
196
|
+
"assertLogs",
|
|
197
|
+
];
|
|
198
|
+
const pyExact = new Set(PY_EXACT);
|
|
199
|
+
const pyLoose = new Set(PY_LOOSE);
|
|
200
|
+
|
|
201
|
+
const PY_EXACT_OP = /==|!=|\bis\b/;
|
|
202
|
+
const PY_LOOSE_OP = /\bin\b|<=|>=|<|>|\bisinstance\s*\(|\bapprox\s*\(/;
|
|
203
|
+
const PY_ASSERT = /^[ \t]*assert\b/gm;
|
|
204
|
+
const PY_METHOD = /\bassert[A-Z][A-Za-z]*\s*\(/g;
|
|
205
|
+
const PY_TEST_CASE = /^[ \t]*(?:async\s+)?def\s+test\w*\s*\(/gm;
|
|
206
|
+
|
|
207
|
+
// One pass blanks every comment and string's contents (same length as source, so offsets line up) and counts each
|
|
208
|
+
// string's characters by position. Must be one pass: a `#` inside a string is not a comment and a quote inside a
|
|
209
|
+
// comment doesn't open a string; triple-quoted strings are tracked since a docstring spans lines.
|
|
210
|
+
// Blanks one string literal in `code`, counts its characters in `text`, and returns where it ends. A single-quoted
|
|
211
|
+
// string stops at the next newline so one stray quote can't blank the rest of the file.
|
|
212
|
+
const blankString = (source, code, text, start) => {
|
|
213
|
+
const ch = source[start];
|
|
214
|
+
const quote = source.startsWith(ch.repeat(3), start) ? ch.repeat(3) : ch;
|
|
215
|
+
let i = start + quote.length;
|
|
216
|
+
while (i < source.length && !source.startsWith(quote, i)) {
|
|
217
|
+
if (quote.length === 1 && source[i] === "\n") {
|
|
218
|
+
return i;
|
|
219
|
+
}
|
|
220
|
+
text[i] = 1;
|
|
221
|
+
code[i] = " ";
|
|
222
|
+
i += source[i] === "\\" ? 2 : 1;
|
|
223
|
+
}
|
|
224
|
+
return Math.min(i + quote.length - 1, source.length - 1);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const blankComment = (source, code, start) => {
|
|
228
|
+
let i = start;
|
|
229
|
+
while (i < source.length && source[i] !== "\n") {
|
|
230
|
+
code[i] = " ";
|
|
231
|
+
i += 1;
|
|
232
|
+
}
|
|
233
|
+
return i;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const blankPython = (source) => {
|
|
237
|
+
const code = [...source];
|
|
238
|
+
const text = new Uint8Array(source.length);
|
|
239
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
240
|
+
const ch = source[i];
|
|
241
|
+
if (ch === "#") {
|
|
242
|
+
i = blankComment(source, code, i);
|
|
243
|
+
} else if (ch === '"' || ch === "'") {
|
|
244
|
+
i = blankString(source, code, text, i);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { code: code.join(""), text };
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const OPENERS = new Set(["(", "[", "{"]);
|
|
251
|
+
const CLOSERS = new Set([")", "]", "}"]);
|
|
252
|
+
|
|
253
|
+
// End of the statement starting at `from`: first newline at bracket depth zero, not escaped by a trailing backslash.
|
|
254
|
+
// Reads the blanked code, so a bracket or backslash inside a string can't extend it.
|
|
255
|
+
const statementEnd = (code, from) => {
|
|
256
|
+
let depth = 0;
|
|
257
|
+
for (let i = from; i < code.length; i += 1) {
|
|
258
|
+
const ch = code[i];
|
|
259
|
+
depth += OPENERS.has(ch) ? 1 : 0;
|
|
260
|
+
depth -= CLOSERS.has(ch) ? 1 : 0;
|
|
261
|
+
const ends = ch === "\n" && depth <= 0 && code[i - 1] !== "\\";
|
|
262
|
+
if (ends) {
|
|
263
|
+
return i;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return code.length;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const charsIn = (text, from, to) => {
|
|
270
|
+
let chars = 0;
|
|
271
|
+
for (let i = from; i < to; i += 1) {
|
|
272
|
+
chars += text[i];
|
|
273
|
+
}
|
|
274
|
+
return chars;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Classifies each `assert` statement by the operators it uses.
|
|
278
|
+
const assertStatements = (code, text) => {
|
|
279
|
+
const totals = { exact: 0, loose: 0, chars: 0 };
|
|
280
|
+
for (const match of code.matchAll(PY_ASSERT)) {
|
|
281
|
+
const end = statementEnd(code, match.index);
|
|
282
|
+
const statement = code.slice(match.index, end);
|
|
283
|
+
const isExact = PY_EXACT_OP.test(statement);
|
|
284
|
+
// No operator at all is a bare truthiness assert; it admits anything that is not falsy.
|
|
285
|
+
totals.exact += isExact ? 1 : 0;
|
|
286
|
+
totals.loose += PY_LOOSE_OP.test(statement) || !isExact ? 1 : 0;
|
|
287
|
+
totals.chars += charsIn(text, match.index, end);
|
|
288
|
+
}
|
|
289
|
+
return totals;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// unittest's assertion methods, classified by name like a matcher.
|
|
293
|
+
const assertMethods = (code, text) => {
|
|
294
|
+
const totals = { exact: 0, loose: 0, chars: 0 };
|
|
295
|
+
for (const match of code.matchAll(PY_METHOD)) {
|
|
296
|
+
const name = match[0].slice(0, match[0].search(/\s*\(/));
|
|
297
|
+
// A name in neither set (assertRaises, a project helper) counts as neither, and its text is not counted.
|
|
298
|
+
if (!pyExact.has(name) && !pyLoose.has(name)) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
totals.exact += pyExact.has(name) ? 1 : 0;
|
|
302
|
+
totals.loose += pyLoose.has(name) ? 1 : 0;
|
|
303
|
+
totals.chars += charsIn(text, match.index, statementEnd(code, match.index));
|
|
304
|
+
}
|
|
305
|
+
return totals;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
export const measurePython = (source) => {
|
|
309
|
+
const { code, text } = blankPython(source);
|
|
310
|
+
const statements = assertStatements(code, text);
|
|
311
|
+
const methods = assertMethods(code, text);
|
|
312
|
+
return {
|
|
313
|
+
exact: statements.exact + methods.exact,
|
|
314
|
+
loose: statements.loose + methods.loose,
|
|
315
|
+
chars: statements.chars + methods.chars,
|
|
316
|
+
tests: [...code.matchAll(PY_TEST_CASE)].length,
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Decides which measure a file gets, by extension, so both readers agree. An unrecognized file falls to the TypeScript
|
|
321
|
+
// measure, which reads it as zero of everything, so it's silently unmeasured rather than wrongly flagged.
|
|
322
|
+
export const measureFile = (source, path) => (path.endsWith('.py') ? measurePython(source) : measure(source));
|
|
323
|
+
|
|
324
|
+
// Weaker, in either of the two shapes measured. A new file, with no before, can only be stronger.
|
|
227
325
|
export const weakened = (before, after) => {
|
|
228
326
|
if (before === undefined) {
|
|
229
327
|
return undefined;
|
|
230
328
|
}
|
|
231
|
-
//
|
|
232
|
-
// it did is not making the `toEqual` → `toMatchObject` move, whichever way its matcher counts went.
|
|
329
|
+
// More text pinned than before rules out a toEqual→toMatchObject move, whatever the matcher counts did.
|
|
233
330
|
if (after.exact < before.exact && after.loose > before.loose && after.chars <= before.chars) {
|
|
234
331
|
return "downgrade";
|
|
235
332
|
}
|
|
@@ -239,6 +336,6 @@ export const weakened = (before, after) => {
|
|
|
239
336
|
return undefined;
|
|
240
337
|
};
|
|
241
338
|
|
|
242
|
-
// One line per weakened file, the numbers a reader needs to judge the heuristic
|
|
339
|
+
// One line per weakened file, with the numbers a reader needs to judge the heuristic themselves.
|
|
243
340
|
export const describeWeakening = (path, shape, before, after) =>
|
|
244
341
|
`${path}: ${shape} (exact ${before.exact}→${after.exact}, loose ${before.loose}→${after.loose}, asserted chars ${before.chars}→${after.chars}, tests ${before.tests}→${after.tests})`;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The steps of a CI job the runner owns, not the repository: a failure in one says the fleet died before any step of
|
|
2
|
+
// the checkout ran, so it is nothing an agent on the code can fix. One list, read by the daemon's CI board and by
|
|
3
|
+
// _tools/scripts/ci/ci-audit.mjs.
|
|
4
|
+
export const INFRA_STEP = /^(Set up job|Set up runner|Initialize containers|Stop containers|Complete job|Post .*)$/;
|
|
5
|
+
|
|
6
|
+
export const isInfraStep = (name) => INFRA_STEP.test(name);
|
package/src/contract-shrink.mjs
CHANGED
|
@@ -1,30 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* (_sandbox/sandbox-contract/src/contract-lock.ts explains the pair). Three readers ask what a newer lock no
|
|
5
|
-
* longer offers: the checkout gate that refuses an undeclared shrink at the push (_tools/checks/contract-shrink.mjs),
|
|
6
|
-
* the landing drafter that forces the `!` and the Breaking-Note into the commit message
|
|
7
|
-
* (_sandbox/sandbox/src/git/contract-shrink.ts), and the tests that hold both to the same judgment. One
|
|
8
|
-
* implementation here, hand-written JavaScript rather than compiled TypeScript for the same reason node.mjs
|
|
9
|
-
* beside it is: the gate runs before `pnpm install`, so it imports this file by relative path and nothing else.
|
|
10
|
-
*
|
|
11
|
-
* ADDITIONS NEVER APPEAR IN THE RESULT. Every reader of the wire parses loosely, so growth breaks nobody, and a
|
|
12
|
-
* detector that flagged growth would put a false `!` on ordinary work.
|
|
13
|
-
*
|
|
14
|
-
* ARRAYS ARE THE SCHEMA'S COLLECTIONS (`oneOf` alternatives, `enum` values, `required` names) and the lock
|
|
15
|
-
* writer keeps them in declaration order rather than sorting, so a position means nothing on its own: every
|
|
16
|
-
* base element must be matched by SOME head element, and extras pass in silence exactly like a new property
|
|
17
|
-
* does. An element that merely changed reads as removed: the same verdict either way.
|
|
18
|
-
*
|
|
19
|
-
* `description` AS A KEYWORD IS PROSE, AND PROSE IS NOT A PROMISE. zod's `.describe()` rides into the lock
|
|
20
|
-
* beside the shape, so re-wording a help sentence used to read as "a surface changed" and demand a `!` commit.
|
|
21
|
-
* Nothing on the wire moves when it does. It is skipped ONLY as a keyword: 78 schemas in this lock carry a
|
|
22
|
-
* real field NAMED `description`, and losing one of those is a genuine break, so the walk tracks whether the
|
|
23
|
-
* object it is reading is a name map (keys are fields) or a schema (keys are keywords). */
|
|
1
|
+
// What base offers that head no longer does, as dotted paths; shared by the push gate and the commit drafter so both
|
|
2
|
+
// draw the same verdict. Growth never appears in the result; array elements match unordered, so a moved element passes.
|
|
3
|
+
// `description` is skipped only as a schema keyword, not when it names an actual field.
|
|
24
4
|
|
|
25
|
-
//
|
|
26
|
-
// carries, everywhere else a key is a keyword. The lock's own root is one too (its keys are the exported
|
|
27
|
-
// schema names), which is why the walk starts `named`.
|
|
5
|
+
// JSON Schema keywords whose value maps NAME to schema; inside one a key is a field, elsewhere a keyword.
|
|
28
6
|
const NAME_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
|
|
29
7
|
|
|
30
8
|
const dotted = (at, key) => (at === "" ? key : `${at}.${key}`);
|
|
@@ -64,9 +42,7 @@ export const shrunkSurfaces = (base, head, at = "", out = [], named = true) => {
|
|
|
64
42
|
return out;
|
|
65
43
|
};
|
|
66
44
|
|
|
67
|
-
//
|
|
68
|
-
// than a throw: one reader feeds a commit-message draft, and a mangled lock is the contract-lock test's failure
|
|
69
|
-
// to report, not a reason to draft nothing.
|
|
45
|
+
// Same comparison over two lock-file texts; either side failing to parse yields no shrink rather than a throw.
|
|
70
46
|
export const lockShrinkage = (baseText, headText) => {
|
|
71
47
|
try {
|
|
72
48
|
return shrunkSurfaces(JSON.parse(baseText), JSON.parse(headText));
|
package/src/control-bytes.mjs
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
* A NUL typed straight into a string literal is invisible in an editor and decisive everywhere else: git, grep,
|
|
6
|
-
* `file`, code review and every diff viewer sniff for one and call the whole file binary. The escape
|
|
7
|
-
* (backslash-u-0000) is the same code point at runtime and leaves the file text, so every reader here asks for
|
|
8
|
-
* it and nothing else. Hand-written JavaScript rather than compiled TypeScript so a pre-push hook on a clone
|
|
9
|
-
* that never installed can import it by relative path. */
|
|
1
|
+
// Bytes that make a text file binary, checked once here for the checkout gate and the daemon's per-edit reader. A NUL
|
|
2
|
+
// reads as binary everywhere (git, grep, diff viewers); its backslash-u-0000 escape is the same code point at runtime
|
|
3
|
+
// but stays text.
|
|
10
4
|
|
|
11
|
-
//
|
|
12
|
-
// Genuinely binary content is skipped by extension rather than by sniffing, because sniffing is exactly the
|
|
13
|
-
// thing that goes wrong here: a source file that LOOKS binary is the bug, not the exemption.
|
|
5
|
+
// Extensions allowed arbitrary bytes, skipped by name not sniffed: binary-looking source is the bug.
|
|
14
6
|
export const BINARY_EXTENSIONS = new Set([
|
|
15
7
|
"png",
|
|
16
8
|
"jpg",
|
|
@@ -43,13 +35,11 @@ export const BINARY_EXTENSIONS = new Set([
|
|
|
43
35
|
|
|
44
36
|
export const isBinaryPath = (path) => BINARY_EXTENSIONS.has(path.split(".").pop()?.toLowerCase() ?? "");
|
|
45
37
|
|
|
46
|
-
// C0 controls minus
|
|
47
|
-
// along: it is as invisible as the rest and has no business in source either.
|
|
38
|
+
// C0 controls minus tab/newline/CR; DEL rides along, equally invisible and out of place in source.
|
|
48
39
|
export const isForbiddenByte = (byte) => byte <= 0x08 || byte === 0x0b || byte === 0x0c || (byte >= 0x0e && byte <= 0x1f) || byte === 0x7f;
|
|
49
40
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// the clean file, the overwhelmingly common case, pays for one linear scan and nothing else.
|
|
41
|
+
// First forbidden byte as {offset, line, column, byte}, or undefined if none; slices only once a bad byte is found, so
|
|
42
|
+
// a clean file costs one linear scan.
|
|
53
43
|
export const firstForbiddenByte = (bytes) => {
|
|
54
44
|
for (let at = 0; at < bytes.length; at++) {
|
|
55
45
|
if (isForbiddenByte(bytes[at])) {
|
package/src/mirror-roots.mjs
CHANGED
|
@@ -1,102 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* tree (`node_modules`) and its build output (`dist`, `generated`) — cannot be checked out and have to come
|
|
5
|
-
* from the main tree. The daemon supplies them as overlayfs mounts, one per directory, the MAIN checkout's copy
|
|
6
|
-
* as the lowerdir and a per-conversation upper layer for whatever the turn writes
|
|
7
|
-
* (_sandbox/sandbox/src/agents/isolation.ts, which imports this set rather than keeping its own).
|
|
8
|
-
*
|
|
9
|
-
* AN OVERLAY RESOLVES ITS LOWERDIR ONCE, AT MOUNT TIME, and holds that dentry for the life of the mount.
|
|
10
|
-
* Rewriting the FILES inside it is harmless, and that is the whole reason mirroring a build directory works at
|
|
11
|
-
* all: measured on this image (ext4 lower, kernel 6.18), unlinking every file in the lower root and writing new
|
|
12
|
-
* ones is picked up by the merged view immediately, entry for entry. REPLACING THE DIRECTORY ITSELF is not.
|
|
13
|
-
* `rm -rf dist` followed by a `mkdir` gives that path a new inode; the mount keeps pointing at the old one, and
|
|
14
|
-
* the merged directory then reads as COMPLETELY EMPTY — not even the entries in the turn's own upper layer,
|
|
15
|
-
* though `stat` on any of those upper files still succeeds, which is what makes the symptom so hard to read.
|
|
16
|
-
* `mount -o remount` does not repair it. Only umount/mount does, and nothing inside the turn can do either: the
|
|
17
|
-
* mount root is the one lower directory a turn cannot shadow with a write of its own.
|
|
18
|
-
*
|
|
19
|
-
* That is not a hazard someone imagined. It is what `_platform/prisma`'s build script did: `rm -rf ./generated
|
|
20
|
-
* ./dist ./.cache` ahead of `prisma generate`. Run on the main tree by `turbo run build` — the push gate's third
|
|
21
|
-
* tier, the image-tree prep, an owner typing `pnpm build` — it replaced the lowerdir of every live agent
|
|
22
|
-
* worktree's `_platform/prisma/generated` overlay at once. Each of those turns was then holding a directory with
|
|
23
|
-
* a freshly generated `client.ts` in it that `readdir` reported as empty, so the `"include": ["./generated/**"]`
|
|
24
|
-
* glob in that package's tsconfig matched nothing and the declarations emit died with
|
|
25
|
-
*
|
|
26
|
-
* _platform/prisma/client.ts(1,15): error TS6307: File '.../generated/client.ts' is not listed within the
|
|
27
|
-
* file list of project '.../_platform/prisma/tsconfig.json'
|
|
28
|
-
*
|
|
29
|
-
* on the turn-ending check of every conversation, whatever the turn had actually changed. A gate that is red for
|
|
30
|
-
* a reason no turn caused is the failure mode docs/ci-failure-audit.md exists to hunt, and it teaches everyone
|
|
31
|
-
* reading it that a red check is background noise.
|
|
32
|
-
*
|
|
33
|
-
* WHY THE RULE IS ABOUT THE MOUNT ROOT AND NOT ABOUT EVERY DIRECTORY UNDER IT. `prisma generate` does the same
|
|
34
|
-
* remove-and-recreate to `generated/models` and `generated/internal` on every run, and no rule here could stop
|
|
35
|
-
* it — that is a third-party generator's business. It does not have to be stopped: a turn's own generate rmdirs
|
|
36
|
-
* those same subdirectories through the MERGED view first, which leaves an opaque upper directory the stale
|
|
37
|
-
* lower can no longer reach. Only the mount root has no such repair, which is exactly where this rule sits.
|
|
38
|
-
*
|
|
39
|
-
* So: EMPTY A MIRRORED DIRECTORY, NEVER REPLACE IT. _tools/scripts/build/clean-outputs.mjs is what does that, and
|
|
40
|
-
* _tools/checks/mirror-roots.mjs refuses the shape wherever a shell command in this repository spells it.
|
|
41
|
-
*
|
|
42
|
-
* Hand-written JavaScript rather than compiled TypeScript for the reason node.mjs gives: the checkout gate that
|
|
43
|
-
* enforces this imports it by relative path from a clone that has never installed, and the daemon imports the
|
|
44
|
-
* same file as `@intentic/constants/mirror-roots`. */
|
|
1
|
+
// Directories an isolated turn's overlay mounts over (node_modules, .venv, dist, generated): emptying them is safe, but
|
|
2
|
+
// replacing the directory (rm -rf then mkdir) orphans the mount at its old inode, and only umount/mount repairs it.
|
|
3
|
+
// _tools/checks/mirror-roots.mjs refuses the replacing shape; clean-outputs.mjs does the emptying instead.
|
|
45
4
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
// isolation.ts for why a mirrored `.cache` would hand a turn the main checkout's idea of what its dist was
|
|
49
|
-
// built from. Nothing mounts a `.cache`, so it is free to be removed outright, and the build scripts fixed for
|
|
50
|
-
// this still do exactly that to theirs.
|
|
51
|
-
export const MIRRORED_DIRS = new Set(["node_modules", "dist", "generated"]);
|
|
5
|
+
// Overlaid directory names; `.cache` is deliberately excluded and `.venv` is python's node_modules.
|
|
6
|
+
export const MIRRORED_DIRS = new Set(["node_modules", ".venv", "dist", "generated"]);
|
|
52
7
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// CONTENTS and leaves the inode alone.
|
|
8
|
+
// Last path segment, quotes and trailing slash stripped; `dist/*` (contents) is not a match, only the directory itself
|
|
9
|
+
// is.
|
|
56
10
|
const lastSegment = (token) => {
|
|
57
11
|
const bare = token.replace(/^['"]|['"]$/g, "").replace(/\/+$/, "");
|
|
58
12
|
return bare.slice(bare.lastIndexOf("/") + 1);
|
|
59
13
|
};
|
|
60
14
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* someone WROTE, and every one of those in this repository is a plain sequence of words. */
|
|
15
|
+
// Splits into shell words, keeping quotes so lastSegment can strip them (and `'{}'` reads as find's placeholder); not a
|
|
16
|
+
// shell parser.
|
|
64
17
|
const tokenize = (segment) => segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
65
18
|
|
|
66
|
-
|
|
67
|
-
* removes a container and `find … -exec rm -rf {} +` removes files, so the preceding word is what tells them
|
|
68
|
-
* apart: a removal is a COMMAND here, or the thing an exec/sudo/xargs runs, never an argument to something
|
|
69
|
-
* else. (It costs nothing to be wrong about `docker rm` anyway — it is never recursive — but a check that
|
|
70
|
-
* reports a container by name would be read as noise, and a noisy gate gets switched off.) */
|
|
19
|
+
// Verbs that remove a directory, and words that may precede one; the word before tells command from argument.
|
|
71
20
|
const REMOVERS = new Set(["rm", "rmdir", "rimraf"]);
|
|
72
21
|
const RUNNERS = new Set(["exec", "-exec", "-execdir", "sudo", "xargs", "then", "do", "else", "{", "(", "npx", "pnpm", "bunx", "yarn"]);
|
|
73
|
-
//
|
|
74
|
-
// never be the operation this is about: `rm -f dist.zip` is fine and must stay unreported. (It is also what
|
|
75
|
-
// keeps `pnpm rm <package>`, an uninstall, out of this: nothing there is recursive.)
|
|
22
|
+
// Recursive rm flags (-rf, -fr, -Rf, -r, --recursive); a non-recursive rm can't take a directory at all.
|
|
76
23
|
const RECURSIVE = /^(?:--recursive$|-[a-zA-Z]*[rR])/;
|
|
77
|
-
// Where a
|
|
24
|
+
// Where a find -exec command ends; everything after belongs to find again.
|
|
78
25
|
const EXEC_END = new Set([";", "\\;", "+"]);
|
|
79
26
|
const PLACEHOLDER = /^['"]?\{\}['"]?$/;
|
|
80
|
-
//
|
|
81
|
-
// yields the directory it started from, which is precisely how you empty a tree without replacing its root.
|
|
27
|
+
// find predicates naming what will be removed.
|
|
82
28
|
const NAME_PREDICATES = new Set(["-name", "-iname", "-path", "-wholename", "-ipath"]);
|
|
83
29
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
* Two shapes, because those are the two ways this repository has ever spelled it:
|
|
87
|
-
* · a literal removal — `rm -rf ./generated ./dist ./.cache`, `rm -rf "$PKG/dist"`
|
|
88
|
-
* · a find that removes what it names — `find . \( -name 'node_modules' -o -name 'dist' \) -prune -exec rm
|
|
89
|
-
* -rf '{}' +`, where the removal's own operand is a placeholder and the find's predicates say what it hits.
|
|
90
|
-
*
|
|
91
|
-
* Split on the separators that end one command, so `a && rm -rf dist` is two commands and a `find … -exec rm …`
|
|
92
|
-
* stays one: only inside a single command do a find's predicates describe that removal's operands.
|
|
93
|
-
*
|
|
94
|
-
* A FIND IS READ WHOLE, AND THAT ROUNDS TOWARDS REFUSING. `find . -name node_modules -prune -o -name dist
|
|
95
|
-
* -prune -exec rm -rf {} +` removes only the second name; the first is pruned past. Telling them apart means
|
|
96
|
-
* implementing find's expression grammar — `-o`, `-a`, `-prune` and their precedence — for a distinction that
|
|
97
|
-
* changes nothing about the answer, since the safe rewrite is the same either way and neither name may be
|
|
98
|
-
* REMOVED by a command running in the checkout. So every `-name` in a removing find is reported, and the fix
|
|
99
|
-
* for a false one is the fix for a true one. */
|
|
30
|
+
// Mirror roots a shell command would replace, split into commands on &&/;/|. Recognizes a literal removal and a find
|
|
31
|
+
// whose predicates name what -exec/-delete removes; over-reports a pruned name rather than parsing -o/-prune.
|
|
100
32
|
export const replacedMirrorRoots = (command) => {
|
|
101
33
|
const found = [];
|
|
102
34
|
for (const segment of command.split(/\|\||&&|[;|\n]/)) {
|
|
@@ -107,7 +39,7 @@ export const replacedMirrorRoots = (command) => {
|
|
|
107
39
|
continue;
|
|
108
40
|
}
|
|
109
41
|
const named = tokens.flatMap((token, at) => (NAME_PREDICATES.has(token) && tokens[at + 1] !== undefined ? [tokens[at + 1]] : []));
|
|
110
|
-
// `find -delete` removes what the predicates name, with no `rm`
|
|
42
|
+
// `find -delete` removes what the predicates name, with no `rm` on the line to notice.
|
|
111
43
|
if (tokens.includes("-delete")) {
|
|
112
44
|
found.push(...named.filter((token) => MIRRORED_DIRS.has(lastSegment(token))));
|
|
113
45
|
}
|
|
@@ -132,7 +64,7 @@ export const replacedMirrorRoots = (command) => {
|
|
|
132
64
|
continue;
|
|
133
65
|
}
|
|
134
66
|
for (const operand of operands) {
|
|
135
|
-
// A `{}` is
|
|
67
|
+
// A `{}` is find's placeholder: it stands for whatever the predicates named.
|
|
136
68
|
const targets = PLACEHOLDER.test(operand) ? named : [operand];
|
|
137
69
|
found.push(...targets.filter((target) => MIRRORED_DIRS.has(lastSegment(target))));
|
|
138
70
|
}
|
package/src/node.mjs
CHANGED
|
@@ -2,57 +2,24 @@ import { existsSync, statSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
* package's own directory, `../../..` from its `src/`, `../../../..` from the installer scripts. Every one of
|
|
9
|
-
* those numbers is correct only for the file's CURRENT depth, and nothing anywhere checks it. Move the file
|
|
10
|
-
* one directory and it silently resolves somewhere else: no import fails, no type breaks; you find out when
|
|
11
|
-
* something reads the wrong .env at runtime, or reads nothing and falls back to a default.
|
|
12
|
-
*
|
|
13
|
-
* Walking up until a marker appears has none of that coupling. A file can sit at any depth, move between
|
|
14
|
-
* packages, or be symlinked in, and still get the same answer.
|
|
15
|
-
*
|
|
16
|
-
* WHY THIS FILE IS HAND-WRITTEN JAVASCRIPT rather than TypeScript compiled to dist, in a package where
|
|
17
|
-
* everything else is compiled: the earliest callers run BEFORE anything is built. `emit-declarations.mjs` is the script
|
|
18
|
-
* that performs the build, and the byte check and the path guard both run ahead of it in `pnpm checks`. A
|
|
19
|
-
* helper those three had to import from `dist/` would be a helper they could not import on a clean checkout,
|
|
20
|
-
* which is exactly how the second copy of this walk gets written. Plain .mjs with a hand-written .d.mts beside
|
|
21
|
-
* it is importable at every point in the build, so there only has to be one.
|
|
22
|
-
*
|
|
23
|
-
* A caller that runs before `pnpm install` has one more constraint: `@intentic/constants/node` is a BARE
|
|
24
|
-
* specifier and bare specifiers resolve through node_modules, which a bare checkout has none of. Those callers
|
|
25
|
-
* (`_tools/checks/*.mjs`, run by the pre-push hook and by CI's preflight job) import THIS FILE by relative path
|
|
26
|
-
* instead. Still one walk; only the way in differs.
|
|
27
|
-
*
|
|
28
|
-
* NOT EXPORTED FROM THE PACKAGE INDEX, and that is deliberate: the index is imported by browser code
|
|
29
|
-
* (Setup.vue reads PLATFORM_WEB_ORIGIN) and must never pull in node:fs. Path VALUES live there; path DISCOVERY
|
|
30
|
-
* lives here, behind the `@intentic/constants/node` subpath. */
|
|
5
|
+
// Finds the monorepo root by walking up to a marker (pnpm-workspace.yaml), not by counting `../..`, which broke
|
|
6
|
+
// silently whenever a file moved. Kept out of the package index (browser-safe, no node:fs) and hand-written as plain
|
|
7
|
+
// .mjs so callers can import it before install or build.
|
|
31
8
|
|
|
32
|
-
//
|
|
33
|
-
// package.json sits in all 78 packages, and .git is absent in an agent worktree's checkout and present in
|
|
34
|
-
// unrelated parents. This file exists exactly once, at exactly the directory everyone means by "the root".
|
|
9
|
+
// Root marker; not package.json (in every package) or .git (absent in a worktree, present in other parents).
|
|
35
10
|
const REPO_MARKER = "pnpm-workspace.yaml";
|
|
36
11
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
* stops a caller reaching for `fileURLToPath(new URL("...", import.meta.url))` out of habit and reintroducing
|
|
40
|
-
* a relative segment on the way in. */
|
|
12
|
+
// Caller's directory from a file:// URL, import.meta.dirname, or a bare path; accepting all three avoids callers
|
|
13
|
+
// composing their own relative path.
|
|
41
14
|
const startDir = (from) => {
|
|
42
15
|
const path = from.startsWith("file:") ? fileURLToPath(from) : resolve(from);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* indistinguishable as strings.
|
|
46
|
-
*
|
|
47
|
-
* A path that does not exist is treated as a directory, and costs nothing when it isn't: the walk simply
|
|
48
|
-
* finds no marker at that level and moves to the parent, which is where a non-existent file's directory
|
|
49
|
-
* was all along. */
|
|
16
|
+
// Resolved via the filesystem, not guessed from the string; a path that doesn't exist is treated as a directory at
|
|
17
|
+
// no cost.
|
|
50
18
|
return statSync(path, { throwIfNoEntry: false })?.isDirectory() === false ? dirname(path) : path;
|
|
51
19
|
};
|
|
52
20
|
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// on, while everything inside the repo treats it as impossible and throws.
|
|
21
|
+
// Walks up from `dir` until `marker` appears beside it, or parents run out (returns ""); callers decide whether that's
|
|
22
|
+
// fatal.
|
|
56
23
|
const walkUp = (dir, marker) => {
|
|
57
24
|
let current = dir;
|
|
58
25
|
for (;;) {
|
|
@@ -67,12 +34,8 @@ const walkUp = (dir, marker) => {
|
|
|
67
34
|
}
|
|
68
35
|
};
|
|
69
36
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
* Throws when the marker is nowhere above the caller, which inside this repo means the checkout is broken. The
|
|
74
|
-
* throw is the point: the counting version's failure mode was to return a WRONG directory confidently, and a
|
|
75
|
-
* wrong directory is how a config loader silently reads no .env and every credential arrives empty. */
|
|
37
|
+
// Monorepo root from anywhere inside it (pass import.meta.url). Throws if the marker is nowhere above, rather than
|
|
38
|
+
// resolving to a confidently wrong directory.
|
|
76
39
|
export const repoRoot = (from) => {
|
|
77
40
|
const found = walkUp(startDir(from), REPO_MARKER);
|
|
78
41
|
if (found === "") {
|
|
@@ -81,11 +44,8 @@ export const repoRoot = (from) => {
|
|
|
81
44
|
return found;
|
|
82
45
|
};
|
|
83
46
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
* each with a different number of dots because each sat at a different depth, all of them meaning "mine".
|
|
87
|
-
*
|
|
88
|
-
* Stops at the FIRST package.json above the caller, so a package's own manifest always wins over the root's. */
|
|
47
|
+
// Caller's own package root: the directory nearest above it holding a package.json. Stops at the first match, so a
|
|
48
|
+
// package's own manifest always wins over the root's.
|
|
89
49
|
export const packageRoot = (from) => {
|
|
90
50
|
const found = walkUp(startDir(from), "package.json");
|
|
91
51
|
if (found === "") {
|