@starci/eslint-canon-fe 6.0.2 → 7.1.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 +18 -0
- package/README.md +3 -5
- package/client-boundary.mjs +2 -5
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +18 -18
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +5 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +3 -68
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/project-graph.mjs +23 -0
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +321 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +95 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/common.mjs +44 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/project-graph.mjs +0 -0
- package/runtime/scripts/lib/project-rule.mjs +39 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +13 -17
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// language.mjs - the one home of "is this text English?" for source, comments, tests and docs (HFS_LANGUAGE_NOT_ENGLISH,
|
|
2
|
+
// BE_SOURCE_FORM, FE_SOURCE_FORM). Shared by the architecture machine (docs) and by both lint canons (each ships a byte
|
|
3
|
+
// copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
|
|
4
|
+
//
|
|
5
|
+
// Detection is structural on characters, never a word list: the letters Vietnamese adds to the Latin alphabet (a-breve, a-circumflex,
|
|
6
|
+
// d-stroke, e-circumflex, o-circumflex, o-horn, u-horn) and every vowel carrying a tone mark. Text is folded to NFC first, so a
|
|
7
|
+
// decomposed spelling (base letter + combining mark) is caught exactly like the precomposed one, and a loanword such as
|
|
8
|
+
// `naive`, `facade` or `Muller` (no tone mark, no Vietnamese letter) is never a hit.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** One Vietnamese letter, precomposed (NFC): the Vietnamese-specific base letters, tone-marked vowels and the Latin Extended Additional block. */
|
|
12
|
+
export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/;
|
|
13
|
+
|
|
14
|
+
/** Whether `text` holds a Vietnamese letter, in NFC or NFD spelling. */
|
|
15
|
+
export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
|
|
16
|
+
|
|
17
|
+
/** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
|
|
18
|
+
export function secondLanguageHits(text) {
|
|
19
|
+
const hits = [];
|
|
20
|
+
String(text).split(/\r?\n/).forEach((raw, index) => {
|
|
21
|
+
const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
|
|
22
|
+
if (match) hits.push({ line: index + 1, column: match.index + 1 });
|
|
23
|
+
});
|
|
24
|
+
return hits;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
|
|
29
|
+
* (scripts/checks/failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
|
|
30
|
+
*/
|
|
31
|
+
export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meaning_vi', 'nextStep_vi', 'causes_vi']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The fields of a document that carry deliberate Vietnamese text, keyed by file: a declared field-level exception of that file's
|
|
35
|
+
* typed content, never a path pattern over content. `fields` are block keys (the key's own value and its list items are exempt);
|
|
36
|
+
* `flowFields` are keys inside an inline flow map (`{ vi: ..., en: ... }`), where only that key's value is exempt. A Vietnamese
|
|
37
|
+
* value under any other key of the same file is still a finding.
|
|
38
|
+
* - modules/kernel/failure-codes.yaml: the operator text the owner mandated for the failure-code catalog.
|
|
39
|
+
* - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
|
|
40
|
+
* - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
|
|
41
|
+
* phrase lists of the archetype recognisers).
|
|
42
|
+
*/
|
|
43
|
+
export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
|
|
44
|
+
'modules/kernel/failure-codes.yaml': Object.freeze({ fields: FAILURE_CODE_VIETNAMESE_FIELDS }),
|
|
45
|
+
'modules/ops/_labels.yaml': Object.freeze({ flowFields: Object.freeze(['vi']) }),
|
|
46
|
+
'modules/goal/archetypes.yaml': Object.freeze({
|
|
47
|
+
fields: Object.freeze([
|
|
48
|
+
'buildIntent', 'canonIntent', 'e2eIntent', 'uatIntent', 'proofNegation', 'integrationIntent', 'integrationNegation', 'brandIntent',
|
|
49
|
+
'phrases', 'requires', 'excludes', 'backend', 'frontend', 'package',
|
|
50
|
+
]),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The slots whose files may carry another language, and why: a catalog is product copy in two languages, and an i18n
|
|
56
|
+
* fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
|
|
57
|
+
* comment pragma and no path pattern. The slots are declared in knowledge/hfs/slots.yaml.
|
|
58
|
+
*/
|
|
59
|
+
export const LOCALIZED_TEXT_SLOTS = Object.freeze([
|
|
60
|
+
'be.domain.messages', 'be.feature.messages', 'be.tests.fixtures.i18n',
|
|
61
|
+
'fe.modules.i18n', 'fe.package.i18n',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Whether a document (Markdown or YAML) of `slot` is exempt: only catalogue and fixture DATA (YAML) is; Markdown is prose and never is. */
|
|
65
|
+
export const isLocalizedDataFile = (rel, slot) => LOCALIZED_TEXT_SLOTS.includes(slot) && /\.ya?ml$/.test(rel);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The key each line of a YAML text belongs to: a `key:` line owns itself, and every deeper-indented line after it (a list item,
|
|
69
|
+
* a folded block, a wrapped scalar) belongs to the same key. A line at or above the key's indent that is not a key belongs to none.
|
|
70
|
+
*/
|
|
71
|
+
export function yamlKeyOfEachLine(text) {
|
|
72
|
+
let key = null;
|
|
73
|
+
let keyIndent = -1;
|
|
74
|
+
return String(text).split(/\r?\n/).map((raw) => {
|
|
75
|
+
if (raw.trim() === '') return key;
|
|
76
|
+
const indent = raw.length - raw.trimStart().length;
|
|
77
|
+
const own = /^(\s*(?:-\s+)?)([A-Za-z_][\w-]*)\s*:(?:\s|$)/.exec(raw);
|
|
78
|
+
if (own) { key = own[2]; keyIndent = own[1].length; return key; }
|
|
79
|
+
// a list item may sit at the key's own indent (`key:` then `- item` at the same column)
|
|
80
|
+
if (indent > keyIndent || (indent === keyIndent && /^\s*-\s/.test(raw))) return key;
|
|
81
|
+
key = null;
|
|
82
|
+
keyIndent = -1;
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The Vietnamese hits of a document, minus the declared field-level exceptions of `rel` (a repository-relative POSIX path). */
|
|
88
|
+
export function documentLanguageHits(rel, text) {
|
|
89
|
+
const declared = DECLARED_VIETNAMESE_FIELDS[rel];
|
|
90
|
+
const hits = secondLanguageHits(text);
|
|
91
|
+
if (!declared) return hits;
|
|
92
|
+
const keys = yamlKeyOfEachLine(text);
|
|
93
|
+
const lines = String(text).split(/\r?\n/);
|
|
94
|
+
return hits.filter((hit) => {
|
|
95
|
+
if (declared.fields?.includes(keys[hit.line - 1])) return false;
|
|
96
|
+
if (!declared.flowFields) return true;
|
|
97
|
+
// an inline flow map: drop the declared fields' values and judge what is left of the line
|
|
98
|
+
const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(`\\b${field}\\s*:\\s*[^,}]*`, 'g'), ''), lines[hit.line - 1]);
|
|
99
|
+
return hasSecondLanguage(rest);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The file extensions of a prose document (Markdown and YAML). */
|
|
104
|
+
export const DOCUMENT_EXTENSIONS = Object.freeze(['.md', '.yaml', '.yml']);
|
|
105
|
+
|
|
106
|
+
/** Whether `rel` is a document a language check reads. */
|
|
107
|
+
export const isDocument = (rel) => DOCUMENT_EXTENSIONS.includes(path.posix.extname(rel));
|
|
Binary file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// project-rule.mjs - the factory of the lint rules that read the project graph (scripts/lib/project-graph.mjs).
|
|
2
|
+
//
|
|
3
|
+
// A project rule judges nothing itself: the graph (the architecture machine's module graph and slot manifest, built once per
|
|
4
|
+
// process) holds the findings of its codes, and the rule reports those of the file it is visiting, on the line the machine named.
|
|
5
|
+
// `settings.starci.injectedTypeScript` is a fixtures-only seam, a function returning the compiler (RuleTester freezes settings, and a frozen compiler cannot run).
|
|
6
|
+
// Both lint canons ship a byte copy of this file in runtime/ and build their rules with it.
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { projectGraph } from './project-graph.mjs';
|
|
9
|
+
import { posixPath } from './path-key.mjs';
|
|
10
|
+
import { belongsTo, codeOf } from '../checks/architecture/surface.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A rule reporting the graph findings of `codes` on the linted file.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ enforcer: object, runtimeRoot: string, hfsOf: (context: object) => object }} input - `enforcer` is a row of LINT_ENFORCERS (id, codes, description, origin, via)
|
|
16
|
+
* @returns {object} An ESLint rule.
|
|
17
|
+
*/
|
|
18
|
+
export const projectRule = ({ enforcer, runtimeRoot, hfsOf }) => {
|
|
19
|
+
return {
|
|
20
|
+
meta: { type: 'problem', docs: { description: enforcer.description }, schema: [], messages: { finding: '{{message}}' } },
|
|
21
|
+
create(context) {
|
|
22
|
+
const hfs = hfsOf(context);
|
|
23
|
+
const rel = posixPath(path.relative(hfs.repoRoot, context.filename));
|
|
24
|
+
if (rel.startsWith('..')) return {};
|
|
25
|
+
const { byFile } = projectGraph({ repoRoot: hfs.repoRoot, runtimeRoot, injectedTypeScript: context.settings?.starci?.injectedTypeScript?.() });
|
|
26
|
+
const mine = (byFile.get(rel) ?? []).filter((finding) => belongsTo(enforcer, finding));
|
|
27
|
+
return {
|
|
28
|
+
Program(node) {
|
|
29
|
+
for (const finding of mine) {
|
|
30
|
+
const line = Number.isInteger(finding.line) && finding.line >= 1 ? finding.line : 1;
|
|
31
|
+
// the machine counts columns from 1, ESLint from 0
|
|
32
|
+
const column = Number.isInteger(finding.column) && finding.column >= 1 ? finding.column - 1 : 0;
|
|
33
|
+
context.report({ node, loc: { start: { line, column } }, messageId: 'finding', data: { message: `[${codeOf(finding)}] ${finding.message}` } });
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// repo-identity.mjs - who a repository is, independent of the folder it is checked out in.
|
|
2
|
+
// A git worktree (D:/starci-lanes/<lane>/<name>) and the main checkout of the same repository are ONE repository:
|
|
3
|
+
// they carry the same README title, the same stack-declaration project name and the same sibling repositories.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { gitOutput } from './git.mjs';
|
|
7
|
+
|
|
8
|
+
const git = (root, args) => gitOutput(args, { cwd: root }).trim();
|
|
9
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
10
|
+
|
|
11
|
+
/** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
|
|
12
|
+
function identityOf(root) {
|
|
13
|
+
let inWorkTree = false, repositoryRoot = false, home = null;
|
|
14
|
+
try {
|
|
15
|
+
// Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
|
|
16
|
+
repositoryRoot = real(git(root, ['rev-parse', '--show-toplevel'])) === real(root);
|
|
17
|
+
inWorkTree = true;
|
|
18
|
+
const common = path.resolve(root, git(root, ['rev-parse', '--git-common-dir']));
|
|
19
|
+
if (repositoryRoot && path.basename(common) === '.git') home = path.dirname(common);
|
|
20
|
+
} catch { /* Not a Git work tree; the caller falls back to the folder and package name. */ }
|
|
21
|
+
return { inWorkTree, repositoryRoot, home };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The product repository name: the main checkout's folder name (so a worktree and its main checkout agree), else the
|
|
26
|
+
* origin remote's name, else package.json name outside Git, else the folder name. A folder inside a larger repository
|
|
27
|
+
* (a nested product, an example) is named by its own folder.
|
|
28
|
+
*/
|
|
29
|
+
export function repositoryName(root) {
|
|
30
|
+
const { inWorkTree, repositoryRoot, home } = identityOf(root);
|
|
31
|
+
if (home) return path.basename(home);
|
|
32
|
+
if (repositoryRoot) {
|
|
33
|
+
try {
|
|
34
|
+
const remote = git(root, ['remote', 'get-url', 'origin']).replace(/[\/]+$/u, '').replace(/\.git$/iu, '');
|
|
35
|
+
const name = remote.split(/[\/:]/u).pop();
|
|
36
|
+
if (name) return name;
|
|
37
|
+
} catch { /* No origin remote. */ }
|
|
38
|
+
}
|
|
39
|
+
if (inWorkTree && !repositoryRoot) return path.basename(root);
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
42
|
+
if (typeof pkg.name === 'string' && pkg.name) return pkg.name.replace(/^@[^/]+\//u, '');
|
|
43
|
+
} catch { /* No readable package.json. */ }
|
|
44
|
+
return path.basename(root);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The folder whose siblings are this repository's sibling repositories: the main checkout for a worktree, else root. */
|
|
48
|
+
export function repositoryHome(root) {
|
|
49
|
+
return identityOf(root).home ?? path.resolve(root);
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// secret-patterns.mjs — the one list of secret shapes the runtime refuses to carry: the push secret scan
|
|
2
|
+
// (scripts/supervisor/push-mains.mjs scanDiff) refuses an outgoing range that adds one, and the typed-log
|
|
3
|
+
// redaction (scripts/kernel/typed-logs.mjs redactText) blanks the value before a log row is stored.
|
|
4
|
+
// FORBIDDEN_FILES name paths that are secrets by being (an env file, a key file); SECRET_PATTERNS match values.
|
|
5
|
+
|
|
6
|
+
export const FORBIDDEN_FILES = [
|
|
7
|
+
{ name: 'env-file', test: (f) => /(^|\/)\.env(\.[^/]*)?$/i.test(f) && !/\.env\.(example|sample|template)$/i.test(f) },
|
|
8
|
+
{ name: 'secrets-dir', test: (f) => /(^|\/)\.secrets\//i.test(f) },
|
|
9
|
+
{ name: 'private-key-file', test: (f) => /\.(pem|key|p12|pfx)$/i.test(f) || /(^|\/)id_(rsa|ed25519|ecdsa)$/i.test(f) },
|
|
10
|
+
// .starcistacks/<stack>/secrets/ commits sops twins only (owner ruling push-scan-test-secrets-encrypted): a plaintext
|
|
11
|
+
// credential there - a test password included - is git-ignored by the product repo and never pushed.
|
|
12
|
+
{ name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
|
|
13
|
+
{ name: 'credentials-json', test: (f) => /(^|\/)(credentials|service-account|client_secret)[^/]*\.json$/i.test(f) },
|
|
14
|
+
];
|
|
15
|
+
export const SECRET_PATTERNS = [
|
|
16
|
+
{ name: 'private-key-block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
|
17
|
+
{ name: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
18
|
+
{ name: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b/ },
|
|
19
|
+
{ name: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
|
|
20
|
+
{ name: 'openai-key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/ },
|
|
21
|
+
{ name: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
22
|
+
{ name: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
23
|
+
{ name: 'telegram-bot-token', re: /\b\d{8,10}:AA[0-9A-Za-z_-]{33}\b/ },
|
|
24
|
+
{ name: 'stripe-secret', re: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/ },
|
|
25
|
+
{ name: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
|
|
26
|
+
// A keyword-assigned value that names itself a stand-in (a test stub's `accessToken: "fixture-..."`) is no
|
|
27
|
+
// candidate; only this heuristic takes the exemption, never a provider-shaped token above.
|
|
28
|
+
// A spec file's keyword-assigned values are test inputs (nivo-fe AuthenticationPage/index.spec.tsx: the sign-in
|
|
29
|
+
// form's password and a mocked accessToken refused the push, 2026-09-24): the heuristic skips spec files too.
|
|
30
|
+
{ name: 'assigned-secret', re: /\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"]([^'"\s$<{]{12,})['"]/i, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
|
|
31
|
+
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/** The names of the patterns one line of `file` matches (never the value). A stand-in value and a spec file's keyword assignment are no hit. */
|
|
36
|
+
export function secretHits(file, text) {
|
|
37
|
+
const names = [];
|
|
38
|
+
for (const rule of SECRET_PATTERNS) {
|
|
39
|
+
if (rule.skipFile?.test(file ?? '')) continue;
|
|
40
|
+
const hit = rule.re.exec(text);
|
|
41
|
+
if (hit && !rule.placeholder?.test(hit[1] ?? '')) names.push(rule.name);
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
|
|
7
|
+
* files of one environment (`environments.<env>.composeFiles`, `include:` followed). The R47 `test-world-files` machine check
|
|
8
|
+
* reads the stack through it: it refuses a fake of a service the stack declares, and a `fakedBy` of a stateful one. Nothing
|
|
9
|
+
* here names a product: the stack declares its services, this reads them.
|
|
10
|
+
*
|
|
11
|
+
* Which services the test world runs for real: every declared service except the app's own services (component role
|
|
12
|
+
* `service`, or a compose service under the `app` profile) and observability sidecars (role `observability`). The
|
|
13
|
+
* toxiproxy service (the failure-injection proxy the world routes every real service through) is declared like any other
|
|
14
|
+
* service and is returned as `proxy`.
|
|
15
|
+
*/
|
|
16
|
+
export const STACKS_DIRECTORY = '.starcistacks';
|
|
17
|
+
export const STACKS_FILE = 'application-stacks.yaml';
|
|
18
|
+
export const DEFAULT_ENVIRONMENT = 'dev';
|
|
19
|
+
|
|
20
|
+
/** Test-only value the `*_FILE` secret variables of a compose service become (the file variable is replaced by the plain variable of the same name). Never a real secret: the test stack listens on loopback only. */
|
|
21
|
+
export const TEST_SECRET_VALUE = 'starci-test-secret';
|
|
22
|
+
|
|
23
|
+
const NOT_RUN_ROLES = new Set(['service', 'observability']);
|
|
24
|
+
const NOT_RUN_PROFILES = new Set(['app']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What the reader knows about the well-known images of a stack, by the repository name of the image: how the test stack
|
|
28
|
+
* tells the service answers (`probe`), which name its fakes could hide behind (`aliases`), and its kind. An image it does not
|
|
29
|
+
* know is probed by TCP and is only matched by its own name.
|
|
30
|
+
*/
|
|
31
|
+
const KNOWN_IMAGES = Object.freeze({
|
|
32
|
+
postgres: { kind: 'database', aliases: ['postgres', 'postgresql', 'pg', 'database', 'db'], probe: { exec: ['pg_isready', '-h', '127.0.0.1'] } },
|
|
33
|
+
redis: { kind: 'cache', aliases: ['redis', 'cache', 'valkey'], probe: { exec: ['redis-cli', 'ping'] } },
|
|
34
|
+
valkey: { kind: 'cache', aliases: ['redis', 'cache', 'valkey'], probe: { exec: ['valkey-cli', 'ping'] } },
|
|
35
|
+
keycloak: { kind: 'identity', aliases: ['keycloak', 'idp', 'oidc'], probe: { http: { path: '/realms/master' } } },
|
|
36
|
+
minio: { kind: 'storage', aliases: ['minio', 's3', 'storage'], probe: { http: { path: '/minio/health/live', containerPort: 9000 } } },
|
|
37
|
+
mailpit: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/livez', containerPort: 8025 } } },
|
|
38
|
+
mailhog: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/', containerPort: 8025 } } },
|
|
39
|
+
maildev: { kind: 'mail', aliases: ['mailpit', 'mail', 'smtp', 'mailhog', 'maildev'], probe: { http: { path: '/healthz', containerPort: 1080 } } },
|
|
40
|
+
toxiproxy: { kind: 'proxy', aliases: ['toxiproxy'], probe: { http: { path: '/version', containerPort: 8474 } } },
|
|
41
|
+
mysql: { kind: 'database', aliases: ['mysql', 'database', 'db'], probe: { tcp: true } },
|
|
42
|
+
mariadb: { kind: 'database', aliases: ['mariadb', 'mysql', 'database', 'db'], probe: { tcp: true } },
|
|
43
|
+
mongo: { kind: 'database', aliases: ['mongo', 'mongodb', 'database', 'db'], probe: { tcp: true } },
|
|
44
|
+
clickhouse: { kind: 'database', aliases: ['clickhouse', 'database', 'db'], probe: { tcp: true } },
|
|
45
|
+
kafka: { kind: 'queue', aliases: ['kafka', 'queue', 'broker'], probe: { tcp: true } },
|
|
46
|
+
redpanda: { kind: 'queue', aliases: ['redpanda', 'kafka', 'queue', 'broker'], probe: { tcp: true } },
|
|
47
|
+
rabbitmq: { kind: 'queue', aliases: ['rabbitmq', 'amqp', 'queue', 'broker'], probe: { tcp: true } },
|
|
48
|
+
nats: { kind: 'queue', aliases: ['nats', 'queue', 'broker'], probe: { tcp: true } },
|
|
49
|
+
elasticsearch: { kind: 'search', aliases: ['elasticsearch', 'elastic', 'search'], probe: { tcp: true } },
|
|
50
|
+
opensearch: { kind: 'search', aliases: ['opensearch', 'search'], probe: { tcp: true } },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The kinds of well-known image that hold data an app reads back. A stack service of one of these kinds (or one the stack gives
|
|
55
|
+
* a persistent volume) is stateful: it always runs real in the test world and can never be faked, even with a declared
|
|
56
|
+
* exception (BE-CONVENTION 1.16: only stateless compute that needs special hardware or an external model may be faked).
|
|
57
|
+
*/
|
|
58
|
+
export const STATEFUL_KINDS = Object.freeze(new Set(['database', 'cache', 'identity', 'storage', 'mail', 'queue', 'search']));
|
|
59
|
+
|
|
60
|
+
/** The repository name of an image reference: `quay.io/keycloak/keycloak:26.0` -> `keycloak`. */
|
|
61
|
+
export const repositoryOf = image => {
|
|
62
|
+
const withoutDigest = image.split('@')[0];
|
|
63
|
+
const lastSegment = withoutDigest.split('/').at(-1) ?? withoutDigest;
|
|
64
|
+
return lastSegment.split(':')[0];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The repository path of an image reference without its tag: `quay.io/keycloak/keycloak:26.0` -> `quay.io/keycloak/keycloak`. */
|
|
68
|
+
export const imagePathOf = image => {
|
|
69
|
+
const withoutDigest = image.split('@')[0];
|
|
70
|
+
const lastSlash = withoutDigest.lastIndexOf('/');
|
|
71
|
+
const colon = withoutDigest.indexOf(':', lastSlash + 1);
|
|
72
|
+
return colon === -1 ? withoutDigest : withoutDigest.slice(0, colon);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
|
|
76
|
+
export const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
|
|
77
|
+
|
|
78
|
+
/** The shell-like words of a compose `command` string, double and single quotes grouping a word. */
|
|
79
|
+
const words = text => {
|
|
80
|
+
const out = [];
|
|
81
|
+
let current = '';
|
|
82
|
+
let quote = null;
|
|
83
|
+
let started = false;
|
|
84
|
+
for (const char of text) {
|
|
85
|
+
if (quote) {
|
|
86
|
+
if (char === quote) quote = null;
|
|
87
|
+
else current += char;
|
|
88
|
+
} else if (char === '"' || char === "'") {
|
|
89
|
+
quote = char;
|
|
90
|
+
started = true;
|
|
91
|
+
} else if (/\s/u.test(char)) {
|
|
92
|
+
if (started || current) out.push(current);
|
|
93
|
+
current = '';
|
|
94
|
+
started = false;
|
|
95
|
+
} else current += char;
|
|
96
|
+
}
|
|
97
|
+
if (started || current) out.push(current);
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
|
|
102
|
+
|
|
103
|
+
/** A compose `environment` (map or `KEY=VALUE` list) as a plain object; a `*_FILE` variable becomes the plain variable with the test value. */
|
|
104
|
+
const environmentOf = value => {
|
|
105
|
+
const entries = Array.isArray(value)
|
|
106
|
+
? value.map(item => { const text = String(item); const at = text.indexOf('='); return at === -1 ? [text, ''] : [text.slice(0, at), text.slice(at + 1)]; })
|
|
107
|
+
: Object.entries(value ?? {}).map(([key, item]) => [key, item === null ? '' : String(item)]);
|
|
108
|
+
const out = {};
|
|
109
|
+
for (const [key, item] of entries) {
|
|
110
|
+
if (key.endsWith('_FILE')) out[key.slice(0, -'_FILE'.length)] = TEST_SECRET_VALUE;
|
|
111
|
+
else out[key] = item;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** The container ports a compose `ports` list publishes: `"5432:5432"`, `"127.0.0.1:8080:80/tcp"`, `5432`, `{target: 80}`. */
|
|
117
|
+
const containerPortsOf = value => asList(value).map(entry => {
|
|
118
|
+
if (entry !== null && typeof entry === 'object') return Number(entry.target);
|
|
119
|
+
const container = String(entry).split('/')[0].split(':').at(-1);
|
|
120
|
+
return Number(container);
|
|
121
|
+
}).filter(Number.isInteger);
|
|
122
|
+
|
|
123
|
+
/** The bind mounts of a compose `volumes` list whose source is a path (`./realm.json:/x:ro`); named volumes are ephemeral in the test stack and are skipped. */
|
|
124
|
+
const mountsOf = (value, directory) => {
|
|
125
|
+
const mounts = [];
|
|
126
|
+
for (const entry of asList(value)) {
|
|
127
|
+
if (entry !== null && typeof entry === 'object') {
|
|
128
|
+
if (entry.type === 'bind' && typeof entry.source === 'string') mounts.push({ host: path.resolve(directory, entry.source), container: String(entry.target), readOnly: entry.read_only === true });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const parts = String(entry).split(':');
|
|
132
|
+
// A Windows drive letter is not a separator: `C:\x:/y` keeps its drive.
|
|
133
|
+
const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
|
|
134
|
+
const target = parts.shift();
|
|
135
|
+
if (!source || !target || !(source.startsWith('.') || path.isAbsolute(source))) continue;
|
|
136
|
+
mounts.push({ host: path.resolve(directory, source), container: target, readOnly: parts.includes('ro') });
|
|
137
|
+
}
|
|
138
|
+
return mounts;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** True when a compose `volumes` list gives the service somewhere to keep data: a named volume, or a bind mount that is not read-only. */
|
|
142
|
+
const persistsData = value => asList(value).some(entry => {
|
|
143
|
+
if (entry !== null && typeof entry === 'object') return entry.type === 'volume' || (entry.type === 'bind' && entry.read_only !== true);
|
|
144
|
+
const parts = String(entry).split(':');
|
|
145
|
+
const source = /^[A-Za-z]$/u.test(parts[0]) && parts.length > 2 ? `${parts.shift()}:${parts.shift()}` : parts.shift();
|
|
146
|
+
parts.shift();
|
|
147
|
+
if (!source) return false;
|
|
148
|
+
const isPath = source.startsWith('.') || path.isAbsolute(source);
|
|
149
|
+
return !isPath || !parts.includes('ro');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const readYaml = file => parseYaml(fs.readFileSync(file, 'utf8')) ?? {};
|
|
153
|
+
|
|
154
|
+
/** The compose services of `file` and of every file it includes: [{name, definition, directory}]. */
|
|
155
|
+
const composeServices = (file, seen = new Set()) => {
|
|
156
|
+
const resolved = path.resolve(file);
|
|
157
|
+
if (seen.has(resolved) || !fs.existsSync(resolved)) return [];
|
|
158
|
+
seen.add(resolved);
|
|
159
|
+
const document = readYaml(resolved);
|
|
160
|
+
const directory = path.dirname(resolved);
|
|
161
|
+
const included = asList(document.include).flatMap(entry => {
|
|
162
|
+
const target = typeof entry === 'string' ? entry : entry?.path;
|
|
163
|
+
return typeof target === 'string' ? composeServices(path.resolve(directory, target), seen) : [];
|
|
164
|
+
});
|
|
165
|
+
const own = Object.entries(document.services ?? {}).map(([name, definition]) => ({ name, definition: definition ?? {}, directory }));
|
|
166
|
+
return [...included, ...own];
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The stack of `environment`, or null when the repository declares none. `{ environment, directory, services, proxy }`:
|
|
171
|
+
* `services` are the services the environment declares (`name`, `image`, `repository`, `tag`, `role`, `kind`, `aliases`,
|
|
172
|
+
* `probe`, `command`, `environment`, `ports`, `mounts`, `persistent`, `runs`), `proxy` the toxiproxy service or null.
|
|
173
|
+
*/
|
|
174
|
+
export function readStack({ root, environment = DEFAULT_ENVIRONMENT }) {
|
|
175
|
+
const stacksFile = path.join(root, STACKS_DIRECTORY, STACKS_FILE);
|
|
176
|
+
if (!fs.existsSync(stacksFile)) return null;
|
|
177
|
+
const declaration = readYaml(stacksFile);
|
|
178
|
+
const env = declaration.environments?.[environment];
|
|
179
|
+
if (!env) return null;
|
|
180
|
+
const directory = path.join(root, STACKS_DIRECTORY, environment);
|
|
181
|
+
const components = declaration.components ?? {};
|
|
182
|
+
const declared = new Map();
|
|
183
|
+
for (const composeFile of asList(env.composeFiles)) {
|
|
184
|
+
for (const { name, definition, directory: composeDirectory } of composeServices(path.join(directory, composeFile))) declared.set(name, { definition, directory: composeDirectory });
|
|
185
|
+
}
|
|
186
|
+
const services = [];
|
|
187
|
+
for (const [name, { definition, directory: composeDirectory }] of declared) {
|
|
188
|
+
const image = typeof definition.image === 'string' ? definition.image : components[name]?.image;
|
|
189
|
+
if (typeof image !== 'string') continue;
|
|
190
|
+
const repository = repositoryOf(image);
|
|
191
|
+
const known = KNOWN_IMAGES[repository];
|
|
192
|
+
const role = components[name]?.role ?? 'stateful';
|
|
193
|
+
const profiles = asList(definition.profiles);
|
|
194
|
+
const command = typeof definition.command === 'string' ? words(definition.command) : asList(definition.command).map(String);
|
|
195
|
+
services.push({
|
|
196
|
+
name,
|
|
197
|
+
image,
|
|
198
|
+
repository,
|
|
199
|
+
tag: isTagged(image) ? image.slice(imagePathOf(image).length + 1) : null,
|
|
200
|
+
role,
|
|
201
|
+
kind: known?.kind ?? 'other',
|
|
202
|
+
aliases: known?.aliases ?? [repository],
|
|
203
|
+
probe: known?.probe ?? { tcp: true },
|
|
204
|
+
command,
|
|
205
|
+
environment: environmentOf(definition.environment),
|
|
206
|
+
ports: containerPortsOf(definition.ports),
|
|
207
|
+
mounts: mountsOf(definition.volumes, composeDirectory),
|
|
208
|
+
persistent: persistsData(definition.volumes),
|
|
209
|
+
runs: !NOT_RUN_ROLES.has(role) && !profiles.some(profile => NOT_RUN_PROFILES.has(profile)),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const proxy = services.find(service => service.kind === 'proxy') ?? null;
|
|
213
|
+
return { environment, directory, services, proxy };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The names a fake of `service` could hide behind: its own name, its image repository and the aliases of a well-known image. */
|
|
217
|
+
export const namesOfService = service => [...new Set([service.name, service.repository, ...service.aliases])];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// test-secrets.mjs — test credentials follow the product repository's own `.starcistacks` convention (owner ruling
|
|
3
|
+
// push-scan-test-secrets-encrypted, 2026-09-28: they work exactly like the stack custody and a plaintext password is never pushed).
|
|
4
|
+
// There is no store of the runtime's own: a test credential lives at `<repo>/.starcistacks/<stack>/secrets/test/<name>`,
|
|
5
|
+
// its plaintext git-ignored by the repo's `.starcistacks/**` rules and only the sops-encrypted twin `<name>.enc`
|
|
6
|
+
// committed. It is written with the repository's existing command (`node scripts/stack-secret.mjs set
|
|
7
|
+
// <stack>/secrets/test/<name>`, npm run secret:set), which encrypts against the recipients `.sops.yaml` names and
|
|
8
|
+
// removes the plaintext; this module only READS: the local plaintext when present, else the `.enc` decrypted by sops
|
|
9
|
+
// with the shared age identity (SOPS_AGE_KEY_FILE, default ~/.starci/master.identity) into memory.
|
|
10
|
+
// A credential that need not be stable across runs (a disposable account registered per run) is generated per run
|
|
11
|
+
// and stored nowhere. A value is never logged.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/lib/test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal] prints nothing unless --reveal
|
|
14
|
+
//
|
|
15
|
+
// In a script: import { testSecret } from '<runtime>/scripts/lib/test-secrets.mjs';
|
|
16
|
+
// const password = testSecret('login-capture-password', { repo });
|
|
17
|
+
// The push secret scan (scripts/supervisor/push-mains.mjs) passes a `.enc` only when isSopsEnvelope holds.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
24
|
+
|
|
25
|
+
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
26
|
+
const STACK = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
27
|
+
/** One sops-encrypted leaf: `ENC[AES256_GCM,data:...,iv:...,tag:...,type:...]` (data may be empty). */
|
|
28
|
+
const SOPS_VALUE = /^ENC\[AES256_GCM,data:[A-Za-z0-9+/=]*,iv:[A-Za-z0-9+/=]+,tag:[A-Za-z0-9+/=]+,type:[a-z]+\]$/;
|
|
29
|
+
|
|
30
|
+
/** Where test credential `name` of `stack` lives in `repo`: {plain, enc, rel}. `rel` is the path the repo's
|
|
31
|
+
* `stack-secret.mjs set` takes. */
|
|
32
|
+
export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
|
|
33
|
+
if (!NAME.test(String(name ?? ''))) throw new Error(`test secret name must match ${NAME} (got ${JSON.stringify(name)})`);
|
|
34
|
+
if (!STACK.test(String(stack ?? ''))) throw new Error(`stack must match ${STACK} (got ${JSON.stringify(stack)})`);
|
|
35
|
+
if (!repo) throw new Error('test secret needs {repo}: the product repository whose .starcistacks holds it');
|
|
36
|
+
const rel = `${stack}/secrets/test/${name}`;
|
|
37
|
+
const plain = path.join(path.resolve(String(repo)), '.starcistacks', stack, 'secrets', 'test', name);
|
|
38
|
+
return { plain, enc: `${plain}.enc`, rel };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The command that stores a test credential, in the product repository's own tooling. */
|
|
42
|
+
export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
|
|
43
|
+
|
|
44
|
+
/** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
|
|
45
|
+
export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
|
|
46
|
+
|
|
47
|
+
/** The sops binary: PATH, then winget's Links and Packages (spawn without a shell ignores PATHEXT). */
|
|
48
|
+
export function resolveSops(env = process.env) {
|
|
49
|
+
const win = process.platform === 'win32';
|
|
50
|
+
const names = win ? ['sops.exe', 'sops'] : ['sops'];
|
|
51
|
+
const dirs = String(env.PATH ?? '').split(win ? ';' : ':').filter(Boolean);
|
|
52
|
+
if (win && env.LOCALAPPDATA) {
|
|
53
|
+
const winget = path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet');
|
|
54
|
+
dirs.push(path.join(winget, 'Links'));
|
|
55
|
+
const packages = path.join(winget, 'Packages');
|
|
56
|
+
try { for (const e of fs.readdirSync(packages)) if (/sops/i.test(e)) dirs.push(path.join(packages, e)); } catch { /* none */ }
|
|
57
|
+
}
|
|
58
|
+
for (const dir of dirs) for (const n of names) { const f = path.join(dir, n); try { if (fs.statSync(f).isFile()) return f; } catch { /* next */ } }
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read test credential `name`: the local plaintext when present, else its committed `.enc` through sops. */
|
|
63
|
+
export function testSecret(name, { repo, stack = 'dev', env = process.env, sops = null } = {}) {
|
|
64
|
+
const { plain, enc, rel } = testSecretPaths(name, { repo, stack });
|
|
65
|
+
if (fs.existsSync(plain)) return fs.readFileSync(plain, 'utf8').replace(/\r?\n$/, '');
|
|
66
|
+
if (!fs.existsSync(enc)) throw new Error(`test secret ${name} is not in .starcistacks/${rel}.enc; store it with \`${setCommand(name, stack)}\` in ${repo}`);
|
|
67
|
+
const bin = sops ?? resolveSops(env);
|
|
68
|
+
if (!bin) throw new Error('sops is not installed (Windows: winget install Mozilla.SOPS)');
|
|
69
|
+
const format = sopsFormatFor(plain);
|
|
70
|
+
const r = spawnSync(bin, ['--decrypt', '--input-type', format, '--output-type', format, enc], {
|
|
71
|
+
cwd: path.resolve(String(repo)), encoding: 'utf8', windowsHide: true,
|
|
72
|
+
env: { ...env, SOPS_AGE_KEY_FILE: env.SOPS_AGE_KEY_FILE || path.join(os.homedir(), '.starci', 'master.identity') },
|
|
73
|
+
});
|
|
74
|
+
if (r.status !== 0) throw new Error(`test secret ${name}: sops could not decrypt .starcistacks/${rel}.enc (${String(r.stderr ?? r.error?.message ?? '').trim().split(/\r?\n/).pop()})`);
|
|
75
|
+
return String(r.stdout).replace(/\r?\n$/, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
|
|
79
|
+
const allEncrypted = (node) => {
|
|
80
|
+
if (node === null) return true;
|
|
81
|
+
if (Array.isArray(node)) return node.every(allEncrypted);
|
|
82
|
+
if (typeof node === 'object') return Object.values(node).every(allEncrypted);
|
|
83
|
+
return typeof node === 'string' && SOPS_VALUE.test(node);
|
|
84
|
+
};
|
|
85
|
+
const sopsTree = (doc) => !!doc && typeof doc === 'object' && !Array.isArray(doc) && !!doc.sops && typeof doc.sops === 'object'
|
|
86
|
+
&& typeof doc.sops.mac === 'string' && SOPS_VALUE.test(doc.sops.mac)
|
|
87
|
+
&& Object.keys(doc).length > 1 && Object.entries(doc).every(([k, v]) => k === 'sops' || allEncrypted(v));
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True only for a sops-encrypted file with no plaintext value: binary/json form ({"data": ENC[...], "sops": {...}}),
|
|
91
|
+
* yaml form (every value ENC[...] plus the sops block) or dotenv form (KEY=ENC[...] lines plus sops_* metadata).
|
|
92
|
+
* The sops metadata must carry its mac. A key kept plaintext through `unencrypted_suffix` does not pass.
|
|
93
|
+
*/
|
|
94
|
+
export function isSopsEnvelope(text) {
|
|
95
|
+
const src = String(text ?? '').replace(/^/, '');
|
|
96
|
+
if (!src.trim()) return false;
|
|
97
|
+
try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
|
|
98
|
+
const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
|
|
99
|
+
if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
|
|
100
|
+
const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
|
|
101
|
+
const meta = pairs.filter(([k]) => k.startsWith('sops_'));
|
|
102
|
+
const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
|
|
103
|
+
return vals.length > 0 && vals.every(([, v]) => SOPS_VALUE.test(v)) && meta.some(([k, v]) => k === 'sops_mac' && SOPS_VALUE.test(v));
|
|
104
|
+
}
|
|
105
|
+
try { return sopsTree(parseYaml(src)); } catch { return false; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
109
|
+
const [cmd, name, ...rest] = process.argv.slice(2);
|
|
110
|
+
const opt = (f) => { const i = rest.indexOf(f); return i >= 0 ? rest[i + 1] : undefined; };
|
|
111
|
+
try {
|
|
112
|
+
if (cmd === 'get') {
|
|
113
|
+
const value = testSecret(name, { repo: opt('--repo'), stack: opt('--stack') ?? 'dev' });
|
|
114
|
+
if (rest.includes('--reveal')) process.stdout.write(`${value}\n`);
|
|
115
|
+
} else {
|
|
116
|
+
console.error(`usage: test-secrets.mjs get <name> --repo <path> [--stack dev] [--reveal]\nstore one with the product repo's own command: ${setCommand()}`);
|
|
117
|
+
process.exitCode = 2;
|
|
118
|
+
}
|
|
119
|
+
} catch (error) { console.error(String(error?.message ?? error)); process.exitCode = 1; }
|
|
120
|
+
}
|
package/served-locale.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
35
|
-
import { fileOf, inSlot,
|
|
35
|
+
import { fileOf, inSlot, stem } from "./lib/scope.mjs"
|
|
36
36
|
|
|
37
37
|
/** The slots that hold transport: the app's `modules/api` and the shared api package. Neither slot names its links, see the note on `linkFileName`. */
|
|
38
38
|
const TRANSPORT_SLOTS = ["fe.modules.api", "fe.package.api"]
|
|
@@ -124,7 +124,7 @@ export const rules = {
|
|
|
124
124
|
// A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
|
|
125
125
|
// because defining it IS its job, and there is no correct way to attach a locale there. A
|
|
126
126
|
// spec asserts about a chain rather than being one.
|
|
127
|
-
if (isLinkImplementationFile(context)
|
|
127
|
+
if (isLinkImplementationFile(context)) return {}
|
|
128
128
|
let terminalNode = null
|
|
129
129
|
let attachesLocale = false
|
|
130
130
|
const note = (node) => {
|
package/shape-slot.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
23
|
-
import { classifyImport, fileOf, inSlot,
|
|
23
|
+
import { classifyImport, fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.mjs"
|
|
24
24
|
|
|
25
25
|
/** The layers and feature kinds that split into a connected `index.tsx` and a pure `component.tsx`; a shared package's layers never split. */
|
|
26
26
|
const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"])
|
|
@@ -206,7 +206,6 @@ export const baseImportPair = {
|
|
|
206
206
|
},
|
|
207
207
|
create(context) {
|
|
208
208
|
const isOwnIndex = roleOfFile(context) === "entry"
|
|
209
|
-
const spec = isSpecFile(fileOf(context))
|
|
210
209
|
const inTier = inSplitTier(context)
|
|
211
210
|
// An import reaches a pure half when the file it resolves to has the `drawing` role of its slot.
|
|
212
211
|
const reachesPure = (source) => classifyImport(context, source)?.role === "drawing"
|
|
@@ -214,7 +213,7 @@ export const baseImportPair = {
|
|
|
214
213
|
const check = (node, source) => {
|
|
215
214
|
if (typeof source !== "string" || !reachesPure(source)) return
|
|
216
215
|
const isSibling = /^\.\/[^/]+$/.test(source)
|
|
217
|
-
if (isSibling &&
|
|
216
|
+
if (isSibling && isOwnIndex) return
|
|
218
217
|
if (isSibling && !inTier) return
|
|
219
218
|
context.report({ node, messageId: "reach", data: { source } })
|
|
220
219
|
}
|