@starci/hfs 4.7.26 → 4.7.28

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -3
  3. package/package.json +7 -1
  4. package/release/candidate-ci.mjs +3 -21
  5. package/runtime/engine/yaml.mjs +1 -1
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/rules.yaml +913 -637
  8. package/runtime/knowledge/hfs/slots.yaml +21 -14
  9. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  10. package/runtime/packages/cli/src/catalog.generated.mjs +130 -54
  11. package/runtime/scripts/api/git/check-ignore.mjs +5 -0
  12. package/runtime/scripts/gates/runtime-host.mjs +83 -0
  13. package/runtime/scripts/gates/sonar-gate.mjs +251 -0
  14. package/runtime/scripts/gates/starcistacks-ci.mjs +55 -0
  15. package/runtime/scripts/gates/starcistacks-form.mjs +26 -0
  16. package/runtime/scripts/gates/starcistacks-runtime.mjs +2 -0
  17. package/runtime/scripts/gates/starcistacks-value-file.mjs +17 -0
  18. package/runtime/scripts/gates/starcistacks.mjs +499 -0
  19. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  20. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  21. package/runtime/scripts/hfs/check.mjs +4 -1
  22. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  23. package/runtime/scripts/hfs/manifest-shape.mjs +1 -3
  24. package/runtime/scripts/hfs/rule-catalog.mjs +7 -10
  25. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  27. package/runtime/scripts/hfs/rules/bypass-markers.mjs +24 -0
  28. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  29. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  30. package/runtime/scripts/lib/bypass-markers.mjs +116 -0
  31. package/runtime/scripts/lib/check-code-loosening.mjs +128 -0
  32. package/runtime/scripts/lib/example-refs.mjs +205 -0
  33. package/runtime/scripts/lib/failure-code-reads.mjs +100 -0
  34. package/runtime/scripts/lib/gate-loosening.mjs +52 -8
  35. package/runtime/scripts/lib/json-formats.mjs +34 -0
  36. package/runtime/scripts/lib/managed-json.mjs +1 -1
  37. package/runtime/scripts/lib/package-at.mjs +1 -1
  38. package/runtime/scripts/lib/properties.mjs +16 -0
  39. package/runtime/scripts/lib/provision-control-failure.mjs +32 -0
  40. package/runtime/scripts/lib/sonar-new-code.mjs +58 -0
  41. package/runtime/scripts/lib/starciwork-boundary.mjs +164 -0
  42. package/runtime/scripts/lib/work-edition.mjs +40 -0
  43. package/sync/format.mjs +1 -1
  44. package/sync/hygiene.mjs +16 -4
  45. package/templates/app/hooks/husky/pre-commit +8 -7
@@ -17,7 +17,13 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
17
17
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
18
18
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
19
19
  const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
20
- const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa', 'specNa', 'exampleNa']);
20
+ /**
21
+ * The enforcer kinds that run only inside the StarCi runtime repository (`starci runtime check`, `starci work`): no published package carries
22
+ * them, so no package spec can hold them (owner ruling no-runtime-specs: the runtime has no specs of its own; the package clean-test and the
23
+ * scaffold e2e are its tests), and no example app is a product they judge. A rule is runtime-judged when every enforcer it has is of these kinds.
24
+ */
25
+ export const RUNTIME_ONLY_ENFORCERS = Object.freeze(['work-validate', 'runtime']);
26
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
21
27
  const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
22
28
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
23
29
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
@@ -116,13 +122,6 @@ function witnessProblems(rule, label, bad) {
116
122
  const value = rule.witness;
117
123
  if (value === undefined) return;
118
124
  const at = `${label}.witness`;
119
- if (isPlainObject(value)) {
120
- unknownKeys(value, new Set(['status', 'reason', 'date']), at, bad);
121
- if (value.status !== 'unreachable') bad.push(`${at}.status must be unreachable`);
122
- textProblem(value.reason, `${at}.reason`, bad);
123
- dateProblem(value.date, `${at}.date`, bad);
124
- return;
125
- }
126
125
  if (!nonEmptyList(value, at, bad)) return;
127
126
  value.forEach((item, index) => witnessEntryProblems(item, `${at}[${index}]`, bad));
128
127
  }
@@ -283,8 +282,6 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
283
282
  if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
284
283
  optionalText(rule, 'editionWhy', label, bad);
285
284
  optionalText(rule, 'templateNa', label, bad);
286
- optionalText(rule, 'specNa', label, bad);
287
- optionalText(rule, 'exampleNa', label, bad);
288
285
  for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
289
286
  witnessProblems(rule, label, bad);
290
287
  enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
@@ -49,7 +49,7 @@ export const A15_CODES = [
49
49
  // codes-end
50
50
  ];
51
51
 
52
- const FINDERS = [
52
+ export const FINDERS = [
53
53
  // finders-begin
54
54
  feBuildArgsFindings,
55
55
  workflowContextsFindings,
@@ -8,7 +8,7 @@ import { found } from './read.mjs';
8
8
 
9
9
  export const HFS_ASSET_OVERSIZE = 'HFS_ASSET_OVERSIZE';
10
10
  export const HFS_ASSET_KIND = 'HFS_ASSET_KIND';
11
- const DEFAULT_ASSET_BYTES = 512000;
11
+ export const DEFAULT_ASSET_BYTES = 512000;
12
12
  const BUILD_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2', '.eot', '.png', '.svg', '.webp', '.jpg', '.jpeg', '.gif', '.ico', '.avif', '.bmp', '.tif', '.tiff'];
13
13
  const MEDIA_EXTENSIONS = new Set(['.mp3', '.mp4', '.wav', '.ogg', '.oga', '.opus', '.flac', '.aac', '.m4a', '.wma', '.aiff', '.aif', '.mid', '.midi', '.mov', '.m4v', '.webm', '.avi', '.mkv', '.mpeg', '.mpg', '.wmv', '.flv', '.3gp']);
14
14
  const IMAGE_EXTENSIONS = new Set(BUILD_EXTENSIONS.filter((ext) => !['.ttf', '.otf', '.woff', '.woff2', '.eot'].includes(ext)));
@@ -0,0 +1,24 @@
1
+ // bypass-markers.mjs - HFS_BYPASS_MARKER (R303): a product repository holds no bypass of a check. Whatever makes a check pass without
2
+ // satisfying it is refused wherever it is written (owner ruling loosening-always-red: a bypass is always red):
3
+ // - an inline disable of any tool (eslint, stylelint, tslint, prettier, biome), a type-check escape (the ts-ignore, ts-expect-error and
4
+ // ts-nocheck directives), a Sonar exclusion comment, an istanbul / c8 / v8 coverage ignore;
5
+ // - a skipped, focused or todo test, or a skip option (a front end has no specs, but its source is still judged for the inline disables);
6
+ // - a failure swallowed in a hook, a script or a workflow: `|| true`, `continue-on-error`, `set +e`, `HUSKY=0`, `--no-verify`,
7
+ // `fail_ci_if_error: false`.
8
+ // The text is read from the tracked tree here, and from the index by `starci app hygiene` (the pre-commit hook), which uses the same table
9
+ // (scripts/lib/bypass-markers.mjs). A managed config edited to loosen is the managed-file drift's (HFS_MANAGED_FILE_DRIFT).
10
+ import { found, readText } from './read.mjs';
11
+ import { bypassesIn } from '../../lib/bypass-markers.mjs';
12
+
13
+ export const HFS_BYPASS_MARKER = 'HFS_BYPASS_MARKER';
14
+
15
+ /** The finding message of one marker of `file`. */
16
+ export const bypassMessage = (file, hit) => `${file}:${hit.line} spells ${hit.label} (${hit.text.slice(0, 80)}); a check is satisfied, never bypassed: fix the cause and delete the marker`;
17
+
18
+ /** The findings of R303 over the tracked paths `files` of the repository at `repoRoot`. */
19
+ export function bypassMarkerFindings({ repoRoot, files }) {
20
+ return files.flatMap((file) => {
21
+ const text = readText(repoRoot, file);
22
+ return text === null ? [] : bypassesIn(file, text).map((hit) => found(HFS_BYPASS_MARKER, file, bypassMessage(file, hit), { line: hit.line }));
23
+ });
24
+ }
@@ -22,7 +22,7 @@ const I18N_CATALOG = 'FE_I18N_CATALOG';
22
22
  export const DEFAULT_LOCALE = 'vi';
23
23
  const ROUTE_FILE = /\/(?:page|layout|template|loading|error|not-found)\.[jt]sx?$/;
24
24
  const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/;
25
- const WIRE_SCHEMA_STAMP = '.schema-sha256';
25
+ export const WIRE_SCHEMA_STAMP = '.schema-sha256';
26
26
 
27
27
  /** Every leaf key of a parsed catalog as a dotted path. */
28
28
  function catalogKeys(value, prefix = '') {
@@ -32,7 +32,7 @@ function catalogKeys(value, prefix = '') {
32
32
  }
33
33
 
34
34
  /** Digest protocol for codegen: sorted app-relative schema path, NUL, LF-normalized source, NUL. */
35
- function wireSchemaDigest(repoRoot, snapshots) {
35
+ export function wireSchemaDigest(repoRoot, snapshots) {
36
36
  const hash = createHash('sha256');
37
37
  for (const snapshot of [...snapshots].sort(byCodeUnit)) {
38
38
  let source;
@@ -77,7 +77,7 @@ function providerSecretFindings(repoRoot, file, repo, findings) {
77
77
  }
78
78
 
79
79
  /** Raw L17 findings. The catalog-driven findings filter decides which editions judge them. */
80
- function liteSecretCustodyFindings({ repoRoot, files, repo }) {
80
+ export function liteSecretCustodyFindings({ repoRoot, files, repo }) {
81
81
  const findings = [];
82
82
  for (const file of files) {
83
83
  if (custodyPathFinding(file, repo, findings)) continue;
@@ -0,0 +1,116 @@
1
+ // bypass-markers.mjs - what a BYPASS of a check looks like in text (owner ruling loosening-always-red: any bypass is red, always).
2
+ // One table serves both sides of the no-bypass law:
3
+ // product scripts/hfs/rules/bypass-markers.mjs (HFS_BYPASS_MARKER, `starci app check`) and `starci app hygiene` (the pre-commit
4
+ // hook) read a whole file and refuse every marker in it;
5
+ // runtime scripts/lib/gate-loosening.mjs reads the ADDED lines of a diff of a watched check, canon template or workflow.
6
+ // A marker is a way to make a check pass without satisfying it: an inline disable of any tool, a type-check escape, a Sonar or
7
+ // coverage exclusion comment, a skipped or focused test, a failure swallowed in a hook or a workflow step.
8
+ // The patterns are assembled from fragments (joined) so that this file never spells a marker itself: the runtime detector watches
9
+ // its own library like every other check script.
10
+ const join = (...parts) => parts.join('');
11
+ const COMMENT_OPEN = /\/\/|\/\*/g;
12
+
13
+ const SUPPRESSION_WORDS = [
14
+ join('eslint', '-disable'), join('stylelint', '-disable'), join('tslint', ':disable'), join('prettier', '-ignore'), join('biome', '-ignore'),
15
+ join('@ts', '-ignore'), join('@ts', '-expect-error'), join('@ts', '-nocheck'), join('NO', 'SONAR'),
16
+ join('istanbul', ' ignore'), join('c8', ' ignore'), join('v8', ' ignore'), join('jscpd', ':ignore'),
17
+ ];
18
+ const escaped = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
19
+ const COMMENT_MARKER = new RegExp(`(${SUPPRESSION_WORDS.map(escaped).join('|')})`);
20
+
21
+ const TEST_MARKERS = [
22
+ [new RegExp(String.raw`\b(?:it|test|describe|suite|context)\.(?:skip|only|todo|failing)\b`), 'a skipped or focused test'],
23
+ [new RegExp(String.raw`\b(?:xit|xtest|xdescribe|fit|fdescribe)\(`), 'a skipped or focused test'],
24
+ [new RegExp(String.raw`\.skipIf\(|\.runIf\(`), 'a conditionally skipped test'],
25
+ [new RegExp(String.raw`\bskip\s*:\s*(?!false\b)\S`), 'a skip option'],
26
+ [new RegExp(String.raw`\bpending\(\)`), 'a pending test'],
27
+ ];
28
+
29
+ const SWALLOW = [
30
+ [/\|\|\s*(?:true|:)(?![\w-])/, 'a failure swallowed with `|| true`'],
31
+ [/\bcontinue-on-error\s*:\s*(?!false\b)\S/, join('continue', '-on-error')],
32
+ [/\bset\s+\+e\b/, 'set +e'],
33
+ [/\bHUSKY\s*=\s*0\b/, 'HUSKY=0'],
34
+ [/--no-verify\b/, '--no-verify'],
35
+ [/\bfail_ci_if_error\s*:\s*false\b/, 'fail_ci_if_error: false'],
36
+ ];
37
+
38
+ // A weakening of the run itself that today's rendered canon still spells in places (the CI template runs jest with it); the runtime
39
+ // detector refuses it as an ADDED line, the product rule waits until the canon is rid of it.
40
+ const PENDING = [
41
+ [/--passWithNoTests\b/, '--passWithNoTests'],
42
+ [/--coverageThreshold\b/, '--coverageThreshold'],
43
+ ];
44
+
45
+ const CODE_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte|css|scss|less)$/;
46
+ const SHELL_FILE = /(?:^|\/)(?:\.husky\/(?!_\/)[^/]+|[^/]+\.sh|hooks\/husky\/[^/]+)$/;
47
+ const WORKFLOW_FILE = /(?:^|\/)(?:\.github|github)\/(?:workflows|actions)\/.+\.ya?ml$/;
48
+ const MANIFEST_FILE = /(?:^|\/)package\.json$/;
49
+ const SKIPPED_TREE = /(?:^|\/)(?:node_modules|dist|coverage|\.next|build)\//;
50
+ const GENERATED_NAME = /(?:^|\/)(?:package-lock\.json|[^/]+\.generated\.[^/]+|[^/]+\.min\.[^/]+)$/;
51
+
52
+ /** The kind of text `file` holds for the markers: 'code', 'shell' (hooks, scripts), 'workflow', 'manifest', or null for a file no marker is read in. */
53
+ export function bypassFileKind(file) {
54
+ if (SKIPPED_TREE.test(file) || GENERATED_NAME.test(file)) return null;
55
+ if (SHELL_FILE.test(file)) return 'shell';
56
+ if (WORKFLOW_FILE.test(file)) return 'workflow';
57
+ if (MANIFEST_FILE.test(file)) return 'manifest';
58
+ if (CODE_FILE.test(file)) return 'code';
59
+ return null;
60
+ }
61
+
62
+ /** Whether `index` of `line` is outside every quoted string (a marker spelled inside a string is data, not a directive). */
63
+ function outsideQuotes(line, index) {
64
+ let quote = null;
65
+ for (let at = 0; at < index; at += 1) {
66
+ const char = line[at];
67
+ if (quote) {
68
+ if (char === '\\') at += 1;
69
+ else if (char === quote) quote = null;
70
+ } else if (char === '"' || char === "'" || char === '`') quote = char;
71
+ }
72
+ return quote === null;
73
+ }
74
+
75
+ /** The text of a shell line without its `$(...)` and backtick substitutions: `|| true` inside one is a query, not a bypass. */
76
+ const withoutSubstitutions = (line) => line.replace(/\$\([^()]*\)/g, '').replace(/`[^`]*`/g, '');
77
+
78
+ const hitsOf = (pairs, line) => pairs.filter(([pattern]) => pattern.test(line)).map(([, label]) => label);
79
+
80
+ function commentHit(line) {
81
+ for (const open of line.matchAll(COMMENT_OPEN)) {
82
+ if (!outsideQuotes(line, open.index)) continue;
83
+ const marker = COMMENT_MARKER.exec(line.slice(open.index));
84
+ if (marker) return marker[1];
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** The labels of the bypass markers on one `line` of a file of `kind` ([] when none). `pending` also reads the markers the canon still spells. */
90
+ export function bypassLabelsOf(kind, line, { pending = false } = {}) {
91
+ if (kind === 'code') {
92
+ const comment = commentHit(line);
93
+ const tests = TEST_MARKERS.filter(([pattern]) => {
94
+ const hit = pattern.exec(line);
95
+ return hit && outsideQuotes(line, hit.index);
96
+ }).map(([, label]) => label);
97
+ return [...(comment ? [comment] : []), ...tests];
98
+ }
99
+ if (kind === 'shell' || kind === 'workflow' || kind === 'manifest') {
100
+ const text = withoutSubstitutions(line);
101
+ if (kind === 'manifest' && !/"[^"]+"\s*:\s*"/.test(line)) return [];
102
+ if (/^\s*#/.test(text)) return [];
103
+ return [...hitsOf(SWALLOW, text), ...(pending ? hitsOf(PENDING, text) : [])];
104
+ }
105
+ return [];
106
+ }
107
+
108
+ /**
109
+ * Every bypass marker of the text of one file: [{line, label, text}] (line numbers from 1). The file's kind decides the patterns;
110
+ * a file of no kind has none.
111
+ */
112
+ export function bypassesIn(file, text, options = {}) {
113
+ const kind = bypassFileKind(file);
114
+ if (!kind || typeof text !== 'string') return [];
115
+ return text.split(/\r?\n/).flatMap((content, index) => bypassLabelsOf(kind, content, options).map((label) => ({ line: index + 1, label, text: content.trim() })));
116
+ }
@@ -0,0 +1,128 @@
1
+ // check-code-loosening.mjs - what loosens a gate when the gate is CODE: a check script, a canon template, a lint package, a workflow or a hook.
2
+ // gate-loosening.mjs judges the lists and numbers of the listed gate files; a bypass done by editing the check itself (a skip, an inline disable,
3
+ // a swallowed failure, a ban or an assertion deleted, an exemption widened) is judged here, over the same unified diff, rename-aware (a line
4
+ // that moves keeps its text, so it is paired off against its removal anywhere in the change). The kinds it reports:
5
+ // bypass-added a bypass marker added to a file of checkCode (scripts/lib/bypass-markers.mjs)
6
+ // check-weakened the change removes more enforcement lines from the files of checkCode than it adds back to them
7
+ // check-removed a pattern-table row or quoted list entry removed from a check script and added nowhere in the change (a quoted
8
+ // operand of a string concatenation, the text of a message, is no row)
9
+ // scope-widened an exemption added: an ignore / exclude / exclusion key gaining an entry, a line more in an ignore file, an item added
10
+ // under codecov.yml `ignore:`, or skipLibCheck turned on
11
+ // A deleted file is not counted for check-weakened or check-removed: a registered check is judged by its entry in a gate file
12
+ // (knowledge/hfs/runtime-slots.yaml), and an unregistered script deleted is dead code (scripts/checks/check-dead-scripts.mjs).
13
+ import { bypassFileKind, bypassLabelsOf } from './bypass-markers.mjs';
14
+
15
+ const SPEC_NAME = /\.spec\.[cm]?[jt]sx?$/;
16
+ const FIXTURE_NAME = /\.fixture(?:\.\w+)?$/;
17
+ const CODE_NAME = /\.[cm]?[jt]sx?$/;
18
+ const COMMENT_LINE = /^\s*(?:\/\/|\/\*|\*|#)/;
19
+ const NOT_TEXT = /^\s*(?:#|\/\/|$)/;
20
+
21
+ const trimmed = (line) => line.trim();
22
+ const clip = (text) => (text.length > 90 ? `${text.slice(0, 89)}…` : text);
23
+
24
+ /** Whether `file` is one of the entries: a folder (the entry ends in a slash) or one file. */
25
+ const listed = (file, entries = []) => entries.some((entry) => (entry.endsWith('/') ? file.startsWith(entry) : file === entry));
26
+
27
+ /** Whether `file` is a check, a canon template, a lint package, a workflow or a hook (checkCode); its specs and fixtures spell markers on purpose and are not. */
28
+ export const isCheckCode = (file, rules) => listed(file, rules.checkCode) && !listed(file, rules.capabilityFiles) && !SPEC_NAME.test(file) && !FIXTURE_NAME.test(file);
29
+
30
+ const WORD = String.raw`\b(?:findings|violations|failures|errors|problems|issues|found|flag|fail)\s*(?:\.push\(|\()`;
31
+ const EXIT = String.raw`process\.exit(?:Code)?\b|\bassert[.(]|\bexpect\(|\bok\s*:\s*false\b`;
32
+ /** The lines that make a check fail: a finding pushed, a failing exit, an assertion. */
33
+ const ENFORCEMENT = new RegExp(`${WORD}|${EXIT}`);
34
+ const enforcementCount = (lines) => lines.filter((line) => !COMMENT_LINE.test(line) && ENFORCEMENT.test(line)).length;
35
+
36
+ const LITERAL = '/.+/[a-z]*|\'[^\']*\'|"[^"]*"|`[^`]*`';
37
+ /** A pattern-table row or a quoted list entry of a check script: the shape of a ban list or a rule table. */
38
+ const LIST_ENTRY = new RegExp(String.raw`^\s*(?:\[\s*(?:${LITERAL})\s*,.*\]|${LITERAL})\s*,?\s*(?://.*)?$`);
39
+
40
+ const countOf = (lines) => lines.reduce((counts, line) => counts.set(trimmed(line), (counts.get(trimmed(line)) ?? 0) + 1), new Map());
41
+
42
+ /** Each file's added lines once every line that also appears removed somewhere in the change (it only moved) is paired off: Map(entry -> {added, removed}). */
43
+ function unmovedOf(files) {
44
+ const added = countOf(files.flatMap((entry) => entry.added));
45
+ const removed = countOf(files.flatMap((entry) => entry.removed));
46
+ const keep = (line) => {
47
+ const key = trimmed(line);
48
+ if ((added.get(key) ?? 0) <= 0 || (removed.get(key) ?? 0) <= 0) return true;
49
+ added.set(key, added.get(key) - 1);
50
+ removed.set(key, removed.get(key) - 1);
51
+ return false;
52
+ };
53
+ return new Map(files.map((entry) => [entry, { added: entry.added.filter(keep), removed: entry.removed }]));
54
+ }
55
+
56
+ function bypassesAdded(entry, unmoved) {
57
+ const kind = bypassFileKind(entry.file);
58
+ if (!kind || entry.deleted) return [];
59
+ return unmoved.added.flatMap((line) => bypassLabelsOf(kind, line, { pending: true })
60
+ .map((label) => ({ kind: 'bypass-added', file: entry.file, detail: `${label}: ${clip(trimmed(line))}`, text: trimmed(line) })));
61
+ }
62
+
63
+ function entriesRemoved(entry, addedBack) {
64
+ if (entry.deleted || !CODE_NAME.test(entry.file)) return [];
65
+ const operand = (line) => entry.operands?.has(line) === true;
66
+ return entry.removed.filter((line) => LIST_ENTRY.test(line) && !COMMENT_LINE.test(line) && !operand(line)).flatMap((line) => {
67
+ const left = addedBack.get(trimmed(line)) ?? 0;
68
+ if (left > 0) { addedBack.set(trimmed(line), left - 1); return []; }
69
+ return [{ kind: 'check-removed', file: entry.file, detail: clip(trimmed(line)) }];
70
+ });
71
+ }
72
+
73
+ const EXEMPT_WORDS = ['ignores?', 'excludes?', 'exclusions?', 'coverageExclusions', '(?:coverage|testPath|modulePath)(?:Path)?IgnorePatterns', 'globalIgnores', String.raw`sonar\.(?:coverage\.)?exclusions`];
74
+ const EXEMPT_KEY = new RegExp(String.raw`["']?(?:${EXEMPT_WORDS.join('|')})["']?\s*[:=]`, 'i');
75
+ const SKIP_LIB_CHECK = /skipLibCheck["']?\s*:\s*true/;
76
+ const IGNORE_FILE = /(?:^|\/)[^/]*ignore$/;
77
+ const CODECOV_FILE = /(?:^|\/)codecov\.ya?ml$/;
78
+
79
+ const itemsOf = (line) => (line.match(/[^\s,"'[\]{}=:]+/g) ?? []).slice(1);
80
+
81
+ function scopesWidened(entry, unmoved) {
82
+ if (entry.deleted) return [];
83
+ const widened = (line) => ({ kind: 'scope-widened', file: entry.file, detail: clip(trimmed(line)) });
84
+ // An ignore file that gains more lines than it loses (a line rewritten is not a line more); codecov.yml: a list item added under `ignore:`.
85
+ if (IGNORE_FILE.test(entry.file)) return unmoved.added.filter((line) => !NOT_TEXT.test(line)).slice(unmoved.removed.filter((line) => !NOT_TEXT.test(line)).length).map(widened);
86
+ if (CODECOV_FILE.test(entry.file)) return unmoved.added.filter((line) => (entry.under?.get(line) ?? []).some((section) => /^ignore\b/.test(section))).map(widened);
87
+ return unmoved.added.flatMap((line) => {
88
+ if (SKIP_LIB_CHECK.test(line)) return [widened(line)];
89
+ const key = EXEMPT_KEY.exec(line)?.[0];
90
+ if (!key) return [];
91
+ const before = unmoved.removed.find((value) => value.includes(key));
92
+ const known = new Set(before === undefined ? [] : itemsOf(before));
93
+ return before === undefined || itemsOf(line).some((item) => !known.has(item)) ? [widened(line)] : [];
94
+ });
95
+ }
96
+
97
+ /** An import of the runtime's one exception list (scripts/lib/allowlist.mjs). */
98
+ const ALLOWLIST_IMPORT = /\bfrom\s+['"][./]*(?:scripts\/)?lib\/allowlist\.mjs['"]/;
99
+ const NAMES_ALLOWLIST = /allowlist/i;
100
+
101
+ /**
102
+ * The removed lines of `entry` that count as enforcement lost. A check that stops reading the exception list (the change removes its import of
103
+ * scripts/lib/allowlist.mjs and adds none back) loses the enforcement that policed that list's own entries (a stale entry, an entry code now
104
+ * reads): those lines name the list, and with the list gone they police nothing. Every other removed enforcement line counts.
105
+ */
106
+ function enforcementRemoved(entry) {
107
+ const dropsList = entry.removed.some((line) => ALLOWLIST_IMPORT.test(line)) && !entry.added.some((line) => ALLOWLIST_IMPORT.test(line));
108
+ return dropsList ? entry.removed.filter((line) => !NAMES_ALLOWLIST.test(line)) : entry.removed;
109
+ }
110
+
111
+ /** Every finding the check files of the change `files` (parseDiff entries) add under `rules` (modules/kernel/gate-loosening.yaml): [{kind, file, detail}]. */
112
+ export function checkCodeFindings(files, rules) {
113
+ const watched = files.filter((entry) => isCheckCode(entry.file, rules));
114
+ const unmoved = unmovedOf(watched);
115
+ const alive = watched.filter((entry) => !entry.deleted);
116
+ const fewerInChange = enforcementCount(alive.flatMap(enforcementRemoved)) - enforcementCount(alive.flatMap((entry) => entry.added));
117
+ const addedBack = countOf(watched.flatMap((entry) => entry.added));
118
+ return watched.flatMap((entry) => {
119
+ const own = unmoved.get(entry);
120
+ const fewer = enforcementCount(enforcementRemoved(entry)) - enforcementCount(entry.added);
121
+ return [
122
+ ...bypassesAdded(entry, own),
123
+ ...entriesRemoved(entry, addedBack),
124
+ ...scopesWidened(entry, own),
125
+ ...(fewerInChange > 0 && !entry.deleted && fewer > 0 ? [{ kind: 'check-weakened', file: entry.file, detail: `${fewer} enforcement line(s) fewer` }] : []),
126
+ ];
127
+ });
128
+ }
@@ -0,0 +1,205 @@
1
+ // example-refs.mjs — the ONE runtime source file that may spell a path into the runtime's own examples/
2
+ // tree or a product name the runtime legitimately knows (R206, NO_EXAMPLE_COUPLING): every other source
3
+ // file reaches these through the constants below, and scripts/checks/check-example-coupling.mjs exempts
4
+ // this file's name only - a declared read, never a path allowlist. A constant belongs here only when the
5
+ // runtime genuinely reads that tree or names that product at run time; a pointer that can be generic must
6
+ // be generic in the file that holds it.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { parseYaml } from '../../engine/yaml.mjs';
11
+ import { validateAgainstSchema } from './json-schema.mjs';
12
+ import { insidePath, samePath, slash } from './path-key.mjs';
13
+ import { byCodeUnit } from './list.mjs';
14
+ import { isLiteWorkRoot, workPathAllowed } from './work-edition.mjs';
15
+ import { WORK_DIR_NAME } from './roots.mjs';
16
+
17
+ /** The runtime's example tree root. */
18
+ export const EXAMPLES_ROOT = 'examples';
19
+
20
+ /** Curated basic sample source; operational artifact writers remain external. */
21
+ export const EXAMPLE_RUNTIMES_ROOT = `${EXAMPLES_ROOT}/.runtimes`;
22
+
23
+ /** Every examples/<name>/hfs.json declaration of kind app, sorted by name. */
24
+ export function discoverExampleApps(root) {
25
+ const directory = path.join(root, EXAMPLES_ROOT);
26
+ let entries;
27
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return []; }
28
+ return entries.filter((entry) => entry.isDirectory()).filter((entry) => {
29
+ try { return JSON.parse(fs.readFileSync(path.join(directory, entry.name, 'hfs.json'), 'utf8'))?.kind === 'app'; }
30
+ catch { return false; }
31
+ }).map((entry) => entry.name).sort(byCodeUnit);
32
+ }
33
+
34
+ /** The example apps that declare a browser journey (hfs.json `browser: true`, the opt-in slot app.browser-config), sorted by name: the matrix of the examples workflow's browser job. */
35
+ export function discoverBrowserExampleApps(root) {
36
+ return discoverExampleApps(root).filter((name) => {
37
+ try { return JSON.parse(fs.readFileSync(path.join(root, EXAMPLES_ROOT, name, 'hfs.json'), 'utf8'))?.browser === true; }
38
+ catch { return false; }
39
+ });
40
+ }
41
+
42
+ /** The generic services-block template a starcistacks follow-up leg is pointed at (S11-02: one template
43
+ * for every repository). */
44
+ export const STACK_DECLARATION_TEMPLATE = `${EXAMPLES_ROOT}/starcistacks-services/template.services.yaml`;
45
+
46
+ /** The reference app declaration scripts/hfs/derived-fields.mjs samples for per-field slot ownership. */
47
+ export const REFERENCE_APP_MANIFEST = `${EXAMPLES_ROOT}/full-app/hfs.json`;
48
+
49
+ /** The example app whose installed tree an fe-kit peer link points at by default. */
50
+ export const REFERENCE_EXAMPLE_APP = 'full-app';
51
+
52
+ /** The grammar css families a product repository may declare (`[data-grammar-family="<id>"]`); the
53
+ * runtime's own product maps to the core sheet. */
54
+ export const GRAMMAR_FAMILIES = Object.freeze({ starci: 'core', nivo: 'nivo' });
55
+
56
+ /** A feature-folder segment that is only the product's own name - no useful leaf (display-names pathLabel). */
57
+ export const PRODUCT_NAME_SEGMENT = /^(nivo|starci|mia)[-\w]*$/i;
58
+
59
+ /** The closed list of product names this runtime shipped against - the names R206
60
+ * (check-example-coupling.mjs) refuses anywhere in runtime source outside this file. */
61
+ export const PRODUCT_NAMES = Object.freeze(['mia-mia', 'miamia', 'nivo', 'starci-academy', 'full-app', 'shape-slot']);
62
+
63
+ /** The catalog of stable pattern IDs pointing at actual executable example source. */
64
+ export const EXAMPLE_CATALOG_FILE = `${EXAMPLES_ROOT}/index.yaml`;
65
+
66
+ function referenceFile(root, relative, { directory = false, exact = false } = {}) {
67
+ if (typeof relative !== 'string' || !relative || relative.includes('\\') || relative.includes(':') || path.isAbsolute(relative)
68
+ || relative.split('/').some((part) => !part || part === '.' || part === '..')) throw new Error(`invalid example input: ${relative}`);
69
+ const file = path.resolve(root, relative);
70
+ if (!insidePath(root, file)) throw new Error(`example input is outside its declared root: ${relative}`);
71
+ const realRoot = fs.realpathSync.native(root), base = path.resolve(root);
72
+ let at = base, expected = realRoot; // the root's own spelling (8.3 short name, symlinked prefix) is not a link inside the tree
73
+ for (const segment of path.relative(base, file).split(path.sep).filter(Boolean)) {
74
+ at = path.join(at, segment); expected = path.join(expected, segment);
75
+ if (fs.lstatSync(at).isSymbolicLink() || !samePath(fs.realpathSync.native(at), expected)
76
+ || (exact && !fs.readdirSync(path.dirname(at)).includes(path.basename(at))))
77
+ throw new Error(`linked example input: ${relative}`);
78
+ }
79
+ if (!(directory ? fs.lstatSync(file).isDirectory() : fs.lstatSync(file).isFile())
80
+ || !insidePath(realRoot, fs.realpathSync.native(file)))
81
+ throw new Error(`example input is not a contained regular file: ${relative}`);
82
+ return file;
83
+ }
84
+
85
+ /** Read and validate complete source references. Duplicate IDs refuse before
86
+ * lookup; source, compiler and test pointers stay within one declared HFS app. */
87
+ function loadExampleCatalog(root, { file = EXAMPLE_CATALOG_FILE } = {}) {
88
+ const catalog = parseYaml(fs.readFileSync(referenceFile(root, file), 'utf8'));
89
+ const schema = parseYaml(fs.readFileSync(referenceFile(root, 'modules/schemas/code-example-catalog.schema.yaml'), 'utf8'));
90
+ const errors = validateAgainstSchema(catalog, schema);
91
+ if (errors.length) throw new Error(`invalid example catalog: ${errors.join('; ')}`);
92
+ assertUniqueExampleIds(catalog.examples);
93
+ for (const row of catalog.examples) assertExampleRow(root, row);
94
+ return catalog;
95
+ }
96
+
97
+ function assertUniqueExampleIds(examples) {
98
+ const ids = new Set();
99
+ for (const row of examples) {
100
+ if (ids.has(row.id)) throw new Error(`duplicate example id: ${row.id}`);
101
+ ids.add(row.id);
102
+ }
103
+ }
104
+
105
+ function assertExampleRow(root, row) {
106
+ const app = exampleAppRoot(root, row);
107
+ assertExampleEntrypoint(row);
108
+ assertUniqueRowValues(row, 'relatedRules');
109
+ for (const key of ['files', 'projects', 'tests']) assertExampleInputs(app, row, key);
110
+ assertSupportedExampleSources(row);
111
+ }
112
+
113
+ function exampleAppRoot(root, row) {
114
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(row.path)) throw new Error(`invalid example app: ${row.path}`);
115
+ const app = path.join(root, EXAMPLES_ROOT, row.path);
116
+ const declaration = JSON.parse(fs.readFileSync(referenceFile(root, `${EXAMPLES_ROOT}/${row.path}/hfs.json`), 'utf8'));
117
+ if (declaration.kind !== 'app') throw new Error(`example has no HFS app declaration: ${row.id}`);
118
+ return app;
119
+ }
120
+
121
+ function assertExampleEntrypoint(row) {
122
+ if (!row.files.includes(row.entrypoint) || !/\.tsx?$/.test(row.entrypoint)) throw new Error(`example entrypoint is not a listed source: ${row.id}`);
123
+ }
124
+
125
+ function assertUniqueRowValues(row, key) {
126
+ if (new Set(row[key]).size !== row[key].length) throw new Error(`duplicate example ${key}: ${row.id}`);
127
+ }
128
+
129
+ function assertExampleInputs(app, row, key) {
130
+ assertUniqueRowValues(row, key);
131
+ for (const relative of row[key]) assertAuthoredExampleInput(app, relative);
132
+ }
133
+
134
+ function assertAuthoredExampleInput(app, relative) {
135
+ if (relative.split('/').some((part) => ['node_modules', 'dist', '.next', '_derived', '.git'].includes(part)))
136
+ throw new Error(`derived or installed example input: ${relative}`);
137
+ referenceFile(app, relative);
138
+ }
139
+
140
+ function assertSupportedExampleSources(row) {
141
+ if (row.files.some((relative) => !/\.(?:tsx?|json)$/.test(relative))) throw new Error(`unsupported example source: ${row.id}`);
142
+ }
143
+
144
+ /** Source, compiler and test inputs of selected stable IDs, relative to the runtime.
145
+ * A null selection requests the declared whole catalog; unknown IDs refuse. */
146
+ export function exampleSourcePaths(root, ids = null, options = {}) {
147
+ const catalog = loadExampleCatalog(root, options), selected = new Set(ids ?? catalog.examples.map((row) => row.id));
148
+ const known = new Set(catalog.examples.map((row) => row.id));
149
+ for (const id of selected) if (!known.has(id)) throw new Error(`unknown example id: ${id}`);
150
+ return [...new Set(catalog.examples.filter((row) => selected.has(row.id))
151
+ .flatMap((row) => [...row.files, ...row.projects, ...row.tests].map((relative) => `${EXAMPLES_ROOT}/${row.path}/${relative}`)))];
152
+ }
153
+
154
+ /** Actual example apps' own Work trees in app-name order. Dependency/template
155
+ * trees are not app Work; an absent own tree contributes no checker input. */
156
+ export function exampleWorkRoots(root) {
157
+ return discoverExampleApps(root).flatMap((app) => {
158
+ const relative = `${EXAMPLES_ROOT}/${app}/.starciwork/index.yaml`;
159
+ const directory = path.dirname(path.join(root, relative)), stat = fs.lstatSync(directory, { throwIfNoEntry: false });
160
+ if (!stat) return [];
161
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`invalid example Work directory: ${app}`);
162
+ referenceFile(root, `${EXAMPLES_ROOT}/${app}/.starciwork`, { directory: true });
163
+ if (!isLiteWorkRoot(directory)) referenceFile(root, relative);
164
+ return [directory];
165
+ });
166
+ }
167
+
168
+ /**
169
+ * The runtime checkout that holds `workRoot` when it is one of that checkout's example Work trees
170
+ * (`<checkout>/examples/<app>/.starciwork` beside `<checkout>/examples/.runtimes`), else null. An example is judged against its
171
+ * own checkout's fixture store: a lane's example by the lane's store, whichever runtime runs the check (the shared pre-commit hook
172
+ * runs the canonical runtime over a lane's staged files).
173
+ */
174
+ export function exampleCheckoutOf(workRoot) {
175
+ if (typeof workRoot !== 'string') return null;
176
+ const work = path.resolve(workRoot);
177
+ const appDir = path.dirname(work), examplesDir = path.dirname(appDir), checkout = path.dirname(examplesDir);
178
+ if (path.basename(work) !== WORK_DIR_NAME || path.basename(examplesDir) !== EXAMPLES_ROOT) return null;
179
+ return fs.existsSync(path.join(examplesDir, '.runtimes')) && fs.existsSync(path.join(appDir, 'hfs.json')) ? checkout : null;
180
+ }
181
+
182
+ /** Per-call READ options for an actual HFS app's own Work tree. A real project
183
+ * uses the default external store; an example never falls back to that store.
184
+ * The fixture CAS is curated source, not a destination for operational writers. */
185
+ export function exampleArtifactReadOptions(runtimeRoot, workRoot, { resolveRoot = workRoot } = {}) {
186
+ if (typeof runtimeRoot !== 'string' || typeof workRoot !== 'string') throw new TypeError('example artifact context needs runtime and Work paths');
187
+ const base = path.resolve(runtimeRoot), target = path.resolve(workRoot);
188
+ const relative = slash(path.relative(base, target)), parts = relative.split('/');
189
+ if (parts[0]?.toLowerCase() !== EXAMPLES_ROOT) return Object.freeze({});
190
+ const app = parts[1];
191
+ if (parts[0] !== EXAMPLES_ROOT || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(app ?? '')
192
+ || !discoverExampleApps(base).includes(app) || parts[2] !== WORK_DIR_NAME)
193
+ throw new Error(`invalid example Work artifact context: ${relative}`);
194
+ referenceFile(base, `${EXAMPLES_ROOT}/${app}/hfs.json`, { exact: true });
195
+ const ownWork = path.join(base, EXAMPLES_ROOT, app, WORK_DIR_NAME);
196
+ if (!isLiteWorkRoot(ownWork)) referenceFile(base, `${EXAMPLES_ROOT}/${app}/.starciwork/index.yaml`, { exact: true });
197
+ if (!workPathAllowed(ownWork, target, { dir: true })) throw new Error('lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore)');
198
+ referenceFile(base, relative, { directory: true, exact: true });
199
+ const resolution = path.resolve(resolveRoot);
200
+ if (!workPathAllowed(ownWork, resolution, { dir: true })) throw new Error('lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore)');
201
+ if (!insidePath(resolution, target, { includeSelf: true }) || !insidePath(ownWork, resolution, { includeSelf: true }))
202
+ throw new Error('selected example validation and resolution must share its own Work root');
203
+ referenceFile(base, slash(path.relative(base, resolution)), { directory: true, exact: true });
204
+ return Object.freeze({ root: path.join(base, EXAMPLE_RUNTIMES_ROOT, app, 'artifacts') });
205
+ }