@nanobpm/nano-workforce 0.71.0 → 0.72.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +7 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/openapi.yaml +24 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/package.json +3 -1
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- package/scripts/reconcile-contracts.ts +104 -0
package/openapi.yaml
CHANGED
|
@@ -864,6 +864,30 @@ components:
|
|
|
864
864
|
type: string
|
|
865
865
|
created_at:
|
|
866
866
|
type: string
|
|
867
|
+
contractConflicts:
|
|
868
|
+
type: array
|
|
869
|
+
description: >-
|
|
870
|
+
Near-duplicate contract-DECLARATION conflicts on a `contract` POST (issue #227): a
|
|
871
|
+
synonym, contradiction, or rejected synonym vs. the durable contract registry. Advisory —
|
|
872
|
+
surfaced so the writer reconciles a divergent contract at authoring time; never a lock.
|
|
873
|
+
items:
|
|
874
|
+
type: object
|
|
875
|
+
additionalProperties: false
|
|
876
|
+
required:
|
|
877
|
+
- kind
|
|
878
|
+
- proposedName
|
|
879
|
+
- existingName
|
|
880
|
+
- detail
|
|
881
|
+
properties:
|
|
882
|
+
kind:
|
|
883
|
+
type: string
|
|
884
|
+
enum: [synonym, contradiction, rejected-synonym]
|
|
885
|
+
proposedName:
|
|
886
|
+
type: string
|
|
887
|
+
existingName:
|
|
888
|
+
type: string
|
|
889
|
+
detail:
|
|
890
|
+
type: string
|
|
867
891
|
AbandonStatus:
|
|
868
892
|
type: object
|
|
869
893
|
required:
|
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
// POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
|
|
8
8
|
// on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
|
|
9
9
|
// sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
|
|
10
|
+
// A `contract` POST additionally returns `contractConflicts` (near-duplicate declaration
|
|
11
|
+
// conflicts vs. the durable registry, #227); the field is absent for other kinds, per the
|
|
12
|
+
// OpenAPI schema's optional property.
|
|
10
13
|
|
|
11
14
|
import {
|
|
12
15
|
appendEntry,
|
|
16
|
+
detectContractDeclarationConflicts,
|
|
13
17
|
detectFileClaimConflicts,
|
|
14
|
-
|
|
18
|
+
normalizeAppKind,
|
|
15
19
|
planKeyForToken,
|
|
16
20
|
} from "../app/blackboard.ts";
|
|
17
21
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -28,40 +32,56 @@ export default defineOperation("appendBlackboard", async ({ req, body }, app) =>
|
|
|
28
32
|
const b = body ?? {};
|
|
29
33
|
const text = typeof b.body === "string" ? b.body.trim() : "";
|
|
30
34
|
if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
|
|
31
|
-
const kind =
|
|
35
|
+
const kind = normalizeAppKind(b.kind);
|
|
32
36
|
const files = Array.isArray(b.files) ? b.files.map(String) : [];
|
|
33
37
|
// Normalize once (trim + default to "system") so the value we send to appendEntry matches the
|
|
34
38
|
// value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
|
|
35
39
|
// "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
|
|
36
40
|
// reported as sibling conflicts.
|
|
37
41
|
const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
|
|
42
|
+
// Trim before it becomes the idempotency key: `dedupe_key` backs a unique index (and is now also
|
|
43
|
+
// parsed for the `<category>:<name>` contract ref), so accidental leading/trailing whitespace would
|
|
44
|
+
// otherwise slip past dedupe and create near-identical entries. A blank-after-trim key is no key.
|
|
45
|
+
const dedupe_key = typeof b.dedupe_key === "string" ? b.dedupe_key.trim() || undefined : undefined;
|
|
38
46
|
const res = await appendEntry(app.data, planKey, {
|
|
39
47
|
author_task,
|
|
40
48
|
kind,
|
|
41
49
|
files,
|
|
42
50
|
body: text,
|
|
43
51
|
wave: typeof b.wave === "number" ? b.wave : null,
|
|
44
|
-
dedupe_key
|
|
52
|
+
dedupe_key,
|
|
45
53
|
});
|
|
46
|
-
// Advisory conflict-of-intent
|
|
47
|
-
// the append and filtered to claims strictly before ours (id < res.id),
|
|
48
|
-
// decided by insertion order
|
|
49
|
-
// own just-written row is never reported. Never blocks the append
|
|
54
|
+
// Advisory conflict-of-intent. For a `file-claim`, surface prior sibling claims on the same
|
|
55
|
+
// file(s) — computed AFTER the append and filtered to claims strictly before ours (id < res.id),
|
|
56
|
+
// so first-writer-wins is decided by insertion order (a sibling that raced a claim in between is
|
|
57
|
+
// still caught, and our own just-written row is never reported). Never blocks the append.
|
|
50
58
|
const conflicts = kind === "file-claim"
|
|
51
59
|
? await detectFileClaimConflicts(app.data, planKey, {
|
|
52
60
|
author_task,
|
|
53
61
|
files,
|
|
54
|
-
beforeId:
|
|
62
|
+
beforeId: res.id,
|
|
55
63
|
})
|
|
56
64
|
: [];
|
|
65
|
+
// For a `contract`, surface near-duplicate DECLARATION conflicts (a synonym/contradiction/rejected
|
|
66
|
+
// synonym vs. the durable registry) so a writer reconciles a divergent contract at authoring time
|
|
67
|
+
// (#227). Advisory — the agent decides how to react.
|
|
68
|
+
const contractConflicts = kind === "contract"
|
|
69
|
+
? detectContractDeclarationConflicts({ dedupe_key, body: text })
|
|
70
|
+
: [];
|
|
57
71
|
app.log.info("blackboard entry appended", {
|
|
58
72
|
planKey,
|
|
59
73
|
kind,
|
|
60
74
|
inserted: res.inserted,
|
|
61
75
|
conflicts: conflicts.length,
|
|
76
|
+
contractConflicts: contractConflicts.length,
|
|
62
77
|
});
|
|
63
78
|
return {
|
|
64
79
|
status: res.inserted ? 201 : 200,
|
|
65
|
-
|
|
80
|
+
// `contractConflicts` is only meaningful on a `contract` POST and is optional in the schema, so
|
|
81
|
+
// omit it entirely for other kinds rather than emitting an always-empty array (keeps the response
|
|
82
|
+
// shape aligned with the OpenAPI contract, which does not require the field).
|
|
83
|
+
body: kind === "contract"
|
|
84
|
+
? { id: res.id, inserted: res.inserted, conflicts, contractConflicts }
|
|
85
|
+
: { id: res.id, inserted: res.inserted, conflicts },
|
|
66
86
|
};
|
|
67
87
|
});
|
|
@@ -100,6 +100,18 @@ test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async
|
|
|
100
100
|
assertEquals(n, 1);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("POST dedupe_key is trimmed → a whitespace-padded retry still dedupes to one row (#227)", async () => {
|
|
104
|
+
const { app, db } = memApp();
|
|
105
|
+
await seedPlan(app, "o/r#1", "tok");
|
|
106
|
+
assertEquals((await call(app, "POST", { token: "tok" }, { author_task: "t", body: "claim", dedupe_key: "t:claim:1" })).status, 201);
|
|
107
|
+
// A retry whose key only differs by leading/trailing whitespace must NOT slip past dedupe.
|
|
108
|
+
const padded = await call(app, "POST", { token: "tok" }, { author_task: "t", body: "claim", dedupe_key: " t:claim:1 " });
|
|
109
|
+
assertEquals(padded.status, 200);
|
|
110
|
+
assertEquals(padded.body.inserted, false);
|
|
111
|
+
const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["o/r#1"]);
|
|
112
|
+
assertEquals(n, 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
103
115
|
test("GET ?since returns only newer entries", async () => {
|
|
104
116
|
const { app } = memApp();
|
|
105
117
|
await seedPlan(app, "o/r#1", "tok");
|
|
@@ -175,4 +187,41 @@ test("POST a non-file-claim carries no conflicts", async () => {
|
|
|
175
187
|
await seedPlan(app, "o/r#1", "tok");
|
|
176
188
|
const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
|
|
177
189
|
assertEquals(res.body.conflicts, []);
|
|
190
|
+
// `contractConflicts` is optional in the schema and only meaningful on a `contract` POST — a
|
|
191
|
+
// non-`contract` response omits it entirely rather than emitting an always-empty array (#229).
|
|
192
|
+
assertEquals("contractConflicts" in res.body, false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("POST kind='contract' persists the contract kind and round-trips through GET (#227)", async () => {
|
|
196
|
+
const { app } = memApp();
|
|
197
|
+
await seedPlan(app, "o/r#1", "tok");
|
|
198
|
+
const post = await call(app, "POST", { token: "tok" }, {
|
|
199
|
+
author_task: "task-a",
|
|
200
|
+
kind: "contract",
|
|
201
|
+
dedupe_key: "env:NANO_WIDGET_TIMEOUT",
|
|
202
|
+
body: "introducing env key NANO_WIDGET_TIMEOUT — app/widget.ts — widget request timeout in ms",
|
|
203
|
+
});
|
|
204
|
+
assertEquals(post.status, 201);
|
|
205
|
+
// No existing contract matches, so no declaration conflicts.
|
|
206
|
+
assertEquals(post.body.contractConflicts, []);
|
|
207
|
+
|
|
208
|
+
const get = await call(app, "GET", { token: "tok" });
|
|
209
|
+
assertEquals(get.body.entries.length, 1);
|
|
210
|
+
// The store's normaliser would coerce an unknown kind to 'note'; the adapter restores 'contract'.
|
|
211
|
+
assertEquals(get.body.entries[0].kind, "contract");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("POST kind='contract' reintroducing a rejected synonym surfaces a declaration conflict (#223/#227)", async () => {
|
|
215
|
+
const { app } = memApp();
|
|
216
|
+
await seedPlan(app, "o/r#1", "tok");
|
|
217
|
+
const post = await call(app, "POST", { token: "tok" }, {
|
|
218
|
+
author_task: "task-b",
|
|
219
|
+
kind: "contract",
|
|
220
|
+
dedupe_key: "env:NANO_PR_BASE_URL",
|
|
221
|
+
body: "base url for the app",
|
|
222
|
+
});
|
|
223
|
+
assertEquals(post.status, 201);
|
|
224
|
+
assertEquals(post.body.contractConflicts.length >= 1, true);
|
|
225
|
+
assertEquals(post.body.contractConflicts[0].kind, "rejected-synonym");
|
|
226
|
+
assertEquals(post.body.contractConflicts[0].existingName, "NANO_WORKFORCE_BASE_URL");
|
|
178
227
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"pretypecheck": "urban gen",
|
|
38
38
|
"check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
|
|
39
39
|
"check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
|
|
40
|
+
"check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
|
|
41
|
+
"reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
|
|
40
42
|
"gen": "urban gen",
|
|
41
43
|
"gen:check": "urban gen --check",
|
|
42
44
|
"layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Coverage for the config-key SCAN in the contract gate (scripts/check-contracts.ts, PR #229).
|
|
2
|
+
//
|
|
3
|
+
// The gate is only as strong as the read patterns it recognises. Before this fix it matched only
|
|
4
|
+
// dot-access (`process.env.KEY`), so a config-family key smuggled in through the `envVar("KEY")`
|
|
5
|
+
// helper or string-literal bracket-access silently bypassed the "must be declared" invariant — which
|
|
6
|
+
// is how NANO_PR_WEBHOOK_SECRET / NANO_AGENTIC* stayed undeclared while `check:contracts` passed
|
|
7
|
+
// green. These assert `envKeyReads` sees all three patterns so the gate can hold them to the registry.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assert, assertEquals } from "#test-assert";
|
|
10
|
+
import { envKeyReads, EXPLICIT_CONFIG_KEYS, toPosixRel } from "./check-contracts.ts";
|
|
11
|
+
import { ENV_CONTRACTS } from "../app/contracts.ts";
|
|
12
|
+
|
|
13
|
+
test("envKeyReads: dot-access is recognised", () => {
|
|
14
|
+
assertEquals(envKeyReads("const x = process.env.NANO_PR_POLL_MS;"), ["NANO_PR_POLL_MS"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("envKeyReads: string-literal bracket-access is recognised (double and single quote)", () => {
|
|
18
|
+
assert(envKeyReads('process.env["NANO_PR_WEBHOOK_SECRET"]').includes("NANO_PR_WEBHOOK_SECRET"));
|
|
19
|
+
assert(envKeyReads("process.env['NANO_AGENTIC']").includes("NANO_AGENTIC"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("envKeyReads: the envVar(\"KEY\") helper is recognised", () => {
|
|
23
|
+
assert(envKeyReads('const s = envVar("NANO_AGENTIC_SECRET") ?? "";').includes("NANO_AGENTIC_SECRET"));
|
|
24
|
+
assert(envKeyReads('envVar( "NANO_WORKFORCE_GIT_SHA" )').includes("NANO_WORKFORCE_GIT_SHA"));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("envKeyReads: catches keys across all patterns in one source", () => {
|
|
28
|
+
const src = [
|
|
29
|
+
"const a = process.env.CAMUNDA_TRANSPORT;",
|
|
30
|
+
'const b = process.env["NANOBPMN_BASE_URL"];',
|
|
31
|
+
'const c = envVar("NANO_WORKFORCE_GIT_SHA");',
|
|
32
|
+
].join("\n");
|
|
33
|
+
const keys = new Set(envKeyReads(src));
|
|
34
|
+
assert(keys.has("CAMUNDA_TRANSPORT"));
|
|
35
|
+
assert(keys.has("NANOBPMN_BASE_URL"));
|
|
36
|
+
assert(keys.has("NANO_WORKFORCE_GIT_SHA"));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("envKeyReads: a dynamic (non-literal) read is NOT matched", () => {
|
|
40
|
+
// `process.env[name]` (a variable) can't be resolved statically, so it isn't reported.
|
|
41
|
+
assertEquals(envKeyReads("const v = process.env[name];"), []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("GITHUB_TOKEN is enforced by the gate and declared in the registry (PR #229 suppressed advisory)", () => {
|
|
45
|
+
// GITHUB_TOKEN is read in production outside the config families (app/service.ts,
|
|
46
|
+
// operations/startPlanFanout.ts). Pin it into the enforced set so the gate holds it to its
|
|
47
|
+
// registry declaration — a future removal from ENV_CONTRACTS while code still reads it must fail.
|
|
48
|
+
assert(EXPLICIT_CONFIG_KEYS.has("GITHUB_TOKEN"), "GITHUB_TOKEN must be an enforced config key");
|
|
49
|
+
assert("GITHUB_TOKEN" in ENV_CONTRACTS, "GITHUB_TOKEN must be declared in ENV_CONTRACTS");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("toPosixRel: normalises Windows backslashes so EXEMPT_FILES matches cross-platform (PR #229 suppressed advisory)", () => {
|
|
53
|
+
// On Windows `join` yields backslashes, so `scripts\check-contracts.ts` would never match the
|
|
54
|
+
// POSIX-style EXEMPT_FILES entry and the checker would self-scan and explode. Normalise them.
|
|
55
|
+
assertEquals(toPosixRel("C:\\repo\\scripts\\check-contracts.ts", "C:\\repo"), "scripts/check-contracts.ts");
|
|
56
|
+
// POSIX paths are already correct and pass through unchanged.
|
|
57
|
+
assertEquals(toPosixRel("/repo/scripts/check-contracts.ts", "/repo"), "scripts/check-contracts.ts");
|
|
58
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// check-contracts — CI gate for the shared contract registry (issue #227, ADR 0004).
|
|
2
|
+
//
|
|
3
|
+
// The registry (`app/contracts.ts`) is the single source of truth for cross-cutting contracts. This
|
|
4
|
+
// gate enforces the invariants that make a duplicate/synonymous contract a BUILD failure rather than
|
|
5
|
+
// a silent runtime fallback (the #223 / nano-ide #234 failure mode):
|
|
6
|
+
//
|
|
7
|
+
// 1. EVERY config-family env key read anywhere in the app (`process.env.NANO_*`, `CAMUNDA_*`,
|
|
8
|
+
// `NANOBPMN_BASE_URL`, `PR_REVIEW_PORT`) MUST be declared in the ONE typed schema
|
|
9
|
+
// `ENV_CONTRACTS`. An undeclared config key is a second, unregistered source of truth — fail.
|
|
10
|
+
// The scan recognises all three read patterns the app uses: dot-access (`process.env.KEY`),
|
|
11
|
+
// string-literal bracket-access (`process.env["KEY"]`), and the `envVar("KEY")` helper
|
|
12
|
+
// (`app/version.ts`) — a config key smuggled in through any of them must still be declared.
|
|
13
|
+
// 2. NO rejected synonym (a name we deliberately retired, e.g. `NANO_PR_BASE_URL`) may appear in
|
|
14
|
+
// code. Its reappearance is the #223 phantom-fallback cascade — fail.
|
|
15
|
+
// 3. The registry must reconcile against itself: no two entries are synonyms, no rejected synonym
|
|
16
|
+
// leaked back into the registry (`reconcileRegistry`).
|
|
17
|
+
//
|
|
18
|
+
// Scope note: the env-key scan targets the config family (below), NOT every env var — infra reads
|
|
19
|
+
// like `NODE_ENV` are not app contracts. `GITHUB_TOKEN`/`CAMUNDA_TOKEN` ARE declared (as secrets) so
|
|
20
|
+
// the schema documents them, but they need no fallback default.
|
|
21
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { dirname, extname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { reconcileRegistry } from "../app/contractReconcile.ts";
|
|
25
|
+
import { ENV_CONTRACTS, rejectedEnvSynonyms } from "../app/contracts.ts";
|
|
26
|
+
|
|
27
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
28
|
+
|
|
29
|
+
// The directories that hold app code (mirrors the `lint` glob in package.json).
|
|
30
|
+
const CODE_DIRS = ["app", "operations", "workers", "pages", "components", "scripts", "e2e"];
|
|
31
|
+
const CODE_FILES = ["main.ts"];
|
|
32
|
+
|
|
33
|
+
// The config-family env-key prefixes/names that MUST be declared in ENV_CONTRACTS. Anything matching
|
|
34
|
+
// this and not declared is an unregistered config source of truth.
|
|
35
|
+
//
|
|
36
|
+
// A config key is read one of three ways in this app, and all three must be held to the registry
|
|
37
|
+
// invariant — otherwise a key smuggled in via `envVar("…")` or bracket-access silently bypasses the
|
|
38
|
+
// gate (the exact blind spot that let NANO_PR_WEBHOOK_SECRET / NANO_AGENTIC* go undeclared):
|
|
39
|
+
// - dot-access: process.env.KEY
|
|
40
|
+
// - string-literal bracket: process.env["KEY"] / process.env['KEY']
|
|
41
|
+
// - the envVar() helper: envVar("KEY") (app/version.ts)
|
|
42
|
+
const CONFIG_KEY_MATCHERS: readonly RegExp[] = [
|
|
43
|
+
/\bprocess\.env\.([A-Z][A-Z0-9_]*)\b/g,
|
|
44
|
+
/\bprocess\.env\[\s*["']([A-Z][A-Z0-9_]*)["']\s*\]/g,
|
|
45
|
+
/\benvVar\(\s*["']([A-Z][A-Z0-9_]*)["']\s*\)/g,
|
|
46
|
+
];
|
|
47
|
+
const CONFIG_FAMILY = /^(NANO_|NANOBPMN_|CAMUNDA_|PR_REVIEW_)/;
|
|
48
|
+
// `GITHUB_TOKEN` is the one production credential read outside the config families (app/service.ts,
|
|
49
|
+
// operations/startPlanFanout.ts). It is declared in ENV_CONTRACTS; enforce that declaration here so a
|
|
50
|
+
// future removal from the registry while code still reads it trips the gate (issue #227).
|
|
51
|
+
export const EXPLICIT_CONFIG_KEYS = new Set(["PR_REVIEW_PORT", "GITHUB_TOKEN"]);
|
|
52
|
+
|
|
53
|
+
/** Every env-key name read in `src` via any supported pattern (dot-access, string-literal
|
|
54
|
+
* bracket-access, or the `envVar("KEY")` helper). Order-preserving, with duplicates, so a caller can
|
|
55
|
+
* report each read site's key against the registry. */
|
|
56
|
+
export function envKeyReads(src: string): string[] {
|
|
57
|
+
const keys: string[] = [];
|
|
58
|
+
for (const matcher of CONFIG_KEY_MATCHERS) {
|
|
59
|
+
for (const m of src.matchAll(matcher)) keys.push(m[1]);
|
|
60
|
+
}
|
|
61
|
+
return keys;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The registry module declares the keys and (in comments/strings) names the rejected synonyms, this
|
|
65
|
+
// checker's own doc comment shows the read patterns it scans for, and this checker's test embeds
|
|
66
|
+
// literal `process.env[…]` / `envVar(…)` fixtures — all would self-trip the scan, so they are exempt.
|
|
67
|
+
const EXEMPT_FILES = new Set([
|
|
68
|
+
"app/contracts.ts",
|
|
69
|
+
"scripts/check-contracts.ts",
|
|
70
|
+
"scripts/check-contracts.test.ts",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function walk(dir: string, out: string[]): void {
|
|
74
|
+
for (const entry of readdirSync(dir)) {
|
|
75
|
+
const full = join(dir, entry);
|
|
76
|
+
if (statSync(full).isDirectory()) {
|
|
77
|
+
if (entry === "node_modules" || entry === "nano-generated" || entry.startsWith(".")) continue;
|
|
78
|
+
walk(full, out);
|
|
79
|
+
} else if ([".ts", ".mts", ".cts"].includes(extname(full))) {
|
|
80
|
+
out.push(full);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Path of `file` relative to ROOT, normalised to POSIX separators so it matches the POSIX-style
|
|
86
|
+
* `EXEMPT_FILES` entries on every platform (Windows `join` yields backslashes — issue #229 review). */
|
|
87
|
+
export function toPosixRel(file: string, root: string = ROOT): string {
|
|
88
|
+
return file.slice(root.length + 1).replaceAll("\\", "/");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectFiles(): string[] {
|
|
92
|
+
const files: string[] = [];
|
|
93
|
+
for (const d of CODE_DIRS) {
|
|
94
|
+
const full = join(ROOT, d);
|
|
95
|
+
try {
|
|
96
|
+
if (statSync(full).isDirectory()) walk(full, files);
|
|
97
|
+
} catch {
|
|
98
|
+
/* dir may not exist in every checkout */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const f of CODE_FILES) {
|
|
102
|
+
const full = join(ROOT, f);
|
|
103
|
+
try {
|
|
104
|
+
if (statSync(full).isFile()) files.push(full);
|
|
105
|
+
} catch {
|
|
106
|
+
/* optional */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return files;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function main(): void {
|
|
113
|
+
const declared = new Set(Object.keys(ENV_CONTRACTS));
|
|
114
|
+
const rejected = rejectedEnvSynonyms();
|
|
115
|
+
const errors: string[] = [];
|
|
116
|
+
|
|
117
|
+
for (const file of collectFiles()) {
|
|
118
|
+
const rel = toPosixRel(file);
|
|
119
|
+
if (EXEMPT_FILES.has(rel)) continue;
|
|
120
|
+
const src = readFileSync(file, "utf8");
|
|
121
|
+
for (const key of envKeyReads(src)) {
|
|
122
|
+
if (rejected.has(key)) {
|
|
123
|
+
errors.push(
|
|
124
|
+
` ${rel}: reads retired synonym '${key}' — reuse the canonical key '${rejected.get(key)}'. ` +
|
|
125
|
+
`A retired synonym must never come back as a silent fallback (issue #223).`,
|
|
126
|
+
);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!CONFIG_FAMILY.test(key) && !EXPLICIT_CONFIG_KEYS.has(key)) continue;
|
|
130
|
+
if (!declared.has(key)) {
|
|
131
|
+
errors.push(
|
|
132
|
+
` ${rel}: reads config env key '${key}' that is NOT declared in ENV_CONTRACTS ` +
|
|
133
|
+
`(app/contracts.ts). Declare it in the ONE typed schema so it can't become a synonym.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const finding of reconcileRegistry()) {
|
|
140
|
+
errors.push(` registry: [${finding.kind}] ${finding.detail}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (errors.length > 0) {
|
|
144
|
+
console.error(`check-contracts: contract-registry violations:\n${errors.join("\n")}`);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(`check-contracts: OK (${declared.size} declared env keys, registry reconciles clean).`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (import.meta.main) main();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Coverage for the advisory-only reconcile pass's URL decoding (scripts/reconcile-contracts.ts, PR
|
|
2
|
+
// #229). The pass is designed to NEVER fail the build. `fileUrlToPath` decodes NANO_APP_DB_URL, and a
|
|
3
|
+
// literal `%` (or any malformed %-escape) makes `decodeURIComponent` throw — which would crash an
|
|
4
|
+
// "advisory, never gate CI" script. Decoding is now best-effort, so a malformed URL degrades to the
|
|
5
|
+
// raw path instead of throwing.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { fileUrlToPath } from "./reconcile-contracts.ts";
|
|
9
|
+
|
|
10
|
+
test("fileUrlToPath: decodes a well-formed percent-escape", () => {
|
|
11
|
+
assertEquals(fileUrlToPath("file:./my%20app.db"), "./my app.db");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("fileUrlToPath: a malformed percent-escape does not throw (best-effort)", () => {
|
|
15
|
+
// A bare `%` is not a valid escape; decodeURIComponent would throw. The pass must not crash — a
|
|
16
|
+
// successful call that returns the raw path proves the decode is best-effort.
|
|
17
|
+
assertEquals(fileUrlToPath("file:./100%done.db"), "./100%done.db");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("fileUrlToPath: a non-file URL yields undefined", () => {
|
|
21
|
+
assertEquals(fileUrlToPath("postgres://localhost/db"), undefined);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("fileUrlToPath: a malformed file:// URL yields undefined, never throws (PR #229)", () => {
|
|
25
|
+
// `new URL("file://…")` throws on a malformed authority/host. The advisory pass must degrade to
|
|
26
|
+
// undefined (best-effort: file simply not found) rather than crash a "never gate CI" script.
|
|
27
|
+
assertEquals(fileUrlToPath("file://%zz/bad host/db.sqlite"), undefined);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("fileUrlToPath: strips a query/hash suffix on an opaque file: URL (PR #229)", () => {
|
|
31
|
+
// A valid SQLite-style URL carries connection params (`?mode=ro`). The opaque `file:` branch must
|
|
32
|
+
// drop the `?…`/`#…` suffix so the path resolves to a real on-disk file, else reconciliation is
|
|
33
|
+
// silently disabled by a path that never exists.
|
|
34
|
+
assertEquals(fileUrlToPath("file:./app.db?mode=ro"), "./app.db");
|
|
35
|
+
assertEquals(fileUrlToPath("file:./app.db#frag"), "./app.db");
|
|
36
|
+
assertEquals(fileUrlToPath("file:./app.db?mode=ro&cache=shared#x"), "./app.db");
|
|
37
|
+
});
|
|
38
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// npm run reconcile:contracts — the contract reconciliation pass runner (issue #227, ADR 0004).
|
|
2
|
+
//
|
|
3
|
+
// A sibling to the L2 retro: read the whole blackboard (`agentic_blackboard`) plus the durable
|
|
4
|
+
// contract registry (`app/contracts.ts`) and report synonyms / contradictions / mock-vs-real skew.
|
|
5
|
+
// Advisory by design — it PRINTS a report (an escalation / merge candidate) and exits 0, so it can
|
|
6
|
+
// run periodically without gating CI. The registry-only, mechanically-enforceable half is the hard
|
|
7
|
+
// gate `scripts/check-contracts.ts`; this pass adds the blackboard-vs-registry view.
|
|
8
|
+
//
|
|
9
|
+
// It reads the app sqlite datasource (NANO_APP_DB_URL, default `file:./app.db`) directly through
|
|
10
|
+
// node:sqlite. If the datasource or the blackboard table is absent, it still runs the static
|
|
11
|
+
// registry reconciliation.
|
|
12
|
+
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { DatabaseSync } from "node:sqlite";
|
|
15
|
+
import { BLACKBOARD_TABLE } from "@nanobpm/agentic/blackboard";
|
|
16
|
+
import {
|
|
17
|
+
type ContractSignal,
|
|
18
|
+
formatReconciliationReport,
|
|
19
|
+
reconcileContracts,
|
|
20
|
+
} from "../app/contractReconcile.ts";
|
|
21
|
+
import type { ContractCategory } from "../app/contracts.ts";
|
|
22
|
+
|
|
23
|
+
function safeDecodeURIComponent(s: string): string {
|
|
24
|
+
try {
|
|
25
|
+
return decodeURIComponent(s);
|
|
26
|
+
} catch {
|
|
27
|
+
// Advisory-only pass: a malformed %-escape in NANO_APP_DB_URL must never fail the build. Fall
|
|
28
|
+
// back to the raw segment so reconciliation still runs (worst case: the file simply isn't found).
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function fileUrlToPath(u: string): string | undefined {
|
|
34
|
+
if (!u.startsWith("file:")) return undefined;
|
|
35
|
+
if (u.startsWith("file://")) {
|
|
36
|
+
let pathname: string;
|
|
37
|
+
try {
|
|
38
|
+
pathname = new URL(u).pathname;
|
|
39
|
+
} catch {
|
|
40
|
+
// Advisory-only pass: a malformed file:// URL must never fail the build. Give up on this
|
|
41
|
+
// input (worst case the file simply isn't found) rather than throwing out of reconciliation.
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const p = safeDecodeURIComponent(pathname);
|
|
45
|
+
return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
|
|
46
|
+
}
|
|
47
|
+
// Opaque `file:` form (no authority) — the `new URL().pathname` branch above already drops any
|
|
48
|
+
// query/hash, but here we hold the raw remainder, so strip a `?…`/`#…` suffix ourselves. A valid
|
|
49
|
+
// SQLite-style URL like `file:./app.db?mode=ro` must resolve to `./app.db`, not `./app.db?mode=ro`
|
|
50
|
+
// (which never exists on disk and would silently disable blackboard reconciliation).
|
|
51
|
+
const raw = u.slice("file:".length).replace(/[?#].*$/, "");
|
|
52
|
+
const p = safeDecodeURIComponent(raw);
|
|
53
|
+
return /^\/[A-Za-z]:/.test(p) ? p.slice(1) : p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseRef(dedupeKey: string | null): { category?: ContractCategory; name?: string } {
|
|
57
|
+
if (!dedupeKey) return {};
|
|
58
|
+
const idx = dedupeKey.indexOf(":");
|
|
59
|
+
if (idx <= 0) return {};
|
|
60
|
+
const category = dedupeKey.slice(0, idx).trim();
|
|
61
|
+
const name = dedupeKey.slice(idx + 1).trim();
|
|
62
|
+
if (!name) return {};
|
|
63
|
+
if (category === "env" || category === "wire" || category === "type" || category === "capability-url") {
|
|
64
|
+
return { category, name };
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadSignals(): ContractSignal[] {
|
|
70
|
+
const url = process.env.NANO_APP_DB_URL ?? "file:./app.db";
|
|
71
|
+
const path = fileUrlToPath(url);
|
|
72
|
+
if (!path || !existsSync(path)) return [];
|
|
73
|
+
let db: DatabaseSync;
|
|
74
|
+
try {
|
|
75
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
76
|
+
} catch {
|
|
77
|
+
// Advisory-only pass: opening the db can still fail (permission, invalid/corrupt file) even
|
|
78
|
+
// though the path exists. Degrade to no signals — reconcile the registry alone — rather than
|
|
79
|
+
// crash a script that must never gate CI.
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
// biome-ignore lint/plugin: external node:sqlite row shape at the DB boundary — the column list is fixed by the SELECT above.
|
|
84
|
+
const rows = db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT author_task, body, dedupe_key FROM ${BLACKBOARD_TABLE} WHERE kind = 'contract' ORDER BY id ASC`,
|
|
87
|
+
)
|
|
88
|
+
.all() as { author_task: string; body: string; dedupe_key: string | null }[];
|
|
89
|
+
return rows.map((r) => ({ authorTask: r.author_task, body: r.body, ...parseRef(r.dedupe_key) }));
|
|
90
|
+
} catch {
|
|
91
|
+
// No blackboard table (bare/unmigrated db) — reconcile the registry alone.
|
|
92
|
+
return [];
|
|
93
|
+
} finally {
|
|
94
|
+
db.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function main(): void {
|
|
99
|
+
const report = reconcileContracts(loadSignals());
|
|
100
|
+
console.log(formatReconciliationReport(report));
|
|
101
|
+
// Advisory: never fail the build. The hard, registry-only gate is `npm run check:contracts`.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (import.meta.main) main();
|