@starci/hfs 4.7.27 → 4.7.29
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 +17 -0
- package/README.md +9 -3
- package/package.json +7 -1
- package/release/candidate-ci.mjs +3 -21
- package/runtime/engine/yaml.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +913 -637
- package/runtime/knowledge/hfs/slots.yaml +21 -14
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +146 -58
- package/runtime/scripts/api/git/check-ignore.mjs +5 -0
- package/runtime/scripts/gates/runtime-host.mjs +83 -0
- package/runtime/scripts/gates/sonar-gate.mjs +251 -0
- package/runtime/scripts/gates/starcistacks-ci.mjs +55 -0
- package/runtime/scripts/gates/starcistacks-form.mjs +26 -0
- package/runtime/scripts/gates/starcistacks-runtime.mjs +2 -0
- package/runtime/scripts/gates/starcistacks-value-file.mjs +17 -0
- package/runtime/scripts/gates/starcistacks.mjs +499 -0
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +1 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +7 -10
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/bypass-markers.mjs +24 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/lib/bypass-markers.mjs +116 -0
- package/runtime/scripts/lib/check-code-loosening.mjs +128 -0
- package/runtime/scripts/lib/example-refs.mjs +205 -0
- package/runtime/scripts/lib/failure-code-reads.mjs +100 -0
- package/runtime/scripts/lib/gate-loosening.mjs +52 -8
- package/runtime/scripts/lib/json-formats.mjs +34 -0
- package/runtime/scripts/lib/package-at.mjs +1 -1
- package/runtime/scripts/lib/properties.mjs +16 -0
- package/runtime/scripts/lib/provision-control-failure.mjs +32 -0
- package/runtime/scripts/lib/sonar-new-code.mjs +58 -0
- package/runtime/scripts/lib/starciwork-boundary.mjs +164 -0
- package/runtime/scripts/lib/work-edition.mjs +40 -0
- package/scaffold/app.mjs +3 -0
- package/scaffold/service.mjs +1 -1
- package/scaffold/test-world.mjs +125 -0
- package/src/main.mjs +1 -1
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +16 -4
- package/templates/app/hooks/husky/pre-commit +8 -7
- package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +32 -0
- package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +14 -0
- package/templates/be/test-world/integration/readiness.integration-spec.ts.tpl +28 -0
- package/templates/be/test-world/world/global-setup.ts.tpl +1 -0
- package/templates/be/test-world/world/global-teardown.ts.tpl +1 -0
- package/templates/be/test-world/world/test-apps.options.ts.tpl +62 -0
- package/templates/be/test-world/world/test-world.config.ts.tpl +20 -0
- package/templates/be/test-world/world/use-test-world.ts.tpl +2 -0
- package/upgrade/index.mjs +48 -9
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-starcistacks.mjs - the .starcistacks services contract (owner ruling 2026-09-24: "the stacks make sonar
|
|
3
|
+
// clear ... update .claude and enforce the .starcistacks format").
|
|
4
|
+
//
|
|
5
|
+
// Every product states the delivery and quality services its code and CI use - Sonar, a
|
|
6
|
+
// container registry, analytics, error tracking - in the `services` block of its stack declaration
|
|
7
|
+
// (modules/schemas/application-stacks.schema.yaml $defs.service): provider, mode local|hosted|disabled,
|
|
8
|
+
// host (local and public URL), the stack that runs a local one, project keys per repository, credentials
|
|
9
|
+
// as custody references, how GitHub CI gets them and ownerAction. A Kernel or an op reads this block
|
|
10
|
+
// before it asks for any credential, and never asks the owner for what it declares `ownerAction: none`
|
|
11
|
+
// with custody present.
|
|
12
|
+
//
|
|
13
|
+
// starci gate starcistacks <repo-root> [--new] [--json]
|
|
14
|
+
//
|
|
15
|
+
// --new the repository is being created by this leg (interface.scaffold, backend.scaffold,
|
|
16
|
+
// package.scaffold): a missing declaration or services block is refused, not a suspect
|
|
17
|
+
//
|
|
18
|
+
// Refusals: an unknown or ambiguous service declaration, custody that is missing where it is declared,
|
|
19
|
+
// an owner action declared for something custody already holds, CI calling a service the declaration
|
|
20
|
+
// disables or omits, and a tracked plaintext secret. Everything an existing repository merely lacks -
|
|
21
|
+
// the declaration, the services block, the .starcistacks rename, ignore rules - is a suspect with a
|
|
22
|
+
// planned follow-up (workspace.manage mode stacks), never a block on running work.
|
|
23
|
+
//
|
|
24
|
+
// Values are never read: custody presence is a file-existence test, and nothing here prints a secret.
|
|
25
|
+
// Exit 0 clean (suspects allowed), 1 refused, 2 usage.
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { lsFiles } from '../api/git/ls-files.mjs'; import { checkIgnore } from '../api/git/check-ignore.mjs';
|
|
29
|
+
import { repositoryName, repositoryHome } from '../hfs/repo-identity.mjs';
|
|
30
|
+
import { isMain } from '../lib/is-main.mjs';
|
|
31
|
+
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
32
|
+
import {isPlainObject as plain} from '../../engine/plain-object.mjs';
|
|
33
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
34
|
+
import { list } from '../lib/list.mjs';
|
|
35
|
+
import { isFile, isDir } from '../lib/fs-kind.mjs';
|
|
36
|
+
import { loadSonarGate } from './sonar-gate.mjs';
|
|
37
|
+
import { resolveCustodyFile, resolveDeclaredRepository, runtimeHostRoot } from './runtime-host.mjs';
|
|
38
|
+
import { slash, trimTrailingSlashes } from '../lib/path-key.mjs';
|
|
39
|
+
import { DECLARATION, STACK_ROOT, declaredStack, findStackDeclaration, readDeclaration, readText, text } from '../lib/stack-declaration.mjs';
|
|
40
|
+
import { readProperties } from '../lib/properties.mjs';
|
|
41
|
+
import { escapeRegExp } from '../lib/regex.mjs';
|
|
42
|
+
import { isInfrastructureValueFile } from './starcistacks-value-file.mjs';
|
|
43
|
+
import { STACK_DECLARATION_TEMPLATE } from '../lib/example-refs.mjs';
|
|
44
|
+
import { checkSecretForm, kindOf } from './starcistacks-form.mjs';
|
|
45
|
+
import { SERVICE_CATALOG, checkServiceForm, checkCiDeclaration, checkQualityService } from './starcistacks-ci.mjs';
|
|
46
|
+
import { applicationStackFindings } from './starcistacks-runtime.mjs';
|
|
47
|
+
export { DECLARATION, STACK_ROOT, findStackDeclaration };
|
|
48
|
+
const RESULT_SCHEMA = 'starci/starcistacks-check@1';
|
|
49
|
+
const DECLARATION_SCHEMA = 'starci/application-stacks@1';
|
|
50
|
+
const FOLLOW_UP = { op: 'workspace.manage', params: { mode: 'stacks' },
|
|
51
|
+
detail: `author the services block of the repository stack declaration (sonar and every other delivery/quality service) from ${STACK_DECLARATION_TEMPLATE}` };
|
|
52
|
+
/** Every finding code this check can emit. */
|
|
53
|
+
export const CODES = [
|
|
54
|
+
'STACKS_BADGE_TOKEN_SERVICE', 'STACKS_DECLARATION_MISSING', 'STACKS_DECLARATION_INVALID', 'STACKS_SERVICES_MISSING',
|
|
55
|
+
'STACKS_SCHEMA_INVALID', 'STACKS_SERVICE_UNKNOWN', 'STACKS_PROVIDER_UNKNOWN',
|
|
56
|
+
'STACKS_SERVICE_AMBIGUOUS', 'STACKS_STACK_UNRESOLVED', 'STACKS_CUSTODY_MISSING', 'STACKS_CUSTODY_UNVERIFIED',
|
|
57
|
+
'STACKS_OWNER_ACTION_REDUNDANT', 'STACKS_PROJECT_MISSING', 'STACKS_PROJECT_DRIFT', 'STACKS_HOST_DRIFT',
|
|
58
|
+
'STACKS_SERVICE_UNDECLARED', 'STACKS_CI_CONTRADICTION', 'STACKS_CI_UNUSED', 'STACKS_CI_NAME_UNREFERENCED',
|
|
59
|
+
'STACKS_PLAINTEXT_TRACKED', 'STACKS_ENC_TWIN_MISSING', 'STACKS_GITIGNORE_OPEN', 'STACKS_DECLARATION_IGNORED',
|
|
60
|
+
'STACKS_GITIGNORE_VALUE_OPEN', 'STACKS_RUNBOOK_IGNORED', 'STACKS_QUALITY_GATE_DRIFT',
|
|
61
|
+
'CI_DECLARATION_INVALID', 'STACKS_CI_LEGACY_FORM',
|
|
62
|
+
'STACKS_EXAMPLE_FORM', 'STACKS_PRODUCT_FORM', 'HFS_STACKS_SHAPE',
|
|
63
|
+
];
|
|
64
|
+
/** Paths (under <root>/<environment>/) the ignore rules must deny; they need not exist. */
|
|
65
|
+
const INFRA_VALUE_PROBES = ['infra/compose/.env', 'infra/compose/.env.generated', 'infra/compose/service/.env.local',
|
|
66
|
+
'infra/compose/tls.key', 'infra/terraform/terraform.tfvars'];
|
|
67
|
+
let schemaCache = null;
|
|
68
|
+
function declarationSchema() {
|
|
69
|
+
if (!schemaCache) schemaCache = parseYaml(fs.readFileSync(path.join(skillRoot, 'modules', 'schemas', 'application-stacks.schema.yaml'), 'utf8'));
|
|
70
|
+
return schemaCache;
|
|
71
|
+
}
|
|
72
|
+
const appendSchemaArrayErrors = (out, node, s, where, run) => { node.forEach((item, index) => { if (s.items) out.push(...run(item, s.items, `${where}[${index}]`)); }); };
|
|
73
|
+
const appendSchemaObjectErrors = (out, node, s, where, run) => {
|
|
74
|
+
if (s.minProperties && Object.keys(node).length < s.minProperties) out.push({ path: where, message: 'object has too few properties' });
|
|
75
|
+
for (const key of s.required ?? []) if (!Object.hasOwn(node, key)) out.push({ path: where, message: `missing ${key}` });
|
|
76
|
+
for (const [key, child] of Object.entries(node)) {
|
|
77
|
+
if (Object.hasOwn(s.properties ?? {}, key)) out.push(...run(child, s.properties[key], `${where}.${key}`));
|
|
78
|
+
else if (s.additionalProperties === false) out.push({ path: `${where}.${key}`, message: 'unknown field' });
|
|
79
|
+
else if (plain(s.additionalProperties)) out.push(...run(child, s.additionalProperties, `${where}.${key}`));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** The subset of JSON Schema the declaration schema uses: type, const, enum, pattern, minLength, required, properties, additionalProperties, items, minProperties, minimum, maximum, $ref, anyOf. */
|
|
83
|
+
function schemaErrors(value, shape, at = '$', root = declarationSchema()) {
|
|
84
|
+
const resolve = (ref) => ref.slice(2).split('/').reduce((node, key) => node?.[key], root);
|
|
85
|
+
const typeOk = (node, s) => ({ object: plain, array: Array.isArray, integer: Number.isInteger }[s.type] ?? ((n) => (s.type ? typeof n === s.type : true)))(node);
|
|
86
|
+
const run = (node, s, where) => {
|
|
87
|
+
if (!plain(s)) return [];
|
|
88
|
+
if (s.$ref) return run(node, resolve(s.$ref), where);
|
|
89
|
+
if (Array.isArray(s.anyOf) && !s.anyOf.some((option) => run(node, option, where).length === 0))
|
|
90
|
+
return [{ path: where, message: 'matches none of the allowed shapes' }];
|
|
91
|
+
if (!typeOk(node, s)) return [{ path: where, message: `expected ${s.type}` }];
|
|
92
|
+
const out = [];
|
|
93
|
+
if (Object.hasOwn(s, 'const') && node !== s.const) out.push({ path: where, message: `must be ${JSON.stringify(s.const)}` });
|
|
94
|
+
if (Array.isArray(s.enum) && !s.enum.includes(node)) out.push({ path: where, message: `must be one of ${s.enum.join(', ')}` });
|
|
95
|
+
if (typeof node === 'number' && ((s.minimum !== undefined && node < s.minimum) || (s.maximum !== undefined && node > s.maximum))) out.push({ path: where, message: 'number outside allowed range' });
|
|
96
|
+
if (typeof node === 'string') out.push(...stringErrors(node, s, where));
|
|
97
|
+
if (Array.isArray(node)) { appendSchemaArrayErrors(out, node, s, where, run); return out; }
|
|
98
|
+
if (!plain(node)) return out;
|
|
99
|
+
appendSchemaObjectErrors(out, node, s, where, run);
|
|
100
|
+
return out;
|
|
101
|
+
};
|
|
102
|
+
const stringErrors = (node, s, where) => {
|
|
103
|
+
const out = [];
|
|
104
|
+
if (s.minLength && node.length < s.minLength) out.push({ path: where, message: 'string too short' });
|
|
105
|
+
if (s.pattern && !new RegExp(s.pattern, 'u').test(node)) out.push({ path: where, message: `does not match ${s.pattern}` });
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
return run(value, shape, at);
|
|
109
|
+
}
|
|
110
|
+
// ---- locating declarations and repositories -------------------------------------------------------------
|
|
111
|
+
/** A frontend declared by its backend's stack: a sibling declaration whose `sources` lists this repository. */
|
|
112
|
+
function governingDeclaration(repo) {
|
|
113
|
+
const name = repositoryName(repo);
|
|
114
|
+
const parent = path.dirname(repositoryHome(repo));
|
|
115
|
+
let entries = []; try { entries = fs.readdirSync(parent, { withFileTypes: true }); } catch { return null; }
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (!entry.isDirectory() || entry.name === name) continue;
|
|
118
|
+
const file = path.join(parent, entry.name, STACK_ROOT, DECLARATION);
|
|
119
|
+
if (!isFile(file)) continue;
|
|
120
|
+
const read = readDeclaration(file);
|
|
121
|
+
if (read.doc && list(read.doc.sources).some((source) => source?.repository === name))
|
|
122
|
+
return { repo: path.join(parent, entry.name), root: STACK_ROOT, file, governs: name, ...read };
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// ---- normalizing a service ------------------------------------------------------------------------------
|
|
127
|
+
const normalizeCredential = (credential, declaringRepo) => {
|
|
128
|
+
const custody = plain(credential.custody) ? credential.custody : {};
|
|
129
|
+
const repo = resolveDeclaredRepository(custody.repository, { fromRepo: declaringRepo });
|
|
130
|
+
const rel = text(custody.path);
|
|
131
|
+
const abs = resolveCustodyFile(repo, rel);
|
|
132
|
+
return { id: text(credential.id), env: text(credential.env), key: text(credential.key), purpose: text(credential.purpose),
|
|
133
|
+
custody: { repository: text(custody.repository), path: rel, resolved: Boolean(repo), file: abs,
|
|
134
|
+
encPresent: abs ? isFile(`${abs}.enc`) : null, plainPresent: abs ? isFile(abs) : null } };
|
|
135
|
+
};
|
|
136
|
+
const ownerActionOf = (s) => {
|
|
137
|
+
const noAction = s.ownerAction === 'none' ? 'none' : null;
|
|
138
|
+
return plain(s.ownerAction) ? { needed: text(s.ownerAction.needed), reason: text(s.ownerAction.reason) } : noAction;
|
|
139
|
+
};
|
|
140
|
+
/** Resolve a declared stack to its host or repository directory without reading custody values. */
|
|
141
|
+
function normalizeStack(stack, entry, declaringRepo) {
|
|
142
|
+
// owner: host - the stack lives inside the installed runtime tree (.claude/ext/<service>);
|
|
143
|
+
// `environment` is the slot the extension serves, not a path segment (<root>/<compose> resolves it).
|
|
144
|
+
const hostOwned = declaredStack(entry)?.hostOwned ?? false;
|
|
145
|
+
const hostRoot = hostOwned ? slash(stack.root) : null;
|
|
146
|
+
const extDir = hostRoot && hostRoot.startsWith('.claude/') && !hostRoot.split('/').includes('..')
|
|
147
|
+
? path.join(skillRoot, hostRoot.replace(/^\.claude\//, '')) : null;
|
|
148
|
+
const stackRepo = stack && !hostOwned ? resolveDeclaredRepository(stack.repository, { fromRepo: declaringRepo }) : null;
|
|
149
|
+
let stackDir = null;
|
|
150
|
+
if (hostOwned) stackDir = extDir;
|
|
151
|
+
else if (stackRepo && text(stack.root) && text(stack.environment)) stackDir = path.join(stackRepo, stack.root, stack.environment);
|
|
152
|
+
return stack ? { owner: text(stack.owner), repository: text(stack.repository), root: text(stack.root), environment: text(stack.environment), compose: text(stack.compose),
|
|
153
|
+
container: text(stack.container), publishedBy: text(stack.publishedBy), hostOwned, repo: stackRepo, dir: stackDir,
|
|
154
|
+
composeFile: stackDir && text(stack.compose) ? path.join(stackDir, stack.compose) : null } : null;
|
|
155
|
+
}
|
|
156
|
+
/** One declared service with its custody resolved to presence (never a value). */
|
|
157
|
+
export function normalizeService(id, entry, { declaringRepo } = {}) {
|
|
158
|
+
const s = plain(entry) ? entry : {};
|
|
159
|
+
const stack = plain(s.stack) ? s.stack : null;
|
|
160
|
+
const normalizedStack = normalizeStack(stack, entry, declaringRepo);
|
|
161
|
+
const credentials = list(s.credentials).filter(plain).map((credential) => normalizeCredential(credential, declaringRepo));
|
|
162
|
+
const ci = plain(s.ci) ? s.ci : {};
|
|
163
|
+
return {
|
|
164
|
+
id, organization: text(s.organization), provider: text(s.provider), mode: text(s.mode), purpose: text(s.purpose), reason: text(s.reason), auth: text(s.auth), qualityGate: text(s.qualityGate),
|
|
165
|
+
host: { local: text(s.host?.local), public: text(s.host?.public), fromCredential: text(s.host?.fromCredential) },
|
|
166
|
+
stack: normalizedStack,
|
|
167
|
+
projects: list(s.projects).filter(plain).map((project) => ({ repository: text(project.repository), key: text(project.key), name: text(project.name) })),
|
|
168
|
+
credentials,
|
|
169
|
+
runtimeSecrets: list(s.runtimeSecrets).map(String),
|
|
170
|
+
ci: { token: text(ci.token), hostVar: text(ci.hostVar),
|
|
171
|
+
organizationVar: text(ci.organizationVar) || (s.provider === 'sonarcloud' && list(s.runtimeSecrets).length && !credentials.length ? 'SONAR_ORGANIZATION' : null), wiring: text(ci.wiring), workflow: text(ci.workflow), permissions: list(ci.permissions).map(String),
|
|
172
|
+
secrets: list(ci.secrets).filter(plain).map((item) => ({ name: text(item.name), credential: text(item.credential) })),
|
|
173
|
+
vars: list(ci.vars).filter(plain).map((item) => ({ name: text(item.name), value: text(item.value) })),
|
|
174
|
+
provisioning: text(ci.provisioning) },
|
|
175
|
+
ownerAction: ownerActionOf(s),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const custodyHeld = (service) => (service.credentials.length ? service.credentials.every((credential) => credential.custody.encPresent || credential.custody.plainPresent) : ['oidc', 'github-token', 'none'].includes(service.auth));
|
|
179
|
+
/**
|
|
180
|
+
* What a tool (scripts/gates/sonar-local.mjs, a CI wiring leg) reads for one service of one repository:
|
|
181
|
+
* the repository's own (or governing) declaration first, then the source host's declaration when its entry
|
|
182
|
+
* lists this repository among its projects. Null when nothing declares it.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveStackService(repoRoot, serviceId) {
|
|
185
|
+
const repo = path.resolve(String(repoRoot ?? ''));
|
|
186
|
+
const name = repositoryName(repo);
|
|
187
|
+
const own = findStackDeclaration(repo);
|
|
188
|
+
const candidates = [own.doc ? own : governingDeclaration(repo)].filter(Boolean);
|
|
189
|
+
const host = runtimeHostRoot();
|
|
190
|
+
if (path.resolve(host) !== repo) { const source = findStackDeclaration(host); if (source.doc) candidates.push({ ...source, sourceHost: true }); }
|
|
191
|
+
for (const declaration of candidates) {
|
|
192
|
+
const entry = declaration.doc?.services?.[serviceId];
|
|
193
|
+
if (!plain(entry)) continue;
|
|
194
|
+
let service = normalizeService(serviceId, entry, { declaringRepo: declaration.repo });
|
|
195
|
+
const listed = service.projects.some((project) => project.repository === name);
|
|
196
|
+
if (declaration.sourceHost && !listed) continue;
|
|
197
|
+
// Through the source host a product gets the shared server, its shared credentials and its own project
|
|
198
|
+
// token (a credential whose id is a project key); other projects' tokens and the host's CI wiring are not its.
|
|
199
|
+
const ownKey = service.projects.find((project) => project.repository === name)?.key;
|
|
200
|
+
const projectKeys = new Set(service.projects.map((project) => project.key));
|
|
201
|
+
if (declaration.sourceHost) service = { ...service, credentials: service.credentials.filter((credential) => !projectKeys.has(credential.id) || credential.id === ownKey),
|
|
202
|
+
ci: { wiring: null, workflow: null, permissions: [], secrets: [], vars: [], provisioning: null } };
|
|
203
|
+
return { declaration: declaration.file, declaringRepo: declaration.repo, sourceHost: Boolean(declaration.sourceHost),
|
|
204
|
+
repository: name, projectKey: service.projects.find((project) => project.repository === name)?.key ?? null, ...service };
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
// ---- repository evidence --------------------------------------------------------------------------------
|
|
209
|
+
function workflowTexts(repo) {
|
|
210
|
+
const dir = path.join(repo, '.github', 'workflows');
|
|
211
|
+
let entries = []; try { entries = fs.readdirSync(dir).filter((name) => /\.ya?ml$/i.test(name)); } catch { return []; }
|
|
212
|
+
return entries.map((name) => ({ file: `.github/workflows/${name}`, text: readText(path.join(dir, name)) ?? '' }));
|
|
213
|
+
}
|
|
214
|
+
function git(call, repo, args) {
|
|
215
|
+
try {
|
|
216
|
+
const result = call(args, { dir: repo, timeout: 8000 });
|
|
217
|
+
if (result.error) return null;
|
|
218
|
+
return { status: result.status, stdout: String(result.stdout ?? '') };
|
|
219
|
+
} catch { return null; }
|
|
220
|
+
}
|
|
221
|
+
// ---- the check ------------------------------------------------------------------------------------------
|
|
222
|
+
const checkLocalStack = (service, at, add, ambiguous) => {
|
|
223
|
+
if (!service.host.local) ambiguous('a local service names host.local, the URL the stack serves it on');
|
|
224
|
+
if (!service.stack) ambiguous('a local service names the stack that runs it (repository, root, environment, compose) or owns it as a host extension (owner: host, root: .claude/ext/<service>)');
|
|
225
|
+
else if (service.stack.hostOwned) {
|
|
226
|
+
if (!service.stack.composeFile || !isFile(service.stack.composeFile))
|
|
227
|
+
add('refuse', 'STACKS_STACK_UNRESOLVED', `${at}.stack`, `the host extension file ${service.stack.root}/${service.stack.compose} does not exist under the runtime tree (${slash(skillRoot)})`);
|
|
228
|
+
const publisher = service.stack.publishedBy;
|
|
229
|
+
if (publisher && service.stack.dir && !isFile(path.join(service.stack.dir, publisher)))
|
|
230
|
+
add('refuse', 'STACKS_STACK_UNRESOLVED', `${at}.stack.publishedBy`, `the host extension file ${service.stack.root}/${publisher} does not exist under the runtime tree (${slash(skillRoot)})`);
|
|
231
|
+
}
|
|
232
|
+
else if (!service.stack.repo) add('suspect', 'STACKS_STACK_UNRESOLVED', `${at}.stack`, `repository ${service.stack.repository} is not checked out on this machine; its compose file and custody are unverified`);
|
|
233
|
+
else if (!service.stack.composeFile || !isFile(service.stack.composeFile))
|
|
234
|
+
add('refuse', 'STACKS_STACK_UNRESOLVED', `${at}.stack`, `${service.stack.repository}/${service.stack.root}/${service.stack.environment}/${service.stack.compose} does not exist`);
|
|
235
|
+
};
|
|
236
|
+
const checkServiceCredentials = (service, at, add, ambiguous) => {
|
|
237
|
+
const ids = new Set();
|
|
238
|
+
for (const credential of service.credentials) {
|
|
239
|
+
const where = `${at}.credentials.${credential.id ?? '?'}`;
|
|
240
|
+
if (credential.id && ids.has(credential.id)) ambiguous(`credential id ${credential.id} is declared twice`);
|
|
241
|
+
ids.add(credential.id);
|
|
242
|
+
if (!credential.env && !credential.key) ambiguous(`credential ${credential.id ?? '?'} names the env variable or the file key its consumer reads`);
|
|
243
|
+
if (!credential.custody.resolved) add('suspect', 'STACKS_CUSTODY_UNVERIFIED', where, `custody repository ${credential.custody.repository} is not checked out on this machine`);
|
|
244
|
+
else if (!credential.custody.encPresent)
|
|
245
|
+
add('refuse', 'STACKS_CUSTODY_MISSING', where, `${credential.custody.repository}/${credential.custody.path}.enc is not in custody; repair the stack custody (secret:gen / stack-secret) - never ask the owner for a value the declaration says the runtime holds`);
|
|
246
|
+
}
|
|
247
|
+
return ids;
|
|
248
|
+
};
|
|
249
|
+
const checkCiNames = (service, id, at, ciUses, ciText, add) => { if (['required', 'optional-follow-up'].includes(service.ci.wiring) && ciUses[id].length) for (const item of [...service.ci.secrets, ...service.ci.vars]) if (item.name && !new RegExp(String.raw`\b${escapeRegExp(item.name)}\b`).test(ciText)) add('suspect', 'STACKS_CI_NAME_UNREFERENCED', `${at}.ci`, `${item.name} is declared for CI but no workflow reads it`); };
|
|
250
|
+
const checkServiceCi = (service, id, at, { governing, name, ciUses, ciText, add, ambiguous }) => {
|
|
251
|
+
const ids = checkServiceCredentials(service, at, add, ambiguous);
|
|
252
|
+
for (const secret of service.ci.secrets)
|
|
253
|
+
if (secret.credential && !ids.has(secret.credential)) ambiguous(`ci secret ${secret.name} names credential ${secret.credential}, which the service does not declare`);
|
|
254
|
+
if (plain(service.ownerAction) && service.credentials.length && custodyHeld(service))
|
|
255
|
+
add('refuse', 'STACKS_OWNER_ACTION_REDUNDANT', `${at}.ownerAction`, 'every credential is already in custody; ownerAction must be none - the owner is never asked for what the runtime holds');
|
|
256
|
+
if (service.ci.wiring === 'not-used' && ciUses[id].length)
|
|
257
|
+
add('refuse', 'STACKS_CI_CONTRADICTION', `${at}.ci`, `ci.wiring not-used, but ${ciUses[id].join(', ')} call it`);
|
|
258
|
+
// A frontend governed by its backend declaration answers only for the services that name it as a project:
|
|
259
|
+
// the backend CI wiring (its image registry, say) is not the frontend's to call.
|
|
260
|
+
const servesThisRepository = !governing || service.projects.some((project) => project.repository === name);
|
|
261
|
+
if (service.ci.wiring === 'required' && !ciUses[id].length && SERVICE_CATALOG[id].ci.length && servesThisRepository)
|
|
262
|
+
add('suspect', 'STACKS_CI_UNUSED', `${at}.ci`, 'ci.wiring required, but no workflow calls the service');
|
|
263
|
+
checkCiNames(service, id, at, ciUses, ciText, add);
|
|
264
|
+
if (ciUses[id].length && service.projects.length && !service.projects.some((project) => project.repository === name) && !governing)
|
|
265
|
+
add('refuse', 'STACKS_PROJECT_MISSING', `${at}.projects`, `the workflows call ${id} but no project entry names repository ${name}`);
|
|
266
|
+
};
|
|
267
|
+
const checkDisabledService = (service, id, at, ciUses, ambiguous, add) => { if (service.mode !== 'disabled') { return false; } if (!service.reason) { ambiguous('a disabled service states its reason'); } if (ciUses[id].length) { add('refuse', 'STACKS_CI_CONTRADICTION', at, `declared disabled, but ${ciUses[id].join(', ')} call it`); } return true; };
|
|
268
|
+
const checkEnabledService = (service, id, at, { governing, name, ciUses, ciText, add, kind }, ambiguous) => {
|
|
269
|
+
checkSecretForm(service, id, at, kind, add);
|
|
270
|
+
if (service.mode === 'local') checkLocalStack(service, at, add, ambiguous);
|
|
271
|
+
if (service.mode === 'hosted' && !service.host.public && !service.host.fromCredential) ambiguous('a hosted service names host.public, or host.fromCredential when the endpoint travels inside a credential');
|
|
272
|
+
if (ciUses[id].length && service.ci.wiring !== 'not-used' && !service.host.public) ambiguous('GitHub CI reaches a service only through host.public; name it');
|
|
273
|
+
if (!service.auth) ambiguous('auth is token, oidc, github-token or none');
|
|
274
|
+
if (service.auth === 'token' && !service.credentials.length && !service.runtimeSecrets.length && !service.ci.token) ambiguous('auth token names at least one credential in custody, or the runtime secret.env variables an example reads (runtimeSecrets)');
|
|
275
|
+
if (service.host.fromCredential && !service.credentials.some((credential) => credential.id === service.host.fromCredential)) ambiguous(`host.fromCredential names ${service.host.fromCredential}, which is not one of its credentials`);
|
|
276
|
+
if (!service.projects.length) ambiguous('an enabled service lists the project key of every repository it serves');
|
|
277
|
+
checkServiceCi(service, id, at, { governing, name, ciUses, ciText, add, ambiguous });
|
|
278
|
+
};
|
|
279
|
+
const checkService = (id, entry, { schema, declFile, declaration, governing, name, ciUses, ciText, normalized, add, kind }) => {
|
|
280
|
+
if (entry?.badgeToken !== undefined && !['codecov', 'sonar'].includes(id)) add('refuse', 'STACKS_BADGE_TOKEN_SERVICE', `${declFile}#services.${id}.badgeToken`, 'badgeToken is allowed only on codecov or sonar');
|
|
281
|
+
if (!Object.hasOwn(SERVICE_CATALOG, id)) {
|
|
282
|
+
add('refuse', 'STACKS_SERVICE_UNKNOWN', `${declFile}#services.${id}`, `${id} is not a catalogued service (${Object.keys(SERVICE_CATALOG).join(', ')}); a service the catalog does not know is one nobody can read unambiguously`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
for (const issue of schemaErrors(entry, ['dependabot', 'audit'].includes(id) ? schema.$defs.serviceOptionalCi : schema.$defs.service, `services.${id}`))
|
|
286
|
+
add('refuse', 'STACKS_SCHEMA_INVALID', `${declFile}#${issue.path}`, issue.message);
|
|
287
|
+
const service = normalizeService(id, entry, { declaringRepo: declaration.repo });
|
|
288
|
+
normalized[id] = service;
|
|
289
|
+
const at = `${declFile}#services.${id}`;
|
|
290
|
+
const ambiguous = (message) => add('refuse', 'STACKS_SERVICE_AMBIGUOUS', at, message);
|
|
291
|
+
checkServiceForm(service, id, entry, at, kind, add, ambiguous);
|
|
292
|
+
if (checkDisabledService(service, id, at, ciUses, ambiguous, add)) return;
|
|
293
|
+
if (checkQualityService(service, id, at, { governing, name, ciUses, ciText, add, ambiguous }, checkServiceCi)) return;
|
|
294
|
+
checkEnabledService(service, id, at, { governing, name, ciUses, ciText, add, kind }, ambiguous);
|
|
295
|
+
};
|
|
296
|
+
const checkUndeclared = ({ repo, ciUses, normalized, services, declFile, add }) => {
|
|
297
|
+
for (const [id, files] of Object.entries(ciUses)) {
|
|
298
|
+
if (['codecov', 'dependabot', 'audit'].includes(id)) continue;
|
|
299
|
+
if (!files.length || normalized[id] || (services && Object.hasOwn(services, id))) continue;
|
|
300
|
+
const viaSource = resolveStackService(repo, id);
|
|
301
|
+
if (viaSource && !services) continue;
|
|
302
|
+
const followUp = services ? '' : ` - follow-up: ${FOLLOW_UP.op} mode stacks`;
|
|
303
|
+
add(services ? 'refuse' : 'suspect', 'STACKS_SERVICE_UNDECLARED', declFile,
|
|
304
|
+
`${files.join(', ')} call ${id}, which the declaration does not state${followUp}`);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const checkQualityGate = (sonar, normalized, declFile, add, repo) => { if (!sonar || sonar.mode === 'disabled') { return; } const gateName = loadSonarGate({ cwd: repo }).gate.name; if (sonar.qualityGate !== gateName) { add(normalized.sonar ? 'refuse' : 'suspect', 'STACKS_QUALITY_GATE_DRIFT', `${declFile}#services.sonar.qualityGate`, `services.sonar.qualityGate is ${sonar.qualityGate ?? 'absent'}; every product names the one gate ${gateName} (knowledge/sonar-gate.yaml owns its thresholds)`); } };
|
|
308
|
+
// Sonar settings the repository carries must agree with the declaration, and every repository names the one quality gate.
|
|
309
|
+
const checkSonarDrift = ({ repo, name, normalized, services, declFile, add }) => {
|
|
310
|
+
const sonar = normalized.sonar ?? (services ? null : resolveStackService(repo, 'sonar'));
|
|
311
|
+
const props = readProperties(path.join(repo, 'sonar-project.properties'));
|
|
312
|
+
if (sonar && sonar.mode !== 'disabled' && Object.keys(props).length) {
|
|
313
|
+
const declared = sonar.projects.find((project) => project.repository === name)?.key;
|
|
314
|
+
if (declared && props['sonar.projectKey'] && props['sonar.projectKey'] !== declared)
|
|
315
|
+
add(normalized.sonar ? 'refuse' : 'suspect', 'STACKS_PROJECT_DRIFT', 'sonar-project.properties', `sonar.projectKey ${props['sonar.projectKey']} but the declaration names ${declared}`);
|
|
316
|
+
if (sonar.host.public && props['sonar.host.url'] && trimTrailingSlashes(props['sonar.host.url']) !== trimTrailingSlashes(sonar.host.public))
|
|
317
|
+
add('suspect', 'STACKS_HOST_DRIFT', 'sonar-project.properties', `sonar.host.url ${props['sonar.host.url']} but the declaration's public host is ${sonar.host.public}`);
|
|
318
|
+
}
|
|
319
|
+
// The one quality gate (knowledge/sonar-gate.yaml): a repository names it, never its own thresholds.
|
|
320
|
+
checkQualityGate(sonar, normalized, declFile, add, repo);
|
|
321
|
+
};
|
|
322
|
+
const checkTrackedCustody = ({ repo, root, add }) => {
|
|
323
|
+
const tracked = git(lsFiles, repo, ['--', root]);
|
|
324
|
+
if (tracked?.status !== 0) return;
|
|
325
|
+
for (const file of tracked.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
326
|
+
const parts = slash(file).split('/');
|
|
327
|
+
const custodyArea = parts.length > 3 && (parts[2] === 'runtime' || parts[2] === 'secrets');
|
|
328
|
+
const base = parts.at(-1);
|
|
329
|
+
// infra/** is tracked source, but a value file inside it (.env*, *.env, *.key, *.pem, *.tfvars)
|
|
330
|
+
// is plaintext custody (inc-5360513a96b3: infra/compose/.env.generated was trackable).
|
|
331
|
+
const infraValue = parts.length > 3 && parts[2] === 'infra' && isInfrastructureValueFile(base) && !base.endsWith('.enc') && !base.endsWith('.example');
|
|
332
|
+
if ((custodyArea && !base.endsWith('.enc') && !['KEYS.md', '.gitkeep'].includes(base)) || infraValue)
|
|
333
|
+
add('refuse', 'STACKS_PLAINTEXT_TRACKED', file, /\.enc\.(?:ya?ml|json|env)$/u.test(base)
|
|
334
|
+
? `${base} is named the wrong way round: a custody file is <name>.<fmt>.enc (rename it to ${base.replace(/\.enc\.(ya?ml|json|env)$/u, '.$1.enc')}) and every read states the format (scripts/api/sops/exec-env.mjs)`
|
|
335
|
+
: 'a custody member is tracked in plaintext; untrack it (git rm --cached), rotate the value and commit only its .enc twin');
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const checkEncTwins = ({ repo, root, add }) => {
|
|
339
|
+
for (const env of envDirs(repo, root)) {
|
|
340
|
+
const filesDir = path.join(repo, root, env.name, 'runtime', 'files');
|
|
341
|
+
let members = [];
|
|
342
|
+
try { members = fs.readdirSync(filesDir); } catch { continue; }
|
|
343
|
+
const bare = members.filter((member) => !member.endsWith('.enc') && !/\.md$/i.test(member) && member !== '.gitkeep'
|
|
344
|
+
&& !/\.(rotation-pending|bak)$/.test(member) && !members.includes(`${member}.enc`));
|
|
345
|
+
if (bare.length) add('suspect', 'STACKS_ENC_TWIN_MISSING', `${root}/${env.name}/runtime/files`,
|
|
346
|
+
`${bare.length} plaintext custody member(s) have no .enc twin (${bare.slice(0, 5).join(', ')}${bare.length > 5 ? ', ...' : ''}); encrypt them (stack-secret) so another machine can decrypt them`);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const checkIgnoreRules = ({ repo, root, add }) => {
|
|
350
|
+
// The rules must still deny a value file under infra AFTER the infra re-includes
|
|
351
|
+
// (stacks-layout.yaml custody.gitignoreRules). git check-ignore --no-index answers for paths
|
|
352
|
+
// that do not exist yet, so the probe proves the rule, not today's files.
|
|
353
|
+
for (const env of envDirs(repo, root)) {
|
|
354
|
+
const open = INFRA_VALUE_PROBES.map((probe) => `${root}/${env.name}/${probe}`)
|
|
355
|
+
.filter((rel) => git(checkIgnore, repo, ['-q', '--no-index', '--', rel])?.status === 1);
|
|
356
|
+
if (open.length) add('refuse', 'STACKS_GITIGNORE_VALUE_OPEN', '.gitignore',
|
|
357
|
+
`the ignore rules leave plaintext value files under ${root}/${env.name}/infra trackable (${open.join(', ')}); deny them after the infra re-includes and re-include *.enc last (modules/schemas/stacks-layout.yaml custody.gitignoreRules)`);
|
|
358
|
+
// The environment runbook is part of the layout contract (stacks-layout.yaml shape.runbook);
|
|
359
|
+
// rules that hide it strand it on one machine.
|
|
360
|
+
const runbook = `${root}/${env.name}/README.md`;
|
|
361
|
+
if (git(checkIgnore, repo, ['-q', '--no-index', '--', runbook])?.status === 0)
|
|
362
|
+
add('suspect', 'STACKS_RUNBOOK_IGNORED', runbook, `the ignore rules hide the ${env.name} runbook; re-include it (!${runbook}) so every machine reads it`);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
// Custody layout (modules/schemas/stacks-layout.yaml custody, the product repositories' secrets-guard).
|
|
366
|
+
const envDirs = (repo, root) => fs.readdirSync(path.join(repo, root), { withFileTypes: true }).filter((entry) => entry.isDirectory());
|
|
367
|
+
const checkCustodyLayout = ({ repo, own, add }) => {
|
|
368
|
+
if (!own.rooted) return;
|
|
369
|
+
const root = STACK_ROOT;
|
|
370
|
+
checkTrackedCustody({ repo, root, add });
|
|
371
|
+
checkEncTwins({ repo, root, add });
|
|
372
|
+
const ignore = readText(path.join(repo, '.gitignore')) ?? '';
|
|
373
|
+
if (!new RegExp(String.raw`^/?${escapeRegExp(root)}/\*\*\s*$`, 'm').test(ignore))
|
|
374
|
+
add('suspect', 'STACKS_GITIGNORE_OPEN', '.gitignore', `no deny-all rule \`${root}/**\`; custody plaintext is only safe when the tree is denied and *.enc, KEYS.md and the declaration are re-included`);
|
|
375
|
+
checkIgnoreRules({ repo, root, add });
|
|
376
|
+
};
|
|
377
|
+
const buildResult = ({ repo, name, newRepo, declaration, governing, normalized, findings }) => {
|
|
378
|
+
const pick = (level) => findings.filter((finding) => finding.level === level).map((finding) => `${finding.file}: ${finding.message} [${finding.code}]`);
|
|
379
|
+
const refused = pick('refuse');
|
|
380
|
+
const needsFollowUp = findings.some((finding) => ['STACKS_DECLARATION_MISSING', 'STACKS_SERVICES_MISSING', 'STACKS_SERVICE_UNDECLARED'].includes(finding.code) && finding.level !== 'refuse');
|
|
381
|
+
return {
|
|
382
|
+
schema: RESULT_SCHEMA, ok: refused.length === 0, repository: name, repoRoot: slash(repo), newRepo,
|
|
383
|
+
declaration: declaration?.file ? slash(declaration.file) : null, root: declaration?.root ?? null,
|
|
384
|
+
governedBy: governing ? slash(governing.repo) : null,
|
|
385
|
+
services: Object.fromEntries(Object.entries(normalized).map(([id, service]) => [id, summary(service)])),
|
|
386
|
+
refused, suspect: pick('suspect'), findings,
|
|
387
|
+
...(needsFollowUp ? { followUp: { ...FOLLOW_UP, ...(isFile(path.join(skillRoot, STACK_DECLARATION_TEMPLATE)) ? { fixture: STACK_DECLARATION_TEMPLATE } : {}) } } : {}),
|
|
388
|
+
};
|
|
389
|
+
};
|
|
390
|
+
const declarationView = (own, governing, missingLevel, shown, add) => {
|
|
391
|
+
const declaration = own.missing ? governing : own;
|
|
392
|
+
if (own.missing && !governing) add(missingLevel, 'STACKS_DECLARATION_MISSING', `.starcistacks/${DECLARATION}`, `no stack declaration (.starcistacks/${DECLARATION}); the services this repository's CI uses are undeclared - follow-up: ${FOLLOW_UP.op} mode stacks`);
|
|
393
|
+
if (declaration?.error) add('refuse', 'STACKS_DECLARATION_INVALID', shown(declaration.file), declaration.error);
|
|
394
|
+
if (declaration?.doc && declaration.doc.schema !== DECLARATION_SCHEMA) add('refuse', 'STACKS_DECLARATION_INVALID', shown(declaration.file), `schema must be ${DECLARATION_SCHEMA}`);
|
|
395
|
+
const doc = declaration?.doc ?? null, services = plain(doc?.services) ? doc.services : null;
|
|
396
|
+
const declFile = declaration?.file ? shown(declaration.file) : `.starcistacks/${DECLARATION}`;
|
|
397
|
+
if (doc && !Object.hasOwn(doc, 'services')) add(missingLevel, 'STACKS_SERVICES_MISSING', declFile, `the declaration has no services block; declare sonar and every other delivery/quality service the repository uses - follow-up: ${FOLLOW_UP.op} mode stacks`);
|
|
398
|
+
return { declaration, services, declFile };
|
|
399
|
+
};
|
|
400
|
+
const checkDeclaredServices = ({ services, declaration, governing, name, ciUses, ciText, declFile, normalized, add, kind }) => {
|
|
401
|
+
if (!services) return;
|
|
402
|
+
const schema = declarationSchema(), ctx = { schema, declFile, declaration, governing, name, ciUses, ciText, normalized, add, kind };
|
|
403
|
+
for (const [id, entry] of Object.entries(services)) checkService(id, entry, ctx);
|
|
404
|
+
};
|
|
405
|
+
const checkDeclarationIgnore = (own, repo, shown, add) => {
|
|
406
|
+
if (!own.missing && own.file) {
|
|
407
|
+
const ignored = git(checkIgnore, repo, ['-q', '--', slash(path.relative(repo, own.file))]);
|
|
408
|
+
if (ignored?.status === 0) add('suspect', 'STACKS_DECLARATION_IGNORED', shown(own.file), `the ignore rules hide the declaration; re-include it (!${slash(path.relative(repo, own.file))}) so every machine reads the same services`);
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* The whole check for one repository. `newRepo`: the leg creates this repository, so what it lacks is
|
|
413
|
+
* refused under the current declaration and custody rules.
|
|
414
|
+
*/
|
|
415
|
+
export function checkStarciStacks(repoRoot, { newRepo = false, examplesRoot = path.join(skillRoot, 'examples') } = {}) {
|
|
416
|
+
const repo = path.resolve(String(repoRoot ?? ''));
|
|
417
|
+
const name = repositoryName(repo);
|
|
418
|
+
const findings = [];
|
|
419
|
+
const add = (level, code, file, message) => findings.push({ level, code, file: slash(file), message });
|
|
420
|
+
const missingLevel = newRepo ? 'refuse' : 'suspect', own = findStackDeclaration(repo);
|
|
421
|
+
const governing = own.missing ? governingDeclaration(repo) : null;
|
|
422
|
+
const shown = (file) => slash(path.relative(repo, file)) || '.';
|
|
423
|
+
const { declaration, services, declFile } = declarationView(own, governing, missingLevel, shown, add);
|
|
424
|
+
checkCiDeclaration(declaration, declFile, kindOf(repo, examplesRoot), declarationSchema, schemaErrors, add);
|
|
425
|
+
// CI evidence: which services the workflows call, and which secret/variable names they read.
|
|
426
|
+
const workflows = workflowTexts(repo);
|
|
427
|
+
const ciText = workflows.map((workflow) => workflow.text).join('\n');
|
|
428
|
+
const ciUses = Object.fromEntries(Object.entries(SERVICE_CATALOG).map(([id, entry]) => [id, workflows.filter((workflow) => entry.ci.some((re) => re.test(workflow.text))).map((workflow) => workflow.file)]));
|
|
429
|
+
const normalized = {};
|
|
430
|
+
checkDeclaredServices({ services, declaration, governing, name, ciUses, ciText, declFile, normalized, add, kind: kindOf(repo, examplesRoot) });
|
|
431
|
+
checkUndeclared({ repo, ciUses, normalized, services, declFile, add });
|
|
432
|
+
checkSonarDrift({ repo, name, normalized, services, declFile, add });
|
|
433
|
+
checkCustodyLayout({ repo, own, add });
|
|
434
|
+
checkDeclarationIgnore(own, repo, shown, add);
|
|
435
|
+
for (const item of applicationStackFindings(repo)) add('refuse', item.code, item.path, item.message);
|
|
436
|
+
return buildResult({ repo, name, newRepo, declaration, governing, normalized, findings });
|
|
437
|
+
}
|
|
438
|
+
/** A report-safe view: custody as presence, never a value. */
|
|
439
|
+
function summary(service) {
|
|
440
|
+
return { provider: service.provider, mode: service.mode, auth: service.auth, host: service.host,
|
|
441
|
+
stack: service.stack ? { owner: service.stack.owner ?? null, repository: service.stack.repository, root: service.stack.root, environment: service.stack.environment, compose: service.stack.compose, container: service.stack.container } : null,
|
|
442
|
+
projects: service.projects, credentials: service.credentials.map((credential) => ({ id: credential.id, env: credential.env, key: credential.key,
|
|
443
|
+
custody: `${credential.custody.repository}/${credential.custody.path}`, present: credential.custody.encPresent ?? null })),
|
|
444
|
+
ci: service.ci, ownerAction: service.ownerAction };
|
|
445
|
+
}
|
|
446
|
+
// ---- the owner-ask guard --------------------------------------------------------------------------------
|
|
447
|
+
const asksAboutCustody = (body) => /\b(tokens?|secrets?|credentials?|api[ _-]?keys?|passwords?|host|url|access)\b/i.test(body) || /\b(github|repository) (settings?|secrets?|variables?)\b|\bSONAR_[A-Z_]+/i.test(body);
|
|
448
|
+
/**
|
|
449
|
+
* An ask that requests what a stack declaration already answers: a service declared `ownerAction: none`
|
|
450
|
+
* whose custody is present (or needs none), named by one of its credential/CI variable names or by the
|
|
451
|
+
* service's name next to a credential word. Returns {service, provider, declaration, matched, message}
|
|
452
|
+
* or null. Declarations read: the asking repository's own (or governing) one, then the source host's
|
|
453
|
+
* when its entry lists this repository.
|
|
454
|
+
*/
|
|
455
|
+
export function ownerAskConflict({ repo, question } = {}) {
|
|
456
|
+
if (!repo || question === undefined || question === null) return null;
|
|
457
|
+
const body = typeof question === 'string' ? question : JSON.stringify(question);
|
|
458
|
+
for (const id of Object.keys(SERVICE_CATALOG)) {
|
|
459
|
+
const service = resolveStackService(repo, id);
|
|
460
|
+
if (!service || service.mode === 'disabled' || service.ownerAction !== 'none' || !custodyHeld(service)) continue;
|
|
461
|
+
const names = [...service.credentials.map((credential) => credential.env), ...service.ci.secrets.map((item) => item.name), ...service.ci.vars.map((item) => item.name)].filter(Boolean);
|
|
462
|
+
const byName = names.find((item) => new RegExp(String.raw`\b${escapeRegExp(item)}\b`).test(body));
|
|
463
|
+
const words = [...new Set([id, service.provider, ...SERVICE_CATALOG[id].words].filter(Boolean))];
|
|
464
|
+
const byWord = words.find((word) => new RegExp(String.raw`\b${escapeRegExp(word)}\b`, 'i').test(body));
|
|
465
|
+
if (!byName && !(byWord && asksAboutCustody(body))) continue;
|
|
466
|
+
const custody = service.credentials.map((credential) => `${credential.custody.repository}/${credential.custody.path}`);
|
|
467
|
+
const custodyNote = custody.length ? ` and its credentials in custody (${custody.join(', ')})` : ` and auth ${service.auth}`;
|
|
468
|
+
const sonarNote = id === 'sonar' ? 'Run starci gate sonar (status, ensure-project, scan) - it reads host, project and token from the declaration and custody. ' : '';
|
|
469
|
+
const provisioningNote = service.ci.provisioning ? ` (${service.ci.provisioning})` : '';
|
|
470
|
+
return { service: id, provider: service.provider, declaration: slash(service.declaration), matched: byName ?? byWord,
|
|
471
|
+
message: `${id} (${service.provider}, ${service.mode}) is declared in ${slash(service.declaration)} with ownerAction none${custodyNote}; the owner is never asked for it. ${sonarNote}CI wiring is ${service.ci.wiring ?? 'undeclared'}${provisioningNote}.` };
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
// ---- CLI ------------------------------------------------------------------------------------------------
|
|
476
|
+
const USAGE = 'Usage: starci gate starcistacks <repo-root> [--new] [--json]\n\nHolds a repository\'s stack declaration services block (sonar, codecov, dependabot, audit, container-registry, analytics, error-tracking) and its custody layout to modules/schemas/application-stacks.schema.yaml and stacks-layout.yaml. Exit 0 clean (suspects allowed), 1 refused, 2 usage.\n';
|
|
477
|
+
async function checkStarciStacksMain(argv = []) {
|
|
478
|
+
const rest = argv;
|
|
479
|
+
const args = rest.filter((arg) => !['--json', '--new'].includes(arg));
|
|
480
|
+
if (args.includes('--help') || args.includes('-h')) return { exitCode: 0, text: USAGE };
|
|
481
|
+
if (args.length !== 1) return { exitCode: 2, text: USAGE };
|
|
482
|
+
if (!isDir(args[0])) return { exitCode: 2, text: `${args[0]}: not a directory\n` };
|
|
483
|
+
const result = checkStarciStacks(args[0], { newRepo: rest.includes('--new') });
|
|
484
|
+
if (rest.includes('--json')) return { exitCode: result.ok ? 0 : 1, text: `${JSON.stringify(result, null, 2)}\n` };
|
|
485
|
+
const lines = [...result.refused.map((line) => ` REFUSED ${line}`), ...result.suspect.map((line) => ` SUSPECT ${line}`)];
|
|
486
|
+
const fixtureNote = result.followUp?.fixture ? ` from ${result.followUp.fixture}` : '';
|
|
487
|
+
if (result.followUp) lines.push(` follow-up: ${result.followUp.op} mode ${result.followUp.params.mode}${fixtureNote}`);
|
|
488
|
+
const listing = `${lines.join('\n')}${lines.length ? '\n' : ''}`;
|
|
489
|
+
const verdict = `${result.ok ? 'OK' : 'FAIL'}: starcistacks ${result.repository} - ${result.refused.length} refused, ${result.suspect.length} suspect, services: ${Object.keys(result.services).join(', ') || 'none declared'}.\n`;
|
|
490
|
+
return { exitCode: result.ok ? 0 : 1, text: `${listing}${verdict}` };
|
|
491
|
+
}
|
|
492
|
+
if (isMain(import.meta.url)) {
|
|
493
|
+
try {
|
|
494
|
+
const { exitCode, text: out } = await checkStarciStacksMain(process.argv.slice(2));
|
|
495
|
+
process.stdout.write(out); process.exitCode = exitCode;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
process.stderr.write(`check-starcistacks: ${error?.stack ?? error}\n`); process.exitCode = 2;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
@@ -24,7 +24,7 @@ const COMPILATION_FAILURE = 'module or proof could not be compiled';
|
|
|
24
24
|
const WORKER_FAILURE = 'proof worker failed or timed out';
|
|
25
25
|
const SAFE_REASONS = new Set([...DI_FAILURES.values(), COMPILATION_FAILURE]);
|
|
26
26
|
const modulePath = app => `apps/${app}/src/app.module.ts`;
|
|
27
|
-
const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.builder.ts`;
|
|
27
|
+
export const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.builder.ts`;
|
|
28
28
|
|
|
29
29
|
const refusal = (file, reason) => ({ code: BOOT_PROOF_CODE, level: 'error', path: file,
|
|
30
30
|
message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --wiring`, reason });
|
|
@@ -177,7 +177,7 @@ export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), fil
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/** Optional standalone entry; coordinator connects the same seam to app check --wiring. */
|
|
180
|
-
function appWiringMain(argv, { cwd = invocationDir(), stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
|
|
180
|
+
export function appWiringMain(argv, { cwd = invocationDir(), stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
|
|
181
181
|
let directory = cwd, json = false;
|
|
182
182
|
for (let at = 0; at < argv.length; at += 1) {
|
|
183
183
|
if (argv[at] === '--cwd' && argv[at + 1]) directory = path.resolve(cwd, argv[++at]);
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
|
|
33
33
|
// HFS_REPO_LOCAL_CHECK (R103, rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
|
|
34
34
|
// HFS_LINT_SUPPRESSION_FILE (R104, rules/lint-suppression.mjs) an eslint suppressions file, script or option
|
|
35
|
+
// HFS_BYPASS_MARKER (R303, rules/bypass-markers.mjs) an inline disable, type-check escape, skipped test or swallowed failure
|
|
35
36
|
// HFS_PROOF_COMMAND_FILE_MISSING (R105, rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
|
|
36
37
|
// HFS_PEER_INTEGRATION_MISSING (R111, rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
|
|
37
38
|
// BE_INTEGRATION_SPEC_MISSING (R112, rules/integration-specs.mjs) an integration with no integration spec that registers its module, maps its refusals and drives an outage
|
|
@@ -77,6 +78,7 @@ import { mcpToolsSnapshotFindings } from './rules/mcp-tools.mjs';
|
|
|
77
78
|
import { appRootFindings } from './rules/app-root.mjs';
|
|
78
79
|
import { frontendFindings } from './rules/frontend-tree.mjs';
|
|
79
80
|
import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
|
|
81
|
+
import { bypassMarkerFindings } from './rules/bypass-markers.mjs';
|
|
80
82
|
import { repoLocalCheckFindings } from './rules/repo-local-checks.mjs';
|
|
81
83
|
import { supabaseSecretFindings } from './rules/supabase-secrets.mjs';
|
|
82
84
|
import { untrackedSecretFindings } from './rules/secrets.mjs';
|
|
@@ -105,7 +107,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
105
107
|
'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
|
|
106
108
|
'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
|
|
107
109
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT', 'HFS_MCP_TOOLS_SNAPSHOT',
|
|
108
|
-
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
|
|
110
|
+
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_BYPASS_MARKER', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
|
|
109
111
|
'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY', 'HFS_STARCIWORK_LITE_FORBIDDEN',
|
|
110
112
|
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
|
|
111
113
|
'HFS_DOCKER_HEALTHCHECK_FAKE', 'DB_BE_ONLY_ACCESS', 'DB_REALTIME_UNLISTED', 'DB_APP_ROLE', 'DB_FORCE_RLS_DEFAULT',
|
|
@@ -213,6 +215,7 @@ function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pin
|
|
|
213
215
|
...feStackFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, manifest }),
|
|
214
216
|
...repoLocalCheckFindings({ repoRoot, files }),
|
|
215
217
|
...lintSuppressionFindings({ repoRoot, files }),
|
|
218
|
+
...bypassMarkerFindings({ repoRoot, files: files.filter((file) => inScope(file)) }),
|
|
216
219
|
...a15Findings({ repoRoot, files, all, repo, resolver, manifest, isRoot, appSide, inScope }),
|
|
217
220
|
];
|
|
218
221
|
findings.push(...trackedStacksGateFindings({ repoRoot, files, all, repo, resolver, manifest, isRoot, inScope, existingFindings: findings }));
|
|
@@ -55,7 +55,7 @@ export function formatCoverageReport(report) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Standalone coordinator entry point: exit 0 proof passed, 2 proof or invocation refused. */
|
|
58
|
-
function coverageFromMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), run = coverageFrom } = {}) {
|
|
58
|
+
export function coverageFromMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), run = coverageFrom } = {}) {
|
|
59
59
|
try {
|
|
60
60
|
const opts = parse(argv);
|
|
61
61
|
const report = run({ repoRoot: path.resolve(cwd, opts.cwd ?? '.'), coverageFrom: opts.coverageFrom });
|
|
@@ -84,7 +84,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
84
84
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
85
85
|
const strList = (v) => stringList(v);
|
|
86
86
|
|
|
87
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', '
|
|
87
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
|
|
88
88
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
89
89
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
90
90
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
@@ -218,8 +218,6 @@ function slotCoverageEvidenceProblems(slot, at) {
|
|
|
218
218
|
const bad = [];
|
|
219
219
|
if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
|
|
220
220
|
bad.push(`${at}: templateNa must be non-empty text`);
|
|
221
|
-
if (slot.exampleNa !== undefined && !(typeof slot.exampleNa === 'string' && slot.exampleNa.trim()))
|
|
222
|
-
bad.push(`${at}: exampleNa must be non-empty text`);
|
|
223
221
|
if (slot.exemplifiedBy === undefined) return bad;
|
|
224
222
|
const refs = slot.exemplifiedBy;
|
|
225
223
|
const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
|