@geraldmaron/construct 1.1.0 → 1.1.1

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.
@@ -1,96 +0,0 @@
1
- // lib/policy/unified-gates.mjs
2
- // Single policy declaration for all enforcement layers
3
-
4
- export const POLICIES = {
5
- commentStyle: {
6
- id: 'comment-style',
7
- description: 'Comments must follow project standards',
8
- layers: ['write', 'commit', 'ci'],
9
- bypass: 'CONSTRUCT_SKIP_COMMENT_STYLE',
10
- critical: false,
11
- },
12
- docCoupling: {
13
- id: 'doc-coupling',
14
- description: 'Code changes require documentation updates',
15
- layers: ['commit', 'push'],
16
- bypass: 'CONSTRUCT_SKIP_DOCS',
17
- critical: false,
18
- },
19
- secretScan: {
20
- id: 'secret-scan',
21
- description: 'No secrets in committed code',
22
- layers: ['commit', 'push', 'ci'],
23
- bypass: 'CONSTRUCT_SKIP_SECRET_SCAN',
24
- critical: true,
25
- },
26
- ciStatus: {
27
- id: 'ci-status',
28
- description: 'CI must be green before push',
29
- layers: ['push', 'session-end'],
30
- bypass: 'CONSTRUCT_SKIP_CI_CHECK',
31
- critical: true,
32
- },
33
- };
34
-
35
- export class UnifiedGateEngine {
36
- constructor(options = {}) {
37
- this.env = options.env || process.env;
38
- }
39
-
40
- isBypassed(policyId) {
41
- const policy = POLICIES[policyId];
42
- if (!policy) return { bypassed: false };
43
-
44
- if (policy.bypass && this.env[policy.bypass] === '1') {
45
- return { bypassed: true, envVar: policy.bypass };
46
- }
47
-
48
- if (this.env.CONSTRUCT_SKIP_ALL_GATES === '1') {
49
- return { bypassed: true, envVar: 'CONSTRUCT_SKIP_ALL_GATES' };
50
- }
51
-
52
- return { bypassed: false };
53
- }
54
-
55
- async evaluateLayer(layer, context = {}) {
56
- const results = [];
57
-
58
- for (const [id, policy] of Object.entries(POLICIES)) {
59
- if (policy.layers.includes(layer)) {
60
- const bypass = this.isBypassed(id);
61
- results.push({
62
- policy: id,
63
- passed: bypass.bypassed || true,
64
- bypassed: bypass.bypassed,
65
- bypassEnvVar: bypass.envVar,
66
- critical: policy.critical,
67
- });
68
- }
69
- }
70
-
71
- const failed = results.filter(r => !r.passed && r.critical);
72
-
73
- return {
74
- layer,
75
- passed: failed.length === 0,
76
- canProceed: failed.length === 0,
77
- results,
78
- summary: `${results.filter(r => r.passed).length}/${results.length} passed`,
79
- };
80
- }
81
- }
82
-
83
- export async function checkGates(layer, context = {}, options = {}) {
84
- const engine = new UnifiedGateEngine(options);
85
- return await engine.evaluateLayer(layer, context);
86
- }
87
-
88
- export function listPolicies() {
89
- return Object.entries(POLICIES).map(([id, policy]) => ({
90
- id,
91
- description: policy.description,
92
- layers: policy.layers,
93
- bypass: policy.bypass,
94
- critical: policy.critical,
95
- }));
96
- }
@@ -1,114 +0,0 @@
1
- /**
2
- * lib/profiles/validate-custom.mjs — Schema validator for user-defined profiles.
3
- *
4
- * Custom profiles live at `<project>/.cx/profile.json` with `custom: true`.
5
- * They are validated on `construct sync` and via the pre-push gate. Rejected
6
- * profiles never get loaded; resolveActiveProfile falls back to the default
7
- * so a malformed escape-hatch file never breaks a project.
8
- *
9
- * Hard limits the validator enforces (kept in sync with schemas/profile.schema.json
10
- * and scripts/lint-profiles.mjs):
11
- * - max 12 stages
12
- * - max 24 intake types
13
- * - max 80 roles per profile
14
- * - max 12 departments per profile
15
- * - max 20 roles per department
16
- * - classificationTable path must stay inside .cx/ (no repo escapes)
17
- *
18
- * Rationale for each cap lives in docs/concepts/persona-research.md.
19
- */
20
- import fs from 'node:fs';
21
- import path from 'node:path';
22
-
23
- const MAX_STAGES = 12;
24
- const MAX_INTAKE_TYPES = 24;
25
- const MAX_ROLES = 80;
26
- const MAX_DEPARTMENTS = 12;
27
- const MAX_ROLES_PER_DEPARTMENT = 20;
28
-
29
- /**
30
- * @returns {string[]} array of error strings; empty means valid.
31
- */
32
- export function validateCustomProfile(profile, { cwd } = {}) {
33
- const errors = [];
34
- if (!profile || typeof profile !== 'object') return ['profile is not an object'];
35
- if (profile.custom !== true) errors.push('custom profiles must set custom: true');
36
-
37
- if (!profile.id || !/^[a-z][a-z0-9-]{1,30}$/.test(profile.id)) {
38
- errors.push('id must match ^[a-z][a-z0-9-]{1,30}$');
39
- }
40
- if (!profile.displayName || typeof profile.displayName !== 'string') {
41
- errors.push('displayName is required');
42
- }
43
- if (!Array.isArray(profile.roles) || profile.roles.length === 0) {
44
- errors.push('roles must be a non-empty array');
45
- } else if (profile.roles.length > MAX_ROLES) {
46
- errors.push(`roles exceeds max of ${MAX_ROLES}`);
47
- }
48
- if (!profile.intake || typeof profile.intake !== 'object') {
49
- errors.push('intake is required');
50
- } else {
51
- if (!Array.isArray(profile.intake.types) || profile.intake.types.length === 0) {
52
- errors.push('intake.types must be a non-empty array');
53
- } else if (profile.intake.types.length > MAX_INTAKE_TYPES) {
54
- errors.push(`intake.types exceeds max of ${MAX_INTAKE_TYPES}`);
55
- }
56
- if (!Array.isArray(profile.intake.stages) || profile.intake.stages.length === 0) {
57
- errors.push('intake.stages must be a non-empty array');
58
- } else if (profile.intake.stages.length > MAX_STAGES) {
59
- errors.push(`intake.stages exceeds max of ${MAX_STAGES}`);
60
- }
61
- if (typeof profile.intake.classificationTable === 'string') {
62
- const t = profile.intake.classificationTable;
63
- if (path.isAbsolute(t)) {
64
- errors.push('intake.classificationTable must be a relative path');
65
- } else if (cwd && !t.startsWith('.cx/')) {
66
- errors.push('intake.classificationTable must live under .cx/ for custom profiles');
67
- }
68
- }
69
- }
70
- if (profile.departments !== undefined) {
71
- if (!Array.isArray(profile.departments)) {
72
- errors.push('departments must be an array');
73
- } else {
74
- if (profile.departments.length > MAX_DEPARTMENTS) {
75
- errors.push(`departments exceeds max of ${MAX_DEPARTMENTS}`);
76
- }
77
- for (const [i, dept] of profile.departments.entries()) {
78
- if (!dept || typeof dept !== 'object') {
79
- errors.push(`departments[${i}] must be an object`);
80
- continue;
81
- }
82
- if (!dept.id || !/^[a-z][a-z0-9-]{1,40}$/.test(dept.id)) {
83
- errors.push(`departments[${i}].id is missing or malformed`);
84
- }
85
- if (!dept.charter || dept.charter.length < 20) {
86
- errors.push(`departments[${i}].charter must be at least 20 chars (mission statement, not a label)`);
87
- }
88
- if (!Array.isArray(dept.roles) || dept.roles.length === 0) {
89
- errors.push(`departments[${i}].roles must be a non-empty array`);
90
- } else if (dept.roles.length > MAX_ROLES_PER_DEPARTMENT) {
91
- errors.push(`departments[${i}].roles exceeds max of ${MAX_ROLES_PER_DEPARTMENT}`);
92
- }
93
- }
94
- }
95
- }
96
- return errors;
97
- }
98
-
99
- /**
100
- * Read and validate `<cwd>/.cx/profile.json`. Returns:
101
- * { status: 'absent' } if the file does not exist
102
- * { status: 'invalid', errors: [...] } if validation failed
103
- * { status: 'ok', profile } if the file is a valid custom profile
104
- */
105
- export function validateCustomProfileFile(cwd) {
106
- const p = path.join(cwd, '.cx', 'profile.json');
107
- if (!fs.existsSync(p)) return { status: 'absent' };
108
- let raw;
109
- try { raw = JSON.parse(fs.readFileSync(p, 'utf8')); }
110
- catch (err) { return { status: 'invalid', errors: [`malformed JSON: ${err.message}`] }; }
111
- const errors = validateCustomProfile(raw, { cwd });
112
- if (errors.length > 0) return { status: 'invalid', errors };
113
- return { status: 'ok', profile: raw };
114
- }
@@ -1,177 +0,0 @@
1
- /**
2
- * lib/services/telemetry-backend.mjs — local services stack management.
3
- *
4
- * Manages the local Postgres service (services/docker-compose.yml).
5
- * In solo mode, Postgres runs as a Docker container with pgvector enabled.
6
- *
7
- * Remote telemetry backends (for trace ingestion) are configured via
8
- * CONSTRUCT_TELEMETRY_URL / CONSTRUCT_TELEMETRY_PUBLIC_KEY /
9
- * CONSTRUCT_TELEMETRY_SECRET_KEY env vars. This module handles local
10
- * infrastructure only — it does not manage telemetry dashboard services.
11
- */
12
-
13
- import { spawnSync } from 'node:child_process';
14
- import fs from 'node:fs';
15
- import os from 'node:os';
16
- import path from 'node:path';
17
-
18
- import { getUserEnvPath, writeEnvValues } from '../env-config.mjs';
19
-
20
- /** Local Postgres port (within Construct's 54329-54339 reserved block). */
21
- export const POSTGRES_LOCAL_PORT = 54329;
22
-
23
- export function isRemoteTelemetry(url = '') {
24
- if (!url) return false;
25
- return !url.includes('localhost') && !url.includes('127.0.0.1');
26
- }
27
-
28
- export function servicesComposePath(rootDir) {
29
- return path.join(rootDir, 'services', 'docker-compose.yml');
30
- }
31
-
32
- // Keep only the N most recent stash pairs (.dump + .json) in the stash dir.
33
- export function pruneStashDir(dir, keep = 3) {
34
- try {
35
- const dumps = fs.readdirSync(dir)
36
- .filter((f) => f.endsWith('.dump'))
37
- .sort()
38
- .reverse();
39
- for (const dump of dumps.slice(keep)) {
40
- fs.rmSync(path.join(dir, dump), { force: true });
41
- fs.rmSync(path.join(dir, dump.replace('.dump', '.json')), { force: true });
42
- }
43
- } catch { /* non-critical */ }
44
- }
45
-
46
- /**
47
- * Verify that the Postgres service is reachable. Returns a status object.
48
- * Verifies the Postgres service is up and ready.
49
- */
50
- export async function verifyPostgresHealth({
51
- port = POSTGRES_LOCAL_PORT,
52
- fetchFn = globalThis.fetch,
53
- maxRetries = 5,
54
- intervalMs = 2000,
55
- } = {}) {
56
- // Postgres is not HTTP — health is checked via pg_isready in docker instead
57
- for (let attempt = 0; attempt < maxRetries; attempt++) {
58
- try {
59
- const result = spawnSync('docker', [
60
- 'exec', 'construct-postgres',
61
- 'pg_isready', '-U', 'construct', '-d', 'construct',
62
- ], { stdio: 'pipe', timeout: 3000 });
63
- if (result.status === 0) return { status: 'verified' };
64
- } catch { /* not ready yet */ }
65
- if (attempt < maxRetries - 1) await new Promise((r) => setTimeout(r, intervalMs));
66
- }
67
- return { status: 'unreachable' };
68
- }
69
-
70
- /**
71
- * Verify telemetry backend keys (remote HTTP).
72
- *
73
- */
74
- export async function verifyTelemetryKeys({
75
- baseUrl,
76
- publicKey,
77
- secretKey,
78
- composeRunner,
79
- composeFile,
80
- homeDir = os.homedir(),
81
- maxRetries = 5,
82
- intervalMs = 2000,
83
- spawnSyncFn = spawnSync,
84
- fetchFn = globalThis.fetch,
85
- overallTimeoutMs = 0,
86
- } = {}) {
87
- const resolvedBaseUrl =
88
- baseUrl ??
89
- process.env.CONSTRUCT_TELEMETRY_URL ??
90
- '';
91
- const resolvedPublicKey =
92
- publicKey ??
93
- process.env.CONSTRUCT_TELEMETRY_PUBLIC_KEY ??
94
- '';
95
- const resolvedSecretKey =
96
- secretKey ??
97
- process.env.CONSTRUCT_TELEMETRY_SECRET_KEY ??
98
- '';
99
-
100
- if (!resolvedPublicKey || !resolvedSecretKey || !resolvedBaseUrl) {
101
- return { status: 'unconfigured' };
102
- }
103
-
104
- async function doVerify() {
105
- const auth = `Basic ${Buffer.from(`${resolvedPublicKey}:${resolvedSecretKey}`).toString('base64')}`;
106
- for (let attempt = 0; attempt < maxRetries; attempt++) {
107
- try {
108
- const controller = new AbortController();
109
- const timer = setTimeout(() => controller.abort(), 3000);
110
- const res = await fetchFn(`${resolvedBaseUrl}/api/public/health`, {
111
- signal: controller.signal,
112
- }).finally(() => clearTimeout(timer));
113
- if (res.ok) break;
114
- } catch { /* not ready yet */ }
115
- if (attempt < maxRetries - 1) await new Promise((r) => setTimeout(r, intervalMs));
116
- }
117
-
118
- try {
119
- const controller = new AbortController();
120
- const timer = setTimeout(() => controller.abort(), 5000);
121
- const res = await fetchFn(`${resolvedBaseUrl}/api/public/traces?limit=1`, {
122
- headers: { Authorization: auth },
123
- signal: controller.signal,
124
- }).finally(() => clearTimeout(timer));
125
- if (res.ok) return { status: 'verified' };
126
- } catch {
127
- return { status: 'unreachable' };
128
- }
129
- return { status: 'auth-failed', reseeded: false };
130
- }
131
-
132
- if (overallTimeoutMs > 0) {
133
- return Promise.race([
134
- doVerify(),
135
- new Promise((_, reject) =>
136
- setTimeout(() => reject(new Error('verifyTelemetryKeys timeout')), overallTimeoutMs),
137
- ),
138
- ]);
139
- }
140
- return doVerify();
141
- }
142
-
143
- /**
144
- * Bring up the local Postgres service via services/docker-compose.yml.
145
- * Starts the local Postgres service via services/docker-compose.yml.
146
- */
147
- export async function startManagedServices({
148
- rootDir,
149
- homeDir = os.homedir(),
150
- env = process.env,
151
- composeRunner,
152
- spawnDetached,
153
- } = {}) {
154
- if (!rootDir) throw new Error('startManagedServices: rootDir is required');
155
- if (!composeRunner) throw new Error('startManagedServices: composeRunner is required');
156
- if (!spawnDetached) throw new Error('startManagedServices: spawnDetached is required');
157
-
158
- const composeFile = servicesComposePath(rootDir);
159
- if (!fs.existsSync(composeFile)) {
160
- return { status: 'unavailable', note: `services compose file missing at ${composeFile}` };
161
- }
162
-
163
- const { logPath } = spawnDetached(
164
- composeRunner.command,
165
- [...composeRunner.argsPrefix, '-p', 'construct-services', '-f', composeFile, 'up', '-d'],
166
- homeDir,
167
- 'services.log',
168
- );
169
-
170
- return {
171
- status: 'started',
172
- note: `Postgres started — connect at localhost:${POSTGRES_LOCAL_PORT}`,
173
- composeFile,
174
- logPath,
175
- };
176
- }
177
-
@@ -1,95 +0,0 @@
1
- /**
2
- * lib/storage/fusion.mjs — score fusion for hybrid retrieval.
3
- *
4
- * Exposes two fusion strategies for combining ranked results from
5
- * multiple retrieval signals (lexical, vector, metadata, recency):
6
- *
7
- * fuseScores({ vector, lexical, metadata, recency }, weights)
8
- * — weighted linear combination, returns the final score and
9
- * component scores so callers can debug retrieval drift.
10
- *
11
- * reciprocalRankFusion(rankedLists, { k })
12
- * — RRF: finalScore = Σ 1 / (k + rank_i) for each list a doc appears in.
13
- * Order-insensitive across lists; robust to score-scale mismatch
14
- * between lexical (BM25) and vector (cosine) backends.
15
- *
16
- * Default weights match the source plan: vector 0.45, lexical 0.35,
17
- * metadata 0.15, recency 0.05. Callers override per-query when needed
18
- * (e.g. weight metadata higher for filter-driven queries).
19
- */
20
-
21
- export const DEFAULT_WEIGHTS = Object.freeze({
22
- vector: 0.45,
23
- lexical: 0.35,
24
- metadata: 0.15,
25
- recency: 0.05,
26
- });
27
-
28
- function clamp01(value) {
29
- if (!Number.isFinite(value)) return 0;
30
- if (value < 0) return 0;
31
- if (value > 1) return 1;
32
- return value;
33
- }
34
-
35
- /**
36
- * Compute a fused score from component signals.
37
- *
38
- * @param {object} components
39
- * @param {number} [components.vector] cosine similarity in [0, 1]
40
- * @param {number} [components.lexical] normalized lexical score in [0, 1]
41
- * @param {number} [components.metadata] metadata-match score in [0, 1]
42
- * @param {number} [components.recency] freshness score in [0, 1]
43
- * @param {object} [weights]
44
- * @returns {{ vector, lexical, metadata, recency, finalScore, weights }}
45
- */
46
- export function fuseScores(components = {}, weights = DEFAULT_WEIGHTS) {
47
- const v = clamp01(components.vector);
48
- const l = clamp01(components.lexical);
49
- const m = clamp01(components.metadata);
50
- const r = clamp01(components.recency);
51
- const w = { ...DEFAULT_WEIGHTS, ...weights };
52
- const finalScore = clamp01(v * w.vector + l * w.lexical + m * w.metadata + r * w.recency);
53
- return { vector: v, lexical: l, metadata: m, recency: r, finalScore, weights: w };
54
- }
55
-
56
- /**
57
- * Reciprocal Rank Fusion across N ranked lists. Each list is an array
58
- * of `{ id, ... }` objects ordered by descending relevance.
59
- *
60
- * @param {Array<Array<object>>} rankedLists
61
- * @param {object} [opts]
62
- * @param {number} [opts.k=60]
63
- * @returns {Array<{ id, rrfScore, appearsIn: number }>}
64
- */
65
- export function reciprocalRankFusion(rankedLists, { k = 60 } = {}) {
66
- if (!Array.isArray(rankedLists) || rankedLists.length === 0) return [];
67
- const scores = new Map();
68
- for (const list of rankedLists) {
69
- if (!Array.isArray(list)) continue;
70
- list.forEach((item, idx) => {
71
- if (!item?.id) return;
72
- const rrf = 1 / (k + idx + 1);
73
- const prev = scores.get(item.id) || { id: item.id, rrfScore: 0, appearsIn: 0 };
74
- prev.rrfScore += rrf;
75
- prev.appearsIn += 1;
76
- scores.set(item.id, prev);
77
- });
78
- }
79
- return [...scores.values()].sort((a, b) => b.rrfScore - a.rrfScore);
80
- }
81
-
82
- /**
83
- * Compute a normalized recency score in [0, 1] given a timestamp and
84
- * a half-life in days. A doc updated today scores ~1, a doc older than
85
- * 4× the half-life scores near 0.
86
- */
87
- export function recencyScore(updatedAt, { halfLifeDays = 30, now = Date.now() } = {}) {
88
- if (!updatedAt) return 0;
89
- const ts = updatedAt instanceof Date ? updatedAt.getTime() : Date.parse(updatedAt);
90
- if (!Number.isFinite(ts)) return 0;
91
- const ageMs = Math.max(0, now - ts);
92
- const halfLifeMs = halfLifeDays * 24 * 60 * 60 * 1000;
93
- if (halfLifeMs <= 0) return 0;
94
- return clamp01(Math.pow(0.5, ageMs / halfLifeMs));
95
- }