rcf-lite 0.25.0 → 0.26.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/CHANGELOG.md +25 -0
- package/blueprints/application-account-settings/docs/topics.md +1 -1
- package/blueprints/application-admin-console/docs/topics.md +1 -1
- package/blueprints/application-api-rest/docs/topics.md +1 -1
- package/blueprints/application-charts/docs/topics.md +1 -1
- package/blueprints/application-dashboard/docs/topics.md +1 -1
- package/blueprints/application-datatable/docs/topics.md +1 -1
- package/blueprints/application-empty-error-states/docs/topics.md +1 -1
- package/blueprints/application-error-handling/docs/topics.md +1 -1
- package/blueprints/application-file-upload/docs/topics.md +1 -1
- package/blueprints/application-forms-wizard/docs/topics.md +1 -1
- package/blueprints/application-notifications-in-app/docs/topics.md +1 -1
- package/blueprints/application-onboarding-tour/docs/topics.md +1 -1
- package/blueprints/application-spa/docs/topics.md +1 -1
- package/blueprints/delivery-ci-workflows/docs/topics.md +1 -1
- package/blueprints/deploy-cloudflare-workers/docs/topics.md +1 -1
- package/blueprints/deploy-hetzner-server/CHANGELOG.md +21 -0
- package/blueprints/deploy-hetzner-server/blueprint.json +1 -1
- package/blueprints/deploy-hetzner-server/contributions/probes/cloud-init-render-lint.mjs +12 -5
- package/blueprints/deploy-hetzner-server/contributions/probes/hcloud-dry-run-mock.mjs +68 -20
- package/blueprints/deploy-hetzner-server/contributions/probes/manifest-schema-validate.mjs +8 -6
- package/blueprints/deploy-hetzner-server/contributions/probes/probe-utils.mjs +7 -1
- package/blueprints/deploy-hetzner-server/contributions/probes/real-account-cloud-init-hardened.mjs +2 -1
- package/blueprints/deploy-hetzner-server/contributions/templates/cloud-init.yaml.tmpl +26 -6
- package/blueprints/deploy-hetzner-server/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-access/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-rate-limiting/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-tunnel/CHANGELOG.md +10 -0
- package/blueprints/edge-cloudflare-tunnel/blueprint.json +1 -1
- package/blueprints/edge-cloudflare-tunnel/contributions/probes/manifest-schema-validate.mjs +4 -1
- package/blueprints/edge-cloudflare-tunnel/contributions/probes/real-account-connector-healthy.mjs +110 -21
- package/blueprints/edge-cloudflare-tunnel/contributions/probes/real-account-tunnel-hostname-routes.mjs +123 -30
- package/blueprints/edge-cloudflare-tunnel/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-turnstile/docs/topics.md +1 -1
- package/blueprints/email-smtp-resend/docs/topics.md +1 -1
- package/blueprints/jobs-background/docs/topics.md +1 -1
- package/blueprints/messaging-queue-cloudflare/CHANGELOG.md +19 -0
- package/blueprints/messaging-queue-cloudflare/README.md +2 -2
- package/blueprints/messaging-queue-cloudflare/blueprint.json +1 -1
- package/blueprints/messaging-queue-cloudflare/contributions/probes/real-account-concurrency-smoke.mjs +192 -37
- package/blueprints/messaging-queue-cloudflare/docs/topics.md +1 -1
- package/blueprints/object-storage-s3/CHANGELOG.md +11 -0
- package/blueprints/object-storage-s3/README.md +14 -1
- package/blueprints/object-storage-s3/blueprint.json +12 -1
- package/blueprints/object-storage-s3/contributions/adrs/adr-2905-object-storage-s3-hetzner-object-storage-provider.json +30 -0
- package/blueprints/object-storage-s3/contributions/probes/hetzner-object-storage-round-trip.mjs +136 -0
- package/blueprints/object-storage-s3/contributions/probes/run-hetzner-object-storage-round-trip.mjs +11 -0
- package/blueprints/object-storage-s3/contributions/requirements/object-storage-s3-req-101.json +18 -0
- package/blueprints/object-storage-s3/contributions/tacs/tac-2904-object-storage-s3-hetzner-endpoint-helper.json +34 -0
- package/blueprints/object-storage-s3/contributions/user-stories/object-storage-s3-us-28110.json +25 -0
- package/blueprints/object-storage-s3/docs/topics.md +1 -1
- package/blueprints/object-storage-s3/guide/object-storage-s3.md +36 -0
- package/blueprints/observability-essentials/docs/topics.md +1 -1
- package/blueprints/observability-logging/docs/topics.md +1 -1
- package/blueprints/observability-probe-endpoints/docs/topics.md +1 -1
- package/blueprints/persistence-data-d1/docs/topics.md +1 -1
- package/blueprints/persistence-data-postgres/docs/topics.md +1 -1
- package/blueprints/persistence-data-sqlite/docs/topics.md +1 -1
- package/blueprints/platform-cloudflare-cron-triggers/docs/topics.md +1 -1
- package/blueprints/platform-cloudflare-durable-objects/CHANGELOG.md +15 -0
- package/blueprints/platform-cloudflare-durable-objects/blueprint.json +1 -1
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/namespace-facade-ready.mjs +3 -3
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/probe-utils.mjs +28 -14
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/real-account-storage-smoke.mjs +132 -26
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/sole-reader-scan.mjs +40 -39
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/storage-round-trip.mjs +26 -16
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/websocket-hub-broadcast.mjs +31 -34
- package/blueprints/platform-cloudflare-durable-objects/docs/topics.md +1 -1
- package/blueprints/platform-cloudflare-kv/CHANGELOG.md +40 -1
- package/blueprints/platform-cloudflare-kv/README.md +41 -13
- package/blueprints/platform-cloudflare-kv/blueprint.json +1 -1
- package/blueprints/platform-cloudflare-kv/contributions/probes/cache-aside-hit-then-miss.mjs +26 -26
- package/blueprints/platform-cloudflare-kv/contributions/probes/event-secrecy.mjs +49 -31
- package/blueprints/platform-cloudflare-kv/contributions/probes/facade-round-trip.mjs +58 -16
- package/blueprints/platform-cloudflare-kv/contributions/probes/list-with-prefix.mjs +6 -5
- package/blueprints/platform-cloudflare-kv/contributions/probes/real-account-eventual-consistency-smoke.mjs +131 -67
- package/blueprints/platform-cloudflare-kv/docs/topics.md +1 -1
- package/blueprints/platform-docker-compose-host/docs/topics.md +1 -1
- package/blueprints/security-auth-clerk/docs/topics.md +1 -1
- package/blueprints/security-auth-keycloak/docs/topics.md +1 -1
- package/blueprints/security-auth-magic-link/docs/topics.md +1 -1
- package/blueprints/security-auth-oauth2/docs/topics.md +1 -1
- package/blueprints/security-secrets-management/docs/topics.md +1 -1
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/harness-template.md +67 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +67 -0
- package/package.json +2 -2
- package/rcf/code-nodes/cn-500.json +12 -0
- package/rcf/code-nodes/cn-510.json +12 -0
- package/rcf/code-nodes/cn-520.json +14 -0
- package/rcf/code-nodes/cn-521.json +12 -0
- package/rcf/code-nodes/cn-522.json +13 -0
- package/rcf/code-nodes/cn-523.json +12 -0
- package/rcf/code-nodes/cn-524.json +14 -0
- package/rcf/code-nodes/cn-525.json +12 -0
- package/rcf/code-nodes/cn-526.json +14 -0
- package/rcf/fbs/fbs-160.json +15 -0
- package/rcf/fbs/fbs-165.json +15 -0
- package/rcf/fbs/fbs-170.json +16 -0
- package/rcf/fbs/fbs-171.json +16 -0
- package/rcf/fbs/fbs-172.json +15 -0
- package/rcf/fbs/fbs-173.json +17 -0
- package/rcf/fbs/fbs-174.json +17 -0
- package/rcf/requirements/req-140.json +149 -0
- package/rcf/requirements/req-145.json +53 -0
- package/rcf/requirements/req-150.json +20 -0
- package/rcf/requirements/req-151.json +85 -0
- package/rcf/requirements/req-152.json +20 -0
- package/rcf/requirements/req-153.json +59 -0
- package/rcf/requirements/req-154.json +44 -0
- package/rcf/test-suites/ts-071.json +2 -2
- package/rcf/test-suites/ts-086.json +2 -2
- package/rcf/test-suites/ts-170.json +22 -0
- package/rcf/test-suites/ts-175.json +22 -0
- package/rcf/test-suites/ts-180.json +30 -0
- package/rcf/test-suites/ts-181.json +30 -0
- package/rcf/test-suites/ts-182.json +22 -0
- package/rcf/test-suites/ts-183.json +38 -0
- package/rcf/test-suites/ts-184.json +38 -0
- package/rcf/user-stories/us-14001.json +21 -0
- package/rcf/user-stories/us-14501.json +24 -0
- package/rcf/user-stories/us-15001.json +26 -0
- package/rcf/user-stories/us-15101.json +26 -0
- package/rcf/user-stories/us-15201.json +21 -0
- package/rcf/user-stories/us-15301.json +31 -0
- package/rcf/user-stories/us-15401.json +31 -0
- package/releases/releases.yaml +11 -1
- package/src/blueprint/apply.js +64 -0
- package/src/blueprint/consistency-lint.js +432 -0
- package/src/blueprint/disposition-ledger.js +224 -0
- package/src/blueprint/dispositions.js +44 -0
- package/src/blueprint/index.js +19 -0
- package/src/browser-verify/manifest-writer.js +53 -2
- package/src/browser-verify/pack-runner.js +18 -0
- package/src/cli/blueprint.js +103 -0
- package/src/setup/identity-seed.js +18 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"createdAt": "2026-09-08T12:17:35.296Z",
|
|
3
|
+
"updatedAt": "2026-09-08T12:19:48.200Z",
|
|
4
|
+
"asA": "rcf-lite maintainer applying edge-cloudflare-tunnel v1.0.0",
|
|
5
|
+
"iWant": "real-account-connector-healthy to actually provision the throwaway hetzner server through the T-1 fixture surface and run cloudflared tunnel info <name> asserting healthy connector count (when both CI_HAS_CLOUDFLARE_ACCOUNT and CI_HAS_HETZNER_ACCOUNT are set), real-account-tunnel-hostname-routes to drive undici for the public-hostname sub-case (activated on CI_HAS_CLOUDFLARE_ACCOUNT) and a two-identity JWT probe for the access-gated sub-case (activated only when CI_HAS_CLOUDFLARE_ACCESS is also set), and manifest-schema-validate to fail with a fixture-setup pointer when the credentials placeholder is missing rather than warn",
|
|
6
|
+
"soThat": "the real-account tunnel run proves connectors are up and routes actually serve on Cloudflare, the warn-on-env-set branches never again mask a broken driver, and a missing fixture pointer surfaces as a clear failure not a warning",
|
|
7
|
+
"description": "Part of H-2. connector-healthy consumes the hetzner fixture as-is; its defects are H-1 scope at HQ; if the fixture path fails locally the driver fails with a pointer naming the fixture step, never warn. hostname-routes: the public-hostname sub-case fetches the scratch subdomain via undici; the access-gated sub-case obtains an Access Service Token identity, exchanges it for a JWT and asserts an authenticated fetch succeeds AND an unauthenticated fetch is denied; the AUD sub-case stays pass-with-skip when CI_HAS_CLOUDFLARE_ACCESS is unset (HQ note: Access is not enabled on the HQ account today, so at gate time the AUD sub-case stays pass-with-skip until HQ enables Access, expected not a driver defect). Env-absent branch keeps pass-with-skip on both probes; a pass must never again be reachable from account-flag presence alone.",
|
|
8
|
+
"usId": "US-15301",
|
|
9
|
+
"prdId": "PRD-001",
|
|
10
|
+
"reqId": "REQ-153",
|
|
11
|
+
"version": "0.1.0",
|
|
12
|
+
"status": "draft",
|
|
13
|
+
"title": "H-2 tunnel real drivers: connector-healthy exercises cloudflared tunnel info against the throwaway hetzner server; hostname-routes drives undici and (when Access is enabled) a two-identity JWT probe; manifest-schema-validate placeholder becomes fail-with-pointer",
|
|
14
|
+
"acceptanceCriteria": [
|
|
15
|
+
{
|
|
16
|
+
"id": "AC-15301-1",
|
|
17
|
+
"description": "When both CI_HAS_CLOUDFLARE_ACCOUNT and CI_HAS_HETZNER_ACCOUNT are set with the required credentials, real-account-connector-healthy provisions the throwaway hetzner server via the T-1 hetzner fixture surface, brings a named cloudflared tunnel connector up on it, runs cloudflared tunnel info <name> through a cloudflared control shim, asserts the reported healthy connector count is at least one, captures a tunnelConnectorUp event on the injected event sink, and tears the connector and server down on exit. If the hetzner fixture path fails locally the driver fails with a pointer naming the failed fixture step (hetzner fixture defects are H-1 scope at HQ, never a warn on this driver). Env-absent branch keeps section 3.5 pass-with-skip.",
|
|
18
|
+
"testable": true
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "AC-15301-2",
|
|
22
|
+
"description": "When CI_HAS_CLOUDFLARE_ACCOUNT is set with the required tunnel credentials, real-account-tunnel-hostname-routes drives an undici fetch against the configured scratch subdomain for the public-hostname sub-case, asserts an HTTP 200 with expected body signature. When CI_HAS_CLOUDFLARE_ACCESS is ALSO set with Access service-token identities, the same probe drives a two-identity JWT sub-case: identity A obtains a JWT via the Service Token exchange and an authenticated fetch to the access-gated hostname succeeds; identity B without a JWT is denied (HTTP 302 to Access or HTTP 401). accountBoundSkipped is never set on the account-set branch; a pass is unreachable from CI_HAS_CLOUDFLARE_ACCOUNT presence alone on this probe. The access-gated sub-case remains pass-with-skip when CI_HAS_CLOUDFLARE_ACCESS is unset (HQ note: not enabled on HQ account today, expected). Env-absent branch keeps pass-with-skip.",
|
|
23
|
+
"testable": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "AC-15301-3",
|
|
27
|
+
"description": "manifest-schema-validate.mjs returns aggregateVerdict fail (not warn) with a detail string naming the missing credentials placeholder file and the fixture-setup step that would create it when the credentials placeholder is absent. The warn-on-missing-credentials-placeholder branch is removed.",
|
|
28
|
+
"testable": true
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"createdAt": "2026-09-08T12:17:35.792Z",
|
|
3
|
+
"updatedAt": "2026-09-08T12:19:48.599Z",
|
|
4
|
+
"asA": "rcf-lite maintainer applying the four Cloudflare-platform blueprints",
|
|
5
|
+
"iWant": "no SIMULATE_ read anywhere in a shipped probe body on the four blueprints, no fail envelope committed under .rcf/reports on the four blueprints, and every committed envelope reflecting a shipped-code pass run against the re-anchored probes",
|
|
6
|
+
"soThat": "the mutation-purity rule holds at the probe boundary, envelopes are trusted evidence rather than stale fail records, and honest labelling of local test doubles restores probe-comment credibility",
|
|
7
|
+
"description": "Part of H-2. All SIMULATE_ branches in shipped probe bodies (kv cache-aside-hit-then-miss, kv event-secrecy, DO sole-reader-scan, DO storage-round-trip, DO websocket-hub-broadcast) move to fixture-side shim modules under packages/rcf-lite/test/fixtures/cf-platform/ or .../cf-edge/. New shim files carry the h2-cf-platform-probe-integrity slug prefix (short form h2-cf acceptable). Every driver's negative path is proven by firing the fixture-side mutation switch and observing the FAIL tail; only shipped-code pass envelopes are committed. The committed fail envelope at .rcf/reports/blueprints/platform-cloudflare-kv/event-secrecy.json is deleted and a shipped-code pass envelope committed in its place; the other four kv envelopes are regenerated so committed envelopes reflect re-anchored shipped-code runs. Probe comments labelling doubles as fake state / fake WebSocket pair / fake clock are reworded to name the local double honestly, or adjudicated acceptable with reason in the PR body.",
|
|
8
|
+
"usId": "US-15401",
|
|
9
|
+
"prdId": "PRD-001",
|
|
10
|
+
"reqId": "REQ-154",
|
|
11
|
+
"version": "0.1.0",
|
|
12
|
+
"status": "draft",
|
|
13
|
+
"title": "H-2 cross-cutting probe hygiene: SIMULATE_ branches out of probe bodies into fixture shims; no fail envelope committed; probe comments name test doubles honestly",
|
|
14
|
+
"acceptanceCriteria": [
|
|
15
|
+
{
|
|
16
|
+
"id": "AC-15401-1",
|
|
17
|
+
"description": "A grep for the token SIMULATE_ across every shipped probe module (contributions/probes/*.mjs) on the four Cloudflare-platform blueprints (platform-cloudflare-kv, platform-cloudflare-durable-objects, messaging-queue-cloudflare, edge-cloudflare-tunnel) returns zero hits. Every mutation switch previously read from a probe body is now read from a fixture-side shim module under packages/rcf-lite/test/fixtures/cf-platform/ or .../cf-edge/ whose file name carries the h2-cf-platform-probe-integrity slug prefix (short form h2-cf acceptable). For each moved switch, a corresponding FAIL tail is observed and recorded in the PR provenance transcript when the shims switch is on.",
|
|
18
|
+
"testable": true
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "AC-15401-2",
|
|
22
|
+
"description": "No committed report under .rcf/reports/blueprints/{platform-cloudflare-kv, platform-cloudflare-durable-objects, messaging-queue-cloudflare, edge-cloudflare-tunnel}/ carries aggregateVerdict fail or warn. Every committed envelope on the four blueprints reflects a shipped-code pass run against the re-anchored probes; the previously committed fail envelope at .rcf/reports/blueprints/platform-cloudflare-kv/event-secrecy.json is replaced with a shipped-code pass envelope.",
|
|
23
|
+
"testable": true
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "AC-15401-3",
|
|
27
|
+
"description": "Every probe-comment or probe-utils-comment surfaced by stage-1 marker rows (kv fake clock; DO fake state; DO probe-utils lines 73 and 75; DO websocket-hub-broadcast fake WebSocket pair; kv not-yet elicit-gating wording) is either reworded to name the local test double honestly on its terms (a fixture in-memory driver realises the Workers KV binding shape; a synthetic WebSocket pair; an elicited fake wall-clock advanced deterministically by the fixture; a follow-up work-item note in place of not-yet) or adjudicated acceptable in the PR body with a stated reason. No shipped probe comment on the four blueprints carries a bare fake / not-yet marker without either honest naming or an adjudication row.",
|
|
28
|
+
"testable": true
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
package/releases/releases.yaml
CHANGED
|
@@ -40,8 +40,18 @@
|
|
|
40
40
|
# `npm install rcf-lite`.
|
|
41
41
|
|
|
42
42
|
feedVersion: 1
|
|
43
|
-
latest: "0.
|
|
43
|
+
latest: "0.26.0"
|
|
44
44
|
releases:
|
|
45
|
+
- version: "0.26.0"
|
|
46
|
+
date: "2026-09-09"
|
|
47
|
+
breaking: false
|
|
48
|
+
headlines:
|
|
49
|
+
- "rcf-lite 0.26.0 is a hardening and authoring release. The Cloudflare-platform blueprints on the shelf pick up real-account probe drivers with positive evidence and one shared account-token env matrix."
|
|
50
|
+
- "The blueprint authoring standard gains three new sections (7c REQ-layer sufficiency, 7d positive-evidence verification, 7e single-definition ownership), ten checklist rows and RULE 16 in the managed agent block."
|
|
51
|
+
- "The rcf define blueprint verb group gains a lint-consistency subverb, a per-slug disposition ledger with an apply-time prompt and a read-only dispositions view, and the identity seed grows a register field. The schema floor moves to rcf-schemas 0.6.2."
|
|
52
|
+
minAgentAction: null
|
|
53
|
+
notesUrl: "https://stravica.ai/docs/rcf/changelog/"
|
|
54
|
+
|
|
45
55
|
- version: "0.25.0"
|
|
46
56
|
date: "2026-09-08"
|
|
47
57
|
breaking: false
|
package/src/blueprint/apply.js
CHANGED
|
@@ -21,6 +21,7 @@ import { updateManifest } from './manifest-writer.js';
|
|
|
21
21
|
import { stampId } from './namespace.js';
|
|
22
22
|
import { nextResolutionId } from './resolutions.js';
|
|
23
23
|
import { buildRefusalMessage, discoverAppliedCapabilities, runCustomAuthCapabilityElicits, runElicitationPhase, writeSidecar } from './capabilities.js';
|
|
24
|
+
import { APPLY_DISPOSITION_PROMPT, initialiseLedger } from './disposition-ledger.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* @typedef {object} ApplyResult
|
|
@@ -410,11 +411,36 @@ export async function applyBlueprint({ projectRoot, tree, source, displaySource,
|
|
|
410
411
|
});
|
|
411
412
|
}
|
|
412
413
|
|
|
414
|
+
// Disposition ledger. On a fresh apply, initialise
|
|
415
|
+
// rcf/blueprints/<slug>.disposition.json with one record per
|
|
416
|
+
// contributed AC so the applying agent has a machine-readable walk
|
|
417
|
+
// list. Reads AC ids and their `disposition` field (rcf-schemas
|
|
418
|
+
// 0.6.2) from the SOURCE user-story JSON so the record reflects the
|
|
419
|
+
// authored shape, not the stamped one; `fixed` ACs land as accepted
|
|
420
|
+
// with the sentinel reason, `template` and unmarked ACs land as
|
|
421
|
+
// pending-disposition. Re-apply leaves an existing ledger untouched.
|
|
422
|
+
let ledgerPath = null;
|
|
423
|
+
let dispositionPrompt = null;
|
|
424
|
+
let acCount = 0;
|
|
425
|
+
if (!dryRun) {
|
|
426
|
+
const acDescriptors = await collectAcDescriptorsFromSource(source, blueprint, namespace);
|
|
427
|
+
acCount = acDescriptors.length;
|
|
428
|
+
if (acCount > 0) {
|
|
429
|
+
const ledgerRes = await initialiseLedger({ projectRoot, slug: appliedSlug, acDescriptors, now });
|
|
430
|
+
ledgerPath = ledgerRes.path;
|
|
431
|
+
if (!ledgerRes.alreadyExisted) {
|
|
432
|
+
dispositionPrompt = APPLY_DISPOSITION_PROMPT(appliedSlug, blueprint.version, acCount);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
413
437
|
return {
|
|
414
438
|
applied: true,
|
|
415
439
|
slug: appliedSlug,
|
|
416
440
|
version: blueprint.version,
|
|
417
441
|
contributions: writtenContributions,
|
|
442
|
+
...(ledgerPath ? { ledgerPath, dispositionAcCount: acCount } : {}),
|
|
443
|
+
...(dispositionPrompt ? { dispositionPrompt } : {}),
|
|
418
444
|
...(wroteSidecar ? { sidecarPath, appliedCapabilities, appliedElicitations } : {}),
|
|
419
445
|
// Companion-suggestion mechanism (spec 2.6). Bubble the source
|
|
420
446
|
// blueprint's `suggestedCompanions[]` up on the result so the CLI
|
|
@@ -428,6 +454,44 @@ export async function applyBlueprint({ projectRoot, tree, source, displaySource,
|
|
|
428
454
|
};
|
|
429
455
|
}
|
|
430
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Walk the blueprint's user-story contributions on-disk and collect
|
|
459
|
+
* every AC as a { id, storyId, sourceDisposition? } descriptor for the
|
|
460
|
+
* disposition ledger. AC ids and story ids are stamped with the
|
|
461
|
+
* applied namespace so they match what the applying agent sees in the
|
|
462
|
+
* tree (rather than the raw blueprint-slug ids on the source files).
|
|
463
|
+
*
|
|
464
|
+
* `disposition` on the AC (rcf-schemas 0.6.2 $defs.acDisposition) is
|
|
465
|
+
* `fixed` | `template` and is optional; an unmarked AC lands as
|
|
466
|
+
* pending-disposition with no `sourceDisposition` field so the operator
|
|
467
|
+
* can decide.
|
|
468
|
+
*/
|
|
469
|
+
async function collectAcDescriptorsFromSource(sourcePath, blueprint, namespace) {
|
|
470
|
+
const descriptors = [];
|
|
471
|
+
for (const c of blueprint.contributions ?? []) {
|
|
472
|
+
if (c.kind !== 'us' || typeof c.path !== 'string') continue;
|
|
473
|
+
const abs = join(sourcePath, 'contributions', c.path);
|
|
474
|
+
let doc;
|
|
475
|
+
try { doc = JSON.parse(await readFile(abs, 'utf8')); } catch { continue; }
|
|
476
|
+
const rawStoryId = typeof doc.usId === 'string' ? doc.usId : c.id;
|
|
477
|
+
// Re-stamp under the applied namespace so a library-qualified apply
|
|
478
|
+
// (namespace differs from the blueprint's own slug) records the
|
|
479
|
+
// story id the applying agent will actually see on the tree.
|
|
480
|
+
const storyStamped = stampId(rawStoryId, namespace);
|
|
481
|
+
const storyId = 'error' in storyStamped ? rawStoryId : storyStamped.id;
|
|
482
|
+
const acs = Array.isArray(doc.acceptanceCriteria) ? doc.acceptanceCriteria : [];
|
|
483
|
+
for (const ac of acs) {
|
|
484
|
+
if (!ac || typeof ac.id !== 'string') continue;
|
|
485
|
+
const descriptor = { id: ac.id, storyId };
|
|
486
|
+
if (ac.disposition === 'fixed' || ac.disposition === 'template') {
|
|
487
|
+
descriptor.sourceDisposition = ac.disposition;
|
|
488
|
+
}
|
|
489
|
+
descriptors.push(descriptor);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return descriptors;
|
|
493
|
+
}
|
|
494
|
+
|
|
431
495
|
function stampContributions(contributions, namespace) {
|
|
432
496
|
const out = [];
|
|
433
497
|
for (const c of contributions ?? []) {
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// Blueprint chain-consistency lint (spec section 5 of the integration
|
|
2
|
+
// and contradiction protocol, 2026-09-09).
|
|
3
|
+
//
|
|
4
|
+
// Two passes over ONE blueprint's own JSON and markdown. Pure functions;
|
|
5
|
+
// the CLI seam loads the blueprint from disk and hands the model in.
|
|
6
|
+
//
|
|
7
|
+
// PASS 1: single-definition ownership, MECHANICAL ONLY.
|
|
8
|
+
// The check is intentionally conservative. Pass 1 fires when the
|
|
9
|
+
// same identifier-shaped literal appears on more than one artefact
|
|
10
|
+
// and one of the appearances is case-INCONSISTENT with the owning
|
|
11
|
+
// TAC on a case-sensitive axis (HTTP header, env-var, camelCase
|
|
12
|
+
// JSON field name in a strict-parse context). A byte-identical
|
|
13
|
+
// restatement is only flagged when the owning TAC declares the
|
|
14
|
+
// token on its `interfaces[]` (the strict interface surface) and
|
|
15
|
+
// the restating AC carries no `ownerRef` back to that TAC. Every
|
|
16
|
+
// other kind of appearance is treated as a reference, not a
|
|
17
|
+
// restatement (the mechanical detector cannot reliably tell a
|
|
18
|
+
// duplicate literal from a legitimate mention without domain
|
|
19
|
+
// knowledge, and the spec explicitly names four human-read shapes
|
|
20
|
+
// the lint does not catch, per section 5.7).
|
|
21
|
+
//
|
|
22
|
+
// PASS 2: REQ delivery, MECHANICAL.
|
|
23
|
+
// A REQ whose description carries a promise phrase (must / requires
|
|
24
|
+
// / guarantees / ceiling / never / invariant / on boot / at boot /
|
|
25
|
+
// event / boundary) must carry a `deliveredBy` link into a TAC or
|
|
26
|
+
// ADR. When the link points at a TAC in the same blueprint, the
|
|
27
|
+
// TAC's responsibilities and interfaces must reference the field
|
|
28
|
+
// (or the REQ's subject, when no `field` sub-path is given).
|
|
29
|
+
//
|
|
30
|
+
// Every finding carries a stable `id` the operator can quote in the
|
|
31
|
+
// blueprint README under "Known chain-consistency-lint suppressions"
|
|
32
|
+
// (pass 1 only; pass 2 findings are not suppressible per spec 5.8).
|
|
33
|
+
|
|
34
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} ConsistencyFinding
|
|
39
|
+
* @property {string} id
|
|
40
|
+
* @property {'pass1'|'pass2'} pass
|
|
41
|
+
* @property {'contractDrift'|'undeclaredRestatement'|'reqNoDelivery'|'reqDeliveryNotCarried'} kind
|
|
42
|
+
* @property {string} subject
|
|
43
|
+
* @property {string[]} refs
|
|
44
|
+
* @property {string} message
|
|
45
|
+
* @property {boolean} [suppressed]
|
|
46
|
+
* @property {string} [suppressionReason]
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const HTTP_HEADER_RE = /^[A-Z][A-Za-z0-9]*(-[A-Z][A-Za-z0-9]*)+$/;
|
|
50
|
+
const HTTP_HEADER_LOWER_RE = /^[a-z][a-z0-9]*(-[a-z][a-z0-9]*)+$/;
|
|
51
|
+
const ENV_VAR_RE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
52
|
+
const CAMEL_JSON_FIELD_RE = /^[a-z][a-zA-Z0-9]*$/;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract identifier-shaped tokens from a body of text: dashed
|
|
56
|
+
* identifiers (Cf-Turnstile-Response, run_worker_first),
|
|
57
|
+
* ALL_CAPS_ENV_VARS, and camelCase / PascalCase runs. Duplicates are
|
|
58
|
+
* preserved so callers can index by occurrence.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} text
|
|
61
|
+
*/
|
|
62
|
+
function extractTokens(text) {
|
|
63
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
const re = /[A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+|[A-Z][a-z]+(?:[A-Z][a-z]*)+|[a-z]+(?:[A-Z][a-z]*){1,}/g;
|
|
66
|
+
for (const m of text.matchAll(re)) out.push(m[0]);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Classify a token for case-sensitivity purposes.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} token
|
|
74
|
+
* @returns {'header'|'headerLower'|'envVar'|'jsonField'|'identifier'}
|
|
75
|
+
*/
|
|
76
|
+
function tokenClass(token) {
|
|
77
|
+
if (ENV_VAR_RE.test(token)) return 'envVar';
|
|
78
|
+
if (HTTP_HEADER_RE.test(token)) return 'header';
|
|
79
|
+
if (HTTP_HEADER_LOWER_RE.test(token)) return 'headerLower';
|
|
80
|
+
if (CAMEL_JSON_FIELD_RE.test(token)) return 'jsonField';
|
|
81
|
+
return 'identifier';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// TAC / ADR / REQ / US / PRD / TAD / BS / FBS id prefixes: these read
|
|
85
|
+
// as identifier-shaped but they are meta references, not contract
|
|
86
|
+
// literals. Skip them in both directions of the drift check.
|
|
87
|
+
const ID_TOKEN_RE = /^(?:TAC|ADR|REQ|US|PRD|TAD|BS|FBS|TS|CN|AC)[-_A-Z0-9-]*$/;
|
|
88
|
+
|
|
89
|
+
// Small stop-list of ordinary English hyphenated / camel-cased words
|
|
90
|
+
// that appear as identifier-shape but are prose, not contracts.
|
|
91
|
+
const PROSE_TOKENS = new Set([
|
|
92
|
+
'stable-coded', 'per-environment', 'wall-clock', 'operator-authored',
|
|
93
|
+
'project-authored', 'vendor-resolved', 'required-absent',
|
|
94
|
+
'deployable-to-slice', 'rotation-days', 'admin-UI',
|
|
95
|
+
'read-through', 'write-through', 'follow-up',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
function isNoise(token) {
|
|
99
|
+
if (token.length < 4) return true;
|
|
100
|
+
if (ID_TOKEN_RE.test(token)) return true;
|
|
101
|
+
if (PROSE_TOKENS.has(token)) return true;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Owned-token dictionary. Keyed by lower-case token; the value is the
|
|
107
|
+
* canonical spelling on the owning TAC's interface surface, plus the
|
|
108
|
+
* TAC id and field where it lives.
|
|
109
|
+
*
|
|
110
|
+
* Only `interfaces[].name` and `interfaces[].summary` (backtick-quoted
|
|
111
|
+
* spans only) plus `internalStructure` (backtick-quoted spans only)
|
|
112
|
+
* count as owning surfaces. `responsibilities[]` and `purpose` are
|
|
113
|
+
* prose narrative where a mention is a reference, not a declaration.
|
|
114
|
+
*/
|
|
115
|
+
function collectOwnedTokens(tacs) {
|
|
116
|
+
/** @type {Map<string, { canonical: string, tacId: string, field: string, klass: string }>} */
|
|
117
|
+
const owned = new Map();
|
|
118
|
+
// record() takes an `onlySensitive` flag. For strict interface
|
|
119
|
+
// surfaces (`interfaces[].name`, `internalStructure` backtick spans,
|
|
120
|
+
// `responsibilities[]` backtick spans) we record every non-prose
|
|
121
|
+
// token including camelCase field names. For less-strict prose
|
|
122
|
+
// surfaces (interface summaries, purpose, responsibilities prose)
|
|
123
|
+
// we only record tokens classified as `header` / `headerLower` /
|
|
124
|
+
// `envVar`, because a prose mention of a camelCase word is usually
|
|
125
|
+
// a reference and only case-sensitive axes reliably indicate a
|
|
126
|
+
// literal declaration.
|
|
127
|
+
const record = (token, tacId, field, onlySensitive) => {
|
|
128
|
+
if (isNoise(token)) return;
|
|
129
|
+
const klass = tokenClass(token);
|
|
130
|
+
if (klass === 'identifier') return;
|
|
131
|
+
if (onlySensitive && klass === 'jsonField') return;
|
|
132
|
+
const key = token.toLowerCase();
|
|
133
|
+
if (!owned.has(key)) owned.set(key, { canonical: token, tacId, field, klass });
|
|
134
|
+
};
|
|
135
|
+
for (const tac of tacs) {
|
|
136
|
+
const tacId = tac.tacId || tac.id || '(unknown-tac)';
|
|
137
|
+
// Strict interface surfaces first so their spellings win the
|
|
138
|
+
// Map's first-write-wins semantics over any weaker prose mention.
|
|
139
|
+
if (Array.isArray(tac.interfaces)) {
|
|
140
|
+
for (let i = 0; i < tac.interfaces.length; i += 1) {
|
|
141
|
+
const iface = tac.interfaces[i];
|
|
142
|
+
if (iface && typeof iface.name === 'string') {
|
|
143
|
+
for (const t of extractTokens(iface.name)) record(t, tacId, `interfaces[${i}].name`, false);
|
|
144
|
+
}
|
|
145
|
+
if (iface && typeof iface.summary === 'string') {
|
|
146
|
+
for (const m of iface.summary.matchAll(/`+([^`]+)`+/g)) {
|
|
147
|
+
for (const t of extractTokens(m[1])) record(t, tacId, `interfaces[${i}].summary`, false);
|
|
148
|
+
}
|
|
149
|
+
// Plain-text summary: case-sensitive axes only. This is what
|
|
150
|
+
// catches `Cf-Turnstile-Response` on TAC-3602's guard summary.
|
|
151
|
+
for (const t of extractTokens(iface.summary)) record(t, tacId, `interfaces[${i}].summary`, true);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (typeof tac.internalStructure === 'string') {
|
|
156
|
+
for (const m of tac.internalStructure.matchAll(/`+([^`]+)`+/g)) {
|
|
157
|
+
for (const t of extractTokens(m[1])) record(t, tacId, 'internalStructure', false);
|
|
158
|
+
}
|
|
159
|
+
for (const t of extractTokens(tac.internalStructure)) record(t, tacId, 'internalStructure', true);
|
|
160
|
+
}
|
|
161
|
+
if (Array.isArray(tac.responsibilities)) {
|
|
162
|
+
for (let i = 0; i < tac.responsibilities.length; i += 1) {
|
|
163
|
+
const s = tac.responsibilities[i];
|
|
164
|
+
if (typeof s !== 'string') continue;
|
|
165
|
+
for (const m of s.matchAll(/`+([^`]+)`+/g)) {
|
|
166
|
+
for (const t of extractTokens(m[1])) record(t, tacId, `responsibilities[${i}]`, false);
|
|
167
|
+
}
|
|
168
|
+
for (const t of extractTokens(s)) record(t, tacId, `responsibilities[${i}]`, true);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (typeof tac.purpose === 'string') {
|
|
172
|
+
// Purpose prose: header / envVar only (never fires on ordinary
|
|
173
|
+
// English or camelCase identifiers).
|
|
174
|
+
for (const t of extractTokens(tac.purpose)) record(t, tacId, 'purpose', true);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return owned;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Collect artefact surfaces (holderId, holderKind, field, text,
|
|
182
|
+
* optional ownerRef on ACs). Used by pass 1's cross-surface walk.
|
|
183
|
+
*/
|
|
184
|
+
function collectArtefactSurfaces(input) {
|
|
185
|
+
const surfaces = [];
|
|
186
|
+
for (const req of input.reqs) {
|
|
187
|
+
const rid = req.reqId || req.id || '(unknown-req)';
|
|
188
|
+
if (typeof req.description === 'string') surfaces.push({ holderId: rid, holderKind: 'req', field: 'description', text: req.description });
|
|
189
|
+
}
|
|
190
|
+
for (const us of input.userStories) {
|
|
191
|
+
const usid = us.usId || us.id || '(unknown-us)';
|
|
192
|
+
if (typeof us.iWant === 'string') surfaces.push({ holderId: usid, holderKind: 'us', field: 'iWant', text: us.iWant });
|
|
193
|
+
if (Array.isArray(us.acceptanceCriteria)) {
|
|
194
|
+
for (const ac of us.acceptanceCriteria) {
|
|
195
|
+
const acid = ac.id || `${usid}-AC?`;
|
|
196
|
+
const ownerRef = ac.ownerRef && typeof ac.ownerRef === 'object' ? ac.ownerRef : null;
|
|
197
|
+
for (const f of ['description', 'given', 'when', 'then']) {
|
|
198
|
+
if (typeof ac[f] === 'string') surfaces.push({ holderId: acid, holderKind: 'ac', field: f, text: ac[f], parentUsId: usid, ownerRef });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const adr of input.adrs) {
|
|
204
|
+
const aid = adr.adrId || adr.id || '(unknown-adr)';
|
|
205
|
+
for (const f of ['context', 'decision', 'consequences']) {
|
|
206
|
+
if (typeof adr[f] === 'string') surfaces.push({ holderId: aid, holderKind: 'adr', field: f, text: adr[f] });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (typeof input.guideMarkdown === 'string' && input.guideMarkdown.length > 0) {
|
|
210
|
+
surfaces.push({ holderId: 'guide', holderKind: 'guide', field: 'body', text: input.guideMarkdown });
|
|
211
|
+
}
|
|
212
|
+
return surfaces;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {{ slug: string, sourcePath: string, tacs: object[], reqs: object[], userStories: object[], adrs: object[], guideMarkdown?: string }} input
|
|
217
|
+
* @returns {ConsistencyFinding[]}
|
|
218
|
+
*/
|
|
219
|
+
export function runPass1(input) {
|
|
220
|
+
const owned = collectOwnedTokens(input.tacs);
|
|
221
|
+
if (owned.size === 0) return [];
|
|
222
|
+
const surfaces = collectArtefactSurfaces(input);
|
|
223
|
+
/** @type {ConsistencyFinding[]} */
|
|
224
|
+
const findings = [];
|
|
225
|
+
const seen = new Set();
|
|
226
|
+
|
|
227
|
+
for (const surface of surfaces) {
|
|
228
|
+
for (const raw of extractTokens(surface.text)) {
|
|
229
|
+
if (isNoise(raw)) continue;
|
|
230
|
+
const key = raw.toLowerCase();
|
|
231
|
+
const owner = owned.get(key);
|
|
232
|
+
if (!owner) continue;
|
|
233
|
+
const isSensitive = owner.klass === 'header' || owner.klass === 'envVar' || owner.klass === 'headerLower';
|
|
234
|
+
// Contract drift: the same lower-cased token, spelled
|
|
235
|
+
// differently, on a case-sensitive axis (HTTP header or
|
|
236
|
+
// env-var). This is the primary mechanical catch and includes
|
|
237
|
+
// the `Cf-Turnstile-Response` / `cf-turnstile-response`
|
|
238
|
+
// specimen shape.
|
|
239
|
+
if (isSensitive && owner.canonical !== raw) {
|
|
240
|
+
// Sentence-start capitalisation is not contract drift.
|
|
241
|
+
// Skip when the two tokens differ ONLY in the case of the
|
|
242
|
+
// very first character (rest of the token is byte-identical).
|
|
243
|
+
if (owner.canonical.length === raw.length
|
|
244
|
+
&& owner.canonical.slice(1) === raw.slice(1)
|
|
245
|
+
&& owner.canonical[0].toLowerCase() === raw[0].toLowerCase()) continue;
|
|
246
|
+
const id = `pass1-drift-${owner.tacId}-${owner.canonical}`;
|
|
247
|
+
const dedupe = `${id}::${surface.holderId}::${raw}`;
|
|
248
|
+
if (!seen.has(dedupe)) {
|
|
249
|
+
seen.add(dedupe);
|
|
250
|
+
findings.push({
|
|
251
|
+
id, pass: 'pass1', kind: 'contractDrift',
|
|
252
|
+
subject: owner.canonical,
|
|
253
|
+
refs: [owner.tacId, surface.holderId],
|
|
254
|
+
message: `${surface.holderId}.${surface.field} spells '${owner.canonical}' as '${raw}'; owner ${owner.tacId}.${owner.field} declares '${owner.canonical}'.`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
// Undeclared restatement: only fires for AC surfaces (the
|
|
260
|
+
// schema field `ownerRef` lives on AC records), only when the
|
|
261
|
+
// owning surface is an `interfaces[].name` (the strict
|
|
262
|
+
// interface surface), and only when the AC does not name an
|
|
263
|
+
// `ownerRef.tacId` matching the owning TAC. Every other cross
|
|
264
|
+
// surface mention is read as a reference, not a restatement.
|
|
265
|
+
if (surface.holderKind !== 'ac') continue;
|
|
266
|
+
if (!owner.field.startsWith('interfaces[') || !owner.field.endsWith('].name')) continue;
|
|
267
|
+
const ownerRef = surface.ownerRef;
|
|
268
|
+
const declared = ownerRef && typeof ownerRef.tacId === 'string' && ownerRef.tacId === owner.tacId;
|
|
269
|
+
if (declared) continue;
|
|
270
|
+
const id = `pass1-restated-${owner.tacId}-${owner.canonical}`;
|
|
271
|
+
const dedupe = `${id}::${surface.holderId}`;
|
|
272
|
+
if (seen.has(dedupe)) continue;
|
|
273
|
+
seen.add(dedupe);
|
|
274
|
+
findings.push({
|
|
275
|
+
id, pass: 'pass1', kind: 'undeclaredRestatement',
|
|
276
|
+
subject: owner.canonical,
|
|
277
|
+
refs: [owner.tacId, surface.holderId],
|
|
278
|
+
message: `${surface.holderId}.${surface.field} restates '${owner.canonical}' owned by ${owner.tacId}.${owner.field}; declare ownerRef or turn the mention into a reference.`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return findings;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// REQ description phrases that read as an externally observable
|
|
286
|
+
// promise the lint asks a TAC or ADR to deliver.
|
|
287
|
+
const PROMISE_TERMS = [
|
|
288
|
+
'must', 'requires', 'required', 'guarantee', 'guarantees', 'guaranteed',
|
|
289
|
+
'ceiling', 'never', 'invariant', 'on boot', 'at boot', 'boot event',
|
|
290
|
+
'always', 'ensures', 'refuse', 'refuses', 'forbid', 'forbidden',
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
export function runPass2(input) {
|
|
294
|
+
const findings = [];
|
|
295
|
+
const tacsById = new Map();
|
|
296
|
+
for (const t of input.tacs) tacsById.set(t.tacId || t.id, t);
|
|
297
|
+
for (const req of input.reqs) {
|
|
298
|
+
const rid = req.reqId || req.id || '(unknown-req)';
|
|
299
|
+
const desc = typeof req.description === 'string' ? req.description : '';
|
|
300
|
+
const promises = PROMISE_TERMS.some((term) => new RegExp(`(^|[^A-Za-z])${term}([^A-Za-z]|$)`, 'i').test(desc));
|
|
301
|
+
if (!promises) continue;
|
|
302
|
+
const delivery = req.deliveredBy && typeof req.deliveredBy === 'object' ? req.deliveredBy : null;
|
|
303
|
+
if (!delivery || (!delivery.tacId && !delivery.adrId)) {
|
|
304
|
+
findings.push({
|
|
305
|
+
id: `pass2-no-delivery-${rid}`, pass: 'pass2', kind: 'reqNoDelivery',
|
|
306
|
+
subject: rid, refs: [rid],
|
|
307
|
+
message: `${rid}.description promises an externally observable property but declares no deliveredBy link into a TAC or ADR.`,
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (delivery.tacId) {
|
|
312
|
+
const target = tacsById.get(delivery.tacId);
|
|
313
|
+
if (!target) {
|
|
314
|
+
findings.push({
|
|
315
|
+
id: `pass2-delivery-missing-tac-${rid}-${delivery.tacId}`, pass: 'pass2', kind: 'reqDeliveryNotCarried',
|
|
316
|
+
subject: rid, refs: [rid, delivery.tacId],
|
|
317
|
+
message: `${rid}.deliveredBy names TAC ${delivery.tacId} which is not present in the blueprint.`,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const carried = collectTacSurfaceText(target).toLowerCase();
|
|
322
|
+
const field = typeof delivery.field === 'string' ? delivery.field.toLowerCase() : '';
|
|
323
|
+
const fieldTail = field ? field.split('.').pop() : '';
|
|
324
|
+
if (fieldTail && !carried.includes(fieldTail)) {
|
|
325
|
+
findings.push({
|
|
326
|
+
id: `pass2-delivery-field-missing-${rid}-${delivery.tacId}`, pass: 'pass2', kind: 'reqDeliveryNotCarried',
|
|
327
|
+
subject: rid, refs: [rid, delivery.tacId, fieldTail],
|
|
328
|
+
message: `${rid}.deliveredBy points at ${delivery.tacId}.${delivery.field} but that field is not present on the TAC's responsibilities or interfaces.`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return findings;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function collectTacSurfaceText(tac) {
|
|
337
|
+
const parts = [];
|
|
338
|
+
if (typeof tac.purpose === 'string') parts.push(tac.purpose);
|
|
339
|
+
if (typeof tac.internalStructure === 'string') parts.push(tac.internalStructure);
|
|
340
|
+
if (Array.isArray(tac.responsibilities)) parts.push(tac.responsibilities.filter((s) => typeof s === 'string').join(' '));
|
|
341
|
+
if (Array.isArray(tac.interfaces)) {
|
|
342
|
+
for (const iface of tac.interfaces) {
|
|
343
|
+
if (iface && typeof iface.name === 'string') parts.push(iface.name);
|
|
344
|
+
if (iface && typeof iface.summary === 'string') parts.push(iface.summary);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return parts.join(' ');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Load a blueprint from disk into the shape the passes consume.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} sourcePath
|
|
354
|
+
* @returns {Promise<{ slug: string, sourcePath: string, tacs: object[], reqs: object[], userStories: object[], adrs: object[], guideMarkdown: string } | { error: string }>}
|
|
355
|
+
*/
|
|
356
|
+
export async function loadForLint(sourcePath) {
|
|
357
|
+
let meta;
|
|
358
|
+
try {
|
|
359
|
+
const raw = await readFile(join(sourcePath, 'blueprint.json'), 'utf8');
|
|
360
|
+
meta = JSON.parse(raw);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return { error: `cannot read blueprint.json under ${sourcePath}: ${err.message}` };
|
|
363
|
+
}
|
|
364
|
+
const slug = meta.slug || '(unknown-slug)';
|
|
365
|
+
const contributions = Array.isArray(meta.contributions) ? meta.contributions : [];
|
|
366
|
+
const tacs = []; const reqs = []; const userStories = []; const adrs = [];
|
|
367
|
+
for (const c of contributions) {
|
|
368
|
+
if (!c || typeof c.path !== 'string') continue;
|
|
369
|
+
const abs = join(sourcePath, 'contributions', c.path);
|
|
370
|
+
let doc;
|
|
371
|
+
try { doc = JSON.parse(await readFile(abs, 'utf8')); } catch { continue; }
|
|
372
|
+
switch (c.kind) {
|
|
373
|
+
case 'tac': tacs.push(doc); break;
|
|
374
|
+
case 'req': reqs.push(doc); break;
|
|
375
|
+
case 'us': userStories.push(doc); break;
|
|
376
|
+
case 'adr': adrs.push(doc); break;
|
|
377
|
+
default: break;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
let guideMarkdown = '';
|
|
381
|
+
try {
|
|
382
|
+
const entries = await readdir(join(sourcePath, 'guide'));
|
|
383
|
+
for (const name of entries) {
|
|
384
|
+
if (!name.endsWith('.md')) continue;
|
|
385
|
+
guideMarkdown += `${await readFile(join(sourcePath, 'guide', name), 'utf8')}\n`;
|
|
386
|
+
}
|
|
387
|
+
} catch { /* no guide directory is fine */ }
|
|
388
|
+
return { slug, sourcePath, tacs, reqs, userStories, adrs, guideMarkdown };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Extract "Known chain-consistency-lint suppressions" entries from a
|
|
393
|
+
* blueprint's README.md. Returns a Map from finding id to reason.
|
|
394
|
+
*/
|
|
395
|
+
export async function loadSuppressions(sourcePath) {
|
|
396
|
+
const out = new Map();
|
|
397
|
+
let readme;
|
|
398
|
+
try { readme = await readFile(join(sourcePath, 'README.md'), 'utf8'); } catch { return out; }
|
|
399
|
+
const m = readme.match(/##\s+Known chain-consistency-lint suppressions([\s\S]*?)(\n##\s+|$)/);
|
|
400
|
+
if (!m) return out;
|
|
401
|
+
for (const line of m[1].split('\n')) {
|
|
402
|
+
const rm = line.match(/^\s*[-*]\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s*:\s*(.+?)\s*$/);
|
|
403
|
+
if (rm) out.set(rm[1], rm[2]);
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Run both passes and shape the result envelope. Pass 1 findings that
|
|
410
|
+
* appear in `suppressions` are marked `suppressed: true`; pass 2
|
|
411
|
+
* findings are never suppressible.
|
|
412
|
+
*/
|
|
413
|
+
export function runLint(input, suppressions = new Map()) {
|
|
414
|
+
const pass1 = runPass1(input);
|
|
415
|
+
const pass2 = runPass2(input);
|
|
416
|
+
let suppressedCount = 0;
|
|
417
|
+
for (const f of pass1) {
|
|
418
|
+
if (suppressions.has(f.id)) {
|
|
419
|
+
f.suppressed = true;
|
|
420
|
+
f.suppressionReason = suppressions.get(f.id);
|
|
421
|
+
suppressedCount += 1;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const findings = [...pass1, ...pass2];
|
|
425
|
+
const unsuppressed = findings.filter((f) => !f.suppressed);
|
|
426
|
+
return {
|
|
427
|
+
blueprint: input.slug,
|
|
428
|
+
findings,
|
|
429
|
+
verdict: unsuppressed.length === 0 ? 'pass' : 'fail',
|
|
430
|
+
passCounts: { pass1: pass1.length, pass2: pass2.length, suppressed: suppressedCount },
|
|
431
|
+
};
|
|
432
|
+
}
|