@intentic/constants 1.240.0 → 1.243.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 +12 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/provider-logos.d.ts +2 -0
- package/dist/provider-logos.d.ts.map +1 -1
- package/dist/provider-logos.js +4 -1
- package/dist/provider-logos.js.map +1 -1
- package/package.json +26 -2
- package/src/assertion-measure.d.mts +14 -0
- package/src/assertion-measure.mjs +244 -0
- package/src/contract-shrink.d.mts +3 -0
- package/src/contract-shrink.mjs +76 -0
- package/src/control-bytes.d.mts +7 -0
- package/src/control-bytes.mjs +65 -0
- package/src/mirror-roots.d.mts +3 -0
- package/src/mirror-roots.mjs +142 -0
- package/src/node.mjs +3 -3
package/README.md
CHANGED
|
@@ -13,10 +13,20 @@ The ports, paths and image references the daemon, the CLIs and the desktop app a
|
|
|
13
13
|
values, and the install-script table. Isomorphic, imported by browser code, so nothing here may touch `node:fs`.
|
|
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
|
+
- [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) and [src/mirror-roots.mjs](src/mirror-roots.mjs): the four
|
|
18
|
+
judgments the repository's checkout gates (`_tools/checks/`) and the daemon both make, kept as one copy each.
|
|
19
|
+
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.
|
|
16
21
|
|
|
17
22
|
## How it fits
|
|
18
23
|
|
|
19
|
-
The bottom of the dependency graph: it imports nothing and almost everything imports it.
|
|
24
|
+
The bottom of the dependency graph: it imports nothing and almost everything imports it. That is also what
|
|
25
|
+
makes it the home of the few pure judgments a pre-install script and the daemon have to share: anything else
|
|
26
|
+
they could both import would need an install to resolve. `mirror-roots.mjs` is the clearest case of that: the
|
|
27
|
+
set of directories an isolated turn overlays is the daemon's business (`agents/isolation.ts` mounts them), and
|
|
28
|
+
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. A port number that lives
|
|
20
30
|
in two files is a port number that will eventually be two different numbers, which is the entire argument for
|
|
21
31
|
this package existing. The same argument covers the directory layouts: `/work`, `/history`, `.intentic`,
|
|
22
32
|
`/opt/intentic`: which were previously typed out by hand across dozens of files with nothing linking the copies.
|
|
@@ -41,7 +51,7 @@ by nothing. Walking up to a marker has no such coupling, so a file can move anyw
|
|
|
41
51
|
gets written. It is also why the root `package.json` depends on this package: without that link, scripts under
|
|
42
52
|
`_tools/scripts/` cannot resolve it by name.
|
|
43
53
|
- **The name only resolves once `pnpm install` has run**, because a bare specifier is looked up through
|
|
44
|
-
`node_modules`. The two callers that run before any install: `
|
|
54
|
+
`node_modules`. The two callers that run before any install: `_tools/checks/run.mjs`, which the `pre-push`
|
|
45
55
|
hook and the CI `preflight` job invoke on a bare checkout: therefore import `../constants/src/node.mjs` by
|
|
46
56
|
path. Same file, same single copy of the walk, no install required. Everything that runs after the install
|
|
47
57
|
imports it by name.
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare const WORKSPACE_ROOT = "/work";
|
|
|
3
3
|
export declare const HISTORY_ROOT = "/history";
|
|
4
4
|
export declare const STATE_DIR = ".intentic";
|
|
5
5
|
export declare const HOST_STATE_ROOT = "/opt/intentic";
|
|
6
|
-
export declare const LEGAL_VERSION = "2026-
|
|
6
|
+
export declare const LEGAL_VERSION = "2026-09-03";
|
|
7
7
|
export declare const LEGAL_CONTACT_EMAIL = "contact@intentic.dev";
|
|
8
8
|
export declare const LEGAL_ENTITY_NAME = "Artur Kurowski, trading as radarsu";
|
|
9
9
|
export declare const LEGAL_ENTITY_COUNTRY = "Poland";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export const WORKSPACE_ROOT = "/work";
|
|
|
3
3
|
export const HISTORY_ROOT = "/history";
|
|
4
4
|
export const STATE_DIR = ".intentic";
|
|
5
5
|
export const HOST_STATE_ROOT = "/opt/intentic";
|
|
6
|
-
export const LEGAL_VERSION = "2026-
|
|
6
|
+
export const LEGAL_VERSION = "2026-09-03";
|
|
7
7
|
export const LEGAL_CONTACT_EMAIL = "contact@intentic.dev";
|
|
8
8
|
export const LEGAL_ENTITY_NAME = "Artur Kurowski, trading as radarsu";
|
|
9
9
|
export const LEGAL_ENTITY_COUNTRY = "Poland";
|
package/dist/provider-logos.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare const PROVIDER_BRAND_PATHS: {
|
|
|
5
5
|
readonly kimi: `M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z`;
|
|
6
6
|
readonly gemini: `M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81`;
|
|
7
7
|
readonly cursor: `M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23`;
|
|
8
|
+
readonly meta: `M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z`;
|
|
9
|
+
readonly zai: `M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z`;
|
|
8
10
|
};
|
|
9
11
|
export type ProviderBrand = keyof typeof PROVIDER_BRAND_PATHS;
|
|
10
12
|
export declare const providerFillRule: (brand: ProviderBrand) => "evenodd" | undefined;
|
|
@@ -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":"AAiBA,eAAO,MAAM,oBAAoB;aAC7B,MAAM,EAAE,qxDAAqxD;aAC7xD,KAAK,EAAE,s7CAAs7C;aAC77C,IAAI,EAAE,8iBAA8iB;aAGpjB,IAAI,EAAE,iDAAiD;aACvD,MAAM,EAAE,8RAA8R;aAGtS,MAAM,EAAE,0VAA0V;aAGlW,IAAI,EAAE,ksCAAksC;aAIxsC,GAAG,EAAE,yHAAyH;CACxH,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,MAAM,OAAO,oBAAoB,CAAC;AAS9D,eAAO,MAAM,gBAAgB,UAAW,aAAa,KAAG,SAAS,GAAG,SAAgE,CAAC"}
|
package/dist/provider-logos.js
CHANGED
|
@@ -5,6 +5,9 @@ export const PROVIDER_BRAND_PATHS = {
|
|
|
5
5
|
kimi: `M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z`,
|
|
6
6
|
gemini: `M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81`,
|
|
7
7
|
cursor: `M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23`,
|
|
8
|
+
meta: `M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z`,
|
|
9
|
+
zai: `M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z`,
|
|
8
10
|
};
|
|
9
|
-
|
|
11
|
+
const EVENODD_BRANDS = new Set([`grok`, `zai`]);
|
|
12
|
+
export const providerFillRule = (brand) => (EVENODD_BRANDS.has(brand) ? `evenodd` : undefined);
|
|
10
13
|
//# sourceMappingURL=provider-logos.js.map
|
|
@@ -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":"AAiBA,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAChC,MAAM,EAAE,qxDAAqxD;IAC7xD,KAAK,EAAE,s7CAAs7C;IAC77C,IAAI,EAAE,8iBAA8iB;IAGpjB,IAAI,EAAE,iDAAiD;IACvD,MAAM,EAAE,8RAA8R;IAGtS,MAAM,EAAE,0VAA0V;IAGlW,IAAI,EAAE,ksCAAksC;IAIxsC,GAAG,EAAE,yHAAyH;CACxH,CAAC;AAUX,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.243.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",
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
15
|
"src/node.mjs",
|
|
16
|
-
"src/node.d.mts"
|
|
16
|
+
"src/node.d.mts",
|
|
17
|
+
"src/assertion-measure.mjs",
|
|
18
|
+
"src/assertion-measure.d.mts",
|
|
19
|
+
"src/contract-shrink.mjs",
|
|
20
|
+
"src/contract-shrink.d.mts",
|
|
21
|
+
"src/control-bytes.mjs",
|
|
22
|
+
"src/control-bytes.d.mts",
|
|
23
|
+
"src/mirror-roots.mjs",
|
|
24
|
+
"src/mirror-roots.d.mts"
|
|
17
25
|
],
|
|
18
26
|
"exports": {
|
|
19
27
|
".": {
|
|
@@ -30,6 +38,22 @@
|
|
|
30
38
|
"./node": {
|
|
31
39
|
"types": "./src/node.d.mts",
|
|
32
40
|
"default": "./src/node.mjs"
|
|
41
|
+
},
|
|
42
|
+
"./assertion-measure": {
|
|
43
|
+
"types": "./src/assertion-measure.d.mts",
|
|
44
|
+
"default": "./src/assertion-measure.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./contract-shrink": {
|
|
47
|
+
"types": "./src/contract-shrink.d.mts",
|
|
48
|
+
"default": "./src/contract-shrink.mjs"
|
|
49
|
+
},
|
|
50
|
+
"./control-bytes": {
|
|
51
|
+
"types": "./src/control-bytes.d.mts",
|
|
52
|
+
"default": "./src/control-bytes.mjs"
|
|
53
|
+
},
|
|
54
|
+
"./mirror-roots": {
|
|
55
|
+
"types": "./src/mirror-roots.d.mts",
|
|
56
|
+
"default": "./src/mirror-roots.mjs"
|
|
33
57
|
}
|
|
34
58
|
},
|
|
35
59
|
"devDependencies": {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Types for assertion-measure.mjs, so the daemon's agreement test (agent-tests.test.ts) can import it by path.
|
|
2
|
+
export interface AssertionMeasure {
|
|
3
|
+
readonly exact: number;
|
|
4
|
+
readonly loose: number;
|
|
5
|
+
readonly chars: number;
|
|
6
|
+
readonly tests: number;
|
|
7
|
+
}
|
|
8
|
+
export type Weakening = "downgrade" | "narrowing";
|
|
9
|
+
export const NARROWING: number;
|
|
10
|
+
export const EXACT: readonly string[];
|
|
11
|
+
export const LOOSE: readonly string[];
|
|
12
|
+
export function measure(source: string): AssertionMeasure;
|
|
13
|
+
export function weakened(before: AssertionMeasure | undefined, after: AssertionMeasure): Weakening | undefined;
|
|
14
|
+
export function describeWeakening(path: string, shape: Weakening, before: AssertionMeasure, after: AssertionMeasure): string;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/* HOW STRONG A TEST FILE'S ASSERTIONS ARE, as three numbers, and whether a second version of the file is weaker.
|
|
2
|
+
*
|
|
3
|
+
* The pure half of the assertion ratchet, shared by the push gate (_tools/scripts/assertion-ratchet.mjs, which
|
|
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. */
|
|
53
|
+
|
|
54
|
+
// Asserted text that shrinks past this fraction of what it was, with no test removed, is a narrowing.
|
|
55
|
+
export const NARROWING = 0.75;
|
|
56
|
+
|
|
57
|
+
/* The vocabulary. Exact matchers pin a value; loose ones admit a family of them. `toThrow` and `toHaveProperty`
|
|
58
|
+
* are both depending on their arguments (a message or a value makes them exact) and are counted as neither, so
|
|
59
|
+
* a file that trades between them moves no number. Asymmetric matchers (`expect.any`, `objectContaining`) loosen
|
|
60
|
+
* whatever exact matcher they sit inside, so each one counts as loose. */
|
|
61
|
+
export const EXACT = [
|
|
62
|
+
"toBe",
|
|
63
|
+
"toEqual",
|
|
64
|
+
"toStrictEqual",
|
|
65
|
+
"toHaveLength",
|
|
66
|
+
"toHaveBeenCalledWith",
|
|
67
|
+
"toHaveBeenLastCalledWith",
|
|
68
|
+
"toHaveBeenNthCalledWith",
|
|
69
|
+
"toHaveBeenCalledTimes",
|
|
70
|
+
"toHaveBeenCalledOnce",
|
|
71
|
+
"toHaveReturnedWith",
|
|
72
|
+
"toHaveLastReturnedWith",
|
|
73
|
+
"toMatchInlineSnapshot",
|
|
74
|
+
"toMatchSnapshot",
|
|
75
|
+
"toMatchFileSnapshot",
|
|
76
|
+
"toThrowErrorMatchingInlineSnapshot",
|
|
77
|
+
"toThrowErrorMatchingSnapshot",
|
|
78
|
+
"toBeNull",
|
|
79
|
+
"toBeUndefined",
|
|
80
|
+
"toBeNaN",
|
|
81
|
+
"toBeCloseTo",
|
|
82
|
+
];
|
|
83
|
+
export const LOOSE = [
|
|
84
|
+
"toContain",
|
|
85
|
+
"toContainEqual",
|
|
86
|
+
"toMatch",
|
|
87
|
+
"toMatchObject",
|
|
88
|
+
"toBeTruthy",
|
|
89
|
+
"toBeFalsy",
|
|
90
|
+
"toBeDefined",
|
|
91
|
+
"toBeGreaterThan",
|
|
92
|
+
"toBeGreaterThanOrEqual",
|
|
93
|
+
"toBeLessThan",
|
|
94
|
+
"toBeLessThanOrEqual",
|
|
95
|
+
"toBeInstanceOf",
|
|
96
|
+
"toBeTypeOf",
|
|
97
|
+
"toSatisfy",
|
|
98
|
+
"toHaveBeenCalled",
|
|
99
|
+
"toHaveReturned",
|
|
100
|
+
"toBeOneOf",
|
|
101
|
+
];
|
|
102
|
+
const exact = new Set(EXACT);
|
|
103
|
+
const loose = new Set(LOOSE);
|
|
104
|
+
const ASYMMETRIC = /\bexpect\.(any|anything|stringContaining|stringMatching|objectContaining|arrayContaining|closeTo)\s*\(/g;
|
|
105
|
+
const MATCHER = /\.(to[A-Z][A-Za-z]*)\s*\(/g;
|
|
106
|
+
const TEST_CASE = /^\s*(?:test|it)(?:\.(?:each|skip|only|concurrent|todo|fails|skipIf|runIf))?\s*\(/gm;
|
|
107
|
+
|
|
108
|
+
/* The literal text a matcher's argument list pins down: from the `(` that opens it to the `)` that closes it,
|
|
109
|
+
* every string literal's characters, every regex's source, and every static run of a template. Walked by hand
|
|
110
|
+
* because a matcher's argument is routinely a multi-line object with nested calls, which no single regex can
|
|
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.
|
|
125
|
+
const pastInterpolation = (source, from) => {
|
|
126
|
+
let braces = 1;
|
|
127
|
+
let i = from + 2;
|
|
128
|
+
for (; i < source.length && braces > 0; i += 1) {
|
|
129
|
+
braces += source[i] === "{" ? 1 : 0;
|
|
130
|
+
braces -= source[i] === "}" ? 1 : 0;
|
|
131
|
+
}
|
|
132
|
+
return i;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// A template literal, from its opening backtick: the characters of its static runs, and where it ends.
|
|
136
|
+
const templateChars = (source, from) => {
|
|
137
|
+
let chars = 0;
|
|
138
|
+
let run = from + 1;
|
|
139
|
+
let i = run;
|
|
140
|
+
while (i < source.length && source[i] !== "`") {
|
|
141
|
+
if (source[i] === "\\") {
|
|
142
|
+
i += 2;
|
|
143
|
+
} else if (source[i] === "$" && source[i + 1] === "{") {
|
|
144
|
+
chars += i - run;
|
|
145
|
+
i = pastInterpolation(source, i);
|
|
146
|
+
run = i;
|
|
147
|
+
} else {
|
|
148
|
+
i += 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { chars: chars + Math.min(i, source.length) - run, end: i };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const assertedChars = (source, from) => {
|
|
155
|
+
let depth = 0;
|
|
156
|
+
let chars = 0;
|
|
157
|
+
for (let i = from; i < source.length; i += 1) {
|
|
158
|
+
const ch = source[i];
|
|
159
|
+
if (ch === "/" && source[i + 1] === "/") {
|
|
160
|
+
const end = source.indexOf("\n", i);
|
|
161
|
+
if (end === -1) {
|
|
162
|
+
return chars;
|
|
163
|
+
}
|
|
164
|
+
i = end;
|
|
165
|
+
} else if (ch === "/" && source[i + 1] === "*") {
|
|
166
|
+
const end = source.indexOf("*/", i + 2);
|
|
167
|
+
if (end === -1) {
|
|
168
|
+
return chars;
|
|
169
|
+
}
|
|
170
|
+
i = end + 1;
|
|
171
|
+
} else if (ch === "(") {
|
|
172
|
+
depth += 1;
|
|
173
|
+
} else if (ch === ")") {
|
|
174
|
+
depth -= 1;
|
|
175
|
+
if (depth === 0) {
|
|
176
|
+
return chars;
|
|
177
|
+
}
|
|
178
|
+
} else if (ch === '"' || ch === "'") {
|
|
179
|
+
const quote = ch;
|
|
180
|
+
let j = i + 1;
|
|
181
|
+
for (; j < source.length && source[j] !== quote; j += 1) {
|
|
182
|
+
if (source[j] === "\\") {
|
|
183
|
+
j += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
chars += j - i - 1;
|
|
187
|
+
i = j;
|
|
188
|
+
} else if (ch === "`") {
|
|
189
|
+
const template = templateChars(source, i);
|
|
190
|
+
chars += template.chars;
|
|
191
|
+
i = template.end;
|
|
192
|
+
} else if (ch === "/" && /[(,\s=]/.test(source[i - 1] ?? "(")) {
|
|
193
|
+
// A regex literal in argument position: its source is asserted text like a string's.
|
|
194
|
+
let j = i + 1;
|
|
195
|
+
for (; j < source.length && source[j] !== "/" && source[j] !== "\n"; j += 1) {
|
|
196
|
+
if (source[j] === "\\") {
|
|
197
|
+
j += 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
chars += j - i - 1;
|
|
201
|
+
i = j;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return chars;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// The three numbers, and the test count that tells a narrowing from a deletion.
|
|
208
|
+
export const measure = (source) => {
|
|
209
|
+
let exactCount = 0;
|
|
210
|
+
let looseCount = 0;
|
|
211
|
+
let chars = 0;
|
|
212
|
+
for (const match of source.matchAll(MATCHER)) {
|
|
213
|
+
const name = match[1];
|
|
214
|
+
if (exact.has(name)) {
|
|
215
|
+
exactCount += 1;
|
|
216
|
+
} else if (loose.has(name)) {
|
|
217
|
+
looseCount += 1;
|
|
218
|
+
}
|
|
219
|
+
chars += assertedChars(source, match.index + match[0].length - 1);
|
|
220
|
+
}
|
|
221
|
+
looseCount += [...source.matchAll(ASYMMETRIC)].length;
|
|
222
|
+
const tests = [...source.matchAll(TEST_CASE)].length;
|
|
223
|
+
return { exact: exactCount, loose: looseCount, chars, tests };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Weaker, in either of the two shapes the header names. `before` absent (a new file) can only be stronger.
|
|
227
|
+
export const weakened = (before, after) => {
|
|
228
|
+
if (before === undefined) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
// The third clause is the scale the first two have none of: see the header. A file that pins MORE text than
|
|
232
|
+
// it did is not making the `toEqual` → `toMatchObject` move, whichever way its matcher counts went.
|
|
233
|
+
if (after.exact < before.exact && after.loose > before.loose && after.chars <= before.chars) {
|
|
234
|
+
return "downgrade";
|
|
235
|
+
}
|
|
236
|
+
if (before.chars > 0 && after.chars < before.chars * NARROWING && after.tests >= before.tests) {
|
|
237
|
+
return "narrowing";
|
|
238
|
+
}
|
|
239
|
+
return undefined;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// One line per weakened file, the numbers a reader needs to judge the heuristic for themselves.
|
|
243
|
+
export const describeWeakening = (path, shape, before, after) =>
|
|
244
|
+
`${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,3 @@
|
|
|
1
|
+
// Types for contract-shrink.mjs, the wire-contract shrink comparison the checkout gate and the daemon share.
|
|
2
|
+
export function shrunkSurfaces(base: unknown, head: unknown, at?: string, out?: string[], named?: boolean): string[];
|
|
3
|
+
export function lockShrinkage(baseText: string, headText: string): string[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* WHAT A WIRE-CONTRACT LOCK LOST, as one comparison shared by everything that asks it.
|
|
2
|
+
*
|
|
3
|
+
* contract.lock.json is the sandbox-contract package's exported schemas as one comparable document
|
|
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). */
|
|
24
|
+
|
|
25
|
+
// The JSON Schema keywords whose value is a map of NAME to schema: inside one a key is a field the wire
|
|
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`.
|
|
28
|
+
const NAME_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
|
|
29
|
+
|
|
30
|
+
const dotted = (at, key) => (at === "" ? key : `${at}.${key}`);
|
|
31
|
+
|
|
32
|
+
// Every surface `base` offers that `head` no longer does, as dotted paths.
|
|
33
|
+
export const shrunkSurfaces = (base, head, at = "", out = [], named = true) => {
|
|
34
|
+
if (Array.isArray(base) || Array.isArray(head)) {
|
|
35
|
+
if (!Array.isArray(base) || !Array.isArray(head)) {
|
|
36
|
+
out.push(at);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
for (const [index, item] of base.entries()) {
|
|
40
|
+
const itemAt = typeof item === "object" && item !== null ? `${at}[${index}]` : `${at} ${JSON.stringify(item)}`;
|
|
41
|
+
const offered = head.some((candidate) => shrunkSurfaces(item, candidate, itemAt, [], false).length === 0);
|
|
42
|
+
if (!offered) {
|
|
43
|
+
out.push(itemAt);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
if (typeof base !== "object" || base === null || typeof head !== "object" || head === null) {
|
|
49
|
+
if (JSON.stringify(base) !== JSON.stringify(head)) {
|
|
50
|
+
out.push(at);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
for (const key of Object.keys(base)) {
|
|
55
|
+
if (!named && key === "description") {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (key in head) {
|
|
59
|
+
shrunkSurfaces(base[key], head[key], dotted(at, key), out, !named && NAME_MAPS.has(key));
|
|
60
|
+
} else {
|
|
61
|
+
out.push(dotted(at, key));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// The same comparison over the two texts of a lock file. Either side failing to parse yields NO shrink rather
|
|
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.
|
|
70
|
+
export const lockShrinkage = (baseText, headText) => {
|
|
71
|
+
try {
|
|
72
|
+
return shrunkSurfaces(JSON.parse(baseText), JSON.parse(headText));
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Types for control-bytes.mjs, the byte-level text invariant every control-character reader shares.
|
|
2
|
+
export const BINARY_EXTENSIONS: ReadonlySet<string>;
|
|
3
|
+
export function isBinaryPath(path: string): boolean;
|
|
4
|
+
export function isForbiddenByte(byte: number): boolean;
|
|
5
|
+
export function firstForbiddenByte(bytes: Uint8Array): { readonly offset: number; readonly line: number; readonly column: number; readonly byte: number } | undefined;
|
|
6
|
+
export function byteName(byte: number): string;
|
|
7
|
+
export function escapeFor(byte: number): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* THE BYTES THAT MAKE A TEXT FILE BINARY, named once for everything that polices them: the checkout gate
|
|
2
|
+
* (_tools/checks/control-chars.mjs, every tracked file), and the daemon's per-edit reader (the `bytes-edit`
|
|
3
|
+
* rule's script), which asks the same question of one file the moment it is written.
|
|
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. */
|
|
10
|
+
|
|
11
|
+
// What is allowed to hold arbitrary bytes. Extensions, not paths: an image is an image wherever it lands.
|
|
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.
|
|
14
|
+
export const BINARY_EXTENSIONS = new Set([
|
|
15
|
+
"png",
|
|
16
|
+
"jpg",
|
|
17
|
+
"jpeg",
|
|
18
|
+
"gif",
|
|
19
|
+
"webp",
|
|
20
|
+
"avif",
|
|
21
|
+
"ico",
|
|
22
|
+
"icns",
|
|
23
|
+
"pdf",
|
|
24
|
+
"woff",
|
|
25
|
+
"woff2",
|
|
26
|
+
"ttf",
|
|
27
|
+
"otf",
|
|
28
|
+
"eot",
|
|
29
|
+
"zip",
|
|
30
|
+
"gz",
|
|
31
|
+
"tgz",
|
|
32
|
+
"br",
|
|
33
|
+
"wasm",
|
|
34
|
+
"mp4",
|
|
35
|
+
"webm",
|
|
36
|
+
"mp3",
|
|
37
|
+
"wav",
|
|
38
|
+
"bin",
|
|
39
|
+
"node",
|
|
40
|
+
"keystore",
|
|
41
|
+
"jks",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export const isBinaryPath = (path) => BINARY_EXTENSIONS.has(path.split(".").pop()?.toLowerCase() ?? "");
|
|
45
|
+
|
|
46
|
+
// C0 controls minus the three every text file legitimately contains (tab, newline, carriage return). DEL rides
|
|
47
|
+
// along: it is as invisible as the rest and has no business in source either.
|
|
48
|
+
export const isForbiddenByte = (byte) => byte <= 0x08 || byte === 0x0b || byte === 0x0c || (byte >= 0x0e && byte <= 0x1f) || byte === 0x7f;
|
|
49
|
+
|
|
50
|
+
// The first forbidden byte in a buffer as `{ offset, line, column, byte }`, or undefined for a clean file.
|
|
51
|
+
// One report per file is enough to send someone to it. Sliced only for a byte already known to be bad, so
|
|
52
|
+
// the clean file, the overwhelmingly common case, pays for one linear scan and nothing else.
|
|
53
|
+
export const firstForbiddenByte = (bytes) => {
|
|
54
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
55
|
+
if (isForbiddenByte(bytes[at])) {
|
|
56
|
+
const upto = bytes.subarray(0, at).toString("utf8");
|
|
57
|
+
return { offset: at, line: upto.split("\n").length, column: upto.length - upto.lastIndexOf("\n"), byte: bytes[at] };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// How a byte is named in a report, and how it should be spelled in the file instead.
|
|
64
|
+
export const byteName = (byte) => (byte === 0x00 ? "NUL" : `0x${byte.toString(16).padStart(2, "0")}`);
|
|
65
|
+
export const escapeFor = (byte) => `\\u${byte.toString(16).padStart(4, "0")}`;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/* THE DIRECTORIES AN ISOLATED TURN MOUNTS OVER, and the one thing the main checkout must never do to them.
|
|
2
|
+
*
|
|
3
|
+
* A worktree holds TRACKED files only, so the two trees a package's dependents resolve THROUGH — its installed
|
|
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/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`. */
|
|
45
|
+
|
|
46
|
+
// The directory NAMES a turn overlays, discovered by name wherever they appear in the tree (isolation.ts walks
|
|
47
|
+
// for them). Caches are deliberately absent, and that absence is load-bearing: see the MIRRORED_DIRS comment in
|
|
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"]);
|
|
52
|
+
|
|
53
|
+
// A path's last segment, with quotes and trailing slashes taken off. `"$PKG/dist"` and `./generated/` both name
|
|
54
|
+
// a mirror root; `node_modules/.pnpm/onnxruntime-web@*` does not, and neither does `dist/*`, which removes the
|
|
55
|
+
// CONTENTS and leaves the inode alone.
|
|
56
|
+
const lastSegment = (token) => {
|
|
57
|
+
const bare = token.replace(/^['"]|['"]$/g, "").replace(/\/+$/, "");
|
|
58
|
+
return bare.slice(bare.lastIndexOf("/") + 1);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/* One shell word at a time, quotes kept so `lastSegment` can strip them and a quoted `'{}'` still reads as the
|
|
62
|
+
* find placeholder it is. Not a shell parser and not trying to be: what this has to recognize is a removal
|
|
63
|
+
* someone WROTE, and every one of those in this repository is a plain sequence of words. */
|
|
64
|
+
const tokenize = (segment) => segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
65
|
+
|
|
66
|
+
/* The verbs that can remove a directory, and the words that may stand in front of one. `docker rm -f <name>`
|
|
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.) */
|
|
71
|
+
const REMOVERS = new Set(["rm", "rmdir", "rimraf"]);
|
|
72
|
+
const RUNNERS = new Set(["exec", "-exec", "-execdir", "sudo", "xargs", "then", "do", "else", "{", "(", "npx", "pnpm", "bunx", "yarn"]);
|
|
73
|
+
// `-rf`, `-fr`, `-Rf`, `-r`, `--recursive`. A non-recursive `rm` cannot take a directory at all, so it can
|
|
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.)
|
|
76
|
+
const RECURSIVE = /^(?:--recursive$|-[a-zA-Z]*[rR])/;
|
|
77
|
+
// Where a `find -exec` command ends. Everything after it belongs to the find again.
|
|
78
|
+
const EXEC_END = new Set([";", "\\;", "+"]);
|
|
79
|
+
const PLACEHOLDER = /^['"]?\{\}['"]?$/;
|
|
80
|
+
// The find predicates that NAME what will be removed, and the one that makes a find safe: `-mindepth 1` never
|
|
81
|
+
// yields the directory it started from, which is precisely how you empty a tree without replacing its root.
|
|
82
|
+
const NAME_PREDICATES = new Set(["-name", "-iname", "-path", "-wholename", "-ipath"]);
|
|
83
|
+
|
|
84
|
+
/* WHICH MIRROR ROOTS A SHELL COMMAND WOULD REPLACE, as the operands were written, so a report can quote them.
|
|
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. */
|
|
100
|
+
export const replacedMirrorRoots = (command) => {
|
|
101
|
+
const found = [];
|
|
102
|
+
for (const segment of command.split(/\|\||&&|[;|\n]/)) {
|
|
103
|
+
const tokens = tokenize(segment);
|
|
104
|
+
// `-mindepth 1` (or deeper) makes every removal in this command an emptying rather than a replacement.
|
|
105
|
+
const shallowest = tokens.indexOf("-mindepth");
|
|
106
|
+
if (shallowest !== -1 && Number(tokens[shallowest + 1]) >= 1) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
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` anywhere in the line to notice.
|
|
111
|
+
if (tokens.includes("-delete")) {
|
|
112
|
+
found.push(...named.filter((token) => MIRRORED_DIRS.has(lastSegment(token))));
|
|
113
|
+
}
|
|
114
|
+
for (const [at, token] of tokens.entries()) {
|
|
115
|
+
const before = tokens[at - 1];
|
|
116
|
+
if (!REMOVERS.has(token) || (before !== undefined && !RUNNERS.has(before))) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const operands = [];
|
|
120
|
+
let recursive = token !== "rm";
|
|
121
|
+
for (const word of tokens.slice(at + 1)) {
|
|
122
|
+
if (EXEC_END.has(word)) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (word.startsWith("-")) {
|
|
126
|
+
recursive ||= RECURSIVE.test(word);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
operands.push(word);
|
|
130
|
+
}
|
|
131
|
+
if (!recursive) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
for (const operand of operands) {
|
|
135
|
+
// A `{}` is the find's placeholder: what it stands for is whatever the predicates named.
|
|
136
|
+
const targets = PLACEHOLDER.test(operand) ? named : [operand];
|
|
137
|
+
found.push(...targets.filter((target) => MIRRORED_DIRS.has(lastSegment(target))));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [...new Set(found)];
|
|
142
|
+
};
|
package/src/node.mjs
CHANGED
|
@@ -14,15 +14,15 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
* packages, or be symlinked in, and still get the same answer.
|
|
15
15
|
*
|
|
16
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. `
|
|
18
|
-
* that performs the build, and the byte check and the path guard both run ahead of it in `pnpm
|
|
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
19
|
* helper those three had to import from `dist/` would be a helper they could not import on a clean checkout,
|
|
20
20
|
* which is exactly how the second copy of this walk gets written. Plain .mjs with a hand-written .d.mts beside
|
|
21
21
|
* it is importable at every point in the build, so there only has to be one.
|
|
22
22
|
*
|
|
23
23
|
* A caller that runs before `pnpm install` has one more constraint: `@intentic/constants/node` is a BARE
|
|
24
24
|
* specifier and bare specifiers resolve through node_modules, which a bare checkout has none of. Those callers
|
|
25
|
-
* (`
|
|
25
|
+
* (`_tools/checks/*.mjs`, run by the pre-push hook and by CI's preflight job) import THIS FILE by relative path
|
|
26
26
|
* instead. Still one walk; only the way in differs.
|
|
27
27
|
*
|
|
28
28
|
* NOT EXPORTED FROM THE PACKAGE INDEX, and that is deliberate: the index is imported by browser code
|